Skip to content

Commit 2ead469

Browse files
committed
fix(store): add explicit v8->v9 report schema migration
1 parent b0c9574 commit 2ead469

2 files changed

Lines changed: 109 additions & 1 deletion

File tree

hub/src/store/index.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export { UserStore } from './userStore'
4343
export { GroupStore } from './groupStore'
4444
export { ReportStore } from './reportStore'
4545

46-
const SCHEMA_VERSION: number = 8
46+
const SCHEMA_VERSION: number = 9
4747
const REQUIRED_TABLES = [
4848
'sessions',
4949
'machines',
@@ -192,6 +192,13 @@ export class Store {
192192
return
193193
}
194194

195+
if (currentVersion === 8 && SCHEMA_VERSION >= 9) {
196+
this.migrateFromV8ToV9()
197+
this.setUserVersion(9)
198+
this.initSchema()
199+
return
200+
}
201+
195202
if (currentVersion !== SCHEMA_VERSION) {
196203
throw this.buildSchemaMismatchError(currentVersion)
197204
}
@@ -794,6 +801,69 @@ export class Store {
794801
}
795802
}
796803

804+
private migrateFromV8ToV9(): void {
805+
try {
806+
this.db.exec('BEGIN')
807+
this.db.exec(`
808+
CREATE TABLE IF NOT EXISTS reports (
809+
id TEXT PRIMARY KEY,
810+
namespace TEXT NOT NULL DEFAULT 'default',
811+
session_id TEXT,
812+
task_id TEXT,
813+
title TEXT NOT NULL,
814+
status TEXT NOT NULL DEFAULT 'unknown',
815+
markdown TEXT NOT NULL DEFAULT '',
816+
metadata TEXT,
817+
created_at INTEGER NOT NULL,
818+
updated_at INTEGER NOT NULL,
819+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL
820+
);
821+
CREATE INDEX IF NOT EXISTS idx_reports_namespace_updated
822+
ON reports(namespace, updated_at DESC);
823+
CREATE INDEX IF NOT EXISTS idx_reports_session_namespace
824+
ON reports(session_id, namespace);
825+
826+
CREATE TABLE IF NOT EXISTS report_assets (
827+
id TEXT PRIMARY KEY,
828+
report_id TEXT NOT NULL,
829+
namespace TEXT NOT NULL DEFAULT 'default',
830+
file_name TEXT NOT NULL,
831+
storage_key TEXT NOT NULL,
832+
mime_type TEXT NOT NULL,
833+
size INTEGER NOT NULL,
834+
caption TEXT,
835+
created_at INTEGER NOT NULL,
836+
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE
837+
);
838+
CREATE INDEX IF NOT EXISTS idx_report_assets_report
839+
ON report_assets(report_id, created_at ASC);
840+
CREATE UNIQUE INDEX IF NOT EXISTS idx_report_assets_storage_key
841+
ON report_assets(report_id, storage_key);
842+
843+
CREATE TABLE IF NOT EXISTS report_shares (
844+
id TEXT PRIMARY KEY,
845+
report_id TEXT NOT NULL,
846+
namespace TEXT NOT NULL DEFAULT 'default',
847+
token TEXT NOT NULL UNIQUE,
848+
created_by TEXT,
849+
created_at INTEGER NOT NULL,
850+
expires_at INTEGER,
851+
revoked_at INTEGER,
852+
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE
853+
);
854+
CREATE INDEX IF NOT EXISTS idx_report_shares_report
855+
ON report_shares(report_id, created_at DESC);
856+
CREATE INDEX IF NOT EXISTS idx_report_shares_token
857+
ON report_shares(token);
858+
`)
859+
this.db.exec('COMMIT')
860+
} catch (error) {
861+
this.db.exec('ROLLBACK')
862+
const message = error instanceof Error ? error.message : String(error)
863+
throw new Error(`SQLite schema migration v8->v9 failed: ${message}`)
864+
}
865+
}
866+
797867
private getMachineColumnNames(): Set<string> {
798868
const rows = this.db.prepare('PRAGMA table_info(machines)').all() as Array<{ name: string }>
799869
return new Set(rows.map((row) => row.name))

hub/src/store/schemaRepair.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ const GROUP_TABLES = [
1616
'group_notes'
1717
] as const
1818

19+
const REPORT_TABLES = [
20+
'reports',
21+
'report_assets',
22+
'report_shares'
23+
] as const
24+
1925
function closeStore(store: Store): void {
2026
const db = (store as unknown as { db: Database }).db
2127
db.close()
@@ -63,4 +69,36 @@ describe('Store schema repair', () => {
6369
closeStore(repaired)
6470
rmSync(dir, { recursive: true, force: true })
6571
})
72+
73+
it('runs v8 to v9 migration and creates report tables', () => {
74+
const dir = mkdtempSync(join(tmpdir(), 'haqi-store-repair-'))
75+
const dbPath = join(dir, 'hapi.db')
76+
77+
const seeded = new Store(dbPath)
78+
const seededDb = (seeded as unknown as { db: Database }).db
79+
seededDb.exec(`
80+
DROP TABLE IF EXISTS report_shares;
81+
DROP TABLE IF EXISTS report_assets;
82+
DROP TABLE IF EXISTS reports;
83+
PRAGMA user_version = 8;
84+
`)
85+
closeStore(seeded)
86+
87+
const migrated = new Store(dbPath)
88+
const migratedDb = (migrated as unknown as { db: Database }).db
89+
const versionRow = migratedDb.prepare('PRAGMA user_version').get() as { user_version: number } | undefined
90+
expect(versionRow?.user_version).toBe(9)
91+
92+
const placeholders = REPORT_TABLES.map(() => '?').join(', ')
93+
const rows = migratedDb.prepare(
94+
`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`
95+
).all(...REPORT_TABLES) as Array<{ name: string }>
96+
const tableSet = new Set(rows.map((row) => row.name))
97+
for (const table of REPORT_TABLES) {
98+
expect(tableSet.has(table)).toBe(true)
99+
}
100+
101+
closeStore(migrated)
102+
rmSync(dir, { recursive: true, force: true })
103+
})
66104
})

0 commit comments

Comments
 (0)