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 }