claude-mem-lite 3.96.0 → 3.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/adopt-cli.mjs +125 -51
- package/bash-utils.mjs +105 -29
- package/claudemd.mjs +63 -19
- package/cli/activity.mjs +36 -18
- package/cli/common.mjs +101 -18
- package/cli/doctor.mjs +29 -9
- package/cli.mjs +69 -10
- package/deep-search.mjs +63 -20
- package/format-utils.mjs +15 -7
- package/haiku-client.mjs +189 -111
- package/hash-utils.mjs +15 -6
- package/hook-context.mjs +135 -46
- package/hook-episode.mjs +76 -23
- package/hook-handoff.mjs +216 -84
- package/hook-llm.mjs +476 -216
- package/hook-memory.mjs +106 -43
- package/hook-optimize.mjs +272 -109
- package/hook-precompact.mjs +1 -1
- package/hook-semaphore.mjs +46 -12
- package/hook-shared.mjs +101 -131
- package/hook-update.mjs +286 -88
- package/hook.mjs +1207 -617
- package/install-metadata.mjs +246 -125
- package/install.mjs +1327 -917
- package/lib/activity.mjs +52 -29
- package/lib/atomic-write.mjs +23 -4
- package/lib/binding-probe.mjs +40 -38
- package/lib/browse-core.mjs +22 -8
- package/lib/citation-tracker.mjs +189 -79
- package/lib/cite-back-hint.mjs +37 -19
- package/lib/cite-recall-path.mjs +3 -1
- package/lib/cli-flags.mjs +4 -2
- package/lib/cli-project.mjs +8 -5
- package/lib/compress-core.mjs +34 -9
- package/lib/cooldown-path.mjs +3 -1
- package/lib/db-backup.mjs +33 -11
- package/lib/deferred-work.mjs +73 -32
- package/lib/delete-core.mjs +25 -9
- package/lib/doctor-benchmark.mjs +5 -1
- package/lib/doctor-drift.mjs +25 -8
- package/lib/edge-attribution.mjs +23 -9
- package/lib/env-number.mjs +7 -8
- package/lib/err-sampler.mjs +22 -11
- package/lib/error-recall-core.mjs +15 -17
- package/lib/events-injection.mjs +25 -8
- package/lib/export-columns.mjs +49 -10
- package/lib/fast-summary.mjs +22 -9
- package/lib/file-edge-match.mjs +4 -2
- package/lib/file-intel.mjs +38 -13
- package/lib/frontmatter.mjs +22 -5
- package/lib/get-core.mjs +78 -11
- package/lib/handoff-constants.mjs +18 -0
- package/lib/hook-stdin.mjs +10 -3
- package/lib/hook-stdout.mjs +21 -7
- package/lib/hook-telemetry.mjs +44 -17
- package/lib/id-routing.mjs +11 -6
- package/lib/import-jsonl.mjs +92 -41
- package/lib/inject-search-core.mjs +5 -2
- package/lib/injected-ids.mjs +19 -12
- package/lib/install-shape.mjs +47 -23
- package/lib/keyctx-marker.mjs +3 -1
- package/lib/lesson-bridge.mjs +8 -5
- package/lib/llm-call.mjs +76 -0
- package/lib/llm-provider-probe.mjs +24 -12
- package/lib/low-signal-patterns.mjs +25 -22
- package/lib/maintain-core.mjs +221 -92
- package/lib/mem-override.mjs +4 -2
- package/lib/metrics.mjs +30 -25
- package/lib/native-binding-hint.mjs +29 -8
- package/lib/observation-write.mjs +74 -19
- package/lib/patha-exclude-meter.mjs +10 -6
- package/lib/persist-reminder.mjs +22 -10
- package/lib/plan-reader.mjs +2 -2
- package/lib/private-strip.mjs +3 -3
- package/lib/proc-lock.mjs +15 -3
- package/lib/proxy-fetch.mjs +49 -13
- package/lib/quiet-scope.mjs +42 -0
- package/lib/recall-core.mjs +19 -7
- package/lib/recent-core.mjs +18 -5
- package/lib/registry-core.mjs +12 -6
- package/lib/release-digest.mjs +4 -1
- package/lib/relevance-floor.mjs +5 -2
- package/lib/reread-guard.mjs +16 -8
- package/lib/resolve-data-dir.mjs +1 -1
- package/lib/rrf.mjs +4 -1
- package/lib/save-enrich.mjs +36 -18
- package/lib/save-observation.mjs +78 -35
- package/lib/scrub-record.mjs +20 -6
- package/lib/search-core.mjs +456 -123
- package/lib/shard-gc.mjs +50 -0
- package/lib/startup-dashboard.mjs +20 -9
- package/lib/stats-core.mjs +101 -35
- package/lib/stats-quality.mjs +56 -20
- package/lib/summary-extractor.mjs +2 -2
- package/lib/task-imperative.mjs +12 -4
- package/lib/timeline-core.mjs +88 -28
- package/lib/tmp-fixture-sweep.mjs +32 -9
- package/lib/transcript-scan.mjs +20 -4
- package/lib/upgrade-banner.mjs +11 -7
- package/mem-cli.mjs +1314 -506
- package/memdir.mjs +53 -21
- package/nlp.mjs +68 -38
- package/npm-shrinkwrap.json +2 -2
- package/package.json +9 -2
- package/plugin-cache-guard.mjs +31 -15
- package/project-utils.mjs +44 -20
- package/registry-enricher.mjs +26 -6
- package/registry-github.mjs +4 -2
- package/registry-importer.mjs +151 -40
- package/registry-recommend.mjs +189 -96
- package/registry-retriever.mjs +229 -70
- package/registry-scanner.mjs +11 -6
- package/registry.mjs +163 -66
- package/rerank.mjs +18 -4
- package/resource-discovery.mjs +14 -4
- package/schema.mjs +258 -99
- package/scoring-sql.mjs +21 -10
- package/scripts/binding-probe-cli.mjs +17 -12
- package/scripts/hook-launcher.mjs +148 -31
- package/scripts/launch-preflight.mjs +5 -7
- package/scripts/launch.mjs +21 -9
- package/scripts/post-tool-recall.js +21 -5
- package/scripts/pre-agent-inject.js +51 -13
- package/scripts/pre-skill-bridge.js +22 -7
- package/scripts/pre-tool-recall.js +157 -77
- package/scripts/prompt-search-utils.mjs +70 -25
- package/scripts/setup.sh +16 -1
- package/scripts/user-prompt-search.js +238 -97
- package/search-engine.mjs +329 -96
- package/search-scoring.mjs +75 -33
- package/secret-scrub.mjs +48 -12
- package/server/fts-check.mjs +12 -9
- package/server.mjs +674 -243
- package/skip-tools.mjs +19 -8
- package/source-files.mjs +54 -21
- package/stop-words.mjs +152 -19
- package/synonyms.mjs +579 -164
- package/tfidf.mjs +130 -40
- package/tier.mjs +1 -1
- package/tool-schemas.mjs +381 -131
- package/utils.mjs +184 -36
package/schema.mjs
CHANGED
|
@@ -178,17 +178,15 @@ export const CURRENT_SCHEMA_VERSION = 47;
|
|
|
178
178
|
// pragma_table_info on a missing table returns zero rows (it does not throw), so
|
|
179
179
|
// naming any column of the new table is a table-presence check.
|
|
180
180
|
const LATEST_MIGRATION_COLUMNS = [
|
|
181
|
-
{ table: 'observations', column: 'decay_seen_at_first_cite' },
|
|
182
|
-
{ table: 'citation_surface_log', column: 'surface' },
|
|
183
|
-
{ table: 'observations', column: 'scope' },
|
|
181
|
+
{ table: 'observations', column: 'decay_seen_at_first_cite' }, // v46
|
|
182
|
+
{ table: 'citation_surface_log', column: 'surface' }, // v45
|
|
183
|
+
{ table: 'observations', column: 'scope' }, // v44
|
|
184
184
|
{ table: 'observation_files', column: 'last_cited_session_id' }, // v43
|
|
185
185
|
];
|
|
186
186
|
|
|
187
187
|
function hasLatestMigrationColumn(db) {
|
|
188
188
|
try {
|
|
189
|
-
const stmt = db.prepare(
|
|
190
|
-
`SELECT 1 AS present FROM pragma_table_info(?) WHERE name = ?`
|
|
191
|
-
);
|
|
189
|
+
const stmt = db.prepare(`SELECT 1 AS present FROM pragma_table_info(?) WHERE name = ?`);
|
|
192
190
|
return LATEST_MIGRATION_COLUMNS.every(({ table, column }) => Boolean(stmt.get(table, column)));
|
|
193
191
|
} catch {
|
|
194
192
|
return false; // table itself missing → caller falls through to CORE_SCHEMA
|
|
@@ -322,7 +320,7 @@ const CORE_SCHEMA = `
|
|
|
322
320
|
// Column migrations (idempotent — only swallow "duplicate column" errors)
|
|
323
321
|
const MIGRATIONS = [
|
|
324
322
|
'ALTER TABLE observations ADD COLUMN importance INTEGER DEFAULT 1',
|
|
325
|
-
|
|
323
|
+
"ALTER TABLE observations ADD COLUMN related_ids TEXT DEFAULT '[]'",
|
|
326
324
|
'ALTER TABLE observations ADD COLUMN minhash_sig TEXT',
|
|
327
325
|
'ALTER TABLE observations ADD COLUMN access_count INTEGER DEFAULT 0',
|
|
328
326
|
'ALTER TABLE observations ADD COLUMN compressed_into INTEGER DEFAULT NULL',
|
|
@@ -432,7 +430,7 @@ export function initSchema(db) {
|
|
|
432
430
|
if (row.version > CURRENT_SCHEMA_VERSION) {
|
|
433
431
|
throw new Error(
|
|
434
432
|
`DB schema is v${row.version} but this claude-mem-lite binary supports up to v${CURRENT_SCHEMA_VERSION}. ` +
|
|
435
|
-
|
|
433
|
+
`A newer version wrote this DB; upgrade claude-mem-lite (npm i -g claude-mem-lite@latest) or point CLAUDE_MEM_DIR to a fresh directory.`,
|
|
436
434
|
);
|
|
437
435
|
}
|
|
438
436
|
}
|
|
@@ -459,14 +457,18 @@ export function initSchema(db) {
|
|
|
459
457
|
db.pragma('foreign_keys = ON');
|
|
460
458
|
return db;
|
|
461
459
|
}
|
|
462
|
-
} catch {
|
|
460
|
+
} catch {
|
|
461
|
+
/* table absent — proceed */
|
|
462
|
+
}
|
|
463
463
|
|
|
464
464
|
// Create core tables
|
|
465
465
|
db.exec(CORE_SCHEMA);
|
|
466
466
|
|
|
467
467
|
// Run column migrations
|
|
468
468
|
for (const sql of MIGRATIONS) {
|
|
469
|
-
try {
|
|
469
|
+
try {
|
|
470
|
+
db.exec(sql);
|
|
471
|
+
} catch (e) {
|
|
470
472
|
if (!e.message?.includes('duplicate column name')) throw e;
|
|
471
473
|
}
|
|
472
474
|
}
|
|
@@ -475,7 +477,9 @@ export function initSchema(db) {
|
|
|
475
477
|
// Old PK assumed one session per project, causing cross-session handoff overwrite
|
|
476
478
|
// (see docs/bug.txt). Rebuild table if still on old PK. Idempotent.
|
|
477
479
|
try {
|
|
478
|
-
const handoffDdl = db
|
|
480
|
+
const handoffDdl = db
|
|
481
|
+
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='session_handoffs'`)
|
|
482
|
+
.get();
|
|
479
483
|
const oldPk = handoffDdl && /PRIMARY KEY\s*\(\s*project\s*,\s*type\s*\)/i.test(handoffDdl.sql);
|
|
480
484
|
if (oldPk) {
|
|
481
485
|
const rebuild = db.transaction(() => {
|
|
@@ -505,37 +509,54 @@ export function initSchema(db) {
|
|
|
505
509
|
});
|
|
506
510
|
rebuild();
|
|
507
511
|
}
|
|
508
|
-
} catch {
|
|
512
|
+
} catch {
|
|
513
|
+
/* non-critical — next open retries */
|
|
514
|
+
}
|
|
509
515
|
|
|
510
516
|
// v25 (T10d): commit-anchored continuation — store HEAD sha at handoff time
|
|
511
517
|
// so detectContinuationIntent can auto-confirm continuation when the working
|
|
512
518
|
// tree hasn't moved since /exit or /clear. Runs AFTER the PK-widen rebuild
|
|
513
519
|
// above so the new column is not clobbered by the DROP+CREATE path.
|
|
514
520
|
try {
|
|
515
|
-
const handoffCols = db
|
|
521
|
+
const handoffCols = db
|
|
522
|
+
.prepare(`PRAGMA table_info(session_handoffs)`)
|
|
523
|
+
.all()
|
|
524
|
+
.map((c) => c.name);
|
|
516
525
|
if (!handoffCols.includes('git_sha_at_handoff')) {
|
|
517
526
|
db.exec(`ALTER TABLE session_handoffs ADD COLUMN git_sha_at_handoff TEXT DEFAULT NULL`);
|
|
518
527
|
}
|
|
519
|
-
} catch {
|
|
528
|
+
} catch {
|
|
529
|
+
/* non-critical — migration retries on next open */
|
|
530
|
+
}
|
|
520
531
|
|
|
521
532
|
// Dedup migration: ensure memory_session_id is unique, then enable FK
|
|
522
|
-
const hasIdx = db
|
|
533
|
+
const hasIdx = db
|
|
534
|
+
.prepare(`SELECT 1 FROM sqlite_master WHERE type='index' AND name='idx_sess_memory_sid'`)
|
|
535
|
+
.get();
|
|
523
536
|
if (!hasIdx) {
|
|
524
|
-
const dupes = db
|
|
537
|
+
const dupes = db
|
|
538
|
+
.prepare(
|
|
539
|
+
`
|
|
525
540
|
SELECT memory_session_id, COUNT(*) as cnt
|
|
526
541
|
FROM sdk_sessions
|
|
527
542
|
WHERE memory_session_id IS NOT NULL
|
|
528
543
|
GROUP BY memory_session_id HAVING cnt > 1
|
|
529
|
-
|
|
544
|
+
`,
|
|
545
|
+
)
|
|
546
|
+
.all();
|
|
530
547
|
|
|
531
548
|
// Atomic: dedup + create unique index in one transaction
|
|
532
549
|
const dedupAndIndex = db.transaction(() => {
|
|
533
550
|
for (const { memory_session_id } of dupes) {
|
|
534
|
-
const rows = db
|
|
551
|
+
const rows = db
|
|
552
|
+
.prepare(
|
|
553
|
+
`
|
|
535
554
|
SELECT s.id FROM sdk_sessions s
|
|
536
555
|
WHERE s.memory_session_id = ?
|
|
537
556
|
ORDER BY s.id ASC
|
|
538
|
-
|
|
557
|
+
`,
|
|
558
|
+
)
|
|
559
|
+
.all(memory_session_id);
|
|
539
560
|
for (let i = 1; i < rows.length; i++) {
|
|
540
561
|
db.prepare('DELETE FROM sdk_sessions WHERE id = ?').run(rows[i].id);
|
|
541
562
|
}
|
|
@@ -547,15 +568,27 @@ export function initSchema(db) {
|
|
|
547
568
|
|
|
548
569
|
// Performance indexes
|
|
549
570
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_epoch_project ON observations(created_at_epoch DESC, project)`);
|
|
550
|
-
db.exec(
|
|
551
|
-
|
|
571
|
+
db.exec(
|
|
572
|
+
`CREATE INDEX IF NOT EXISTS idx_sess_sum_epoch ON session_summaries(created_at_epoch DESC, project)`,
|
|
573
|
+
);
|
|
574
|
+
db.exec(
|
|
575
|
+
`CREATE INDEX IF NOT EXISTS idx_obs_project_epoch_minhash ON observations(project, created_at_epoch DESC) WHERE minhash_sig IS NOT NULL`,
|
|
576
|
+
);
|
|
552
577
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_user_prompts_session ON user_prompts(content_session_id)`);
|
|
553
|
-
db.exec(
|
|
554
|
-
|
|
578
|
+
db.exec(
|
|
579
|
+
`CREATE INDEX IF NOT EXISTS idx_user_prompts_cc ON user_prompts(cc_session_id) WHERE cc_session_id IS NOT NULL`,
|
|
580
|
+
);
|
|
581
|
+
db.exec(
|
|
582
|
+
`CREATE INDEX IF NOT EXISTS idx_obs_superseded ON observations(superseded_at) WHERE superseded_at IS NOT NULL`,
|
|
583
|
+
);
|
|
555
584
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_obs_branch ON observations(branch) WHERE branch IS NOT NULL`);
|
|
556
585
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_project ON sdk_sessions(project)`);
|
|
557
|
-
db.exec(
|
|
558
|
-
|
|
586
|
+
db.exec(
|
|
587
|
+
`CREATE INDEX IF NOT EXISTS idx_obs_not_compressed ON observations(created_at_epoch DESC) WHERE COALESCE(compressed_into, 0) = 0`,
|
|
588
|
+
);
|
|
589
|
+
db.exec(
|
|
590
|
+
`CREATE INDEX IF NOT EXISTS idx_handoffs_project_time ON session_handoffs(project, type, created_at_epoch DESC)`,
|
|
591
|
+
);
|
|
559
592
|
// v47 (audit 2026-09-02 P2-11 + the previous round's ALGO-7), one additive migration for
|
|
560
593
|
// both. Additive only: new indexes on existing columns, no table rewrite, no data move.
|
|
561
594
|
//
|
|
@@ -567,13 +600,17 @@ export function initSchema(db) {
|
|
|
567
600
|
// The second narrows the live-row scan the injection faces run per project. Partial on the
|
|
568
601
|
// same predicate `liveObsFilterSql` uses, so the index covers exactly the rows those
|
|
569
602
|
// queries can return.
|
|
570
|
-
db.exec(
|
|
603
|
+
db.exec(
|
|
604
|
+
`CREATE INDEX IF NOT EXISTS idx_obs_project_live ON observations(project, created_at_epoch DESC) WHERE superseded_at IS NULL AND COALESCE(compressed_into, 0) = 0`,
|
|
605
|
+
);
|
|
571
606
|
|
|
572
607
|
// FTS5 migration: recreate observations_fts when columns are missing (one-time)
|
|
573
608
|
// Detect old FTS5 table missing lesson_learned or search_aliases and recreate with full column set
|
|
574
609
|
let obsFtsRecreated = false;
|
|
575
610
|
try {
|
|
576
|
-
const ftsDdl = db
|
|
611
|
+
const ftsDdl = db
|
|
612
|
+
.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='observations_fts'`)
|
|
613
|
+
.get();
|
|
577
614
|
if (ftsDdl && (!ftsDdl.sql.includes('lesson_learned') || !ftsDdl.sql.includes('search_aliases'))) {
|
|
578
615
|
db.exec(`DROP TRIGGER IF EXISTS observations_ai`);
|
|
579
616
|
db.exec(`DROP TRIGGER IF EXISTS observations_ad`);
|
|
@@ -581,7 +618,9 @@ export function initSchema(db) {
|
|
|
581
618
|
db.exec(`DROP TABLE IF EXISTS observations_fts`);
|
|
582
619
|
obsFtsRecreated = true;
|
|
583
620
|
}
|
|
584
|
-
} catch {
|
|
621
|
+
} catch {
|
|
622
|
+
/* non-critical — ensureFTS will create if missing */
|
|
623
|
+
}
|
|
585
624
|
|
|
586
625
|
// v27 migration: drop legacy _au triggers that fire on ANY row UPDATE so
|
|
587
626
|
// ensureFTS reinstates them with `AFTER UPDATE OF <fts_cols>`. Trigger fires
|
|
@@ -590,21 +629,31 @@ export function initSchema(db) {
|
|
|
590
629
|
// Conditional per #7647: only drop when the stored DDL lacks the scoped
|
|
591
630
|
// `UPDATE OF` clause (handles re-run + fresh-DB cases).
|
|
592
631
|
for (const [trg, tbl] of [
|
|
593
|
-
['observations_au',
|
|
632
|
+
['observations_au', 'observations'],
|
|
594
633
|
['session_summaries_au', 'session_summaries'],
|
|
595
|
-
['user_prompts_au',
|
|
634
|
+
['user_prompts_au', 'user_prompts'],
|
|
596
635
|
]) {
|
|
597
636
|
try {
|
|
598
637
|
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name=?`).get(trg);
|
|
599
638
|
if (row && row.sql && !/\bAFTER\s+UPDATE\s+OF\s+/i.test(row.sql)) {
|
|
600
639
|
db.exec(`DROP TRIGGER IF EXISTS ${tbl}_au`);
|
|
601
640
|
}
|
|
602
|
-
} catch {
|
|
641
|
+
} catch {
|
|
642
|
+
/* non-critical — ensureFTS will recreate */
|
|
643
|
+
}
|
|
603
644
|
}
|
|
604
645
|
|
|
605
646
|
// FTS5 full-text search tables + triggers (idempotent)
|
|
606
647
|
ensureFTS(db, 'observations_fts', 'observations', OBS_FTS_COLUMNS);
|
|
607
|
-
ensureFTS(db, 'session_summaries_fts', 'session_summaries', [
|
|
648
|
+
ensureFTS(db, 'session_summaries_fts', 'session_summaries', [
|
|
649
|
+
'request',
|
|
650
|
+
'investigated',
|
|
651
|
+
'learned',
|
|
652
|
+
'completed',
|
|
653
|
+
'next_steps',
|
|
654
|
+
'notes',
|
|
655
|
+
'remaining_items',
|
|
656
|
+
]);
|
|
608
657
|
ensureFTS(db, 'user_prompts_fts', 'user_prompts', ['prompt_text']);
|
|
609
658
|
|
|
610
659
|
// Rebuild FTS5 if we just recreated it above (the new index is empty and must be
|
|
@@ -618,7 +667,9 @@ export function initSchema(db) {
|
|
|
618
667
|
try {
|
|
619
668
|
const cnt = db.prepare(`SELECT COUNT(*) as cnt FROM observations`).get();
|
|
620
669
|
if (cnt.cnt > 0) db.exec(`INSERT INTO observations_fts(observations_fts) VALUES('rebuild')`);
|
|
621
|
-
} catch {
|
|
670
|
+
} catch {
|
|
671
|
+
/* non-critical */
|
|
672
|
+
}
|
|
622
673
|
}
|
|
623
674
|
|
|
624
675
|
// v36 migration: narrow events_fts_au like the v27 fix above. The events FTS
|
|
@@ -629,11 +680,15 @@ export function initSchema(db) {
|
|
|
629
680
|
// the CREATE TRIGGER IF NOT EXISTS below reinstates the scoped form (handles
|
|
630
681
|
// re-run + fresh-DB: undefined row on a fresh DB is a no-op).
|
|
631
682
|
try {
|
|
632
|
-
const row = db
|
|
683
|
+
const row = db
|
|
684
|
+
.prepare(`SELECT sql FROM sqlite_master WHERE type='trigger' AND name='events_fts_au'`)
|
|
685
|
+
.get();
|
|
633
686
|
if (row && row.sql && !/\bAFTER\s+UPDATE\s+OF\s+/i.test(row.sql)) {
|
|
634
687
|
db.exec(`DROP TRIGGER IF EXISTS events_fts_au`);
|
|
635
688
|
}
|
|
636
|
-
} catch {
|
|
689
|
+
} catch {
|
|
690
|
+
/* non-critical — recreated below */
|
|
691
|
+
}
|
|
637
692
|
|
|
638
693
|
// ─── v2.31 T6: events table + FTS5 (activity namespace) ───────────────────
|
|
639
694
|
// Independent namespace for bugfix/lesson/bug/discovery/refactor/feature/
|
|
@@ -718,7 +773,9 @@ export function initSchema(db) {
|
|
|
718
773
|
'ALTER TABLE observation_files ADD COLUMN last_resolved_session_id TEXT DEFAULT NULL',
|
|
719
774
|
'ALTER TABLE observation_files ADD COLUMN last_cited_session_id TEXT DEFAULT NULL',
|
|
720
775
|
]) {
|
|
721
|
-
try {
|
|
776
|
+
try {
|
|
777
|
+
db.exec(sql);
|
|
778
|
+
} catch (e) {
|
|
722
779
|
if (!e.message?.includes('duplicate column name')) throw e;
|
|
723
780
|
}
|
|
724
781
|
}
|
|
@@ -728,12 +785,16 @@ export function initSchema(db) {
|
|
|
728
785
|
try {
|
|
729
786
|
const obsFilesCount = db.prepare('SELECT COUNT(*) as c FROM observation_files').get().c;
|
|
730
787
|
if (obsFilesCount === 0) {
|
|
731
|
-
const obsWithFiles = db
|
|
732
|
-
|
|
733
|
-
|
|
788
|
+
const obsWithFiles = db
|
|
789
|
+
.prepare(
|
|
790
|
+
`SELECT id, files_modified FROM observations WHERE files_modified IS NOT NULL AND files_modified != '[]'`,
|
|
791
|
+
)
|
|
792
|
+
.all();
|
|
734
793
|
if (obsWithFiles.length > 0) {
|
|
735
794
|
const migrateFiles = db.transaction(() => {
|
|
736
|
-
const insertFile = db.prepare(
|
|
795
|
+
const insertFile = db.prepare(
|
|
796
|
+
'INSERT OR IGNORE INTO observation_files (obs_id, filename) VALUES (?, ?)',
|
|
797
|
+
);
|
|
737
798
|
for (const row of obsWithFiles) {
|
|
738
799
|
try {
|
|
739
800
|
const files = JSON.parse(row.files_modified);
|
|
@@ -744,13 +805,17 @@ export function initSchema(db) {
|
|
|
744
805
|
}
|
|
745
806
|
}
|
|
746
807
|
}
|
|
747
|
-
} catch {
|
|
808
|
+
} catch {
|
|
809
|
+
/* skip malformed JSON */
|
|
810
|
+
}
|
|
748
811
|
}
|
|
749
812
|
});
|
|
750
813
|
migrateFiles();
|
|
751
814
|
}
|
|
752
815
|
}
|
|
753
|
-
} catch {
|
|
816
|
+
} catch {
|
|
817
|
+
/* non-critical — migration can retry on next open */
|
|
818
|
+
}
|
|
754
819
|
|
|
755
820
|
// observation_files orphan cleanup moved to runDeferredCleanups() (audit P1-5):
|
|
756
821
|
// it now runs retryably outside the version fast-path. See DEFERRED_CLEANUPS.
|
|
@@ -911,30 +976,46 @@ export function auditSessionConsistency(db, { graceMinutes = 5 } = {}) {
|
|
|
911
976
|
const cutoff = Date.now() - graceMinutes * 60_000;
|
|
912
977
|
// UUID-shape gate mirrors the v30 trigger — same length=36 + LIKE pattern.
|
|
913
978
|
const UUID_LIKE = '________-____-____-____-____________';
|
|
914
|
-
const idMixUuidShape = db
|
|
979
|
+
const idMixUuidShape = db
|
|
980
|
+
.prepare(
|
|
981
|
+
`
|
|
915
982
|
SELECT COUNT(*) AS c FROM sdk_sessions
|
|
916
983
|
WHERE memory_session_id IS NOT NULL
|
|
917
984
|
AND memory_session_id = content_session_id
|
|
918
985
|
AND length(memory_session_id) = 36
|
|
919
986
|
AND memory_session_id LIKE ?
|
|
920
|
-
|
|
921
|
-
|
|
987
|
+
`,
|
|
988
|
+
)
|
|
989
|
+
.get(UUID_LIKE).c;
|
|
990
|
+
const idMixOther = db
|
|
991
|
+
.prepare(
|
|
992
|
+
`
|
|
922
993
|
SELECT COUNT(*) AS c FROM sdk_sessions
|
|
923
994
|
WHERE memory_session_id IS NOT NULL
|
|
924
995
|
AND memory_session_id = content_session_id
|
|
925
996
|
AND NOT (length(memory_session_id) = 36 AND memory_session_id LIKE ?)
|
|
926
|
-
|
|
927
|
-
|
|
997
|
+
`,
|
|
998
|
+
)
|
|
999
|
+
.get(UUID_LIKE).c;
|
|
1000
|
+
const missingMemId = db
|
|
1001
|
+
.prepare(
|
|
1002
|
+
`
|
|
928
1003
|
SELECT COUNT(*) AS c FROM sdk_sessions
|
|
929
1004
|
WHERE memory_session_id IS NULL
|
|
930
1005
|
AND started_at_epoch < ?
|
|
931
|
-
|
|
932
|
-
|
|
1006
|
+
`,
|
|
1007
|
+
)
|
|
1008
|
+
.get(cutoff).c;
|
|
1009
|
+
const orphanObs = db
|
|
1010
|
+
.prepare(
|
|
1011
|
+
`
|
|
933
1012
|
SELECT COUNT(*) AS c FROM observations o
|
|
934
1013
|
WHERE NOT EXISTS (
|
|
935
1014
|
SELECT 1 FROM sdk_sessions s WHERE s.memory_session_id = o.memory_session_id
|
|
936
1015
|
)
|
|
937
|
-
|
|
1016
|
+
`,
|
|
1017
|
+
)
|
|
1018
|
+
.get().c;
|
|
938
1019
|
// Audit P3-14 backstop. `observations.importance` is INTEGER DEFAULT 1 but NULLABLE, and
|
|
939
1020
|
// the two maintenance faces disagree about what NULL means: decayAndMarkIdle reads
|
|
940
1021
|
// COALESCE(importance,1)=1 and queues the row for purge, runIdleCleanup reads a bare
|
|
@@ -943,17 +1024,16 @@ export function auditSessionConsistency(db, { graceMinutes = 5 } = {}) {
|
|
|
943
1024
|
// ones that got in another way — an old version, a hand-edited DB, a restored dump.
|
|
944
1025
|
// Reported here rather than in its own command because `orphan_obs` above establishes
|
|
945
1026
|
// that this audit already covers observation-level integrity, not only sessions.
|
|
946
|
-
const obsImportanceNull = db
|
|
947
|
-
'SELECT COUNT(*) AS c FROM observations WHERE importance IS NULL'
|
|
948
|
-
|
|
1027
|
+
const obsImportanceNull = db
|
|
1028
|
+
.prepare('SELECT COUNT(*) AS c FROM observations WHERE importance IS NULL')
|
|
1029
|
+
.get().c;
|
|
949
1030
|
return {
|
|
950
1031
|
id_mix_uuid_shape: idMixUuidShape,
|
|
951
1032
|
id_mix_other: idMixOther,
|
|
952
1033
|
missing_mem_id: missingMemId,
|
|
953
1034
|
orphan_obs: orphanObs,
|
|
954
1035
|
obs_importance_null: obsImportanceNull,
|
|
955
|
-
healthy: idMixUuidShape === 0 && missingMemId === 0 && orphanObs === 0
|
|
956
|
-
&& obsImportanceNull === 0,
|
|
1036
|
+
healthy: idMixUuidShape === 0 && missingMemId === 0 && orphanObs === 0 && obsImportanceNull === 0,
|
|
957
1037
|
};
|
|
958
1038
|
}
|
|
959
1039
|
|
|
@@ -969,16 +1049,16 @@ const DEFERRED_CLEANUPS = [
|
|
|
969
1049
|
// while early warm-start handles ran with foreign_keys OFF, so junction rows
|
|
970
1050
|
// leaked. Idempotent (NOT IN is empty on a clean DB).
|
|
971
1051
|
name: 'orphan-observation-files',
|
|
972
|
-
run: (db) =>
|
|
973
|
-
`DELETE FROM observation_files WHERE obs_id NOT IN (SELECT id FROM observations)`
|
|
974
|
-
).run(),
|
|
1052
|
+
run: (db) =>
|
|
1053
|
+
db.prepare(`DELETE FROM observation_files WHERE obs_id NOT IN (SELECT id FROM observations)`).run(),
|
|
975
1054
|
},
|
|
976
1055
|
{
|
|
977
1056
|
// v28 (v2.47) P0-1: orphaned observation_vectors — same FK-OFF root cause.
|
|
978
1057
|
name: 'orphan-observation-vectors',
|
|
979
|
-
run: (db) =>
|
|
980
|
-
|
|
981
|
-
|
|
1058
|
+
run: (db) =>
|
|
1059
|
+
db
|
|
1060
|
+
.prepare(`DELETE FROM observation_vectors WHERE observation_id NOT IN (SELECT id FROM observations)`)
|
|
1061
|
+
.run(),
|
|
982
1062
|
},
|
|
983
1063
|
{
|
|
984
1064
|
// Project-name normalization: migrate short names ("mem") to canonical
|
|
@@ -986,26 +1066,34 @@ const DEFERRED_CLEANUPS = [
|
|
|
986
1066
|
// substring. Idempotent: only acts on remaining short-name records.
|
|
987
1067
|
name: 'normalize-project-names',
|
|
988
1068
|
run: (db) => {
|
|
989
|
-
const shortProjects = db
|
|
1069
|
+
const shortProjects = db
|
|
1070
|
+
.prepare(
|
|
1071
|
+
`
|
|
990
1072
|
SELECT DISTINCT project FROM observations
|
|
991
1073
|
WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
|
|
992
1074
|
UNION
|
|
993
1075
|
SELECT DISTINCT project FROM sdk_sessions
|
|
994
1076
|
WHERE project NOT LIKE '%--_%' AND project != '' AND project IS NOT NULL
|
|
995
|
-
|
|
1077
|
+
`,
|
|
1078
|
+
)
|
|
1079
|
+
.all();
|
|
996
1080
|
if (shortProjects.length === 0) return;
|
|
997
1081
|
const normalize = db.transaction(() => {
|
|
998
1082
|
for (const { project: shortName } of shortProjects) {
|
|
999
|
-
let canonical = db
|
|
1000
|
-
|
|
1001
|
-
|
|
1083
|
+
let canonical = db
|
|
1084
|
+
.prepare(
|
|
1085
|
+
`SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`,
|
|
1086
|
+
)
|
|
1087
|
+
.get(`%--${shortName}`);
|
|
1002
1088
|
if (!canonical) {
|
|
1003
|
-
const tokens = shortName.split(/[-_.]/).filter(t => t.length >= 5);
|
|
1089
|
+
const tokens = shortName.split(/[-_.]/).filter((t) => t.length >= 5);
|
|
1004
1090
|
for (const token of tokens) {
|
|
1005
|
-
canonical = db
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1091
|
+
canonical = db
|
|
1092
|
+
.prepare(
|
|
1093
|
+
`SELECT project FROM observations WHERE project LIKE ? AND project LIKE '%--_%'
|
|
1094
|
+
GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1`,
|
|
1095
|
+
)
|
|
1096
|
+
.get(`%${token}%`);
|
|
1009
1097
|
if (canonical) break;
|
|
1010
1098
|
}
|
|
1011
1099
|
}
|
|
@@ -1017,10 +1105,20 @@ const DEFERRED_CLEANUPS = [
|
|
|
1017
1105
|
// handoffs (session_handoffs) were stranded on the old name — invisible to
|
|
1018
1106
|
// every project-scoped query after normalization. All eight carry a
|
|
1019
1107
|
// `project` column (verified).
|
|
1020
|
-
for (const table of [
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1108
|
+
for (const table of [
|
|
1109
|
+
'observations',
|
|
1110
|
+
'sdk_sessions',
|
|
1111
|
+
'session_summaries',
|
|
1112
|
+
'session_handoffs',
|
|
1113
|
+
'citation_log',
|
|
1114
|
+
'citation_surface_log',
|
|
1115
|
+
'events',
|
|
1116
|
+
'deferred_work',
|
|
1117
|
+
]) {
|
|
1118
|
+
db.prepare(`UPDATE ${table} SET project = ? WHERE project = ?`).run(
|
|
1119
|
+
canonical.project,
|
|
1120
|
+
shortName,
|
|
1121
|
+
);
|
|
1024
1122
|
}
|
|
1025
1123
|
}
|
|
1026
1124
|
}
|
|
@@ -1039,7 +1137,12 @@ const DEFERRED_CLEANUPS = [
|
|
|
1039
1137
|
export function runDeferredCleanups(db) {
|
|
1040
1138
|
let done;
|
|
1041
1139
|
try {
|
|
1042
|
-
done = new Set(
|
|
1140
|
+
done = new Set(
|
|
1141
|
+
db
|
|
1142
|
+
.prepare('SELECT name FROM migration_cleanups')
|
|
1143
|
+
.all()
|
|
1144
|
+
.map((r) => r.name),
|
|
1145
|
+
);
|
|
1043
1146
|
} catch {
|
|
1044
1147
|
return; // table not present yet (pre-migration open) — nothing to do
|
|
1045
1148
|
}
|
|
@@ -1069,7 +1172,7 @@ export function ensureDb() {
|
|
|
1069
1172
|
// Remove DB_DIR only if it has no user data (no .db files)
|
|
1070
1173
|
if (existsSync(DB_DIR)) {
|
|
1071
1174
|
try {
|
|
1072
|
-
const hasDbFiles = readdirSync(DB_DIR).some(f => f.endsWith('.db'));
|
|
1175
|
+
const hasDbFiles = readdirSync(DB_DIR).some((f) => f.endsWith('.db'));
|
|
1073
1176
|
if (!hasDbFiles) rmSync(DB_DIR, { recursive: true, force: true });
|
|
1074
1177
|
} catch {}
|
|
1075
1178
|
}
|
|
@@ -1083,12 +1186,17 @@ export function ensureDb() {
|
|
|
1083
1186
|
if (!existsSync(DB_PATH) && existsSync(oldPath)) {
|
|
1084
1187
|
renameSync(oldPath, DB_PATH);
|
|
1085
1188
|
for (const ext of ['-wal', '-shm']) {
|
|
1086
|
-
if (existsSync(oldPath + ext))
|
|
1189
|
+
if (existsSync(oldPath + ext))
|
|
1190
|
+
try {
|
|
1191
|
+
renameSync(oldPath + ext, DB_PATH + ext);
|
|
1192
|
+
} catch {}
|
|
1087
1193
|
}
|
|
1088
1194
|
}
|
|
1089
1195
|
|
|
1090
1196
|
const db = new Database(DB_PATH);
|
|
1091
|
-
try {
|
|
1197
|
+
try {
|
|
1198
|
+
chmodSync(DB_PATH, 0o600);
|
|
1199
|
+
} catch {}
|
|
1092
1200
|
db.pragma('journal_mode = WAL');
|
|
1093
1201
|
// 5000ms matches the MCP server (server.mjs) — 3000ms wasn't enough under realistic
|
|
1094
1202
|
// concurrency (parallel CLI saves + a long-running FTS rebuild can push individual
|
|
@@ -1106,7 +1214,9 @@ export function ensureDb() {
|
|
|
1106
1214
|
runDeferredCleanups(ready);
|
|
1107
1215
|
return ready;
|
|
1108
1216
|
} catch (e) {
|
|
1109
|
-
try {
|
|
1217
|
+
try {
|
|
1218
|
+
db.close();
|
|
1219
|
+
} catch {}
|
|
1110
1220
|
throw e;
|
|
1111
1221
|
}
|
|
1112
1222
|
}
|
|
@@ -1118,8 +1228,9 @@ export function ensureDb() {
|
|
|
1118
1228
|
* uncheckpointed transactions — silent data loss.
|
|
1119
1229
|
*/
|
|
1120
1230
|
export function isDbCorruptionError(err) {
|
|
1121
|
-
return /SQLITE_CORRUPT|SQLITE_NOTADB|malformed|not a database|disk image/i
|
|
1122
|
-
|
|
1231
|
+
return /SQLITE_CORRUPT|SQLITE_NOTADB|malformed|not a database|disk image/i.test(
|
|
1232
|
+
`${err?.code || ''} ${err?.message || ''}`,
|
|
1233
|
+
);
|
|
1123
1234
|
}
|
|
1124
1235
|
|
|
1125
1236
|
/**
|
|
@@ -1141,14 +1252,26 @@ export function ensureDbWithWalRecovery({ warn, info } = {}) {
|
|
|
1141
1252
|
} catch (firstErr) {
|
|
1142
1253
|
if (!isDbCorruptionError(firstErr)) throw firstErr;
|
|
1143
1254
|
warn?.(`DB corruption detected, attempting WAL recovery: ${firstErr.message}`);
|
|
1144
|
-
try {
|
|
1145
|
-
|
|
1255
|
+
try {
|
|
1256
|
+
rmSync(DB_PATH + '-wal', { force: true });
|
|
1257
|
+
} catch {
|
|
1258
|
+
/* best-effort */
|
|
1259
|
+
}
|
|
1260
|
+
try {
|
|
1261
|
+
rmSync(DB_PATH + '-shm', { force: true });
|
|
1262
|
+
} catch {
|
|
1263
|
+
/* best-effort */
|
|
1264
|
+
}
|
|
1146
1265
|
try {
|
|
1147
1266
|
const db = ensureDb();
|
|
1148
1267
|
info?.('DB recovered after WAL cleanup');
|
|
1149
1268
|
return db;
|
|
1150
1269
|
} catch (retryErr) {
|
|
1151
|
-
try {
|
|
1270
|
+
try {
|
|
1271
|
+
retryErr.walRecoveryAttempted = true;
|
|
1272
|
+
} catch {
|
|
1273
|
+
/* frozen error — fine */
|
|
1274
|
+
}
|
|
1152
1275
|
throw retryErr;
|
|
1153
1276
|
}
|
|
1154
1277
|
}
|
|
@@ -1171,9 +1294,15 @@ export function rebuildFTS(db) {
|
|
|
1171
1294
|
const errors = [];
|
|
1172
1295
|
for (const fts of FTS_TABLES) {
|
|
1173
1296
|
try {
|
|
1174
|
-
if (!idRe.test(fts)) {
|
|
1297
|
+
if (!idRe.test(fts)) {
|
|
1298
|
+
errors.push(`${fts}: invalid identifier`);
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1175
1301
|
const exists = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(fts);
|
|
1176
|
-
if (!exists) {
|
|
1302
|
+
if (!exists) {
|
|
1303
|
+
errors.push(`${fts}: not found`);
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1177
1306
|
db.exec(`INSERT INTO ${fts}(${fts}) VALUES('rebuild')`);
|
|
1178
1307
|
rebuilt.push(fts);
|
|
1179
1308
|
} catch (e) {
|
|
@@ -1195,9 +1324,17 @@ export function checkFTSIntegrity(db) {
|
|
|
1195
1324
|
let healthy = true;
|
|
1196
1325
|
for (const fts of FTS_TABLES) {
|
|
1197
1326
|
try {
|
|
1198
|
-
if (!idRe.test(fts)) {
|
|
1327
|
+
if (!idRe.test(fts)) {
|
|
1328
|
+
details.push(`${fts}: invalid identifier`);
|
|
1329
|
+
healthy = false;
|
|
1330
|
+
continue;
|
|
1331
|
+
}
|
|
1199
1332
|
const exists = db.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(fts);
|
|
1200
|
-
if (!exists) {
|
|
1333
|
+
if (!exists) {
|
|
1334
|
+
details.push(`${fts}: missing`);
|
|
1335
|
+
healthy = false;
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1201
1338
|
db.exec(`INSERT INTO ${fts}(${fts}) VALUES('integrity-check')`);
|
|
1202
1339
|
details.push(`${fts}: ok`);
|
|
1203
1340
|
} catch (e) {
|
|
@@ -1212,13 +1349,13 @@ export function ensureFTS(db, ftsName, tableName, columns) {
|
|
|
1212
1349
|
// Validate identifiers to prevent SQL injection (done upfront; both
|
|
1213
1350
|
// branches below use these identifiers in string-interpolated SQL)
|
|
1214
1351
|
const idRe = /^[a-z][a-z0-9_]*$/;
|
|
1215
|
-
if (!idRe.test(ftsName) || !idRe.test(tableName) || !columns.every(c => idRe.test(c))) {
|
|
1352
|
+
if (!idRe.test(ftsName) || !idRe.test(tableName) || !columns.every((c) => idRe.test(c))) {
|
|
1216
1353
|
throw new Error(`Invalid identifier in ensureFTS: ${ftsName}, ${tableName}`);
|
|
1217
1354
|
}
|
|
1218
1355
|
|
|
1219
1356
|
const colList = columns.join(', ');
|
|
1220
|
-
const newVals = columns.map(c => `new.${c}`).join(', ');
|
|
1221
|
-
const oldVals = columns.map(c => `old.${c}`).join(', ');
|
|
1357
|
+
const newVals = columns.map((c) => `new.${c}`).join(', ');
|
|
1358
|
+
const oldVals = columns.map((c) => `old.${c}`).join(', ');
|
|
1222
1359
|
|
|
1223
1360
|
// Column-aware (re)creation. An existing FTS table is never silently reused when its
|
|
1224
1361
|
// indexed-column set has drifted from `columns`. Root cause of a silent-write bug class:
|
|
@@ -1235,8 +1372,15 @@ export function ensureFTS(db, ftsName, tableName, columns) {
|
|
|
1235
1372
|
let recreated = false;
|
|
1236
1373
|
if (ftsRow) {
|
|
1237
1374
|
let existingCols = [];
|
|
1238
|
-
try {
|
|
1239
|
-
|
|
1375
|
+
try {
|
|
1376
|
+
existingCols = db
|
|
1377
|
+
.prepare(`PRAGMA table_info(${ftsName})`)
|
|
1378
|
+
.all()
|
|
1379
|
+
.map((c) => c.name);
|
|
1380
|
+
} catch {
|
|
1381
|
+
/* unreadable → treat as drifted, recreate */
|
|
1382
|
+
}
|
|
1383
|
+
const drifted = existingCols.length !== columns.length || columns.some((c) => !existingCols.includes(c));
|
|
1240
1384
|
if (drifted) {
|
|
1241
1385
|
db.exec(`DROP TRIGGER IF EXISTS ${tableName}_ai`);
|
|
1242
1386
|
db.exec(`DROP TRIGGER IF EXISTS ${tableName}_ad`);
|
|
@@ -1246,7 +1390,9 @@ export function ensureFTS(db, ftsName, tableName, columns) {
|
|
|
1246
1390
|
}
|
|
1247
1391
|
}
|
|
1248
1392
|
if (!ftsRow || recreated) {
|
|
1249
|
-
db.exec(
|
|
1393
|
+
db.exec(
|
|
1394
|
+
`CREATE VIRTUAL TABLE ${ftsName} USING fts5(${colList}, content='${tableName}', content_rowid='id')`,
|
|
1395
|
+
);
|
|
1250
1396
|
}
|
|
1251
1397
|
|
|
1252
1398
|
// Triggers created / recreated independently of FTS table existence so that
|
|
@@ -1280,7 +1426,9 @@ export function ensureFTS(db, ftsName, tableName, columns) {
|
|
|
1280
1426
|
try {
|
|
1281
1427
|
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM ${tableName}`).get();
|
|
1282
1428
|
if (cnt.c > 0) db.exec(`INSERT INTO ${ftsName}(${ftsName}) VALUES('rebuild')`);
|
|
1283
|
-
} catch {
|
|
1429
|
+
} catch {
|
|
1430
|
+
/* non-critical — index repopulates lazily on next write */
|
|
1431
|
+
}
|
|
1284
1432
|
}
|
|
1285
1433
|
}
|
|
1286
1434
|
|
|
@@ -1300,8 +1448,17 @@ export function ensureEventsFTS(db) {
|
|
|
1300
1448
|
let recreated = false;
|
|
1301
1449
|
if (ftsRow) {
|
|
1302
1450
|
let existingCols = [];
|
|
1303
|
-
try {
|
|
1304
|
-
|
|
1451
|
+
try {
|
|
1452
|
+
existingCols = db
|
|
1453
|
+
.prepare(`PRAGMA table_info(events_fts)`)
|
|
1454
|
+
.all()
|
|
1455
|
+
.map((c) => c.name);
|
|
1456
|
+
} catch {
|
|
1457
|
+
/* unreadable → recreate */
|
|
1458
|
+
}
|
|
1459
|
+
const drifted =
|
|
1460
|
+
existingCols.length !== EVENTS_FTS_COLUMNS.length ||
|
|
1461
|
+
EVENTS_FTS_COLUMNS.some((c) => !existingCols.includes(c));
|
|
1305
1462
|
if (drifted) {
|
|
1306
1463
|
db.exec(`DROP TRIGGER IF EXISTS events_fts_ai`);
|
|
1307
1464
|
db.exec(`DROP TRIGGER IF EXISTS events_fts_ad`);
|
|
@@ -1342,6 +1499,8 @@ export function ensureEventsFTS(db) {
|
|
|
1342
1499
|
try {
|
|
1343
1500
|
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM events`).get();
|
|
1344
1501
|
if (cnt.c > 0) db.exec(`INSERT INTO events_fts(events_fts) VALUES('rebuild')`);
|
|
1345
|
-
} catch {
|
|
1502
|
+
} catch {
|
|
1503
|
+
/* non-critical — index repopulates lazily on next write */
|
|
1504
|
+
}
|
|
1346
1505
|
}
|
|
1347
1506
|
}
|