From 365728aed79b467c0ade98dff120124bc72df174 Mon Sep 17 00:00:00 2001 From: weishu Date: Sat, 3 Jan 2026 15:52:29 +0800 Subject: [PATCH] refactor(store): introduce schema versioning and remove compatibility migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/store/index.ts | 101 +++++++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 29 deletions(-) diff --git a/server/src/store/index.ts b/server/src/store/index.ts index b5db31ca..f876a80b 100644 --- a/server/src/store/index.ts +++ b/server/src/store/index.ts @@ -66,6 +66,15 @@ export type VersionedUpdateResult = | { 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 {