diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 50aaffe4a..a79cf90cf 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3772,17 +3772,6 @@ function runMigrations(db: Database.Database): void { if (!err.message?.includes('duplicate column name')) throw err; } }, - // Per-segment travel mode for boundary legs: a leg whose ORIGIN is not a place - // (booking arrival, morning hotel) stores its mode on the DESTINATION stop. - // NULL = inherit the day default. INERT whenever the previous timeline element - // is itself a place (that place's outgoing leg_transport_mode wins). - () => { - try { - db.exec('ALTER TABLE day_assignments ADD COLUMN incoming_leg_transport_mode TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, // School holidays are a visual Vacay calendar layer. Keep them separate from // public holidays so applyHolidayCalendars never removes vacation entries for // school-break dates. Appended LAST: this branch was cut before #1435/#1281 @@ -3876,6 +3865,20 @@ function runMigrations(db: Database.Database): void { const hasColor = db.prepare("SELECT 1 FROM pragma_table_info('day_notes') WHERE name = 'color'").get(); if (!hasColor) db.exec('ALTER TABLE day_notes ADD COLUMN color TEXT'); }, + + // Per-segment travel mode for boundary legs: a leg whose ORIGIN is not a place + // (booking arrival, morning hotel) stores its mode on the DESTINATION stop. + // NULL = inherit the day default. INERT whenever the previous timeline element + // is itself a place (that place's outgoing leg_transport_mode wins). + // Appended LAST: the array is index-addressed against schema_version, so a slot + // inserted anywhere above this line is simply skipped on every existing database. + () => { + try { + db.exec('ALTER TABLE day_assignments ADD COLUMN incoming_leg_transport_mode TEXT'); + } catch (err: any) { + if (!err.message?.includes('duplicate column name')) throw err; + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/tests/integration/leg-mode-incoming.test.ts b/server/tests/integration/leg-mode-incoming.test.ts index 8548f291b..1e2adcad7 100644 --- a/server/tests/integration/leg-mode-incoming.test.ts +++ b/server/tests/integration/leg-mode-incoming.test.ts @@ -32,6 +32,27 @@ describe('incoming_leg_transport_mode migration', () => { const cols = testDb.prepare(`PRAGMA table_info(day_assignments)`).all() as { name: string }[]; expect(cols.map(c => c.name)).toContain('incoming_leg_transport_mode'); }); + + // The case above starts from an empty database and runs every migration, so it + // passes wherever this one sits in the array. Existing installs replay only the + // slots above their schema_version, which is why the migration has to be the + // trailing entry: from anywhere but the end it is silently skipped on upgrade. + it('still lands on a database that already holds every earlier migration', () => { + const upgraded = new Database(':memory:'); + upgraded.exec('PRAGMA foreign_keys = ON'); + createTables(upgraded); + runMigrations(upgraded); + + const { version } = upgraded.prepare('SELECT version FROM schema_version').get() as { version: number }; + upgraded.exec('ALTER TABLE day_assignments DROP COLUMN incoming_leg_transport_mode'); + upgraded.prepare('UPDATE schema_version SET version = ?').run(version - 1); + + runMigrations(upgraded); + + const cols = upgraded.prepare(`PRAGMA table_info(day_assignments)`).all() as { name: string }[]; + expect(cols.map(c => c.name)).toContain('incoming_leg_transport_mode'); + upgraded.close(); + }); }); // ─────────────────────────────────────────────────────────────────────────────