1import * as Effect from "effect/Effect";
2import * as Path from "effect/Path";
3import * as Redacted from "effect/Redacted";
4import * as Schedule from "effect/Schedule";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import {
10 isTransientDbError,
11 type MigrateResult,
12 type MigrateShowResult,
13 readMigrationPackages,
14 resolveGraphHead,
15 runPrismaCli,
16} from "./internal.ts";
17
18export type MigrateProps = {
19 /**
20 * Postgres connection string of the database to migrate. Accepts the
21 * redacted connection outputs produced by database resources (e.g.
22 * `branch.origin.connectionString`, `database.directConnectionString`).
23 */
24 url: Redacted.Redacted<string> | string;
25 /**
26 * The {@link Contract} this database tracks. Passing the contract's output
27 * both orders the deploy (plan migrations before applying them) and drives
28 * the diff: the database is up to date exactly when its marker hash equals
29 * `contract.contractHash`.
30 */
31 contract: {
32 /** The emitted contract's storage hash (see {@link Contract}). */
33 contractHash: string;
34 /** Path to `prisma.config.ts`, relative to the current working directory. */
35 config: string;
36 /** Migration graph directory, relative to the current working directory. */
37 migrationsDir: string;
38 };
39 /**
40 * Path to the project's `prisma.config.ts`, relative to the current
41 * working directory. Defaults to the config the {@link Contract} was
42 * emitted from.
43 *
44 * @default contract.config
45 */
46 config?: string;
47 /**
48 * Target contract reference (hash, prefix, ref name, or migration dir
49 * name) to migrate to instead of the graph head. Useful for pinning an
50 * environment to a signed-off contract.
51 */
52 to?: string;
53};
54
55export type Migrate = Resource<
56 "Prisma.Migrate",
57 MigrateProps,
58 {
59 /**
60 * The database marker's contract hash after the last apply — the
61 * database's current contract identity, equal to the target contract's
62 * `contractHash` when converged.
63 */
64 markerHash: string;
65 },
66 never,
67 Providers
68>;
69
70/**
71 * Applies pending Prisma migration packages to a Postgres database
72 * during `alchemy deploy` — the deploy-graph equivalent of running
73 * `prisma db migrate --db $DATABASE_URL` by hand.
74 *
75 * The apply is idempotent: Prisma records the applied contract in the
76 * database's `prisma_contract.marker` table and only walks the pending part
77 * of the migration graph, so re-deploys are no-ops and a database
78 * provisioned in the same deploy is bootstrapped from empty. Fresh databases
79 * that are still coming up are retried briefly (bounded) before failing.
80 *
81 * Destroying the resource never touches the database — dropping tables is
82 * not the IaC engine's call to make.
83 *
84 * ### Migrating a database on deploy
85 * **Example:** Neon branch
86 * ```typescript
87 * const contract = yield* Prisma.Contract("contract");
88 * const project = yield* Neon.Project("db");
89 * const branch = yield* Neon.Branch("main", { project });
90 *
91 * yield* Prisma.Migrate("migrate", {
92 * url: branch.origin.connectionString,
93 * contract,
94 * });
95 * ```
96 *
97 * **Example:** Prisma Postgres
98 * ```typescript
99 * const contract = yield* Prisma.Contract("contract");
100 * const database = yield* Prisma.Database("db", { project });
101 *
102 * yield* Prisma.Migrate("migrate", {
103 * url: database.directConnectionString,
104 * contract,
105 * });
106 * ```
107 *
108 * **Example:** Pinning an environment to a contract ref
109 * ```typescript
110 * yield* Prisma.Migrate("migrate", {
111 * url: branch.origin.connectionString,
112 * contract,
113 * to: "production",
114 * });
115 * ```
116 *
117 * @resource
118 * @category ORM
119 */
120export const Migrate = Resource<Migrate>("Prisma.Migrate");
121
122const urlValue = (url: Redacted.Redacted<string> | string): string =>
123 Redacted.isRedacted(url) ? Redacted.value(url) : url;
124
125export const MigrateProvider = () =>
126 Provider.effect(
127 Migrate,
128 Effect.gen(function* () {
129 const path = yield* Path.Path;
130
131 const resolveConfig = (p: MigrateProps) =>
132 path.resolve(
133 process.cwd(),
134 p.config ?? p.contract.config ?? "./prisma.config.ts",
135 );
136
137 const configDir = (p: MigrateProps) => path.dirname(resolveConfig(p));
138
139 return {
140 // Non-listable: the marker row lives inside an arbitrary database
141 // reachable only through the props' connection string — there is no
142 // account-level API to enumerate migrated databases.
143 list: () => Effect.succeed([]),
144 diff: Effect.fn(function* ({ news, output }) {
145 if (!isResolved(news)) return undefined;
146 if (!output) return undefined;
147 // The database is converged when its marker matches the contract
148 // the props point at; anything else (new migration planned, marker
149 // reset, database recreated out-of-band) is an update. `reconcile`
150 // observes the real database, so update is always safe.
151 return news.contract.contractHash !== output.markerHash
152 ? { action: "update" }
153 : undefined;
154 }),
155 read: Effect.fn(function* ({ olds, output }) {
156 if (!output || !olds) return output;
157 // `db migrate --show` is a read-only preview of the pending path
158 // from the database's current marker. An empty path means the marker is
159 // at the on-disk graph head; otherwise the first pending package's
160 // `from` IS the marker ("empty" = database was never initialized).
161 const show = yield* runPrismaCli<MigrateShowResult>(
162 [
163 "db",
164 "migrate",
165 "--show",
166 "--db",
167 urlValue(olds.url),
168 "--config",
169 resolveConfig(olds),
170 ],
171 { cwd: configDir(olds) },
172 ).pipe(
173 // An unreachable database is not evidence the resource is gone —
174 // keep the last observed state rather than failing refresh.
175 Effect.catchTag("Prisma.CliError", () => Effect.succeed(undefined)),
176 );
177 if (show === undefined) return output;
178 const pending = show.migrations;
179 if (pending.length === 0) {
180 const packages = yield* readMigrationPackages(
181 path.resolve(process.cwd(), olds.contract.migrationsDir),
182 );
183 const head = resolveGraphHead(packages);
184 return { markerHash: head?.to ?? output.markerHash };
185 }
186 const marker = pending[0]?.from;
187 if (marker === undefined || marker === "empty") return undefined;
188 return { markerHash: marker };
189 }),
190 reconcile: Effect.fn(function* ({ news, session }) {
191 yield* session.note(
192 `Applying Prisma migrations (target ${news.contract.contractHash.slice(0, 8)})`,
193 );
194 const result = yield* runPrismaCli<MigrateResult>(
195 [
196 "db",
197 "migrate",
198 "--db",
199 urlValue(news.url),
200 "--config",
201 resolveConfig(news),
202 ...(news.to ? ["--to", news.to] : []),
203 ],
204 { cwd: configDir(news) },
205 ).pipe(
206 // A database provisioned earlier in this deploy may still be
207 // warming up — retry connection-flavored failures briefly.
208 Effect.retry({
209 while: (error) => isTransientDbError(error),
210 schedule: Schedule.exponential("1 second"),
211 times: 5,
212 }),
213 );
214 yield* session.note(
215 result.migrationsApplied > 0
216 ? `Applied ${result.migrationsApplied} migration(s) → ${result.markerHash.slice(0, 8)}`
217 : "Database already up to date",
218 );
219 return { markerHash: result.markerHash };
220 }),
221 delete: Effect.fn(function* () {
222 // Never drop tables or unwind migrations on teardown — the
223 // database's contents outlive the stack by design.
224 }),
225 };
226 }),
227 );