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 }