conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

migrate.js (6725B)


      1 // src/lib/db/migrate.js - forward-only migration runner
      2 //
      3 // Migrations live in migrations/<dialect>/NNN_name.sql and are applied in
      4 // filename order. Each applied file is recorded with a checksum, so editing a
      5 // migration that has already run is reported as an error rather than silently
      6 // diverging between environments.
      7 //
      8 // Note on atomicity: sqlite and postgres both run DDL inside a transaction,
      9 // so a failed migration rolls back completely there. MySQL and TiDB implicitly
     10 // commit on DDL, so a file that fails halfway can leave its earlier statements
     11 // applied. The surrounding transaction is still worth having on those, since
     12 // it keeps the bookkeeping insert and any DML atomic. Keep one logical change
     13 // per file so a partial apply stays easy to reason about.
     14 
     15 import fs from 'node:fs/promises';
     16 import path from 'node:path';
     17 import crypto from 'node:crypto';
     18 import { fileURLToPath } from 'node:url';
     19 import { RULES } from './query.js';
     20 
     21 const HERE = path.dirname(fileURLToPath(import.meta.url));
     22 export const MIGRATIONS_ROOT = path.resolve(HERE, '../../../migrations');
     23 
     24 const DDL = {
     25   sqlite: `
     26     CREATE TABLE IF NOT EXISTS _migrations (
     27       filename TEXT PRIMARY KEY,
     28       checksum TEXT NOT NULL,
     29       executed_at INTEGER NOT NULL
     30     )
     31   `,
     32   mysql: `
     33     CREATE TABLE IF NOT EXISTS _migrations (
     34       filename VARCHAR(255) NOT NULL PRIMARY KEY,
     35       checksum CHAR(64) NOT NULL,
     36       executed_at BIGINT NOT NULL
     37     ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
     38   `,
     39   postgres: `
     40     CREATE TABLE IF NOT EXISTS _migrations (
     41       filename TEXT NOT NULL PRIMARY KEY,
     42       checksum CHAR(64) NOT NULL,
     43       executed_at BIGINT NOT NULL
     44     )
     45   `,
     46 };
     47 
     48 // Splits a SQL file into statements on semicolons, while respecting string
     49 // literals, quoted identifiers and comments. Splitting on a bare semicolon,
     50 // as the prototype did, corrupts any statement containing one inside a
     51 // literal. Lexing rules are per dialect, shared with the query compiler.
     52 export function splitStatements(sql, dialect = 'sqlite') {
     53   const rules = RULES[dialect];
     54   if (!rules) throw new Error(`unknown dialect: ${dialect}`);
     55 
     56   const out = [];
     57   let cur = '';
     58   let i = 0;
     59 
     60   while (i < sql.length) {
     61     const c = sql[i];
     62     const next = sql[i + 1];
     63 
     64     if ((c === '-' && next === '-') || (rules.hash && c === '#')) {
     65       const nl = sql.indexOf('\n', i);
     66       if (nl === -1) break;
     67       cur += '\n';
     68       i = nl + 1;
     69       continue;
     70     }
     71 
     72     if (c === '/' && next === '*') {
     73       let depth = 1;
     74       let j = i + 2;
     75       while (j < sql.length && depth > 0) {
     76         if (rules.nestedBlock && sql[j] === '/' && sql[j + 1] === '*') {
     77           depth += 1;
     78           j += 2;
     79         } else if (sql[j] === '*' && sql[j + 1] === '/') {
     80           depth -= 1;
     81           j += 2;
     82         } else {
     83           j += 1;
     84         }
     85       }
     86       cur += ' ';
     87       i = j;
     88       continue;
     89     }
     90 
     91     if (c === "'" || c === '"' || (rules.backtick && c === '`')) {
     92       const quote = c;
     93       cur += c;
     94       i += 1;
     95       while (i < sql.length) {
     96         // Backslash escapes apply to MySQL string literals, not to backtick
     97         // quoted identifiers.
     98         if (rules.backslash && quote !== '`' && sql[i] === '\\') {
     99           cur += sql[i] + (sql[i + 1] ?? '');
    100           i += 2;
    101           continue;
    102         }
    103         if (sql[i] === quote) {
    104           // A doubled quote is a literal quote, not a terminator.
    105           if (sql[i + 1] === quote) {
    106             cur += quote + quote;
    107             i += 2;
    108             continue;
    109           }
    110           cur += quote;
    111           i += 1;
    112           break;
    113         }
    114         cur += sql[i];
    115         i += 1;
    116       }
    117       continue;
    118     }
    119 
    120     // Dollar quoted bodies routinely contain semicolons.
    121     if (rules.dollar && c === '$') {
    122       const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i));
    123       if (m) {
    124         const tag = m[0];
    125         const end = sql.indexOf(tag, i + tag.length);
    126         const stop = end === -1 ? sql.length : end + tag.length;
    127         cur += sql.slice(i, stop);
    128         i = stop;
    129         continue;
    130       }
    131     }
    132 
    133     if (c === ';') {
    134       if (cur.trim()) out.push(cur.trim());
    135       cur = '';
    136       i += 1;
    137       continue;
    138     }
    139 
    140     cur += c;
    141     i += 1;
    142   }
    143 
    144   if (cur.trim()) out.push(cur.trim());
    145   return out;
    146 }
    147 
    148 function checksum(sql) {
    149   // Normalize line endings so a CRLF checkout does not invalidate history.
    150   return crypto.createHash('sha256').update(sql.replace(/\r\n/g, '\n')).digest('hex');
    151 }
    152 
    153 export async function runMigrations(db, options = {}) {
    154   const dir = options.dir || path.join(MIGRATIONS_ROOT, db.dialect);
    155   const log = options.logger || ((m) => console.log(`[migrate] ${m}`));
    156 
    157   await db.exec(DDL[db.dialect]);
    158 
    159   const appliedRows = await db.all('SELECT filename, checksum FROM _migrations');
    160   const applied = new Map(appliedRows.map((r) => [r.filename, r.checksum]));
    161 
    162   let files;
    163   try {
    164     files = (await fs.readdir(dir)).filter((f) => f.endsWith('.sql')).sort();
    165   } catch (e) {
    166     if (e.code === 'ENOENT') throw new Error(`no migrations directory for dialect ${db.dialect}: ${dir}`);
    167     throw e;
    168   }
    169 
    170   const pending = [];
    171   for (const file of files) {
    172     const sql = await fs.readFile(path.join(dir, file), 'utf8');
    173     const sum = checksum(sql);
    174     const prev = applied.get(file);
    175     if (prev === undefined) {
    176       pending.push({ file, sql, sum });
    177       continue;
    178     }
    179     if (prev !== sum) {
    180       throw new Error(
    181         `migration ${file} was modified after it was applied\n` +
    182         `  recorded: ${prev}\n` +
    183         `  on disk:  ${sum}\n` +
    184         'Add a new migration instead of editing an applied one.'
    185       );
    186     }
    187   }
    188 
    189   // Detect files removed from disk but still recorded, which usually means a
    190   // downgrade or a bad merge.
    191   for (const name of applied.keys()) {
    192     if (!files.includes(name)) {
    193       throw new Error(`migration ${name} is recorded as applied but is missing from ${dir}`);
    194     }
    195   }
    196 
    197   if (pending.length === 0) {
    198     log(`up to date (${files.length} applied)`);
    199     return { applied: [], total: files.length };
    200   }
    201 
    202   const done = [];
    203   for (const { file, sql, sum } of pending) {
    204     log(`applying ${file}`);
    205     const statements = splitStatements(sql, db.dialect);
    206     if (statements.length === 0) throw new Error(`migration ${file} contains no statements`);
    207     await db.transaction(async (tx) => {
    208       for (const stmt of statements) await tx.exec(stmt);
    209       await tx.run(
    210         'INSERT INTO _migrations (filename, checksum, executed_at) VALUES ({file}, {sum}, {at})',
    211         { file, sum, at: Date.now() }
    212       );
    213     });
    214     done.push(file);
    215   }
    216 
    217   log(`applied ${done.length} migration(s)`);
    218   return { applied: done, total: files.length };
    219 }