import { splitSqlStatements } from "../sql/split-statements.js"; /** * Adapt a migration file's statements for execution INSIDE the apply runner's own * transaction. * *

Why this exists

* A SQLite table rebuild (the recreate-and-copy recipe: a column type change, an * evolved `field.enum @values`, a CHECK or FK change) is emitted as a **self-contained, * standalone-runnable script** — `PRAGMA foreign_keys = OFF; BEGIN TRANSACTION; … * COMMIT; PRAGMA foreign_keys = ON;` — which is the correct recipe when you pipe the * file into `sqlite3`. But `applyPending` runs the file's statements inside ONE Kysely * transaction (so the data change and its ledger row commit or roll back together), and * SQLite rejects a nested `BEGIN` outright: * * SQLITE_ERROR: cannot start a transaction within a transaction * * The result was that **no table-rebuild migration could be applied at all on sqlite — * the scaffold's default dialect** — via `--apply`, `apply-pending`, or under Bun. Worse, * the failure landed mid-file: the leading statements had already run, so a dependent * view could be dropped and not recreated, leaving the database in a partially-applied * state with nothing recorded in the ledger. Found by a fresh-adopter test the first time * an enum gained a member. * *

Why the fix is here and not in the emitter

* Deleting `BEGIN`/`COMMIT` from the emitted file would fix the runner and silently make * the file non-atomic for anyone executing it directly (`sqlite3 db < up.sql`, a Flyway * runner via the ADR-0015 output adapter, a hand-rolled deploy script). The file is a * committed artifact with more than one consumer. So the file stays a correct standalone * script and the RUNNER adapts it to the transaction it already owns — the same division * D1 uses, where `applyD1SafetyPass` strips transaction control because D1 wraps the file * in an implicit transaction. * *

What it does

* 1. **Drops transaction control** (`BEGIN`/`COMMIT`/`ROLLBACK`/`SAVEPOINT`/`RELEASE`). * The runner's transaction supplies the atomicity the file was asking for. * 2. **Rewrites `PRAGMA foreign_keys = OFF` to `PRAGMA defer_foreign_keys = ON`.** This is * not cosmetic: `foreign_keys` is a **no-op inside a transaction**, so the rebuild would * lose its FK protection precisely where it needs it (dropping a referenced table). * `defer_foreign_keys` is the in-transaction equivalent — enforcement is deferred to * commit — and is exactly what the D1 cascade emitter uses for the same reason. * 3. **Drops the matching `PRAGMA foreign_keys = ON`**, whose only job was to undo (1); * `defer_foreign_keys` resets itself at commit. * * Postgres migrations contain none of these constructs, so this is a no-op for them — * which is why it keys on statement text rather than needing the dialect threaded in. */ export interface RunnerTransactionPassResult { /** Statements to execute, in order, inside the runner's transaction. */ statements: string[]; /** Human-readable adaptations made, for `--verbose`/diagnostics. Empty when untouched. */ notes: string[]; } const TRANSACTION_CONTROL = /^\s*(BEGIN|COMMIT|END\s+TRANSACTION|ROLLBACK|SAVEPOINT|RELEASE)\b/i; const FK_OFF = /^\s*PRAGMA\s+foreign_keys\s*=\s*(OFF|0|false)\s*$/i; const FK_ON = /^\s*PRAGMA\s+foreign_keys\s*=\s*(ON|1|true)\s*$/i; export function prepareForRunnerTransaction(sqlText: string): RunnerTransactionPassResult { const notes: string[] = []; const statements: string[] = []; for (const stmt of splitSqlStatements(sqlText)) { if (TRANSACTION_CONTROL.test(stmt)) { notes.push(`dropped transaction control (runner owns the transaction): ${firstWords(stmt)}`); continue; } if (FK_OFF.test(stmt)) { // Preserve the INTENT. A bare foreign_keys pragma does nothing inside a // transaction, so keeping it verbatim would silently drop FK protection. statements.push("PRAGMA defer_foreign_keys = ON"); notes.push("rewrote `PRAGMA foreign_keys = OFF` to `PRAGMA defer_foreign_keys = ON` (the in-transaction equivalent)"); continue; } if (FK_ON.test(stmt)) { notes.push("dropped `PRAGMA foreign_keys = ON` (defer_foreign_keys resets at commit)"); continue; } statements.push(stmt); } return { statements, notes }; } function firstWords(stmt: string): string { return stmt.trim().split(/\s+/).slice(0, 3).join(" "); }