1import * as Effect from "effect/Effect";
2import type * as FileSystem from "effect/FileSystem";
3import type * as Path from "effect/Path";
4import { hashMigrations } from "../SqlFile.ts";
5import { recordsEqual } from "../../Util/equal.ts";
6import { ALCHEMY_DEFAULT_TABLE, applyAlchemyFormat } from "./AlchemyFormat.ts";
7import {
8 MigrationError,
9 type DrizzleV0LayoutError,
10 type MigrationHistoryConflictError,
11 type SqlExecutor,
12} from "./Format.ts";
13import { readMigrationRecords } from "./Records.ts";
14
15/**
16 * The migrations input surface shared by every SQL database resource.
17 * A plain string is a directory; the object form overrides the
18 * applied-migrations table name. A `Drizzle.Schema` resource's attributes
19 * satisfy the `{ out }` shape structurally, so `migrations: schema` works
20 * without importing the Drizzle module.
21 *
22 * There is exactly ONE bookkeeping format — Alchemy's `__alchemy_migrations`
23 * table. A database previously migrated with drizzle-kit, Prisma, or
24 * wrangler is adopted by a one-way conversion: the foreign table's history
25 * is copied into Alchemy's table once and the foreign table is left frozen
26 * (never written, never dropped). From then on Alchemy's table is the only
27 * bookkeeping.
28 */
29export type MigrationsInput =
30 | string
31 | {
32 /** Directory containing the migration files. */
33 dir: string;
34 /** Override the applied-migrations table name. */
35 table?: string;
36 }
37 | {
38 /** A `Drizzle.Schema`-shaped resource output. */
39 out: string;
40 };
41
42export interface NormalizedMigrationsInput {
43 dir: string;
44 table?: string;
45}
46
47export const normalizeMigrationsInput = (
48 input: MigrationsInput,
49): NormalizedMigrationsInput => {
50 if (typeof input === "string") return { dir: input };
51 if ("out" in input) return { dir: input.out };
52 return input;
53};
54
55/**
56 * Normalize a resource's `migrations` prop into the registry input shape.
57 * Shared by every SQL database resource.
58 */
59export const migrationsInputOf = (props: {
60 migrations?: MigrationsInput;
61}): NormalizedMigrationsInput | undefined =>
62 props.migrations ? normalizeMigrationsInput(props.migrations) : undefined;
63
64/**
65 * What prior state remembers about migrations — the bookkeeping table a
66 * previous deploy used. Rows written by pre-registry Alchemy persisted
67 * their table name (`d1_migrations`, `neon_migrations`, custom names), and
68 * honoring it keeps them converging against the same table (upgraded in
69 * place to the current column shape) instead of starting a new one.
70 */
71export interface StampedMigrationsState {
72 table?: string | undefined;
73}
74
75export const stampedOf = (
76 output: { migrationsTable: string | undefined } | undefined,
77): StampedMigrationsState => ({ table: output?.migrationsTable });
78
79export interface ResolvedMigrations {
80 dir: string;
81 table: string;
82}
83
84/**
85 * Resolve where this deploy's bookkeeping lives. Precedence: explicit
86 * `table` on the input, then the table persisted by a prior deploy, then
87 * the default `__alchemy_migrations`.
88 */
89export const resolveMigrations = (options: {
90 input: NormalizedMigrationsInput;
91 stamped: StampedMigrationsState;
92}): ResolvedMigrations => ({
93 dir: options.input.dir,
94 table: options.input.table ?? options.stamped.table ?? ALCHEMY_DEFAULT_TABLE,
95});
96
97/**
98 * Apply pending migrations. The directory layout only selects how records
99 * are read and keyed (drizzle-kit/Prisma dirs by directory name, flat dirs
100 * by file path) — the bookkeeping is always Alchemy's table, converting
101 * foreign or legacy history on first contact (see `AlchemyFormat.ts`).
102 */
103export const applyMigrations = (options: {
104 resolved: ResolvedMigrations;
105 executor: SqlExecutor;
106}): Effect.Effect<
107 void,
108 MigrationError | MigrationHistoryConflictError | DrizzleV0LayoutError,
109 FileSystem.FileSystem | Path.Path
110> =>
111 Effect.gen(function* () {
112 const { resolved, executor } = options;
113 const records = yield* readMigrationRecords(resolved.dir);
114 yield* applyAlchemyFormat({
115 executor,
116 table: resolved.table,
117 records,
118 });
119 });
120
121const hashMigrationsDir = (dir: string) =>
122 hashMigrations(dir).pipe(
123 Effect.mapError(
124 (cause) =>
125 new MigrationError({
126 message: `Failed to read migrations from ${dir}: ${String(cause)}`,
127 cause,
128 }),
129 ),
130 );
131
132/** The result of one migration sync, ready to stamp into attributes. */
133export interface MigrationRun {
134 resolved: ResolvedMigrations;
135 hashes: Record<string, string>;
136}
137
138/**
139 * The whole per-deploy migration pipeline: hash the directory (for state
140 * drift tracking), resolve the bookkeeping table, and — when the directory
141 * is non-empty — apply pending migrations. The database provider supplies
142 * only `withExecutor`, a bracket that acquires its target's
143 * {@link SqlExecutor} (a D1 HTTP handle, a pg client over a Neon URI or
144 * PlanetScale temp role, a mysql2 connection) around the apply.
145 */
146export const runMigrations = <E, R>(options: {
147 input: NormalizedMigrationsInput;
148 stamped: StampedMigrationsState;
149 withExecutor: (
150 apply: (
151 executor: SqlExecutor,
152 ) => Effect.Effect<
153 void,
154 MigrationError | MigrationHistoryConflictError | DrizzleV0LayoutError,
155 FileSystem.FileSystem | Path.Path
156 >,
157 ) => Effect.Effect<void, E, R>;
158}): Effect.Effect<
159 MigrationRun,
160 MigrationError | E,
161 R | FileSystem.FileSystem | Path.Path
162> =>
163 Effect.gen(function* () {
164 const hashes = yield* hashMigrationsDir(options.input.dir);
165 const resolved = resolveMigrations(options);
166 if (Object.keys(hashes).length > 0) {
167 yield* options.withExecutor((executor) =>
168 applyMigrations({ resolved, executor }),
169 );
170 }
171 return { resolved, hashes };
172 });
173
174/**
175 * The shared migration half of a provider `diff`: true when pending file
176 * changes or a bookkeeping-table move require an update. Callers decide the
177 * action shape (`{ action: "update" }`, with or without stables).
178 */
179export const diffMigrations = (options: {
180 news: { migrations?: MigrationsInput };
181 output:
182 | {
183 migrationsTable: string | undefined;
184 migrationsHashes: Record<string, string>;
185 }
186 | undefined;
187}): Effect.Effect<boolean, MigrationError, FileSystem.FileSystem | Path.Path> =>
188 Effect.gen(function* () {
189 const input = migrationsInputOf(options.news);
190 if (!input) return false;
191 const newHashes = yield* hashMigrationsDir(input.dir);
192 if (!recordsEqual(newHashes, options.output?.migrationsHashes ?? {})) {
193 return true;
194 }
195 const resolved = resolveMigrations({
196 input,
197 stamped: stampedOf(options.output),
198 });
199 return (
200 resolved.table !== (options.output?.migrationsTable ?? resolved.table)
201 );
202 });
203
204/**
205 * The migration attributes every SQL database resource persists, threaded
206 * uniformly: the run's results when migrations ran, otherwise the prior
207 * state (so removing `migrations` keeps the stamp and hashes for a later
208 * re-add).
209 */
210export const migrationsAttrs = (options: {
211 input: NormalizedMigrationsInput | undefined;
212 run: MigrationRun | undefined;
213 output:
214 | {
215 migrationsTable: string | undefined;
216 migrationsHashes: Record<string, string>;
217 }
218 | undefined;
219}): {
220 migrationsDir: string | undefined;
221 migrationsTable: string | undefined;
222 migrationsHashes: Record<string, string>;
223} => ({
224 migrationsDir: options.input?.dir,
225 migrationsTable:
226 options.run?.resolved.table ?? options.output?.migrationsTable,
227 migrationsHashes:
228 options.run?.hashes ?? options.output?.migrationsHashes ?? {},
229});