Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 93 additions & 7 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2067,14 +2067,28 @@ export class SqliteLcmStore {
}
}

let deletedOrphans = 0;
let deletedMalformedEvents = 0;
if (before.foreignKeyViolations > 0) {
deletedOrphans = this.deleteOrphanedChildRowsSync();
appliedActions.push(`deleted ${deletedOrphans} orphaned child row(s)`);
}

if (before.malformedEventRows > 0) {
deletedMalformedEvents = this.deleteMalformedEventRowsSync();
appliedActions.push(`deleted ${deletedMalformedEvents} malformed event row(s)`);
}

const shouldRefreshAllFts = deletedOrphans > 0 || deletedMalformedEvents > 0;
if (
before.messageFts.expected !== before.messageFts.actual ||
before.summaryFts.expected !== before.summaryFts.actual ||
before.artifactFts.expected !== before.artifactFts.actual ||
before.summarySessionsNeedingRebuild.length > 0 ||
before.orphanSummaryEdges > 0
before.orphanSummaryEdges > 0 ||
shouldRefreshAllFts
) {
this.refreshSearchIndexesSync(checkedSessions);
this.refreshSearchIndexesSync(shouldRefreshAllFts ? undefined : checkedSessions);
appliedActions.push('rebuilt FTS indexes');
}

Expand Down Expand Up @@ -2172,19 +2186,31 @@ export class SqliteLcmStore {
}
}

private listForeignKeyViolationsSync(): Array<{ table: string; rowid: number | null }> {
return this.getDb().prepare('PRAGMA foreign_key_check').all() as Array<{
table: string;
rowid: number | null;
}>;
}

private countForeignKeyViolationsSync(): number {
const rows = this.getDb().prepare('PRAGMA foreign_key_check').all() as unknown[];
return rows.length;
return this.listForeignKeyViolationsSync().length;
}

private countMalformedEventRowsSync(): number {
return this.findMalformedEventRowIDsSync().length;
}

private findMalformedEventRowIDsSync(): number[] {
const rows = this.getDb()
.prepare('SELECT event_type, payload_json FROM events')
.prepare('SELECT rowid, id, event_type, payload_json FROM events')
.all() as Array<{
rowid: number;
id: string | null;
event_type: string;
payload_json: string;
}>;
let malformed = 0;
const malformed: number[] = [];
for (const row of rows) {
const expectedStub =
row.event_type.startsWith('message.') || row.event_type.startsWith('session.')
Expand All @@ -2194,12 +2220,72 @@ export class SqliteLcmStore {
try {
JSON.parse(row.payload_json);
} catch {
malformed += 1;
malformed.push(row.rowid);
}
}
return malformed;
}

private deleteMalformedEventRowsSync(): number {
const malformed = this.findMalformedEventRowIDsSync();
if (malformed.length === 0) return 0;
// Delete by rowid: a corrupt page can yield a row whose PRIMARY KEY (id)
// is NULL, and `WHERE id = NULL` never matches.
const stmt = this.getDb().prepare('DELETE FROM events WHERE rowid = ?');
let deleted = 0;
for (const rowID of malformed) {
deleted += (stmt.run(rowID) as { changes: number }).changes;
}
return deleted;
}

private deleteForeignKeyViolationRowsSync(): number {
const db = this.getDb();
let deleted = 0;
// Iterate until the checker comes back clean so future schema additions with
// deeper dependency chains are still repaired without hard-coding table order.
for (let pass = 0; pass < 10; pass += 1) {
const violations = this.listForeignKeyViolationsSync();
if (violations.length === 0) break;
let round = 0;
const seen = new Set<string>();
for (const violation of violations) {
if (typeof violation.rowid !== 'number') continue;
const key = `${violation.table}:${violation.rowid}`;
if (seen.has(key)) continue;
seen.add(key);

const tableName = `"${violation.table.replaceAll('"', '""')}"`;
round += (
db.prepare(`DELETE FROM ${tableName} WHERE rowid = ?`).run(violation.rowid) as {
changes: number;
}
).changes;
}
if (round === 0) break;
deleted += round;
}
return deleted;
}

private deleteSessionlessEventRowsSync(): number {
return (
this.getDb()
.prepare(
`DELETE FROM events
WHERE session_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM sessions WHERE sessions.session_id = events.session_id
)`,
)
.run() as { changes: number }
).changes;
}

private deleteOrphanedChildRowsSync(): number {
return this.deleteForeignKeyViolationRowsSync() + this.deleteSessionlessEventRowsSync();
}

private diagnoseSummarySession(session: NormalizedSession): DoctorSessionIssue | undefined {
const issues: string[] = [];
const archived = this.getArchivedMessages(session.messages);
Expand Down
95 changes: 95 additions & 0 deletions tests/store-doctor.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,98 @@ test('doctor reports and repairs summary drift, FTS drift, and orphan blobs', as
await cleanupWorkspace(workspace);
}
});

