| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as FileSystem from "effect/FileSystem"; |
| 3 | import * as Path from "effect/Path"; |
| 4 | import * as crypto from "node:crypto"; |
| 5 | import { listSqlFiles, splitSqlStatements } from "../SqlFile.ts"; |
| 6 | import { detectLayout } from "./Detect.ts"; |
| 7 | import { MigrationError, type MigrationRecord } from "./Format.ts"; |
| 8 | import { DRIZZLE_DIR_PATTERN, timestampPrefixMillis } from "./Utils.ts"; |
| 9 | |
| 10 | export { |
| 11 | DRIZZLE_DIR_PATTERN, |
| 12 | inlineSqlParams, |
| 13 | quoteIdentifier, |
| 14 | sqlLiteral, |
| 15 | timestampPrefixMillis, |
| 16 | } from "./Utils.ts"; |
| 17 | |
| 18 | /** Map filesystem failures into the migration error channel. */ |
| 19 | export const mapPlatformError = <A, E, R>( |
| 20 | effect: Effect.Effect<A, E, R>, |
| 21 | context: string, |
| 22 | ): Effect.Effect<A, MigrationError, R> => |
| 23 | effect.pipe( |
| 24 | Effect.mapError( |
| 25 | (cause) => |
| 26 | new MigrationError({ |
| 27 | message: `${context}: ${String(cause)}`, |
| 28 | cause, |
| 29 | }), |
| 30 | ), |
| 31 | ); |
| 32 | |
| 33 | const sha256 = (content: string) => |
| 34 | Effect.sync(() => crypto.createHash("sha256").update(content).digest("hex")); |
| 35 | |
| 36 | /** |
| 37 | * Read a drizzle-v1-layout directory (`{ts}_{name}/migration.sql`) into |
| 38 | * records keyed the way drizzle keys them: `name` = the directory name, |
| 39 | * sorted by name (drizzle's own sort), hash = sha256 of `migration.sql`. |
| 40 | */ |
| 41 | export const readDrizzleDirRecords = (dir: string) => |
| 42 | mapPlatformError( |
| 43 | Effect.gen(function* () { |
| 44 | const fs = yield* FileSystem.FileSystem; |
| 45 | const path = yield* Path.Path; |
| 46 | const entries = yield* fs.readDirectory(dir); |
| 47 | const names: string[] = []; |
| 48 | for (const entry of entries) { |
| 49 | if (!DRIZZLE_DIR_PATTERN.test(entry)) continue; |
| 50 | const sqlPath = path.join(dir, entry, "migration.sql"); |
| 51 | if (yield* fs.exists(sqlPath)) names.push(entry); |
| 52 | } |
| 53 | names.sort((a, b) => a.localeCompare(b)); |
| 54 | const records: MigrationRecord[] = []; |
| 55 | for (const name of names) { |
| 56 | const sql = yield* fs.readFileString( |
| 57 | path.join(dir, name, "migration.sql"), |
| 58 | ); |
| 59 | records.push({ |
| 60 | name, |
| 61 | hash: yield* sha256(sql), |
| 62 | createdAtMillis: timestampPrefixMillis(name), |
| 63 | sql, |
| 64 | statements: splitSqlStatements(sql), |
| 65 | }); |
| 66 | } |
| 67 | return records; |
| 68 | }), |
| 69 | `Failed to read drizzle migrations from ${dir}`, |
| 70 | ); |
| 71 | |
| 72 | /** |
| 73 | * Read a flat directory of `.sql` files into records keyed by relative file |
| 74 | * path — the convention wrangler and legacy Alchemy state share. Nested |
| 75 | * `dir/migration.sql` paths are included (via `listSqlFiles`'s recursive |
| 76 | * listing) so legacy state written against drizzle-layout dirs keeps |
| 77 | * resolving. |
| 78 | */ |
| 79 | export const readFlatRecords = (dir: string) => |
| 80 | mapPlatformError( |
| 81 | listSqlFiles(dir).pipe( |
| 82 | Effect.map((files) => |
| 83 | files.map((file): MigrationRecord => ({ |
| 84 | name: file.id, |
| 85 | hash: file.hash, |
| 86 | createdAtMillis: timestampPrefixMillis(file.id), |
| 87 | sql: file.sql, |
| 88 | statements: splitSqlStatements(file.sql), |
| 89 | })), |
| 90 | ), |
| 91 | ), |
| 92 | `Failed to read migrations from ${dir}`, |
| 93 | ); |
| 94 | |
| 95 | /** Read migration records using the directory's detected layout. */ |
| 96 | export const readMigrationRecords = (dir: string) => |
| 97 | detectLayout(dir).pipe( |
| 98 | Effect.flatMap((layout) => |
| 99 | layout === "flat" ? readFlatRecords(dir) : readDrizzleDirRecords(dir), |
| 100 | ), |
| 101 | ); |