| 1 | import * as Effect from "effect/Effect"; |
| 2 | import type * as FileSystem from "effect/FileSystem"; |
| 3 | import type * as Path from "effect/Path"; |
| 4 | import { hashMigrations } from "../SqlFile.ts"; |
| 5 | import { recordsEqual } from "../../Util/equal.ts"; |
| 6 | import { ALCHEMY_DEFAULT_TABLE, applyAlchemyFormat } from "./AlchemyFormat.ts"; |
| 7 | import { |
| 8 | MigrationError, |
| 9 | type DrizzleV0LayoutError, |
| 10 | type MigrationHistoryConflictError, |
| 11 | type SqlExecutor, |
| 12 | } from "./Format.ts"; |
| 13 | import { 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 | */ |
| 29 | export 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 | |
| 42 | export interface NormalizedMigrationsInput { |
| 43 | dir: string; |
| 44 | table?: string; |
| 45 | } |
| 46 | |
| 47 | export 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 | */ |
| 59 | export 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 | */ |
| 71 | export interface StampedMigrationsState { |
| 72 | table?: string | undefined; |
| 73 | } |
| 74 | |
| 75 | export const stampedOf = ( |
| 76 | output: { migrationsTable: string | undefined } | undefined, |
| 77 | ): StampedMigrationsState => ({ table: output?.migrationsTable }); |
| 78 | |
| 79 | export 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 | */ |
| 89 | export 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 | */ |
| 103 | export 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 | |
| 121 | const 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. */ |
| 133 | export 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 | */ |
| 146 | export 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 | */ |
| 179 | export 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 | */ |
| 210 | export 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 | }); |