test('doctor apply deletes orphaned child rows, detached summary refs, and malformed events', async () => {
const workspace = makeWorkspace('lcm-doctor-orphans');
let store;

try {
const options = makeOptions({
freshTailMessages: 1,
minMessagesForTransform: 4,
});

store = new SqliteLcmStore(workspace, options);
await store.init();
await createSession(store, workspace, 's1', 1);
for (const [messageID, created, text] of [
['m1', 2, 'alpha archived note'],
['m2', 3, 'beta archived note'],
['m3', 4, 'gamma archived note'],
['m4', 5, 'delta archived note'],
['m5', 6, 'epsilon archived note'],
['m6', 7, 'zeta archived note'],
['m7', 8, 'eta archived note'],
['m8', 9, 'theta fresh tail note'],
]) {
await captureMessage(store, {
sessionID: 's1',
messageID,
created,
parts: [textPart('s1', messageID, `${messageID}-p`, text)],
});
}
await store.buildCompactionContext('s1');
store.close();

const db = new DatabaseSync(path.join(workspace, '.lcm', 'lcm.db'), {
enableForeignKeyConstraints: false,
timeout: 5000,
});
db.exec(`
INSERT INTO messages (message_id, session_id, created_at, info_json)
VALUES ('gm1', 'ghost', 100, '{"role":"user"}');
INSERT INTO parts (part_id, session_id, message_id, part_json)
VALUES ('gp1', 'ghost', 'gm1', '{"type":"text","text":"orphan"}');
INSERT INTO events (id, session_id, event_type, ts, payload_json)
VALUES ('ev-orphan', 'ghost', 'message.updated', 100, '[message.updated]');
INSERT INTO events (id, session_id, event_type, ts, payload_json)
VALUES ('ev-malformed', 's1', 'message.created', 101, 'not json {');
INSERT INTO events (id, session_id, event_type, ts, payload_json)
VALUES (NULL, 's1', 'art_broken', 102, 'corrupt page row {');
INSERT INTO message_fts (session_id, message_id, role, created_at, content)
VALUES ('ghost', 'gm1', 'user', '100', 'orphaned indexed content');
INSERT INTO summary_nodes (node_id, session_id, level, node_kind, start_index, end_index, message_ids_json, summary_text, strategy, created_at)
VALUES ('extra-a', 's1', 0, 'leaf', 0, 0, '["m1"]', 'detached alpha', 'deterministic-v1', 103);
INSERT INTO summary_nodes (node_id, session_id, level, node_kind, start_index, end_index, message_ids_json, summary_text, strategy, created_at)
VALUES ('extra-b', 's1', 0, 'leaf', 1, 1, '["m2"]', 'detached beta', 'deterministic-v1', 103);
INSERT INTO summary_edges (session_id, parent_id, child_id, child_position)
VALUES ('ghost', 'extra-a', 'extra-b', 0);
INSERT INTO summary_state (session_id, archived_count, latest_message_created, archived_signature, root_node_ids_json, updated_at)
VALUES ('ghost', 0, 0, '', '[]', 103);
INSERT INTO summary_fts (session_id, node_id, level, created_at, content)
VALUES ('s1', 'extra-a', '0', '103', 'detached alpha');
INSERT INTO summary_fts (session_id, node_id, level, created_at, content)
VALUES ('s1', 'extra-b', '0', '103', 'detached beta');
`);
db.close();

store = new SqliteLcmStore(workspace, options);
await store.init();

const dryRun = await store.doctor({ sessionID: 's1' });
assert.match(dryRun, /status=issues-found/);
assert.match(dryRun, /summary_sessions_needing_rebuild=0/);
assert.match(dryRun, /orphan_summary_edges=0/);
assert.match(dryRun, /foreign_key_violations=4/);
assert.match(dryRun, /malformed_event_rows=2/);

const repaired = await store.doctor({ sessionID: 's1', apply: true });
assert.match(repaired, /status=repaired/);
// gm1, gp1, ev-orphan, the detached summary edge, and the detached summary
// state are all repaired during apply.
assert.match(repaired, /deleted 5 orphaned child row\(s\)/);
assert.match(repaired, /deleted 2 malformed event row\(s\)/);

const scopedClean = await store.doctor({ sessionID: 's1' });
const globalClean = await store.doctor();
assert.match(scopedClean, /status=clean/);
assert.match(globalClean, /status=clean/);
assert.match(globalClean, /foreign_key_violations=0/);
assert.match(globalClean, /malformed_event_rows=0/);
assert.match(globalClean, /message_fts_delta=0/);
} finally {
store?.close();
await cleanupWorkspace(workspace);
}
});