From dca35a6bd066b0bb6fc7219d4865068e64573f80 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 24 Jun 2026 15:21:30 -0700 Subject: [PATCH] feat(chronicle): doctor chronicle_projection_health probe (#2390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Life Chronicle Phase B.13. An always-run doctor check (keyed off the event_page_id schema, NOT a migration verify-hook) counts timeline projections whose event page is soft-deleted — hidden at read time, surfaced here as a cleanup backlog (`gbrain integrity auto`). Tolerant of pre-migration brains. 1 detection test. (auto_chronicle / chronicle.tz flags already work via getConfig defaults; their docs land with document-release at ship.) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/doctor.ts | 27 +++++++++++++++++++++++++ test/chronicle-doctor.test.ts | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 test/chronicle-doctor.test.ts diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index f3b67ad30..9b0dc3a4b 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -538,6 +538,33 @@ export async function doctorReportRemote(engine: BrainEngine): Promise( + `SELECT count(*)::int AS n FROM timeline_entries te + JOIN pages ep ON ep.id = te.event_page_id + WHERE te.event_page_id IS NOT NULL AND ep.deleted_at IS NOT NULL`, + ); + const n = Number(orphans[0]?.n ?? 0); + checks.push( + n === 0 + ? { name: 'chronicle_projection_health', status: 'ok', message: 'No orphaned event projections' } + : { + name: 'chronicle_projection_health', + status: 'warn', + message: + `${n} timeline projection(s) point to soft-deleted event pages ` + + '(hidden at read time; clean up with `gbrain integrity auto`).', + }, + ); + } catch { + checks.push({ name: 'chronicle_projection_health', status: 'ok', message: 'no event projections yet' }); + } + // 3. Brain score try { const health = await engine.getHealth(); diff --git a/test/chronicle-doctor.test.ts b/test/chronicle-doctor.test.ts new file mode 100644 index 000000000..b9275bac9 --- /dev/null +++ b/test/chronicle-doctor.test.ts @@ -0,0 +1,38 @@ +/** + * v0.42.x — Life Chronicle (#2390) doctor chronicle_projection_health (Phase B.13). + * Verifies the orphan-detection query the doctor check runs: a timeline + * projection whose event page is soft-deleted is counted (hidden at read time, + * flagged for cleanup). + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { runChronicleExtract, type ChronicleJudge } from '../src/core/chronicle/extract-events.ts'; + +let engine: PGLiteEngine; + +const orphanQuery = `SELECT count(*)::int AS n FROM timeline_entries te + JOIN pages ep ON ep.id = te.event_page_id + WHERE te.event_page_id IS NOT NULL AND ep.deleted_at IS NOT NULL`; + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({ database_url: '' }); + await engine.initSchema(); +}); +afterAll(async () => { await engine.disconnect(); }); + +describe('chronicle_projection_health detection', () => { + test('counts projections whose event page is soft-deleted; zero otherwise', async () => { + await engine.putPage('meetings/d', { type: 'meeting', title: 'd', compiled_truth: 'x'.repeat(120), effective_date: new Date('2026-06-18T12:00:00Z') }); + const judge: ChronicleJudge = async () => ({ events: [{ when: '2026-06-18T12:00:00Z', who: [], what: 'an event', kind: 'meeting' }] }); + await runChronicleExtract(engine, { slug: 'meetings/d', judge }); + + let r = await engine.executeRaw<{ n: number }>(orphanQuery); + expect(Number(r[0].n)).toBe(0); // healthy: event page live + + // Soft-delete the event page → its projection becomes an orphan. + await engine.executeRaw(`UPDATE pages SET deleted_at = now() WHERE type = 'event'`); + r = await engine.executeRaw<{ n: number }>(orphanQuery); + expect(Number(r[0].n)).toBe(1); + }); +});