58 lines · 7.5 KB

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 same first / all / run / batch surface 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.ts and generated into src/server/scoped-db/schema.gen.ts from the historical D1 migrations. New scoped DDL is appended to schema-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_locators in 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: UserInbox holds involvement, and ADVISORY_DB holds published advisories, and each creator's AccountState indexes 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_deliveries or notification_digest_items arms 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 (at next_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 until backfillDone.
  • Access control stays in D1 on purpose: collaborators and org_members are what "which repositories can this user see" is computed from, in SQL, for listings.
  • Repo and account deletion call destroy() on the owning objects and forgetScope() 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.