1import { generate } from "@/Prisma/ORM/Generate.ts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { expect, layer } from "alchemy-test";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as Result from "effect/Result";
8import * as Schema from "effect/Schema";
9import * as Schedule from "effect/Schedule";
10import * as Stream from "effect/Stream";
11import { ChildProcess } from "effect/process";
12
13const describe = layer(NodeServices.layer, { excludeTestServices: true });
14
15describe("Effect contract generation", (it) => {
16 it.effect(
17 "emits deterministic PSL bindings, validates rows, and removes disabled outputs",
18 () =>
19 Effect.gen(function* () {
20 const fs = yield* FileSystem.FileSystem;
21 const path = yield* Path.Path;
22 const fixtures = yield* path.fromFileUrl(
23 new URL("./fixtures", import.meta.url),
24 );
25 const directory = yield* fs.makeTempDirectoryScoped({
26 directory: fixtures,
27 prefix: "generate-",
28 });
29 const source = yield* fs.readFileString(
30 path.join(fixtures, "psl/contract.psl"),
31 );
32 yield* fs.writeFileString(path.join(directory, "contract.psl"), source);
33 const config = (client: boolean, schemas: boolean) =>
34 `import { defineConfig } from "@prisma/orm-postgres/config";\nimport { definePrismaConfig } from "prisma/config";\nimport { withEffect } from "alchemy/Prisma/ORM/generator";\nexport default definePrismaConfig({ orm: withEffect(defineConfig({ contract: "./contract.psl", output: "./generated" }), { client: ${client}, schemas: ${schemas} }) });\n`;
35 for (const [name, client, schemas] of [
36 ["both", true, true],
37 ["schemas", false, true],
38 ["client", true, false],
39 ] as const) {
40 yield* fs.writeFileString(
41 path.join(directory, `${name}.config.ts`),
42 config(client, schemas),
43 );
44 }
45 const result = yield* generate(path.join(directory, "both.config.ts"));
46 const before = yield* Effect.forEach(result.files, (file) =>
47 fs.readFileString(file),
48 );
49 yield* generate(path.join(directory, "both.config.ts"));
50 expect(
51 yield* Effect.forEach(result.files, (file) =>
52 fs.readFileString(file),
53 ),
54 ).toEqual(before);
55 expect(result.files).toHaveLength(6);
56 const schemaUrl = yield* path.toFileUrl(
57 path.join(result.directory, "schemas.ts"),
58 );
59 const { schemas } = yield* Effect.promise(() => import(schemaUrl.href));
60 expect(
61 Schema.is(schemas.public.User)({
62 id: 1,
63 email: "a@example.com",
64 name: null,
65 }),
66 ).toBe(true);
67 expect(
68 Schema.is(schemas.public.User)({
69 id: "bad",
70 email: "a@example.com",
71 name: null,
72 }),
73 ).toBe(false);
74 const standalone = yield* fs.readFileString(
75 path.join(result.directory, "schemas.ts"),
76 );
77 expect(standalone).not.toContain('from "alchemy');
78 expect(standalone).not.toContain("@prisma");
79 yield* generate(path.join(directory, "schemas.config.ts"));
80 expect(yield* fs.exists(path.join(result.directory, "client.ts"))).toBe(
81 false,
82 );
83 expect(
84 yield* fs.exists(path.join(result.directory, "runtime.ts")),
85 ).toBe(false);
86 expect(
87 yield* fs.readFileString(path.join(result.directory, "index.ts")),
88 ).not.toContain("makeDatabase");
89 yield* generate(path.join(directory, "client.config.ts"));
90 expect(
91 yield* fs.exists(path.join(result.directory, "schemas.ts")),
92 ).toBe(false);
93 expect(yield* fs.exists(path.join(result.directory, "client.ts"))).toBe(
94 true,
95 );
96 yield* fs.writeFileString(
97 path.join(result.directory, "schemas.ts"),
98 "// handwritten\n",
99 );
100 const refused = yield* generate(
101 path.join(directory, "both.config.ts"),
102 ).pipe(Effect.result);
103 expect(Result.isFailure(refused)).toBe(true);
104 expect(
105 yield* fs.readFileString(path.join(result.directory, "schemas.ts")),
106 ).toBe("// handwritten\n");
107 yield* fs.remove(path.join(result.directory, "schemas.ts"));
108 yield* fs.writeFileString(
109 path.join(directory, "contract.psl"),
110 "invalid contract",
111 );
112 const failed = yield* generate(
113 path.join(directory, "both.config.ts"),
114 ).pipe(Effect.result);
115 expect(Result.isFailure(failed)).toBe(true);
116 expect(
117 yield* fs.readFileString(
118 path.join(result.directory, "contract.json"),
119 ),
120 ).toBe(
121 before[
122 result.files.indexOf(path.join(result.directory, "contract.json"))
123 ],
124 );
125 }).pipe(Effect.scoped),
126 {
127 tags: ["unit", "provider:prisma", "provider:prisma:orm", "local"],
128 timeout: 120_000,
129 },
130 );
131
132 it.effect(
133 "watch regenerates imported TypeScript changes and recovers after invalid source",
134 () =>
135 Effect.gen(function* () {
136 const fs = yield* FileSystem.FileSystem;
137 const path = yield* Path.Path;
138 const fixtures = yield* path.fromFileUrl(
139 new URL("./fixtures", import.meta.url),
140 );
141 const directory = yield* fs.makeTempDirectoryScoped({
142 directory: fixtures,
143 prefix: "watch-",
144 });
145 const config = path.join(directory, "prisma.config.ts");
146 yield* fs.writeFileString(
147 config,
148 `import { defineConfig } from "@prisma/orm-postgres/config";\nimport { definePrismaConfig } from "prisma/config";\nimport { withEffect } from "alchemy/Prisma/ORM/generator";\nexport default definePrismaConfig({ orm: withEffect(defineConfig({ contract: "./contract.ts", output: "./generated" })) });\n`,
149 );
150 yield* fs.writeFileString(
151 path.join(directory, "contract.ts"),
152 `import { defineContract, field, model } from "alchemy/Prisma/ORM";\nimport { value } from "./fields.ts";\nexport const contract = defineContract({ models: { Item: model("Item", { fields: { id: field.column({ codecId: "pg/int4@1", nativeType: "int4" }).id(), value } }) } });\n`,
153 );
154 const fieldModule = (codec: "text" | "int4") =>
155 `import { field } from "alchemy/Prisma/ORM";\nexport const value = field.column({ codecId: "pg/${codec}@1", nativeType: "${codec}" });\n`;
156 const fields = path.join(directory, "fields.ts");
157 yield* fs.writeFileString(fields, fieldModule("text"));
158 const bin = yield* path.fromFileUrl(
159 new URL("../../../bin/cli.js", import.meta.url),
160 );
161 const process = yield* ChildProcess.make(globalThis.process.execPath, [
162 bin,
163 "prisma",
164 "generate",
165 "--config",
166 config,
167 "--watch",
168 "--no-input",
169 ]);
170 let output = "";
171 yield* process.stdout.pipe(
172 Stream.decodeText(),
173 Stream.runForEach((text) =>
174 Effect.sync(() => {
175 output += text;
176 }),
177 ),
178 Effect.forkScoped,
179 );
180 yield* process.stderr.pipe(
181 Stream.decodeText(),
182 Stream.runForEach((text) =>
183 Effect.sync(() => {
184 output += text;
185 }),
186 ),
187 Effect.forkScoped,
188 );
189 const wait = (condition: () => boolean) =>
190 Effect.sync(condition).pipe(
191 Effect.repeat({
192 schedule: Schedule.spaced("1 second"),
193 until: Boolean,
194 times: 10,
195 }),
196 Effect.flatMap((ready) =>
197 Effect.sync(() => expect(ready, output).toBe(true)),
198 ),
199 );
200 yield* wait(() => output.includes("Watching"));
201 const generated = path.join(directory, "generated/schemas.ts");
202 const waitForField = (expression: string) =>
203 fs.readFileString(generated).pipe(
204 Effect.repeat({
205 schedule: Schedule.spaced("1 second"),
206 until: (text) => text.includes(expression),
207 times: 10,
208 }),
209 Effect.tap((text) =>
210 Effect.sync(() => expect(text).toContain(expression)),
211 ),
212 );
213 yield* fs.writeFileString(fields, fieldModule("int4"));
214 yield* waitForField('"value": Schema.Int');
215 yield* fs.writeFileString(fields, "invalid typescript");
216 yield* wait(() => output.includes("Prisma.CliError"));
217 yield* fs.writeFileString(fields, fieldModule("text"));
218 yield* waitForField('"value": Schema.String');
219 }).pipe(Effect.scoped),
220 {
221 tags: ["unit", "provider:prisma", "provider:prisma:orm", "local"],
222 timeout: 120_000,
223 },
224 );
225});