ad902bedd9
When a claude-mem DB is synced between machines running different versions, orphaned indexes can reference non-existent columns (e.g. idx_observations_content_hash referencing content_hash). This causes SQLite to throw "malformed database schema" on ALL queries, including PRAGMAs, creating a silent 503 failure loop. The fix detects this on startup, uses Python's sqlite3 module to drop the orphaned schema objects (bun:sqlite doesn't support writable_schema modifications), resets migration versions, and lets the idempotent migration system recreate everything properly. Fixes #1307 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
254 lines
9.3 KiB
TypeScript
254 lines
9.3 KiB
TypeScript
/**
|
|
* Tests for malformed schema repair in Database.ts
|
|
*
|
|
* Mock Justification: NONE (0% mock code)
|
|
* - Uses real SQLite with temp file — tests actual schema repair logic
|
|
* - Uses Python sqlite3 to simulate cross-version schema corruption
|
|
* (bun:sqlite doesn't allow writable_schema modifications)
|
|
* - Covers the cross-machine sync scenario from issue #1307
|
|
*
|
|
* Value: Prevents the silent 503 failure loop when a DB is synced between
|
|
* machines running different claude-mem versions
|
|
*/
|
|
import { describe, it, expect } from 'bun:test';
|
|
import { Database } from 'bun:sqlite';
|
|
import { ClaudeMemDatabase } from '../../../src/services/sqlite/Database.js';
|
|
import { MigrationRunner } from '../../../src/services/sqlite/migrations/runner.js';
|
|
import { existsSync, unlinkSync, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { execFileSync, execSync } from 'child_process';
|
|
|
|
function tempDbPath(): string {
|
|
return join(tmpdir(), `claude-mem-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
|
}
|
|
|
|
function cleanup(path: string): void {
|
|
for (const suffix of ['', '-wal', '-shm']) {
|
|
const p = path + suffix;
|
|
if (existsSync(p)) unlinkSync(p);
|
|
}
|
|
}
|
|
|
|
function hasPython(): boolean {
|
|
try {
|
|
execSync('python3 --version', { stdio: 'pipe' });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Use Python's sqlite3 to corrupt a DB by removing the content_hash column
|
|
* from the observations table definition while leaving the index intact.
|
|
* This simulates what happens when a DB from a newer version is synced.
|
|
*/
|
|
function corruptDbViaPython(dbPath: string): void {
|
|
const script = join(tmpdir(), `corrupt-${Date.now()}.py`);
|
|
writeFileSync(script, `
|
|
import sqlite3, re, sys
|
|
c = sqlite3.connect(sys.argv[1])
|
|
c.execute("PRAGMA writable_schema = ON")
|
|
row = c.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='observations'").fetchone()
|
|
if row:
|
|
new_sql = re.sub(r',\\s*content_hash\\s+TEXT', '', row[0])
|
|
c.execute("UPDATE sqlite_master SET sql = ? WHERE type='table' AND name='observations'", (new_sql,))
|
|
c.execute("PRAGMA writable_schema = OFF")
|
|
c.commit()
|
|
c.close()
|
|
`);
|
|
try {
|
|
execSync(`python3 "${script}" "${dbPath}"`, { timeout: 10000 });
|
|
} finally {
|
|
if (existsSync(script)) unlinkSync(script);
|
|
}
|
|
}
|
|
|
|
describe('Schema repair on malformed database', () => {
|
|
it('should repair a database with an orphaned index referencing a non-existent column', () => {
|
|
if (!hasPython()) {
|
|
console.log('Python3 not available, skipping test');
|
|
return;
|
|
}
|
|
|
|
const dbPath = tempDbPath();
|
|
try {
|
|
// Step 1: Create a valid database with all migrations
|
|
const db = new Database(dbPath, { create: true, readwrite: true });
|
|
db.run('PRAGMA journal_mode = WAL');
|
|
db.run('PRAGMA foreign_keys = ON');
|
|
|
|
const runner = new MigrationRunner(db);
|
|
runner.runAllMigrations();
|
|
|
|
// Verify content_hash column and index exist
|
|
const hasContentHash = db.prepare('PRAGMA table_info(observations)').all()
|
|
.some((col: any) => col.name === 'content_hash');
|
|
expect(hasContentHash).toBe(true);
|
|
|
|
// Checkpoint WAL so all data is in the main file
|
|
db.run('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
db.close();
|
|
|
|
// Step 2: Corrupt the DB
|
|
corruptDbViaPython(dbPath);
|
|
|
|
// Step 3: Verify the DB is actually corrupted
|
|
const corruptDb = new Database(dbPath, { readwrite: true });
|
|
let threw = false;
|
|
try {
|
|
corruptDb.query('SELECT name FROM sqlite_master WHERE type = "table" LIMIT 1').all();
|
|
} catch (e: any) {
|
|
threw = true;
|
|
expect(e.message).toContain('malformed database schema');
|
|
expect(e.message).toContain('idx_observations_content_hash');
|
|
}
|
|
corruptDb.close();
|
|
expect(threw).toBe(true);
|
|
|
|
// Step 4: Open via ClaudeMemDatabase — it should auto-repair
|
|
const repaired = new ClaudeMemDatabase(dbPath);
|
|
|
|
// Verify the DB is functional
|
|
const tables = repaired.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
|
.all() as { name: string }[];
|
|
const tableNames = tables.map(t => t.name);
|
|
expect(tableNames).toContain('observations');
|
|
expect(tableNames).toContain('sdk_sessions');
|
|
|
|
// Verify the index was recreated by the migration runner
|
|
const indexes = repaired.db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_observations_content_hash'")
|
|
.all() as { name: string }[];
|
|
expect(indexes.length).toBe(1);
|
|
|
|
// Verify the content_hash column was re-added by the migration
|
|
const columns = repaired.db.prepare('PRAGMA table_info(observations)').all() as { name: string }[];
|
|
expect(columns.some(c => c.name === 'content_hash')).toBe(true);
|
|
|
|
repaired.close();
|
|
} finally {
|
|
cleanup(dbPath);
|
|
}
|
|
});
|
|
|
|
it('should handle a fresh database without triggering repair', () => {
|
|
const dbPath = tempDbPath();
|
|
try {
|
|
const db = new ClaudeMemDatabase(dbPath);
|
|
const tables = db.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
|
.all() as { name: string }[];
|
|
expect(tables.length).toBeGreaterThan(0);
|
|
db.close();
|
|
} finally {
|
|
cleanup(dbPath);
|
|
}
|
|
});
|
|
|
|
it('should repair a corrupted DB that has no schema_versions table', () => {
|
|
if (!hasPython()) {
|
|
console.log('Python3 not available, skipping test');
|
|
return;
|
|
}
|
|
|
|
const dbPath = tempDbPath();
|
|
const scriptPath = join(tmpdir(), `corrupt-nosv-${Date.now()}.py`);
|
|
try {
|
|
// Build a minimal DB with only a malformed observations table and orphaned index
|
|
// — no schema_versions table. This simulates a partially-initialized DB that was
|
|
// synced before migrations ever ran.
|
|
writeFileSync(scriptPath, `
|
|
import sqlite3, sys
|
|
c = sqlite3.connect(sys.argv[1])
|
|
c.execute('PRAGMA writable_schema = ON')
|
|
# Inject an orphaned index into sqlite_master without any backing table.
|
|
# This simulates a partially-synced DB where index metadata arrived but
|
|
# the table schema is incomplete or missing columns.
|
|
idx_sql = 'CREATE INDEX idx_observations_content_hash ON observations(content_hash, created_at_epoch)'
|
|
c.execute(
|
|
"INSERT INTO sqlite_master (type, name, tbl_name, rootpage, sql) VALUES ('index', 'idx_observations_content_hash', 'observations', 0, ?)",
|
|
(idx_sql,)
|
|
)
|
|
c.execute('PRAGMA writable_schema = OFF')
|
|
c.commit()
|
|
c.close()
|
|
`);
|
|
execFileSync('python3', [scriptPath, dbPath], { timeout: 10000 });
|
|
|
|
// Verify it's corrupted
|
|
const corruptDb = new Database(dbPath, { readwrite: true });
|
|
let threw = false;
|
|
try {
|
|
corruptDb.query('SELECT name FROM sqlite_master WHERE type = "table" LIMIT 1').all();
|
|
} catch (e: any) {
|
|
threw = true;
|
|
expect(e.message).toContain('malformed database schema');
|
|
}
|
|
corruptDb.close();
|
|
expect(threw).toBe(true);
|
|
|
|
// ClaudeMemDatabase must repair and fully initialize despite missing schema_versions
|
|
const repaired = new ClaudeMemDatabase(dbPath);
|
|
const tables = repaired.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
|
.all() as { name: string }[];
|
|
const tableNames = tables.map(t => t.name);
|
|
expect(tableNames).toContain('schema_versions');
|
|
expect(tableNames).toContain('observations');
|
|
expect(tableNames).toContain('sdk_sessions');
|
|
repaired.close();
|
|
} finally {
|
|
cleanup(dbPath);
|
|
if (existsSync(scriptPath)) unlinkSync(scriptPath);
|
|
}
|
|
});
|
|
|
|
it('should preserve existing data through repair and re-migration', () => {
|
|
if (!hasPython()) {
|
|
console.log('Python3 not available, skipping test');
|
|
return;
|
|
}
|
|
|
|
const dbPath = tempDbPath();
|
|
try {
|
|
// Step 1: Create a fully migrated DB and insert a session + observation
|
|
const db = new Database(dbPath, { create: true, readwrite: true });
|
|
db.run('PRAGMA journal_mode = WAL');
|
|
db.run('PRAGMA foreign_keys = ON');
|
|
|
|
const runner = new MigrationRunner(db);
|
|
runner.runAllMigrations();
|
|
|
|
const now = new Date().toISOString();
|
|
const epoch = Date.now();
|
|
db.prepare(`
|
|
INSERT INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`).run('test-content-1', 'test-memory-1', 'test-project', now, epoch, 'active');
|
|
|
|
db.prepare(`
|
|
INSERT INTO observations (memory_session_id, project, type, created_at, created_at_epoch)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`).run('test-memory-1', 'test-project', 'discovery', now, epoch);
|
|
|
|
db.run('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
db.close();
|
|
|
|
// Step 2: Corrupt the DB
|
|
corruptDbViaPython(dbPath);
|
|
|
|
// Step 3: Repair via ClaudeMemDatabase
|
|
const repaired = new ClaudeMemDatabase(dbPath);
|
|
|
|
// Data must survive the repair + re-migration
|
|
const sessions = repaired.db.prepare('SELECT COUNT(*) as count FROM sdk_sessions').get() as { count: number };
|
|
const observations = repaired.db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number };
|
|
expect(sessions.count).toBe(1);
|
|
expect(observations.count).toBe(1);
|
|
|
|
repaired.close();
|
|
} finally {
|
|
cleanup(dbPath);
|
|
}
|
|
});
|
|
});
|