import { createHash } from 'node:crypto' import { readFile, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' import { PROJECT_SCHEMA_MIGRATIONS, PROJECT_SCHEMA_VERSION, runProjectSchemaMigrations, type ProjectMigrationTransaction } from '../src/facade/projectSchema' const root = resolve(new URL('..', import.meta.url).pathname) const outputPath = resolve(root, 'config/project-migration-verification.json') const checkOnly = process.argv.includes('--check') const migrationHash = createHash('sha256').update(PROJECT_SCHEMA_MIGRATIONS.map(({ version, sql }) => `${version}:${sql}`).join('\n')).digest('hex') type RehearsalTransaction = ProjectMigrationTransaction & { applied: number[]; committed: boolean; rolledBack: boolean; executed: number[] } const createTransaction = (initial: number[], failVersion?: number): RehearsalTransaction => { let working = new Set(initial) let snapshot = new Set(initial) const transaction: RehearsalTransaction = { applied: initial.slice(), committed: false, rolledBack: false, executed: [], begin: () => { snapshot = new Set(working) }, isApplied: (version) => working.has(version), execute: (sql) => { const migration = PROJECT_SCHEMA_MIGRATIONS.find((candidate) => candidate.sql === sql) if (!migration) throw new Error('unknown migration SQL') transaction.executed.push(migration.version) if (migration.version === failVersion) throw new Error(`injected migration failure ${failVersion}`) }, markApplied: (version) => { working.add(version); transaction.applied = [...working].sort((left, right) => left - right) }, commit: () => { transaction.committed = true }, rollback: () => { working = new Set(snapshot); transaction.applied = [...working].sort((left, right) => left - right); transaction.rolledBack = true }, } return transaction } const versions = PROJECT_SCHEMA_MIGRATIONS.map(({ version }) => version) const forward = versions.map((startVersion) => { const transaction = createTransaction(versions.filter((version) => version < startVersion)) const applied = runProjectSchemaMigrations(transaction, PROJECT_SCHEMA_MIGRATIONS, 1700000000000) const expected = versions.filter((version) => version >= startVersion) if (JSON.stringify(applied) !== JSON.stringify(expected) || !transaction.committed || transaction.rolledBack || JSON.stringify(transaction.applied) !== JSON.stringify(versions)) throw new Error(`forward migration rehearsal failed at start ${startVersion}`) return { from: startVersion - 1, to: PROJECT_SCHEMA_VERSION, applied, committed: transaction.committed, rolledBack: transaction.rolledBack } }) const rollback = versions.map((failVersion) => { const initial = versions.filter((version) => version < failVersion) const transaction = createTransaction(initial, failVersion) let failed = false try { runProjectSchemaMigrations(transaction, PROJECT_SCHEMA_MIGRATIONS, 1700000000000) } catch (error) { failed = error instanceof Error && error.message === `injected migration failure ${failVersion}` } if (!failed || transaction.committed || !transaction.rolledBack || JSON.stringify(transaction.applied) !== JSON.stringify(initial)) throw new Error(`rollback rehearsal failed at migration ${failVersion}`) return { failedAt: failVersion, restoredVersions: initial, committed: transaction.committed, rolledBack: transaction.rolledBack } }) const rerun = createTransaction(versions) const rerunApplied = runProjectSchemaMigrations(rerun, PROJECT_SCHEMA_MIGRATIONS, 1700000000000) if (rerunApplied.length !== 0 || !rerun.committed || rerun.rolledBack) throw new Error('idempotent migration rehearsal failed') const report = { schemaVersion: 1, status: 'pass', currentSchemaVersion: PROJECT_SCHEMA_VERSION, migrationVersions: versions, migrationSha256: migrationHash, forward, rollback, idempotentRerun: { applied: rerunApplied, committed: rerun.committed, rolledBack: rerun.rolledBack }, recoveryPolicy: { onFailure: 'rollback-entire-transaction', unsupportedFutureVersion: 'reject-before-write' }, } const serialized = `${JSON.stringify(report, null, 2)}\n` if (checkOnly) { const existing = await readFile(outputPath, 'utf8').catch(() => '') if (existing !== serialized) throw new Error('Project migration rehearsal report is stale; run `npm run test:project-migration`.') console.log(JSON.stringify({ status: 'project-migration-pass', currentSchemaVersion: PROJECT_SCHEMA_VERSION, forwardCases: forward.length, rollbackCases: rollback.length, migrationSha256: migrationHash }, null, 2)) } else { await writeFile(outputPath, serialized) console.log(JSON.stringify({ status: 'project-migration-generated', output: outputPath, currentSchemaVersion: PROJECT_SCHEMA_VERSION, forwardCases: forward.length, rollbackCases: rollback.length, migrationSha256: migrationHash }, null, 2)) }