| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Schedule from "effect/Schedule"; |
| 3 | import { Unowned } from "../AdoptPolicy.ts"; |
| 4 | import { isResolved } from "../Diff.ts"; |
| 5 | import * as Provider from "../Provider.ts"; |
| 6 | import { |
| 7 | DEV_TIMESTAMP, |
| 8 | attrOrString, |
| 9 | devId, |
| 10 | devProvider, |
| 11 | } from "./Internal/DevStub.ts"; |
| 12 | import * as ProviderLayer from "../Local/ProviderLayer.ts"; |
| 13 | import { Resource } from "../Resource.ts"; |
| 14 | import { |
| 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"; |
| 31 | import { Retry } from "@distilled.cloud/prisma"; |
| 32 | import type { Project } from "./Project.ts"; |
| 33 | import type { Providers } from "./Providers.ts"; |
| 34 | import { |
| 35 | concreteIdsChanged, |
| 36 | isInputObject, |
| 37 | isPrismaDevId, |
| 38 | resolveProjectId, |
| 39 | unresolvedProjectIdOf, |
| 40 | } from "./Refs.ts"; |
| 41 | import type { ObservedSourceRepository } from "./Internal/Observed.ts"; |
| 42 | import { PrismaPaginationError } from "./Internal/Pagination.ts"; |
| 43 | |
| 44 | export 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 | |
| 76 | export 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 | */ |
| 173 | export 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`). |
| 179 | const 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 | |
| 205 | const 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 | |
| 221 | const 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 | |
| 233 | const 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 | |
| 249 | class SourceRepositoryLinkNotReady extends Error {} |
| 250 | |
| 251 | const sourceRepositoryConsistencySchedule = Schedule.max([ |
| 252 | Schedule.exponential("250 millis"), |
| 253 | Schedule.recurs(5), |
| 254 | ]); |
| 255 | |
| 256 | const 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 | |
| 357 | const 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 | |
| 602 | const 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 | |
| 617 | export const SourceRepositoryProvider = () => |
| 618 | ProviderLayer.dual(SourceRepository, { |
| 619 | local: () => ProviderLocal(), |
| 620 | live: () => ProviderLive(), |
| 621 | }); |