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
39 message_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, role TEXT NOT NULL,39 message_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, role TEXT NOT NULL,
40 text TEXT NOT NULL, is_streaming INTEGER NOT NULL, attachments_json TEXT,40 text TEXT NOT NULL, is_streaming INTEGER NOT NULL, attachments_json TEXT,
41 created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`);41 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.
42 database.exec(`CREATE TABLE projection_thread_activities (44 database.exec(`CREATE TABLE projection_thread_activities (
43 activity_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, tone TEXT NOT NULL,45 activity_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, tone TEXT NOT NULL,
44 kind TEXT NOT NULL, summary TEXT NOT NULL, payload_json TEXT NOT NULL,46 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)`);
46 database.exec(`CREATE TABLE projection_thread_sessions (48 database.exec(`CREATE TABLE projection_thread_sessions (
47 thread_id TEXT PRIMARY KEY, status TEXT NOT NULL, provider_name TEXT,49 thread_id TEXT PRIMARY KEY, status TEXT NOT NULL, provider_name TEXT,
48 active_turn_id TEXT, last_error TEXT, updated_at TEXT NOT NULL)`);50 active_turn_id TEXT, last_error TEXT, updated_at TEXT NOT NULL)`);
@@ -155,8 +157,16 @@ function makeSource( · 106 unmodified lines
155 );157 );
156 for (let a = 0; a < activitiesPerThread; a += 1) {158 for (let a = 0; a < activitiesPerThread; a += 1) {
157 database159 database
158 .prepare(`INSERT INTO projection_thread_activities VALUES (?,?,'neutral','tool','ran',?,?)`)160 .prepare(
159 .run(`a-${threadId}-${String(a)}`, threadId, "{}", `2026-07-10T00:00:0${String(a)}.000Z`);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 );
160 }170 }
161 }171 }
162 database.exec("COMMIT");172 database.exec("COMMIT");
@@ -375,6 +385,41 @@ describe("seedDevDatabase", () => { · 212 unmodified lines
375 });385 });
376 });386 });
377 387
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
378 // The delete mirrors the server's predicate, which spares rows carrying a423 // The delete mirrors the server's predicate, which spares rows carrying a
379 // checkpoint. Without the `checkpoint_turn_count IS NULL` clause a checkpoint424 // checkpoint. Without the `checkpoint_turn_count IS NULL` clause a checkpoint
380 // row that happened to be pending would be destroyed along with the starts.425 // 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
553 table: "projection_thread_activities",553 table: "projection_thread_activities",
554 keyColumn: "thread_id",554 keyColumn: "thread_id",
555 keys: threadIds,555 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 },
557 }),570 }),
558 );571 );
559 const sessions = record(572 const sessions = record(