From a93fcf504f13935657c02ff2c3e8d029ea0fb1c3 Mon Sep 17 00:00:00 2001 From: Anton Senkovskiy Date: Tue, 21 Jul 2026 08:04:41 +0100 Subject: [PATCH] fix(chronicle): bound last-seen to <= asof/today so future events do not read as "seen today" (#2993) getLastSeen had no upper date bound, so a future-dated chronicle event (a scheduled calendar-event, a planned milestone) became the entity's "last seen" date; finalizeLastSeen's Math.max(0, ...) then clamped the negative delta, reporting days_ago: 0 -- the entity reads as seen-today. Recording future events is intended (eligibility ELIGIBLE_TYPES includes calendar-event); the reader just needs to stop counting them as "seen". Bound the query to te.date <= COALESCE(asof, current_date) in both engines, mirroring getOnThisDay's existing te.date < target bound. asof now reaches the WHERE clause (previously it only reached finalizeLastSeen), so as-of time-travel is honored for the date filter too. Regression test added: an entity with past events plus a future event -> last seen returns the most recent PAST event, not the future one; and as-of after the future date lets it through. Fails before, passes after. --- src/core/pglite-engine.ts | 7 ++ src/core/postgres-engine.ts | 6 ++ test/chronicle-timeline-reads.test.ts | 19 +++++ test/e2e/chronicle-last-seen-postgres.test.ts | 77 +++++++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 test/e2e/chronicle-last-seen-postgres.test.ts diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index 3b3a9cf01..54efd0873 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -3675,6 +3675,13 @@ export class PGLiteEngine implements BrainEngine { THEN ep.frontmatter->'event'->'who' ELSE '[]'::jsonb END ) AS w(name) WHERE w.name = $1 OR w.name LIKE $2)))`, ]; + // "Last seen" is a PAST relation: chronicle stores future events + // (calendar-event is eligible), which must not read as "last seen". + // Bound to <= asof/today, mirroring getOnThisDay's `te.date < target`. + let seenThrough: string; + if (opts?.asof) { params.push(opts.asof); seenThrough = `$${params.length}::date`; } + else { seenThrough = `current_date`; } + where.push(`te.date <= ${seenThrough}`); this.pushChronicleSource(where, params, opts); const result = await this.db.query( `SELECT te.date::text AS last_date, ep.slug AS last_event_slug diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index 8e014bf48..b42f2a909 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -3826,12 +3826,18 @@ export class PostgresEngine implements BrainEngine { const sql = this.sql; // "Seen" = the entity's own page has a timeline row, OR an event's `who` // array references the entity (exact slug or wikilink-substring match). + // "Last seen" is a PAST relation: the chronicle legitimately stores + // future events (calendar-event is eligibility-eligible), so bound to + // <= asof/today or a scheduled event reads as "seen today". Mirrors + // getOnThisDay's `te.date < target` bound. + const seenThrough = opts?.asof ? sql`${opts.asof}::date` : sql`current_date`; const rows = await sql` SELECT te.date::text AS last_date, ep.slug AS last_event_slug FROM timeline_entries te JOIN pages p ON p.id = te.page_id AND p.deleted_at IS NULL LEFT JOIN pages ep ON ep.id = te.event_page_id WHERE (te.event_page_id IS NULL OR ep.deleted_at IS NULL) + AND te.date <= ${seenThrough} AND ( p.slug = ${entitySlug} OR (ep.id IS NOT NULL AND EXISTS ( diff --git a/test/chronicle-timeline-reads.test.ts b/test/chronicle-timeline-reads.test.ts index 3541526ed..36386ddde 100644 --- a/test/chronicle-timeline-reads.test.ts +++ b/test/chronicle-timeline-reads.test.ts @@ -95,6 +95,25 @@ describe('Life Chronicle timeline reads', () => { expect(never.days_ago).toBeNull(); }); + test('getLastSeen ignores future-dated events (bounds to <= asof/today)', async () => { + // Chronicle legitimately stores future events (a scheduled calendar-event, + // a planned milestone). "Last seen" must not return one, or the entity + // reads as seen-today (days_ago clamped to 0). Regression for the missing + // upper date bound in getLastSeen. + const fut = await insertPage({ slug: 'life/events/2026-08-01-fut', type: 'event', effectiveDate: '2026-08-01T10:00:00Z', frontmatter: '{"event":{"who":["people/sarah-chen"],"kind":"event"}}' }); + await insertProjection(ids.meeting, fut, '2026-08-01', 'Planned Q3 launch'); + // asof BEFORE the future event: last-seen is the most recent PAST event. + const seen = await engine.getLastSeen('people/sarah-chen', { asof: '2026-06-25', sourceId: 'default' }); + expect(seen.last_date).toBe('2026-06-20'); // NOT 2026-08-01 + expect(seen.days_ago).toBe(5); // NOT 0 + // asof AFTER it: now it legitimately counts. + const later = await engine.getLastSeen('people/sarah-chen', { asof: '2026-08-02', sourceId: 'default' }); + expect(later.last_date).toBe('2026-08-01'); + expect(later.days_ago).toBe(1); + // Clean up so this future event doesn't leak into later shared-fixture tests. + await engine.executeRaw('UPDATE pages SET deleted_at = now() WHERE id = $1', [fut]); + }); + test('source isolation: default scope excludes other-source events', async () => { const def = await engine.getTimelineForDate('2026-06-18', { sourceId: 'default' }); expect(def.some(r => r.event_slug === 'life/events/2026-06-18-099')).toBe(false); diff --git a/test/e2e/chronicle-last-seen-postgres.test.ts b/test/e2e/chronicle-last-seen-postgres.test.ts new file mode 100644 index 000000000..1841da4bb --- /dev/null +++ b/test/e2e/chronicle-last-seen-postgres.test.ts @@ -0,0 +1,77 @@ +/** + * v0.42.x — Life Chronicle getLastSeen, LIVE Postgres engine (#2390 follow-up). + * + * Parity coverage for the PGLite regression in + * test/chronicle-timeline-reads.test.ts: getLastSeen must bound to + * `te.date <= COALESCE(asof, current_date)` so a future-dated chronicle + * event (a scheduled calendar-event) is NOT reported as "seen today". + * + * Uses the canonical e2e harness (setupDB/teardownDB/getEngine); gated by + * DATABASE_URL via hasDatabase() and skips cleanly when unset, per the repo + * E2E lifecycle. Seeds its own fixtures via direct SQL, mirroring the PGLite + * test, then asserts the same fail-before / pass-after behavior on Postgres. + * + * Run: DATABASE_URL=... bun test test/e2e/chronicle-last-seen-postgres.test.ts + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import type { PostgresEngine } from '../../src/core/postgres-engine.ts'; +import { hasDatabase, setupDB, teardownDB } from './helpers.ts'; + +const RUN = hasDatabase(); +const d = RUN ? describe : describe.skip; + +let engine: PostgresEngine; +const ids: Record = {}; + +async function insertPage(opts: { + slug: string; type: string; sourceId?: string; + effectiveDate?: string | null; frontmatter?: string; +}): Promise { + const rows = await engine.executeRaw<{ id: number }>( + `INSERT INTO pages (source_id, slug, type, title, effective_date, frontmatter) + VALUES ($1, $2, $3, $4, $5::timestamptz, $6::text::jsonb) + RETURNING id`, + [opts.sourceId ?? 'default', opts.slug, opts.type, opts.slug, + opts.effectiveDate ?? null, opts.frontmatter ?? '{}'], + ); + return rows[0].id; +} + +async function insertProjection(depthId: number, eventId: number, date: string, summary: string): Promise { + await engine.executeRaw( + `INSERT INTO timeline_entries (page_id, date, source, summary, detail, event_page_id) + VALUES ($1, $2::date, $3, $4, '', $5)`, + [depthId, date, `life-chronicle:event:${eventId}`, summary, eventId], + ); +} + +d('getLastSeen (live Postgres) bounds to <= asof/today', () => { + beforeAll(async () => { + engine = await setupDB(); + + ids.meeting = await insertPage({ slug: 'meetings/2026-06-18-sync', type: 'meeting' }); + await insertPage({ slug: 'people/sarah-chen', type: 'person' }); + // Past events for Sarah: 06-18 15:30 (commitment) and 06-20 10:00 (decision). + ids.e1 = await insertPage({ slug: 'life/events/2026-06-18-001', type: 'event', effectiveDate: '2026-06-18T15:30:00Z', frontmatter: '{"event":{"who":["people/sarah-chen"],"kind":"commitment"}}' }); + ids.e3 = await insertPage({ slug: 'life/events/2026-06-20-001', type: 'event', effectiveDate: '2026-06-20T10:00:00Z', frontmatter: '{"event":{"who":["people/sarah-chen"],"kind":"decision"}}' }); + await insertProjection(ids.meeting, ids.e1, '2026-06-18', 'Sarah committed to Q3'); + await insertProjection(ids.meeting, ids.e3, '2026-06-20', 'Decision on launch'); + // Future event: a scheduled Q3 launch on 2026-08-01. + ids.fut = await insertPage({ slug: 'life/events/2026-08-01-fut', type: 'event', effectiveDate: '2026-08-01T10:00:00Z', frontmatter: '{"event":{"who":["people/sarah-chen"],"kind":"event"}}' }); + await insertProjection(ids.meeting, ids.fut, '2026-08-01', 'Planned Q3 launch'); + }); + + afterAll(async () => { await teardownDB(); }); + + test('future event does not read as last-seen; asof after it lets it through', async () => { + // asof BEFORE the future event → last-seen is the most recent PAST event. + const seen = await engine.getLastSeen('people/sarah-chen', { asof: '2026-06-25', sourceId: 'default' }); + expect(seen.last_date).toBe('2026-06-20'); // NOT 2026-08-01 + expect(seen.days_ago).toBe(5); // NOT 0 + + // asof AFTER the future event → it is now in-bound and becomes last-seen. + const later = await engine.getLastSeen('people/sarah-chen', { asof: '2026-08-02', sourceId: 'default' }); + expect(later.last_date).toBe('2026-08-01'); + expect(later.days_ago).toBe(1); + }); +});