1import * as Neon from "@/Neon";
2import * as Prisma from "@/Prisma";
3import * as SQL from "@/SQL/Postgres.ts";
4import * as Stack from "@/Stack";
5import { State } from "@/State";
6import * as Test from "@/Test/Alchemy";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Path from "effect/Path";
12import * as Redacted from "effect/Redacted";
13
14const { test } = Test.make({
15 providers: Layer.mergeAll(Prisma.providers(), Neon.providers()),
16});
17
18const HOOK_TIMEOUT = 300_000;
19
20const CONFIG_SOURCE = `
21import { defineConfig as ormConfig } from "@prisma/orm-postgres/config";
22import { definePrismaConfig } from "prisma/config";
23
24export default definePrismaConfig({
25 orm: ormConfig({
26 contract: "./contract.prisma",
27 output: "./generated",
28 }),
29});
30`;
31
32const CONTRACT_SOURCE = `model User {
33 id Int @id @default(autoincrement())
34 email String @unique
35 name String?
36}
37`;
38
39const DRIFTED_CONTRACT_SOURCE =
40 CONTRACT_SOURCE +
41 `
42model Post {
43 id Int @id @default(autoincrement())
44 title String
45}
46`;
47
48const stageWorkspace = Effect.gen(function* () {
49 const fs = yield* FileSystem.FileSystem;
50 const path = yield* Path.Path;
51 // Stage inside the repo (not the OS temp dir): prisma.config.ts
52 // imports `@prisma/orm-postgres/config`, resolved by walking up from the
53 // config file to the workspace node_modules.
54 const cwd = yield* Effect.sync(() => process.cwd());
55 const tempParent = path.join(cwd, ".alchemy", "tmp");
56 yield* fs.makeDirectory(tempParent, { recursive: true });
57 const root = yield* fs.makeTempDirectory({
58 directory: tempParent,
59 prefix: "alchemy-prisma-migrate-test-",
60 });
61 yield* fs.writeFileString(path.join(root, "prisma.config.ts"), CONFIG_SOURCE);
62 const contractPath = path.join(root, "contract.prisma");
63 yield* fs.writeFileString(contractPath, CONTRACT_SOURCE);
64 return {
65 root,
66 contractPath,
67 configPath: path.join(root, "prisma.config.ts"),
68 };
69});
70
71/** Out-of-band observation of the migrated database via a raw pg client. */
72const queryTables = (url: string) =>
73 Effect.scoped(
74 Effect.gen(function* () {
75 const sql = yield* SQL.Postgres({ url: Redacted.make(url) });
76 const rows = yield* sql<{ table_name: string }>`
77 select table_name from information_schema.tables
78 where table_schema = 'public'
79 `;
80 return rows.map((row) => row.table_name).sort();
81 }),
82 );
83
84const queryMarker = (url: string) =>
85 Effect.scoped(
86 Effect.gen(function* () {
87 const sql = yield* SQL.Postgres({ url: Redacted.make(url) });
88 const rows = yield* sql<{ core_hash: string }>`
89 select core_hash from prisma_contract.marker where space = 'app'
90 `;
91 return rows[0]?.core_hash;
92 }),
93 );
94
95const getStatus = Effect.fn(function* (fqn: string) {
96 const state = yield* yield* State;
97 const stk = yield* Stack.Stack;
98 const s = yield* state.get({ stack: stk.name, stage: stk.stage, fqn });
99 return s?.status;
100});
101
102test.provider(
103 "applies planned migrations to a Neon branch and converges",
104 (stack) =>
105 Effect.gen(function* () {
106 const fs = yield* FileSystem.FileSystem;
107 const ws = yield* stageWorkspace;
108
109 yield* stack.destroy();
110
111 const deployStack = () =>
112 stack.deploy(
113 Effect.gen(function* () {
114 const contract = yield* Prisma.Contract("app-contract", {
115 config: ws.configPath,
116 });
117 const project = yield* Neon.Project("PrismaMigrateProject");
118 const branch = yield* Neon.Branch("PrismaMigrateBranch", {
119 project,
120 });
121 const migrate = yield* Prisma.Migrate("app-migrate", {
122 url: branch.connectionUri,
123 contract,
124 });
125 return { contract, branch, migrate };
126 }),
127 );
128
129 // 1. Greenfield: plans the initial package and bootstraps the database.
130 const initial = yield* deployStack();
131 expect(initial.migrate.markerHash).toEqual(initial.contract.contractHash);
132
133 const tables = yield* queryTables(initial.branch.connectionUri);
134 expect(tables).toContain("user");
135 expect(tables).not.toContain("post");
136 expect(yield* queryMarker(initial.branch.connectionUri)).toEqual(
137 initial.contract.contractHash,
138 );
139
140 // 2. Re-deploy with no drift: both resources are noops.
141 yield* deployStack();
142 expect(yield* getStatus("app-contract")).toEqual("created");
143 expect(yield* getStatus("app-migrate")).toEqual("created");
144
145 // 3. Contract drift: a new package is planned and applied in one deploy.
146 yield* fs.writeFileString(ws.contractPath, DRIFTED_CONTRACT_SOURCE);
147 yield* Effect.sleep("1 second");
148
149 const drifted = yield* deployStack();
150 expect(yield* getStatus("app-migrate")).toEqual("updated");
151 expect(drifted.migrate.markerHash).toEqual(drifted.contract.contractHash);
152 expect(drifted.migrate.markerHash).not.toEqual(
153 initial.migrate.markerHash,
154 );
155
156 const driftedTables = yield* queryTables(drifted.branch.connectionUri);
157 expect(driftedTables).toContain("user");
158 expect(driftedTables).toContain("post");
159 expect(yield* queryMarker(drifted.branch.connectionUri)).toEqual(
160 drifted.contract.contractHash,
161 );
162
163 // 4. Destroy never unwinds the database: the branch is deleted with the
164 // stack (Neon owns it), but Migrate's delete itself is a no-op — pinned
165 // here by the destroy completing even though tables exist.
166 yield* stack.destroy();
167 }),
168 {
169 tags: [
170 "provider:neon",
171 "provider:neon:branch",
172 "provider:neon:project",
173 "provider:prisma",
174 "provider:prisma:orm",
175 "live",
176 ],
177 timeout: HOOK_TIMEOUT,
178 },
179);