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.
This commit is contained in:
Maurice
2026-10-07 16:08:48 +02:00
parent 63f7eab4bb
commit bfa512589c
4 changed files with 85 additions and 3 deletions
+22 -2
View File
@@ -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<number> {
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<string[]> {
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) {
@@ -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<void> {
// 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,
@@ -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));
});
});
@@ -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);
});