From 9d565a29a180db8133f8f763e2d1424d04534872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Fri, 14 Aug 2026 21:28:13 +0700 Subject: [PATCH 1/2] test(acv2): establish combined Schema 23 causal RED --- .../wpA/authorityWriteHost.test.js | 12 +- ...ntProjectionJobsCanonicalMigration.test.js | 232 ++++++++++++++++++ .../batch27DeveloperHandoffV2Closure.test.js | 2 +- 3 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 backend/tests/architectureClosureV2/wpA/domainEventProjectionJobsCanonicalMigration.test.js diff --git a/backend/tests/architectureClosureV2/wpA/authorityWriteHost.test.js b/backend/tests/architectureClosureV2/wpA/authorityWriteHost.test.js index d35605657..87e4b2f9b 100644 --- a/backend/tests/architectureClosureV2/wpA/authorityWriteHost.test.js +++ b/backend/tests/architectureClosureV2/wpA/authorityWriteHost.test.js @@ -56,11 +56,11 @@ function requireA1() { return authority; } -test('A1 base migration plus post-merge integrity migration expose Schema 22 and the AuthorityWriteHost boundary', () => { +test('A1 historical Schema 22 authority remains intact while current R32 exposes Schema 23 and the AuthorityWriteHost boundary', () => { const authority = loadHostModule(); assert.ok(fs.existsSync(migrationPath), 'WP-A schema migration entrypoint is missing'); assert.ok(authority, 'AuthorityWriteHost service is missing'); - assert.equal(SCHEMA_VERSION, 22); + assert.equal(SCHEMA_VERSION, 23); assert.equal(typeof authority?.acquireAuthorityWriteHost, 'function'); assert.equal(typeof authority?.assertCurrentAuthorityWriteHostToken, 'function'); }); @@ -113,7 +113,7 @@ test('SqliteConnectionBroker requires a genuine externally acquired host capabil } }); -test('fresh database bootstrap and Schema 20 upgrade both converge to the complete Schema 22 object set', () => { +test('fresh database bootstrap and Schema 20 upgrade preserve complete Schema 22 authority while current R32 reaches Schema 23', () => { const authority = requireA1(); const required = new Set([ 'authority_write_host_lease', @@ -149,7 +149,7 @@ test('fresh database bootstrap and Schema 20 upgrade both converge to the comple host = authority.acquireAuthorityWriteHost({ dbPath, instanceId: `host-${mode}` }); broker = new SqliteConnectionBroker({ dbPath, authorityWriteHostCapability: host.capability }); const store = broker.open(); - assert.equal(store.getMeta('schema_version'), 22); + assert.equal(store.getMeta('schema_version'), SCHEMA_VERSION); const names = tableNames(store.db); for (const name of required) assert.equal(names.has(name), true, `${mode}:${name}`); const baseMigration = store.db.prepare("SELECT status,checksum FROM r32_schema_migrations WHERE migration_id='021_architecture_closure_v2_wp_a'").get(); @@ -159,6 +159,10 @@ test('fresh database bootstrap and Schema 20 upgrade both converge to the comple assert.equal(integrityMigration?.status, 'completed'); assert.equal(integrityMigration?.target_schema_version, 22); assert.match(String(integrityMigration?.checksum || ''), /^[a-f0-9]{64}$/); + const projectionJobMigration = store.db.prepare("SELECT status,checksum,target_schema_version FROM r32_schema_migrations WHERE migration_id='023_architecture_closure_v2_domain_event_projection_jobs_canonical'").get(); + assert.equal(projectionJobMigration?.status, 'completed'); + assert.equal(projectionJobMigration?.target_schema_version, 23); + assert.match(String(projectionJobMigration?.checksum || ''), /^[a-f0-9]{64}$/); } finally { try { broker?.close(); } catch (_) {} try { host?.close(); } catch (_) {} diff --git a/backend/tests/architectureClosureV2/wpA/domainEventProjectionJobsCanonicalMigration.test.js b/backend/tests/architectureClosureV2/wpA/domainEventProjectionJobsCanonicalMigration.test.js new file mode 100644 index 000000000..9239643bd --- /dev/null +++ b/backend/tests/architectureClosureV2/wpA/domainEventProjectionJobsCanonicalMigration.test.js @@ -0,0 +1,232 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { DatabaseSync } = require('node:sqlite'); + +const { R32SqliteStore, SCHEMA_VERSION } = require('../../../lib/r32SqliteStore'); + +const EXPECTED_COLUMNS = Object.freeze([ + 'job_id', + 'event_id', + 'projector_name', + 'state', + 'attempts', + 'claim_token', + 'lease_expires_at', + 'next_attempt_at', + 'last_error', + 'created_at', + 'updated_at' +]); + +function withTempRoot(prefix, work) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + try { + return work(root); + } finally { + fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }); + } +} + +function withRawDatabase(work) { + return withTempRoot('yance-acv2-projection-job-migration-', root => { + const dbPath = path.join(root, 'legacy-schema22.db'); + const db = new DatabaseSync(dbPath); + try { + db.exec('PRAGMA foreign_keys=ON;'); + return work(db); + } finally { + try { db.close(); } catch (_) {} + } + }); +} + +function createLegacyFixture(db, { includeCanonicalEvent = true } = {}) { + db.exec(` + CREATE TABLE domain_events( + event_id TEXT PRIMARY KEY + ) STRICT; + + CREATE TABLE canonical_event_headers( + event_id TEXT PRIMARY KEY + ) STRICT; + + CREATE TABLE domain_event_projection_jobs( + job_id TEXT PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE, + projector_name TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('pending','processing','applied','failed','quarantined')), + attempts INTEGER NOT NULL DEFAULT 0, + claim_token TEXT NOT NULL DEFAULT '', + lease_expires_at TEXT NOT NULL DEFAULT '', + next_attempt_at TEXT NOT NULL DEFAULT '', + last_error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(event_id) REFERENCES domain_events(event_id) ON DELETE CASCADE + ) STRICT; + + CREATE INDEX idx_domain_event_projection_jobs_claim + ON domain_event_projection_jobs(state,next_attempt_at,created_at); + `); + + db.prepare('INSERT INTO domain_events(event_id) VALUES(?)').run('event-1'); + if (includeCanonicalEvent) { + db.prepare('INSERT INTO canonical_event_headers(event_id) VALUES(?)').run('event-1'); + } + db.prepare(`INSERT INTO domain_event_projection_jobs( + job_id,event_id,projector_name,state,attempts,claim_token,lease_expires_at, + next_attempt_at,last_error,created_at,updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run( + 'job-1', + 'event-1', + 'message-v1', + 'failed', + 7, + 'claim-7', + '2026-08-14T01:02:03.000Z', + '2026-08-14T01:03:04.000Z', + 'projection failed after durable enqueue', + '2026-08-14T01:00:00.000Z', + '2026-08-14T01:01:00.000Z' + ); +} + +function foreignKeys(db) { + return db.prepare('PRAGMA foreign_key_list(domain_event_projection_jobs)').all(); +} + +function claimIndexColumns(db) { + const exists = db.prepare(`SELECT name FROM sqlite_master + WHERE type='index' AND name='idx_domain_event_projection_jobs_claim'`).get(); + if (!exists) return []; + return db.prepare("PRAGMA index_info('idx_domain_event_projection_jobs_claim')") + .all().map(row => String(row.name)); +} + +function rowSnapshot(db) { + const row = db.prepare(`SELECT ${EXPECTED_COLUMNS.join(',')} + FROM domain_event_projection_jobs WHERE job_id='job-1'`).get(); + return Object.fromEntries(EXPECTED_COLUMNS.map(column => [column, row?.[column]])); +} + +function loadMigration() { + return require('../../../migrations/architectureClosureV2DomainEventProjectionJobsCanonical'); +} + +test('fresh R32 bootstrap owns durable projection jobs through the canonical event ledger', () => withTempRoot( + 'yance-acv2-projection-job-bootstrap-', + root => { + const store = new R32SqliteStore({ dbPath: path.join(root, 'yance-r32.db') }); + try { + const keys = foreignKeys(store.db); + assert.equal(keys.length, 1, 'domain_event_projection_jobs must have one event identity foreign key'); + assert.equal(String(keys[0].from), 'event_id'); + assert.equal(String(keys[0].to), 'event_id'); + assert.equal( + String(keys[0].table), + 'canonical_event_headers', + 'durable projection jobs must not remain anchored to the retired legacy domain_events ledger' + ); + assert.equal(String(keys[0].on_delete).toUpperCase(), 'CASCADE'); + assert.deepEqual( + store.db.prepare('PRAGMA table_info(domain_event_projection_jobs)').all().map(row => String(row.name)), + EXPECTED_COLUMNS + ); + assert.deepEqual(claimIndexColumns(store.db), ['state', 'next_attempt_at', 'created_at']); + assert.equal(SCHEMA_VERSION, 23, 'R32 schema version must register the forward canonical projection-job migration'); + } finally { + store.close(); + } + } +)); + +test('Schema 23 rebuild preserves every durable job field while moving only event identity authority', () => withRawDatabase(db => { + createLegacyFixture(db); + const before = rowSnapshot(db); + const migration = loadMigration(); + + const first = migration.applyArchitectureClosureV2DomainEventProjectionJobsCanonical(db); + assert.equal(first.migrationId, migration.MIGRATION_ID); + assert.equal(first.targetSchemaVersion, 23); + assert.equal(first.checksum, migration.MIGRATION_CHECKSUM); + + assert.deepEqual(rowSnapshot(db), before, 'migration must preserve retry, claim, error, state and timestamps byte-for-byte'); + assert.deepEqual( + foreignKeys(db).map(row => ({ + table: String(row.table), + from: String(row.from), + to: String(row.to), + onDelete: String(row.on_delete).toUpperCase() + })), + [{ table: 'canonical_event_headers', from: 'event_id', to: 'event_id', onDelete: 'CASCADE' }] + ); + assert.deepEqual(claimIndexColumns(db), ['state', 'next_attempt_at', 'created_at']); + assert.deepEqual( + db.prepare('PRAGMA table_info(domain_event_projection_jobs)').all().map(row => String(row.name)), + EXPECTED_COLUMNS + ); + assert.equal(db.prepare('PRAGMA foreign_key_check(domain_event_projection_jobs)').all().length, 0); + + const receipt = db.prepare(`SELECT target_schema_version,status,checksum + FROM r32_schema_migrations WHERE migration_id=?`).get(migration.MIGRATION_ID); + assert.equal(Number(receipt.target_schema_version), 23); + assert.equal(String(receipt.status), 'completed'); + assert.equal(String(receipt.checksum), migration.MIGRATION_CHECKSUM); + assert.equal(JSON.parse(db.prepare("SELECT value_json FROM r32_meta WHERE key='schema_version'").get().value_json), 23); + assert.equal(JSON.parse(db.prepare("SELECT value_json FROM r32_meta WHERE key='schemaVersion'").get().value_json), 23); + + const second = migration.applyArchitectureClosureV2DomainEventProjectionJobsCanonical(db); + assert.equal(second.checksum, first.checksum); + assert.deepEqual(rowSnapshot(db), before, 'idempotent re-entry must not rewrite durable projection jobs'); +})); + +test('Schema 23 fails closed before rebuild when a durable job has no canonical event header', () => withRawDatabase(db => { + createLegacyFixture(db, { includeCanonicalEvent: false }); + const before = rowSnapshot(db); + const migration = loadMigration(); + + assert.throws( + () => migration.applyArchitectureClosureV2DomainEventProjectionJobsCanonical(db), + error => error?.code === 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_CANONICAL_EVENT_MISSING' + && error?.eventId === 'event-1' + ); + + assert.deepEqual(rowSnapshot(db), before, 'orphan rejection must leave the durable job untouched'); + assert.equal(String(foreignKeys(db)[0].table), 'domain_events', 'failed migration must leave the legacy table definition intact'); + assert.deepEqual(claimIndexColumns(db), ['state', 'next_attempt_at', 'created_at']); + const receipt = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='r32_schema_migrations'").get() + ? db.prepare('SELECT * FROM r32_schema_migrations WHERE migration_id=?').get(migration.MIGRATION_ID) + : undefined; + assert.equal(receipt, undefined, 'failed migration must not claim completion'); +})); + +test('Schema 23 migration receipt is checksum-pinned and cannot bless altered history', () => withRawDatabase(db => { + createLegacyFixture(db); + const migration = loadMigration(); + db.exec(`CREATE TABLE r32_schema_migrations( + migration_id TEXT PRIMARY KEY, + target_schema_version INTEGER NOT NULL, + status TEXT NOT NULL, + checksum TEXT NOT NULL DEFAULT '', + started_at TEXT NOT NULL, + completed_at TEXT NOT NULL DEFAULT '', + report_json TEXT NOT NULL DEFAULT '{}' + ) STRICT;`); + db.prepare(`INSERT INTO r32_schema_migrations( + migration_id,target_schema_version,status,checksum,started_at,completed_at,report_json + ) VALUES(?,23,'completed',?,'2026-08-14T00:00:00.000Z','2026-08-14T00:00:00.000Z','{}')`) + .run(migration.MIGRATION_ID, '0'.repeat(64)); + + assert.throws( + () => migration.applyArchitectureClosureV2DomainEventProjectionJobsCanonical(db), + error => error?.code === 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_MIGRATION_CHECKSUM_MISMATCH' + && error?.expectedChecksum === migration.MIGRATION_CHECKSUM + && error?.actualChecksum === '0'.repeat(64) + ); + assert.equal(String(foreignKeys(db)[0].table), 'domain_events'); +})); diff --git a/backend/tests/batch27DeveloperHandoffV2Closure.test.js b/backend/tests/batch27DeveloperHandoffV2Closure.test.js index 6be3ba1ad..f6b099ded 100644 --- a/backend/tests/batch27DeveloperHandoffV2Closure.test.js +++ b/backend/tests/batch27DeveloperHandoffV2Closure.test.js @@ -83,7 +83,7 @@ test.after(() => { test('current schema preserves Batch27 structured unknown, learning ledger, AI physical state and recovery metrics', () => { const f = fixture('yance-b27-current-schema-'); try { - assert.equal(SCHEMA_VERSION, 22); + assert.equal(SCHEMA_VERSION, 23); assert.equal(Number(f.store.getMeta('schema_version')), SCHEMA_VERSION); const tables = new Set(f.store.db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(row => row.name)); for (const name of ['learning_signal_ledger','ai_provider_physical_execution_state','durable_recovery_metrics']) assert.equal(tables.has(name), true, name); From d4bb8581f2109e76ed271be2708fbf3b5964b17a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A5=E9=92=B1?= Date: Fri, 14 Aug 2026 21:30:28 +0700 Subject: [PATCH 2/2] fix(acv2): canonicalize Schema 23 projection-job authority Yance-Failure-First-Red-Head: 9d565a29a180db8133f8f763e2d1424d04534872 Yance-Failure-First-Red-Run: 31809691243 Yance-Failure-First-Red-Conclusion: failure --- backend/lib/r32SqliteStore.js | 15 +- ...ureV2DomainEventProjectionJobsCanonical.js | 349 ++++++++++++++++++ 2 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/architectureClosureV2DomainEventProjectionJobsCanonical.js diff --git a/backend/lib/r32SqliteStore.js b/backend/lib/r32SqliteStore.js index 635cc14df..ef1e7f867 100644 --- a/backend/lib/r32SqliteStore.js +++ b/backend/lib/r32SqliteStore.js @@ -60,6 +60,12 @@ const { applyArchitectureClosureV2WpA, TARGET_SCHEMA_VERSION: ACV2_WP_A_SCHEMA_VERSION } = require('../migrations/architectureClosureV2WpA'); +const { + applyArchitectureClosureV2DomainEventProjectionJobsCanonical, + isArchitectureClosureV2DomainEventProjectionJobsCanonicalApplied, + ensureArchitectureClosureV2WpABaseForSchema23Reentry, + TARGET_SCHEMA_VERSION: ACV2_DOMAIN_EVENT_PROJECTION_JOBS_CANONICAL_SCHEMA_VERSION +} = require('../migrations/architectureClosureV2DomainEventProjectionJobsCanonical'); const { ensureCanonicalProjectionReceiptSchema } = require('../migrations/projectionReceiptSchemaAuthority'); const { acquireAuthorityWriteHost, @@ -72,7 +78,7 @@ const { SqliteTransactionCoordinator } = require('../store/sqliteTransactionCoor // M5 — schema-version governance. Bump this only when a forward migration is // shipped; an older binary opening a newer DB must fail fast (downgrade risk), // never silently corrupt. -const SCHEMA_VERSION = Math.max(STAGE634_SCHEMA_VERSION, ROUND12_SCHEMA_VERSION, ROUND12_13_HARDENING_SCHEMA_VERSION, ROUND12_13_REMAINING_SCHEMA_VERSION, ROUND12_13_FINAL_GOVERNANCE_SCHEMA_VERSION, ROUND12_13_FINAL_SEVEN_SCHEMA_VERSION, BATCH22_IDENTITY_ROUTE_SCHEMA_VERSION, BATCH24_STATE_TRANSACTION_SCHEMA_VERSION, BATCH26_PLATFORM_AI_LEARNING_SCHEMA_VERSION, BATCH27_DEVELOPER_HANDOFF_SCHEMA_VERSION, BATCH41_FIX6M_SCHEMA_VERSION, BATCH42_FIX6O_SCHEMA_VERSION, ACV2_WP_A_SCHEMA_VERSION); +const SCHEMA_VERSION = Math.max(STAGE634_SCHEMA_VERSION, ROUND12_SCHEMA_VERSION, ROUND12_13_HARDENING_SCHEMA_VERSION, ROUND12_13_REMAINING_SCHEMA_VERSION, ROUND12_13_FINAL_GOVERNANCE_SCHEMA_VERSION, ROUND12_13_FINAL_SEVEN_SCHEMA_VERSION, BATCH22_IDENTITY_ROUTE_SCHEMA_VERSION, BATCH24_STATE_TRANSACTION_SCHEMA_VERSION, BATCH26_PLATFORM_AI_LEARNING_SCHEMA_VERSION, BATCH27_DEVELOPER_HANDOFF_SCHEMA_VERSION, BATCH41_FIX6M_SCHEMA_VERSION, BATCH42_FIX6O_SCHEMA_VERSION, ACV2_WP_A_SCHEMA_VERSION, ACV2_DOMAIN_EVENT_PROJECTION_JOBS_CANONICAL_SCHEMA_VERSION); function nowIso() { return new Date().toISOString(); @@ -1144,7 +1150,12 @@ class R32SqliteStore { applyBatch27DeveloperHandoffV2Closure(this.db); applyBatch41Fix6MArchitectureReferenceClosure(this.db); applyBatch42Fix6OScopedSafetyAndOmnichannelRuntime(this.db); - applyArchitectureClosureV2WpA(this.db); + if (isArchitectureClosureV2DomainEventProjectionJobsCanonicalApplied(this.db)) { + ensureArchitectureClosureV2WpABaseForSchema23Reentry(this.db); + } else { + applyArchitectureClosureV2WpA(this.db); + } + applyArchitectureClosureV2DomainEventProjectionJobsCanonical(this.db); ensureCanonicalProjectionReceiptSchema(this.db); } diff --git a/backend/migrations/architectureClosureV2DomainEventProjectionJobsCanonical.js b/backend/migrations/architectureClosureV2DomainEventProjectionJobsCanonical.js new file mode 100644 index 000000000..0779fc4bc --- /dev/null +++ b/backend/migrations/architectureClosureV2DomainEventProjectionJobsCanonical.js @@ -0,0 +1,349 @@ +'use strict'; + +const crypto = require('node:crypto'); +const { + isArchitectureClosureV2WpAIntegrityApplied, + ensureConsistency: ensureArchitectureClosureV2WpAIntegrityConsistency +} = require('./architectureClosureV2WpAIntegrity'); + +const MIGRATION_ID = '023_architecture_closure_v2_domain_event_projection_jobs_canonical'; +const TARGET_SCHEMA_VERSION = 23; +const TABLE = 'domain_event_projection_jobs'; +const LEGACY_TABLE = 'domain_event_projection_jobs_legacy_v22'; +const CLAIM_INDEX = 'idx_domain_event_projection_jobs_claim'; +const EXPECTED_COLUMNS = Object.freeze([ + 'job_id', + 'event_id', + 'projector_name', + 'state', + 'attempts', + 'claim_token', + 'lease_expires_at', + 'next_attempt_at', + 'last_error', + 'created_at', + 'updated_at' +]); +const CLAIM_INDEX_COLUMNS = Object.freeze(['state', 'next_attempt_at', 'created_at']); +const MIGRATION_CONTRACT = Object.freeze({ + authority: 'AuthorityWriteHost', + schemaVersion: TARGET_SCHEMA_VERSION, + table: TABLE, + columns: EXPECTED_COLUMNS, + states: Object.freeze(['pending', 'processing', 'applied', 'failed', 'quarantined']), + canonicalForeignKey: Object.freeze({ + from: 'event_id', + table: 'canonical_event_headers', + to: 'event_id', + onDelete: 'CASCADE' + }), + legacyForeignKey: Object.freeze({ + from: 'event_id', + table: 'domain_events', + to: 'event_id', + onDelete: 'CASCADE' + }), + claimIndex: Object.freeze({ name: CLAIM_INDEX, columns: CLAIM_INDEX_COLUMNS }), + orphanPolicy: 'FAIL_CLOSED_BEFORE_TABLE_REBUILD', + dataLossAllowed: false, + syntheticCanonicalEventAllowed: false, + historicalMigrationRewriteAllowed: false +}); +const MIGRATION_CHECKSUM = crypto.createHash('sha256') + .update(JSON.stringify({ migrationId: MIGRATION_ID, contract: MIGRATION_CONTRACT })) + .digest('hex'); + +function nowIso() { return new Date().toISOString(); } +function tableExists(db, table) { + return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table)); +} +function ensureMigrationTable(db) { + db.exec(`CREATE TABLE IF NOT EXISTS r32_schema_migrations( + migration_id TEXT PRIMARY KEY, + target_schema_version INTEGER NOT NULL, + status TEXT NOT NULL, + checksum TEXT NOT NULL DEFAULT '', + started_at TEXT NOT NULL, + completed_at TEXT NOT NULL DEFAULT '', + report_json TEXT NOT NULL DEFAULT '{}' + ) STRICT;`); +} +function ensureMetaTable(db) { + db.exec(`CREATE TABLE IF NOT EXISTS r32_meta( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ) STRICT;`); +} +function readSchemaVersion(db) { + if (!tableExists(db, 'r32_meta')) return 0; + const rows = db.prepare("SELECT value_json FROM r32_meta WHERE key IN ('schema_version','schemaVersion')").all(); + let highest = 0; + for (const row of rows) { + let value; + try { value = JSON.parse(row.value_json); } catch (_) { value = row.value_json; } + const numeric = Number(value); + if (!Number.isInteger(numeric) || numeric < 0) { + const error = new Error('Schema 23 projection-job migration found invalid schema-version metadata'); + error.code = 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_SCHEMA_VERSION_INVALID'; + error.value = row.value_json; + throw error; + } + highest = Math.max(highest, numeric); + } + return highest; +} +function setSchemaVersionAtLeast(db, value, at = nowIso()) { + ensureMetaTable(db); + const encoded = JSON.stringify(Math.max(Number(value), readSchemaVersion(db))); + const statement = db.prepare(`INSERT INTO r32_meta(key,value_json,updated_at) VALUES(?,?,?) + ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json,updated_at=excluded.updated_at`); + statement.run('schema_version', encoded, at); + statement.run('schemaVersion', encoded, at); +} +function actualColumns(db, table = TABLE) { + return db.prepare(`PRAGMA table_info(${table})`).all().map(row => String(row.name)); +} +function assertExactColumns(db, table = TABLE) { + const actual = actualColumns(db, table); + if (JSON.stringify(actual) !== JSON.stringify(EXPECTED_COLUMNS)) { + const error = new Error(`Schema 23 ${TABLE} columns do not match the frozen durable-job contract`); + error.code = 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_COLUMNS_MISMATCH'; + error.expectedColumns = [...EXPECTED_COLUMNS]; + error.actualColumns = actual; + throw error; + } +} +function foreignKeys(db, table = TABLE) { + return db.prepare(`PRAGMA foreign_key_list(${table})`).all().map(row => ({ + table: String(row.table), + from: String(row.from), + to: String(row.to), + onDelete: String(row.on_delete).toUpperCase() + })); +} +function sameForeignKey(actual, expected) { + return actual.length === 1 + && actual[0].table === expected.table + && actual[0].from === expected.from + && actual[0].to === expected.to + && actual[0].onDelete === expected.onDelete; +} +function claimIndexColumns(db) { + const index = db.prepare("SELECT 1 FROM sqlite_master WHERE type='index' AND name=?").get(CLAIM_INDEX); + if (!index) return []; + return db.prepare(`PRAGMA index_info(${CLAIM_INDEX})`).all().map(row => String(row.name)); +} +function hasUniqueEventId(db) { + return db.prepare(`SELECT 1 + FROM pragma_index_list('${TABLE}') il + WHERE il.[unique]=1 + AND (SELECT GROUP_CONCAT(ii.name,',') FROM pragma_index_info(il.name) ii)='event_id' + LIMIT 1`).get() != null; +} +function assertNoCanonicalOrphans(db) { + const orphan = db.prepare(`SELECT jobs.event_id AS event_id + FROM ${TABLE} jobs + LEFT JOIN canonical_event_headers headers ON headers.event_id=jobs.event_id + WHERE headers.event_id IS NULL + ORDER BY jobs.created_at,jobs.job_id + LIMIT 1`).get(); + if (!orphan) return; + const error = new Error('Durable projection job has no canonical event header; refusing Schema 23 rebuild'); + error.code = 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_CANONICAL_EVENT_MISSING'; + error.eventId = String(orphan.event_id || ''); + throw error; +} +function ensureBaseTables(db) { + for (const table of [TABLE, 'canonical_event_headers']) { + if (!tableExists(db, table)) { + const error = new Error(`Schema 23 projection-job migration requires ${table}`); + error.code = 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_BASE_TABLE_MISSING'; + error.table = table; + throw error; + } + } + assertExactColumns(db); +} +function ensureConsistency(db) { + ensureBaseTables(db); + assertNoCanonicalOrphans(db); + const keys = foreignKeys(db); + if (!sameForeignKey(keys, MIGRATION_CONTRACT.canonicalForeignKey)) { + const error = new Error('Schema 23 durable projection-job event identity is not canonical'); + error.code = 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_CANONICAL_FK_MISSING'; + error.actualForeignKeys = keys; + throw error; + } + if (!hasUniqueEventId(db)) { + const error = new Error('Schema 23 durable projection-job event identity uniqueness is missing'); + error.code = 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_EVENT_ID_UNIQUE_MISSING'; + throw error; + } + const actualIndex = claimIndexColumns(db); + if (JSON.stringify(actualIndex) !== JSON.stringify(CLAIM_INDEX_COLUMNS)) { + const error = new Error('Schema 23 durable projection-job claim index does not match its frozen contract'); + error.code = 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_CLAIM_INDEX_MISMATCH'; + error.expectedColumns = [...CLAIM_INDEX_COLUMNS]; + error.actualColumns = actualIndex; + throw error; + } + const violation = db.prepare(`PRAGMA foreign_key_check(${TABLE})`).get(); + if (violation) { + const error = new Error('Schema 23 durable projection-job foreign-key integrity check failed'); + error.code = 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_FOREIGN_KEY_VIOLATION'; + error.violation = violation; + throw error; + } +} +function rebuildToCanonicalForeignKey(db) { + ensureBaseTables(db); + assertNoCanonicalOrphans(db); + const keys = foreignKeys(db); + if (sameForeignKey(keys, MIGRATION_CONTRACT.canonicalForeignKey)) { + const error = new Error('Schema 23 canonical projection-job table exists without its migration receipt'); + error.code = 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_CANONICAL_TABLE_UNRECORDED'; + throw error; + } + if (!sameForeignKey(keys, MIGRATION_CONTRACT.legacyForeignKey)) { + const error = new Error('Schema 23 refuses to rebuild a projection-job table with an unknown foreign-key contract'); + error.code = 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_LEGACY_FK_MISMATCH'; + error.expectedForeignKey = MIGRATION_CONTRACT.legacyForeignKey; + error.actualForeignKeys = keys; + throw error; + } + if (tableExists(db, LEGACY_TABLE)) { + const error = new Error(`Schema 23 temporary table already exists: ${LEGACY_TABLE}`); + error.code = 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_TEMP_TABLE_PRESENT'; + throw error; + } + + db.exec(`ALTER TABLE ${TABLE} RENAME TO ${LEGACY_TABLE};`); + db.exec(`CREATE TABLE ${TABLE}( + job_id TEXT PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE, + projector_name TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('pending','processing','applied','failed','quarantined')), + attempts INTEGER NOT NULL DEFAULT 0, + claim_token TEXT NOT NULL DEFAULT '', + lease_expires_at TEXT NOT NULL DEFAULT '', + next_attempt_at TEXT NOT NULL DEFAULT '', + last_error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(event_id) REFERENCES canonical_event_headers(event_id) ON DELETE CASCADE + ) STRICT;`); + db.exec(`INSERT INTO ${TABLE}(${EXPECTED_COLUMNS.join(',')}) + SELECT ${EXPECTED_COLUMNS.join(',')} FROM ${LEGACY_TABLE};`); + db.exec(`DROP TABLE ${LEGACY_TABLE};`); + db.exec(`CREATE INDEX ${CLAIM_INDEX} ON ${TABLE}(${CLAIM_INDEX_COLUMNS.join(',')});`); + return true; +} +function migrationReceipt(db) { + if (!tableExists(db, 'r32_schema_migrations')) return null; + return db.prepare('SELECT * FROM r32_schema_migrations WHERE migration_id=?').get(MIGRATION_ID) || null; +} +function verifyReceipt(existing) { + if (!existing) return false; + if (String(existing.checksum || '') !== MIGRATION_CHECKSUM) { + const error = new Error('Schema 23 projection-job canonical migration checksum mismatch'); + error.code = 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_MIGRATION_CHECKSUM_MISMATCH'; + error.expectedChecksum = MIGRATION_CHECKSUM; + error.actualChecksum = String(existing.checksum || ''); + throw error; + } + if (Number(existing.target_schema_version) !== TARGET_SCHEMA_VERSION) { + const error = new Error('Schema 23 projection-job canonical migration target version mismatch'); + error.code = 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_MIGRATION_TARGET_MISMATCH'; + error.expectedTargetSchemaVersion = TARGET_SCHEMA_VERSION; + error.actualTargetSchemaVersion = Number(existing.target_schema_version); + throw error; + } + if (String(existing.status || '') !== 'completed') { + const error = new Error('Schema 23 projection-job canonical migration is not completed'); + error.code = 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_MIGRATION_INCOMPLETE'; + throw error; + } + return true; +} +function isArchitectureClosureV2DomainEventProjectionJobsCanonicalApplied(db) { + const existing = migrationReceipt(db); + if (!existing && readSchemaVersion(db) >= TARGET_SCHEMA_VERSION) { + const error = new Error('Schema metadata claims v23 without the projection-job canonical migration receipt'); + error.code = 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_SCHEMA_VERSION_UNRECORDED'; + error.schemaVersion = readSchemaVersion(db); + throw error; + } + return verifyReceipt(existing); +} +function ensureArchitectureClosureV2WpABaseForSchema23Reentry(db) { + if (!isArchitectureClosureV2WpAIntegrityApplied(db)) { + const error = new Error('Schema 23 projection-job migration requires completed Schema 22 ACV2 integrity authority'); + error.code = 'ACV2_DOMAIN_EVENT_PROJECTION_JOB_WP_A_INTEGRITY_REQUIRED'; + throw error; + } + ensureArchitectureClosureV2WpAIntegrityConsistency(db); +} +function result() { + return Object.freeze({ + migrationId: MIGRATION_ID, + targetSchemaVersion: TARGET_SCHEMA_VERSION, + checksum: MIGRATION_CHECKSUM, + table: TABLE, + canonicalForeignKey: MIGRATION_CONTRACT.canonicalForeignKey, + claimIndex: MIGRATION_CONTRACT.claimIndex + }); +} +function applyArchitectureClosureV2DomainEventProjectionJobsCanonical(db) { + ensureMigrationTable(db); + const existing = migrationReceipt(db); + if (verifyReceipt(existing)) { + ensureConsistency(db); + setSchemaVersionAtLeast(db, TARGET_SCHEMA_VERSION); + return result(); + } + + const at = nowIso(); + db.exec('SAVEPOINT acv2_domain_event_projection_jobs_canonical_v23'); + try { + const rebuilt = rebuildToCanonicalForeignKey(db); + ensureConsistency(db); + setSchemaVersionAtLeast(db, TARGET_SCHEMA_VERSION, at); + const report = JSON.stringify({ + authority: 'AuthorityWriteHost', + schemaVersion: TARGET_SCHEMA_VERSION, + migrationChecksum: MIGRATION_CHECKSUM, + table: TABLE, + rebuilt, + canonicalForeignKey: MIGRATION_CONTRACT.canonicalForeignKey, + claimIndex: MIGRATION_CONTRACT.claimIndex, + orphanPolicy: MIGRATION_CONTRACT.orphanPolicy, + dataLossAllowed: false + }); + db.prepare(`INSERT INTO r32_schema_migrations( + migration_id,target_schema_version,status,checksum,started_at,completed_at,report_json + ) VALUES(?,?,?,?,?,?,?)`) + .run(MIGRATION_ID, TARGET_SCHEMA_VERSION, 'completed', MIGRATION_CHECKSUM, at, at, report); + db.exec('RELEASE SAVEPOINT acv2_domain_event_projection_jobs_canonical_v23'); + } catch (error) { + try { db.exec('ROLLBACK TO SAVEPOINT acv2_domain_event_projection_jobs_canonical_v23'); } catch (_) {} + try { db.exec('RELEASE SAVEPOINT acv2_domain_event_projection_jobs_canonical_v23'); } catch (_) {} + throw error; + } + return result(); +} + +module.exports = { + MIGRATION_ID, + TARGET_SCHEMA_VERSION, + TABLE, + CLAIM_INDEX, + EXPECTED_COLUMNS, + CLAIM_INDEX_COLUMNS, + MIGRATION_CONTRACT, + MIGRATION_CHECKSUM, + isArchitectureClosureV2DomainEventProjectionJobsCanonicalApplied, + ensureArchitectureClosureV2WpABaseForSchema23Reentry, + ensureConsistency, + applyArchitectureClosureV2DomainEventProjectionJobsCanonical +};