import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as crypto from "node:crypto";
import { listSqlFiles, splitSqlStatements } from "../SqlFile.ts";
import {
MigrationError,
type MigrationDialect,
type MigrationRecord,
} from "./Format.ts";
/** Map filesystem failures into the migration error channel. */
export const mapPlatformError = (
effect: Effect.Effect,
context: string,
): Effect.Effect =>
effect.pipe(
Effect.mapError(
(cause) =>
new MigrationError({
message: `${context}: ${String(cause)}`,
cause,
}),
),
);
/** Matches drizzle-kit v1 migration directories: `YYYYMMDDHHMMSS_name`. */
export const DRIZZLE_DIR_PATTERN = /^\d{14}_.+/;
/**
* Parse a 14-digit `YYYYMMDDHHMMSS` prefix into UTC millis (drizzle's
* `formatToMillis`). Returns undefined when the name has no such prefix.
*/
export const timestampPrefixMillis = (name: string): number | undefined => {
const prefix = name.slice(0, 14);
if (!/^\d{14}$/.test(prefix)) return undefined;
const year = Number.parseInt(prefix.slice(0, 4), 10);
const month = Number.parseInt(prefix.slice(4, 6), 10) - 1;
const day = Number.parseInt(prefix.slice(6, 8), 10);
const hour = Number.parseInt(prefix.slice(8, 10), 10);
const minute = Number.parseInt(prefix.slice(10, 12), 10);
const second = Number.parseInt(prefix.slice(12, 14), 10);
return Date.UTC(year, month, day, hour, minute, second);
};
const sha256 = (content: string) =>
Effect.sync(() => crypto.createHash("sha256").update(content).digest("hex"));
/**
* Read a drizzle-v1-layout directory (`{ts}_{name}/migration.sql`) into
* records keyed the way drizzle keys them: `name` = the directory name,
* sorted by name (drizzle's own sort), hash = sha256 of `migration.sql`.
*/
export const readDrizzleDirRecords = (dir: string) =>
mapPlatformError(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const entries = yield* fs.readDirectory(dir);
const names: string[] = [];
for (const entry of entries) {
if (!DRIZZLE_DIR_PATTERN.test(entry)) continue;
const sqlPath = path.join(dir, entry, "migration.sql");
if (yield* fs.exists(sqlPath)) names.push(entry);
}
names.sort((a, b) => a.localeCompare(b));
const records: MigrationRecord[] = [];
for (const name of names) {
const sql = yield* fs.readFileString(
path.join(dir, name, "migration.sql"),
);
records.push({
name,
hash: yield* sha256(sql),
createdAtMillis: timestampPrefixMillis(name),
sql,
statements: splitSqlStatements(sql),
});
}
return records;
}),
`Failed to read drizzle migrations from ${dir}`,
);
/**
* Read a flat directory of `.sql` files into records keyed by relative file
* path — the convention wrangler and legacy Alchemy state share. Nested
* `dir/migration.sql` paths are included (via `listSqlFiles`'s recursive
* listing) so legacy state written against drizzle-layout dirs keeps
* resolving.
*/
export const readFlatRecords = (dir: string) =>
mapPlatformError(
listSqlFiles(dir).pipe(
Effect.map((files) =>
files.map((file): MigrationRecord => ({
name: file.id,
hash: file.hash,
createdAtMillis: timestampPrefixMillis(file.id),
sql: file.sql,
statements: splitSqlStatements(file.sql),
})),
),
),
`Failed to read migrations from ${dir}`,
);
/**
* Render a parameter as a SQL literal. Only used for Alchemy's own
* bookkeeping queries (names, hashes, millis, ISO dates) against executors
* without native parameter support (the D1 HTTP API and the local workerd
* tunnel).
*/
export const sqlLiteral = (value: unknown): string => {
if (value === null || value === undefined) return "NULL";
if (typeof value === "number" || typeof value === "bigint") {
return String(value);
}
if (typeof value === "boolean") return value ? "1" : "0";
if (value instanceof Date) return `'${value.toISOString()}'`;
return `'${String(value).replaceAll("'", "''")}'`;
};
/**
* Inline `?` (sqlite/mysql) or `$n` (postgres) placeholders as SQL
* literals. String scanning respects quoted spans so literal `?`s inside
* strings survive.
*/
export const inlineSqlParams = (
sql: string,
params: ReadonlyArray,
dialect: MigrationDialect,
): string => {
if (params.length === 0) return sql;
if (dialect === "postgres") {
return sql.replace(/\$(\d+)/g, (match, n: string) => {
const index = Number.parseInt(n, 10) - 1;
return index >= 0 && index < params.length
? sqlLiteral(params[index])
: match;
});
}
let out = "";
let paramIndex = 0;
let quote: string | undefined;
for (let i = 0; i < sql.length; i++) {
const ch = sql[i];
if (quote) {
out += ch;
if (ch === quote) quote = undefined;
continue;
}
if (ch === "'" || ch === '"' || ch === "`") {
quote = ch;
out += ch;
continue;
}
if (ch === "?" && paramIndex < params.length) {
out += sqlLiteral(params[paramIndex++]);
continue;
}
out += ch;
}
return out;
};
/** Quote an identifier for the given dialect. */
export const quoteIdentifier = (
identifier: string,
dialect: MigrationDialect,
): string =>
dialect === "mysql"
? `\`${identifier.replaceAll("`", "``")}\``
: `"${identifier.replaceAll('"', '""')}"`;