1/**
2 * Shared schemas of the git-service REST contract (DESIGN.md §5):
3 * primitives, the tagged-error taxonomy, and the domain shapes. The
4 * per-group endpoint declarations live in the sibling modules (`Repos.ts`,
5 * `Refs.ts`, `Objects.ts`, `Pulls.ts`); the assembled `GitApi` lives in
6 * `../Api.ts`.
7 *
8 * The groups carry no middleware and no auth errors of their own: the
9 * `HttpApi` you mount them in decides who may call them, and application handlers
10 * decide which refs may move.
11 */
12import * as Schema from "effect/Schema";
13// Pulls in the `httpApiStatus` annotation augmentation used by the error
14// classes below.
15import "effect/http-api/HttpApiSchema";
16
17// ─────────────────────────────────────────────────────────────────────────────
18// Primitives
19// ─────────────────────────────────────────────────────────────────────────────
20
21/**
22 * A 40-hex SHA-1 object id, branded so oids never mix with plain strings.
23 * Widening to SHA-256 later is mechanical because every consumer goes
24 * through this schema (DESIGN.md §1 "Hashing").
25 */
26export const Oid = Schema.String.check(Schema.isPattern(/^[0-9a-f]{40}$/)).pipe(
27 Schema.brand("Oid"),
28);
29
30/** The decoded (branded) type of {@link Oid}. */
31export type Oid = typeof Oid.Type;
32
33/**
34 * A repository name: 1–100 chars, alphanumeric with `._-` separators,
35 * must start and end alphanumeric. Case-insensitive match, stored as given.
36 */
37export const RepoName = Schema.String.check(
38 Schema.isPattern(/^[a-z0-9](?:[a-z0-9._-]{0,98}[a-z0-9])?$/i),
39);
40
41/**
42 * An owner (namespace) name. Same grammar as {@link RepoName}. The literal
43 * owner `api` is reserved (rejected by the Registry at creation) so the
44 * `/api/v1` prefix can never collide with a repo URL.
45 */
46export const OwnerName = RepoName;
47
48/**
49 * A full ref name (`refs/heads/main`, `refs/tags/v1.0`). Rejects the
50 * whitespace / `~^:?*[\` characters git itself forbids. HEAD is virtual
51 * (symref to the default branch) and is never a valid `RefName` here.
52 */
53export const RefName = Schema.String.check(
54 Schema.isPattern(/^refs\/[^\s~^:?*\[\\]+$/),
55);
56
57/**
58 * Lifecycle status of a repo. Async operations (import/fork/delete) flip
59 * this field; clients poll `GET /repos/:owner/:repo` until `ready` (or 404
60 * once a `deleting` repo's purge completes). There is no jobs API.
61 */
62export const RepoStatus = Schema.Literals([
63 "ready",
64 "importing",
65 "forking",
66 "deleting",
67]);
68
69/** The decoded type of {@link RepoStatus}. */
70export type RepoStatus = typeof RepoStatus.Type;
71
72// ─────────────────────────────────────────────────────────────────────────────
73// Error taxonomy (tagged, status-annotated)
74// ─────────────────────────────────────────────────────────────────────────────
75
76/** 403 — the repo is flagged `readOnly`; writes are rejected. */
77export class ReadOnlyRepo extends Schema.TaggedError<ReadOnlyRepo>()(
78 "ReadOnlyRepo",
79 {},
80 { httpApiStatus: 403 },
81) {}
82
83/** 403 — application policy refused a proposed ref update. */
84export class PushDenied extends Schema.TaggedError<PushDenied>()(
85 "PushDenied",
86 { ref: Schema.String, reason: Schema.String },
87 { httpApiStatus: 403 },
88) {}
89
90/** 404 — no repo at `owner/repo` (or it finished deleting). */
91export class RepoNotFound extends Schema.TaggedError<RepoNotFound>()(
92 "RepoNotFound",
93 { owner: Schema.String, repo: Schema.String },
94 { httpApiStatus: 404 },
95) {}
96
97/** 409 — a repo already exists at `owner/repo`. */
98export class RepoAlreadyExists extends Schema.TaggedError<RepoAlreadyExists>()(
99 "RepoAlreadyExists",
100 { owner: Schema.String, repo: Schema.String },
101 { httpApiStatus: 409 },
102) {}
103
104/**
105 * 409 — the repo exists but is not `ready` (importing / forking /
106 * deleting); retry after polling `GET /repos/:owner/:repo`.
107 */
108export class RepoNotReady extends Schema.TaggedError<RepoNotReady>()(
109 "RepoNotReady",
110 { status: RepoStatus },
111 { httpApiStatus: 409 },
112) {}
113
114/** 404 — the named ref does not exist. */
115export class RefNotFound extends Schema.TaggedError<RefNotFound>()(
116 "RefNotFound",
117 { ref: Schema.String },
118 { httpApiStatus: 404 },
119) {}
120
121/**
122 * 409 — compare-and-swap failure on a ref write: `expectedOid` did not
123 * match the ref's current value (`currentOid`, `null` when the ref does
124 * not exist).
125 */
126export class RefConflict extends Schema.TaggedError<RefConflict>()(
127 "RefConflict",
128 { ref: Schema.String, currentOid: Schema.NullOr(Oid) },
129 { httpApiStatus: 409 },
130) {}
131
132/** 404 — no object with the given oid in this repo. */
133export class ObjectNotFound extends Schema.TaggedError<ObjectNotFound>()(
134 "ObjectNotFound",
135 { oid: Schema.String },
136 { httpApiStatus: 404 },
137) {}
138
139/** 422 — the object exists but has a different type than the endpoint expects. */
140export class WrongObjectType extends Schema.TaggedError<WrongObjectType>()(
141 "WrongObjectType",
142 { oid: Schema.String, expected: Schema.String, actual: Schema.String },
143 { httpApiStatus: 422 },
144) {}
145
146/**
147 * 422 — the blob is too large for the base64 JSON endpoint (> 1 MiB);
148 * use the raw streaming route `GET .../blobs/:oid/raw` instead.
149 */
150export class ObjectTooLarge extends Schema.TaggedError<ObjectTooLarge>()(
151 "ObjectTooLarge",
152 { oid: Schema.String, size: Schema.Number },
153 { httpApiStatus: 422 },
154) {}
155
156/**
157 * 422 — the two commits share no common ancestor within the walk bound
158 * (disjoint histories, or divergence beyond the server's 10k-commit cap;
159 * shallow imports can also surface this across the shallow boundary).
160 */
161export class NoMergeBase extends Schema.TaggedError<NoMergeBase>()(
162 "NoMergeBase",
163 { base: Oid, head: Oid },
164 { httpApiStatus: 422 },
165) {}
166
167/** 502 — the async import job failed against the source remote. */
168export class ImportFailed extends Schema.TaggedError<ImportFailed>()(
169 "ImportFailed",
170 { reason: Schema.String },
171 { httpApiStatus: 502 },
172) {}
173
174/** 400 — semantically invalid request (e.g. reserved owner name). */
175export class ValidationError extends Schema.TaggedError<ValidationError>()(
176 "ValidationError",
177 { message: Schema.String },
178 { httpApiStatus: 400 },
179) {}
180
181/** 404 — no pull request with this number. */
182export class PullNotFound extends Schema.TaggedError<PullNotFound>()(
183 "PullNotFound",
184 { number: Schema.Number },
185 { httpApiStatus: 404 },
186) {}
187
188/** 422 — the PR's base or head branch does not exist (deleted after open). */
189export class BranchMissing extends Schema.TaggedError<BranchMissing>()(
190 "BranchMissing",
191 { ref: Schema.String },
192 { httpApiStatus: 422 },
193) {}
194
195/** 409 — an open PR for this base/head pair already exists. */
196export class PullExists extends Schema.TaggedError<PullExists>()(
197 "PullExists",
198 { number: Schema.Number },
199 { httpApiStatus: 409 },
200) {}
201
202/**
203 * 409 — the operation is invalid in the PR's current state (e.g. merge a
204 * merged PR, reopen a merged PR).
205 */
206export class PullStateConflict extends Schema.TaggedError<PullStateConflict>()(
207 "PullStateConflict",
208 {
209 number: Schema.Number,
210 state: Schema.Literals(["open", "closed", "merged"]),
211 },
212 { httpApiStatus: 409 },
213) {}
214
215/** 422 — head is already reachable from base; there is nothing to merge. */
216export class NothingToMerge extends Schema.TaggedError<NothingToMerge>()(
217 "NothingToMerge",
218 { number: Schema.Number },
219 { httpApiStatus: 422 },
220) {}
221
222/**
223 * 409 — the three-way merge is not trivial: these paths changed on both
224 * sides relative to the merge base.
225 */
226export class MergeConflict extends Schema.TaggedError<MergeConflict>()(
227 "MergeConflict",
228 {
229 number: Schema.Number,
230 /** Conflicting paths, capped at 20 (the full set may be larger). */
231 paths: Schema.Array(Schema.String),
232 },
233 { httpApiStatus: 409 },
234) {}
235
236// ─────────────────────────────────────────────────────────────────────────────
237// Auth middleware
238// ─────────────────────────────────────────────────────────────────────────────
239
240// ─────────────────────────────────────────────────────────────────────────────
241// Domain shapes
242// ─────────────────────────────────────────────────────────────────────────────
243
244/**
245 * A repository resource as returned by the management plane.
246 */
247/**
248 * Where a repo's objects currently live (DESIGN.md §12.1). `loose` rows
249 * hold their bytes in DO SQLite; `packed` objects have been compacted into
250 * immutable R2 packs; `r2` objects are oversize singletons in R2.
251 */
252export class ObjectStats extends Schema.Class<ObjectStats>("ObjectStats")({
253 /** Objects whose bytes are still in DO SQLite rows. */
254 loose: Schema.Number,
255 /** Commits, trees and tags kept in SQLite rows (never packed). */
256 resident: Schema.Number,
257 /** Objects compacted into R2 packs. */
258 packed: Schema.Number,
259 /** Oversize objects stored as standalone R2 keys. */
260 r2: Schema.Number,
261 /** Total compressed bytes across all locations. */
262 bytes: Schema.Number,
263}) {}
264
265/**
266 * Server-side timing of the most recent push (DESIGN.md §19 phase 0).
267 *
268 * Wall-clock `git push` time includes client-side packing and the upload,
269 * so it bounds server cost rather than measuring it. These are measured
270 * inside the Durable Object, around the work itself.
271 */
272export class PushStats extends Schema.Class<PushStats>("PushStats")({
273 /** Objects in the pushed pack. */
274 objects: Schema.Number,
275 /** Pack bytes received. */
276 bytes: Schema.Number,
277 /** Parsing + staging the pack (inflate, sha1, SQL). */
278 ingestMs: Schema.Number,
279 /**
280 * The SQL staging portion of `ingestMs`. `ingestMs - stageMs` is CPU:
281 * inflate, sha1, delta resolution, commit/tree parsing. The split decides
282 * whether sharding storage or hoisting parsing is the real win
283 * (DESIGN.md §19).
284 */
285 stageMs: Schema.Number,
286 /** The connectivity check. */
287 connectivityMs: Schema.Number,
288 /** The ref CAS transaction. */
289 finalizeMs: Schema.Number,
290 /** Everything the DO did for this push. */
291 totalMs: Schema.Number,
292 /**
293 * Ingest split by phase (ms): inflate, hash, delta, deflate, copy, sink.
294 * Meaningful as CPU only where the clock runs during synchronous work
295 * (local workerd); production workerd freezes `performance.now()` between
296 * awaits, so there only phases that yield to the event loop accumulate —
297 * which is itself diagnostic (an async inflate path shows up, a sync one
298 * reads 0).
299 */
300 phases: Schema.optional(Schema.Record(Schema.String, Schema.Number)),
301}) {}
302
303export class Repo extends Schema.Class<Repo>("Repo")({
304 /** Owner (namespace) segment of the repo's URL. */
305 owner: OwnerName,
306 /** Repo name segment of the repo's URL. */
307 name: RepoName,
308 /**
309 * The stable ULID identity of the repo. Renames never move data because
310 * the Repo DO id derives from `repoId`, not `owner/name`.
311 */
312 repoId: Schema.String,
313 /** Short branch name (e.g. `main`) that HEAD symrefs to. */
314 defaultBranch: Schema.String,
315 /** Optional human description. */
316 description: Schema.NullOr(Schema.String),
317 /** When `true`, receive-pack and REST ref writes are rejected. */
318 readOnly: Schema.Boolean,
319 /**
320 * Public repos are readable (REST reads and `git clone`/`fetch`)
321 * without any token; writes always require one.
322 */
323 public: Schema.Boolean,
324 /** Parent `repoId` when this repo is a fork, else `null`. */
325 forkOf: Schema.NullOr(Schema.String),
326 /** Lifecycle status; poll this for async fork/import/delete progress. */
327 status: RepoStatus,
328 /** Creation time, epoch milliseconds. */
329 createdAt: Schema.Number,
330 /** Object storage breakdown (DESIGN.md §12.1). */
331 objects: ObjectStats,
332 /** Server-side timing of the last push, if any (DESIGN.md §19). */
333 lastPush: Schema.NullOr(PushStats),
334}) {}
335
336/**
337 * A ref (branch or tag). `peeled` carries the target commit for annotated
338 * tags (the advertisement's `^{}` line).
339 */
340export class Ref extends Schema.Class<Ref>("Ref")({
341 /** Full ref name, e.g. `refs/heads/main`. */
342 name: RefName,
343 /** The object the ref points at. */
344 oid: Oid,
345 /** For annotated tags: the peeled (commit) oid. */
346 peeled: Schema.optional(Oid),
347}) {}
348
349/** An author/committer/tagger signature line. */
350const Signature = Schema.Struct({
351 name: Schema.String,
352 email: Schema.String,
353 /** Unix timestamp (seconds). */
354 date: Schema.Number,
355 /** Timezone offset as written, e.g. `+0200`. */
356 tz: Schema.String,
357});
358
359/** Parsed commit metadata for the REST read surface. */
360export class CommitInfo extends Schema.Class<CommitInfo>("CommitInfo")({
361 oid: Oid,
362 /** Root tree of the commit. */
363 tree: Oid,
364 /** Parent commit oids in order. */
365 parents: Schema.Array(Oid),
366 author: Signature,
367 committer: Signature,
368 /** Full commit message (verbatim, including trailing newline). */
369 message: Schema.String,
370}) {}
371
372/** A single tree entry. `commit` type = gitlink (submodule). */
373export class TreeEntry extends Schema.Class<TreeEntry>("TreeEntry")({
374 /** Octal mode string as stored, e.g. `100644`, `40000`, `120000`. */
375 mode: Schema.String,
376 /** Entry name (single path segment). */
377 name: Schema.String,
378 oid: Oid,
379 /** Entry kind derived from mode. */
380 type: Schema.Literals(["blob", "tree", "commit"]),
381}) {}
382
383/**
384 * File-level change status. v1 has no rename detection (see the DESIGN note
385 * on the `diff` endpoint in `Objects.ts`); a v1.1 exact-oid pass would add
386 * `"renamed"` + an `oldPath`.
387 */
388export const FileStatus = Schema.Literals(["added", "removed", "modified"]);
389
390/** The decoded type of {@link FileStatus}. */
391export type FileStatus = typeof FileStatus.Type;
392
393/**
394 * One changed file in a commit diff or comparison. Content is NOT included —
395 * clients fetch old/new blobs by oid (`blobs/:oid`, `blobs/:oid/raw`) and
396 * diff locally. `oldSize`/`newSize` let the client gate binary/oversize
397 * files without a round trip. Gitlinks (mode 160000) appear as entries whose
398 * oids are commit oids; clients render "Subproject commit ..." and must not
399 * fetch them as blobs.
400 */
401export class DiffEntry extends Schema.Class<DiffEntry>("DiffEntry")({
402 /** Full slash-separated path from the repo root. */
403 path: Schema.String,
404 status: FileStatus,
405 /** Blob oid on the old side (absent for `added`). */
406 oldOid: Schema.optional(Oid),
407 /** Blob oid on the new side (absent for `removed`). */
408 newOid: Schema.optional(Oid),
409 /** Octal mode on the old side, e.g. `100644` (absent for `added`). */
410 oldMode: Schema.optional(Schema.String),
411 /** Octal mode on the new side (absent for `removed`). */
412 newMode: Schema.optional(Schema.String),
413 /** Uncompressed byte size of the old blob (absent for `added`/gitlinks). */
414 oldSize: Schema.optional(Schema.Number),
415 /** Uncompressed byte size of the new blob (absent for `removed`/gitlinks). */
416 newSize: Schema.optional(Schema.Number),
417}) {}
418
419/** The changed-file list of one commit vs its first parent. */
420export class CommitDiff extends Schema.Class<CommitDiff>("CommitDiff")({
421 oid: Oid,
422 /** The first parent the diff was computed against; `null` for a root
423 * commit (diffed against the empty tree — every file is `added`). */
424 parent: Schema.NullOr(Oid),
425 files: Schema.Array(DiffEntry),
426 /** `true` when the file list was cut at the server cap (1000 files). */
427 truncated: Schema.Boolean,
428}) {}
429
430/** GitHub-style three-dot comparison of two commits. */
431export class Comparison extends Schema.Class<Comparison>("Comparison")({
432 /** Resolved base commit oid (tags peeled). */
433 base: Oid,
434 /** Resolved head commit oid (tags peeled). */
435 head: Oid,
436 /** Best common ancestor (first both-reachable commit by generation). */
437 mergeBase: Oid,
438 /** Commits reachable from head but not base (`git rev-list --count base..head`). */
439 aheadBy: Schema.Number,
440 /** Commits reachable from base but not head. */
441 behindBy: Schema.Number,
442 /** head-side commits, committer-time descending, capped at 250. */
443 commits: Schema.Array(CommitInfo),
444 commitsTruncated: Schema.Boolean,
445 /** File diff of mergeBase..head (three-dot, like GitHub compare/PR). */
446 files: Schema.Array(DiffEntry),
447 filesTruncated: Schema.Boolean,
448}) {}
449
450/** Lifecycle state of a pull request. `merged` is terminal. */
451export const PullState = Schema.Literals(["open", "closed", "merged"]);
452
453/** The decoded type of {@link PullState}. */
454export type PullState = typeof PullState.Type;
455
456/**
457 * A pull request. PRs track **live** branches by ref name — the row stores
458 * intent + lifecycle, never a diff snapshot. Diff and mergeability are
459 * recomputed from current ref tips on every read (see {@link PullDetail}).
460 */
461export class Pull extends Schema.Class<Pull>("Pull")({
462 /** Per-repo monotonic PR number (1-based, never reused). */
463 number: Schema.Int,
464 title: Schema.String,
465 body: Schema.NullOr(Schema.String),
466 /** Full base ref name, e.g. `refs/heads/main`. */
467 baseRef: RefName,
468 /** Full head ref name, e.g. `refs/heads/feature`. */
469 headRef: RefName,
470 state: PullState,
471 /** Creation time, epoch milliseconds. */
472 createdAt: Schema.Number,
473 /** Last update (title/body/state) time, epoch milliseconds. */
474 updatedAt: Schema.Number,
475 /** Merge time (epoch milliseconds); `null` unless `state` is `merged`. */
476 mergedAt: Schema.NullOr(Schema.Number),
477 /** FF: the head tip; the merge commit otherwise. Set iff state=merged. */
478 mergeCommit: Schema.NullOr(Oid),
479}) {}
480
481/**
482 * PR detail = the row + live computed compare fields. Live fields are
483 * `null` when uncomputable: a missing base/head branch, a saturated
484 * ancestor walk, or a merged PR (its record is `mergeCommit`).
485 */
486export class PullDetail extends Pull.extend<PullDetail>("PullDetail")({
487 /** Current tip of `baseRef`; `null` if the branch is gone. */
488 baseOid: Schema.NullOr(Oid),
489 /** Current tip of `headRef`; `null` if the branch is gone. */
490 headOid: Schema.NullOr(Oid),
491 mergeBase: Schema.NullOr(Oid),
492 /** Commits on head not on base (`null` when the walk saturates). */
493 aheadBy: Schema.NullOr(Schema.Int),
494 /** Commits on base not on head. */
495 behindBy: Schema.NullOr(Schema.Int),
496 /**
497 * `true` = FF-able or trivially merge-able; `false` = conflicting paths
498 * or up-to-date; `null` = uncomputable.
499 */
500 mergeable: Schema.NullOr(Schema.Boolean),
501 /** Why: `"ff" | "merge-commit" | "conflict" | "up-to-date" | "unknown"`. */
502 mergeableReason: Schema.NullOr(Schema.String),
503}) {}
504
505/** Response of a successful PR merge. */
506export class MergeResult extends Schema.Class<MergeResult>("MergeResult")({
507 /** How the merge landed. */
508 method: Schema.Literals(["ff", "merge-commit"]),
509 /** The oid the base ref now points at. */
510 oid: Oid,
511 /** The PR row after the merge (`state: "merged"`). */
512 pull: Pull,
513}) {}
514
515/**
516 * Response of repo create/fork/import: the repo plus a ready-to-use HTTPS
517 * remote URL and a bootstrap `write` token, killing the create-then-mint
518 * round trip.
519 */
520export class RepoCreated extends Schema.Class<RepoCreated>("RepoCreated")({
521 repo: Repo,
522 /** HTTPS clone URL for the new repo. */
523 remote: Schema.String,
524}) {}
525
526/**
527 * Cursor-paginated wrapper used by list endpoints.
528 *
529 * @example
530 * ```typescript
531 * const page = Paginated(Repo); // { items: Repo[], nextCursor, hasMore }
532 * ```
533 */
534export const Paginated = <A extends Schema.Top>(items: A) =>
535 Schema.Struct({
536 items: Schema.Array(items),
537 nextCursor: Schema.NullOr(Schema.String),
538 hasMore: Schema.Boolean,
539 });
540// ─────────────────────────────────────────────────────────────────────────────
541// Shared path-parameter structs
542// ─────────────────────────────────────────────────────────────────────────────
543
544/** `(owner, repo)` path segments shared by repo-scoped endpoints. */
545export const RepoPath = Schema.Struct({ owner: OwnerName, repo: RepoName });
546
547/** `(owner, repo, oid)` path segments for object read endpoints. */
548export const RepoOidPath = Schema.Struct({
549 owner: OwnerName,
550 repo: RepoName,
551 oid: Oid,
552});