72 lines
4.8 KiB
TypeScript
72 lines
4.8 KiB
TypeScript
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))
|
|
}
|