Where state lives: Durable Objects, with D1 as the directory
gitfor's runtime cost scales with the repositories and people that are active, not with one shared database. D1 is a directory: the lookups that turn a name, id or credential into the object that owns the data. Everything else lives in a Durable Object keyed by the repository or account it belongs to.
Layout
| Store | Holds | Keyed by |
|---|---|---|
D1 DB (directory) |
accounts, users, user_emails, repos (+ repos_fts), redirects, retired names, collaborators, org_members, stars, watches, follows, scope_locators, signing_key_ids, commit_signatures, lfs_blobs, operator tables (admin audit, abuse reports, instance settings, waitlist, Stripe events, cron locks, hourly limits, Artifacts stats, integrations) |
global |
D1 ADVISORY_DB |
published_advisory_packages: the cross-repo index dependency scans match against |
global |
RepoState |
Issues, pull requests, reviews, comments, labels, milestones, reactions, discussions, releases, attachments, CI runs/jobs/statuses/caches, webhooks, secrets, variables, environments, deploy tokens, branch protection, security alerts and advisories, LFS objects and locks, sites and deployments, activity and push events | repo id, or site:<id> for a site with no repository |
RepoSearch |
Code index of the default branch | repo id |
AccountState |
Sessions, passkeys, tokens, OAuth grants, notifications and their settings, org invitations, owner secrets and variables, projects, runners, signing keys, AI endpoints, billing, credits and usage | account id, scratch:<id>, or _anonymous for login challenges |
UserInbox |
Unread badge and the involvement index (issues and pull requests the user authored, is assigned to or reviews) | user id |
| Other objects | Counters, MeterBuffer, RepoInsights, Runner*, MergeQueue, WebhookLog, ProjectRoom, EditorRoom, McpHub, … |
per purpose |
How code reaches a store
repoDb(repoId)/accountDb(accountId)(src/server/scoped-db) give the samefirst / all / run / batchsurface as the D1 helper, bound to one object. Statements for different stores can't share a batch.- The tables each object creates are listed in
scripts/gen-scoped-schema.tsand generated intosrc/server/scoped-db/schema.gen.tsfrom the historical D1 migrations. New scoped DDL is appended toschema-extra.ts; every object applies it on its next request. - No joins across stores. Query the scoped table, collect ids, look names up in the directory
(
accountsById,repos), and merge in code. - Finding the owner of a row when only its id or credential hash is known:
scope_locatorsin D1 (src/server/scoped-db/locators.ts). New session and token formats embed their account (or repo) id so the hot paths skip the directory entirely; older formats resolve through the locators. - Cross-repo lists come from indexes fed by events, not from joins:
UserInboxholds involvement, andADVISORY_DBholds published advisories, and each creator'sAccountStateindexes the deploy tokens they made (deploy_token_index). No request path visits every repository or account: add an index instead. - Expiry and digests run in the object itself. Inserting into
sessions,auth_tokens,oauth_codes,oauth_refresh_tokens,notification_deliveriesornotification_digest_itemsarms the AccountState alarm for the next expiry; the alarm deletes what is due, flushes due digests and re-arms (src/server/account-state/housekeeping.ts). Idle accounts cost nothing; no cron walks the accounts. RepoState does the same for scheduled workflows (atnext_run_at), CI cache eviction, job token expiry, webhook delivery retention, orphaned attachments, hidden-ref cleanup retries and jobs on runners that stopped heartbeating (src/server/repo-state/housekeeping.ts); a failing task backs off for five minutes. - Stale notifications about repositories the recipient can no longer read are filtered on every read and
pruned then (
filterReadable), instead of fanning out over every account when a repository goes private or is deleted. - Backfills for new indexes walk repositories or accounts a page per cron run (
src/server/backfill.ts); readers keep their old path untilbackfillDone. - Access control stays in D1 on purpose:
collaboratorsandorg_membersare what "which repositories can this user see" is computed from, in SQL, for listings. - Repo and account deletion call
destroy()on the owning objects andforgetScope()on the locators; D1's cascading deletes no longer reach the moved data. - Global search covers repositories and accounts only. Issues, pull requests and code are searched inside a repository, or (for code) across one owner's repositories by fanning out to their objects.
Tests
Tests that touch scoped tables mock ~/server/scoped-db with memoryScopes().module from
src/server/scoped-db/testing.ts, which backs every scope with its own in-memory bun:sqlite using the real
schema.