1import * as Prisma from "@/Prisma";
2import * as Provider from "@/Provider";
3import * as Stack from "@/Stack";
4import { State } from "@/State";
5import * as Test from "@/Test/Alchemy";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Result from "effect/Result";
11
12const { test } = Test.make({ providers: Prisma.providers() });
13
14const CONFIG_SOURCE = `
15import { defineConfig as ormConfig } from "@prisma/orm-postgres/config";
16import { definePrismaConfig } from "prisma/config";
17
18export default definePrismaConfig({
19 orm: ormConfig({
20 contract: "./contract.prisma",
21 output: "./generated",
22 }),
23});
24`;
25
26const CONTRACT_SOURCE = `// use prisma-8
27model User {
28 id Int @id @default(autoincrement())
29 email String @unique
30 name String?
31}
32`;
33
34const DRIFTED_CONTRACT_SOURCE =
35 CONTRACT_SOURCE +
36 `
37model Post {
38 id Int @id @default(autoincrement())
39 title String
40}
41`;
42
43// Making \`name\` required forces a data transform on existing rows, which
44// Prisma renders as unfilled placeholder(...) closures.
45const PLACEHOLDER_CONTRACT_SOURCE = CONTRACT_SOURCE.replace(
46 "name String?",
47 "name String",
48);
49
50const TS_CONFIG_SOURCE = `
51import { defineConfig as ormConfig } from "@prisma/orm-postgres/config";
52import { definePrismaConfig } from "prisma/config";
53
54export default definePrismaConfig({
55 orm: ormConfig({
56 contract: "./contract.ts",
57 output: "./generated",
58 }),
59});
60`;
61
62// The TypeScript-authored form (Prisma 8's headline direction): the same
63// contract as CONTRACT_SOURCE, via the defineContract builder DSL.
64const TS_CONTRACT_SOURCE = `
65import { defineContract } from "@prisma/orm-postgres/contract-builder";
66
67export const contract = defineContract({}, ({ field, model }) => ({
68 models: {
69 User: model("User", {
70 fields: {
71 id: field.id.uuidv7String(),
72 email: field.text().unique(),
73 name: field.text().optional(),
74 },
75 }),
76 },
77}));
78`;
79
80const stageWorkspace = (contractSource: string) =>
81 Effect.gen(function* () {
82 const fs = yield* FileSystem.FileSystem;
83 const path = yield* Path.Path;
84 // Stage inside the repo (not the OS temp dir): prisma.config.ts
85 // imports `@prisma/orm-postgres/config`, and bun resolves that bare
86 // import by walking up from the config file — which only finds
87 // `node_modules` when the staging dir lives in the workspace.
88 const cwd = yield* Effect.sync(() => process.cwd());
89 const tempParent = path.join(cwd, ".alchemy", "tmp");
90 yield* fs.makeDirectory(tempParent, { recursive: true });
91 const root = yield* fs.makeTempDirectory({
92 directory: tempParent,
93 prefix: "alchemy-prisma-contract-test-",
94 });
95 yield* fs.writeFileString(
96 path.join(root, "prisma.config.ts"),
97 CONFIG_SOURCE,
98 );
99 const contractPath = path.join(root, "contract.prisma");
100 yield* fs.writeFileString(contractPath, contractSource);
101 return {
102 root,
103 contractPath,
104 configPath: path.join(root, "prisma.config.ts"),
105 migrationsDir: path.join(root, "migrations"),
106 };
107 });
108
109const readPackageDirs = (migrationsDir: string) =>
110 Effect.gen(function* () {
111 const fs = yield* FileSystem.FileSystem;
112 const path = yield* Path.Path;
113 const appDir = path.join(migrationsDir, "app");
114 const exists = yield* fs.exists(appDir);
115 if (!exists) return [] as string[];
116 return (yield* fs.readDirectory(appDir)).sort();
117 });
118
119const readPackageMeta = (migrationsDir: string, dirName: string) =>
120 Effect.gen(function* () {
121 const fs = yield* FileSystem.FileSystem;
122 const path = yield* Path.Path;
123 const text = yield* fs.readFileString(
124 path.join(migrationsDir, "app", dirName, "migration.json"),
125 );
126 return JSON.parse(text) as {
127 from: string | null;
128 to: string;
129 migrationHash: string;
130 };
131 });
132
133const getStatus = Effect.fn(function* (fqn: string) {
134 const state = yield* yield* State;
135 const stk = yield* Stack.Stack;
136 const s = yield* state.get({ stack: stk.name, stage: stk.stage, fqn });
137 return s?.status;
138});
139
140test.provider(
141 "initial deploy emits the contract and plans the first migration",
142 (stack) =>
143 Effect.gen(function* () {
144 const fs = yield* FileSystem.FileSystem;
145 const path = yield* Path.Path;
146 const ws = yield* stageWorkspace(CONTRACT_SOURCE);
147
148 const contract = yield* stack.deploy(
149 Prisma.Contract("app-contract", { config: ws.configPath }),
150 );
151
152 // Emitted artifacts land in the config's output dir.
153 expect(
154 yield* fs.exists(path.join(ws.root, "generated", "contract.json")),
155 ).toBe(true);
156 expect(
157 yield* fs.exists(path.join(ws.root, "generated", "contract.d.ts")),
158 ).toBe(true);
159
160 // One migration package planned from the empty contract to the head.
161 const dirs = yield* readPackageDirs(ws.migrationsDir);
162 expect(dirs).toHaveLength(1);
163 const meta = yield* readPackageMeta(ws.migrationsDir, dirs[0]!);
164 expect(meta.from).toBeNull();
165 expect(meta.to).toEqual(contract.contractHash);
166 expect(contract.migrations).toEqual(dirs);
167 }),
168 { tags: ["provider:prisma", "provider:prisma:orm", "live"] },
169);
170
171test.provider(
172 "repeated deploys with no contract drift stay noop, not update (spurious updates would cascade into Prisma.Migrate)",
173 (stack) =>
174 Effect.gen(function* () {
175 const ws = yield* stageWorkspace(CONTRACT_SOURCE);
176
177 yield* stack.deploy(
178 Prisma.Contract("app-contract", { config: ws.configPath }),
179 );
180 expect(yield* getStatus("app-contract")).toEqual("created");
181
182 yield* stack.deploy(
183 Prisma.Contract("app-contract", { config: ws.configPath }),
184 );
185 expect(yield* getStatus("app-contract")).toEqual("created");
186
187 // No duplicate package was planned (`migration plan` without an
188 // explicit --from would happily re-plan from empty every run).
189 expect(yield* readPackageDirs(ws.migrationsDir)).toHaveLength(1);
190 }),
191 { tags: ["provider:prisma", "provider:prisma:orm", "live"] },
192);
193
194test.provider(
195 "deploy after a real contract change updates the resource",
196 (stack) =>
197 Effect.gen(function* () {
198 const fs = yield* FileSystem.FileSystem;
199 const ws = yield* stageWorkspace(CONTRACT_SOURCE);
200
201 const initial = yield* stack.deploy(
202 Prisma.Contract("app-contract", { config: ws.configPath }),
203 );
204 const [initialDir] = yield* readPackageDirs(ws.migrationsDir);
205
206 yield* fs.writeFileString(ws.contractPath, DRIFTED_CONTRACT_SOURCE);
207 yield* Effect.sleep("1 second");
208
209 const drifted = yield* stack.deploy(
210 Prisma.Contract("app-contract", { config: ws.configPath }),
211 );
212
213 expect(yield* getStatus("app-contract")).toEqual("updated");
214 expect(drifted.contractHash).not.toEqual(initial.contractHash);
215
216 // The new package chains from the previous head.
217 const dirs = yield* readPackageDirs(ws.migrationsDir);
218 expect(dirs).toHaveLength(2);
219 const initialMeta = yield* readPackageMeta(ws.migrationsDir, initialDir!);
220 const newDir = dirs.find((dir) => dir !== initialDir);
221 const newMeta = yield* readPackageMeta(ws.migrationsDir, newDir!);
222 expect(newMeta.from).toEqual(initialMeta.to);
223 expect(newMeta.to).toEqual(drifted.contractHash);
224 }),
225 { tags: ["provider:prisma", "provider:prisma:orm", "live"] },
226);
227
228test.provider(
229 "TypeScript-authored contracts emit, plan, and get resolvable types",
230 (stack) =>
231 Effect.gen(function* () {
232 const fs = yield* FileSystem.FileSystem;
233 const path = yield* Path.Path;
234 const ws = yield* stageWorkspace(CONTRACT_SOURCE);
235 // Swap in the TS-authored form of the same workspace.
236 yield* fs.remove(ws.contractPath);
237 yield* fs.writeFileString(
238 path.join(ws.root, "prisma.config.ts"),
239 TS_CONFIG_SOURCE,
240 );
241 yield* fs.writeFileString(
242 path.join(ws.root, "contract.ts"),
243 TS_CONTRACT_SOURCE,
244 );
245
246 const contract = yield* stack.deploy(
247 Prisma.Contract("ts-contract", { config: ws.configPath }),
248 );
249
250 const dirs = yield* readPackageDirs(ws.migrationsDir);
251 expect(dirs).toHaveLength(1);
252 expect((yield* readPackageMeta(ws.migrationsDir, dirs[0]!)).to).toEqual(
253 contract.contractHash,
254 );
255
256 // The CLI emits unpublished @internal/* specifiers for TS-authored
257 // contracts; the resource must rewrite them to the public subpaths or
258 // the emitted types cannot resolve in a user project.
259 const dts = yield* fs.readFileString(
260 path.join(ws.root, "generated", "contract.d.ts"),
261 );
262 expect(dts).not.toContain("@internal/");
263 expect(dts).toContain("@prisma/orm-postgres/");
264 }),
265 { tags: ["provider:prisma", "provider:prisma:orm", "live"] },
266);
267
268test.provider(
269 "list returns [] (non-listable local build artifact)",
270 (stack) =>
271 Effect.gen(function* () {
272 yield* stack.destroy();
273
274 const provider = yield* Provider.findProvider(Prisma.Contract);
275 const all = yield* provider.list();
276 expect(all).toEqual([]);
277
278 yield* stack.destroy();
279 }),
280 { tags: ["provider:prisma", "provider:prisma:orm", "live"] },
281);
282
283test.provider(
284 "refuses plans with unfilled placeholders (data transforms need a human decision)",
285 (stack) =>
286 Effect.gen(function* () {
287 const fs = yield* FileSystem.FileSystem;
288 const ws = yield* stageWorkspace(CONTRACT_SOURCE);
289
290 yield* stack.deploy(
291 Prisma.Contract("app-contract", { config: ws.configPath }),
292 );
293
294 // name String? -> String needs a backfill for existing NULL rows; the
295 // planned SQL runs against a real database later in the same deploy,
296 // so the resource must fail with guidance instead of deciding.
297 yield* fs.writeFileString(ws.contractPath, PLACEHOLDER_CONTRACT_SOURCE);
298 yield* Effect.sleep("1 second");
299
300 const result = yield* Effect.result(
301 stack.deploy(
302 Prisma.Contract("app-contract", { config: ws.configPath }),
303 ),
304 );
305
306 expect(Result.isFailure(result)).toBe(true);
307 if (Result.isFailure(result)) {
308 expect(String(result.failure)).toContain("placeholder");
309 expect(String(result.failure)).toContain("migration.ts");
310 }
311 }),
312 { tags: ["provider:prisma", "provider:prisma:orm", "live"] },
313);