| 1 | import { generate } from "@/Prisma/ORM/Generate.ts"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { expect, layer } from "alchemy-test"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Path from "effect/Path"; |
| 7 | import * as Result from "effect/Result"; |
| 8 | import * as Schema from "effect/Schema"; |
| 9 | import * as Schedule from "effect/Schedule"; |
| 10 | import * as Stream from "effect/Stream"; |
| 11 | import { ChildProcess } from "effect/process"; |
| 12 | |
| 13 | const describe = layer(NodeServices.layer, { excludeTestServices: true }); |
| 14 | |
| 15 | describe("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 | }); |