auth

Main authentication module for my projects
git clone git://git.finwo.net/app/auth
Log | Files | Refs

migrate.js (6238B)


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