From b93b7892381f278fe59728db9daeb43a6884a5e7 Mon Sep 17 00:00:00 2001 From: weishu Date: Tue, 28 Jul 2026 12:27:33 +0800 Subject: [PATCH] fix(hub): reconcile divergent sqlite schema migrations --- hub/src/store/index.ts | 12 ++++++- hub/src/store/migration-v12.test.ts | 4 +-- hub/src/store/migration-v13.test.ts | 55 ++++++++++++++++++++++++++--- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index b189382e..0de49c95 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -29,7 +29,7 @@ export { ScratchlistStore } from './scratchlistStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' -const SCHEMA_VERSION: number = 13 +const SCHEMA_VERSION: number = 14 const REQUIRED_TABLES = [ 'sessions', 'machines', @@ -140,6 +140,7 @@ export class Store { 10: () => this.migrateFromV10ToV11(), 11: () => this.migrateFromV11ToV12(), 12: () => this.migrateFromV12ToV13(), + 13: () => this.migrateFromV13ToV14(), }) if (currentVersion === 0) { @@ -528,6 +529,9 @@ export class Store { } private migrateFromV12ToV13(): void { + // Two development branches previously used schema v12 for different + // tables. Reconcile both shapes before advancing the version. + this.migrateFromV11ToV12() this.db.exec(` CREATE TABLE IF NOT EXISTS message_epochs ( session_id TEXT PRIMARY KEY, @@ -537,6 +541,12 @@ export class Store { `) } + private migrateFromV13ToV14(): void { + // Repair v13 databases produced before the divergent v12 migrations + // were reconciled. Both underlying migrations are idempotent. + this.migrateFromV12ToV13() + } + private getSessionColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/migration-v12.test.ts b/hub/src/store/migration-v12.test.ts index 82e07a7a..058a6f75 100644 --- a/hub/src/store/migration-v12.test.ts +++ b/hub/src/store/migration-v12.test.ts @@ -32,7 +32,7 @@ describe('Store V11→V12 migration: session_scratchlist table', () => { expect(rows).toHaveLength(1) }) - it('V11 DB migrates through V13 via Store: session_scratchlist created', () => { + it('V11 DB migrates through V14 via Store: session_scratchlist created', () => { const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v12-test-')) const dbPath = join(dir, 'test.db') let store: Store | undefined @@ -61,7 +61,7 @@ describe('Store V11→V12 migration: session_scratchlist table', () => { } }) - it('V9 DB migrates through V13 (multi-hop service_tier + fcm_devices + scratchlist)', () => { + it('V9 DB migrates through V14 (multi-hop service_tier + fcm_devices + scratchlist)', () => { const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v9-to-v12-')) const dbPath = join(dir, 'test.db') let store: Store | undefined diff --git a/hub/src/store/migration-v13.test.ts b/hub/src/store/migration-v13.test.ts index 4b11fe35..990b32bb 100644 --- a/hub/src/store/migration-v13.test.ts +++ b/hub/src/store/migration-v13.test.ts @@ -5,15 +5,17 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { Store } from './index' -describe('Store V12→V13 migration: message_epochs', () => { - it('fresh DB has message_epochs table', () => { +describe('Store V12/V13→V14 schema reconciliation', () => { + it('fresh DB has both reconciled tables', () => { const store = new Store(':memory:') expect(tableExists(store, 'message_epochs')).toBe(true) + expect(tableExists(store, 'session_scratchlist')).toBe(true) + expect(getUserVersion(store)).toBe(14) store.close() }) - it('V12 DB migrates to V13 and preserves existing messages', () => { - const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v13-test-')) + it('scratchlist V12 DB migrates to V14 and preserves existing messages', () => { + const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v14-test-')) const dbPath = join(dir, 'test.db') let store: Store | undefined try { @@ -31,6 +33,8 @@ describe('Store V12→V13 migration: message_epochs', () => { store = new Store(dbPath) expect(tableExists(store, 'message_epochs')).toBe(true) + expect(tableExists(store, 'session_scratchlist')).toBe(true) + expect(getUserVersion(store)).toBe(14) expect(store.messages.getMessageEpoch('session-1')).toBe(0) expect(store.messages.getMessages('session-1')).toHaveLength(1) } finally { @@ -38,6 +42,43 @@ describe('Store V12→V13 migration: message_epochs', () => { rmSync(dir, { recursive: true, force: true }) } }) + + it.each([ + ['V12', 12], + ['V13', 13] + ] as const)('repairs divergent %s DB missing session_scratchlist', (_label, version) => { + const dir = mkdtempSync(join(tmpdir(), `hapi-migration-v${version}-repair-test-`)) + const dbPath = join(dir, 'test.db') + let store: Store | undefined + try { + const db = new Database(dbPath, { create: true, readwrite: true, strict: true }) + db.exec('PRAGMA journal_mode = WAL') + db.exec('PRAGMA foreign_keys = ON') + createV12Schema(db) + db.exec(` + DROP TABLE session_scratchlist; + CREATE TABLE message_epochs ( + session_id TEXT PRIMARY KEY, + epoch INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + INSERT INTO sessions (id, created_at, updated_at) VALUES ('session-1', 1, 1); + INSERT INTO messages (id, session_id, content, created_at, seq, invoked_at) + VALUES ('message-1', 'session-1', '{}', 1, 1, 1); + PRAGMA user_version = ${version}; + `) + db.close() + + store = new Store(dbPath) + expect(tableExists(store, 'message_epochs')).toBe(true) + expect(tableExists(store, 'session_scratchlist')).toBe(true) + expect(getUserVersion(store)).toBe(14) + expect(store.messages.getMessages('session-1')).toHaveLength(1) + } finally { + store?.close() + rmSync(dir, { recursive: true, force: true }) + } + }) }) function tableExists(store: Store, name: string): boolean { @@ -48,6 +89,12 @@ function tableExists(store: Store, name: string): boolean { return row !== null } +function getUserVersion(store: Store): number { + const db: Database = (store as unknown as { db: Database }).db + const row = db.prepare('PRAGMA user_version').get() as { user_version: number } + return row.user_version +} + function createV12Schema(db: Database): void { db.exec(` CREATE TABLE sessions (