From 5f57c63426a01310f540be4c35e402da4c5e497f Mon Sep 17 00:00:00 2001 From: Konstantinos Thermos Date: Wed, 16 Sep 2026 16:32:02 +0300 Subject: [PATCH] fix(journey): make the source_assignment_id migration replay-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unreleased migration has to survive being replayed on a schema where it already applied — installs skip releases, and a test rewinds a fully migrated database to the last released version to prove it. The bare ADD COLUMN threw "duplicate column name" on that replay, which took every migration test down with it. Guard the ALTER on pragma_table_info, and add `source_assignment_id IS NULL` to the backfill so a replay cannot re-resolve a row that has since followed its stop to another day. The trips e2e harness hand-builds a minimal journey_entries table rather than running migrations, so it needs the column spelled out too. --- server/src/db/migrations.ts | 9 +++++++-- server/tests/e2e/trips.e2e.test.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 5763547da..5a1087b56 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -4927,7 +4927,12 @@ function runMigrations(db: Database.Database): void { * already there instead of writing a duplicate beside it. */ () => { - db.exec('ALTER TABLE journey_entries ADD COLUMN source_assignment_id INTEGER'); + const hasColumn = db + .prepare("SELECT 1 FROM pragma_table_info('journey_entries') WHERE name = 'source_assignment_id'") + .get(); + if (!hasColumn) db.exec('ALTER TABLE journey_entries ADD COLUMN source_assignment_id INTEGER'); + // `source_assignment_id IS NULL` keeps the replay from re-resolving a row that + // has since followed its stop to another day. db.exec(` UPDATE journey_entries SET source_assignment_id = ( @@ -4938,7 +4943,7 @@ function runMigrations(db: Database.Database): void { ORDER BY d.day_number ASC, d.date ASC, da.order_index ASC, da.id ASC LIMIT 1 ) - WHERE source_place_id IS NOT NULL + WHERE source_place_id IS NOT NULL AND source_assignment_id IS NULL `); db.exec( 'CREATE INDEX IF NOT EXISTS idx_journey_entries_source_assignment ON journey_entries(source_place_id, source_assignment_id)', diff --git a/server/tests/e2e/trips.e2e.test.ts b/server/tests/e2e/trips.e2e.test.ts index 16915c91b..f70c761dd 100644 --- a/server/tests/e2e/trips.e2e.test.ts +++ b/server/tests/e2e/trips.e2e.test.ts @@ -39,7 +39,7 @@ const { db } = vi.hoisted(() => { created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`); // deleteTrip cleans up synced journey entries before dropping the trip row. tmp.exec(`CREATE TABLE journey_entries (id INTEGER PRIMARY KEY AUTOINCREMENT, journey_id INTEGER, - source_trip_id INTEGER, source_place_id INTEGER, type TEXT NOT NULL);`); + source_trip_id INTEGER, source_place_id INTEGER, source_assignment_id INTEGER, type TEXT NOT NULL);`); // bundle()'s todoItems now runs TodoService's real SQL (DI-injected, no mock). tmp.exec(`CREATE TABLE todo_items (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT NOT NULL, checked INTEGER NOT NULL DEFAULT 0, category TEXT, sort_order INTEGER NOT NULL DEFAULT 0,