1import crypto from "node:crypto";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5
6export interface SqlFile {
7 id: string;
8 sql: string;
9 hash: string;
10}
11
12/**
13 * Recursively list `.sql` files under `directory`, sorted by their numeric
14 * prefix (e.g. `0001_init.sql`) and then by name.
15 */
16export const listSqlFiles = (directory: string) =>
17 Effect.gen(function* () {
18 const fs = yield* FileSystem.FileSystem;
19 const entries = yield* fs.readDirectory(directory, { recursive: true });
20
21 const sqlFiles = entries
22 .filter((name) => name.endsWith(".sql"))
23 .sort((a, b) => {
24 const aNum = getPrefix(a);
25 const bNum = getPrefix(b);
26 if (aNum !== null && bNum !== null) return aNum - bNum;
27 if (aNum !== null) return -1;
28 if (bNum !== null) return 1;
29 return a.localeCompare(b);
30 });
31
32 return yield* Effect.all(sqlFiles.map((id) => readSqlFile(directory, id)));
33 });
34
35/**
36 * Read a single `.sql` file relative to `directory` and compute its content
37 * hash. The `sql` field is marked non-enumerable so it isn't serialized into
38 * resource state.
39 */
40export const readSqlFile = (directory: string, name: string) =>
41 Effect.gen(function* () {
42 const fs = yield* FileSystem.FileSystem;
43 const path = yield* Path.Path;
44 const sql = yield* fs.readFileString(path.resolve(directory, name));
45 const hash = crypto.createHash("sha256").update(sql).digest("hex");
46 const file: SqlFile = { id: name, sql, hash };
47 Object.defineProperty(file, "sql", { enumerable: false });
48 return file;
49 });
50
51export const hashMigrations = (migrationsDir: string) =>
52 listSqlFiles(migrationsDir).pipe(
53 Effect.map((files) => {
54 const hashes: Record<string, string> = {};
55 for (const file of files) hashes[file.id] = file.hash;
56 return hashes;
57 }),
58 );
59
60export const hashImports = (
61 importFiles: ReadonlyArray<string>,
62 rootDir: string,
63) =>
64 Effect.gen(function* () {
65 const hashes: Record<string, string> = {};
66 for (const filePath of importFiles) {
67 const file = yield* readSqlFile(rootDir, filePath);
68 hashes[filePath] = file.hash;
69 }
70 return hashes;
71 });
72
73const getPrefix = (name: string): number | null => {
74 const prefix = name.split("_")[0];
75 const num = Number.parseInt(prefix, 10);
76 return Number.isNaN(num) ? null : num;
77};
78
79// Drizzle (and other migration tools) emit `--> statement-breakpoint` between
80// statements. drizzle-kit puts the marker directly after single-line
81// statements (`...;--> statement-breakpoint`), so the split can't anchor on a
82// preceding newline.
83const STATEMENT_BREAKPOINT = /\s*-->\s*statement-breakpoint\s*/g;
84
85/**
86 * Split a migration file into individual statements on its
87 * `--> statement-breakpoint` markers. MySQL engines need this: they only
88 * treat `--` as a comment when followed by whitespace, so the `-->` token is
89 * a syntax error ("syntax error at position 2" on Vitess). Postgres and
90 * sqlite comment on `--` regardless of the next character, so their runners
91 * send files whole.
92 */
93export const splitSqlStatements = (sql: string): string[] =>
94 sql
95 .split(STATEMENT_BREAKPOINT)
96 .map((s) => s.trim())
97 .filter((s) => s.length > 0);