From bfa512589c90f8a6b35fb3c627b8011b9df5ddb6 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 7 Oct 2026 16:08:48 +0200 Subject: [PATCH] fix(db): baseline a database from the pre-ORM Tours branch correctly That branch numbered its own steps after the upstream step it was cut from, so a database at schema_version 243 to 246 (or 216 to 218) with the Tours tables had four upstream migrations marked as run without ever running them. The baseline now cuts such a version back to the last upstream step, and the Tours migration adds the difficulty cap to a tours table an earlier branch stage created without it. --- server/src/db/legacy-baseline.ts | 24 ++++++++++++++-- .../Migration20200101042000_tours.ts | 15 +++++++++- server/tests/unit/db/legacy-baseline.test.ts | 21 ++++++++++++++ server/tests/unit/db/tours-migration.test.ts | 28 +++++++++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/server/src/db/legacy-baseline.ts b/server/src/db/legacy-baseline.ts index 51be84e37..27a748f8d 100644 --- a/server/src/db/legacy-baseline.ts +++ b/server/src/db/legacy-baseline.ts @@ -128,6 +128,25 @@ async function legacyVersion(connection: Connection, migrator: Migrator): Promis return version; } +/** + * The pre-ORM Tours branch numbered its own steps after the upstream step it + * was cut from: 216 to 218 on the older line, 243 to 246 on the later one. + * Such a database's schema_version counts Tours steps, not the upstream steps + * of the same numbers, so it is cut back to the last upstream step it really + * has. The upstream steps then run, and the Tours migration adopts the tables + * the branch already created. + */ +async function withoutToursSteps(connection: Connection, version: number): Promise { + const toursLine = version >= 216 && version <= 218 ? 215 : version >= 243 && version <= 246 ? 242 : null; + if (toursLine === null) return version; + const table: unknown[] = await connection.execute( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'tour_types'`, + ); + if (table.length === 0) return version; + console.log(`[DB] schema_version ${version} was written by the pre-ORM Tours branch; baselining to upstream step ${toursLine}`); + return toursLine; +} + /** * The migrations a positional-runner database already has — steps 1..N, never * the baseline (see the file header) — or `[]` for every database that is not @@ -138,8 +157,9 @@ export async function planLegacyBaseline( migrator: Migrator, readSource: ReadSource = readFromDisk, ): Promise { - const version = await legacyVersion(connection, migrator); - if (version === null) return []; + const recorded = await legacyVersion(connection, migrator); + if (recorded === null) return []; + const version = await withoutToursSteps(connection, recorded); const map = buildLegacyStepMap(await migrator.getPending(), readSource); if (version > map.finalStep) { diff --git a/server/src/db/migrations/Migration20200101042000_tours.ts b/server/src/db/migrations/Migration20200101042000_tours.ts index 585a65e51..c9edf9ff1 100644 --- a/server/src/db/migrations/Migration20200101042000_tours.ts +++ b/server/src/db/migrations/Migration20200101042000_tours.ts @@ -1,4 +1,5 @@ import { Migration } from '@mikro-orm/migrations'; +import { addColumnIfMissing, tableExists } from '../migration-utils'; /** * Tours: a facet on `places` for hikes and other single-day routes. @@ -18,7 +19,19 @@ import { Migration } from '@mikro-orm/migrations'; export class Migration20200101042000_tours extends Migration { override name = 'Migration20200101042000_tours'; - override up(): void { + override async up(): Promise { + // The branch's earlier stages created `tours` without the difficulty cap. + // `CREATE TABLE IF NOT EXISTS` below leaves such a table as it is, so the + // column is added first, while the statements below are still queued. + if (await tableExists(this, 'tours')) { + await addColumnIfMissing( + this, + 'tours', + 'max_hiking_difficulty', + 'max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6)', + ); + } + this.addSql(` CREATE TABLE IF NOT EXISTS tour_types ( key TEXT PRIMARY KEY, diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index 6eb70860f..94400cf9f 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -238,4 +238,25 @@ describe('planLegacyBaseline / migrateToHead', () => { await migrate(); await expect(migrate()).resolves.toBeUndefined(); }); + + it.each([ + [246, 242], + [243, 242], + [217, 215], + ])('LEGACYBASE-010: a pre-ORM Tours branch database at schema_version %i is baselined to upstream step %i', async (version, upstream) => { + const all = await pendingNames(orm); + const numbered = all.filter((name) => !UNNUMBERED.includes(name)); + await rawExec(orm, 'CREATE TABLE schema_version (version INTEGER NOT NULL)'); + await rawExec(orm, `INSERT INTO schema_version (version) VALUES (${version})`); + await rawExec(orm, 'CREATE TABLE tour_types (key TEXT PRIMARY KEY)'); + expect(await baseline()).toEqual(numbered.slice(0, upstream)); + }); + + it('LEGACYBASE-011: the same schema_version without the Tours tables is an upstream database and keeps its step', async () => { + const all = await pendingNames(orm); + const numbered = all.filter((name) => !UNNUMBERED.includes(name)); + await rawExec(orm, 'CREATE TABLE schema_version (version INTEGER NOT NULL)'); + await rawExec(orm, 'INSERT INTO schema_version (version) VALUES (244)'); + expect(await baseline()).toEqual(numbered.slice(0, 244)); + }); }); diff --git a/server/tests/unit/db/tours-migration.test.ts b/server/tests/unit/db/tours-migration.test.ts index f3bbc31a8..c7d84cfd8 100644 --- a/server/tests/unit/db/tours-migration.test.ts +++ b/server/tests/unit/db/tours-migration.test.ts @@ -136,4 +136,32 @@ describe('Tours migration', () => { await orm.close(true); } }, 30000); + + it('TOURMIG-004: a tours table from an earlier branch stage gains the difficulty cap and keeps its rows', async () => { + const orm = await ormBeforeTarget(); + try { + await rawExec(orm, `CREATE TABLE tour_types ( + key TEXT PRIMARY KEY, label_key TEXT NOT NULL, icon TEXT NOT NULL, color TEXT NOT NULL, routing_profile TEXT, + is_sport INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0)`); + await rawExec(orm, "INSERT INTO tour_types (key, label_key, icon, color, routing_profile) VALUES ('hike', 'tourTypes.hike', 'Mountain', '#16a34a', 'pedestrian')"); + await rawExec(orm, `CREATE TABLE tours ( + place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE, + tour_type TEXT NOT NULL REFERENCES tour_types(key), distance REAL, elevation_gain REAL, elevation_loss REAL, + duration REAL, difficulty TEXT, wanderer_ref TEXT, match_confidence REAL, tour_group_id INTEGER, + created_at TEXT DEFAULT CURRENT_TIMESTAMP)`); + await seedTripWithPlace(orm); + await rawExec(orm, "INSERT INTO tours (place_id, tour_type, distance) VALUES (100, 'hike', 8)"); + + await migrateTo(orm, TARGET); + + expect(await rawQuery(orm, 'SELECT place_id, distance, max_hiking_difficulty FROM tours')).toEqual([ + { place_id: 100, distance: 8, max_hiking_difficulty: 2 }, + ]); + expect(await rawQuery(orm, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'tour_waypoints'")).toEqual([ + { name: 'tour_waypoints' }, + ]); + } finally { + await orm.close(true); + } + }, 30000); });