1import { Unowned } from "@/AdoptPolicy";
2import { InstanceId } from "@/InstanceId";
3import * as Output from "@/Output";
4import { createPhysicalName } from "@/PhysicalName";
5import * as Provider from "@/Provider";
6import type { App } from "@/Prisma/App";
7import { Branch as PrismaBranch, BranchProvider } from "@/Prisma/Branch";
8import { PrismaApiError, PrismaClient } from "@/Prisma/Client";
9import {
10 CustomDomain as PrismaCustomDomain,
11 CustomDomainProvider,
12} from "@/Prisma/CustomDomain";
13import {
14 Database as PrismaDatabase,
15 DatabaseProvider,
16} from "@/Prisma/Database";
17import {
18 EnvironmentVariable as PrismaEnvironmentVariable,
19 EnvironmentVariableProvider,
20} from "@/Prisma/EnvironmentVariable";
21import { Project as PrismaProject, ProjectProvider } from "@/Prisma/Project";
22import { recoverDatabaseConnectionSecrets } from "@/Prisma/Internal/DatabaseSecrets";
23import {
24 SourceRepository as PrismaSourceRepository,
25 SourceRepositoryProvider,
26} from "@/Prisma/SourceRepository";
27import * as Test from "@/Test/Alchemy";
28import { expect, it } from "alchemy-test";
29import {
30 conflict,
31 data,
32 dispatchTo,
33 failure,
34 type FakeManagementApi,
35 json,
36 makeFakeManagementApi,
37 noContent,
38 notFound,
39 page,
40 unhandled,
41} from "./fixtures/FakeManagementApi.ts";
42import { testStackContext } from "./fixtures/StackContext.ts";
43import { PlatformServices } from "@/Util/PlatformServices";
44import * as Effect from "effect/Effect";
45import * as Fiber from "effect/Fiber";
46import * as Layer from "effect/Layer";
47import * as Redacted from "effect/Redacted";
48import * as Result from "effect/Result";
49import * as TestClock from "effect/testing/TestClock";
50import type { PrismaManagementClient } from "@/Prisma/Client";
51import type {
52 Branch as ApiBranch,
53 CustomDomain as ApiCustomDomain,
54 Database as ApiDatabase,
55 DatabaseConnectionWithSecrets,
56 Project as ApiProject,
57 SourceRepository as ApiSourceRepository,
58} from "@/Prisma/Types";
59import { AlchemyContext } from "@/AlchemyContext";
60
61const createdAt = "2026-01-01T00:00:00.000Z";
62
63const liveProviderContext = Layer.succeed(AlchemyContext, {
64 dotAlchemy: ".alchemy-test",
65 dev: false,
66 adopt: false,
67});
68
69class TestPrismaProviders extends Provider.ProviderCollection<TestPrismaProviders>()(
70 "Prisma",
71) {}
72
73const projectLayer = (fake: FakeManagementApi) =>
74 Layer.effect(TestPrismaProviders, Provider.collection([PrismaProject])).pipe(
75 Layer.provideMerge(ProjectProvider()),
76 Layer.provide(liveProviderContext),
77 Layer.provideMerge(fake.layer),
78 );
79
80/** Wire serializers for the in-memory cloud's Types.ts-shaped records. */
81const toWireProject = (project: ApiProject) => ({
82 ...project,
83 logicalId: null,
84});
85
86const toWireDatabase = (database: ApiDatabase) => database;
87
88const toWireCreatedDatabase = (database: ApiDatabase) => ({
89 ...database,
90 apiKeys: [],
91 connectionString: "postgres://direct",
92 directConnection: {
93 host: "db.prisma.test",
94 user: "prisma",
95 pass: "secret",
96 },
97});
98
99const branchLayer = (fake: FakeManagementApi) =>
100 Layer.effect(TestPrismaProviders, Provider.collection([PrismaBranch])).pipe(
101 Layer.provideMerge(BranchProvider()),
102 Layer.provide(liveProviderContext),
103 Layer.provideMerge(fake.layer),
104 );
105
106const databaseLayer = (fake: FakeManagementApi) =>
107 Layer.effect(TestPrismaProviders, Provider.collection([PrismaDatabase])).pipe(
108 Layer.provideMerge(DatabaseProvider()),
109 Layer.provide(liveProviderContext),
110 Layer.provideMerge(fake.layer),
111 );
112
113/**
114 * Serve the Management API from the same hermetic client-shaped handlers this
115 * suite declares, for the resources that now call distilled operations.
116 * `dispatchTo` maps each handler's result onto the wire (see the fixture).
117 */
118const clientBackedApi = (client: any) =>
119 makeFakeManagementApi((request) => {
120 // segments[0] is the "v1" prefix.
121 const [head, id, tail] = request.pathname
122 .split("/")
123 .filter((segment) => segment.length > 0)
124 .slice(1);
125 const body = request.bodyJson as any;
126 const query = Object.fromEntries(new URLSearchParams(request.search));
127 const { call, callVoid, list } = dispatchTo(request);
128
129 if (head === "projects") {
130 if (id === undefined && request.method === "GET") {
131 return call(client.listProjects, [], list);
132 }
133 if (tail === "branches" && request.method === "GET") {
134 return call(client.listBranches, [id, query], list);
135 }
136 if (tail === "databases" && request.method === "GET") {
137 return call(client.listProjectDatabases, [id, query], list);
138 }
139 }
140 if (head === "services" && id === undefined && request.method === "GET") {
141 return call(client.listApps, [query], list);
142 }
143 if (head === "environment-variables") {
144 if (id === undefined) {
145 return request.method === "GET"
146 ? call(client.listEnvironmentVariables, [query], list)
147 : call(client.createEnvironmentVariable, [body]);
148 }
149 if (request.method === "GET") {
150 return call(client.getEnvironmentVariable, [id]);
151 }
152 if (request.method === "PATCH") {
153 return call(client.updateEnvironmentVariable, [id, body]);
154 }
155 if (request.method === "DELETE") {
156 return callVoid(client.deleteEnvironmentVariable, [id]);
157 }
158 }
159 if (head === "source-repositories") {
160 if (id === undefined) {
161 return request.method === "GET"
162 ? call(client.listSourceRepositories, [query], list)
163 : call(client.createSourceRepository, [body]);
164 }
165 if (request.method === "GET") {
166 return call(client.getSourceRepository, [id]);
167 }
168 if (request.method === "DELETE") {
169 return callVoid(client.deleteSourceRepository, [id]);
170 }
171 }
172 return unhandled(request);
173 });
174
175const environmentVariableLayer = (client: PrismaManagementClient) =>
176 Layer.effect(
177 TestPrismaProviders,
178 Provider.collection([PrismaEnvironmentVariable]),
179 ).pipe(
180 Layer.provideMerge(EnvironmentVariableProvider()),
181 Layer.provide(liveProviderContext),
182 Layer.provideMerge(clientBackedApi(client).layer),
183 );
184
185const customDomainLayer = (client: PrismaManagementClient) =>
186 Layer.effect(
187 TestPrismaProviders,
188 Provider.collection([PrismaCustomDomain]),
189 ).pipe(
190 Layer.provideMerge(CustomDomainProvider()),
191 Layer.provideMerge(Layer.succeed(PrismaClient, client)),
192 Layer.provide(liveProviderContext),
193 );
194
195const sourceRepositoryLayer = (client: PrismaManagementClient) =>
196 Layer.effect(
197 TestPrismaProviders,
198 Provider.collection([PrismaSourceRepository]),
199 ).pipe(
200 Layer.provideMerge(SourceRepositoryProvider()),
201 Layer.provide(liveProviderContext),
202 Layer.provideMerge(clientBackedApi(client).layer),
203 );
204
205const apiProject = (
206 id: string,
207 name: string,
208 defaultRegion: string | null = "us-east-1",
209): ApiProject => ({
210 id,
211 type: "project",
212 url: `https://api.prisma.test/v1/projects/${id}`,
213 name,
214 createdAt,
215 defaultRegion,
216 workspace: {
217 id: "workspace-1",
218 url: "https://api.prisma.test/v1/workspaces/workspace-1",
219 name: "team",
220 },
221});
222
223const apiConnection = (
224 databaseId: string,
225 connectionId = `connection-${databaseId}`,
226): DatabaseConnectionWithSecrets => ({
227 id: connectionId,
228 type: "connection",
229 url: `https://api.prisma.test/v1/connections/${connectionId}`,
230 name: "default",
231 createdAt,
232 kind: "postgres",
233 endpoints: {
234 direct: {
235 host: "db.prisma.test",
236 port: 5432,
237 connectionString: `postgres://user:password@db.prisma.test/${databaseId}`,
238 },
239 pooled: {
240 host: "pool.prisma.test",
241 port: 5432,
242 connectionString: `postgres://user:password@pool.prisma.test/${databaseId}`,
243 },
244 },
245 database: {
246 id: databaseId,
247 url: `https://api.prisma.test/v1/databases/${databaseId}`,
248 name: databaseId,
249 },
250});
251
252it.effect(
253 "database credential recovery waits for a ready default connection",
254 () => {
255 const provisioning: ApiDatabase = {
256 ...apiDatabase("database-provisioning", {
257 projectId: "project-1",
258 name: "provisioning",
259 }),
260 status: "provisioning",
261 defaultConnectionId: null,
262 connections: [],
263 };
264 const ready: ApiDatabase = {
265 ...provisioning,
266 status: "ready",
267 defaultConnectionId: "connection-provisioning",
268 };
269 let reads = 0;
270 let rotations = 0;
271 const fake = makeFakeManagementApi((request) => {
272 if (request.pathname.startsWith("/v1/databases/")) {
273 return data(toWireDatabase(reads++ === 0 ? provisioning : ready));
274 }
275 if (request.pathname.endsWith("/rotate")) {
276 rotations += 1;
277 return data(apiConnection(ready.id, ready.defaultConnectionId!));
278 }
279 return unhandled(request);
280 });
281
282 return Effect.gen(function* () {
283 const fiber = yield* recoverDatabaseConnectionSecrets(
284 provisioning,
285 {},
286 ).pipe(Effect.forkChild({ startImmediately: true }));
287 yield* Effect.yieldNow;
288 yield* TestClock.adjust("1 second");
289 const recovered = yield* Fiber.join(fiber);
290 expect(recovered.database.status).toBe("ready");
291 expect(recovered.database.defaultConnectionId).toBe(
292 "connection-provisioning",
293 );
294 expect(
295 Redacted.value(recovered.secrets.directConnectionString!),
296 ).toContain(ready.id);
297 expect(rotations).toBe(1);
298 }).pipe(Effect.provide(fake.layer), Effect.provide(TestClock.layer()));
299 },
300 { tags: ["unit", "provider:prisma", "provider:prisma:database", "local"] },
301);
302
303it.effect(
304 "database credential recovery has a bounded status-rich timeout",
305 () => {
306 const provisioning: ApiDatabase = {
307 ...apiDatabase("database-stuck", {
308 projectId: "project-1",
309 name: "stuck",
310 }),
311 status: "provisioning",
312 defaultConnectionId: null,
313 connections: [],
314 };
315 const fake = makeFakeManagementApi((request) => {
316 if (request.pathname.startsWith("/v1/databases/")) {
317 return data(toWireDatabase(provisioning));
318 }
319 if (request.pathname.endsWith("/rotate")) {
320 throw new Error("must not rotate while provisioning");
321 }
322 return unhandled(request);
323 });
324
325 return Effect.gen(function* () {
326 const fiber = yield* recoverDatabaseConnectionSecrets(
327 provisioning,
328 {},
329 ).pipe(Effect.result, Effect.forkChild({ startImmediately: true }));
330 yield* Effect.yieldNow;
331 yield* TestClock.adjust("1 minute");
332 const result = yield* Fiber.join(fiber);
333 expect(Result.isFailure(result)).toBe(true);
334 if (Result.isFailure(result)) {
335 expect(String(result.failure)).toContain("database-stuck");
336 expect(String(result.failure)).toContain("provisioning");
337 expect(String(result.failure)).toContain("defaultConnectionId 'null'");
338 }
339 }).pipe(Effect.provide(fake.layer), Effect.provide(TestClock.layer()));
340 },
341 { tags: ["unit", "provider:prisma", "provider:prisma:database", "local"] },
342);
343
344const makeProjectCloud = (initial: ApiProject[] = []) => {
345 const projects = new Map(initial.map((project) => [project.id, project]));
346 const databases = new Map<string, ApiDatabase>();
347 const calls: Array<[string, unknown?]> = [];
348 let nextId = initial.length + 1;
349 let nextDatabaseId = 1;
350 let conflictNextProjectDatabaseCreate = false;
351 let staleNextProjectDatabaseObservation = false;
352 let staleProjectReads = 0;
353 let staleProjectDefaultRegion: string | null = null;
354 let staleDatabaseLists = 0;
355 let staleDatabases: ApiDatabase[] = [];
356
357 const currentProject = (project: ApiProject) => {
358 const database = Array.from(databases.values()).find(
359 (database) => database.project.id === project.id && database.isDefault,
360 );
361 return {
362 ...project,
363 defaultRegion: database?.region?.id ?? null,
364 };
365 };
366
367 const makeDatabase = (
368 project: ApiProject,
369 input: { region?: string; isDefault?: boolean },
370 ): ApiDatabase => {
371 if (input.isDefault) {
372 for (const [id, database] of databases) {
373 if (database.project.id === project.id && database.isDefault) {
374 databases.set(id, { ...database, isDefault: false });
375 }
376 }
377 }
378 const id = `project-database-${nextDatabaseId++}`;
379 const database: ApiDatabase = {
380 id,
381 type: "database",
382 url: `https://api.prisma.test/v1/databases/${id}`,
383 name: project.name,
384 status: "ready",
385 createdAt,
386 isDefault: input.isDefault ?? false,
387 defaultConnectionId: `connection-${id}`,
388 connections: [apiConnection(id)],
389 project: {
390 id: project.id,
391 url: project.url,
392 name: project.name,
393 },
394 region: {
395 id: input.region ?? "us-east-1",
396 name: input.region ?? "us-east-1",
397 },
398 source: { type: "empty" },
399 branchId: null,
400 };
401 databases.set(id, database);
402 return database;
403 };
404
405 // The same in-memory cloud, served over the wire for the resources that
406 // call distilled operations instead of the client above.
407 const fake = makeFakeManagementApi((request) => {
408 const segments = request.pathname.split("/").filter((s) => s.length > 0);
409
410 if (request.pathname === "/v1/services" && request.method === "GET") {
411 calls.push([
412 "listApps",
413 Object.fromEntries(new URLSearchParams(request.search)),
414 ]);
415 return page([]);
416 }
417
418 if (request.pathname === "/v1/projects" && request.method === "GET") {
419 calls.push(["listProjects"]);
420 return page(
421 Array.from(projects.values()).map(currentProject).map(toWireProject),
422 );
423 }
424
425 if (request.pathname === "/v1/projects" && request.method === "POST") {
426 const input = request.bodyJson as {
427 name?: string;
428 region?: string;
429 createDatabase?: boolean;
430 };
431 calls.push(["createProject", input]);
432 const id = `project-${nextId++}`;
433 const project = apiProject(id, input.name ?? `project-${id}`, null);
434 projects.set(id, project);
435 const database =
436 input.createDatabase === false
437 ? null
438 : makeDatabase(project, { region: input.region, isDefault: true });
439 return data(
440 {
441 ...toWireProject(currentProject(project)),
442 database: database === null ? null : toWireCreatedDatabase(database),
443 },
444 { status: 201 },
445 );
446 }
447
448 if (segments.length === 3 && segments[1] === "projects") {
449 const id = segments[2]!;
450 if (request.method === "GET") {
451 calls.push(["getProject", id]);
452 const stored = projects.get(id);
453 const project = stored
454 ? staleProjectReads > 0
455 ? {
456 ...currentProject(stored),
457 defaultRegion: staleProjectDefaultRegion,
458 }
459 : currentProject(stored)
460 : undefined;
461 if (staleProjectReads > 0) staleProjectReads -= 1;
462 return project === undefined
463 ? notFound("not found")
464 : data(toWireProject(project));
465 }
466 if (request.method === "PATCH") {
467 const input = request.bodyJson as {
468 name?: string;
469 settings?: Record<string, unknown>;
470 };
471 calls.push(["updateProject", { id, input }]);
472 const project = projects.get(id)!;
473 const updated = { ...project, name: input.name ?? project.name };
474 projects.set(id, updated);
475 return data(toWireProject(currentProject(updated)));
476 }
477 if (request.method === "DELETE") {
478 calls.push(["deleteProject", id]);
479 projects.delete(id);
480 for (const [databaseId, database] of databases) {
481 if (database.project.id === id) databases.delete(databaseId);
482 }
483 return json(null, { status: 204 });
484 }
485 }
486
487 if (
488 segments.length === 4 &&
489 segments[1] === "projects" &&
490 segments[3] === "databases"
491 ) {
492 const projectId = segments[2]!;
493 if (request.method === "GET") {
494 calls.push(["listProjectDatabases", projectId]);
495 if (staleDatabaseLists > 0) {
496 staleDatabaseLists -= 1;
497 return page(staleDatabases.map(toWireDatabase));
498 }
499 return page(
500 Array.from(databases.values())
501 .filter((database) => database.project.id === projectId)
502 .map(toWireDatabase),
503 );
504 }
505 if (request.method === "POST") {
506 const input = request.bodyJson as {
507 region?: string;
508 isDefault?: boolean;
509 };
510 calls.push(["createProjectDatabase", { projectId, input }]);
511 if (conflictNextProjectDatabaseCreate) {
512 conflictNextProjectDatabaseCreate = false;
513 return conflict("default database promotion in progress");
514 }
515 const previousDefault = Array.from(databases.values()).find(
516 (database) => database.project.id === projectId && database.isDefault,
517 );
518 const created = makeDatabase(projects.get(projectId)!, input);
519 if (staleNextProjectDatabaseObservation) {
520 staleNextProjectDatabaseObservation = false;
521 staleProjectReads = 1;
522 staleProjectDefaultRegion = previousDefault?.region?.id ?? null;
523 staleDatabaseLists = 1;
524 staleDatabases = Array.from(databases.values())
525 .filter((database) => database.project.id === projectId)
526 .map((database) => ({
527 ...database,
528 isDefault: database.id === previousDefault?.id,
529 }));
530 }
531 return data(toWireCreatedDatabase(created), { status: 201 });
532 }
533 }
534
535 if (segments.length === 3 && segments[1] === "databases") {
536 const id = segments[2]!;
537 if (request.method === "GET") {
538 calls.push(["getDatabase", id]);
539 const database = databases.get(id);
540 return database === undefined
541 ? notFound("not found")
542 : data(toWireDatabase(database));
543 }
544 if (request.method === "DELETE") {
545 calls.push(["deleteDatabase", id]);
546 databases.delete(id);
547 return json(null, { status: 204 });
548 }
549 }
550
551 if (
552 segments.length === 4 &&
553 segments[1] === "connections" &&
554 segments[3] === "rotate" &&
555 request.method === "POST"
556 ) {
557 const id = segments[2]!;
558 calls.push(["rotateConnection", id]);
559 const database = Array.from(databases.values()).find(
560 (candidate) => candidate.defaultConnectionId === id,
561 )!;
562 return data(apiConnection(database.id, id));
563 }
564
565 return unhandled(request);
566 });
567
568 return {
569 fake,
570 calls,
571 databases,
572 projects,
573 conflictNextProjectDatabaseCreate: () => {
574 conflictNextProjectDatabaseCreate = true;
575 },
576 staleNextProjectDatabaseObservation: () => {
577 staleNextProjectDatabaseObservation = true;
578 },
579 };
580};
581
582const foreignProject = apiProject("project-foreign", "app");
583const refusalCloud = makeProjectCloud([foreignProject]);
584const refusal = Test.make({
585 providers: projectLayer(refusalCloud.fake),
586});
587
588refusal.test.provider(
589 "Plan refuses cold adoption of a foreign Prisma project",
590 (stack) =>
591 Effect.gen(function* () {
592 refusalCloud.projects.clear();
593 refusalCloud.projects.set(foreignProject.id, foreignProject);
594 refusalCloud.calls.length = 0;
595 yield* stack.destroy();
596
597 const result = yield* stack
598 .deploy(
599 PrismaProject("Project", {
600 name: "app",
601 createDatabase: false,
602 }),
603 )
604 .pipe(Effect.result);
605
606 expect(Result.isFailure(result)).toBe(true);
607 if (Result.isFailure(result)) {
608 expect(String(result.failure)).toContain("Cannot adopt resource");
609 }
610 expect(refusalCloud.projects.get("project-foreign")?.name).toBe("app");
611 expect(refusalCloud.calls.map(([operation]) => operation)).not.toContain(
612 "updateProject",
613 );
614
615 yield* stack.destroy();
616 }),
617 {
618 tags: [
619 "unit",
620 "provider:prisma",
621 "provider:prisma:database",
622 "provider:prisma:project",
623 "local",
624 ],
625 },
626);
627
628const generatedProjectRecoveryCloud = makeProjectCloud();
629const generatedProjectRecovery = Test.make({
630 providers: projectLayer(generatedProjectRecoveryCloud.fake),
631});
632
633generatedProjectRecovery.test.provider(
634 "generated Project physical names recover as owned for the same instance",
635 (stack) =>
636 Effect.gen(function* () {
637 generatedProjectRecoveryCloud.projects.clear();
638 yield* stack.destroy();
639 const instanceId = "00000000000000000000000000000000";
640 const name = yield* createPhysicalName({ id: "Project" }).pipe(
641 Effect.provideService(InstanceId, instanceId),
642 );
643 generatedProjectRecoveryCloud.projects.set(
644 "project-generated",
645 apiProject("project-generated", name, null),
646 );
647 const provider = yield* Provider.findProvider(PrismaProject);
648 const observed = yield* provider.read!({
649 id: "Project",
650 instanceId,
651 olds: { createDatabase: false },
652 output: undefined,
653 } as never).pipe(Effect.provideService(InstanceId, instanceId));
654
655 expect(observed).toBeDefined();
656 expect(Unowned.is(observed!)).toBe(false);
657 expect((observed as PrismaProject["Attributes"]).projectName).toBe(name);
658 generatedProjectRecoveryCloud.databases.set("database-new-default", {
659 ...apiDatabase("database-new-default", {
660 projectId: "project-generated",
661 name: "new-default",
662 isDefault: true,
663 }),
664 connections: [],
665 });
666 const staleSecret = Redacted.make("postgres://old-project-secret");
667 const switched = yield* provider.read!({
668 id: "Project",
669 instanceId,
670 olds: { createDatabase: true },
671 output: {
672 ...(observed as PrismaProject["Attributes"]),
673 databaseId: "database-old-default",
674 directConnectionString: staleSecret,
675 pooledConnectionString: staleSecret,
676 accelerateConnectionString: staleSecret,
677 password: Redacted.make("old-password"),
678 },
679 } as never).pipe(Effect.provideService(InstanceId, instanceId));
680 expect((switched as PrismaProject["Attributes"]).databaseId).toBe(
681 "database-new-default",
682 );
683 expect(
684 (switched as PrismaProject["Attributes"]).directConnectionString,
685 ).toBeUndefined();
686 expect(
687 (switched as PrismaProject["Attributes"]).password,
688 ).toBeUndefined();
689 const cannotDropAdoptedDefault = yield* provider
690 .reconcile({
691 id: "Project",
692 instanceId,
693 news: { createDatabase: false },
694 olds: undefined,
695 output: switched,
696 bindings: [],
697 } as never)
698 .pipe(Effect.provideService(InstanceId, instanceId), Effect.result);
699 expect(Result.isFailure(cannotDropAdoptedDefault)).toBe(true);
700 if (Result.isFailure(cannotDropAdoptedDefault)) {
701 expect(String(cannotDropAdoptedDefault.failure)).toContain(
702 "cannot be removed in place",
703 );
704 }
705 generatedProjectRecoveryCloud.calls.length = 0;
706 const recoveredSecrets = yield* provider
707 .reconcile({
708 id: "Project",
709 instanceId,
710 news: { createDatabase: true },
711 olds: { createDatabase: true },
712 output: switched,
713 bindings: [],
714 } as never)
715 .pipe(Effect.provideService(InstanceId, instanceId));
716 expect(
717 Redacted.value(recoveredSecrets.directConnectionString!),
718 ).toContain("database-new-default");
719 expect(generatedProjectRecoveryCloud.calls).toContainEqual([
720 "rotateConnection",
721 "connection-database-new-default",
722 ]);
723 generatedProjectRecoveryCloud.projects.clear();
724 generatedProjectRecoveryCloud.databases.clear();
725 yield* stack.destroy();
726 }),
727 {
728 tags: [
729 "unit",
730 "provider:prisma",
731 "provider:prisma:database",
732 "provider:prisma:project",
733 "local",
734 ],
735 },
736);
737
738const adoptionCloud = makeProjectCloud([foreignProject]);
739const adoption = Test.make({
740 providers: projectLayer(adoptionCloud.fake),
741 adopt: true,
742});
743
744adoption.test.provider(
745 "Plan adopts explicitly and applies write-only project settings",
746 (stack) =>
747 Effect.gen(function* () {
748 adoptionCloud.projects.clear();
749 adoptionCloud.projects.set(foreignProject.id, foreignProject);
750 adoptionCloud.calls.length = 0;
751 yield* stack.destroy();
752
753 const project = yield* stack.deploy(
754 PrismaProject("Project", {
755 name: "app",
756 createDatabase: true,
757 region: "us-east-1",
758 settings: {},
759 }),
760 );
761
762 expect(project.projectId).toBe("project-foreign");
763 expect(adoptionCloud.calls).toContainEqual([
764 "updateProject",
765 { id: "project-foreign", input: { name: "app", settings: {} } },
766 ]);
767
768 yield* stack.destroy();
769 }),
770 {
771 tags: [
772 "unit",
773 "provider:prisma",
774 "provider:prisma:database",
775 "provider:prisma:project",
776 "local",
777 ],
778 },
779);
780
781const replacementCloud = makeProjectCloud();
782const replacement = Test.make({
783 providers: projectLayer(replacementCloud.fake),
784});
785
786replacement.test.provider(
787 "Apply refuses to replace a data-bearing default database for a region change",
788 (stack) =>
789 Effect.gen(function* () {
790 replacementCloud.projects.clear();
791 replacementCloud.calls.length = 0;
792 yield* stack.destroy();
793
794 const first = yield* stack.deploy(
795 PrismaProject("Project", {
796 name: "app",
797 createDatabase: true,
798 region: "us-east-1",
799 }),
800 );
801 replacementCloud.calls.length = 0;
802
803 const result = yield* stack
804 .deploy(
805 PrismaProject("Project", {
806 name: "app",
807 createDatabase: true,
808 region: "us-west-1" as const,
809 }),
810 )
811 .pipe(Effect.result);
812
813 expect(Result.isFailure(result)).toBe(true);
814 if (Result.isFailure(result)) {
815 expect(String(result.failure)).toContain("Cannot safely change");
816 expect(String(result.failure)).toContain("explicit data migration");
817 }
818 expect(first.projectId).toBeDefined();
819 const operations = replacementCloud.calls.map(([operation]) => operation);
820 expect(operations).not.toContain("createProjectDatabase");
821 expect(operations).not.toContain("deleteDatabase");
822 expect(operations).not.toContain("deleteProject");
823 expect(Array.from(replacementCloud.databases.values())).toHaveLength(1);
824 expect(
825 Array.from(replacementCloud.databases.values())[0]?.region?.id,
826 ).toBe("us-east-1");
827
828 yield* stack.destroy();
829 }),
830 {
831 tags: [
832 "unit",
833 "provider:prisma",
834 "provider:prisma:database",
835 "provider:prisma:project",
836 "local",
837 ],
838 },
839);
840
841const eventuallyConsistentRegionCloud = makeProjectCloud();
842const eventuallyConsistentRegion = Test.make({
843 providers: projectLayer(eventuallyConsistentRegionCloud.fake),
844});
845
846eventuallyConsistentRegion.test.provider(
847 "Project default database creation retries one stale observation",
848 (stack) =>
849 Effect.gen(function* () {
850 eventuallyConsistentRegionCloud.projects.clear();
851 eventuallyConsistentRegionCloud.databases.clear();
852 yield* stack.destroy();
853
854 const first = yield* stack.deploy(
855 PrismaProject("Project", {
856 name: "app",
857 createDatabase: false,
858 }),
859 );
860 eventuallyConsistentRegionCloud.calls.length = 0;
861 eventuallyConsistentRegionCloud.staleNextProjectDatabaseObservation();
862 const second = yield* stack.deploy(
863 PrismaProject("Project", {
864 name: "app",
865 createDatabase: true,
866 region: "us-west-1" as const,
867 }),
868 );
869
870 expect(second.projectId).toBe(first.projectId);
871 expect(second.defaultRegion).toBe("us-west-1");
872 expect(Redacted.value(second.directConnectionString!)).toContain(
873 second.databaseId!,
874 );
875 expect(
876 eventuallyConsistentRegionCloud.calls.filter(
877 ([operation]) => operation === "getProject",
878 ).length,
879 ).toBeGreaterThanOrEqual(3);
880 expect(
881 eventuallyConsistentRegionCloud.calls.filter(
882 ([operation]) => operation === "listProjectDatabases",
883 ).length,
884 ).toBeGreaterThanOrEqual(3);
885
886 yield* stack.destroy();
887 }),
888 {
889 tags: [
890 "unit",
891 "provider:prisma",
892 "provider:prisma:database",
893 "provider:prisma:project",
894 "local",
895 ],
896 },
897);
898
899const conflictingRegionCloud = makeProjectCloud();
900const conflictingRegion = Test.make({
901 providers: projectLayer(conflictingRegionCloud.fake),
902});
903
904conflictingRegion.test.provider(
905 "Project default database creation rejects a conflict without an observed default",
906 (stack) =>
907 Effect.gen(function* () {
908 conflictingRegionCloud.projects.clear();
909 conflictingRegionCloud.databases.clear();
910 yield* stack.destroy();
911
912 const first = yield* stack.deploy(
913 PrismaProject("Project", {
914 name: "app",
915 createDatabase: false,
916 }),
917 );
918 conflictingRegionCloud.calls.length = 0;
919 conflictingRegionCloud.conflictNextProjectDatabaseCreate();
920
921 const result = yield* stack
922 .deploy(
923 PrismaProject("Project", {
924 name: "app",
925 createDatabase: true,
926 region: "us-west-1" as const,
927 }),
928 )
929 .pipe(Effect.result);
930
931 expect(Result.isFailure(result)).toBe(true);
932 if (Result.isFailure(result)) {
933 expect(String(result.failure)).toContain(
934 "does not expose the requested default database",
935 );
936 }
937 expect(first.databaseId).toBeUndefined();
938 expect(conflictingRegionCloud.databases.size).toBe(0);
939 expect(
940 conflictingRegionCloud.calls.map(([operation]) => operation),
941 ).not.toContain("deleteDatabase");
942
943 conflictingRegionCloud.databases.clear();
944 yield* stack.destroy();
945 }),
946 {
947 tags: [
948 "unit",
949 "provider:prisma",
950 "provider:prisma:database",
951 "provider:prisma:project",
952 "local",
953 ],
954 },
955);
956
957const addDefaultCloud = makeProjectCloud();
958const addDefault = Test.make({
959 providers: projectLayer(addDefaultCloud.fake),
960});
961
962addDefault.test.provider(
963 "Apply adds a missing Project default database without replacing the Project",
964 (stack) =>
965 Effect.gen(function* () {
966 addDefaultCloud.projects.clear();
967 addDefaultCloud.databases.clear();
968 yield* stack.destroy();
969
970 const first = yield* stack.deploy(
971 PrismaProject("Project", {
972 name: "app",
973 createDatabase: false,
974 }),
975 );
976 addDefaultCloud.calls.length = 0;
977 const second = yield* stack.deploy(
978 PrismaProject("Project", {
979 name: "app",
980 createDatabase: true,
981 region: "us-west-1" as const,
982 }),
983 );
984
985 expect(second.projectId).toBe(first.projectId);
986 expect(second.defaultRegion).toBe("us-west-1");
987 expect(addDefaultCloud.databases.size).toBe(1);
988 expect(addDefaultCloud.calls.map(([operation]) => operation)).toContain(
989 "createProjectDatabase",
990 );
991 expect(
992 addDefaultCloud.calls.map(([operation]) => operation),
993 ).not.toContain("deleteProject");
994
995 yield* stack.destroy();
996 }),
997 {
998 tags: [
999 "unit",
1000 "provider:prisma",
1001 "provider:prisma:database",
1002 "provider:prisma:project",
1003 "local",
1004 ],
1005 },
1006);
1007
1008const removeDefaultCloud = makeProjectCloud();
1009const removeDefault = Test.make({
1010 providers: projectLayer(removeDefaultCloud.fake),
1011});
1012
1013removeDefault.test.provider(
1014 "Apply replaces a Project when removing its last default database",
1015 (stack) =>
1016 Effect.gen(function* () {
1017 removeDefaultCloud.projects.clear();
1018 removeDefaultCloud.databases.clear();
1019 yield* stack.destroy();
1020
1021 const first = yield* stack.deploy(
1022 PrismaProject("Project", {
1023 name: "app",
1024 createDatabase: true,
1025 }),
1026 );
1027 removeDefaultCloud.calls.length = 0;
1028 const second = yield* stack.deploy(
1029 PrismaProject("Project", {
1030 name: "app",
1031 createDatabase: false,
1032 }),
1033 );
1034
1035 expect(second.projectId).not.toBe(first.projectId);
1036 expect(second.databaseId).toBeUndefined();
1037 const operations = removeDefaultCloud.calls.map(
1038 ([operation]) => operation,
1039 );
1040 expect(operations.indexOf("deleteProject")).toBeLessThan(
1041 operations.indexOf("createProject"),
1042 );
1043
1044 yield* stack.destroy();
1045 }),
1046 {
1047 tags: [
1048 "unit",
1049 "provider:prisma",
1050 "provider:prisma:database",
1051 "provider:prisma:project",
1052 "local",
1053 ],
1054 },
1055);
1056
1057const apiDatabase = (
1058 id: string,
1059 input: {
1060 projectId: string;
1061 name?: string;
1062 region?: string;
1063 isDefault?: boolean;
1064 source?: ApiDatabase["source"];
1065 },
1066): ApiDatabase => ({
1067 id,
1068 type: "database",
1069 url: `https://api.prisma.test/v1/databases/${id}`,
1070 name: input.name ?? `database-${id}`,
1071 status: "ready",
1072 createdAt,
1073 isDefault: input.isDefault ?? false,
1074 defaultConnectionId: `connection-${id}`,
1075 connections: [apiConnection(id)],
1076 project: {
1077 id: input.projectId,
1078 url: `https://api.prisma.test/v1/projects/${input.projectId}`,
1079 name: "app",
1080 },
1081 region: { id: input.region ?? "us-east-1", name: "Region" },
1082 source: input.source ?? { type: "empty" },
1083 branchId: null,
1084});
1085
1086const makeDatabaseCloud = () => {
1087 const databases = new Map<string, ApiDatabase>();
1088 const calls: Array<[string, unknown?]> = [];
1089 let nextId = 1;
1090 // The same in-memory cloud, served over the wire for the Database resource.
1091 const fake = makeFakeManagementApi((request) => {
1092 const segments = request.pathname.split("/").filter((s) => s.length > 0);
1093
1094 if (request.pathname === "/v1/databases" && request.method === "GET") {
1095 return page(Array.from(databases.values()).map(toWireDatabase));
1096 }
1097 if (request.pathname === "/v1/databases" && request.method === "POST") {
1098 const input = request.bodyJson as {
1099 projectId: string;
1100 name?: string;
1101 region?: string;
1102 isDefault?: boolean;
1103 source?: ApiDatabase["source"];
1104 };
1105 calls.push(["createDatabase", input]);
1106 if (input.isDefault) {
1107 for (const [id, database] of databases) {
1108 if (database.project.id === input.projectId && database.isDefault) {
1109 databases.set(id, { ...database, isDefault: false });
1110 }
1111 }
1112 }
1113 const id = `database-${nextId++}`;
1114 const database = apiDatabase(id, input);
1115 databases.set(id, database);
1116 return data(toWireCreatedDatabase(database), { status: 201 });
1117 }
1118
1119 if (
1120 segments.length === 4 &&
1121 segments[1] === "projects" &&
1122 segments[3] === "databases" &&
1123 request.method === "GET"
1124 ) {
1125 const projectId = segments[2]!;
1126 return page(
1127 Array.from(databases.values())
1128 .filter((database) => database.project.id === projectId)
1129 .map(toWireDatabase),
1130 );
1131 }
1132
1133 if (segments.length === 3 && segments[1] === "databases") {
1134 const id = segments[2]!;
1135 if (request.method === "GET") {
1136 calls.push(["getDatabase", id]);
1137 const database = databases.get(id);
1138 return database === undefined
1139 ? notFound("not found")
1140 : data(toWireDatabase(database));
1141 }
1142 if (request.method === "PATCH") {
1143 const input = request.bodyJson as { name?: string };
1144 const database = databases.get(id)!;
1145 const updated = { ...database, name: input.name ?? database.name };
1146 databases.set(id, updated);
1147 return data(toWireDatabase(updated));
1148 }
1149 if (request.method === "DELETE") {
1150 calls.push(["deleteDatabase", id]);
1151 databases.delete(id);
1152 return json(null, { status: 204 });
1153 }
1154 }
1155
1156 if (
1157 segments.length === 4 &&
1158 segments[1] === "connections" &&
1159 segments[3] === "rotate" &&
1160 request.method === "POST"
1161 ) {
1162 const id = segments[2]!;
1163 calls.push(["rotateConnection", id]);
1164 const database = Array.from(databases.values()).find(
1165 (candidate) => candidate.defaultConnectionId === id,
1166 )!;
1167 return data(apiConnection(database.id, id));
1168 }
1169
1170 return unhandled(request);
1171 });
1172
1173 return { fake, calls, databases };
1174};
1175
1176const generatedDatabaseRecoveryCloud = makeDatabaseCloud();
1177const generatedDatabaseRecovery = Test.make({
1178 providers: databaseLayer(generatedDatabaseRecoveryCloud.fake),
1179});
1180
1181generatedDatabaseRecovery.test.provider(
1182 "unnamed Database physical names recover exactly without adoption",
1183 (stack) =>
1184 Effect.gen(function* () {
1185 generatedDatabaseRecoveryCloud.databases.clear();
1186 yield* stack.destroy();
1187 const instanceId = "00000000000000000000000000000000";
1188 const name = yield* createPhysicalName({ id: "Database" }).pipe(
1189 Effect.provideService(InstanceId, instanceId),
1190 );
1191 generatedDatabaseRecoveryCloud.databases.set("database-generated", {
1192 ...apiDatabase("database-generated", {
1193 projectId: "project-1",
1194 name,
1195 isDefault: false,
1196 }),
1197 connections: [],
1198 });
1199 const provider = yield* Provider.findProvider(PrismaDatabase);
1200 const observed = yield* provider.read!({
1201 id: "Database",
1202 instanceId,
1203 olds: { project: "project-1", isDefault: false },
1204 output: undefined,
1205 } as never).pipe(Effect.provideService(InstanceId, instanceId));
1206
1207 expect(observed).toBeDefined();
1208 expect(Unowned.is(observed!)).toBe(false);
1209 expect((observed as PrismaDatabase["Attributes"]).databaseName).toBe(
1210 name,
1211 );
1212 const localSecret = Redacted.make("postgres://local-dev-secret");
1213 const fromDev = yield* provider.read!({
1214 id: "Database",
1215 instanceId,
1216 olds: { project: "project-1", isDefault: false },
1217 output: {
1218 databaseId: "dev:database:Database",
1219 databaseName: "local",
1220 projectId: "dev:project:Project",
1221 status: "ready",
1222 region: null,
1223 isDefault: false,
1224 branchId: null,
1225 defaultConnectionId: null,
1226 createdAt,
1227 directConnectionString: localSecret,
1228 pooledConnectionString: localSecret,
1229 accelerateConnectionString: localSecret,
1230 host: "127.0.0.1",
1231 user: "postgres",
1232 password: Redacted.make("local-password"),
1233 },
1234 } as never).pipe(Effect.provideService(InstanceId, instanceId));
1235 expect((fromDev as PrismaDatabase["Attributes"]).databaseId).toBe(
1236 "database-generated",
1237 );
1238 expect(
1239 (fromDev as PrismaDatabase["Attributes"]).directConnectionString,
1240 ).toBeUndefined();
1241 expect(
1242 (fromDev as PrismaDatabase["Attributes"]).password,
1243 ).toBeUndefined();
1244
1245 generatedDatabaseRecoveryCloud.calls.length = 0;
1246 const recovered = yield* provider
1247 .reconcile({
1248 id: "Database",
1249 instanceId,
1250 news: { project: "project-1", isDefault: false },
1251 olds: { project: "project-1", isDefault: false },
1252 output: observed,
1253 bindings: [],
1254 } as never)
1255 .pipe(Effect.provideService(InstanceId, instanceId));
1256 expect(Redacted.value(recovered.directConnectionString!)).toContain(
1257 "database-generated",
1258 );
1259 expect(generatedDatabaseRecoveryCloud.calls).toContainEqual([
1260 "rotateConnection",
1261 "connection-database-generated",
1262 ]);
1263
1264 const explicit = {
1265 ...apiDatabase("database-explicit", {
1266 projectId: "project-1",
1267 name: "explicit",
1268 isDefault: false,
1269 }),
1270 connections: [],
1271 };
1272 generatedDatabaseRecoveryCloud.databases.set(explicit.id, explicit);
1273 const adoptionObserved = yield* provider.read!({
1274 id: "ExplicitDatabase",
1275 instanceId,
1276 olds: {
1277 project: "project-1",
1278 name: "explicit",
1279 isDefault: false,
1280 },
1281 output: undefined,
1282 } as never).pipe(Effect.provideService(InstanceId, instanceId));
1283 expect(Unowned.is(adoptionObserved!)).toBe(true);
1284 const wrongRegion = yield* provider
1285 .reconcile({
1286 id: "ExplicitDatabase",
1287 instanceId,
1288 news: {
1289 project: "project-1",
1290 name: "explicit",
1291 region: "us-west-1",
1292 isDefault: false,
1293 },
1294 olds: undefined,
1295 output: adoptionObserved,
1296 bindings: [],
1297 } as never)
1298 .pipe(Effect.provideService(InstanceId, instanceId), Effect.result);
1299 expect(Result.isFailure(wrongRegion)).toBe(true);
1300 if (Result.isFailure(wrongRegion)) {
1301 expect(String(wrongRegion.failure)).toContain("immutable region");
1302 }
1303 const cannotPromoteAdopted = yield* provider
1304 .reconcile({
1305 id: "ExplicitDatabase",
1306 instanceId,
1307 news: {
1308 project: "project-1",
1309 name: "explicit",
1310 isDefault: true,
1311 },
1312 olds: undefined,
1313 output: adoptionObserved,
1314 bindings: [],
1315 } as never)
1316 .pipe(Effect.provideService(InstanceId, instanceId), Effect.result);
1317 expect(Result.isFailure(cannotPromoteAdopted)).toBe(true);
1318 if (Result.isFailure(cannotPromoteAdopted)) {
1319 expect(String(cannotPromoteAdopted.failure)).toContain(
1320 "cannot manage a default database",
1321 );
1322 }
1323 const adopted = yield* provider
1324 .reconcile({
1325 id: "ExplicitDatabase",
1326 instanceId,
1327 news: {
1328 project: "project-1",
1329 name: "explicit",
1330 isDefault: false,
1331 },
1332 olds: undefined,
1333 output: adoptionObserved,
1334 bindings: [],
1335 } as never)
1336 .pipe(Effect.provideService(InstanceId, instanceId));
1337 expect(adopted.directConnectionString).toBeUndefined();
1338 expect(
1339 generatedDatabaseRecoveryCloud.calls.filter(
1340 ([operation, id]) =>
1341 operation === "rotateConnection" &&
1342 id === "connection-database-explicit",
1343 ),
1344 ).toEqual([]);
1345
1346 const adoptedWithRotation = yield* provider
1347 .reconcile({
1348 id: "ExplicitDatabase",
1349 instanceId,
1350 news: {
1351 project: "project-1",
1352 name: "explicit",
1353 isDefault: false,
1354 rotateCredentialsOnAdopt: true,
1355 },
1356 olds: undefined,
1357 output: adoptionObserved,
1358 bindings: [],
1359 } as never)
1360 .pipe(Effect.provideService(InstanceId, instanceId));
1361 expect(
1362 Redacted.value(adoptedWithRotation.directConnectionString!),
1363 ).toContain("database-explicit");
1364 expect(generatedDatabaseRecoveryCloud.calls).toContainEqual([
1365 "rotateConnection",
1366 "connection-database-explicit",
1367 ]);
1368 generatedDatabaseRecoveryCloud.databases.clear();
1369 yield* stack.destroy();
1370 }),
1371 { tags: ["unit", "provider:prisma", "provider:prisma:database", "local"] },
1372);
1373
1374it.effect(
1375 "refuses an undeletable standalone default database",
1376 () => {
1377 let created = false;
1378 const client = {
1379 createDatabase: () =>
1380 Effect.sync(() => {
1381 created = true;
1382 throw new Error("must fail before create");
1383 }),
1384 } as unknown as PrismaManagementClient;
1385
1386 return Effect.gen(function* () {
1387 const provider = yield* PrismaDatabase.Provider;
1388 const error = yield* provider
1389 .reconcile({
1390 id: "Database",
1391 instanceId: "00000000000000000000000000000000",
1392 news: {
1393 project: "project-1",
1394 name: "primary",
1395 region: "us-east-1",
1396 isDefault: true,
1397 },
1398 olds: undefined,
1399 output: undefined,
1400 bindings: [],
1401 } as never)
1402 .pipe(Effect.flip);
1403
1404 expect(String(error)).toContain("could never be destroyed");
1405 expect(created).toBe(false);
1406 }).pipe(
1407 Effect.provide(DatabaseProvider()),
1408 Effect.provide(Layer.succeed(PrismaClient, client)),
1409 Effect.provide(liveProviderContext),
1410 Effect.provide(testStackContext),
1411 Effect.provide(makeFakeManagementApi(unhandled).layer),
1412 Effect.provide(PlatformServices),
1413 );
1414 },
1415 { tags: ["unit", "provider:prisma", "provider:prisma:database", "local"] },
1416);
1417
1418const inheritedRegionCloud = makeDatabaseCloud();
1419const inheritedRegion = Test.make({
1420 providers: databaseLayer(inheritedRegionCloud.fake),
1421});
1422
1423inheritedRegion.test.provider(
1424 "Database region inherit is stable and follows the project default region",
1425 (stack) =>
1426 Effect.gen(function* () {
1427 inheritedRegionCloud.databases.clear();
1428 yield* stack.destroy();
1429 inheritedRegionCloud.databases.set(
1430 "project-default",
1431 apiDatabase("project-default", {
1432 projectId: "project-1",
1433 name: "project-default",
1434 region: "us-east-1",
1435 isDefault: true,
1436 }),
1437 );
1438
1439 const first = yield* stack.deploy(
1440 PrismaDatabase("Database", {
1441 project: "project-1",
1442 name: "inherited",
1443 region: "inherit",
1444 }),
1445 );
1446 expect(first.region).toBe("us-east-1");
1447
1448 inheritedRegionCloud.calls.length = 0;
1449 const second = yield* stack.deploy(
1450 PrismaDatabase("Database", {
1451 project: "project-1",
1452 name: "inherited",
1453 region: "inherit",
1454 }),
1455 );
1456 expect(second.databaseId).toBe(first.databaseId);
1457 expect(
1458 inheritedRegionCloud.calls.map(([operation]) => operation),
1459 ).not.toContain("createDatabase");
1460
1461 inheritedRegionCloud.databases.set("project-default", {
1462 ...inheritedRegionCloud.databases.get("project-default")!,
1463 region: { id: "us-west-1", name: "US West" },
1464 });
1465 inheritedRegionCloud.calls.length = 0;
1466 const moved = yield* stack.deploy(
1467 PrismaDatabase("Database", {
1468 project: "project-1",
1469 name: "inherited",
1470 region: "inherit",
1471 }),
1472 );
1473 expect(moved.databaseId).not.toBe(first.databaseId);
1474 expect(moved.region).toBe("us-west-1");
1475 expect(
1476 inheritedRegionCloud.calls.map(([operation]) => operation),
1477 ).toContain("createDatabase");
1478
1479 yield* stack.destroy();
1480 inheritedRegionCloud.databases.clear();
1481 }),
1482 { tags: ["unit", "provider:prisma", "provider:prisma:database", "local"] },
1483);
1484
1485const environmentVariable = {
1486 id: "env-1",
1487 type: "environment-variable" as const,
1488 url: "https://api.prisma.test/v1/environment-variables/env-1",
1489 projectId: "project-1",
1490 branchId: null,
1491 class: "production" as const,
1492 key: "TOKEN",
1493 valueKid: "kid-1",
1494 isManagedBySystem: false,
1495 createdAt,
1496 updatedAt: createdAt,
1497};
1498const environmentSecrets = new Map([[environmentVariable.id, "foreign"]]);
1499const environmentCalls: Array<[string, unknown?]> = [];
1500const environmentClient = {
1501 listEnvironmentVariables: () => Effect.succeed([environmentVariable]),
1502 getEnvironmentVariable: (id: string) =>
1503 Effect.succeed({ ...environmentVariable, id }),
1504 createEnvironmentVariable: () =>
1505 Effect.fail(
1506 new PrismaApiError({
1507 method: "POST",
1508 path: "/v1/environment-variables",
1509 status: 409,
1510 message: "already exists",
1511 }),
1512 ),
1513 updateEnvironmentVariable: (id: string, input: { value: string }) =>
1514 Effect.sync(() => {
1515 environmentCalls.push(["updateEnvironmentVariable", { id, input }]);
1516 environmentSecrets.set(id, input.value);
1517 return { ...environmentVariable, id };
1518 }),
1519 deleteEnvironmentVariable: (id: string) =>
1520 Effect.sync(() => {
1521 environmentSecrets.delete(id);
1522 }),
1523} as unknown as PrismaManagementClient;
1524const environmentAdoption = Test.make({
1525 providers: environmentVariableLayer(environmentClient),
1526 adopt: true,
1527});
1528
1529environmentAdoption.test.provider(
1530 "adoption and ordinary deploys always converge write-only environment secrets",
1531 (stack) =>
1532 Effect.gen(function* () {
1533 environmentSecrets.clear();
1534 environmentSecrets.set(environmentVariable.id, "foreign");
1535 environmentCalls.length = 0;
1536 yield* stack.destroy();
1537
1538 const deploy = () =>
1539 stack.deploy(
1540 PrismaEnvironmentVariable("Token", {
1541 project: "project-1",
1542 class: "production",
1543 key: "TOKEN",
1544 value: Redacted.make("desired"),
1545 }),
1546 );
1547
1548 yield* deploy();
1549 expect(environmentSecrets.get("env-1")).toBe("desired");
1550
1551 environmentSecrets.set("env-1", "externally-drifted");
1552 yield* deploy();
1553 expect(environmentSecrets.get("env-1")).toBe("desired");
1554 expect(
1555 environmentCalls.filter(
1556 ([operation]) => operation === "updateEnvironmentVariable",
1557 ),
1558 ).toHaveLength(2);
1559
1560 yield* stack.destroy();
1561 }),
1562 {
1563 tags: [
1564 "unit",
1565 "provider:prisma",
1566 "provider:prisma:environmentvariable",
1567 "local",
1568 ],
1569 },
1570);
1571
1572const customDomainCalls: Array<[string, unknown?]> = [];
1573const customDomainCloud = new Map<string, ApiCustomDomain>();
1574let nextCustomDomainId = 1;
1575const customDomainClient = {
1576 listAppDomains: (appId: string) =>
1577 Effect.sync(() => {
1578 customDomainCalls.push(["listAppDomains", appId]);
1579 return Array.from(customDomainCloud.values()).filter(
1580 (domain) => domain.appId === appId,
1581 );
1582 }),
1583 getCustomDomain: (id: string) =>
1584 Effect.suspend(() => {
1585 const domain = customDomainCloud.get(id);
1586 return domain
1587 ? Effect.succeed(domain)
1588 : Effect.fail(
1589 new PrismaApiError({
1590 method: "GET",
1591 path: `/v1/domains/${id}`,
1592 status: 404,
1593 message: "not found",
1594 }),
1595 );
1596 }),
1597 getApp: (appId: string) =>
1598 Effect.sync(() => {
1599 customDomainCalls.push(["getApp", appId]);
1600 return {
1601 id: appId,
1602 type: "app" as const,
1603 url: `https://api.prisma.test/v1/services/${appId}`,
1604 name: "api",
1605 region: { id: "us-east-1", name: "US East" },
1606 projectId: "project-1",
1607 branchId: "branch-1",
1608 latestDeploymentId: null,
1609 appEndpointDomain: "api.prisma.build",
1610 createdAt,
1611 };
1612 }),
1613 getBranch: (branchId: string) =>
1614 Effect.succeed({
1615 id: branchId,
1616 type: "branch" as const,
1617 url: `https://api.prisma.test/v1/branches/${branchId}`,
1618 gitName: "main",
1619 isDefault: true,
1620 role: "production" as const,
1621 createdAt,
1622 updatedAt: createdAt,
1623 project: {
1624 id: "project-1",
1625 url: "https://api.prisma.test/v1/projects/project-1",
1626 name: "app",
1627 },
1628 }),
1629 createAppDomain: (appId: string, input: { hostname: string }) =>
1630 Effect.sync(() => {
1631 customDomainCalls.push(["createAppDomain", { appId, input }]);
1632 const id = `domain-${nextCustomDomainId++}`;
1633 const domain: ApiCustomDomain = {
1634 id,
1635 type: "custom-domain" as const,
1636 url: `https://api.prisma.test/v1/domains/${id}`,
1637 hostname: input.hostname,
1638 appId,
1639 status: "pending_dns" as const,
1640 foundryStatus: "pending",
1641 failureReason: null,
1642 failureCategory: null,
1643 certExpiresAt: null,
1644 dnsRecords: [
1645 {
1646 type: "CNAME" as const,
1647 name: input.hostname,
1648 value: "api.prisma.build",
1649 ttl: null,
1650 },
1651 ],
1652 createdAt,
1653 updatedAt: createdAt,
1654 };
1655 customDomainCloud.set(id, domain);
1656 return { status: 201 as const, domain };
1657 }),
1658 retryCustomDomain: (id: string) =>
1659 Effect.sync(() => {
1660 customDomainCalls.push(["retryCustomDomain", id]);
1661 const domain = customDomainCloud.get(id)!;
1662 const retried: ApiCustomDomain = {
1663 ...domain,
1664 status: "verifying",
1665 foundryStatus: "provisioning",
1666 failureReason: null,
1667 failureCategory: null,
1668 updatedAt: createdAt,
1669 };
1670 customDomainCloud.set(id, retried);
1671 return retried;
1672 }),
1673 deleteCustomDomain: (id: string) =>
1674 Effect.sync(() => {
1675 customDomainCalls.push(["deleteCustomDomain", id]);
1676 customDomainCloud.delete(id);
1677 }),
1678} as unknown as PrismaManagementClient;
1679const customDomains = Test.make({
1680 providers: customDomainLayer(customDomainClient),
1681});
1682
1683customDomains.test.provider(
1684 "custom domains use the canonical App API and exact status fields",
1685 (stack) =>
1686 Effect.gen(function* () {
1687 customDomainCalls.length = 0;
1688 customDomainCloud.clear();
1689 nextCustomDomainId = 1;
1690 yield* stack.destroy();
1691
1692 const domain = yield* stack.deploy(
1693 PrismaCustomDomain("Domain", {
1694 app: {
1695 appId: Output.asOutput("app-1"),
1696 } as unknown as App,
1697 hostname: "NEW.EXAMPLE.COM.",
1698 }),
1699 );
1700
1701 expect(domain.appId).toBe("app-1");
1702 expect(domain.hostname).toBe("new.example.com");
1703 expect(domain.foundryStatus).toBe("pending");
1704 expect(customDomainCalls).toContainEqual(["listAppDomains", "app-1"]);
1705 expect(customDomainCalls).toContainEqual([
1706 "createAppDomain",
1707 { appId: "app-1", input: { hostname: "new.example.com" } },
1708 ]);
1709
1710 const replacement = domain;
1711
1712 customDomainCloud.set(replacement.customDomainId, {
1713 ...customDomainCloud.get(replacement.customDomainId)!,
1714 status: "failed",
1715 foundryStatus: "failed",
1716 failureReason: "DNS verification failed",
1717 failureCategory: "dns",
1718 });
1719 const provider = yield* Provider.findProvider(PrismaCustomDomain);
1720 const failed = yield* provider.read!({
1721 id: "Domain",
1722 instanceId: "00000000000000000000000000000000",
1723 olds: { app: "app-1", hostname: "new.example.com" },
1724 output: replacement,
1725 } as never);
1726 const failedDiff = yield* provider.diff!({
1727 id: "Domain",
1728 instanceId: "00000000000000000000000000000000",
1729 olds: { app: "app-1", hostname: "new.example.com" },
1730 news: { app: "app-1", hostname: "new.example.com" },
1731 output: failed,
1732 oldBindings: [],
1733 newBindings: [],
1734 } as never);
1735 expect(failedDiff).toEqual({ action: "update" });
1736 const activeDiff = yield* provider.diff!({
1737 id: "Domain",
1738 instanceId: "00000000000000000000000000000000",
1739 olds: { app: "app-1", hostname: "new.example.com" },
1740 news: { app: "app-1", hostname: "new.example.com" },
1741 output: { ...replacement, status: "active" },
1742 oldBindings: [],
1743 newBindings: [],
1744 } as never);
1745 expect(activeDiff).toBeUndefined();
1746 customDomainCalls.length = 0;
1747 const retried = yield* provider.reconcile({
1748 id: "Domain",
1749 instanceId: "00000000000000000000000000000000",
1750 news: { app: "app-1", hostname: "new.example.com" },
1751 olds: { app: "app-1", hostname: "new.example.com" },
1752 output: failed,
1753 bindings: [],
1754 } as never);
1755 expect(retried.customDomainId).toBe(replacement.customDomainId);
1756 expect(retried.status).toBe("verifying");
1757 expect(customDomainCalls).toContainEqual([
1758 "retryCustomDomain",
1759 replacement.customDomainId,
1760 ]);
1761 expect(
1762 customDomainCalls.filter(
1763 ([operation]) => operation === "retryCustomDomain",
1764 ),
1765 ).toHaveLength(1);
1766 const moveDiff = yield* provider.diff!({
1767 id: "Domain",
1768 instanceId: "00000000000000000000000000000000",
1769 olds: { app: "app-1", hostname: "new.example.com" },
1770 news: { app: "app-2", hostname: "new.example.com" },
1771 output: retried,
1772 oldBindings: [],
1773 newBindings: [],
1774 } as never).pipe(Effect.result);
1775 expect(Result.isFailure(moveDiff)).toBe(true);
1776 if (Result.isFailure(moveDiff)) {
1777 expect(String(moveDiff.failure)).toContain("cannot atomically replace");
1778 }
1779 const hostnameDiff = yield* provider.diff!({
1780 id: "Domain",
1781 instanceId: "00000000000000000000000000000000",
1782 olds: { app: "app-1", hostname: "new.example.com" },
1783 news: { app: "app-1", hostname: "other.example.com" },
1784 output: retried,
1785 oldBindings: [],
1786 newBindings: [],
1787 } as never).pipe(Effect.result);
1788 expect(Result.isFailure(hostnameDiff)).toBe(true);
1789
1790 const mismatch = yield* provider
1791 .reconcile({
1792 id: "Domain",
1793 instanceId: "00000000000000000000000000000000",
1794 news: { app: "app-2", hostname: "new.example.com" },
1795 olds: { app: "app-1", hostname: "new.example.com" },
1796 output: retried,
1797 bindings: [],
1798 } as never)
1799 .pipe(Effect.result);
1800 expect(Result.isFailure(mismatch)).toBe(true);
1801
1802 yield* stack.destroy();
1803 }),
1804 {
1805 tags: [
1806 "unit",
1807 "provider:prisma",
1808 "provider:prisma:app",
1809 "provider:prisma:customdomain",
1810 "local",
1811 ],
1812 },
1813);
1814
1815const sourceRepositoryCloud = new Map<string, ApiSourceRepository>();
1816const sourceRepositoryCalls: Array<[string, unknown?]> = [];
1817let nextSourceRepositoryId = 1;
1818const sourceRepositoryClient = {
1819 listProjects: () =>
1820 Effect.succeed([
1821 apiProject("project-1", "one", null),
1822 apiProject("project-2", "two", null),
1823 ]),
1824 listSourceRepositories: ({ projectId }: { projectId: string }) =>
1825 Effect.succeed(
1826 Array.from(sourceRepositoryCloud.values()).filter(
1827 (repository) =>
1828 repository.projectId === projectId && repository.status === "active",
1829 ),
1830 ),
1831 listApps: () => Effect.succeed([]),
1832 listProjectDatabases: () => Effect.succeed([]),
1833 listBranches: (projectId: string) =>
1834 Effect.succeed([
1835 {
1836 id: `branch-${projectId}`,
1837 type: "branch" as const,
1838 url: `https://api.prisma.test/v1/branches/branch-${projectId}`,
1839 gitName: "main",
1840 isDefault: true,
1841 role: "production" as const,
1842 createdAt,
1843 updatedAt: createdAt,
1844 project: {
1845 id: projectId,
1846 url: `https://api.prisma.test/v1/projects/${projectId}`,
1847 name: projectId,
1848 },
1849 },
1850 ]),
1851 getSourceRepository: (id: string) =>
1852 Effect.suspend(() => {
1853 const repository = sourceRepositoryCloud.get(id);
1854 return repository?.status === "active"
1855 ? Effect.succeed(repository)
1856 : Effect.fail(
1857 new PrismaApiError({
1858 method: "GET",
1859 path: `/v1/source-repositories/${id}`,
1860 status: 404,
1861 message: "not found",
1862 }),
1863 );
1864 }),
1865 createSourceRepository: (input: {
1866 projectId: string;
1867 provider: "github";
1868 providerRepositoryId: number;
1869 installationId?: string;
1870 }) =>
1871 Effect.suspend(() => {
1872 sourceRepositoryCalls.push(["createSourceRepository", input]);
1873 const conflict = Array.from(sourceRepositoryCloud.values()).some(
1874 (repository) =>
1875 repository.status === "active" &&
1876 (repository.projectId === input.projectId ||
1877 repository.repoId === input.providerRepositoryId),
1878 );
1879 if (conflict) {
1880 return Effect.fail(
1881 new PrismaApiError({
1882 method: "POST",
1883 path: "/v1/source-repositories",
1884 status: 409,
1885 message: "already linked",
1886 }),
1887 );
1888 }
1889 const id = `source-${nextSourceRepositoryId++}`;
1890 const repository: ApiSourceRepository = {
1891 id,
1892 type: "source-repository",
1893 url: `https://api.prisma.test/v1/source-repositories/${id}`,
1894 repoId: input.providerRepositoryId,
1895 provider: input.provider,
1896 repoFullName: `owner/repo-${input.providerRepositoryId}`,
1897 defaultBranch: "main",
1898 isPrivate: false,
1899 status: "active",
1900 projectId: input.projectId,
1901 installationId: input.installationId ?? "installation-auto",
1902 createdAt,
1903 updatedAt: createdAt,
1904 };
1905 sourceRepositoryCloud.set(id, repository);
1906 return Effect.succeed(repository);
1907 }),
1908 deleteSourceRepository: (id: string) =>
1909 Effect.sync(() => {
1910 sourceRepositoryCalls.push(["deleteSourceRepository", id]);
1911 const repository = sourceRepositoryCloud.get(id);
1912 if (repository) {
1913 sourceRepositoryCloud.set(id, {
1914 ...repository,
1915 status: "archived",
1916 });
1917 }
1918 }),
1919} as unknown as PrismaManagementClient;
1920const sourceRepositories = Test.make({
1921 providers: sourceRepositoryLayer(sourceRepositoryClient),
1922});
1923
1924sourceRepositories.test.provider(
1925 "source repository links reject non-atomic relinks without mutating the live link",
1926 (stack) =>
1927 Effect.gen(function* () {
1928 sourceRepositoryCloud.clear();
1929 sourceRepositoryCalls.length = 0;
1930 nextSourceRepositoryId = 1;
1931 yield* stack.destroy();
1932
1933 const first = yield* stack.deploy(
1934 PrismaSourceRepository("Repository", {
1935 project: "project-1",
1936 providerRepositoryId: 123,
1937 }),
1938 );
1939 sourceRepositoryCalls.length = 0;
1940 const relink = yield* stack
1941 .deploy(
1942 PrismaSourceRepository("Repository", {
1943 project: "project-2",
1944 providerRepositoryId: 456,
1945 }),
1946 )
1947 .pipe(Effect.result);
1948 expect(Result.isFailure(relink)).toBe(true);
1949 if (Result.isFailure(relink)) {
1950 expect(String(relink.failure)).toContain(
1951 "cannot be replaced atomically",
1952 );
1953 }
1954 expect(sourceRepositoryCalls).toEqual([]);
1955 expect(sourceRepositoryCloud.get(first.sourceRepositoryId)?.status).toBe(
1956 "active",
1957 );
1958
1959 const provider = yield* PrismaSourceRepository.Provider;
1960 const archivedDiff = yield* provider.diff!({
1961 id: "Repository",
1962 instanceId: "00000000000000000000000000000000",
1963 olds: { project: "project-1", providerRepositoryId: 123 },
1964 news: { project: "project-1", providerRepositoryId: 123 },
1965 output: { ...first, status: "archived" as const },
1966 oldBindings: [],
1967 newBindings: [],
1968 } as never).pipe(Effect.result);
1969 expect(Result.isFailure(archivedDiff)).toBe(true);
1970
1971 yield* stack.destroy();
1972 expect(sourceRepositoryCloud.get(first.sourceRepositoryId)?.status).toBe(
1973 "archived",
1974 );
1975 }),
1976 {
1977 tags: [
1978 "unit",
1979 "provider:prisma",
1980 "provider:prisma:project",
1981 "provider:prisma:sourcerepository",
1982 "local",
1983 ],
1984 },
1985);
1986
1987const branchCloud = new Map<string, ApiBranch>();
1988const branchCalls: Array<[string, unknown?]> = [];
1989let nextBranchId = 1;
1990const branchClient = {
1991 listProjects: () => Effect.succeed([apiProject("project-1", "app")]),
1992 listBranches: (_projectId: string, query?: { gitName?: string }) =>
1993 Effect.succeed(
1994 Array.from(branchCloud.values()).filter(
1995 (branch) =>
1996 query?.gitName === undefined || branch.gitName === query.gitName,
1997 ),
1998 ),
1999 getBranch: (id: string) => Effect.succeed(branchCloud.get(id)!),
2000 createBranch: (
2001 projectId: string,
2002 input: { gitName: string; isDefault?: boolean },
2003 ) =>
2004 Effect.sync(() => {
2005 branchCalls.push(["createBranch", { projectId, input }]);
2006 const first = branchCloud.size === 0;
2007 const makeDefault = first || input.isDefault === true;
2008 if (makeDefault) {
2009 for (const [id, branch] of branchCloud) {
2010 branchCloud.set(id, { ...branch, isDefault: false });
2011 }
2012 }
2013 const id = `branch-${nextBranchId++}`;
2014 const branch = {
2015 id,
2016 type: "branch" as const,
2017 url: `https://api.prisma.test/v1/branches/${id}`,
2018 gitName: input.gitName,
2019 isDefault: makeDefault,
2020 role: first ? ("production" as const) : ("preview" as const),
2021 createdAt,
2022 updatedAt: createdAt,
2023 project: {
2024 id: projectId,
2025 url: `https://api.prisma.test/v1/projects/${projectId}`,
2026 name: "app",
2027 },
2028 };
2029 branchCloud.set(id, branch);
2030 return branch;
2031 }),
2032 updateBranch: (id: string, input: { isDefault?: boolean | null }) =>
2033 Effect.sync(() => {
2034 branchCalls.push(["updateBranch", { id, input }]);
2035 if (input.isDefault !== true) {
2036 throw new Error("the Management API rejects default demotion");
2037 }
2038 for (const [branchId, branch] of branchCloud) {
2039 branchCloud.set(branchId, {
2040 ...branch,
2041 isDefault: branchId === id,
2042 });
2043 }
2044 return branchCloud.get(id)!;
2045 }),
2046 deleteBranch: (id: string) =>
2047 Effect.sync(() => {
2048 branchCalls.push(["deleteBranch", id]);
2049 branchCloud.delete(id);
2050 }),
2051} as unknown as PrismaManagementClient;
2052
2053// The same in-memory branch cloud, served over the wire.
2054const branchFake = makeFakeManagementApi((request) => {
2055 const segments = request.pathname.split("/").filter((s) => s.length > 0);
2056
2057 if (request.pathname === "/v1/projects" && request.method === "GET") {
2058 return page([toWireProject(apiProject("project-1", "app"))]);
2059 }
2060
2061 if (
2062 segments.length === 4 &&
2063 segments[1] === "projects" &&
2064 segments[3] === "branches"
2065 ) {
2066 const projectId = segments[2]!;
2067 if (request.method === "GET") {
2068 const gitName = request.search.includes("gitName=")
2069 ? new URLSearchParams(request.search).get("gitName")
2070 : null;
2071 return page(
2072 Array.from(branchCloud.values()).filter(
2073 (branch) => gitName === null || branch.gitName === gitName,
2074 ),
2075 );
2076 }
2077 if (request.method === "POST") {
2078 const input = request.bodyJson as {
2079 gitName: string;
2080 isDefault?: boolean;
2081 };
2082 branchCalls.push(["createBranch", { projectId, input }]);
2083 const first = branchCloud.size === 0;
2084 const makeDefault = first || input.isDefault === true;
2085 if (makeDefault) {
2086 for (const [id, branch] of branchCloud) {
2087 branchCloud.set(id, { ...branch, isDefault: false });
2088 }
2089 }
2090 const id = `branch-${nextBranchId++}`;
2091 const branch = {
2092 id,
2093 type: "branch" as const,
2094 url: `https://api.prisma.test/v1/branches/${id}`,
2095 gitName: input.gitName,
2096 isDefault: makeDefault,
2097 role: first ? ("production" as const) : ("preview" as const),
2098 createdAt,
2099 updatedAt: createdAt,
2100 project: {
2101 id: projectId,
2102 url: `https://api.prisma.test/v1/projects/${projectId}`,
2103 name: "app",
2104 },
2105 };
2106 branchCloud.set(id, branch);
2107 return data(branch, { status: 201 });
2108 }
2109 }
2110
2111 if (segments.length === 3 && segments[1] === "branches") {
2112 const id = segments[2]!;
2113 if (request.method === "GET") {
2114 const branch = branchCloud.get(id);
2115 return branch === undefined ? notFound("not found") : data(branch);
2116 }
2117 if (request.method === "PATCH") {
2118 const input = request.bodyJson as { isDefault?: boolean | null };
2119 branchCalls.push(["updateBranch", { id, input }]);
2120 if (input.isDefault !== true) {
2121 throw new Error("the Management API rejects default demotion");
2122 }
2123 for (const [branchId, branch] of branchCloud) {
2124 branchCloud.set(branchId, { ...branch, isDefault: branchId === id });
2125 }
2126 return data(branchCloud.get(id)!);
2127 }
2128 if (request.method === "DELETE") {
2129 branchCalls.push(["deleteBranch", id]);
2130 branchCloud.delete(id);
2131 return json(null, { status: 204 });
2132 }
2133 }
2134
2135 return unhandled(request);
2136});
2137
2138const branches = Test.make({
2139 providers: branchLayer(branchFake),
2140});
2141
2142branches.test.provider(
2143 "branch promotion persists and restores the displaced default on destroy",
2144 (stack) =>
2145 Effect.gen(function* () {
2146 branchCloud.clear();
2147 branchCalls.length = 0;
2148 nextBranchId = 1;
2149 yield* stack.destroy();
2150
2151 const main: ApiBranch = {
2152 id: "branch-main",
2153 type: "branch",
2154 url: "https://api.prisma.test/v1/branches/branch-main",
2155 gitName: "main",
2156 isDefault: true,
2157 role: "production",
2158 createdAt,
2159 updatedAt: createdAt,
2160 project: {
2161 id: "project-1",
2162 url: "https://api.prisma.test/v1/projects/project-1",
2163 name: "app",
2164 },
2165 };
2166 branchCloud.set(main.id, main);
2167
2168 const preview = yield* stack.deploy(
2169 PrismaBranch("Preview", {
2170 project: "project-1",
2171 gitName: "preview",
2172 isDefault: true,
2173 }),
2174 );
2175 expect(preview.isDefault).toBe(true);
2176 expect(preview.role).toBe("preview");
2177 expect(preview.previousDefaultBranchId).toBe(main.id);
2178 expect(branchCloud.get(main.id)?.isDefault).toBe(false);
2179 expect(branchCloud.get(main.id)?.role).toBe("production");
2180
2181 const provider = yield* Provider.findProvider(PrismaBranch);
2182 const alreadyDefaultDiff = yield* provider.diff!({
2183 id: "Preview",
2184 instanceId: "00000000000000000000000000000000",
2185 olds: {
2186 project: "project-1",
2187 gitName: "preview",
2188 isDefault: true,
2189 },
2190 news: {
2191 project: "project-1",
2192 gitName: "preview",
2193 isDefault: true,
2194 },
2195 output: preview,
2196 oldBindings: [],
2197 newBindings: [],
2198 } as never);
2199 expect(alreadyDefaultDiff).toBeUndefined();
2200
2201 // A different API client can promote another branch, atomically
2202 // demoting the desired default. The next deploy must observe and heal it
2203 // even though desired props did not change.
2204 branchCloud.set(main.id, {
2205 ...branchCloud.get(main.id)!,
2206 isDefault: true,
2207 });
2208 branchCloud.set(preview.branchId, {
2209 ...branchCloud.get(preview.branchId)!,
2210 isDefault: false,
2211 });
2212 branchCalls.length = 0;
2213 const observed = yield* provider.read!({
2214 id: "Preview",
2215 instanceId: "00000000000000000000000000000000",
2216 olds: {
2217 project: "project-1",
2218 gitName: "preview",
2219 isDefault: true,
2220 },
2221 output: preview,
2222 } as never);
2223 const driftDiff = yield* provider.diff!({
2224 id: "Preview",
2225 instanceId: "00000000000000000000000000000000",
2226 olds: {
2227 project: "project-1",
2228 gitName: "preview",
2229 isDefault: true,
2230 },
2231 news: {
2232 project: "project-1",
2233 gitName: "preview",
2234 isDefault: true,
2235 },
2236 output: observed,
2237 oldBindings: [],
2238 newBindings: [],
2239 } as never);
2240 expect(driftDiff).toEqual({ action: "update" });
2241 yield* provider.reconcile({
2242 id: "Preview",
2243 instanceId: "00000000000000000000000000000000",
2244 news: {
2245 project: "project-1",
2246 gitName: "preview",
2247 isDefault: true,
2248 },
2249 olds: {
2250 project: "project-1",
2251 gitName: "preview",
2252 isDefault: true,
2253 },
2254 output: observed,
2255 bindings: [],
2256 } as never);
2257 expect(branchCloud.get(preview.branchId)?.isDefault).toBe(true);
2258 expect(branchCalls).toContainEqual([
2259 "updateBranch",
2260 { id: preview.branchId, input: { isDefault: true } },
2261 ]);
2262 expect(yield* provider.list()).toEqual([]);
2263 branchCalls.length = 0;
2264 yield* stack.destroy();
2265 expect(branchCloud.get(main.id)?.isDefault).toBe(true);
2266 expect(branchCloud.has(preview.branchId)).toBe(false);
2267 expect(branchCalls).toContainEqual([
2268 "updateBranch",
2269 { id: main.id, input: { isDefault: true } },
2270 ]);
2271 expect(branchCalls).toContainEqual(["deleteBranch", preview.branchId]);
2272
2273 branchCloud.clear();
2274 branchCalls.length = 0;
2275 const firstBranch = yield* provider
2276 .reconcile({
2277 id: "UnsafeFirst",
2278 instanceId: "00000000000000000000000000000000",
2279 news: {
2280 project: "project-1",
2281 gitName: "main",
2282 isDefault: false,
2283 },
2284 olds: undefined,
2285 output: undefined,
2286 bindings: [],
2287 } as never)
2288 .pipe(Effect.result);
2289 expect(Result.isFailure(firstBranch)).toBe(true);
2290 if (Result.isFailure(firstBranch)) {
2291 expect(String(firstBranch.failure)).toContain(
2292 "undeletable production branch",
2293 );
2294 }
2295 expect(branchCalls.map(([operation]) => operation)).not.toContain(
2296 "createBranch",
2297 );
2298 }),
2299 {
2300 tags: [
2301 "unit",
2302 "provider:prisma",
2303 "provider:prisma:branch",
2304 "provider:prisma:project",
2305 "local",
2306 ],
2307 },
2308);