fix(hub): reconcile divergent sqlite schema migrations

This commit is contained in:
weishu
2026-07-28 12:27:33 +08:00
parent 92958890de
commit b93b789238
3 changed files with 64 additions and 7 deletions
+11 -1
View File
@@ -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<string> {
const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
return new Set(rows.map((row) => row.name))
+2 -2
View File
@@ -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
+51 -4
View File
@@ -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 (