mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
refactor(store): introduce schema versioning and remove compatibility migrations
Replace legacy migration logic (ALTER TABLE, missing column checks) with explicit schema version tracking. This prevents silent schema mismatches and simplifies the database lifecycle—users must rebuild or offline-migrate on version mismatch. Changes: - Add SCHEMA_VERSION and REQUIRED_TABLES constants - Refactor initSchema() to check version and table presence - Extract createSchema() and helper methods (getUserVersion, setUserVersion, etc) - Add compound indexes for better query performance (tag+namespace, platform+namespace) - Store dbPath for better error messages
This commit is contained in:
+72
-29
@@ -66,6 +66,15 @@ export type VersionedUpdateResult<T> =
|
||||
| { result: 'version-mismatch'; version: number; value: T }
|
||||
| { result: 'error' }
|
||||
|
||||
const SCHEMA_VERSION = 1
|
||||
const REQUIRED_TABLES = [
|
||||
'sessions',
|
||||
'machines',
|
||||
'messages',
|
||||
'users',
|
||||
'push_subscriptions'
|
||||
] as const
|
||||
|
||||
type DbSessionRow = {
|
||||
id: string
|
||||
tag: string | null
|
||||
@@ -203,8 +212,10 @@ function toStoredPushSubscription(row: DbPushSubscriptionRow): StoredPushSubscri
|
||||
|
||||
export class Store {
|
||||
private db: Database
|
||||
private readonly dbPath: string
|
||||
|
||||
constructor(dbPath: string) {
|
||||
this.dbPath = dbPath
|
||||
if (dbPath !== ':memory:' && !dbPath.startsWith('file::memory:')) {
|
||||
const dir = dirname(dbPath)
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
@@ -240,7 +251,25 @@ export class Store {
|
||||
}
|
||||
|
||||
private initSchema(): void {
|
||||
// Step 1: Create tables and indexes that don't depend on new columns
|
||||
const currentVersion = this.getUserVersion()
|
||||
if (currentVersion === 0) {
|
||||
if (this.hasAnyUserTables()) {
|
||||
throw this.buildSchemaMismatchError(currentVersion)
|
||||
}
|
||||
|
||||
this.createSchema()
|
||||
this.setUserVersion(SCHEMA_VERSION)
|
||||
return
|
||||
}
|
||||
|
||||
if (currentVersion !== SCHEMA_VERSION) {
|
||||
throw this.buildSchemaMismatchError(currentVersion)
|
||||
}
|
||||
|
||||
this.assertRequiredTablesPresent()
|
||||
}
|
||||
|
||||
private createSchema(): void {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -260,6 +289,7 @@ export class Store {
|
||||
seq INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_tag ON sessions(tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_tag_namespace ON sessions(tag, namespace);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS machines (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -274,6 +304,7 @@ export class Store {
|
||||
active_at INTEGER,
|
||||
seq INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_machines_namespace ON machines(namespace);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -296,6 +327,7 @@ export class Store {
|
||||
UNIQUE(platform, platform_user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_platform ON users(platform);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_platform_namespace ON users(platform, namespace);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -308,39 +340,50 @@ export class Store {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_namespace ON push_subscriptions(namespace);
|
||||
`)
|
||||
}
|
||||
|
||||
// Step 2: Migrate existing tables (add missing columns)
|
||||
const sessionColumns = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
|
||||
const sessionColumnNames = new Set(sessionColumns.map((c) => c.name))
|
||||
private getUserVersion(): number {
|
||||
const row = this.db.prepare('PRAGMA user_version').get() as { user_version: number } | undefined
|
||||
return row?.user_version ?? 0
|
||||
}
|
||||
|
||||
if (!sessionColumnNames.has('namespace')) {
|
||||
this.db.exec("ALTER TABLE sessions ADD COLUMN namespace TEXT NOT NULL DEFAULT 'default'")
|
||||
}
|
||||
if (!sessionColumnNames.has('todos')) {
|
||||
this.db.exec('ALTER TABLE sessions ADD COLUMN todos TEXT')
|
||||
}
|
||||
if (!sessionColumnNames.has('todos_updated_at')) {
|
||||
this.db.exec('ALTER TABLE sessions ADD COLUMN todos_updated_at INTEGER')
|
||||
}
|
||||
private setUserVersion(version: number): void {
|
||||
this.db.exec(`PRAGMA user_version = ${version}`)
|
||||
}
|
||||
|
||||
const machineColumns = this.db.prepare('PRAGMA table_info(machines)').all() as Array<{ name: string }>
|
||||
const machineColumnNames = new Set(machineColumns.map((c) => c.name))
|
||||
if (!machineColumnNames.has('namespace')) {
|
||||
this.db.exec("ALTER TABLE machines ADD COLUMN namespace TEXT NOT NULL DEFAULT 'default'")
|
||||
}
|
||||
private hasAnyUserTables(): boolean {
|
||||
const row = this.db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1"
|
||||
).get() as { name?: string } | undefined
|
||||
return Boolean(row?.name)
|
||||
}
|
||||
|
||||
const userColumns = this.db.prepare('PRAGMA table_info(users)').all() as Array<{ name: string }>
|
||||
const userColumnNames = new Set(userColumns.map((c) => c.name))
|
||||
if (!userColumnNames.has('namespace')) {
|
||||
this.db.exec("ALTER TABLE users ADD COLUMN namespace TEXT NOT NULL DEFAULT 'default'")
|
||||
}
|
||||
private assertRequiredTablesPresent(): void {
|
||||
const placeholders = REQUIRED_TABLES.map(() => '?').join(', ')
|
||||
const rows = this.db.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`
|
||||
).all(...REQUIRED_TABLES) as Array<{ name: string }>
|
||||
const existing = new Set(rows.map((row) => row.name))
|
||||
const missing = REQUIRED_TABLES.filter((table) => !existing.has(table))
|
||||
|
||||
// Step 3: Create indexes that depend on namespace column (after migration)
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_tag_namespace ON sessions(tag, namespace);
|
||||
CREATE INDEX IF NOT EXISTS idx_machines_namespace ON machines(namespace);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_platform_namespace ON users(platform, namespace);
|
||||
`)
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`SQLite schema is missing required tables (${missing.join(', ')}). ` +
|
||||
'Back up and rebuild the database, or run an offline migration to the expected schema version.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private buildSchemaMismatchError(currentVersion: number): Error {
|
||||
const location = (this.dbPath === ':memory:' || this.dbPath.startsWith('file::memory:'))
|
||||
? 'in-memory database'
|
||||
: this.dbPath
|
||||
return new Error(
|
||||
`SQLite schema version mismatch for ${location}. ` +
|
||||
`Expected ${SCHEMA_VERSION}, found ${currentVersion}. ` +
|
||||
'This build does not run compatibility migrations. ' +
|
||||
'Back up and rebuild the database, or run an offline migration to the expected schema version.'
|
||||
)
|
||||
}
|
||||
|
||||
getOrCreateSession(tag: string, metadata: unknown, agentState: unknown, namespace: string): StoredSession {
|
||||
|
||||
Reference in New Issue
Block a user