import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { Log } from "../../core/src/index.js"; import type { FieldDefinition, DatabaseAdapter } from "./types.js"; import type { SQLiteAdapter } from "./adapters/sqlite.js"; import type { DiscoveredModel } from "./model.js"; import { getAdapter, adapterQuery, adapterExecute, adapterTableExists, adapterCreateTable, adapterColumns, adapterStartTransaction, adapterCommit, adapterRollback, } from "./database.js"; // --------------------------------------------------------------------------- // Firebird ALTER TABLE ADD idempotency check // --------------------------------------------------------------------------- // Firebird does not support IF NOT EXISTS for ALTER TABLE ADD. When a migration // adds a column that already exists, Firebird throws an error and blocks the // entire migration. These helpers detect ALTER TABLE ... ADD statements and // query RDB$RELATION_FIELDS to see if the column is already present. If so, // the statement is silently skipped rather than executed. /** * Regex to match ALTER TABLE ADD ... * Captures table name and column name (quoted or unquoted). */ const ALTER_ADD_RE = /^\s*ALTER\s+TABLE\s+(?:"([^"]+)"|(\S+))\s+ADD\s+(?:"([^"]+)"|(\S+))/i; /** * Check if the adapter is a Firebird adapter. * * Detection is by the adapter's class name (`constructor.name`) — the engine * discriminator used throughout this package (see `engineOf`). A previous * duck-type check (`queryAsync` + `translateSql`) was WRONG: every async * adapter (Postgres/MySQL/MSSQL) exposes BOTH of those, so the Postgres * adapter was mis-identified as Firebird. That routed the migration-tracking * CREATE into the Firebird `CREATE GENERATOR` + no-default-id DDL and the * record INSERT into the Firebird `GEN_ID()` path — both invalid on Postgres, * so `migrate()` died on the FIRST migration with "syntax error" / aborted * transaction. Class-name detection fixes the mis-route without altering the * runner loop, transaction handling, or raise/return semantics. */ /** * Unwrap a CachedDatabaseAdapter (what initDatabase() returns) to the concrete * underlying adapter. Engine detection keys on constructor.name, which on the * wrapper reads "CachedDatabaseAdapter" — so without unwrapping, every real app * (all of which go through initDatabase) is mis-detected as SQLite and migrate() * emits AUTOINCREMENT on Postgres/MySQL/MSSQL. Drills through any nesting; a raw * adapter passes through unchanged. */ function unwrapAdapter(db: DatabaseAdapter): DatabaseAdapter { let cur: unknown = db; while ( cur && (cur as { constructor?: { name?: string } }).constructor?.name === "CachedDatabaseAdapter" && (cur as { adapter?: unknown }).adapter ) { cur = (cur as { adapter: unknown }).adapter; } return cur as DatabaseAdapter; } function isFirebirdAdapter(db: DatabaseAdapter): boolean { return unwrapAdapter(db).constructor.name === "FirebirdAdapter"; } /** * Check if a column already exists in a Firebird table. */ async function firebirdColumnExists( db: DatabaseAdapter, table: string, column: string, ): Promise { const rows = (await (db as any).queryAsync( "SELECT 1 FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME = ? AND TRIM(RDB$FIELD_NAME) = ?", [table.toUpperCase(), column.toUpperCase()], )) as unknown[]; return rows.length > 0; } /** * If stmt is an ALTER TABLE ... ADD on Firebird and the column already exists, * returns a skip reason string. Returns null if the statement should execute normally. * * Exported (like its shouldSkipCreateTable sibling) so it can be driven * directly against a REAL Firebird connection in * test/migrationContract.test.ts -- no fake adapter needed. */ export async function shouldSkipForFirebird( db: DatabaseAdapter, stmt: string, ): Promise { if (!isFirebirdAdapter(db)) return null; const m = stmt.match(ALTER_ADD_RE); if (!m) return null; const table = m[1] ?? m[2]; const column = m[3] ?? m[4]; if (await firebirdColumnExists(db, table, column)) { return `Column ${column} already exists in ${table}, skipping`; } return null; } /** * Match CREATE TABLE — name may be quoted ("x"), bracketed ([x] MSSQL), * or bare. Captures the table name. */ const CREATE_TABLE_RE = /^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"([^"]+)"|\[([^\]]+)\]|(\w+))/i; /** * Identify the database engine via the adapter's class name. * (constructor.name is the engine discriminator used throughout this package.) * * SQLite is the default for any unrecognized adapter (matches the rest of the * package and Python's `db.get_database_type() or "sqlite"`). */ function engineOf( db: DatabaseAdapter, ): "firebird" | "mssql" | "postgres" | "mysql" | "sqlite" { switch (unwrapAdapter(db).constructor.name) { case "FirebirdAdapter": return "firebird"; case "MssqlAdapter": return "mssql"; case "PostgresAdapter": return "postgres"; case "MysqlAdapter": return "mysql"; default: return "sqlite"; } } /** * Engine-aware auto-increment integer primary-key column for the * `tina4_migration` tracking table. * * Each engine spells an auto-increment integer PK differently — SQLite uses * AUTOINCREMENT, PostgreSQL SERIAL, MySQL AUTO_INCREMENT, MSSQL IDENTITY(1,1). * Emitting raw `AUTOINCREMENT` on any non-SQLite engine fails with * "syntax error at or near AUTOINCREMENT" — which is exactly why `migrate()` * was unusable on PostgreSQL/MySQL/MSSQL. Mirrors the engine-aware id DDL in * the adapters' createTableAsync and Python's `_create_v3_table`. (Firebird is * handled by its own generator branch and never reaches here.) */ function migrationIdColumn(db: DatabaseAdapter): string { switch (engineOf(db)) { case "postgres": return "id SERIAL PRIMARY KEY"; case "mysql": return "id INTEGER PRIMARY KEY AUTO_INCREMENT"; case "mssql": return "id INTEGER IDENTITY(1,1) PRIMARY KEY"; default: return "id INTEGER PRIMARY KEY AUTOINCREMENT"; } } /** * Make CREATE TABLE idempotent on engines lacking IF NOT EXISTS. * * Firebird and MSSQL do not support `CREATE TABLE IF NOT EXISTS`, so a raw * CREATE in a re-run migration raises "object already exists". When the target * table already exists on those engines, return a skip reason so the statement * is skipped (mirrors the Firebird ALTER-TABLE-ADD idempotency guard). * SQLite/MySQL/PostgreSQL support IF NOT EXISTS and are left to the engine. * Only a genuine already-exists is skipped — every other error still raises. */ export async function shouldSkipCreateTable( db: DatabaseAdapter, stmt: string, ): Promise { const engine = engineOf(db); if (engine !== "firebird" && engine !== "mssql") return null; const m = stmt.match(CREATE_TABLE_RE); if (!m) return null; const table = m[1] ?? m[2] ?? m[3]; try { if (await adapterTableExists(db, table)) { return `Table ${table} already exists, skipping CREATE TABLE`; } } catch { return null; } return null; } /** * Sync model definitions to the database (create tables, add columns). */ export async function syncModels(models: DiscoveredModel[]): Promise { const adapter = getAdapter(); for (const { definition } of models) { const { tableName, fields, softDelete, fieldMapping } = definition; const mapping = fieldMapping ?? {}; // Helper to get DB column name for a JS property name const getDbCol = (prop: string): string => mapping[prop] ?? prop; // If softDelete is enabled, ensure is_deleted field exists const allFields = { ...fields }; if (softDelete && !allFields.is_deleted) { allFields.is_deleted = { type: "integer", default: 0, }; } // Remap field keys to DB column names for table creation/migration const dbFields: Record = {}; for (const [fieldName, def] of Object.entries(allFields)) { const dbCol = getDbCol(fieldName); dbFields[dbCol] = def; } if (!(await adapterTableExists(adapter, tableName))) { // adapterCreateTable prefers createTableAsync — engine-aware DDL on // PostgreSQL/MySQL/MSSQL/Firebird (TIMESTAMP/BOOLEAN/SERIAL etc.). await adapterCreateTable(adapter, tableName, dbFields); console.log(` Created table: ${tableName}`); } else { // Check for new columns. SQLite exposes the legacy getTableColumns/ // addColumn helpers; other engines use getColumns()/ALTER TABLE. // // Collapsing this into getColumns() looks obviously right and is NOT: // it broke the legacy NOT NULL migration_id path, which is the bug that // wedged every migration for ~20 releases (python#93). getTableColumns // reads PRAGMA directly; getColumns goes through schema splitting and // does not return the same thing here. Removing it needs its own change // with that path tested, not a drive-by in an interface tidy-up. const existingCols = (adapter as any).getTableColumns ? (adapter as SQLiteAdapter).getTableColumns(tableName) : await adapterColumns(adapter, tableName); const existingNames = new Set(existingCols.map((c) => c.name.toLowerCase())); for (const [colName, def] of Object.entries(dbFields)) { if (!existingNames.has(colName.toLowerCase())) { if ((adapter as any).addColumn) { (adapter as any).addColumn(tableName, colName, def); } else { await adapterExecute(adapter, buildAddColumnSql(adapter, tableName, colName, def)); } console.log(` Added column: ${tableName}.${colName}`); } } } } } /** * Build an engine-aware `ALTER TABLE ... ADD COLUMN` statement for adapters * that don't expose the SQLite-style addColumn() helper. */ function buildAddColumnSql( adapter: DatabaseAdapter, table: string, colName: string, def: FieldDefinition, ): string { const engine = unwrapAdapter(adapter).constructor.name; const isPg = engine === "PostgresAdapter"; const typeMap: Record = isPg ? { integer: "INTEGER", string: def.maxLength ? `VARCHAR(${def.maxLength})` : "VARCHAR(255)", text: "TEXT", number: "DOUBLE PRECISION", numeric: "DOUBLE PRECISION", boolean: "BOOLEAN", datetime: "TIMESTAMP" } : { integer: "INTEGER", string: def.maxLength ? `VARCHAR(${def.maxLength})` : "VARCHAR(255)", text: "TEXT", number: "DOUBLE PRECISION", numeric: "DOUBLE PRECISION", boolean: "INTEGER", datetime: "TIMESTAMP" }; const sqlType = typeMap[def.type] ?? "TEXT"; let sql = `ALTER TABLE "${table}" ADD COLUMN "${colName}" ${sqlType}`; if (def.default !== undefined && def.default !== "now") { const dv = typeof def.default === "string" ? `'${def.default}'` : typeof def.default === "boolean" ? (isPg ? (def.default ? "TRUE" : "FALSE") : (def.default ? "1" : "0")) : String(def.default); sql += ` DEFAULT ${dv}`; } else if (def.default === "now") { sql += " DEFAULT CURRENT_TIMESTAMP"; } return sql; } /** * Migration tracking table name. */ const MIGRATION_TABLE = "tina4_migration"; /** * The tracking-table identifier, quoted for the CALLING adapter's engine. * * SQLite, PostgreSQL, MSSQL and Firebird all accept ANSI double-quoted * identifiers, but MySQL's default sql_mode (no ANSI_QUOTES — the stock * `mysql:8` image never sets it) parses `"..."` as a STRING LITERAL, not an * identifier. `CREATE TABLE "tina4_migration" (...)` on a database where the * table does not already exist fails: "You have an error in your SQL syntax * ... near '"tina4_migration" ('" — MEASURED against a real fresh MySQL 8 * container (a long-lived dev database that already has the table masks this * completely, which is exactly why it only ever surfaced on CI's ephemeral * one). Backticks are MySQL's identifier quote and are accepted in every * sql_mode, so they are always correct there. */ function mt(db: DatabaseAdapter): string { const engine = engineOf(db); // Firebird: leave it UNQUOTED so it folds to the upper-case TINA4_MIGRATION // that the PHP and Python masters create. A quoted lower-case identifier is a // DIFFERENT, case-sensitive table there, so a quoted spelling cannot see a // ledger written by another Tina4 language — while tableExists() matches // case-insensitively and reports it present, so the INSERT fails alone. if (engine === "firebird") return MIGRATION_TABLE; return engine === "mysql" ? `\`${MIGRATION_TABLE}\`` : `"${MIGRATION_TABLE}"`; } /** * Derive a human-readable description from a migration name, matching the * Python master: strip a leading numeric/timestamp prefix and turn `_` into * spaces. `20250101120000_create_users` -> `create users`. */ function deriveDescription(name: string): string { return name.replace(/^\d+_/, "").replace(/_/g, " "); } /** * Build an `ALTER TABLE ... ADD` statement for the tracking table. Firebird * uses `ADD ` (no COLUMN keyword); every other engine uses `ADD COLUMN`. */ function migrationAddColumnSql(db: DatabaseAdapter, col: string, type: string, extra = ""): string { return `ALTER TABLE ${mt(db)} ADD ${isFirebirdAdapter(db) ? "" : "COLUMN "}${col} ${type}${extra}`; } /** * The canonical `tina4_migration` bookkeeping shape, shared across all four * Tina4 frameworks (3.13.55 parity): * * id * migration_name VARCHAR(500) NOT NULL UNIQUE * description VARCHAR(500) * batch INTEGER NOT NULL DEFAULT 1 * executed_at VARCHAR(50) -- a written ISO-8601 string, NOT a CURRENT_TIMESTAMP default * passed INTEGER NOT NULL DEFAULT 1 * * A migration is "applied" iff a row exists with passed = 1. Operates on the * passed adapter so both ensureMigrationTable() (global adapter) and migrate() * (its own adapter) share ONE implementation — mirrors Python's single * `_ensure_tracking_table`. */ async function ensureMigrationTableOn(db: DatabaseAdapter): Promise { if (!(await adapterTableExists(db, MIGRATION_TABLE))) { if (isFirebirdAdapter(db)) { // Firebird: no AUTOINCREMENT, no TEXT type, use a generator for IDs. try { await adapterExecute(db, "CREATE GENERATOR GEN_TINA4_MIGRATION_ID"); try { await adapterExecute(db, "COMMIT"); } catch { /* ignore */ } } catch { // Generator may already exist } await adapterExecute(db, `CREATE TABLE ${mt(db)} ( id INTEGER NOT NULL PRIMARY KEY, migration_name VARCHAR(500) NOT NULL UNIQUE, description VARCHAR(500), batch INTEGER DEFAULT 1 NOT NULL, executed_at VARCHAR(50) NOT NULL, passed INTEGER DEFAULT 1 NOT NULL )`); } else { // Engine-aware bookkeeping DDL (non-Firebird). Each engine spells an // auto-increment integer PK differently — SQLite AUTOINCREMENT, Postgres // SERIAL, MySQL AUTO_INCREMENT, MSSQL IDENTITY(1,1) — via // migrationIdColumn(). migration_name is VARCHAR (a TEXT column cannot // carry UNIQUE on MySQL); SQLite gives VARCHAR TEXT affinity so it stays // behaviour-identical there. executed_at is a plain VARCHAR(50) written // explicitly (new Date().toISOString()) — NOT a CURRENT_TIMESTAMP default. const idCol = migrationIdColumn(db); const engine = engineOf(db); const ifNotExists = engine === "mssql" ? "" : "IF NOT EXISTS "; await adapterExecute(db, `CREATE TABLE ${ifNotExists}${mt(db)} ( ${idCol}, migration_name VARCHAR(500) NOT NULL UNIQUE, description VARCHAR(500), batch INTEGER NOT NULL DEFAULT 1, executed_at VARCHAR(50) NOT NULL, passed INTEGER NOT NULL DEFAULT 1 )`); } return; } await upgradeMigrationTable(db); } /** * Non-destructive in-place upgrade of an existing tracking table. * * A table created by an OLDER v3 (<= 3.13.54) has `name` + `applied_at` and no * `migration_name`/`description`/`passed`. Detect that shape, ADD the canonical * columns, and copy the values across (`migration_name = name`, `passed = 1`, * `executed_at = applied_at`) so already-applied migrations are still seen as * applied and are NOT re-run. The old `name`/`applied_at` columns are left in * place (harmless, ignored from now on). Any other legacy shape only has its * missing `batch` column ensured (preserves the prior behaviour). */ async function upgradeMigrationTable(db: DatabaseAdapter): Promise { let cols: Set; try { const columns = (db as any).getTableColumns ? (db as SQLiteAdapter).getTableColumns(MIGRATION_TABLE) : await adapterColumns(db, MIGRATION_TABLE); cols = new Set(columns.map((c) => c.name.toLowerCase())); } catch { return; // cannot introspect — leave the table untouched } const fb = isFirebirdAdapter(db); if (cols.has("name") && !cols.has("migration_name")) { // Old-v3 -> canonical. Firebird has no TEXT type, so VARCHAR there. const nameType = fb ? "VARCHAR(500)" : "TEXT"; const tsType = fb ? "VARCHAR(50)" : "TEXT"; const tryExec = async (sql: string): Promise => { try { await adapterExecute(db, sql); } catch { /* column may already exist */ } }; await tryExec(migrationAddColumnSql(db, "migration_name", nameType)); if (!cols.has("description")) await tryExec(migrationAddColumnSql(db, "description", nameType)); if (!cols.has("passed")) await tryExec(migrationAddColumnSql(db, "passed", "INTEGER", " DEFAULT 1")); if (!cols.has("executed_at")) await tryExec(migrationAddColumnSql(db, "executed_at", tsType)); if (!cols.has("batch")) await tryExec(migrationAddColumnSql(db, "batch", "INTEGER", " DEFAULT 1")); // Copy legacy values across so applied migrations remain applied. await tryExec(`UPDATE ${mt(db)} SET migration_name = name WHERE migration_name IS NULL`); await tryExec(`UPDATE ${mt(db)} SET passed = 1 WHERE passed IS NULL`); if (cols.has("applied_at")) { await tryExec(`UPDATE ${mt(db)} SET executed_at = applied_at WHERE executed_at IS NULL`); } return; } // Any other legacy shape: just ensure the batch column exists (prior behaviour). if (!cols.has("batch")) { try { await adapterExecute(db, migrationAddColumnSql(db, "batch", "INTEGER", " NOT NULL DEFAULT 1")); } catch { // ignore — column may already exist } } } /** * Ensure the migration tracking table exists in the canonical shape (creating * it or upgrading an older one in place) on the global adapter. */ export async function ensureMigrationTable(): Promise { await ensureMigrationTableOn(getAdapter()); } /** * Get the current batch number (max batch + 1). */ export async function getNextBatch(): Promise { const adapter = getAdapter(); const rows = await adapterQuery<{ max_batch: number | null }>(adapter, `SELECT MAX(batch) as max_batch FROM ${mt(adapter)} WHERE passed = 1`, ); return (rows[0]?.max_batch ?? 0) + 1; } /** * Check if a migration has already been applied (a row with passed = 1). */ export async function isMigrationApplied(name: string): Promise { const adapter = getAdapter(); const rows = await adapterQuery(adapter, `SELECT id FROM ${mt(adapter)} WHERE migration_name = ? AND passed = 1`, [name], ); return rows.length > 0; } /** * Write the canonical "applied" bookkeeping row for a migration. * * DELETEs any existing row for this migration_name FIRST, then INSERTs the * fresh row. A leftover passed=0 row (a prior failure recorded via the public * recordMigration() API, or one carried over from a <= 3.13.54 upgrade) is * therefore superseded, so the migration re-applies cleanly instead of colliding * on the UNIQUE migration_name constraint when the fresh passed=1 row is * INSERTed (that collision used to wedge a previously-failed migration). * DELETE+INSERT is portable across every engine (no UPSERT dialect variance) and * holds the invariant that the table carries AT MOST ONE row per migration_name - * latest state wins. * * Operates on the passed adapter so both migrate() (its own adapter) and * recordMigration() (the global adapter) share ONE implementation - mirrors the * Python master's single _record_applied(). Preserves the Firebird generator-id * branch (no AUTOINCREMENT on Firebird). Writes the canonical columns: * migration_name, a derived description, batch, an explicit ISO-8601 executed_at * timestamp, and passed (default 1). */ async function recordApplied( db: DatabaseAdapter, name: string, batch: number, passed: number = 1, ): Promise { const now = new Date().toISOString(); const description = deriveDescription(name); // Delete-before-insert: supersede any leftover row for this migration_name so // the INSERT below never collides on the UNIQUE migration_name. await adapterExecute(db, `DELETE FROM ${mt(db)} WHERE migration_name = ?`, [name], ); // Build the column list from the columns that ACTUALLY exist on the table, so // a legacy column left behind by an in-place upgrade is populated rather than // defaulted to NULL. Node never created a `migration_id` column, but a Node app // can be pointed at a database whose tina4_migration table was created by // tina4-python v3 <= 3.13.54 - there `migration_id` is NOT NULL, and an insert // that omits it fails, wedging every migration on that database // (tina4-python#93). Mirrors the PHP + Python masters. const cols = await trackingColumns(db); const insertCols = ["migration_name", "description", "batch", "executed_at", "passed"]; const values: unknown[] = [name, description, batch, now, passed]; if (cols.has("migration_id")) { insertCols.push("migration_id"); values.push(name); } if (isFirebirdAdapter(db)) { // Firebird: generate the id from the sequence. const rows = await adapterQuery<{ next_id: number }>(db, "SELECT GEN_ID(GEN_TINA4_MIGRATION_ID, 1) AS NEXT_ID FROM RDB$DATABASE", ); insertCols.unshift("id"); values.unshift(rows[0]?.next_id ?? 1); } const placeholders = insertCols.map(() => "?").join(", "); await adapterExecute(db, `INSERT INTO ${mt(db)} (${insertCols.join(", ")}) VALUES (${placeholders})`, values, ); } /** * Lowercased column names on the tracking table. Returns an empty set on any * failure so a missing/unreadable table falls back to the canonical columns. */ async function trackingColumns(db: DatabaseAdapter): Promise> { try { // The DatabaseAdapter contract exposes getColumns() (feature 3: renamed from // columns() to match the other three frameworks and the get- prefix used // everywhere else). // // This reads through `as any`, so the compiler could not catch the rename // here - it went silently to undefined, `cols` came back empty, migration_id // was left out of the insert and every migration failed on the legacy // NOT NULL column. Exactly the failure python#93 caused, from the opposite // direction. Kept optional-chained for adapters that predate the contract. const cols = await (db as any).getColumns?.(MIGRATION_TABLE); if (!Array.isArray(cols)) return new Set(); return new Set( cols.map((c: any) => String(c?.name ?? c ?? "").toLowerCase()).filter(Boolean), ); } catch { return new Set(); } } /** * Record a migration as applied (public API). Routes through recordApplied() so * a leftover passed=0 row for the same migration_name is deleted before the * fresh row is written (at most one row per migration_name). */ export async function recordMigration(name: string, batch: number, passed: number = 1): Promise { await recordApplied(getAdapter(), name, batch, passed); } /** * Apply a migration (run its up function and record it). */ export async function applyMigration( name: string, up: () => void | Promise, batch: number, ): Promise { if (await isMigrationApplied(name)) { return; } await up(); await recordMigration(name, batch); } /** * Get all migrations from the last batch. */ export async function getLastBatchMigrations(): Promise> { const adapter = getAdapter(); const rows = await adapterQuery<{ max_batch: number | null }>(adapter, `SELECT MAX(batch) as max_batch FROM ${mt(adapter)} WHERE passed = 1`, ); const lastBatch = rows[0]?.max_batch; if (lastBatch === null || lastBatch === undefined) return []; return adapterQuery<{ id: number; migration_name: string; batch: number }>(adapter, `SELECT id, migration_name, batch FROM ${mt(adapter)} WHERE batch = ? AND passed = 1 ORDER BY id DESC`, [lastBatch], ); } /** * Remove a migration record (used during rollback). */ export async function removeMigrationRecord(name: string): Promise { const adapter = getAdapter(); await adapterExecute(adapter, `DELETE FROM ${mt(adapter)} WHERE migration_name = ?`, [name], ); } /** * Rollback the last batch of migrations using .down.sql files. * * FAIL-SAFE (MIG-DEC-02, reuses the Python reference model): for each * migration in the last batch (in reverse order), the down artifact must * actually run before the tracking record is removed. A MISSING .down.sql * (or, on the legacy Map API, no registered down function) or a FAILING down * statement now THROWS instead of logging a warning/error and still deleting * the record — the old behaviour was the exact MIG-ROLLBACK-DROPS-LEDGER bug: * the schema stayed applied but the ledger row vanished, silently untracked. * The DELETE runs inside the SAME transaction as the down statements, so a * partially-executed down (some statements ran, a later one failed) rolls * back too — no half-reversed schema left behind either. * * @param migrationsDir - Directory containing migration files (default: "migrations") * @param delimiter - SQL statement delimiter (default: ";") * @returns Array of the down-migration files that were run, e.g. * "000001_create_users.down.sql". (The legacy down-FUNCTION Map API returns the * bare migration name instead, since no .down.sql file is involved there.) * @throws When a migration in the batch has no down artifact, or its down * statement(s) fail — the batch stops at that migration; earlier * migrations in the SAME call that already rolled back stay rolled back * (each is its own transaction). * * NOTE on return form (intentional, cross-framework): migration return values reflect * WHAT each method acted on, so the forms differ by method and that is by design (not * unified). migrate()/getApplied()/getPending() return the up-migration filename * ("name.sql"); rollback() returns the DOWN-migration filename it executed * ("name.down.sql") — matching the Python master. So a caller diffing rollback() * against getApplied() compares ".down.sql" vs ".sql": strip the suffixes (or compare * the bare "name" stem) to relate them. */ export async function rollback( migrationsDir?: string | Map void | Promise>, delimiter?: string, ): Promise { // Handle legacy API: if first arg is a Map, use old behaviour if (migrationsDir instanceof Map) { const downFunctions = migrationsDir; const migrations = await getLastBatchMigrations(); const rolledBack: string[] = []; for (const migration of migrations) { const down = downFunctions.get(migration.migration_name); if (!down) { throw new Error( `Cannot rollback ${migration.migration_name}: no down function registered`, ); } await down(); await removeMigrationRecord(migration.migration_name); // Legacy down-FUNCTION API: no .down.sql file is involved here, so return the // bare migration name (the file-based path below returns "name.down.sql"). rolledBack.push(migration.migration_name); } return rolledBack; } const dir = resolve(migrationsDir ?? "migrations"); const delim = delimiter ?? ";"; const db = getAdapter(); const migrations = await getLastBatchMigrations(); const rolledBack: string[] = []; for (const migration of migrations) { // Determine the .down.sql filename const downFile = `${migration.migration_name}.down.sql`; const downPath = join(dir, downFile); if (!existsSync(downPath)) { throw new Error( `Cannot rollback ${migration.migration_name}: no .down.sql file found`, ); } const sqlContent = readFileSync(downPath, "utf-8").trim(); if (sqlContent) { const statements = splitStatements(sqlContent, delim); try { await adapterStartTransaction(db); for (const stmt of statements) { await adapterExecute(db, stmt); } // Remove the tracking record INSIDE the same transaction as the down // statements, so a failure below rolls back the DDL too, not just the // record removal. await removeMigrationRecord(migration.migration_name); await adapterCommit(db); } catch (err) { try { await adapterRollback(db); } catch { // rollback may fail if auto-rolled-back } const msg = err instanceof Error ? err.message : String(err); throw new Error(`Rollback failed: ${migration.migration_name} — ${msg}`); } } else { // An EXISTING but empty/comment-only .down.sql (0 statements) is a // deliberate no-op success — matches the Python reference // (createMigration scaffolds an empty .down.sql by default). await removeMigrationRecord(migration.migration_name); } // Return the down-migration file that was run (e.g. "name.down.sql"), matching // the Python master's rollback return form. rolledBack.push(`${migration.migration_name}.down.sql`); } return rolledBack; } /** * Get all applied migrations. */ export async function getAppliedMigrations(): Promise> { const adapter = getAdapter(); return adapterQuery<{ id: number; migration_name: string; description: string; batch: number; executed_at: string; passed: number }>(adapter, `SELECT * FROM ${mt(adapter)} WHERE passed = 1 ORDER BY id ASC`, ); } // --------------------------------------------------------------------------- // SQL-file-based migration system (matches Python's tina4_python.migration API) // --------------------------------------------------------------------------- /** * Result returned by the `migrate()` function. */ export interface MigrationResult { /** Filenames of successfully applied migrations. */ applied: string[]; /** Filenames that were already applied (skipped). */ skipped: string[]; /** Filenames that failed with error details. */ failed: string[]; } /** * Result returned by the `status()` function. */ export interface MigrationStatus { /** Filenames of completed (already applied) migrations. */ completed: string[]; /** Filenames of pending (not yet applied) migrations. */ pending: string[]; } /** * Smart/curly quotes — editors, word processors, docs and chat apps silently * convert a straight " to “ ” and a straight ' to ‘ ’ (plus primes ′ ″). Those * characters are NOT valid SQL string/identifier delimiters, so a pasted-in * migration fails to run ("syntax error near …"). Map them back to straight * ASCII quotes. (Real string CONTENTS are unaffected by intent — we only swap * the lookalike code points for their ASCII equivalents.) */ const SMART_QUOTES: Record = { // Double-quote lookalikes → straight " (U+0022) "“": '"', "”": '"', "„": '"', "‟": '"', "″": '"', // Single-quote / apostrophe lookalikes → straight ' (U+0027) "‘": "'", "’": "'", "‚": "'", "‛": "'", "′": "'", }; const SMART_QUOTE_RE = new RegExp(`[${Object.keys(SMART_QUOTES).join("")}]`, "g"); /** * Replace smart/curly quotes with straight ASCII quotes so migration SQL * authored or pasted from an editor/doc actually runs (those code points are * not valid SQL delimiters). Already-straight quotes and ordinary string * content are returned byte-for-byte unchanged. */ export function normalizeQuotes(sql: string): string { return sql.replace(SMART_QUOTE_RE, (ch) => SMART_QUOTES[ch]); } const SET_TERM_RE = /^SET\s+TERM\s+(\S+)$/i; /** * Return the new terminator from a `SET TERM ` directive. * * `SET TERM` is a script-level directive (recognised by isql and other * InterBase/Firebird tooling, not run by the engine) that changes the * terminator separating statements. Recognising it lets a statement whose own * body contains the default `;` terminator — a trigger, stored procedure or * `EXECUTE BLOCK` — be kept intact rather than split on those inner `;`. The * terminator may be more than one character (e.g. `!!`). * * @param statement A single, already-trimmed statement. * @returns The new terminator, or `null` when `statement` is not a `SET TERM` * directive. */ export function parseSetTerm(statement: string): string | null { const m = statement.trim().match(SET_TERM_RE); return m ? m[1] : null; } interface SplitState { sql: string; statements: string[]; current: string; delimiter: string; dlen: number; i: number; inDollarBlock: boolean; inSlashBlock: boolean; } function consumeQuotedSql(state: SplitState, quote: string): void { state.current += quote; state.i += 1; while (state.i < state.sql.length) { if (state.sql[state.i] === quote && state.sql[state.i + 1] === quote) { state.current += quote + quote; state.i += 2; } else { state.current += state.sql[state.i]; state.i += 1; if (state.sql[state.i - 1] === quote) break; } } } function consumeSpecialSqlToken(state: SplitState): boolean { const { sql, i } = state; const ch = sql[i]; if (!state.inSlashBlock && ch === "$" && sql[i + 1] === "$") { state.current += "$$"; state.i += 2; state.inDollarBlock = !state.inDollarBlock; return true; } if (!state.inDollarBlock && ch === "/" && sql[i + 1] === "/" && !(i > 0 && sql[i - 1] === ":")) { state.current += "//"; state.i += 2; state.inSlashBlock = !state.inSlashBlock; return true; } if (state.inDollarBlock || state.inSlashBlock) { state.current += ch; state.i += 1; return true; } if (ch === "/" && sql[i + 1] === "*") { const end = sql.indexOf("*/", i + 2); state.i = end === -1 ? sql.length : end + 2; return true; } if (ch === "-" && sql[i + 1] === "-") { const end = sql.indexOf("\n", i + 2); state.i = end === -1 ? sql.length : end; return true; } if (ch === "'" || ch === '"') { consumeQuotedSql(state, ch); return true; } return false; } function consumeSqlDelimiter(state: SplitState): boolean { if (state.dlen === 0 || !state.sql.startsWith(state.delimiter, state.i)) return false; state.i += state.dlen; const statement = state.current.trim(); state.current = ""; if (!statement) return true; const newTerm = parseSetTerm(statement); if (newTerm !== null) { state.delimiter = newTerm; state.dlen = newTerm.length; } else { state.statements.push(statement); } return true; } /** * Split SQL text into individual statements with a single-pass, quote- and * comment-aware scanner. The split decision is made character by character so * the delimiter only ever fires in real statement position. * * This is the fix for issue #54: the old implementation split on `delimiter` * BEFORE stripping `-- …` line comments, so a `;` inside a line comment * fragmented one statement into several broken pieces. A scanner that knows * where it is (code / comment / string) cannot make that mistake. * * Handled, in priority order, only when NOT already inside a stored-proc block: * - `$$ … $$` and `// … //` stored-proc blocks are kept intact (inner `;` never * splits). A `//` preceded by `:` is a URL scheme (`https://…`), not a delimiter. * - `/* … *​/` block comments are stripped. * - `-- …` line comments are stripped to end of line (the newline is kept). * - `'…'` single-quoted strings and `"…"` double-quoted identifiers are copied * verbatim, honouring the SQL doubled-quote escape (`''` / `""`); a `;`, `--` * or `/*` inside a literal is data, not a delimiter or comment. * - A `SET TERM ` directive switches the active terminator and is * consumed (never emitted), so a statement whose own body contains the default * terminator — a Firebird trigger, stored procedure or `EXECUTE BLOCK` — * survives as one. Multi-character terminators (e.g. `!!`) are supported. * Mirrors the tina4-python `_split_statements` / tina4-php / tina4-ruby scanner (parity). */ export function splitStatements(sql: string, delimiter = ";"): string[] { const state: SplitState = { sql: normalizeQuotes(sql), statements: [], current: "", delimiter, dlen: delimiter.length, i: 0, inDollarBlock: false, inSlashBlock: false, }; while (state.i < state.sql.length) { if (consumeSpecialSqlToken(state) || consumeSqlDelimiter(state)) continue; state.current += state.sql[state.i]; state.i += 1; } const trailing = state.current.trim(); if (trailing && parseSetTerm(trailing) === null) state.statements.push(trailing); return state.statements; } /** * Sort migration filenames supporting both naming patterns: * - Sequential: 000001_name.sql, 000002_name.sql * - Timestamp: 20240315120000_name.sql (YYYYMMDDHHMMSS) * * Numeric-aware: a file with a leading numeric/timestamp prefix sorts first by * that number (so `9_*` applies before `10_*` — a plain lexical sort misorders * unpadded prefixes because "10" < "9"). Files with NO numeric prefix sort * AFTER the numbered ones, then lexically. Mirrors Python's `_migration_sort_key`. */ export function sortMigrationFiles(files: string[]): string[] { // Returns a tuple-like key: [group, numeric, name]. // group 0 = has numeric prefix (sorts first); group 1 = no prefix (sorts after). const key = (name: string): [number, bigint, string] => { const m = name.match(/^(\d+)/); return m ? [0, BigInt(m[1]), name] : [1, 0n, name]; }; return [...files].sort((a, b) => { const [ag, an, anm] = key(a); const [bg, bn, bnm] = key(b); if (ag !== bg) return ag - bg; if (an < bn) return -1; if (an > bn) return 1; return anm.localeCompare(bnm); }); } /** * Warn (once) about migration filenames without a recognized NNNNNN_/timestamp * prefix — their ordering relative to numbered migrations is undefined, a silent * out-of-order-apply footgun. Mirrors Python's runner. */ function warnUnprefixedMigrations(files: string[]): void { const unprefixed = files.filter((f) => !/^\d+[_-]/.test(f)); if (unprefixed.length > 0) { Log.warning( "Migration file(s) without a numeric/timestamp prefix may apply out of order: " + unprefixed.join(", "), ); } } /** * Run all pending SQL-file migrations. * * Supports both naming patterns: * - Sequential: 000001_description.sql * - Timestamp: YYYYMMDDHHMMSS_description.sql * * 1. Creates the `tina4_migration` tracking table if it doesn't exist. * 2. Scans `migrationsDir` for `.sql` files (excluding `.down.sql`), sorted. * 3. Skips files already recorded as applied. * 4. Splits file content on `delimiter` and executes each statement. * 5. On success records the migration with the current batch number. * 6. On error logs and continues. * 7. Returns a summary of applied / skipped / failed files. * * @param adapter - A DatabaseAdapter instance (or omit to use the global adapter). * @param options - Optional configuration. */ export async function migrate( adapter?: DatabaseAdapter, options?: { migrationsDir?: string; delimiter?: string }, ): Promise { const db = adapter ?? getAdapter(); const dir = resolve(options?.migrationsDir ?? "migrations"); const delimiter = options?.delimiter ?? ";"; const result: MigrationResult = { applied: [], skipped: [], failed: [] }; if (!existsSync(dir)) { return result; } // Ensure the canonical tracking table exists (creates it, or upgrades an // older <= 3.13.54 `name`/`applied_at` table in place). Shared with // ensureMigrationTable() so both paths produce the identical schema. await ensureMigrationTableOn(db); // Collect .sql files (exclude .down.sql), numeric-aware sorted (9_ before 10_). const files = sortMigrationFiles( readdirSync(dir).filter((f) => f.endsWith(".sql") && !f.endsWith(".down.sql")), ); if (files.length === 0) return result; // Warn about files without a recognized numeric/timestamp prefix — their // ordering relative to numbered migrations is undefined. warnUnprefixedMigrations(files); // Determine the batch number for this run let currentBatch = 1; try { const batchRows = await adapterQuery<{ max_batch: number | null }>(db, `SELECT MAX(batch) as max_batch FROM ${mt(db)} WHERE passed = 1`, ); currentBatch = (batchRows[0]?.max_batch ?? 0) + 1; } catch { // Table may have old schema without batch column currentBatch = 1; } for (const file of files) { const migrationId = file.replace(/\.sql$/, ""); // Applied iff a row exists with passed = 1. ensureMigrationTableOn() above // guarantees the canonical migration_name column (fresh or upgraded). let alreadyApplied = false; try { const existing = await adapterQuery<{ id: number }>(db, `SELECT id FROM ${mt(db)} WHERE migration_name = ? AND passed = 1`, [migrationId], ); alreadyApplied = existing.length > 0; } catch { alreadyApplied = false; } if (alreadyApplied) { result.skipped.push(file); continue; } const sqlContent = readFileSync(join(dir, file), "utf-8").trim(); if (!sqlContent) { result.skipped.push(file); continue; } const statements = splitStatements(sqlContent, delimiter); try { await adapterStartTransaction(db); for (const stmt of statements) { // Idempotency on engines lacking IF NOT EXISTS: Firebird ALTER-TABLE-ADD, // and CREATE TABLE on Firebird/MSSQL. Pre-check the catalogue so a genuine // already-exists is skipped instead of raising — every other error still // raises (rolls the file back). const skipReason = (await shouldSkipForFirebird(db, stmt)) ?? (await shouldSkipCreateTable(db, stmt)); if (skipReason) { console.log(` Migration ${file}: ${skipReason}`); continue; } await adapterExecute(db, stmt); } // Record as applied (passed = 1) via recordApplied(), which DELETEs any // leftover row for this migration_name before the fresh INSERT - so a // previously-failed migration (a leftover passed=0 row) re-applies // cleanly instead of colliding on the UNIQUE migration_name. Both the // DELETE and INSERT run inside this file's open transaction. await recordApplied(db, migrationId, currentBatch); await adapterCommit(db); result.applied.push(file); } catch (err) { try { await adapterRollback(db); } catch { // rollback may fail if transaction was auto-rolled-back } const msg = err instanceof Error ? err.message : String(err); console.error(` Migration failed: ${file} — ${msg}`); result.failed.push(file); // STOP at the first failure (parity with Python/PHP/Ruby). The file rolled // back; later migrations must NOT be applied on top of a missing earlier // one (a missing column / table would cascade silent corruption). Already- // applied files stay applied — fix the bad file and re-run. Log.error(`Migration stopped at first failure: ${file} — ${msg}`); break; } } return result; } /** * Get migration status: which migrations are completed and which are pending. * * @param adapter - A DatabaseAdapter instance (or omit to use the global adapter). * @param options - Optional configuration. * @returns Object with `completed` and `pending` arrays of filenames. */ export async function status( adapter?: DatabaseAdapter, options?: { migrationsDir?: string }, ): Promise { const db = adapter ?? getAdapter(); const dir = resolve(options?.migrationsDir ?? "migrations"); const result: MigrationStatus = { completed: [], pending: [] }; if (!existsSync(dir)) { return result; } // Ensure tracking table exists if (!(await adapterTableExists(db, MIGRATION_TABLE))) { // No table means nothing has been run — all files are pending const files = sortMigrationFiles( readdirSync(dir).filter((f) => f.endsWith(".sql") && !f.endsWith(".down.sql")), ); result.pending = files; return result; } // The table exists — upgrade an older <= 3.13.54 shape in place so the // canonical migration_name column is readable below (never creates a table // here; the tableExists check above already returned for the absent case). await ensureMigrationTableOn(db); // Collect .sql files (exclude .down.sql) const files = sortMigrationFiles( readdirSync(dir).filter((f) => f.endsWith(".sql") && !f.endsWith(".down.sql")), ); // Get all applied migration names (passed = 1) from the DB. const appliedNames = new Set(); try { const rows = await adapterQuery<{ migration_name: string }>(db, `SELECT migration_name FROM ${mt(db)} WHERE passed = 1`, ); for (const row of rows) { if (row.migration_name) appliedNames.add(row.migration_name); } } catch (err) { // The tracking table exists (checked above) but reading applied-state // failed — do NOT silently report every migration as pending. That // masked a real bug: passing the Database WRAPPER (no .query) instead of a // raw adapter made adapterQuery throw here, so status() reported all-pending // and the MCP migration_run re-applied everything each call. Log loud so a // wrong-arg / bad adapter can never again hide as "nothing applied yet". // (Python master #57 "don't swallow" lesson.) Applied-state stays empty for // this call, but the cause is now visible instead of masked. Log.error(`Migration status: could not read applied migrations from "${MIGRATION_TABLE}" — ${(err as Error).message}`); } for (const file of files) { const migrationId = file.replace(/\.sql$/, ""); if (appliedNames.has(migrationId)) { result.completed.push(file); } else { result.pending.push(file); } } return result; } /** * Create a new empty SQL migration file with a timestamp prefix. * * Creates BOTH the up migration (.sql) and the down migration (.down.sql). * * @param description - Human-readable description (used in filename). * @param options - Optional configuration. * @returns Object with paths to the created up and down files. */ export async function createMigration( description: string, options?: { migrationsDir?: string; kind?: "sql" | "code" | "class" }, ): Promise { // MEASURED 2026-08-06: the accepted kind differed in every framework - // python "python", php "php", ruby "ruby" OR "python", node "class" - and // NONE validated it, so create_migration(..., kind="python") produced a code // migration in Python and Ruby and a SILENT .sql file in PHP and Node. // "code" is now the canonical spelling in all four; each keeps its own // language name as a legacy alias; anything else raises. const kind = (options?.kind ?? "sql").trim().toLowerCase(); if (!["sql", "code", "class"].includes(kind)) { throw new Error( `Unknown migration kind "${kind}". Use "sql" (default) or "code" ` + `(alias: "class"). An unrecognised kind used to produce a .sql file ` + `silently, which is why this now throws.`, ); } if (kind === "code" || kind === "class") { return createClassMigration(description, options); } const dir = resolve(options?.migrationsDir ?? "migrations"); // Ensure directory exists if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } // Sanitise description for filename const safeName = description .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_|_$/g, ""); // Use YYYYMMDDHHMMSS timestamp prefix const now = new Date(); const timestamp = [ now.getFullYear(), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0"), String(now.getHours()).padStart(2, "0"), String(now.getMinutes()).padStart(2, "0"), String(now.getSeconds()).padStart(2, "0"), ].join(""); const upFileName = `${timestamp}_${safeName}.sql`; const downFileName = `${timestamp}_${safeName}.down.sql`; const upPath = join(dir, upFileName); const downPath = join(dir, downFileName); const upTemplate = `-- Migration: ${description}\n-- Created: ${now.toISOString()}\n\n`; const downTemplate = `-- Rollback: ${description}\n-- Created: ${now.toISOString()}\n\n`; writeFileSync(upPath, upTemplate, "utf-8"); writeFileSync(downPath, downTemplate, "utf-8"); return { upPath, downPath }; } /** * Create a new TypeScript class-based migration file with a timestamp prefix. * * @param description - Human-readable description (used in filename and class name). * @param options - Optional configuration. * @returns Path to the created file. */ export async function createClassMigration( description: string, options?: { migrationsDir?: string }, ): Promise { const dir = resolve(options?.migrationsDir ?? "migrations"); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } const safeName = description .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_|_$/g, ""); // Derive PascalCase class name const className = description .replace(/[^a-zA-Z0-9 ]+/g, " ") .trim() .split(/\s+/) .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) .join(""); const now = new Date(); const timestamp = [ now.getFullYear(), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0"), String(now.getHours()).padStart(2, "0"), String(now.getMinutes()).padStart(2, "0"), String(now.getSeconds()).padStart(2, "0"), ].join(""); const fileName = `${timestamp}_${safeName}.ts`; const filePath = join(dir, fileName); const content = `// Migration: ${description}\n` + `// Created: ${now.toISOString()}\n\n` + `import type { DatabaseAdapter } from "tina4-nodejs/orm";\n\n` + `export class ${className} {\n` + ` async up(db: DatabaseAdapter): Promise {\n` + ` // db.execute("CREATE TABLE ...");\n` + ` }\n\n` + ` async down(db: DatabaseAdapter): Promise {\n` + ` // db.execute("DROP TABLE IF EXISTS ...");\n` + ` }\n` + `}\n`; writeFileSync(filePath, content, "utf-8"); return filePath; } /** * Object-oriented Migration class — canonical Tina4 Migration API. * * Provides parity with Python, PHP, and Ruby: * - migrate() Run all pending migrations * - rollback(steps=1) Roll back last N batches * - status() Show completed/pending * - create(description) Scaffold new .sql + .down.sql files * - getApplied() List applied migrations * - getPending() List pending migration filenames * - getFiles() List all migration files on disk * * @example * const m = new Migration(db, { migrationsDir: "migrations" }); * await m.migrate(); * await m.rollback(2); * await m.status(); * await m.create("add users table"); */ export class Migration { private db?: DatabaseAdapter; private dir: string; private delimiter: string; constructor(db?: DatabaseAdapter, options?: { migrationsDir?: string; delimiter?: string }) { this.db = db; this.dir = options?.migrationsDir ?? "migrations"; this.delimiter = options?.delimiter ?? ";"; } /** Run all pending migrations. Returns applied/skipped/failed summary. */ async migrate(): Promise { return migrate(this.db, { migrationsDir: this.dir, delimiter: this.delimiter }); } /** Roll back the last N batches. Returns list of rolled-back migration names. */ async rollback(steps = 1): Promise { const db = this.db ?? (await import("./database.js")).getAdapter(); // If tracking table doesn't exist yet there's nothing to roll back if (!(await adapterTableExists(db, MIGRATION_TABLE))) return []; const rolled: string[] = []; for (let i = 0; i < steps; i++) { const batch = await rollback(this.dir, this.delimiter); if (batch.length === 0) break; rolled.push(...batch); } return rolled; } /** Get migration status: which are completed and which are pending. */ async status(): Promise { return status(this.db, { migrationsDir: this.dir }); } /** * Scaffold a new migration file. * * kind="sql" — creates {timestamp}_{description}.sql + .down.sql (default) * kind="code" — creates {timestamp}_{description}.ts with a TypeScript class * template. "class" is accepted as a legacy alias. * * Returns the path to the created up file (or class file). */ async create( description: string, kind: "sql" | "code" | "class" = "sql", ): Promise { // Route through createMigration so the validation lives in ONE place - a // second copy of the accepted set is a second place for it to drift. return createMigration(description, { migrationsDir: this.dir, kind }); } /** Return list of completed (applied) migration filenames. */ async getApplied(): Promise { const s = await this.status(); return s.completed; } /** Return list of pending migration filenames. */ async getPending(): Promise { const s = await this.status(); return s.pending; } /** Return sorted list of all migration files on disk (excludes .down.sql). */ getFiles(): string[] { const dir = resolve(this.dir); if (!existsSync(dir)) return []; return sortMigrationFiles( readdirSync(dir).filter((f) => f.endsWith(".sql") && !f.endsWith(".down.sql")), ); } }