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.
Showing the changes to scripts (2 of 2 files). Show all
Unified · Split
| @@ -39,10 +39,12 @@ function createSchema( · 38 unmodified lines | ||
| 39 | 39 | message_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, role TEXT NOT NULL, |
| 40 | 40 | text TEXT NOT NULL, is_streaming INTEGER NOT NULL, attachments_json TEXT, |
| 41 | 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 | 44 | database.exec(`CREATE TABLE projection_thread_activities ( |
| 43 | 45 | activity_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, tone TEXT NOT NULL, |
| 44 | 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 | 48 | database.exec(`CREATE TABLE projection_thread_sessions ( |
| 47 | 49 | thread_id TEXT PRIMARY KEY, status TEXT NOT NULL, provider_name TEXT, |
| 48 | 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 | 158 | for (let a = 0; a < activitiesPerThread; a += 1) { |
| 157 | 159 | 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 | + ); | |
| 160 | 170 | } |
| 161 | 171 | } |
| 162 | 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 | 423 | // The delete mirrors the server's predicate, which spares rows carrying a |
| 379 | 424 | // checkpoint. Without the `checkpoint_turn_count IS NULL` clause a checkpoint |
| 380 | 425 | // row that happened to be pending would be destroyed along with the starts. |
| @@ -553,7 +553,20 @@ export function seedDevDatabase(options: DevSeedOptions): DevSeedSummary { · 552 unmodified lines | ||
| 553 | 553 | table: "projection_thread_activities", |
| 554 | 554 | keyColumn: "thread_id", |
| 555 | 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 | 572 | const sessions = record( |