1/**
2 * The singleton Registry Durable Object (DESIGN.md §2.1, §3.1).
3 *
4 * The Registry is the control-plane authority for the `owner/name → repoId`
5 * mapping: uniqueness CAS at creation, the listing surface, and the
6 * fork-lineage ledger (`fork_of`, `fork_count`). It is addressed as a
7 * singleton via `getByName(REGISTRY_DO_NAME)`; the sharding seam
8 * (`getByName("registry:" + owner)`) changes no RPC interface.
9 *
10 * Repo ids are ULIDs minted here at creation — **not** `owner/name` — so
11 * renames never move data and a deleted-then-recreated name gets a fresh
12 * Repo DO (no zombie state).
13 *
14 * ### Resolving a repo
15 * **Example:** Worker-side lookup
16 * ```typescript
17 * const registry = yield* Registry;
18 * const entry = yield* registry.getByName(REGISTRY_DO_NAME).resolve("acme", "web");
19 * if (entry === undefined) return yield* new RepoNotFound({ owner: "acme", repo: "web" });
20 * ```
21 */
22import * as Cloudflare from "../Cloudflare/index.ts";
23import * as Context from "effect/Context";
24import * as Layer from "effect/Layer";
25import type { RuntimeContext } from "../RuntimeContext.ts";
26import * as Effect from "effect/Effect";
27import { RepoAlreadyExists, ValidationError } from "./Api.ts";
28import { StoreError } from "./Protocol/Store.ts";
29import {
30 initRegistrySchema,
31 makeSqlClient,
32 type RegistryRepoRow,
33} from "./Store/Sql.ts";
34
35// ─────────────────────────────────────────────────────────────────────────────
36// ULID
37// ─────────────────────────────────────────────────────────────────────────────
38
39/** Crockford base32 alphabet used by ULIDs. */
40const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
41
42/**
43 * Mints a ULID (48-bit millisecond timestamp + 80 random bits, Crockford
44 * base32, 26 chars). Used for repo ids, token ids, and push ids —
45 * lexicographically sortable by creation time and safe as an R2 key prefix.
46 */
47export const ulid = (): Effect.Effect<string> =>
48 Effect.sync(() => {
49 let time = Date.now();
50 const chars = new Array<string>(26);
51 // 10 chars of time, most-significant first
52 for (let i = 9; i >= 0; i--) {
53 chars[i] = CROCKFORD[time % 32]!;
54 time = Math.floor(time / 32);
55 }
56 const rand = crypto.getRandomValues(new Uint8Array(16));
57 // 16 chars of randomness, 5 bits each from a fresh random byte
58 for (let i = 0; i < 16; i++) {
59 chars[10 + i] = CROCKFORD[rand[i]! % 32]!;
60 }
61 return chars.join("");
62 });
63
64// ─────────────────────────────────────────────────────────────────────────────
65// Contract
66// ─────────────────────────────────────────────────────────────────────────────
67
68/**
69 * The singleton Registry instance name: `registry.getByName(REGISTRY_DO_NAME)`.
70 */
71export const REGISTRY_DO_NAME = "registry" as const;
72
73/**
74 * The reserved owner name — `/api/v1/**` must never collide with a repo URL,
75 * so the Registry rejects `api` at creation (DESIGN.md §2.2).
76 */
77export const RESERVED_OWNERS: ReadonlySet<string> = new Set(["api"]);
78
79/** One registry row, as returned over RPC (plain serializable data). */
80export interface RegistryEntry {
81 readonly owner: string;
82 readonly name: string;
83 readonly repoId: string;
84 readonly description: string | null;
85 /** Parent repoId when this repo is a fork, else `null`. */
86 readonly forkOf: string | null;
87 /** Live forks referencing this repo's R2 prefix. */
88 readonly forkCount: number;
89 /** Epoch milliseconds. */
90 readonly createdAt: number;
91 /** Anyone can read/clone without a token (GitHub public-repo model). */
92 readonly public: boolean;
93 /** Soft-delete marker set while the purge alarm runs, else `null`. */
94 readonly deletedAt: number | null;
95 /**
96 * Denormalised display fields, pushed by the Repo DO on change so
97 * `repos.list` renders a page without waking one DO per row
98 * (DESIGN.md §15 bottleneck 7). The DO stays the source of truth.
99 */
100 readonly defaultBranch: string;
101 readonly readOnly: boolean;
102 readonly status: string;
103}
104
105/** Summary fields the Repo DO pushes to the Registry when they change. */
106export interface RepoSummary {
107 readonly defaultBranch: string;
108 readonly readOnly: boolean;
109 /** Anyone can read/clone without a token (GitHub public-repo model). */
110 readonly public: boolean;
111 readonly status: string;
112}
113
114/** Input of {@link RegistryShape.createRepo}. */
115export interface CreateRepoInput {
116 readonly owner: string;
117 readonly name: string;
118 readonly description?: string | undefined;
119 /** Anyone can read/clone without a token when `true`. */
120 readonly public?: boolean | undefined;
121 /** Parent repoId when creating a fork target row. */
122 readonly forkOf?: string | undefined;
123}
124
125/** Input of {@link RegistryShape.list}. */
126export interface ListReposInput {
127 readonly owner?: string | undefined;
128 readonly cursor?: string | undefined;
129 readonly limit?: number | undefined;
130 /** Restrict to public repos — the anonymous/non-admin listing. */
131 readonly publicOnly?: boolean | undefined;
132}
133
134/** Result page of {@link RegistryShape.list}. */
135export interface ListReposResult {
136 readonly items: ReadonlyArray<RegistryEntry>;
137 readonly nextCursor: string | null;
138 readonly hasMore: boolean;
139}
140
141/**
142 * The Registry DO's typed RPC surface. Note the platform gotcha: no method
143 * may be named `delete` (reserved by Cloudflare's RPC stub proxy) — row
144 * removal is `removeRow`.
145 */
146export interface RegistryShape {
147 /**
148 * Uniqueness-CAS insert of a new repo row; mints the repoId ULID.
149 * Rejects reserved owners with {@link ValidationError} and existing
150 * `owner/name` rows (including rows still purging) with
151 * {@link RepoAlreadyExists}. When `forkOf` is given, the parent's
152 * `fork_count` is bumped in the same transaction.
153 */
154 readonly createRepo: (
155 input: CreateRepoInput,
156 ) => Effect.Effect<
157 RegistryEntry,
158 RepoAlreadyExists | ValidationError | StoreError,
159 RuntimeContext
160 >;
161 /** Looks up a live (non-deleted) row, or `undefined`. */
162 readonly resolve: (
163 owner: string,
164 name: string,
165 ) => Effect.Effect<RegistryEntry | undefined, StoreError, RuntimeContext>;
166 /** Cursor-paginated listing of live rows, optionally filtered by owner. */
167 readonly list: (
168 input: ListReposInput,
169 ) => Effect.Effect<ListReposResult, StoreError, RuntimeContext>;
170 /**
171 * Records a new fork of `parentRepoId` (`fork_count + 1`). Equivalent to
172 * `bumpForkCount(parentRepoId, 1)`; kept as its own verb for call-site
173 * clarity.
174 */
175 readonly recordFork: (
176 parentRepoId: string,
177 ) => Effect.Effect<number, StoreError, RuntimeContext>;
178 /**
179 * Adjusts a repo's `fork_count` by `delta` (may be 0 to read) and returns
180 * the new value. The delete-purge job polls this: the parent's R2 prefix
181 * is retained while the count is positive (DESIGN.md §2.3 Delete).
182 */
183 readonly bumpForkCount: (
184 repoId: string,
185 delta: number,
186 ) => Effect.Effect<number, StoreError, RuntimeContext>;
187 /**
188 * Soft-deletes a row (sets `deleted_at`); the name stays reserved until
189 * {@link RegistryShape.removeRow} after the purge completes.
190 */
191 readonly markDeleted: (
192 repoId: string,
193 ) => Effect.Effect<void, StoreError, RuntimeContext>;
194 /**
195 * Hard-removes a row after purge — the last step of repo deletion, which
196 * frees the `owner/name` for reuse. Also decrements the fork parent's
197 * `fork_count` when the removed repo was a fork.
198 */
199 readonly removeRow: (
200 repoId: string,
201 ) => Effect.Effect<void, StoreError, RuntimeContext>;
202 /**
203 * Refreshes the denormalised display fields for a repo. Called by the
204 * Repo DO whenever they change (create, metadata update, status
205 * transition), so listing never has to consult the DO.
206 */
207 readonly updateSummary: (
208 repoId: string,
209 summary: RepoSummary,
210 ) => Effect.Effect<void, StoreError, RuntimeContext>;
211}
212
213// ─────────────────────────────────────────────────────────────────────────────
214// Implementation
215// ─────────────────────────────────────────────────────────────────────────────
216
217export const toEntry = (row: RegistryRepoRow): RegistryEntry => ({
218 owner: row.owner,
219 name: row.name,
220 repoId: row.repo_id,
221 description: row.description,
222 forkOf: row.fork_of,
223 forkCount: row.fork_count,
224 createdAt: row.created_at,
225 deletedAt: row.deleted_at,
226 defaultBranch: row.default_branch,
227 readOnly: row.read_only === 1,
228 public: row.is_public === 1,
229 status: row.status,
230});
231
232/** Encodes/decodes the opaque list cursor: base64url of `owner\0name`. */
233export const encodeCursor = (owner: string, name: string): string =>
234 Buffer.from(`${owner}\0${name}`, "utf8").toString("base64url");
235export const decodeCursor = (
236 cursor: string,
237): readonly [owner: string, name: string] | undefined => {
238 try {
239 const text = Buffer.from(cursor, "base64url").toString("utf8");
240 const sep = text.indexOf("\0");
241 if (sep === -1) return undefined;
242 return [text.slice(0, sep), text.slice(sep + 1)];
243 } catch {
244 return undefined;
245 }
246};
247
248/** Default / maximum page size of {@link RegistryShape.list}. */
249export const DEFAULT_LIST_LIMIT = 50;
250
251/**
252 * The singleton Registry Durable Object class (modular form — the runtime
253 * implementation lives in {@link RegistryLive}, provided by `GitWorker`).
254 */
255export class Registry extends Cloudflare.DurableObject<
256 Registry,
257 RegistryShape
258>()("GitRegistry", {
259 // RPC-boundary error revival (see `DurableObjectProps.errors`).
260 errors: [RepoAlreadyExists, ValidationError, StoreError],
261}) {}
262
263/**
264 * `Git.RegistryStore` — the repo *index* as a swappable block: the
265 * `owner/name -> repoId` mapping, uniqueness, and listing.
266 *
267 * The rest of the service depends on this contract, never on a
268 * particular backend, so the index can be chosen at assembly:
269 *
270 * - {@link RegistryDurableObject} — one Durable Object. Zero extra
271 * infrastructure, and the strongest consistency (a single-threaded
272 * `transactionSync` makes create a true CAS). It is also one object
273 * for the whole service, so reads funnel through a single thread in a
274 * single region.
275 * - `Git.RegistryD1` — a D1 database. Reads scale out (D1 replicates
276 * them) and `WHERE owner = ?` listing is what a relational store is
277 * for; uniqueness comes from the `PRIMARY KEY (owner, name)` instead
278 * of from single-threading.
279 *
280 * Both satisfy {@link RegistryShape} exactly, so swapping is one line in
281 * the layer graph.
282 *
283 * @binding
284 */
285export class RegistryStore extends Context.Service<
286 RegistryStore,
287 RegistryShape
288>()("alchemy/Git/RegistryStore") {}
289
290/**
291 * The Registry DO implementation Layer. Provide on the hosting Worker's
292 * init effect (`Effect.provide(RegistryLive)`).
293 */
294export const RegistryLive = Registry.make(
295 Effect.gen(function* () {
296 const state = yield* Cloudflare.DurableObjectState;
297
298 return Effect.gen(function* () {
299 const sql = makeSqlClient(state);
300 // Constructor effects cannot fail — schema init failure is a defect.
301 yield* initRegistrySchema(sql).pipe(Effect.orDie);
302
303 const shape: RegistryShape = {
304 createRepo: Effect.fn(function* (input: CreateRepoInput) {
305 const owner = input.owner.toLowerCase();
306 const name = input.name.toLowerCase();
307 if (RESERVED_OWNERS.has(owner)) {
308 return yield* new ValidationError({
309 message: `owner name '${owner}' is reserved`,
310 });
311 }
312 const repoId = yield* ulid();
313 const createdAt = Date.now();
314 // The DO's single-threaded event loop + transactionSync make the
315 // exists-check + insert a real CAS.
316 return yield* sql.transactionSync<RegistryEntry, RepoAlreadyExists>(
317 (raw, rollback) => {
318 const existing = raw
319 .exec<RegistryRepoRow>(
320 `SELECT * FROM repos WHERE owner = ? AND name = ?`,
321 owner,
322 name,
323 )
324 .toArray();
325 if (existing.length > 0) {
326 // Includes soft-deleted rows: the name frees only after purge.
327 rollback(new RepoAlreadyExists({ owner, repo: name }));
328 }
329 raw.exec(
330 `INSERT INTO repos (owner, name, repo_id, description, is_public, fork_of, fork_count, created_at, deleted_at)
331 VALUES (?, ?, ?, ?, ?, ?, 0, ?, NULL)`,
332 owner,
333 name,
334 repoId,
335 input.description ?? null,
336 input.public === true ? 1 : 0,
337 input.forkOf ?? null,
338 createdAt,
339 );
340 if (input.forkOf !== undefined) {
341 raw.exec(
342 `UPDATE repos SET fork_count = fork_count + 1 WHERE repo_id = ?`,
343 input.forkOf,
344 );
345 }
346 return {
347 owner,
348 name,
349 repoId,
350 defaultBranch: "main",
351 readOnly: false,
352 public: input.public === true,
353 status: "ready",
354 description: input.description ?? null,
355 forkOf: input.forkOf ?? null,
356 forkCount: 0,
357 createdAt,
358 deletedAt: null,
359 } satisfies RegistryEntry;
360 },
361 );
362 }),
363
364 resolve: Effect.fn(function* (owner: string, name: string) {
365 // Soft-deleted rows ARE returned (with `deletedAt` set): the name
366 // stays reserved until the purge alarm calls `removeRow`, and the
367 // Worker needs to see the in-flight deletion to report
368 // `status: "deleting"` on GET (instead of a premature 404 that
369 // would race re-creates against the purge).
370 const row = yield* sql.first<RegistryRepoRow>(
371 `SELECT * FROM repos WHERE owner = ? AND name = ?`,
372 owner.toLowerCase(),
373 name.toLowerCase(),
374 );
375 return row === undefined ? undefined : toEntry(row);
376 }),
377
378 list: Effect.fn(function* (input: ListReposInput) {
379 const limit = Math.max(
380 1,
381 Math.min(input.limit ?? DEFAULT_LIST_LIMIT, 100),
382 );
383 const after =
384 input.cursor === undefined ? undefined : decodeCursor(input.cursor);
385 const conditions: Array<string> = ["deleted_at IS NULL"];
386 const bindings: Array<string | number> = [];
387 if (input.publicOnly === true) {
388 conditions.push("is_public = 1");
389 }
390 if (input.owner !== undefined) {
391 conditions.push("owner = ?");
392 bindings.push(input.owner.toLowerCase());
393 }
394 if (after !== undefined) {
395 conditions.push("(owner > ? OR (owner = ? AND name > ?))");
396 bindings.push(after[0], after[0], after[1]);
397 }
398 const rows = yield* sql.all<RegistryRepoRow>(
399 `SELECT * FROM repos WHERE ${conditions.join(" AND ")}
400 ORDER BY owner, name LIMIT ?`,
401 ...bindings,
402 limit + 1,
403 );
404 const hasMore = rows.length > limit;
405 const page = hasMore ? rows.slice(0, limit) : rows;
406 const last = page[page.length - 1];
407 return {
408 items: page.map(toEntry),
409 nextCursor:
410 hasMore && last !== undefined
411 ? encodeCursor(last.owner, last.name)
412 : null,
413 hasMore,
414 } satisfies ListReposResult;
415 }),
416
417 recordFork: (parentRepoId) => shape.bumpForkCount(parentRepoId, 1),
418
419 bumpForkCount: Effect.fn(function* (repoId: string, delta: number) {
420 if (delta !== 0) {
421 yield* sql.run(
422 `UPDATE repos SET fork_count = MAX(0, fork_count + ?) WHERE repo_id = ?`,
423 delta,
424 repoId,
425 );
426 const row = yield* sql.first<{ fork_count: number }>(
427 `SELECT fork_count FROM repos WHERE repo_id = ?`,
428 repoId,
429 );
430 return row?.fork_count ?? 0;
431 }
432 // The read path (delta 0) DERIVES the count from live fork rows
433 // rather than the stored column: the purge fork-pin must keep
434 // working after the parent's own registry row is removed (the
435 // name frees immediately on delete; only the R2 prefix stays
436 // pinned while forks reference it), and fork rows keep their
437 // `fork_of` pointer until their own purge completes.
438 const row = yield* sql.first<{ n: number }>(
439 `SELECT COUNT(*) AS n FROM repos WHERE fork_of = ?`,
440 repoId,
441 );
442 return row?.n ?? 0;
443 }),
444
445 markDeleted: Effect.fn(function* (repoId: string) {
446 yield* sql.run(
447 `UPDATE repos SET deleted_at = ? WHERE repo_id = ? AND deleted_at IS NULL`,
448 Date.now(),
449 repoId,
450 );
451 }),
452
453 updateSummary: Effect.fn(function* (
454 repoId: string,
455 summary: RepoSummary,
456 ) {
457 yield* sql.run(
458 `UPDATE repos SET default_branch = ?, read_only = ?, is_public = ?, status = ?
459 WHERE repo_id = ?`,
460 summary.defaultBranch,
461 summary.readOnly ? 1 : 0,
462 summary.public ? 1 : 0,
463 summary.status,
464 repoId,
465 );
466 }),
467
468 removeRow: Effect.fn(function* (repoId: string) {
469 const row = yield* sql.first<RegistryRepoRow>(
470 `SELECT * FROM repos WHERE repo_id = ?`,
471 repoId,
472 );
473 if (row === undefined) {
474 return; // idempotent — purge re-runs are expected
475 }
476 yield* sql.run(`DELETE FROM repos WHERE repo_id = ?`, repoId);
477 if (row.fork_of !== null) {
478 yield* sql.run(
479 `UPDATE repos SET fork_count = MAX(0, fork_count - 1) WHERE repo_id = ?`,
480 row.fork_of,
481 );
482 }
483 }),
484 };
485
486 return shape;
487 });
488 }),
489);
490
491/**
492 * {@link RegistryStore} backed by the singleton Registry Durable Object —
493 * the default. Registers the DO implementation and resolves the store to
494 * its stub, so callers never name the instance.
495 *
496 * @layer
497 * @provides Git.RegistryStore
498 */
499export const RegistryDurableObject: Layer.Layer<RegistryStore> = Layer.effect(
500 RegistryStore,
501 Effect.gen(function* () {
502 const namespace = yield* Registry;
503 // `getByName` must stay LAZY: the layer is built during deploy
504 // planning as well as at runtime, and the namespace only resolves to
505 // something callable inside a request. Each method therefore takes
506 // the stub at call time, exactly as the hand-rolled `registryStub()`
507 // did before this contract existed.
508 const stub = () => namespace.getByName(REGISTRY_DO_NAME);
509 return {
510 createRepo: (input) => stub().createRepo(input),
511 resolve: (owner, name) => stub().resolve(owner, name),
512 list: (input) => stub().list(input),
513 recordFork: (parentRepoId) => stub().recordFork(parentRepoId),
514 bumpForkCount: (repoId, delta) => stub().bumpForkCount(repoId, delta),
515 markDeleted: (repoId) => stub().markDeleted(repoId),
516 removeRow: (repoId) => stub().removeRow(repoId),
517 updateSummary: (repoId, summary) => stub().updateSummary(repoId, summary),
518 } satisfies RegistryShape;
519 }),
520).pipe(Layer.provide(RegistryLive)) as never;
521
522export default RegistryLive;