History

Cap activities by the order the app displays them

Auditing the fixture against the real migrations (the method that found the missing checkpoint column) turned up a second gap: the test schema omitted `projection_thread_activities.sequence`, and the copy's per-thread cap ordered on `created_at` alone. The app reads these back ordered by `sequence` first, then `created_at` (ProjectionSnapshotQuery). A burst of tool activity lands many rows on one timestamp, so capping on the timestamp alone cuts arbitrarily among them — demonstrated with four tied rows where "keep the newest two" kept the two the timeline treats as oldest. Ordered to match, falling back to the timestamp when the source predates migration 008. Fixture gained the column, and the regression test fails against the old ordering.

Theo Browne committed 3 months ago

Commit dc375f31 parent a37db1a2 files · +62 −4Browse files

Unified · Split

2 files
  1. scripts/lib/dev-seed.test.ts +48 −3
  2. scripts/lib/dev-seed.ts +14 −1
scripts/lib/dev-seed.test.ts+48−3
@@ -39,10 +39,12 @@ function createSchema( · 38 unmodified lines
3939 message_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, role TEXT NOT NULL,
4040 text TEXT NOT NULL, is_streaming INTEGER NOT NULL, attachments_json TEXT,
4141 created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`);
42+ // `sequence` (migration 008) is what the app orders activities by, ahead of
43+ // created_at, so the copy's per-thread cap has to see it.
4244 database.exec(`CREATE TABLE projection_thread_activities (
4345 activity_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, tone TEXT NOT NULL,
4446 kind TEXT NOT NULL, summary TEXT NOT NULL, payload_json TEXT NOT NULL,
45− created_at TEXT NOT NULL)`);
47+ sequence INTEGER, created_at TEXT NOT NULL)`);
4648 database.exec(`CREATE TABLE projection_thread_sessions (
4749 thread_id TEXT PRIMARY KEY, status TEXT NOT NULL, provider_name TEXT,
4850 active_turn_id TEXT, last_error TEXT, updated_at TEXT NOT NULL)`);
@@ -155,8 +157,16 @@ function makeSource( · 106 unmodified lines
155157 );
156158 for (let a = 0; a < activitiesPerThread; a += 1) {
157159 database
158− .prepare(`INSERT INTO projection_thread_activities VALUES (?,?,'neutral','tool','ran',?,?)`)
159− .run(`a-${threadId}-${String(a)}`, threadId, "{}", `2026-07-10T00:00:0${String(a)}.000Z`);
160+ .prepare(
161+ `INSERT INTO projection_thread_activities VALUES (?,?,'neutral','tool','ran',?,?,?)`,
162+ )
163+ .run(
164+ `a-${threadId}-${String(a)}`,
165+ threadId,
166+ "{}",
167+ a,
168+ `2026-07-10T00:00:0${String(a)}.000Z`,
169+ );
160170 }
161171 }
162172 database.exec("COMMIT");
@@ -375,6 +385,41 @@ describe("seedDevDatabase", () => { · 212 unmodified lines
375385 });
376386 });
377387
388+ // A burst of tool activity lands many rows on the same timestamp. Capping on
389+ // created_at alone then cuts arbitrarily among them, and can keep exactly the
390+ // rows the timeline treats as oldest.
391+ it("caps activities by the order the app displays them", () => {
392+ withDatabases(({ source, target }) => {
393+ const sourceDatabase = new NodeSqlite.DatabaseSync(source);
394+ sourceDatabase.exec("DELETE FROM projection_thread_activities");
395+ const tied = "2026-07-10T00:00:00.000Z";
396+ for (const sequence of [1, 2, 3, 4]) {
397+ sourceDatabase
398+ .prepare(
399+ `INSERT INTO projection_thread_activities
400+ VALUES (?, 't4', 'neutral', 'tool', 'ran', '{}', ?, ?)`,
401+ )
402+ .run(`tie-${String(sequence)}`, sequence, tied);
403+ }
404+ sourceDatabase.close();
405+
406+ seedDevDatabase({
407+ sourceDbPath: source,
408+ targetDbPath: target,
409+ threadLimit: 1,
410+ activityLimit: 2,
411+ seededAt: SEEDED_AT,
412+ });
413+
414+ const kept = query<{ activity_id: string }>(
415+ target,
416+ "SELECT activity_id FROM projection_thread_activities ORDER BY sequence DESC",
417+ ).map((row) => row.activity_id);
418+ // The two highest sequences, not whichever two the storage order yielded.
419+ assert.deepStrictEqual(kept, ["tie-4", "tie-3"]);
420+ });
421+ });
422+
378423 // The delete mirrors the server's predicate, which spares rows carrying a
379424 // checkpoint. Without the `checkpoint_turn_count IS NULL` clause a checkpoint
380425 // row that happened to be pending would be destroyed along with the starts.
scripts/lib/dev-seed.ts+14−1
@@ -553,7 +553,20 @@ export function seedDevDatabase(options: DevSeedOptions): DevSeedSummary { · 552 unmodified lines
553553 table: "projection_thread_activities",
554554 keyColumn: "thread_id",
555555 keys: threadIds,
556− perKeyLimit: { orderBy: "created_at", limit: options.activityLimit },
556+ // Ordered the way the app reads these back — `sequence` first, then
557+ // `created_at` (ProjectionSnapshotQuery). Capping on `created_at`
558+ // alone cuts arbitrarily among rows sharing a timestamp, which is the
559+ // common case for a burst of tool activity: the "newest N" kept can be
560+ // the oldest N in the order the timeline actually displays.
561+ //
562+ // `sequence` arrived in migration 008, so a source without it falls
563+ // back to the timestamp — same drift tolerance as the rest of the copy.
564+ perKeyLimit: {
565+ orderBy: hasColumn(source, "projection_thread_activities", "sequence")
566+ ? `"created_at" DESC, "sequence"`
567+ : `"created_at"`,
568+ limit: options.activityLimit,
569+ },
557570 }),
558571 );
559572 const sessions = record(