mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, number> = {};
|
||||
|
||||
async function insertPage(opts: {
|
||||
slug: string; type: string; sourceId?: string;
|
||||
effectiveDate?: string | null; frontmatter?: string;
|
||||
}): Promise<number> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user