1import * as Effect from "effect/Effect";
2import * as Schedule from "effect/Schedule";
3import { Unowned } from "../AdoptPolicy.ts";
4import { isResolved } from "../Diff.ts";
5import * as Provider from "../Provider.ts";
6import {
7 DEV_TIMESTAMP,
8 attrOrString,
9 devId,
10 devProvider,
11} from "./Internal/DevStub.ts";
12import * as ProviderLayer from "../Local/ProviderLayer.ts";
13import { Resource } from "../Resource.ts";
14import {
15 type GetServicesResponse,
16 type GetProjectBranchesResponse,
17 type GetProjectDatabasesResponse,
18 type GetProjectsResponse,
19 type GetSourceRepositoriesResponse,
20 deleteSourceRepository,
21 getServices,
22 getService,
23 getDatabase,
24 getProjects,
25 getProjectBranches,
26 getProjectDatabases,
27 getSourceRepositories,
28 getSourceRepository,
29 createSourceRepository,
30} from "@distilled.cloud/prisma/management";
31import { Retry } from "@distilled.cloud/prisma";
32import type { Project } from "./Project.ts";
33import type { Providers } from "./Providers.ts";
34import {
35 concreteIdsChanged,
36 isInputObject,
37 isPrismaDevId,
38 resolveProjectId,
39 unresolvedProjectIdOf,
40} from "./Refs.ts";
41import type { ObservedSourceRepository } from "./Internal/Observed.ts";
42import { PrismaPaginationError } from "./Internal/Pagination.ts";
43
44export interface SourceRepositoryProps {
45 /**
46 * Project ID or project output to link. Linking has Management API side
47 * effects: it creates/renames the default branch and attaches currently
48 * unassigned databases and apps. To order downstream resources
49 * after those effects, pass this resource's `projectId` output to them.
50 * Deleting this resource only disconnects the repository link; Prisma
51 * preserves branches and resources that were attached while linking.
52 */
53 project: string | Project;
54 /**
55 * Git provider.
56 *
57 * @default "github"
58 */
59 provider?: "github";
60 /**
61 * GitHub's permanent numeric repository ID, not `owner/repo`, a Prisma
62 * project ID, or an SCM installation ID. Retrieve it with:
63 *
64 * ```bash
65 * gh api repos/OWNER/REPO --jq '.id'
66 * ```
67 */
68 providerRepositoryId: number;
69 /**
70 * Optional SCM installation ID to use for linking. When omitted, Prisma
71 * auto-picks the workspace's installation.
72 */
73 installationId?: string;
74}
75
76export interface SourceRepository extends Resource<
77 "Prisma.SourceRepository",
78 SourceRepositoryProps,
79 {
80 /**
81 * Prisma source repository link ID.
82 */
83 sourceRepositoryId: string;
84 /**
85 * Project ID linked to the repository.
86 */
87 projectId: string;
88 /**
89 * Numeric provider repository ID.
90 */
91 repoId: number;
92 /**
93 * Source control provider.
94 */
95 provider: "github";
96 /**
97 * Full repository name, for example "owner/repo".
98 */
99 repoFullName: string;
100 /**
101 * Default repository branch.
102 */
103 defaultBranch: string;
104 /**
105 * Whether the repository is private.
106 */
107 isPrivate: boolean;
108 /**
109 * Link status.
110 */
111 status: "active" | "archived";
112 /**
113 * SCM installation ID used for the link.
114 */
115 installationId: string;
116 /**
117 * ISO timestamp when the link was created.
118 */
119 createdAt: string;
120 /**
121 * ISO timestamp when the link was last updated.
122 */
123 updatedAt: string;
124 },
125 never,
126 Providers
127> {}
128
129/**
130 * A linked source repository for Prisma apps.
131 *
132 * GitHub is currently the only supported provider. Linking requires an
133 * existing Prisma SCM installation. `providerRepositoryId` is GitHub's
134 * permanent numeric repository ID; retrieve it with
135 * `gh api repos/OWNER/REPO --jq '.id'`. When `installationId` is omitted,
136 * Prisma selects the workspace installation.
137 *
138 * Linking creates or renames the repository-owned default branch. Observe
139 * that branch through the Management API; do not declare it again as a
140 * separate `Prisma.Branch` resource. Use this resource's outputs to order
141 * downstream databases and apps after the link side effects complete.
142 * Deleting the link does not roll those side effects back: existing branches,
143 * databases, and apps remain in the project.
144 *
145 * The project, repository ID, provider, and installation form an immutable
146 * link identity. Alchemy refuses an automatic relink because unlinking cannot
147 * roll back branch and resource attachments. Existing links require explicit
148 * adoption.
149 *
150 * ### Finding the Repository ID
151 * **Example:** Read the numeric GitHub repository ID
152 * ```bash
153 * gh api repos/OWNER/REPO --jq '.id'
154 * ```
155 *
156 * ### Linking a Repository
157 * **Example:** GitHub repository
158 * ```typescript
159 * const repo = yield* Prisma.SourceRepository("repo", {
160 * project: project.projectId,
161 * // Replace with the value returned by the GitHub command above.
162 * providerRepositoryId: 123456789,
163 * });
164 * const database = yield* Prisma.Database("database", {
165 * project: repo.projectId,
166 * branchGitName: repo.defaultBranch,
167 * });
168 * ```
169 *
170 * @resource
171 * @product Compute
172 */
173export const SourceRepository = Resource<SourceRepository>(
174 "Prisma.SourceRepository",
175);
176
177// Distilled emits the cursor-paginated list operations as plain ops, so
178// callers walk `pagination` themselves (see `src/Neon/Project.ts`).
179const listSourceRepositories = (projectId: string) =>
180 Effect.gen(function* () {
181 const repos: GetSourceRepositoriesResponse["data"][number][] = [];
182 let cursor: string | undefined;
183 while (true) {
184 const page = yield* getSourceRepositories(
185 cursor === undefined
186 ? { projectId, limit: 100 }
187 : { projectId, limit: 100, cursor },
188 );
189 repos.push(...page.data);
190 const nextCursor = page.pagination.nextCursor;
191 if (!page.pagination.hasMore) break;
192 if (nextCursor === null) {
193 return yield* Effect.fail(
194 new PrismaPaginationError({
195 message:
196 "Invalid Prisma Management API pagination response from getSourceRepositories: hasMore was true without a non-empty nextCursor",
197 }),
198 );
199 }
200 cursor = nextCursor;
201 }
202 return repos;
203 });
204
205const findRepository = (projectId: string) =>
206 listSourceRepositories(projectId).pipe(
207 Effect.flatMap((repos) => {
208 const active = repos.filter(
209 (repo: ObservedSourceRepository) => repo.status === "active",
210 );
211 return active.length > 1
212 ? Effect.fail(
213 new Error(
214 `Prisma project '${projectId}' has multiple active source repositories; refusing to select one arbitrarily.`,
215 ),
216 )
217 : Effect.succeed(active[0]);
218 }),
219 );
220
221const matchesDesiredRepository = (
222 repo: ObservedSourceRepository,
223 projectId: string,
224 props: SourceRepositoryProps,
225) =>
226 repo.status === "active" &&
227 repo.projectId === projectId &&
228 repo.repoId === props.providerRepositoryId &&
229 repo.provider === (props.provider ?? "github") &&
230 (props.installationId === undefined ||
231 repo.installationId === props.installationId);
232
233const attrsFrom = (
234 repo: ObservedSourceRepository,
235): SourceRepository["Attributes"] => ({
236 sourceRepositoryId: repo.id,
237 projectId: repo.projectId,
238 repoId: repo.repoId,
239 provider: repo.provider,
240 repoFullName: repo.repoFullName,
241 defaultBranch: repo.defaultBranch,
242 isPrivate: repo.isPrivate,
243 status: repo.status,
244 installationId: repo.installationId,
245 createdAt: repo.createdAt,
246 updatedAt: repo.updatedAt,
247});
248
249class SourceRepositoryLinkNotReady extends Error {}
250
251const sourceRepositoryConsistencySchedule = Schedule.max([
252 Schedule.exponential("250 millis"),
253 Schedule.recurs(5),
254]);
255
256const verifyRepositoryLink = Effect.fn(function* (
257 repo: ObservedSourceRepository,
258 expectedAppIds: readonly string[] = [],
259 expectedDatabaseIds: readonly string[] = [],
260) {
261 const observed = (yield* getSourceRepository({ id: repo.id })).data;
262 if (
263 observed.status !== "active" ||
264 observed.projectId !== repo.projectId ||
265 observed.repoId !== repo.repoId ||
266 observed.provider !== repo.provider ||
267 observed.installationId !== repo.installationId
268 ) {
269 return yield* Effect.fail(
270 new SourceRepositoryLinkNotReady(
271 `Prisma source repository link '${repo.id}' has not converged to its requested immutable identity.`,
272 ),
273 );
274 }
275 const branches = yield* Effect.gen(function* () {
276 const items: GetProjectBranchesResponse["data"][number][] = [];
277 let cursor: string | undefined;
278 while (true) {
279 const page = yield* getProjectBranches(
280 cursor === undefined
281 ? {
282 projectId: repo.projectId,
283 gitName: observed.defaultBranch,
284 limit: 100,
285 }
286 : {
287 projectId: repo.projectId,
288 gitName: observed.defaultBranch,
289 limit: 100,
290 cursor,
291 },
292 );
293 items.push(...page.data);
294 const nextCursor = page.pagination.nextCursor;
295 if (!page.pagination.hasMore) break;
296 if (nextCursor === null) {
297 return yield* Effect.fail(
298 new PrismaPaginationError({
299 message:
300 "Invalid Prisma Management API pagination response from getProjectBranches: hasMore was true without a non-empty nextCursor",
301 }),
302 );
303 }
304 cursor = nextCursor;
305 }
306 return items;
307 });
308 const defaults = branches.filter(
309 (branch) =>
310 branch.gitName === observed.defaultBranch && branch.isDefault === true,
311 );
312 if (defaults.length !== 1) {
313 return yield* Effect.fail(
314 new SourceRepositoryLinkNotReady(
315 `Prisma source repository link '${repo.id}' did not produce exactly one default branch named '${observed.defaultBranch}'.`,
316 ),
317 );
318 }
319 const branchId = defaults[0]!.id;
320 yield* Effect.forEach(
321 expectedAppIds,
322 (appId) =>
323 getService({ serviceId: appId }).pipe(
324 Effect.map((response) => response.data),
325 Effect.flatMap((app) =>
326 app.branchId === branchId
327 ? Effect.void
328 : Effect.fail(
329 new SourceRepositoryLinkNotReady(
330 `Prisma App '${appId}' was not attached to repository branch '${branchId}'.`,
331 ),
332 ),
333 ),
334 ),
335 { concurrency: 8 },
336 );
337 yield* Effect.forEach(
338 expectedDatabaseIds,
339 (databaseId) =>
340 getDatabase({ databaseId }).pipe(
341 Effect.map((response) => response.data),
342 Effect.flatMap((database) =>
343 database.branchId === branchId
344 ? Effect.void
345 : Effect.fail(
346 new SourceRepositoryLinkNotReady(
347 `Prisma database '${databaseId}' was not attached to repository branch '${branchId}'.`,
348 ),
349 ),
350 ),
351 ),
352 { concurrency: 8 },
353 );
354 return observed;
355});
356
357const ProviderLive = () =>
358 Provider.effect(
359 SourceRepository,
360 Effect.gen(function* () {
361 return {
362 stables: ["sourceRepositoryId"],
363 list: Effect.fn(function* () {
364 const projects = yield* Effect.gen(function* () {
365 const items: GetProjectsResponse["data"][number][] = [];
366 let cursor: string | undefined;
367 while (true) {
368 const page = yield* getProjects(
369 cursor === undefined ? {} : { cursor },
370 );
371 items.push(...page.data);
372 const nextCursor = page.pagination.nextCursor;
373 if (!page.pagination.hasMore) break;
374 if (nextCursor === null) {
375 return yield* Effect.fail(
376 new PrismaPaginationError({
377 message:
378 "Invalid Prisma Management API pagination response from getProjects: hasMore was true without a non-empty nextCursor",
379 }),
380 );
381 }
382 cursor = nextCursor;
383 }
384 return items;
385 });
386 const repositories = yield* Effect.forEach(
387 projects,
388 (project) =>
389 listSourceRepositories(project.id).pipe(
390 Effect.catchTag("NotFound", () => Effect.succeed([])),
391 ),
392 { concurrency: 8 },
393 );
394 return repositories.flat().map(attrsFrom);
395 }),
396 diff: Effect.fn(function* ({ olds, news, output }) {
397 if (!isInputObject(news)) return undefined;
398 if (isPrismaDevId(output?.sourceRepositoryId)) {
399 return { action: "update" } as const;
400 }
401 const oldProjectId =
402 output?.projectId ?? unresolvedProjectIdOf(olds.project);
403 const newProjectId = isResolved(news.project)
404 ? unresolvedProjectIdOf(news.project)
405 : undefined;
406 const newRepositoryId = isResolved(news.providerRepositoryId)
407 ? news.providerRepositoryId
408 : undefined;
409 const observedMismatch =
410 output &&
411 ((newProjectId !== undefined &&
412 output.projectId !== newProjectId) ||
413 (newRepositoryId !== undefined &&
414 output.repoId !== newRepositoryId) ||
415 (isResolved(news.provider) &&
416 output.provider !== (news.provider ?? "github")) ||
417 (isResolved(news.installationId) &&
418 news.installationId !== undefined &&
419 output.installationId !== news.installationId) ||
420 output.status !== "active");
421 if (
422 concreteIdsChanged(oldProjectId, newProjectId) ||
423 (isResolved(news.provider) &&
424 (news.provider ?? "github") !== (olds.provider ?? "github")) ||
425 (isResolved(news.providerRepositoryId) &&
426 news.providerRepositoryId !== olds.providerRepositoryId) ||
427 (isResolved(news.installationId) &&
428 news.installationId !== olds.installationId) ||
429 observedMismatch
430 ) {
431 return yield* Effect.fail(
432 new Error(
433 `Prisma source repository links cannot be replaced atomically: unlinking mutates branch/resource attachments and a failed relink cannot restore them. Create a new project or explicitly unlink and relink in separate reviewed deployments.`,
434 ),
435 );
436 }
437 return undefined;
438 }),
439 read: Effect.fn(function* ({ output, olds }) {
440 const sourceRepositoryId = isPrismaDevId(output?.sourceRepositoryId)
441 ? undefined
442 : output?.sourceRepositoryId;
443 const repo = sourceRepositoryId
444 ? yield* getSourceRepository({
445 id: sourceRepositoryId,
446 }).pipe(
447 Effect.map((response) => response.data),
448 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
449 )
450 : yield* Effect.gen(function* () {
451 const projectId = unresolvedProjectIdOf(olds.project);
452 return projectId ? yield* findRepository(projectId) : undefined;
453 });
454 if (!repo) return undefined;
455 const attrs = attrsFrom(repo);
456 return sourceRepositoryId === undefined ? Unowned(attrs) : attrs;
457 }),
458 reconcile: Effect.fn(function* ({ news, output }) {
459 const projectId = yield* resolveProjectId(news.project);
460 const sourceRepositoryId = isPrismaDevId(output?.sourceRepositoryId)
461 ? undefined
462 : output?.sourceRepositoryId;
463 let repo = sourceRepositoryId
464 ? yield* getSourceRepository({
465 id: sourceRepositoryId,
466 }).pipe(
467 Effect.map((response) => response.data),
468 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
469 )
470 : undefined;
471 if (repo && !matchesDesiredRepository(repo, projectId, news)) {
472 return yield* Effect.fail(
473 new Error(
474 `Prisma project '${projectId}' is already linked to '${repo.repoFullName}'. The Management API has no atomic relink or rollback operation, so this provider will not delete the live link during reconcile. Explicitly unlink it in a separate reviewed deployment before linking repository '${news.providerRepositoryId}'.`,
475 ),
476 );
477 }
478 if (!repo) {
479 const previouslyUnassignedApps = (yield* Effect.gen(function* () {
480 const items: GetServicesResponse["data"][number][] = [];
481 let cursor: string | undefined;
482 while (true) {
483 const page = yield* getServices(
484 cursor === undefined
485 ? { projectId, branchId: "unassigned", limit: 100 }
486 : { projectId, branchId: "unassigned", limit: 100, cursor },
487 );
488 items.push(...page.data);
489 const nextCursor = page.pagination.nextCursor;
490 if (!page.pagination.hasMore) break;
491 if (nextCursor === null) {
492 return yield* Effect.fail(
493 new PrismaPaginationError({
494 message:
495 "Invalid Prisma Management API pagination response from getServices: hasMore was true without a non-empty nextCursor",
496 }),
497 );
498 }
499 cursor = nextCursor;
500 }
501 return items;
502 })).map((app) => app.id);
503 const previouslyUnassignedDatabases = (yield* Effect.gen(
504 function* () {
505 const items: GetProjectDatabasesResponse["data"][number][] = [];
506 let cursor: string | undefined;
507 while (true) {
508 const page = yield* getProjectDatabases(
509 cursor === undefined
510 ? { projectId, limit: 100 }
511 : { projectId, limit: 100, cursor },
512 );
513 items.push(...page.data);
514 const nextCursor = page.pagination.nextCursor;
515 if (!page.pagination.hasMore) break;
516 if (nextCursor === null) {
517 return yield* Effect.fail(
518 new PrismaPaginationError({
519 message:
520 "Invalid Prisma Management API pagination response from getProjectDatabases: hasMore was true without a non-empty nextCursor",
521 }),
522 );
523 }
524 cursor = nextCursor;
525 }
526 return items;
527 },
528 ))
529 .filter((database) => database.branchId === null)
530 .map((database) => database.id);
531 repo = yield* createSourceRepository({
532 projectId,
533 provider: news.provider ?? "github",
534 providerRepositoryId: news.providerRepositoryId,
535 ...(news.installationId === undefined
536 ? {}
537 : { installationId: news.installationId }),
538 }).pipe(
539 // A replayed create would link the repository twice; the retry
540 // policy cannot see the request, so opt out explicitly.
541 Retry.none,
542 Effect.map((response) => response.data),
543 Effect.catchTag("Conflict", () =>
544 Effect.fail(
545 new Error(
546 `Prisma source repository '${news.providerRepositoryId}' appeared after the adoption check. Refusing to take over the project link; rerun with adoption enabled if it is the intended repository.`,
547 ),
548 ),
549 ),
550 );
551 repo = yield* verifyRepositoryLink(
552 repo,
553 previouslyUnassignedApps,
554 previouslyUnassignedDatabases,
555 ).pipe(
556 Effect.retry({
557 while: (error) => error instanceof SourceRepositoryLinkNotReady,
558 schedule: sourceRepositoryConsistencySchedule,
559 }),
560 Effect.mapError(
561 (error) =>
562 new Error(
563 `Prisma source repository link '${repo!.id}' was created but its branch/resource side effects did not converge. The link was not deleted because unlinking cannot roll back partial attachments; inspect it and adopt after repair.`,
564 { cause: error },
565 ),
566 ),
567 );
568 } else {
569 repo = yield* verifyRepositoryLink(repo);
570 }
571 return attrsFrom(repo);
572 }),
573 delete: Effect.fn(function* ({ output }) {
574 if (isPrismaDevId(output.sourceRepositoryId)) return;
575 const repo = yield* getSourceRepository({
576 id: output.sourceRepositoryId,
577 }).pipe(
578 Effect.map((response) => response.data),
579 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
580 );
581 if (!repo) return;
582 if (
583 repo.projectId !== output.projectId ||
584 repo.repoId !== output.repoId ||
585 repo.provider !== output.provider ||
586 repo.installationId !== output.installationId
587 ) {
588 return yield* Effect.fail(
589 new Error(
590 `Prisma source repository '${repo.id}' no longer matches its persisted project/repository/installation identity. Refusing to delete a mismatched link.`,
591 ),
592 );
593 }
594 yield* deleteSourceRepository({ id: repo.id }).pipe(
595 Effect.catchTag("NotFound", () => Effect.void),
596 );
597 }),
598 };
599 }),
600 );
601
602const ProviderLocal = () =>
603 devProvider(SourceRepository, ["sourceRepositoryId"], ({ id, news }) => ({
604 sourceRepositoryId: devId("source-repository", id),
605 projectId: attrOrString(news.project, "projectId"),
606 repoId: news.providerRepositoryId,
607 provider: news.provider ?? "github",
608 repoFullName: `dev/${id}`,
609 defaultBranch: "main",
610 isPrivate: false,
611 status: "active",
612 installationId: devId("installation", id),
613 createdAt: DEV_TIMESTAMP,
614 updatedAt: DEV_TIMESTAMP,
615 }));
616
617export const SourceRepositoryProvider = () =>
618 ProviderLayer.dual(SourceRepository, {
619 local: () => ProviderLocal(),
620 live: () => ProviderLive(),
621 });