1/** Repository operations over storage services. HTTP handlers adapt these results. */
2import type * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint";
3
4import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";
6import { Base64 } from "effect/encoding";
7import * as Layer from "effect/Layer";
8import type {
9 CompactRepo,
10 Compare,
11 CreatePull,
12 CreateRepo,
13 DeleteRepo,
14 ForkRepo,
15 GetBlob,
16 GetCommit,
17 GetDiff,
18 GetLog,
19 GetPull,
20 GetRef,
21 GetRepo,
22 GetTree,
23 ImportRepo,
24 ListPulls,
25 ListRefs,
26 ListRepos,
27 MergePull,
28 RemoveRef,
29 UpdatePull,
30 UpdateRef,
31 UpdateRepo,
32} from "./Api.ts";
33import {
34 CommitDiff,
35 CommitInfo,
36 Comparison,
37 DiffEntry,
38 ImportFailed,
39 MergeResult,
40 ObjectStats,
41 ObjectTooLarge,
42 Pull,
43 PullDetail,
44 PushStats,
45 Ref,
46 Repo,
47 RepoAlreadyExists,
48 RepoCreated,
49 RepoNotFound,
50 RepoNotReady,
51 TreeEntry,
52 type Oid,
53} from "./Api.ts";
54
55import {
56 parseCommit,
57 parseTree,
58 treeEntryKind,
59} from "./Protocol/ObjectCodec.ts";
60import type { StoreError } from "./Protocol/Store.ts";
61import { RegistryStore, type RegistryEntry } from "./RegistryObject.ts";
62import {
63 RepoStore,
64 type CommitData,
65 type DiffEntryData,
66 type PullData,
67 type PullDetailData,
68 type RefData,
69 type RepoMetaData,
70} from "./RepoObject.ts";
71
72/** TTL of the in-isolate `owner/name → repoId` cache (DESIGN.md §2.1). */
73export const RESOLVE_CACHE_TTL_MS = 60_000;
74
75/** Max entries of the in-isolate resolve cache (insertion-order eviction). */
76export const RESOLVE_CACHE_MAX = 1024;
77
78/** Blobs above this size are 422 on the JSON endpoint (use `/raw`). */
79export const MAX_JSON_BLOB_BYTES = 1024 * 1024;
80
81// ─────────────────────────────────────────────────────────────────────────────
82// Pure mapping helpers
83// ─────────────────────────────────────────────────────────────────────────────
84
85const asOid = (value: string): Oid => value as Oid;
86
87/** Maps the Repo DO's plain metadata onto the REST `Repo` schema class. */
88const toRepo = (meta: RepoMetaData): Repo =>
89 new Repo({
90 owner: meta.owner,
91 name: meta.name,
92 repoId: meta.repoId,
93 defaultBranch: meta.defaultBranch,
94 description: meta.description,
95 readOnly: meta.readOnly,
96 public: meta.public,
97 forkOf: meta.forkOf,
98 status: meta.status,
99 createdAt: meta.createdAt,
100 objects: new ObjectStats(meta.objects),
101 lastPush: meta.lastPush === null ? null : new PushStats(meta.lastPush),
102 });
103
104/** Maps a DO ref onto the REST `Ref` schema class. */
105const toRef = (ref: RefData): Ref =>
106 ref.peeled === undefined
107 ? new Ref({ name: ref.name, oid: asOid(ref.oid) })
108 : new Ref({
109 name: ref.name,
110 oid: asOid(ref.oid),
111 peeled: asOid(ref.peeled),
112 });
113
114/** Maps a DO diff entry onto the REST `DiffEntry` schema class. */
115const toDiffEntry = (entry: DiffEntryData): DiffEntry =>
116 new DiffEntry({
117 path: entry.path,
118 status: entry.status,
119 oldOid: entry.oldOid === undefined ? undefined : asOid(entry.oldOid),
120 newOid: entry.newOid === undefined ? undefined : asOid(entry.newOid),
121 oldMode: entry.oldMode,
122 newMode: entry.newMode,
123 oldSize: entry.oldSize,
124 newSize: entry.newSize,
125 });
126
127/** Maps a DO pull row onto the REST `Pull` schema class. */
128const toPull = (pull: PullData): Pull =>
129 new Pull({
130 number: pull.number,
131 title: pull.title,
132 body: pull.body,
133 baseRef: pull.baseRef,
134 headRef: pull.headRef,
135 state: pull.state,
136 createdAt: pull.createdAt,
137 updatedAt: pull.updatedAt,
138 mergedAt: pull.mergedAt,
139 mergeCommit: pull.mergeCommit === null ? null : asOid(pull.mergeCommit),
140 });
141
142/** Maps a DO pull detail onto the REST `PullDetail` schema class. */
143const toPullDetail = (pull: PullDetailData): PullDetail =>
144 new PullDetail({
145 number: pull.number,
146 title: pull.title,
147 body: pull.body,
148 baseRef: pull.baseRef,
149 headRef: pull.headRef,
150 state: pull.state,
151 createdAt: pull.createdAt,
152 updatedAt: pull.updatedAt,
153 mergedAt: pull.mergedAt,
154 mergeCommit: pull.mergeCommit === null ? null : asOid(pull.mergeCommit),
155 baseOid: pull.baseOid === null ? null : asOid(pull.baseOid),
156 headOid: pull.headOid === null ? null : asOid(pull.headOid),
157 mergeBase: pull.mergeBase === null ? null : asOid(pull.mergeBase),
158 aheadBy: pull.aheadBy,
159 behindBy: pull.behindBy,
160 mergeable: pull.mergeable,
161 mergeableReason: pull.mergeableReason,
162 });
163
164/** Maps a DO commit onto the REST `CommitInfo` schema class. */
165const toCommitInfo = (commit: CommitData): CommitInfo =>
166 new CommitInfo({
167 oid: asOid(commit.oid),
168 tree: asOid(commit.tree),
169 parents: commit.parents.map(asOid),
170 author: commit.author,
171 committer: commit.committer,
172 message: commit.message,
173 });
174
175/**
176 * Registry-derived fallback `Repo` for list pages when a Repo DO cannot be
177 * consulted (e.g. its config was never seeded because the create crashed
178 * between the Registry insert and `initRepo`).
179 */
180const registryFallbackRepo = (entry: RegistryEntry): Repo =>
181 new Repo({
182 owner: entry.owner,
183 name: entry.name,
184 repoId: entry.repoId,
185 defaultBranch: entry.defaultBranch,
186 description: entry.description,
187 readOnly: entry.readOnly,
188 public: entry.public,
189 forkOf: entry.forkOf,
190 status:
191 entry.deletedAt !== null ? "deleting" : (entry.status as Repo["status"]),
192 createdAt: entry.createdAt,
193 // No DO to ask (unseeded or mid-purge) — report an empty store.
194 objects: new ObjectStats({
195 loose: 0,
196 resident: 0,
197 packed: 0,
198 r2: 0,
199 bytes: 0,
200 }),
201 lastPush: null,
202 });
203
204export const makeOperations = Effect.gen(function* () {
205 const registry = yield* RegistryStore;
206 const repos = yield* RepoStore;
207 // The registry block, whichever backend the assembly provided.
208 const registryStub = () => registry;
209
210 // ── owner/name → RegistryEntry, 60 s in-isolate LRU (DESIGN.md §2.1) ──
211 // A stale hit fails safe: the Repo DO stores its own (owner, name) and
212 // 404s mismatched requests, and cache entries are dropped on delete.
213 interface CacheSlot {
214 readonly entry: RegistryEntry;
215 readonly expires: number;
216 }
217 const resolveCache = new Map<string, CacheSlot>();
218
219 const cacheKey = (owner: string, repo: string) =>
220 `${owner.toLowerCase()}/${repo.toLowerCase()}`;
221
222 const dropCached = (owner: string, repo: string) =>
223 Effect.sync(() => {
224 resolveCache.delete(cacheKey(owner, repo));
225 });
226
227 const resolveCached = (owner: string, repo: string) =>
228 Effect.gen(function* () {
229 const key = cacheKey(owner, repo);
230 const now = yield* Effect.sync(() => Date.now());
231 const hit = resolveCache.get(key);
232 if (hit !== undefined && hit.expires > now) return hit.entry;
233 const entry = yield* registryStub().resolve(owner, repo);
234 // Rows mid-purge are transient (removed when the purge alarm
235 // finishes) — never cache them, or a 60 s stale hit would keep
236 // reporting "deleting" after the name has freed.
237 if (entry !== undefined && entry.deletedAt === null) {
238 yield* Effect.sync(() => {
239 if (resolveCache.size >= RESOLVE_CACHE_MAX) {
240 const oldest = resolveCache.keys().next().value;
241 if (oldest !== undefined) resolveCache.delete(oldest);
242 }
243 resolveCache.set(key, {
244 entry,
245 expires: now + RESOLVE_CACHE_TTL_MS,
246 });
247 });
248 }
249 return entry;
250 });
251
252 /**
253 * Resolve including rows whose async purge is still draining
254 * (`deletedAt` set) — only `repos.get` (report `status: "deleting"`)
255 * and `repos.delete` (idempotent 204) want those.
256 */
257 const resolveIncludingDeleting = (owner: string, repo: string) =>
258 resolveCached(owner, repo).pipe(
259 Effect.catchTag("StoreError", (error: StoreError) => Effect.die(error)),
260 Effect.flatMap((entry) =>
261 entry === undefined
262 ? Effect.fail(new RepoNotFound({ owner, repo }))
263 : Effect.succeed(entry),
264 ),
265 );
266
267 /**
268 * Resolve or fail with a typed 404. Rows mid-purge count as gone for
269 * every data-plane route (the name is reserved but the repo is dead).
270 * Storage failures are defects.
271 */
272 const resolveOrNotFound = (owner: string, repo: string) =>
273 resolveIncludingDeleting(owner, repo).pipe(
274 Effect.filterOrFail(
275 (entry) => entry.deletedAt === null,
276 () => new RepoNotFound({ owner, repo }),
277 ),
278 );
279
280 const remoteUrl = (owner: string, name: string) =>
281 Effect.succeed(`/${owner}/${name}.git`);
282
283 // Accept decoded fields rather than endpoint metadata so these handlers also
284 // work with APIs that add middleware or prefixes.
285 // ── REST handler groups ────────────────────────────────────────────────
286
287 /**
288 * Inserts the registry row, repairing an ORPHAN first: a row whose Repo
289 * DO was never seeded (a create that died between the registry insert
290 * and `initRepo`). An orphan poisons the name permanently — `GET` 404s
291 * because the DO has no config, while `POST` 409s because the row
292 * exists — so detect it (registry row present + DO reports
293 * `RepoNotFound`), drop the row, and insert again.
294 */
295 const insertRepoRow = (input: {
296 readonly owner: string;
297 readonly name: string;
298 readonly description?: string | undefined;
299 readonly public?: boolean | undefined;
300 }) =>
301 registryStub()
302 .createRepo(input)
303 .pipe(
304 Effect.catchTag("StoreError", (error) => Effect.die(error)),
305 Effect.catchTag("RepoAlreadyExists", (conflict) =>
306 Effect.gen(function* () {
307 const existing = yield* resolveCached(input.owner, input.name).pipe(
308 Effect.catchTag("StoreError", (error) => Effect.die(error)),
309 );
310 if (existing === undefined) {
311 return yield* Effect.fail(conflict);
312 }
313 const orphaned = yield* repos
314 .getByName(existing.repoId)
315 .readMeta()
316 .pipe(
317 Effect.as(false),
318 Effect.catchTag("RepoNotFound", () => Effect.succeed(true)),
319 Effect.catchCause(() => Effect.succeed(false)),
320 );
321 if (!orphaned) {
322 return yield* Effect.fail(conflict);
323 }
324 yield* registryStub()
325 .removeRow(existing.repoId)
326 .pipe(
327 Effect.catchTag("StoreError", (error) => Effect.die(error)),
328 );
329 yield* dropCached(input.owner, input.name);
330 return yield* registryStub()
331 .createRepo(input)
332 .pipe(
333 Effect.catchTag("StoreError", (error) => Effect.die(error)),
334 );
335 }),
336 ),
337 );
338
339 const reposRoutes = {
340 create: ({
341 payload,
342 }: Pick<HttpApiEndpoint.Request<typeof CreateRepo>, "payload">) =>
343 Effect.gen(function* () {
344 const entry = yield* insertRepoRow({
345 owner: payload.owner,
346 name: payload.name,
347 description: payload.description,
348 public: payload.public,
349 });
350 const init = yield* repos
351 .getByName(entry.repoId)
352 .initRepo({
353 repoId: entry.repoId,
354 owner: entry.owner,
355 name: entry.name,
356 defaultBranch: payload.defaultBranch ?? "main",
357 description: payload.description ?? null,
358 readOnly: payload.readOnly ?? false,
359 public: payload.public ?? false,
360 forkOf: null,
361 })
362 .pipe(
363 // Seeding the DO failed (or died): drop the row we just
364 // inserted rather than leave an orphan behind.
365 Effect.onError(() =>
366 registryStub()
367 .removeRow(entry.repoId)
368 .pipe(
369 Effect.ignore,
370 Effect.andThen(dropCached(entry.owner, entry.name)),
371 ),
372 ),
373 Effect.catchTag("StoreError", (error) => Effect.die(error)),
374 );
375 const remote = yield* remoteUrl(entry.owner, entry.name);
376 return new RepoCreated({
377 repo: toRepo(init.meta),
378 remote,
379 });
380 }),
381 get: ({
382 params,
383 }: Pick<HttpApiEndpoint.Request<typeof GetRepo>, "params">) =>
384 Effect.gen(function* () {
385 // Includes rows mid-purge: GET keeps reporting
386 // status "deleting" until the purge alarm frees the name (only
387 // then a 404), so "poll GET until 404 then re-create" never
388 // races the purge.
389 const entry = yield* resolveIncludingDeleting(
390 params.owner,
391 params.repo,
392 );
393 if (entry.deletedAt !== null) {
394 return registryFallbackRepo(entry);
395 }
396 const meta = yield* repos
397 .getByName(entry.repoId)
398 .getRepoMeta()
399 .pipe(
400 Effect.catchTag("StoreError", (error) => Effect.die(error)),
401 Effect.catchTag("RepoNotFound", () =>
402 Effect.fail(
403 new RepoNotFound({
404 owner: params.owner,
405 repo: params.repo,
406 }),
407 ),
408 ),
409 );
410 return toRepo(meta);
411 }),
412 update: ({
413 params,
414 payload,
415 }: Pick<
416 HttpApiEndpoint.Request<typeof UpdateRepo>,
417 "params" | "payload"
418 >) =>
419 Effect.gen(function* () {
420 const entry = yield* resolveOrNotFound(params.owner, params.repo);
421 const meta = yield* repos
422 .getByName(entry.repoId)
423 .updateRepoMeta({
424 description: payload.description,
425 defaultBranch: payload.defaultBranch,
426 readOnly: payload.readOnly,
427 public: payload.public,
428 })
429 .pipe(
430 Effect.catchTag("StoreError", (error) => Effect.die(error)),
431 Effect.catchTag("RepoNotFound", () =>
432 Effect.fail(
433 new RepoNotFound({
434 owner: params.owner,
435 repo: params.repo,
436 }),
437 ),
438 ),
439 );
440 return toRepo(meta);
441 }),
442 list: ({
443 query,
444 }: Pick<HttpApiEndpoint.Request<typeof ListRepos>, "query">) =>
445 Effect.gen(function* () {
446 // Everything the Registry holds; `public: true` narrows it. Who
447 // may list at all was decided in front of the route.
448 const page = yield* registryStub()
449 .list({
450 owner: query.owner,
451 cursor: query.cursor,
452 limit: query.limit,
453 publicOnly: query.public === true,
454 })
455 .pipe(Effect.catchTag("StoreError", (error) => Effect.die(error)));
456 // Rendered straight from the Registry's denormalised columns:
457 // listing must NOT wake one Durable Object per row (measured at
458 // ~30 ms per row — DESIGN.md §14.1 / §15 bottleneck 7). Live
459 // `objects` stats need the DO, so a listing reports zeros and
460 // callers who want them read the repo directly.
461 const items = page.items.map(registryFallbackRepo);
462 return {
463 items,
464 nextCursor: page.nextCursor,
465 hasMore: page.hasMore,
466 };
467 }),
468 delete: ({
469 params,
470 }: Pick<HttpApiEndpoint.Request<typeof DeleteRepo>, "params">) =>
471 Effect.gen(function* () {
472 const entry = yield* resolveIncludingDeleting(
473 params.owner,
474 params.repo,
475 );
476 // Always (re-)arm the purge — even when the row is already
477 // soft-deleted. A second DELETE mid-drain is an idempotent 204,
478 // and re-arming is what recovers a purge whose alarm was lost
479 // (crash between markDeleted and the first alarm run).
480 yield* repos
481 .getByName(entry.repoId)
482 .startPurge()
483 .pipe(
484 Effect.catchTag("StoreError", (error) => Effect.die(error)),
485 // The registry row exists but the DO holds no state — an
486 // orphan, or a purge that already wiped storage. There is
487 // nothing to purge, so free the name directly (never 404:
488 // the caller can see this repo, so DELETE must remove it).
489 Effect.catchTag("RepoNotFound", () =>
490 registryStub()
491 .removeRow(entry.repoId)
492 .pipe(
493 Effect.catchTag("StoreError", (error) => Effect.die(error)),
494 ),
495 ),
496 );
497 yield* registryStub()
498 .markDeleted(entry.repoId)
499 .pipe(Effect.catchTag("StoreError", (error) => Effect.die(error)));
500 yield* dropCached(params.owner, params.repo);
501 }),
502 fork: ({
503 params,
504 payload,
505 }: Pick<HttpApiEndpoint.Request<typeof ForkRepo>, "params" | "payload">) =>
506 Effect.gen(function* () {
507 const source = yield* resolveOrNotFound(params.owner, params.repo);
508 const sourceMeta = yield* repos
509 .getByName(source.repoId)
510 .readMeta()
511 .pipe(
512 Effect.catchTag("StoreError", (error) => Effect.die(error)),
513 Effect.catchTag("RepoNotFound", () =>
514 Effect.fail(
515 new RepoNotFound({
516 owner: params.owner,
517 repo: params.repo,
518 }),
519 ),
520 ),
521 );
522 if (sourceMeta.status !== "ready") {
523 return yield* new RepoNotReady({ status: sourceMeta.status });
524 }
525 const entry = yield* registryStub()
526 .createRepo({
527 owner: payload.targetOwner,
528 name: payload.targetName,
529 description: sourceMeta.description ?? undefined,
530 forkOf: source.repoId,
531 })
532 .pipe(
533 Effect.catchTag("StoreError", (error) => Effect.die(error)),
534 // `fork` declares no ValidationError (reserved target
535 // owner) — surface it as the closest declared conflict.
536 Effect.catchTag("ValidationError", () =>
537 Effect.fail(
538 new RepoAlreadyExists({
539 owner: payload.targetOwner,
540 repo: payload.targetName,
541 }),
542 ),
543 ),
544 );
545 const init = yield* repos
546 .getByName(entry.repoId)
547 .startFork({
548 repoId: entry.repoId,
549 owner: entry.owner,
550 name: entry.name,
551 defaultBranch: sourceMeta.defaultBranch,
552 description: sourceMeta.description,
553 readOnly: false,
554 // Forks inherit the source's visibility.
555 public: sourceMeta.public,
556 forkOf: source.repoId,
557 parentRepoId: source.repoId,
558 })
559 .pipe(Effect.catchTag("StoreError", (error) => Effect.die(error)));
560 const remote = yield* remoteUrl(entry.owner, entry.name);
561 return new RepoCreated({
562 repo: toRepo(init.meta),
563 remote,
564 });
565 }),
566 compact: ({
567 params,
568 }: Pick<HttpApiEndpoint.Request<typeof CompactRepo>, "params">) =>
569 Effect.gen(function* () {
570 const entry = yield* resolveOrNotFound(params.owner, params.repo);
571 yield* repos
572 .getByName(entry.repoId)
573 .startCompact()
574 .pipe(
575 Effect.catchTag("StoreError", (error) => Effect.die(error)),
576 Effect.catchTag("RepoNotFound", () =>
577 Effect.fail(
578 new RepoNotFound({
579 owner: params.owner,
580 repo: params.repo,
581 }),
582 ),
583 ),
584 );
585 }),
586 import: ({
587 payload,
588 }: Pick<HttpApiEndpoint.Request<typeof ImportRepo>, "payload">) =>
589 Effect.gen(function* () {
590 const entry = yield* registryStub()
591 .createRepo({
592 owner: payload.owner,
593 name: payload.name,
594 })
595 .pipe(
596 Effect.catchTag("StoreError", (error) => Effect.die(error)),
597 // `import` declares no ValidationError — a reserved owner
598 // is an import that can never succeed.
599 Effect.catchTag("ValidationError", (error) =>
600 Effect.fail(new ImportFailed({ reason: error.message })),
601 ),
602 );
603 const init = yield* repos
604 .getByName(entry.repoId)
605 .startImport({
606 repoId: entry.repoId,
607 owner: entry.owner,
608 name: entry.name,
609 defaultBranch: "main",
610 description: null,
611 readOnly: false,
612 public: false,
613 forkOf: null,
614 source: {
615 url: payload.source.url,
616 ref: payload.source.ref,
617 depth: payload.source.depth,
618 },
619 })
620 .pipe(Effect.catchTag("StoreError", (error) => Effect.die(error)));
621 const remote = yield* remoteUrl(entry.owner, entry.name);
622 return new RepoCreated({
623 repo: toRepo(init.meta),
624 remote,
625 });
626 }),
627 };
628
629 const refsRoutes = {
630 list: ({
631 params,
632 query,
633 }: Pick<HttpApiEndpoint.Request<typeof ListRefs>, "params" | "query">) =>
634 Effect.gen(function* () {
635 const entry = yield* resolveOrNotFound(params.owner, params.repo);
636 const page = yield* repos
637 .getByName(entry.repoId)
638 .listRefs(query.prefix)
639 .pipe(
640 Effect.catchTag("StoreError", (error) => Effect.die(error)),
641 Effect.catchTag("RepoNotFound", () =>
642 Effect.fail(
643 new RepoNotFound({
644 owner: params.owner,
645 repo: params.repo,
646 }),
647 ),
648 ),
649 );
650 return { head: page.head, refs: page.refs.map(toRef) };
651 }),
652 get: ({
653 params,
654 query,
655 }: Pick<HttpApiEndpoint.Request<typeof GetRef>, "params" | "query">) =>
656 Effect.gen(function* () {
657 const entry = yield* resolveOrNotFound(params.owner, params.repo);
658 const ref = yield* repos
659 .getByName(entry.repoId)
660 .getRef(query.name)
661 .pipe(
662 Effect.catchTag("StoreError", (error) => Effect.die(error)),
663 Effect.catchTag("RepoNotFound", () =>
664 Effect.fail(
665 new RepoNotFound({
666 owner: params.owner,
667 repo: params.repo,
668 }),
669 ),
670 ),
671 );
672 return toRef(ref);
673 }),
674 update: ({
675 params,
676 query,
677 payload,
678 }: Pick<
679 HttpApiEndpoint.Request<typeof UpdateRef>,
680 "params" | "query" | "payload"
681 >) =>
682 Effect.gen(function* () {
683 const entry = yield* resolveOrNotFound(params.owner, params.repo);
684 const stub = repos.getByName(entry.repoId);
685 const ref = yield* stub
686 .updateRef({
687 name: query.name,
688 newOid: payload.newOid,
689 expectedOid: payload.expectedOid,
690 })
691 .pipe(
692 Effect.catchTag("StoreError", (error) => Effect.die(error)),
693 Effect.catchTag("RepoNotFound", () =>
694 Effect.fail(
695 new RepoNotFound({
696 owner: params.owner,
697 repo: params.repo,
698 }),
699 ),
700 ),
701 );
702 return toRef(ref);
703 }),
704 remove: ({
705 params,
706 query,
707 payload,
708 }: Pick<
709 HttpApiEndpoint.Request<typeof RemoveRef>,
710 "params" | "query" | "payload"
711 >) =>
712 Effect.gen(function* () {
713 const entry = yield* resolveOrNotFound(params.owner, params.repo);
714 const stub = repos.getByName(entry.repoId);
715 yield* stub
716 .removeRef({
717 name: query.name,
718 expectedOid: payload.expectedOid,
719 })
720 .pipe(
721 Effect.catchTag("StoreError", (error) => Effect.die(error)),
722 Effect.catchTag("RepoNotFound", () =>
723 Effect.fail(
724 new RepoNotFound({
725 owner: params.owner,
726 repo: params.repo,
727 }),
728 ),
729 ),
730 );
731 }),
732 };
733
734 const objectsRoutes = {
735 commit: ({
736 params,
737 }: Pick<HttpApiEndpoint.Request<typeof GetCommit>, "params">) =>
738 Effect.gen(function* () {
739 const entry = yield* resolveOrNotFound(params.owner, params.repo);
740 const data = yield* repos
741 .getByName(entry.repoId)
742 .readObject({ oid: params.oid, expect: "commit" })
743 .pipe(
744 Effect.catchTag("StoreError", (error) => Effect.die(error)),
745 Effect.catchTag("RepoNotFound", () =>
746 Effect.fail(
747 new RepoNotFound({
748 owner: params.owner,
749 repo: params.repo,
750 }),
751 ),
752 ),
753 );
754 // A stored commit that fails to parse is corrupt — a defect.
755 const parsed = yield* parseCommit(data.content).pipe(Effect.orDie);
756 return new CommitInfo({
757 oid: params.oid,
758 tree: asOid(parsed.tree),
759 parents: parsed.parents.map(asOid),
760 author: {
761 name: parsed.author.name,
762 email: parsed.author.email,
763 date: parsed.author.when,
764 tz: parsed.author.tz,
765 },
766 committer: {
767 name: parsed.committer.name,
768 email: parsed.committer.email,
769 date: parsed.committer.when,
770 tz: parsed.committer.tz,
771 },
772 message: parsed.message,
773 });
774 }),
775 log: ({
776 params,
777 query,
778 }: Pick<HttpApiEndpoint.Request<typeof GetLog>, "params" | "query">) =>
779 Effect.gen(function* () {
780 const entry = yield* resolveOrNotFound(params.owner, params.repo);
781 const page = yield* repos
782 .getByName(entry.repoId)
783 .readCommitLog({
784 ref: query.ref,
785 cursor: query.cursor,
786 limit: query.limit,
787 })
788 .pipe(
789 Effect.catchTag("StoreError", (error) => Effect.die(error)),
790 Effect.catchTag("RepoNotFound", () =>
791 Effect.fail(
792 new RepoNotFound({
793 owner: params.owner,
794 repo: params.repo,
795 }),
796 ),
797 ),
798 );
799 return {
800 items: page.items.map(toCommitInfo),
801 nextCursor: page.nextCursor,
802 hasMore: page.hasMore,
803 };
804 }),
805 tree: ({
806 params,
807 }: Pick<HttpApiEndpoint.Request<typeof GetTree>, "params">) =>
808 Effect.gen(function* () {
809 const entry = yield* resolveOrNotFound(params.owner, params.repo);
810 const data = yield* repos
811 .getByName(entry.repoId)
812 .readObject({ oid: params.oid, expect: "tree" })
813 .pipe(
814 Effect.catchTag("StoreError", (error) => Effect.die(error)),
815 Effect.catchTag("RepoNotFound", () =>
816 Effect.fail(
817 new RepoNotFound({
818 owner: params.owner,
819 repo: params.repo,
820 }),
821 ),
822 ),
823 );
824 const entries = yield* parseTree(data.content).pipe(Effect.orDie);
825 return {
826 oid: params.oid,
827 entries: entries.map(
828 (item) =>
829 new TreeEntry({
830 mode: item.mode,
831 name: item.name,
832 oid: asOid(item.oid),
833 type: treeEntryKind(item.mode),
834 }),
835 ),
836 };
837 }),
838 blob: ({
839 params,
840 }: Pick<HttpApiEndpoint.Request<typeof GetBlob>, "params">) =>
841 Effect.gen(function* () {
842 const entry = yield* resolveOrNotFound(params.owner, params.repo);
843 const data = yield* repos
844 .getByName(entry.repoId)
845 .readObject({ oid: params.oid, expect: "blob" })
846 .pipe(
847 Effect.catchTag("StoreError", (error) => Effect.die(error)),
848 Effect.catchTag("RepoNotFound", () =>
849 Effect.fail(
850 new RepoNotFound({
851 owner: params.owner,
852 repo: params.repo,
853 }),
854 ),
855 ),
856 );
857 if (data.size > MAX_JSON_BLOB_BYTES) {
858 return yield* new ObjectTooLarge({
859 oid: params.oid,
860 size: data.size,
861 });
862 }
863 return {
864 oid: params.oid,
865 size: data.size,
866 encoding: "base64" as const,
867 content: Base64.encode(data.content),
868 };
869 }),
870 diff: ({
871 params,
872 }: Pick<HttpApiEndpoint.Request<typeof GetDiff>, "params">) =>
873 Effect.gen(function* () {
874 const entry = yield* resolveOrNotFound(params.owner, params.repo);
875 const data = yield* repos
876 .getByName(entry.repoId)
877 .readCommitDiff({ oid: params.oid })
878 .pipe(
879 Effect.catchTag("StoreError", (error) => Effect.die(error)),
880 Effect.catchTag("RepoNotFound", () =>
881 Effect.fail(
882 new RepoNotFound({
883 owner: params.owner,
884 repo: params.repo,
885 }),
886 ),
887 ),
888 );
889 return new CommitDiff({
890 oid: params.oid,
891 parent: data.parent === null ? null : asOid(data.parent),
892 files: data.files.map(toDiffEntry),
893 truncated: data.truncated,
894 });
895 }),
896 compare: ({
897 params,
898 query,
899 }: Pick<HttpApiEndpoint.Request<typeof Compare>, "params" | "query">) =>
900 Effect.gen(function* () {
901 const entry = yield* resolveOrNotFound(params.owner, params.repo);
902 const data = yield* repos
903 .getByName(entry.repoId)
904 .compareCommits({ base: query.base, head: query.head })
905 .pipe(
906 Effect.catchTag("StoreError", (error) => Effect.die(error)),
907 Effect.catchTag("RepoNotFound", () =>
908 Effect.fail(
909 new RepoNotFound({
910 owner: params.owner,
911 repo: params.repo,
912 }),
913 ),
914 ),
915 );
916 return new Comparison({
917 base: asOid(data.base),
918 head: asOid(data.head),
919 mergeBase: asOid(data.mergeBase),
920 aheadBy: data.aheadBy,
921 behindBy: data.behindBy,
922 commits: data.commits.map(toCommitInfo),
923 commitsTruncated: data.commitsTruncated,
924 files: data.files.map(toDiffEntry),
925 filesTruncated: data.filesTruncated,
926 });
927 }),
928 };
929
930 const pullsRoutes = {
931 create: ({
932 params,
933 payload,
934 }: Pick<
935 HttpApiEndpoint.Request<typeof CreatePull>,
936 "params" | "payload"
937 >) =>
938 Effect.gen(function* () {
939 const entry = yield* resolveOrNotFound(params.owner, params.repo);
940 const pull = yield* repos
941 .getByName(entry.repoId)
942 .createPull({
943 title: payload.title,
944 body: payload.body,
945 base: payload.base,
946 head: payload.head,
947 })
948 .pipe(
949 Effect.catchTag("StoreError", (error) => Effect.die(error)),
950 Effect.catchTag("RepoNotFound", () =>
951 Effect.fail(
952 new RepoNotFound({
953 owner: params.owner,
954 repo: params.repo,
955 }),
956 ),
957 ),
958 );
959 return toPull(pull);
960 }),
961 list: ({
962 params,
963 query,
964 }: Pick<HttpApiEndpoint.Request<typeof ListPulls>, "params" | "query">) =>
965 Effect.gen(function* () {
966 const entry = yield* resolveOrNotFound(params.owner, params.repo);
967 const page = yield* repos
968 .getByName(entry.repoId)
969 .listPulls({
970 state: query.state,
971 cursor: query.cursor,
972 limit: query.limit,
973 })
974 .pipe(
975 Effect.catchTag("StoreError", (error) => Effect.die(error)),
976 Effect.catchTag("RepoNotFound", () =>
977 Effect.fail(
978 new RepoNotFound({
979 owner: params.owner,
980 repo: params.repo,
981 }),
982 ),
983 ),
984 );
985 return {
986 items: page.items.map(toPull),
987 nextCursor: page.nextCursor,
988 hasMore: page.hasMore,
989 };
990 }),
991 get: ({
992 params,
993 }: Pick<HttpApiEndpoint.Request<typeof GetPull>, "params">) =>
994 Effect.gen(function* () {
995 const entry = yield* resolveOrNotFound(params.owner, params.repo);
996 const detail = yield* repos
997 .getByName(entry.repoId)
998 .getPull(params.number)
999 .pipe(
1000 Effect.catchTag("StoreError", (error) => Effect.die(error)),
1001 Effect.catchTag("RepoNotFound", () =>
1002 Effect.fail(
1003 new RepoNotFound({
1004 owner: params.owner,
1005 repo: params.repo,
1006 }),
1007 ),
1008 ),
1009 );
1010 return toPullDetail(detail);
1011 }),
1012 update: ({
1013 params,
1014 payload,
1015 }: Pick<
1016 HttpApiEndpoint.Request<typeof UpdatePull>,
1017 "params" | "payload"
1018 >) =>
1019 Effect.gen(function* () {
1020 const entry = yield* resolveOrNotFound(params.owner, params.repo);
1021 const pull = yield* repos
1022 .getByName(entry.repoId)
1023 .updatePull({
1024 number: params.number,
1025 title: payload.title,
1026 body: payload.body,
1027 state: payload.state,
1028 })
1029 .pipe(
1030 Effect.catchTag("StoreError", (error) => Effect.die(error)),
1031 Effect.catchTag("RepoNotFound", () =>
1032 Effect.fail(
1033 new RepoNotFound({
1034 owner: params.owner,
1035 repo: params.repo,
1036 }),
1037 ),
1038 ),
1039 );
1040 return toPull(pull);
1041 }),
1042 merge: ({
1043 params,
1044 payload,
1045 }: Pick<HttpApiEndpoint.Request<typeof MergePull>, "params" | "payload">) =>
1046 Effect.gen(function* () {
1047 const entry = yield* resolveOrNotFound(params.owner, params.repo);
1048 const stub = repos.getByName(entry.repoId);
1049 const result = yield* stub
1050 .mergePull({
1051 number: params.number,
1052 message: payload.message,
1053 expectedHeadOid: payload.expectedHeadOid,
1054 })
1055 .pipe(
1056 Effect.catchTag("StoreError", (error) => Effect.die(error)),
1057 Effect.catchTag("RepoNotFound", () =>
1058 Effect.fail(
1059 new RepoNotFound({
1060 owner: params.owner,
1061 repo: params.repo,
1062 }),
1063 ),
1064 ),
1065 );
1066 return new MergeResult({
1067 method: result.method,
1068 oid: asOid(result.oid),
1069 pull: toPull(result.pull),
1070 });
1071 }),
1072 };
1073
1074 return {
1075 repos: reposRoutes,
1076 refs: refsRoutes,
1077 objects: objectsRoutes,
1078 pulls: pullsRoutes,
1079 resolveCached,
1080 };
1081});
1082/** @internal Shared operations/cache used by the engine and default HTTP adapters. */
1083export class Operations extends Context.Service<
1084 Operations,
1085 Effect.Success<typeof makeOperations>
1086>()("alchemy/Git/Operations") {}
1087export const OperationsLive = Layer.effect(Operations, makeOperations);