Repository
The examples use Authentication from
Getting Started: the application’s request middleware.
Git.ReposDurableObject hosts one Durable Object per repository and
provides Git.RepoStore, the service every route reaches it through:
const GitLive = Git.ApiLive.pipe( Layer.provide(Git.ApiHandlersLive), Layer.provide(Authentication.layer), Layer.provide(Git.ReposDurableObject), // ReposDurableObject requires: Layer.provide(Git.HasherInline), // push verification Layer.provide(Git.BlobStoreR2(GitObjects)), // packs, bundles, spilled pushes Layer.provide(Git.RegistryDurableObject), // owner/name → repoId);The object is keyed by the repository’s id, a ULID the Registry hands out, so a rename never moves data. Its SQLite holds everything that has to be consistent: refs, the object index, hot object bytes, the commit graph, and pull requests. A push is a compare-and-swap on a ref inside one transaction.
The service
Section titled “The service”const repos = yield* Git.RepoStore;const repo = repos.getByName(repoId); // a typed RPC stub over the Durable ObjectThe stub is Git.GitRepoShape. It asks no questions about who is
calling; that was decided in front of the route:
interface GitRepoShape { // lifecycle initRepo(input): Effect<InitRepoResult, StoreError>; startImport(input): Effect<InitRepoResult, StoreError>; startFork(input): Effect<InitRepoResult, StoreError>; startCompact(): Effect<void, RepoNotFound | StoreError>; startPurge(): Effect<void, RepoNotFound | StoreError>;
// metadata getRepoMeta(): Effect<RepoMetaData, RepoNotFound | StoreError>; readMeta(): Effect<RepoMetaData, RepoNotFound | StoreError>; updateRepoMeta(input): Effect<RepoMetaData, RepoNotFound | RefNotFound | StoreError>;
// refs listRefs(input): Effect<RefsPage, RepoNotFound | StoreError>; getRef(name): Effect<RefData, RepoNotFound | RefNotFound | StoreError>; updateRef(input): Effect<RefData, RefConflict | ObjectNotFound | ReadOnlyRepo | …>; removeRef(input): Effect<void, RefNotFound | RefConflict | ReadOnlyRepo | …>;
// objects readObject(input): Effect<ObjectData, ObjectNotFound | WrongObjectType | …>; readCommitLog(input): Effect<CommitLogPage, RefNotFound | …>; readCommitDiff(input): Effect<CommitDiffData, ObjectNotFound | …>;Every error is a tagged class the stub reconstructs across the RPC
boundary, so Effect.catchTag("RefConflict", …) works in the Worker.
Call it from a route of your own
Section titled “Call it from a route of your own”Resolve the id through the registry, then talk to the object. A route that answers the default branch’s tip:
import * as HttpApi from "effect/unstable/httpapi/HttpApi";import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint";import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup";
export const Tip = HttpApiEndpoint.get("tip", "/api/v1/repos/:owner/:repo/tip", { params: Git.RepoPath, success: Git.Ref, error: [Git.RepoNotFound, Git.RefNotFound],});
export class AppRoutes extends HttpApiGroup.make("app").add(Tip) {}export class AppApi extends HttpApi.make("app").add(AppRoutes) {}
export const AppRoutesLive = HttpApiBuilder.group(AppApi, "app", (h) => Effect.gen(function* () { const registry = yield* Git.RegistryStore; const repos = yield* Git.RepoStore; return h.handle("tip", Effect.fn(function* ({ params }) { const entry = yield* registry .resolve(params.owner, params.repo) .pipe(Effect.catchTag("StoreError", (error) => Effect.die(error))); if (entry === undefined) return yield* new Git.RepoNotFound(params); const repo = repos.getByName(entry.repoId); const meta = yield* repo .getRepoMeta() .pipe(Effect.catchTag("StoreError", (error) => Effect.die(error))); const ref = yield* repo .getRef(`refs/heads/${meta.defaultBranch}`) .pipe(Effect.catchTag("StoreError", (error) => Effect.die(error))); return new Git.Ref({ name: ref.name, oid: ref.oid as Git.Oid }); })); }),);Provide AppRoutesLive to HttpApiBuilder.layer(AppApi), then merge that route layer beside Git.ApiLive.
Git.ApiLive registers the Git groups.
HTTP routes shows the full assembly. The registry
and repository clients are resolved when the group builds; the handler
runs once per request behind the route middleware.
Compose application operations
Section titled “Compose application operations”Use Git.Engine to work with repository data from your own handler:
const git = yield* Git.Engine;const repo = yield* git.repositories.get({ owner: "alice", repo: "demo" });const prepared = yield* git.prepareRefRemoval(repo, { ref: "refs/heads/feature" });yield* checkRefChanges(repo, prepared.updates);yield* prepared.commit;yield* Effect.log("branch removed");Run this inside Effect.scoped. checkRefChanges is your application function;
it can use your request user and database. The prepared operation pins the ref
value and can commit only once before the scope closes. Use the same policy from
push and merge handlers. Engine operations describes the APIs.
RepoStore remains the lower storage contract for implementing another backend.
Its raw RPC methods are infrastructure primitives, not an authorization boundary.
Implement your own
Section titled “Implement your own”The contract is Git.GitRepoShape behind Git.RepoStore. A different
store provides the same service, and nothing else in the graph changes:
export const ReposMine = Layer.effect( Git.RepoStore, Effect.gen(function* () { // return { getByName: (repoId) => GitRepoShape } }),);The registry and the blob store are the seams it can reuse. The wire protocol (fetch) and the push pipeline are the parts
that assume the object’s transactional storage, so a store without a
per-repository serialization point starts there.
Where the bytes are
Section titled “Where the bytes are”| Objects | Live in | Because |
|---|---|---|
| commits, trees, tags | SQLite rows, deflated | the tree walk behind every fetch runs on SQLite |
| fresh blobs | SQLite rows, deflated | a push commits as one transaction |
| oversize blobs | the blob store, one index row here | never buffered in the object |
| compacted blobs | immutable packs in the blob store | a busy repository’s object stays small |
| clone bundles | the blob store, keyed by a refs hash | the Worker streams them, no pack byte transits the object |
A full clone whose refs have not moved is one object in the blob store; the Worker pipes it. Otherwise the clone is assembled from rows and packs, and a fresh bundle is scheduled. Objects are stored deflated, so a pack is concatenation plus a checksum.
Maintenance runs on the object’s alarms, armed by the work itself. Every job is idempotent and resumable:
| Job | Armed by | Does |
|---|---|---|
| compaction | a push crossing a size threshold, or startCompact |
rewrites loose blobs into a pack |
| bundling | refs moving | cuts a fresh clone bundle |
| purge | startPurge |
drains rows and blobs, then frees the name |
| fork, import | startFork, startImport |
copies from a sibling object or fetches from a remote |
curl -u "x:$GIT_SECRET" -X POST "$HOST/api/v1/repos/acme/web/compact"What it reports
Section titled “What it reports”const repo = yield* client.repos.get({ params: { owner: "acme", repo: "web" } });
repo.objects; // { loose, resident, packed, r2, bytes }repo.lastPush; // { ingestMs, stageMs, connectivityMs, finalizeMs, totalMs }Listings report zeros for both, so listing a thousand repositories does not wake a thousand objects. Scaling has the throughput numbers behind the layout above.