1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import * as Artifacts from "../../Artifacts.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 type EmitResult,
11 type MigrationPackage,
12 type PlanResult,
13 CliError,
14 readMigrationPackages,
15 rewriteEmittedTypes,
16 resolveGraphHead,
17 runPrismaCli,
18} from "./internal.ts";
19
20export type ContractProps = {
21 /**
22 * Path to the project's `prisma.config.ts`, relative to the current
23 * working directory. The config is the single source of truth for the
24 * contract source, emit output directory, and database defaults — exactly
25 * as when running the `prisma` CLI by hand.
26 *
27 * @default "./prisma.config.ts"
28 */
29 config?: string;
30 /**
31 * Directory holding the migration graph (`app/` packages +
32 * content-addressed `snapshots/`), relative to the **config file's
33 * directory** — the same base the CLI resolves against. If you override
34 * `orm.migrations.dir` in `prisma.config.ts`, set this to the same value.
35 *
36 * @default "./migrations"
37 */
38 migrationsDir?: string;
39 /**
40 * Name slug for migration directories planned during deploy
41 * (`migrations/app/{timestamp}_{name}/`). Directory timestamps are
42 * minute-resolution, so the default slug embeds the target contract hash
43 * to keep rapid successive deploys from colliding.
44 *
45 * @default "migration-{contractHash prefix}"
46 */
47 name?: string;
48};
49
50export type Contract = Resource<
51 "Prisma.Contract",
52 ContractProps,
53 {
54 /**
55 * The emitted contract's storage hash — the identity the migration graph
56 * and the database marker track. Changes exactly when the contract's
57 * storage shape changes; downstream {@link Migrate} resources diff this
58 * against the database marker to decide whether to run.
59 */
60 contractHash: string;
61 /**
62 * Path to `prisma.config.ts`, relative to the current working
63 * directory. Downstream resources ({@link Migrate}) inherit it so the
64 * whole toolchain reads one config.
65 */
66 config: string;
67 /** Path to the migration graph directory, relative to the current working directory. */
68 migrationsDir: string;
69 /** Migration package directory names in the app contract space, in name order. */
70 migrations: string[];
71 /** Path to the emitted `contract.json` (runtime IR), relative to the current working directory. */
72 contractJson: string;
73 /** Path to the emitted `contract.d.ts` (types), relative to the current working directory. */
74 contractTypes: string;
75 },
76 never,
77 Providers
78>;
79
80/**
81 * A Prisma ORM v8 contract managed as an Alchemy resource.
82 *
83 * Runs `prisma contract emit` and — when the contract's storage shape
84 * has drifted from the migration graph — `prisma migration plan` as
85 * part of `alchemy deploy`, so the emitted contract artifacts
86 * (`contract.json` / `contract.d.ts`) and the migration packages under
87 * `migrations/` are always regenerated from the source contract before
88 * anything downstream deploys. Pair it with {@link Migrate} to apply the
89 * planned packages to a database in the same deploy:
90 *
91 * ```typescript
92 * const contract = yield* Prisma.Contract("contract");
93 *
94 * const migrate = yield* Prisma.Migrate("migrate", {
95 * url: branch.origin.connectionString,
96 * contract,
97 * });
98 * ```
99 *
100 * Plans that require a human decision (data backfills rendered as
101 * `placeholder(...)` closures in `migration.ts`) are never auto-answered:
102 * the deploy fails with instructions to fill the placeholder, self-emit the
103 * package (`node migrations/app/<dir>/migration.ts`), and commit — the next
104 * deploy then sees no drift and applies the committed package.
105 *
106 * The resource is delete-safe: removing it from the stack does **not** wipe
107 * the migrations directory or the emitted contract, since both are checked
108 * in and shared with other environments.
109 *
110 * ### Declaring the contract
111 * **Example:** Contract at the project root
112 * ```typescript
113 * // expects ./prisma.config.ts and writes ./migrations
114 * const contract = yield* Prisma.Contract("contract");
115 * ```
116 *
117 * **Example:** Custom config location
118 * ```typescript
119 * const contract = yield* Prisma.Contract("contract", {
120 * config: "./db/prisma.config.ts",
121 * // resolved relative to ./db (the config's directory)
122 * migrationsDir: "./migrations",
123 * });
124 * ```
125 *
126 * ### Applying migrations on deploy
127 * **Example:** Contract + Migrate against Neon
128 * ```typescript
129 * const contract = yield* Prisma.Contract("contract");
130 * const project = yield* Neon.Project("db");
131 * const branch = yield* Neon.Branch("main", { project });
132 *
133 * yield* Prisma.Migrate("migrate", {
134 * url: branch.origin.connectionString,
135 * contract,
136 * });
137 * ```
138 *
139 * @resource
140 * @category ORM
141 */
142export const Contract = Resource<Contract>("Prisma.Contract");
143
144const placeholderGuidance = (dir: string) =>
145 [
146 `The planned migration package ${dir} contains unfilled placeholder(s) — a data`,
147 "backfill or destructive change that Prisma cannot decide automatically",
148 "(the SQL is applied to a real database later in the same deploy).",
149 "",
150 "To resolve:",
151 ` 1. Edit ${dir}/migration.ts and replace each placeholder(...) with a typed query plan.`,
152 ` 2. Self-emit the package: node ${dir}/migration.ts`,
153 " 3. Commit the package and re-deploy (the contract will see no drift and apply it).",
154 "",
155 `Or abandon the change: delete ${dir}/ and revert the contract source.`,
156 ].join("\n");
157
158export const ContractProvider = () =>
159 Provider.effect(
160 Contract,
161 Effect.gen(function* () {
162 const fs = yield* FileSystem.FileSystem;
163 const path = yield* Path.Path;
164
165 // Every prop is optional, so `Prisma.Contract("contract")` (no props
166 // object at all) is legal — the engine then hands lifecycle ops
167 // `undefined` rather than `{}`.
168 const resolveConfig = (p: ContractProps | undefined) =>
169 path.resolve(process.cwd(), p?.config ?? "./prisma.config.ts");
170
171 const configDir = (p: ContractProps | undefined) =>
172 path.dirname(resolveConfig(p));
173
174 const resolveMigrationsDir = (p: ContractProps | undefined) =>
175 path.resolve(configDir(p), p?.migrationsDir ?? "./migrations");
176
177 // Attributes expose paths relative to the current working directory so
178 // persisted state stays portable across machines/checkouts. Internal
179 // filesystem ops always use the absolute forms.
180 const relative = (abs: string) => path.relative(process.cwd(), abs);
181
182 const emit = (p: ContractProps | undefined, outputPath?: string) =>
183 runPrismaCli<EmitResult>(
184 [
185 "contract",
186 "emit",
187 "--config",
188 resolveConfig(p),
189 ...(outputPath ? ["--output-path", outputPath] : []),
190 ],
191 { cwd: configDir(p) },
192 );
193
194 /**
195 * Emit the contract into a temp directory (so plan-time diffs never
196 * mutate the workspace) and compare its storage hash against the
197 * migration graph head. Cached per resource for the run so plan and
198 * apply share one CLI invocation.
199 */
200 const detectDrift = (p: ContractProps | undefined) =>
201 Effect.gen(function* () {
202 const tmp = yield* fs.makeTempDirectory({
203 prefix: "alchemy-prisma-contract-",
204 });
205 const emitted = yield* emit(p, tmp).pipe(
206 Effect.ensuring(
207 fs.remove(tmp, { recursive: true }).pipe(Effect.ignore),
208 ),
209 );
210 const packages = yield* readMigrationPackages(
211 resolveMigrationsDir(p),
212 );
213 const head = resolveGraphHead(packages);
214 return {
215 storageHash: emitted.storageHash,
216 packages,
217 head,
218 // Drift when the graph doesn't cover the emitted contract, or the
219 // head package was planned but never self-emitted (empty ops.json
220 // means unfilled placeholders — reconcile re-raises the guidance).
221 changed:
222 head === undefined ||
223 head.to !== emitted.storageHash ||
224 head.opsEmpty,
225 };
226 }).pipe(Artifacts.cached("Prisma.Contract.detectDrift"));
227
228 const attributes = (
229 p: ContractProps | undefined,
230 emitted: EmitResult,
231 packages: readonly MigrationPackage[],
232 ) => ({
233 contractHash: emitted.storageHash,
234 config: relative(resolveConfig(p)),
235 migrationsDir: relative(resolveMigrationsDir(p)),
236 migrations: packages.map((pkg) => pkg.dirName),
237 contractJson: relative(emitted.files.json),
238 contractTypes: relative(emitted.files.dts),
239 });
240
241 return {
242 // Non-listable: a Prisma.Contract is a local build artifact (emitted
243 // contract + migration packages under paths supplied entirely by
244 // props). There is no remote store to enumerate.
245 list: () => Effect.succeed([]),
246 diff: Effect.fn(function* ({ news, output }) {
247 if (!isResolved(news)) return undefined;
248 if (!output) return undefined;
249 const drift = yield* detectDrift(news);
250 // Emitted artifacts must exist on disk for the runtime bundle; a
251 // fresh checkout without them needs a re-emit even with no drift.
252 const emittedExists =
253 output.contractJson !== undefined &&
254 (yield* fs.exists(
255 path.resolve(process.cwd(), output.contractJson),
256 ));
257 // Only flag an update when something would actually change —
258 // otherwise downstream resources (e.g. Prisma.Migrate) would see
259 // `contract.contractHash` as an unresolved Output during plan and
260 // cascade into spurious updates of their own. The canonical-path
261 // comparison migrates legacy state to cwd-relative form.
262 return drift.changed ||
263 !emittedExists ||
264 output.config !== relative(resolveConfig(news)) ||
265 output.migrationsDir !== relative(resolveMigrationsDir(news))
266 ? { action: "update" }
267 : undefined;
268 }),
269 read: Effect.fn(function* ({ olds, output }) {
270 if (!output) return undefined;
271 const props = olds ?? ({} as ContractProps);
272 const migrationsDir = resolveMigrationsDir(props);
273 const configExists = yield* fs.exists(resolveConfig(props));
274 if (!configExists) return undefined;
275 const packages = yield* readMigrationPackages(migrationsDir);
276 const head = resolveGraphHead(packages);
277 return {
278 ...output,
279 contractHash: head?.to ?? output.contractHash,
280 migrationsDir: relative(migrationsDir),
281 migrations: packages.map((pkg) => pkg.dirName),
282 };
283 }),
284 reconcile: Effect.fn(function* ({ news, output, session }) {
285 yield* session.note(
286 `${output ? "Re-emitting" : "Emitting"} Prisma contract`,
287 );
288 const emitted = yield* emit(news);
289 // Emits leak unpublished @internal/* specifiers (through rc.8);
290 // rewrite them to the public @prisma/orm-* packages.
291 yield* rewriteEmittedTypes(emitted.files.dts);
292 const migrationsDir = resolveMigrationsDir(news);
293 let packages = yield* readMigrationPackages(migrationsDir);
294 const head = resolveGraphHead(packages);
295
296 if (head === undefined || head.to !== emitted.storageHash) {
297 yield* session.note(
298 `Planning Prisma migration (${head ? `${head.to.slice(0, 8)} →` : "empty →"} ${emitted.storageHash.slice(0, 8)})`,
299 );
300 const plan = yield* runPrismaCli<PlanResult>(
301 [
302 "migration",
303 "plan",
304 "--config",
305 resolveConfig(news),
306 "--name",
307 // Package directory names are minute-resolution
308 // (`{YYYYMMDDTHHmm}_{name}`), so the default slug carries the
309 // target hash to keep two plans in the same minute (e.g.
310 // rapid successive deploys) from colliding.
311 news?.name ?? `migration-${emitted.storageHash.slice(0, 8)}`,
312 ...(head ? ["--from", head.to] : []),
313 ],
314 { cwd: configDir(news) },
315 );
316 if (plan.pendingPlaceholders) {
317 const dir = plan.dir
318 ? relative(path.resolve(configDir(news), plan.dir))
319 : "the planned migration directory";
320 return yield* Effect.fail(
321 new CliError({ message: placeholderGuidance(dir) }),
322 );
323 }
324 if (!plan.noOp && plan.dir !== undefined) {
325 // Guard against a config/props mismatch: if the CLI wrote the
326 // package outside the directory this resource watches, every
327 // deploy would re-plan a duplicate package.
328 const planDir = path.resolve(configDir(news), plan.dir);
329 if (!planDir.startsWith(migrationsDir + path.sep)) {
330 return yield* Effect.fail(
331 new CliError({
332 message: [
333 `prisma wrote the migration package to ${relative(planDir)},`,
334 `outside this resource's migrationsDir (${relative(migrationsDir)}).`,
335 "Set the `migrationsDir` prop to match `orm.migrations.dir` in prisma.config.ts.",
336 ].join("\n"),
337 }),
338 );
339 }
340 }
341 packages = yield* readMigrationPackages(migrationsDir);
342 }
343
344 // A converged graph whose head was planned earlier but never
345 // self-emitted still blocks the deploy — Migrate would fail on it.
346 const finalHead = resolveGraphHead(packages);
347 if (finalHead?.opsEmpty) {
348 const dir = relative(
349 path.join(migrationsDir, "app", finalHead.dirName),
350 );
351 return yield* Effect.fail(
352 new CliError({ message: placeholderGuidance(dir) }),
353 );
354 }
355
356 return attributes(news, emitted, packages);
357 }),
358 delete: Effect.fn(function* () {
359 // Emitted contract artifacts and migration packages are checked in;
360 // do not delete on resource teardown.
361 }),
362 };
363 }),
364 );