/** * MSSQL schema versioning (see ../schemaMigrations.js). * * Step context: `{ query, getVersion, assertVersion, setVersion }` where * `query(sql, params)` runs inside the step's transaction (same contract as * `provider.query`). Note: a column added by `ALTER TABLE` is not visible to * later statements of the SAME batch — use separate `query()` calls. */ import { BASE_SCHEMA_VERSION, assertStepResult, latestSchemaVersion, makeVersionContext, migrateSchema, parseSchemaVersion, } from '../schemaMigrations.js'; export const UPGRADE_DIR = new URL('./upgrade/', import.meta.url); export const DOWNGRADE_DIR = new URL('./downgrade/', import.meta.url); /** Schema version a fresh database is created at. */ export const SCHEMA_VERSION = latestSchemaVersion(UPGRADE_DIR); /** * @typedef {(sql: string, params?: object) => Promise<{ rows: any[] }>} QueryFn */ /** * Reads `meta.version`, or `null` when the meta table/row is missing. * @param {QueryFn} query * @returns {Promise} */ export async function readSchemaVersion(query) { const result = await query(` IF OBJECT_ID(N'dbo.meta', N'U') IS NOT NULL SELECT value FROM meta WHERE [key] = N'version' ELSE SELECT CAST(NULL AS NVARCHAR(MAX)) AS value WHERE 1 = 0 `); const row = result.rows[0]; return row ? parseSchemaVersion(row.value) : null; } async function writeSchemaVersion(query, version) { await query(` UPDATE meta SET value = @version WHERE [key] = N'version'; IF @@ROWCOUNT = 0 INSERT INTO meta ([key], value) VALUES (N'version', @version); `, { version: String(version) }); } async function hasAnyTable(query) { const result = await query(`SELECT TOP 1 1 AS found FROM sys.tables WHERE is_ms_shipped = 0`); return result.rows.length > 0; } /** * @param {import('./index.js').MssqlProvider} provider */ function makeRunStep(provider) { return async (step, fn) => { await provider.transaction(async (runner) => { const query = (sql, params) => runner.query(sql, params); const ctx = { query, ...makeVersionContext(step.file, () => readSchemaVersion(query), (v) => writeSchemaVersion(query, v)), }; await fn(ctx); assertStepResult(step, await readSchemaVersion(query)); }); }; } /** * Upgrades an existing database to `SCHEMA_VERSION`. Must run BEFORE the * `schema.js` scripts so they never meet an outdated table shape. * Fresh (empty) databases are skipped — `INIT_META` stamps them. * A non-empty database without a version row is treated as version 1. * @param {import('./index.js').MssqlProvider} provider * @param {(message: string) => void} [log] */ export async function upgradeSchema(provider, log) { const query = (sql, params) => provider.query(sql, params); if (!(await hasAnyTable(query))) return 0; let current = await readSchemaVersion(query); if (current === null) { await query(` IF OBJECT_ID(N'dbo.meta', N'U') IS NULL CREATE TABLE meta ([key] NVARCHAR(255) NOT NULL, value NVARCHAR(MAX) NOT NULL, CONSTRAINT PK_meta PRIMARY KEY ([key])); `); await writeSchemaVersion(query, BASE_SCHEMA_VERSION); current = BASE_SCHEMA_VERSION; } return migrateSchema({ direction: 'upgrade', dirUrl: UPGRADE_DIR, current, target: SCHEMA_VERSION, runStep: makeRunStep(provider), log, }); } /** * Downgrades the database to `target` (CLI only, never at server start). * @param {import('./index.js').MssqlProvider} provider * @param {number} target * @param {(message: string) => void} [log] */ export async function downgradeSchema(provider, target, log) { const current = await readSchemaVersion((sql, params) => provider.query(sql, params)); if (current === null) throw new Error('Database has no schema version (meta table missing)'); return migrateSchema({ direction: 'downgrade', dirUrl: DOWNGRADE_DIR, current, target, runStep: makeRunStep(provider), log, }); }