mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
aa24a9eff7
With "Show whole trip" on, every leg of every day went to the router one at a time, a second apart, and the route cache lived in memory only, so each visit started over: a trip of a month took 30 to 45 seconds before its route was drawn, on every open. Routes the router answered are now also kept on the device (Dexie v14, routeCache by RouteCalculator's cache key, a month at most, the oldest give way past 3000). A leg found there needs no request and no pause, so a trip whose legs did not change opens with its roads at once; only new or changed legs are routed. Plugin routes stay in memory only, since they depend on trip and day. The cache moves out of RouteCalculator into routeStore.ts. Closes #2794
428 lines
18 KiB
TypeScript
428 lines
18 KiB
TypeScript
/**
|
|
* Upgrade paths of the offline Dexie database.
|
|
*
|
|
* Every version() in src/db/offlineDb.ts runs once, on a real device, when a
|
|
* new bundle opens a database an older one wrote. These tests do the same in
|
|
* fake-indexeddb: build the database as the previous version left it, seed a
|
|
* row into every table it had, open it with the current TrekOfflineDb and check
|
|
* that the rows survived and that the version's upgrade did what it is for.
|
|
*
|
|
* A device can sit on any older version, so each case starts one version back
|
|
* and goes all the way to the current one. The guard at the bottom fails when a
|
|
* version is declared without a case here, or without its stores in HISTORY.
|
|
*/
|
|
import Dexie from 'dexie';
|
|
import 'fake-indexeddb/auto';
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
import { TrekOfflineDb } from '../../../src/db/offlineDb';
|
|
|
|
type StoreSpec = Record<string, string | null>;
|
|
|
|
/**
|
|
* The stores each version declared, as shipped. Never edit a shipped entry: a
|
|
* device that ran it has exactly this schema. A new version adds one entry.
|
|
*/
|
|
const HISTORY: Record<number, StoreSpec> = {
|
|
1: {
|
|
trips: 'id',
|
|
days: 'id, trip_id',
|
|
places: 'id, trip_id',
|
|
packingItems: 'id, trip_id',
|
|
todoItems: 'id, trip_id',
|
|
budgetItems: 'id, trip_id',
|
|
reservations: 'id, trip_id',
|
|
tripFiles: 'id, trip_id',
|
|
mutationQueue: 'id, tripId, status, createdAt',
|
|
syncMeta: 'tripId',
|
|
blobCache: 'url, cachedAt',
|
|
},
|
|
2: {
|
|
accommodations: 'id, trip_id',
|
|
tripMembers: '[tripId+id], tripId',
|
|
tags: 'id',
|
|
categories: 'id',
|
|
},
|
|
3: { blobCache: 'url, cachedAt, tripId' },
|
|
4: { importFiles: '[jobId+fileName], jobId, createdAt' },
|
|
5: { areaPlaces: 'gers, tripId, searchName' },
|
|
6: { roadtripPreferences: 'tripId' },
|
|
7: { areaPlaces: null },
|
|
8: { areaPlaces: '[gers+tripId], gers, tripId, searchName' },
|
|
9: { tours: 'place_id, trip_id' },
|
|
10: { budgetSettlements: 'tripId' },
|
|
11: { ferrataTiles: '[tripId+tile], tripId, fetchedAt' },
|
|
12: { tourStationVisits: '[assignment_id+station_key], trip_id, assignment_id' },
|
|
13: { tourDrafts: 'tripId' },
|
|
14: { routeCache: 'key, savedAt' },
|
|
};
|
|
|
|
const HISTORY_VERSIONS = Object.keys(HISTORY)
|
|
.map(Number)
|
|
.sort((a, b) => a - b);
|
|
|
|
/** The tables a database at `version` has, with their store spec. */
|
|
function schemaAt(version: number): Record<string, string> {
|
|
const tables: Record<string, string> = {};
|
|
for (const v of HISTORY_VERSIONS) {
|
|
if (v > version) break;
|
|
for (const [table, spec] of Object.entries(HISTORY[v])) {
|
|
if (spec === null) delete tables[table];
|
|
else tables[table] = spec;
|
|
}
|
|
}
|
|
return tables;
|
|
}
|
|
|
|
// ── Representative rows ──────────────────────────────────────────────────────
|
|
|
|
/** A row for every table a database at `version` has, in that version's shape. */
|
|
function rowsAt(version: number): Record<string, Record<string, unknown>> {
|
|
const all: Record<string, Record<string, unknown>> = {
|
|
trips: { id: 1, title: 'Lisbon', start_date: '2026-07-01' },
|
|
days: { id: 11, trip_id: 1, date: '2026-07-01' },
|
|
places: { id: 21, trip_id: 1, name: 'Belem Tower', updated_at: '2026-06-01T10:00:00Z' },
|
|
packingItems: { id: 31, trip_id: 1, name: 'Charger', checked: 0 },
|
|
todoItems: { id: 41, trip_id: 1, name: 'Book the ferry' },
|
|
budgetItems: { id: 51, trip_id: 1, name: 'Hotel', total_price: 120 },
|
|
reservations: { id: 61, trip_id: 1, title: 'Ferry' },
|
|
tripFiles: { id: 71, trip_id: 1, original_name: 'ticket.pdf' },
|
|
// A write queued before the format stamp existed: no schemaVersion.
|
|
mutationQueue: {
|
|
id: 'queued-1',
|
|
tripId: 1,
|
|
method: 'PUT',
|
|
url: '/trips/1/places/21',
|
|
body: { name: 'Belem' },
|
|
createdAt: 1,
|
|
status: 'pending',
|
|
attempts: 0,
|
|
lastError: null,
|
|
resource: 'places',
|
|
entityId: 21,
|
|
},
|
|
syncMeta: {
|
|
tripId: 1,
|
|
lastSyncedAt: 1000,
|
|
status: 'idle',
|
|
tilesBbox: [-9.2, 38.7, -9.1, 38.8],
|
|
filesCachedCount: 2,
|
|
// The area fingerprint arrived with the area cache (v5).
|
|
...(version >= 5 ? { areaPlacesKey: '-9.2,38.7,-9.1,38.8' } : {}),
|
|
},
|
|
// Before v3 a cached blob named no trip and kept no byte count. The blob is a
|
|
// stand-in object: the upgrade reads only its size.
|
|
blobCache:
|
|
version >= 3
|
|
? {
|
|
url: '/api/files/71/download',
|
|
tripId: 1,
|
|
blob: { size: 42 },
|
|
bytes: 42,
|
|
mime: 'application/pdf',
|
|
cachedAt: 5,
|
|
}
|
|
: { url: '/api/files/71/download', blob: { size: 42 }, mime: 'application/pdf', cachedAt: 5 },
|
|
accommodations: { id: 81, trip_id: 1, place_id: 21 },
|
|
tripMembers: { tripId: 1, id: 7, username: 'ana' },
|
|
tags: { id: 3, name: 'food' },
|
|
categories: { id: 4, name: 'Museum' },
|
|
importFiles: { jobId: 'job-1', fileName: 'booking.pdf', blob: { size: 3 }, createdAt: 9 },
|
|
areaPlaces: { gers: 'g-1', tripId: 1, name: 'Café A', searchName: 'cafe a', address: '', cachedAt: 7 },
|
|
roadtripPreferences: { tripId: 1, preferences: { avoid_tolls: true } },
|
|
tours: { place_id: 21, trip_id: 1, name: 'Old town loop', distance: 4200 },
|
|
budgetSettlements: { tripId: 1, base: 'EUR', settlement: { balances: [], flows: [] } },
|
|
ferrataTiles: { tripId: 1, tile: '930:236', fetchedAt: 11, ferratas: [] },
|
|
tourStationVisits: { assignment_id: 71, station_key: 'stop-01', status: 'visited', trip_id: 1 },
|
|
tourDrafts: { tripId: 1, draft: { payload: '{"version":11}', updated_at: '2026-10-22 09:00:00' } },
|
|
routeCache: { key: 'driving:km:11.5,48.1;11.6,48.2', route: { coordinates: [], legs: [] }, savedAt: 13 },
|
|
};
|
|
const tables = schemaAt(version);
|
|
return Object.fromEntries(Object.entries(all).filter(([table]) => table in tables));
|
|
}
|
|
|
|
/** What `rowsAt(from)` must read as once the current version opened it. */
|
|
function expectedAfterUpgrade(from: number): Record<string, Record<string, unknown> | null> {
|
|
const expected: Record<string, Record<string, unknown> | null> = { ...rowsAt(from) };
|
|
if (from < 3) expected.blobCache = { ...expected.blobCache, tripId: -1, bytes: 42 };
|
|
if (from < 8) {
|
|
// v7 dropped the GERS-keyed area cache and v8 cleared every fingerprint, so
|
|
// the next sync downloads the area again.
|
|
if ('areaPlaces' in expected) expected.areaPlaces = null;
|
|
if (expected.syncMeta) {
|
|
const meta = { ...expected.syncMeta };
|
|
delete meta.areaPlacesKey;
|
|
expected.syncMeta = meta;
|
|
}
|
|
}
|
|
return expected;
|
|
}
|
|
|
|
// ── Opening a database at an older version ───────────────────────────────────
|
|
|
|
const opened: Dexie[] = [];
|
|
let dbCounter = 0;
|
|
|
|
function freshName(): string {
|
|
dbCounter += 1;
|
|
return `trek-offline-upgrade-${dbCounter}`;
|
|
}
|
|
|
|
/** Create `name` as a build declaring versions 1..`version` would, and seed it. */
|
|
async function writeOldDatabase(name: string, version: number): Promise<void> {
|
|
const old = new Dexie(name);
|
|
for (const v of HISTORY_VERSIONS) {
|
|
if (v > version) break;
|
|
old.version(v).stores(HISTORY[v]);
|
|
}
|
|
await old.open();
|
|
for (const [table, row] of Object.entries(rowsAt(version))) await old.table(table).put(row);
|
|
old.close();
|
|
}
|
|
|
|
async function openCurrent(name: string): Promise<TrekOfflineDb> {
|
|
const db = new TrekOfflineDb(name);
|
|
opened.push(db);
|
|
await db.open();
|
|
return db;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
for (const db of opened.splice(0)) {
|
|
db.close();
|
|
await Dexie.delete(db.name);
|
|
}
|
|
});
|
|
|
|
// ── One case per declared version ────────────────────────────────────────────
|
|
|
|
interface UpgradeCase {
|
|
/** What the version is for, as the test name. */
|
|
intent: string;
|
|
/** Checks beyond "every row survived", on a database upgraded from the version before. */
|
|
check: (db: TrekOfflineDb) => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Keyed by the version the case upgrades INTO, from the one before it. Version
|
|
* 1 has no predecessor, so its case is a fresh install.
|
|
*/
|
|
const UPGRADE_CASES: Record<number, UpgradeCase> = {
|
|
1: {
|
|
intent: 'a fresh install gets every table, empty',
|
|
check: async (db) => {
|
|
expect(db.tables.map((t) => t.name).sort()).toEqual(Object.keys(schemaAt(Infinity)).sort());
|
|
for (const table of db.tables) expect(await table.count()).toBe(0);
|
|
},
|
|
},
|
|
2: {
|
|
intent: 'adds accommodations, trip members, tags and categories',
|
|
check: async (db) => {
|
|
await db.table('tripMembers').put({ tripId: 2, id: 7, username: 'ana' });
|
|
expect(await db.tripMembers.where('tripId').equals(2).count()).toBe(1);
|
|
expect(await db.tripMembers.get([2, 7])).toMatchObject({ username: 'ana' });
|
|
},
|
|
},
|
|
3: {
|
|
intent: 'scopes the blob cache by trip and backfills the byte count',
|
|
check: async (db) => {
|
|
const legacy = await db.blobCache.where('tripId').equals(-1).toArray();
|
|
expect(legacy.map((b) => b.url)).toEqual(['/api/files/71/download']);
|
|
expect(legacy[0].bytes).toBe(42);
|
|
},
|
|
},
|
|
4: {
|
|
intent: 'adds the booking-import source files',
|
|
check: async (db) => {
|
|
await db.importFiles.put({ jobId: 'job-2', fileName: 'a.pdf', blob: new Blob(['a']), createdAt: 1 });
|
|
expect(await db.importFiles.where('jobId').equals('job-2').count()).toBe(1);
|
|
},
|
|
},
|
|
5: {
|
|
intent: 'adds the area cache for offline place search',
|
|
check: async (db) => {
|
|
expect(await db.areaPlaces.count()).toBe(0);
|
|
expect(await db.syncMeta.get(1)).not.toHaveProperty('areaPlacesKey');
|
|
},
|
|
},
|
|
6: {
|
|
intent: 'adds the driving settings',
|
|
check: async (db) => {
|
|
await db.table('roadtripPreferences').put({ tripId: 2, preferences: {} });
|
|
expect(await db.roadtripPreferences.count()).toBe(1);
|
|
// The empty area cache comes from v7 and v8 on the way to the current version.
|
|
expect(await db.areaPlaces.count()).toBe(0);
|
|
expect(await db.syncMeta.get(1)).not.toHaveProperty('areaPlacesKey');
|
|
},
|
|
},
|
|
7: {
|
|
intent: 'drops the GERS-keyed area cache and keeps the driving settings',
|
|
check: async (db) => {
|
|
expect(await db.roadtripPreferences.get(1)).toMatchObject({ preferences: { avoid_tolls: true } });
|
|
expect(await db.areaPlaces.where('tripId').equals(1).count()).toBe(0);
|
|
expect(await db.syncMeta.get(1)).toMatchObject({ lastSyncedAt: 1000, filesCachedCount: 2 });
|
|
},
|
|
},
|
|
8: {
|
|
intent: 'rebuilds the area cache per trip and clears every area fingerprint',
|
|
check: async (db) => {
|
|
const meta = await db.syncMeta.get(1);
|
|
expect(meta).not.toHaveProperty('areaPlacesKey');
|
|
expect(meta).toMatchObject({ lastSyncedAt: 1000, tilesBbox: [-9.2, 38.7, -9.1, 38.8] });
|
|
// The same place near two trips is two rows now.
|
|
const place = { gers: 'g-9', name: 'Kiosk', searchName: 'kiosk', address: '', lat: null, lng: null };
|
|
const rest = { category: null, website: null, phone: null, cachedAt: 1 };
|
|
await db.areaPlaces.bulkPut([
|
|
{ ...place, ...rest, tripId: 1 },
|
|
{ ...place, ...rest, tripId: 2 },
|
|
]);
|
|
expect(await db.areaPlaces.where('gers').equals('g-9').count()).toBe(2);
|
|
},
|
|
},
|
|
9: {
|
|
intent: 'adds the tours facet without touching the area cache or its fingerprint',
|
|
check: async (db) => {
|
|
// v8's upgrade ran on the way to v8 and must not run again.
|
|
expect(await db.syncMeta.get(1)).toMatchObject({ areaPlacesKey: '-9.2,38.7,-9.1,38.8' });
|
|
expect(await db.areaPlaces.get(['g-1', 1])).toMatchObject({ name: 'Café A' });
|
|
await db.table('tours').put({ place_id: 99, trip_id: 3 });
|
|
expect(await db.tours.where('trip_id').equals(3).count()).toBe(1);
|
|
},
|
|
},
|
|
10: {
|
|
intent: 'adds the cached Costs settlement and keeps the tours',
|
|
check: async (db) => {
|
|
expect(await db.tours.get(21)).toMatchObject({ name: 'Old town loop' });
|
|
await db.table('budgetSettlements').put({ tripId: 3, base: 'USD', settlement: { flows: [] } });
|
|
expect(await db.budgetSettlements.get(3)).toMatchObject({ base: 'USD' });
|
|
},
|
|
},
|
|
11: {
|
|
intent: 'adds the via ferrata tiles per trip and keeps the Costs settlement',
|
|
check: async (db) => {
|
|
expect(await db.budgetSettlements.get(1)).toMatchObject({ base: 'EUR' });
|
|
await db.table('ferrataTiles').bulkPut([
|
|
{ tripId: 3, tile: '930:236', fetchedAt: 5, ferratas: [] },
|
|
{ tripId: 3, tile: '930:237', fetchedAt: 6, ferratas: [] },
|
|
// The same tile for another trip is its own row.
|
|
{ tripId: 4, tile: '930:236', fetchedAt: 7, ferratas: [] },
|
|
]);
|
|
expect(await db.ferrataTiles.where('tripId').equals(3).count()).toBe(2);
|
|
expect(await db.ferrataTiles.get([4, '930:236'])).toMatchObject({ fetchedAt: 7 });
|
|
expect(await db.ferrataTiles.where('fetchedAt').below(6).count()).toBe(1);
|
|
},
|
|
},
|
|
12: {
|
|
intent: 'adds the visits of the city walks per day and keeps the via ferrata tiles',
|
|
check: async (db) => {
|
|
expect(await db.ferrataTiles.get([1, '930:236'])).toMatchObject({ fetchedAt: 11 });
|
|
await db.table('tourStationVisits').bulkPut([
|
|
{ assignment_id: 71, station_key: 'stop-01', status: 'visited', trip_id: 3 },
|
|
{ assignment_id: 71, station_key: 'stop-02', status: 'skipped', trip_id: 3 },
|
|
// The same station on another day of the walk is its own row.
|
|
{ assignment_id: 72, station_key: 'stop-01', status: 'visited', trip_id: 3 },
|
|
]);
|
|
expect(await db.tourStationVisits.where('trip_id').equals(3).count()).toBe(3);
|
|
expect(await db.tourStationVisits.where('assignment_id').equals(71).count()).toBe(2);
|
|
expect(await db.tourStationVisits.get([71, 'stop-01'])).toMatchObject({ status: 'visited' });
|
|
expect(await db.tourStationVisits.get([72, 'stop-01'])).toMatchObject({ status: 'visited' });
|
|
},
|
|
},
|
|
13: {
|
|
intent: 'adds the account draft of the Tours planner per trip and keeps the visits of the city walks',
|
|
check: async (db) => {
|
|
expect(await db.tourStationVisits.get([71, 'stop-01'])).toMatchObject({ status: 'visited' });
|
|
await db.table('tourDrafts').bulkPut([
|
|
{ tripId: 3, draft: { payload: '{"name":"A"}', updated_at: '2026-10-22 09:00:00' } },
|
|
{ tripId: 4, draft: { payload: '{"name":"B"}', updated_at: '2026-10-22 09:00:00' } },
|
|
]);
|
|
// One draft per trip: a second one replaces the first.
|
|
await db
|
|
.table('tourDrafts')
|
|
.put({ tripId: 3, draft: { payload: '{"name":"C"}', updated_at: '2026-10-22 09:01:00' } });
|
|
expect(await db.tourDrafts.count()).toBe(2);
|
|
expect((await db.tourDrafts.get(3))?.draft.payload).toBe('{"name":"C"}');
|
|
},
|
|
},
|
|
14: {
|
|
intent: 'adds the road routes kept on the device and keeps the account drafts of the Tours planner',
|
|
check: async (db) => {
|
|
expect((await db.tourDrafts.get(1))?.draft.payload).toBe('{"version":11}');
|
|
await db.table('routeCache').bulkPut([
|
|
{ key: 'driving:km:1,2;3,4', route: { legs: [] }, savedAt: 20 },
|
|
{ key: 'walking:km:1,2;3,4', route: { legs: [] }, savedAt: 10 },
|
|
]);
|
|
// One row per key: the same route answered again replaces the old one.
|
|
await db.table('routeCache').put({ key: 'driving:km:1,2;3,4', route: { legs: [1] }, savedAt: 30 });
|
|
expect(await db.routeCache.count()).toBe(2);
|
|
// The oldest come first, which is how the store prunes.
|
|
expect(await db.routeCache.orderBy('savedAt').primaryKeys()).toEqual([
|
|
'walking:km:1,2;3,4',
|
|
'driving:km:1,2;3,4',
|
|
]);
|
|
},
|
|
},
|
|
};
|
|
|
|
describe('offlineDb upgrades', () => {
|
|
for (const [version, upgradeCase] of Object.entries(UPGRADE_CASES).map(([v, c]) => [Number(v), c] as const)) {
|
|
it(`v${version}: ${upgradeCase.intent}`, async () => {
|
|
const name = freshName();
|
|
const from = version - 1;
|
|
if (from >= 1) await writeOldDatabase(name, from);
|
|
|
|
const db = await openCurrent(name);
|
|
expect(db.verno).toBe(Math.max(...HISTORY_VERSIONS));
|
|
|
|
if (from >= 1) {
|
|
for (const [table, row] of Object.entries(expectedAfterUpgrade(from))) {
|
|
const rows = await db.table(table).toArray();
|
|
if (row === null) expect(rows, table).toEqual([]);
|
|
else expect(rows, table).toEqual([row]);
|
|
}
|
|
}
|
|
await upgradeCase.check(db);
|
|
});
|
|
}
|
|
|
|
it('keeps a queued write from before the format stamp readable, unstamped', async () => {
|
|
const name = freshName();
|
|
await writeOldDatabase(name, Math.max(...HISTORY_VERSIONS));
|
|
const db = await openCurrent(name);
|
|
const row = await db.mutationQueue.get('queued-1');
|
|
expect(row).toMatchObject({ status: 'pending', url: '/trips/1/places/21' });
|
|
expect(row?.schemaVersion).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── Guard: no version without a case ─────────────────────────────────────────
|
|
|
|
describe('offlineDb upgrade coverage', () => {
|
|
const source = readFileSync(resolve(__dirname, '../../../src/db/offlineDb.ts'), 'utf8');
|
|
const declared = [...source.matchAll(/this\.version\((\d+)\)/g)].map((m) => Number(m[1]));
|
|
|
|
it('declares each version once', () => {
|
|
expect(declared.length).toBeGreaterThan(0);
|
|
expect(new Set(declared).size).toBe(declared.length);
|
|
});
|
|
|
|
it('has an upgrade case for every declared version', () => {
|
|
const cases = Object.keys(UPGRADE_CASES).map(Number);
|
|
expect(cases.sort((a, b) => a - b)).toEqual([...declared].sort((a, b) => a - b));
|
|
});
|
|
|
|
it('has the stores of every declared version in HISTORY', () => {
|
|
expect(HISTORY_VERSIONS).toEqual([...declared].sort((a, b) => a - b));
|
|
});
|
|
|
|
it('builds the current schema from HISTORY', async () => {
|
|
const db = await openCurrent(freshName());
|
|
const actual = Object.fromEntries(
|
|
db.tables.map((t) => [t.name, [t.schema.primKey.src, ...t.schema.indexes.map((i) => i.src)].join(', ')])
|
|
);
|
|
expect(actual).toEqual(schemaAt(Infinity));
|
|
});
|
|
});
|