Tasks — port the explore-timeline server foundation to main
tasks4/27
1. SQLite migration: additive semantic_time column + expression index
- 1.1 Add
semantic_time TEXT NOT NULL DEFAULT ''to the inlinerecordsCREATE TABLE IF NOT EXISTSschema inserver/db.js(with the semantic-time comment explaining COALESCE fallback). - 1.2 Add
migrateRecordSemanticTimeColumn(raw)guarded byhasTableColumn(raw, 'records', 'semantic_time')that runsALTER TABLE records ADD COLUMN semantic_time TEXT NOT NULL DEFAULT ''on a pre-existing table; verify it is a no-op on a DB that already has it. - 1.3 In the post-migration index block (after the column is guaranteed
present), create
CREATE INDEX IF NOT EXISTS idx_records_semantic_time ON records(connector_instance_id, stream, (COALESCE(NULLIF(semantic_time, ''), emitted_at)) DESC, record_key DESC). Do NOT create it in the inline schema block (column may not exist yet).
2. Postgres migration: additive semantic_time column + expression index
- 2.1 Add
semantic_time TEXT NOT NULL DEFAULT ''to the Postgresrecordstable definition inserver/postgres-storage.js. - 2.2 Run
ALTER TABLE records ADD COLUMN IF NOT EXISTS semantic_time TEXT NOT NULL DEFAULT ''in the migration block (idempotent, O(1), no mass UPDATE). - 2.3 Create the expression index
CREATE INDEX IF NOT EXISTS idx_pg_records_semantic_time ON records(connector_instance_id, stream, (COALESCE(NULLIF(semantic_time, ''), emitted_at)) DESC, record_key DESC)after the column is present.
3. Ingest write path (both dialects)
- 3.1 Add
semantic_timeto theINSERTcolumn list and theON CONFLICT ... DO UPDATE SET semantic_time = excluded.semantic_timeinqueries/records/ingest/upsert-record.sql. - 3.2 Wire the record write to derive
semantic_time(manifest consent_time_field / cursor_field, coerced ISO, falling back toemitted_atwhen absent/unparseable) on both backends; do NOT rewrite historical rows.
4. Port explore-timeline-substrate.ts
- 4.1 Add
server/explore-timeline-substrate.tsimplementingExploreTimelineDependenciesfor SQLite (sqliteExploreTimelineDeps) and Postgres (postgresExploreTimelineDeps), dispatched bybuildExploreTimelineDeps(); reads ordered byCOALESCE(NULLIF(semantic_time, ''), emitted_at) DESC, record_key DESC. - 4.2 Port the server-side composite-cursor store (short opaque handle for
the O(partition-count) blob; stale/expired/unknown handle → typed
invalid_cursor400). - 4.3 Keep the storage boundary: substrate speaks only to storage, parameterized value placeholders only, fixed column/table names.
5. Port the rs.explore.timeline operation
- 5.1 Add
operations/rs-explore-timeline/index.tswith the k-way merge across(connector_instance_id, stream)partitions, NO partition cap,MAX(id)ingest-sequence snapshot anchor,new_since_snapshotcount. - 5.2 Return both
connector_id(type) andconnector_instance_id(instance) on every record; one opaquenext_cursor.
6. Mount the reference-only route
- 6.1 Mount
GET /_ref/explore/recordsinserver/routes/ref-admin.tsusingbuildExploreTimelineDeps()+executeExploreTimeline. - 6.2 Keep it documented as a reference-only
_refread surface (not core PDPP API).
7. Tests (dual-dialect)
- 7.1 Migration tests: column add is idempotent and works on a NON-EMPTY records table with no bulk UPDATE; expression index exists after migration (both backends).
- 7.2 COALESCE fallback: a
''row sorts byemitted_at; a realsemantic_timerow sorts by that value. - 7.3 Postgres
EXPLAINof the merged-timeline read shows Index Scan, no Sort, before any backfill. - 7.4 Substrate conformance: SQLite and Postgres return identical merged-feed
observable results (ordering, cursor paging,
new_since_snapshot, uncapped partitions, both identities). - 7.5 Route test:
GET /_ref/explore/recordsreturns the merged feed with a handle/cursor; paging is strictly non-increasing semantic time, no duplicates, every partition reachable.
8. Validation
- 8.1
tscclean; full reference-implementation suite green (dual-backend). - 8.2
openspec validate port-explore-timeline-server-foundation --strictpasses. - 8.3
openspec validate --all --strictpasses.
Acceptance checks
- On a non-empty SQLite DB and a non-empty Postgres DB, boot runs the migration
with no bulk
UPDATE, adds the column idempotently, and creates the expression index; re-running boot is a no-op. GET /_ref/explore/recordsorders the merged cross-source feed byCOALESCE(NULLIF(semantic_time, ''), emitted_at); pre-migration rows fall back toemitted_atand are not mis-attributed.- No bucket-aggregate endpoint is introduced by this change (out of scope).
9. Follow-on: server oldest-first direction
- 9.1 Add
direction=ascsupport tors.explore.timelineand carry the direction inside the composite cursor so every page of one traversal uses the same keyset direction. - 9.2 Flip both SQLite and Postgres partition seek predicates and
ORDER BYclauses fordirection=asc, while preserving thenowCeilingpast/future clamp. - 9.3 Parse
direction=asconGET /_ref/explore/records; default all other values to newest-first (desc). - 9.4 Add
rs-explore-timeline-oldest-ascending.test.jsproving oldest-first pages from the genuinely-oldest record across partitions to the end without client reversal.