mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
bde2ad2ca8
With the Tours addon on, the Atlas panel gets a third tab, Tours, beside
Stats and Bucket List: what you have done on your tours across all your
trips. A period (all time, this year, the last 12 months or an earlier year)
and a tour type narrow it; the tab shows the distance large with the number
of tours at its corner, the ascent and descent, and the types with their
distance. "Show tours" opens every tour of the period in a dialog with the
same filters, the sums, the split by type and one row per tour.
What counts is said, never guessed. A tour counts once the day it is on is
over, with the values of the planning, badged "Planned" and explained as
not measured. A linked actual track (ACT-01) replaces those values for its
execution, badged "Recorded" and named, and is never added on top nor
counted as a tour of its own. A tour on several days counts once, a whole
route does not count beside the stages planned from it, a paddle tour sums
no heights, and a value nobody knows stays out of the sum, which then reads
"at least". Only the user's own part counts: a day names them among its
participants, or names nobody on a trip of their own; being able to open
somebody else's trip makes none of its tours theirs. Tours still ahead, on
no day or only on undated days are named as not counting. A tour that fell
through can be left out of the sums ("Leave out"): it stays in the plan
and in the list, dimmed, and "Count again" brings it back. Only the user's
own numbers change.
Server: GET /api/tours/activity-stats answers the user's activity entries
across every trip they can open, and PUT /api/tours/activity-stats/exclusions
leaves a tour out or counts it again (the tour must be in such a trip, else
404). A tour_activity_exclusions table keeps the exclusions per user and
tour. The sums are added up by a shared summary in @trek/shared, so the
Atlas and the assistant say the same numbers. MCP: get_tour_activity_stats
and set_tour_activity_excluded over the same service. The tab reads live
and needs a connection, like the rest of the Atlas statistics.
313 lines
15 KiB
TypeScript
313 lines
15 KiB
TypeScript
/**
|
|
* The legacy step → migration map and the baseline that uses it
|
|
* (`src/db/legacy-baseline.ts`, Plan 4 final review B1).
|
|
*
|
|
* The map is derived from the migrations' own `Legacy migration step N`
|
|
* docstrings; LEGACYMAP-001/002 pin that derivation against the files on disk
|
|
* so a new migration that mis-numbers, duplicates or drops a step fails here
|
|
* rather than on somebody's upgrade. The rest pin the fail-closed paths.
|
|
* The end-to-end upgrade of real legacy databases is
|
|
* `tests/integration/legacy-upgrade-v*.test.ts`.
|
|
*/
|
|
import { buildLegacyStepMap, migrateToHead, planLegacyBaseline } from '../../../src/db/legacy-baseline';
|
|
import {
|
|
createMigrationOrm,
|
|
migrateTo,
|
|
migratorOf,
|
|
pendingNames,
|
|
rawExec,
|
|
rawQuery,
|
|
} from '../../helpers/migration-step';
|
|
import type { MigrationInfo } from '@mikro-orm/core';
|
|
import type { MikroORM } from '@mikro-orm/sqlite';
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
const MIGRATIONS = path.join(__dirname, '../../../src/db/migrations');
|
|
const BASELINE = 'Migration20200101000000_baseline_schema';
|
|
/** The migrations that carry no legacy step: the baseline, and the post-legacy ones every upgrade runs. */
|
|
const UNNUMBERED = [
|
|
BASELINE,
|
|
'Migration20200101040200_trek_photo_cache_meta_cache_key_not_null',
|
|
'Migration20200101042000_tours',
|
|
'Migration20200101042100_a_trip_reminder_remembers_it_was_sent',
|
|
'Migration20200101042200_an_index_on_every_foreign_key',
|
|
'Migration20200101042300_every_tour_type_the_contract_names',
|
|
'Migration20200101042400_one_account_per_email_whatever_its_case',
|
|
'Migration20200101042500_a_cron_tick_runs_in_one_process',
|
|
'Migration20200101042600_a_session_row_per_sign_in',
|
|
'Migration20200101042700_timestamps_stored_in_one_spelling',
|
|
'Migration20200101042800_an_email_the_account_typed_in_itself',
|
|
'Migration20200101042900_tour_planned_total_duration',
|
|
'Migration20200101043000_tour_break_additional_duration',
|
|
'Migration20200101043100_an_imported_tour_has_no_hiking_difficulty',
|
|
'Migration20200101043200_via_ferrata_tiles_outlive_a_restart',
|
|
'Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata',
|
|
'Migration20200101043400_wanderer_connection_and_tour_sources',
|
|
'Migration20200101043500_wanderer_difficulty_on_the_sac_scale',
|
|
'Migration20200101043600_a_tour_remembers_the_import_it_was_derived_from',
|
|
'Migration20200101043700_an_import_planned_over_keeps_its_recorded_track',
|
|
'Migration20200101043800_a_tour_records_where_it_came_from',
|
|
'Migration20200101043900_a_tour_can_be_paddled',
|
|
'Migration20200101044000_a_paddle_tour_plans_its_portages_and_locks',
|
|
'Migration20200101044100_paddle_obstacles_from_openstreetmap',
|
|
'Migration20200101044200_a_paddle_tour_records_its_obstacle_decisions',
|
|
'Migration20200101044300_a_trip_links_a_journal',
|
|
'Migration20200101044400_a_paddle_region_builds_its_water_network',
|
|
'Migration20200101044500_a_tour_waypoint_can_shape_the_route',
|
|
'Migration20200101044600_a_paddle_tour_remembers_how_it_crosses_lakes',
|
|
'Migration20200101044700_paddle_water_data_loads_where_a_paddler_plans',
|
|
'Migration20200101044800_a_tour_counts_its_revisions',
|
|
'Migration20200101044900_a_tour_waypoint_can_be_a_place_of_the_trip',
|
|
'Migration20200101045000_a_hike_keeps_its_routing_preferences',
|
|
'Migration20200101045100_a_tour_remembers_the_trip_it_was_taken_from',
|
|
'Migration20200101045200_a_tour_can_be_ridden',
|
|
'Migration20200101045300_a_tour_can_be_a_city_walk',
|
|
'Migration20200101045400_a_tour_waypoint_remembers_the_kind_of_place_it_was_found_as',
|
|
'Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity',
|
|
'Migration20200101045510_a_tour_can_be_a_run',
|
|
'Migration20200101045520_a_tour_can_be_a_ski_track',
|
|
'Migration20200101045600_a_tour_can_be_ridden_on_horseback',
|
|
'Migration20200101045700_a_tour_records_the_changes_saved_from_a_proposal',
|
|
'Migration20200101045710_a_saved_tour_change_can_be_taken_back',
|
|
'Migration20200101045900_a_tour_keeps_the_track_it_was_done_with',
|
|
'Migration20200101045910_a_tour_can_be_left_out_of_ones_activities',
|
|
];
|
|
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
|
// schema_version past it.
|
|
const LEGACY_FINAL_STEP = 258;
|
|
|
|
/** Synthetic migrations whose "source" is the text given, for the fail-closed cases. */
|
|
function fake(sources: Array<[string, string]>): { list: MigrationInfo[]; read: (p: string) => string } {
|
|
const byPath = new Map(sources.map(([name, text]) => [`/fake/${name}.ts`, text]));
|
|
return {
|
|
list: sources.map(([name]) => ({ name, path: `/fake/${name}.ts` })),
|
|
read: (p) => byPath.get(p) ?? '',
|
|
};
|
|
}
|
|
|
|
describe('the legacy step map', () => {
|
|
it('LEGACYMAP-001: every numbered migration maps to exactly one step, steps 1..258 are all present, in order, behind the baseline', async () => {
|
|
const orm = await createMigrationOrm();
|
|
try {
|
|
const all = await migratorOf(orm).getPending();
|
|
const map = buildLegacyStepMap(all);
|
|
expect(map.finalStep).toBe(LEGACY_FINAL_STEP);
|
|
expect([...map.steps.keys()]).toEqual(Array.from({ length: LEGACY_FINAL_STEP }, (_, i) => i + 1));
|
|
expect(new Set(map.steps.values()).size).toBe(LEGACY_FINAL_STEP);
|
|
expect(map.baseline).toEqual([BASELINE]);
|
|
|
|
// The unnumbered ones: the baseline, and the post-legacy migrations that
|
|
// must always run on an upgraded install.
|
|
const numbered = new Set(map.steps.values());
|
|
expect(all.map((m) => m.name).filter((name) => !numbered.has(name))).toEqual(UNNUMBERED);
|
|
expect(map.steps.get(242)).toBe('Migration20200101040300_a_booked_night_finally_took_the_seat_a_new');
|
|
expect(map.steps.get(243)).toBe('Migration20200101040400_immich_learns_the_switch_synology_airtrail_and');
|
|
expect(map.steps.get(258)).toBe('Migration20200101041900_a_journey_that_puts_an_entry_on');
|
|
expect(map.steps.get(26)).toBe('Migration20200101002600_day_assignments_add_assignment_time');
|
|
} finally {
|
|
await orm.close(true);
|
|
}
|
|
});
|
|
|
|
it('LEGACYMAP-002: the map agrees with an independent read of the files on disk', async () => {
|
|
const onDisk = new Map<string, number>();
|
|
for (const file of fs.readdirSync(MIGRATIONS).filter((f) => f.endsWith('.ts'))) {
|
|
const steps = [
|
|
...fs.readFileSync(path.join(MIGRATIONS, file), 'utf8').matchAll(/Legacy migration step (\d+)\b/g),
|
|
];
|
|
expect(steps.length, file).toBeLessThanOrEqual(1);
|
|
if (steps.length === 1) onDisk.set(file.replace(/\.ts$/, ''), Number(steps[0]![1]));
|
|
}
|
|
const orm = await createMigrationOrm();
|
|
try {
|
|
const map = buildLegacyStepMap(await migratorOf(orm).getPending());
|
|
expect(new Map([...map.steps].map(([step, name]) => [name, step]))).toEqual(onDisk);
|
|
} finally {
|
|
await orm.close(true);
|
|
}
|
|
});
|
|
|
|
it('LEGACYMAP-003: refuses a step claimed twice', () => {
|
|
const { list, read } = fake([
|
|
['M0_base', ''],
|
|
['M1', 'Legacy migration step 1'],
|
|
['M2', 'Legacy migration step 1'],
|
|
]);
|
|
expect(() => buildLegacyStepMap(list, read)).toThrow(/legacy step 1 is claimed by both M1 and M2/);
|
|
});
|
|
|
|
it('LEGACYMAP-004: refuses a gap in the steps', () => {
|
|
const { list, read } = fake([
|
|
['M0_base', ''],
|
|
['M1', 'Legacy migration step 1'],
|
|
['M3', 'Legacy migration step 3'],
|
|
]);
|
|
expect(() => buildLegacyStepMap(list, read)).toThrow(/no migration carries legacy step 2/);
|
|
});
|
|
|
|
it('LEGACYMAP-005: refuses a migration naming two steps, and steps applied out of order', () => {
|
|
const two = fake([
|
|
['M0_base', ''],
|
|
['M1', 'Legacy migration step 1, formerly Legacy migration step 2'],
|
|
]);
|
|
expect(() => buildLegacyStepMap(two.list, two.read)).toThrow(/M1 names more than one legacy step \(1, 2\)/);
|
|
|
|
const swapped = fake([
|
|
['M0_base', ''],
|
|
['M1', 'Legacy migration step 2'],
|
|
['M2', 'Legacy migration step 1'],
|
|
]);
|
|
expect(() => buildLegacyStepMap(swapped.list, swapped.read)).toThrow(
|
|
/M2 \(legacy step 1\) sorts after legacy step 2/,
|
|
);
|
|
});
|
|
|
|
it('LEGACYMAP-006: refuses no baseline, no numbered step at all, and a migration without a source file', () => {
|
|
const noBase = fake([['M1', 'Legacy migration step 1']]);
|
|
expect(() => buildLegacyStepMap(noBase.list, noBase.read)).toThrow(
|
|
/no baseline migration sorts ahead of legacy step 1/,
|
|
);
|
|
|
|
const none = fake([['M0_base', '']]);
|
|
expect(() => buildLegacyStepMap(none.list, none.read)).toThrow(/no migration names a legacy step/);
|
|
|
|
expect(() => buildLegacyStepMap([{ name: 'M0_classOnly' }])).toThrow(/M0_classOnly has no source file/);
|
|
});
|
|
});
|
|
|
|
describe('planLegacyBaseline / migrateToHead', () => {
|
|
let orm: MikroORM;
|
|
|
|
beforeEach(async () => {
|
|
orm = await createMigrationOrm();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await orm.close(true);
|
|
});
|
|
|
|
const baseline = () => planLegacyBaseline(orm.em.getConnection(), migratorOf(orm));
|
|
const migrate = () => migrateToHead(orm.em.getConnection(), migratorOf(orm));
|
|
const recorded = async () =>
|
|
(await rawQuery<{ name: string }>(orm, 'SELECT name FROM mikro_orm_migrations ORDER BY id')).map((r) => r.name);
|
|
|
|
it('LEGACYBASE-001: a fresh database (no schema_version) is left alone', async () => {
|
|
await expect(baseline()).resolves.toEqual([]);
|
|
expect(await recorded()).toEqual([]);
|
|
});
|
|
|
|
it('LEGACYBASE-002: a database the migrator already knows is left alone, even though it carries schema_version', async () => {
|
|
await migrateTo(orm, 'Migration20200101020900_create_schema_version_new');
|
|
await rawExec(orm, 'INSERT INTO schema_version (version) VALUES (241)');
|
|
const before = await recorded();
|
|
await expect(baseline()).resolves.toEqual([]);
|
|
expect(await recorded()).toEqual(before);
|
|
});
|
|
|
|
it('LEGACYBASE-003: a legacy database at step N plans steps 1..N in application order — never the baseline — and writes nothing', async () => {
|
|
const all = await pendingNames(orm);
|
|
await rawExec(orm, 'CREATE TABLE schema_version (version INTEGER NOT NULL)');
|
|
await rawExec(orm, 'INSERT INTO schema_version (version) VALUES (10)');
|
|
// Review N1: the legacy runner ran createTables() before its steps on every
|
|
// boot, and ~30 tables exist only in the baseline, so it must run, not be marked.
|
|
await expect(baseline()).resolves.toEqual(all.slice(1, 11));
|
|
expect(all[0]).toBe(BASELINE);
|
|
expect(await recorded()).toEqual([]);
|
|
});
|
|
|
|
it('LEGACYBASE-004: refuses a schema_version newer than the last legacy step, and records nothing', async () => {
|
|
await rawExec(orm, 'CREATE TABLE schema_version (version INTEGER NOT NULL)');
|
|
await rawExec(orm, `INSERT INTO schema_version (version) VALUES (${LEGACY_FINAL_STEP + 1})`);
|
|
await expect(baseline()).rejects.toThrow(
|
|
`[DB] Refusing to boot: schema_version 259 is newer than the last legacy step this release knows (258)`,
|
|
);
|
|
expect(await recorded()).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
['0', '(0)', /schema_version 0 is not a legacy step/],
|
|
['a text value', "('205')", /schema_version 205 is not a legacy step/],
|
|
['a fraction', '(20.5)', /schema_version 20.5 is not a legacy step/],
|
|
['two rows', '(205), (206)', /schema_version holds 2 rows/],
|
|
['no row', null, /schema_version holds 0 rows/],
|
|
])(
|
|
'LEGACYBASE-005: refuses an unmappable schema_version (%s), and records nothing',
|
|
async (_label, values, error) => {
|
|
await rawExec(orm, 'CREATE TABLE schema_version (version)');
|
|
if (values) await rawExec(orm, `INSERT INTO schema_version (version) VALUES ${values}`);
|
|
await expect(baseline()).rejects.toThrow(error);
|
|
expect(await recorded()).toEqual([]);
|
|
},
|
|
);
|
|
|
|
it('LEGACYBASE-006: the baseline rows commit with the run — a failed first run leaves nothing recorded, so the next boot baselines again', async () => {
|
|
const all = await pendingNames(orm);
|
|
await rawExec(orm, 'CREATE TABLE schema_version (version INTEGER NOT NULL)');
|
|
await rawExec(orm, 'INSERT INTO schema_version (version) VALUES (241)');
|
|
// An obstacle for Migration20200101040200's rebuild, which runs after the baseline.
|
|
await rawExec(orm, 'CREATE TABLE trek_photo_cache_meta_new (x INTEGER)');
|
|
await expect(migrate()).rejects.toThrow();
|
|
expect(await recorded()).toEqual([]);
|
|
|
|
// Nothing half-done: the next boot still sees a legacy install and plans the same steps.
|
|
await expect(baseline()).resolves.toEqual(all.filter((name) => name !== BASELINE).slice(0, 241));
|
|
expect(await recorded()).toEqual([]);
|
|
});
|
|
|
|
it('LEGACYBASE-007: an install the positional runner left past step 242 is baselined to its step, never refused or replayed', 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 (250)');
|
|
const planned = await baseline();
|
|
expect(planned).toEqual(numbered.slice(0, 250));
|
|
expect(planned.at(-1)).toBe('Migration20200101041100_half_company_holidays_0_5_covers_the');
|
|
expect(await recorded()).toEqual([]);
|
|
});
|
|
|
|
it('LEGACYBASE-008: refuses a database a newer TREK migrated, before applying anything', async () => {
|
|
await migrate();
|
|
await rawExec(
|
|
orm,
|
|
"INSERT INTO mikro_orm_migrations (name, executed_at) VALUES ('Migration20990101000000_from_a_newer_release', CURRENT_TIMESTAMP)",
|
|
);
|
|
const before = await recorded();
|
|
await expect(migrate()).rejects.toThrow(
|
|
/Refusing to boot: the database was migrated by a newer TREK \(1 unknown migration\(s\), latest Migration20990101000000_from_a_newer_release\)/,
|
|
);
|
|
expect(await recorded()).toEqual(before);
|
|
});
|
|
|
|
it('LEGACYBASE-009: a database at head of this build boots again without complaint', async () => {
|
|
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));
|
|
});
|
|
});
|