Files
Web_FreeCAD_Bitbybit/scripts/run-project-migration-rehearsal.ts

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))
}