| 1 | import * as Prisma from "@/Prisma"; |
| 2 | import * as Provider from "@/Provider"; |
| 3 | import * as Stack from "@/Stack"; |
| 4 | import { State } from "@/State"; |
| 5 | import * as Test from "@/Test/Alchemy"; |
| 6 | import { expect } from "alchemy-test"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Path from "effect/Path"; |
| 10 | import * as Result from "effect/Result"; |
| 11 | |
| 12 | const { test } = Test.make({ providers: Prisma.providers() }); |
| 13 | |
| 14 | const CONFIG_SOURCE = ` |
| 15 | import { defineConfig as ormConfig } from "@prisma/orm-postgres/config"; |
| 16 | import { definePrismaConfig } from "prisma/config"; |
| 17 | |
| 18 | export default definePrismaConfig({ |
| 19 | orm: ormConfig({ |
| 20 | contract: "./contract.prisma", |
| 21 | output: "./generated", |
| 22 | }), |
| 23 | }); |
| 24 | `; |
| 25 | |
| 26 | const CONTRACT_SOURCE = `// use prisma-8 |
| 27 | model User { |
| 28 | id Int @id @default(autoincrement()) |
| 29 | email String @unique |
| 30 | name String? |
| 31 | } |
| 32 | `; |
| 33 | |
| 34 | const DRIFTED_CONTRACT_SOURCE = |
| 35 | CONTRACT_SOURCE + |
| 36 | ` |
| 37 | model 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. |
| 45 | const PLACEHOLDER_CONTRACT_SOURCE = CONTRACT_SOURCE.replace( |
| 46 | "name String?", |
| 47 | "name String", |
| 48 | ); |
| 49 | |
| 50 | const TS_CONFIG_SOURCE = ` |
| 51 | import { defineConfig as ormConfig } from "@prisma/orm-postgres/config"; |
| 52 | import { definePrismaConfig } from "prisma/config"; |
| 53 | |
| 54 | export 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. |
| 64 | const TS_CONTRACT_SOURCE = ` |
| 65 | import { defineContract } from "@prisma/orm-postgres/contract-builder"; |
| 66 | |
| 67 | export 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 | |
| 80 | const 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 | |
| 109 | const 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 | |
| 119 | const 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 | |
| 133 | const 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 | |
| 140 | test.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 | |
| 171 | test.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 | |
| 194 | test.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 | |
| 228 | test.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 | |
| 268 | test.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 | |
| 283 | test.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 | ); |