feat(ferratas): keep loaded via ferratas in a tile cache on server and device, group segments, own look and count badge

This commit is contained in:
Maurice
2026-10-14 16:21:30 +02:00
parent a9882a6415
commit 160bc89e78
126 changed files with 5003 additions and 1153 deletions
+5 -4
View File
@@ -30,19 +30,20 @@ describe('ALL_ENTITIES', () => {
});
/**
* Plan 2 Task 3: every one of the 132 entities the migrated schema
* Plan 2 Task 3: every one of the 133 entities the migrated schema
* describes (120 rewritten + the 5 document-sync tables added this task,
* + push_subscriptions and google_api_usage from legacy steps 245 and 254,
* + tour_types, tours and tour_waypoints from the Tours migration,
* + scheduler_leases from the cron lease migration
* + user_sessions, the session rows behind revocable session tokens)
* + user_sessions, the session rows behind revocable session tokens
* + ferrata_tiles, the stored via ferrata grid cells)
* is registered, and no two share a class name (a silent duplicate would
* shadow one of them in `orm.getMetadata()`, exactly the kind of drift
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
* which de-duplicates by class name).
*/
it('ENT-003: exactly 132 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(132);
it('ENT-003: exactly 133 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(133);
const names = ALL_ENTITIES.map((s) => s.meta.className);
expect(new Set(names).size).toBe(names.length);
});
@@ -15,7 +15,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
* `scripts/generate-entities.ts`'s `RULE1d_fixNaNNumericDefaults` and
* `tests/unit/db/generate-entities.test.ts`'s `RULE1D-*` fixture tests for
* the mechanism). This file pins the OUTPUT of that rule against the real,
* committed entities (every one of the 132, not a sample) and reproduces
* committed entities (every one of the 133, not a sample) and reproduces
* the reviewer's own insert probe so a future regenerate/hand-edit that
* reintroduces a `NaN` initialiser fails loudly here, not silently at a
* caller's first `em.create()`.
@@ -28,7 +28,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 446;
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 132 entities', () => {
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
const failures: string[] = [];
for (const schema of ALL_ENTITIES) {
const Ctor = schema.class as new () => Record<string, unknown>;
@@ -0,0 +1,67 @@
/**
* `Migration20200101043200_via_ferrata_tiles_outlive_a_restart`: the table
* that keeps the via ferrata grid cells exists only from this step on, keyed
* by the cell, with every column required.
*/
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
import type { MikroORM } from '@mikro-orm/sqlite';
import { describe, expect, it } from 'vitest';
const TARGET = 'Migration20200101043200_via_ferrata_tiles_outlive_a_restart';
async function ormBeforeTarget(): Promise<MikroORM> {
const orm = await createMigrationOrm();
const names = await pendingNames(orm);
const index = names.indexOf(TARGET);
expect(index).toBeGreaterThan(0);
await migrateTo(orm, names[index - 1]);
return orm;
}
const tables = (orm: MikroORM) =>
rawQuery<{ name: string }>(orm, "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ferrata_tiles'");
describe('Via ferrata tiles migration', () => {
it('FERTILEMIG-001: creates ferrata_tiles with a required key, payload and fetch time', async () => {
const orm = await ormBeforeTarget();
try {
expect(await tables(orm)).toEqual([]);
await migrateTo(orm, TARGET);
expect(await tables(orm)).toEqual([{ name: 'ferrata_tiles' }]);
const columns = await rawQuery<{ name: string; type: string; notnull: number; pk: number }>(
orm,
'SELECT name, type, "notnull", pk FROM pragma_table_info(\'ferrata_tiles\') ORDER BY cid',
);
expect(columns).toEqual([
{ name: 'tile_key', type: 'TEXT', notnull: 1, pk: 1 },
{ name: 'payload', type: 'TEXT', notnull: 1, pk: 0 },
{ name: 'fetched_at', type: 'INTEGER', notnull: 1, pk: 0 },
]);
} finally {
await orm.close(true);
}
}, 30000);
it('FERTILEMIG-002: holds one row per cell and refuses a row without a payload', async () => {
const orm = await ormBeforeTarget();
try {
await migrateTo(orm, TARGET);
await rawExec(orm, "INSERT INTO ferrata_tiles (tile_key, payload, fetched_at) VALUES ('942,224', '{}', 1)");
await expect(
rawExec(orm, "INSERT INTO ferrata_tiles (tile_key, payload, fetched_at) VALUES ('942,224', '{}', 2)"),
).rejects.toThrow(/UNIQUE|PRIMARY KEY/);
await expect(
rawExec(orm, "INSERT INTO ferrata_tiles (tile_key, payload, fetched_at) VALUES ('942,225', NULL, 2)"),
).rejects.toThrow(/NOT NULL/);
await expect(
rawExec(orm, "INSERT INTO ferrata_tiles (tile_key, payload, fetched_at) VALUES (NULL, '{}', 2)"),
).rejects.toThrow(/NOT NULL/);
} finally {
await orm.close(true);
}
}, 30000);
});
@@ -14,6 +14,7 @@ describe('known migrations', () => {
expect(known.has('Migration20200101042900_tour_planned_total_duration')).toBe(true);
expect(known.has('Migration20200101043000_tour_break_additional_duration')).toBe(true);
expect(known.has('Migration20200101043100_an_imported_tour_has_no_hiking_difficulty')).toBe(true);
expect(known.has('Migration20200101043200_via_ferrata_tiles_outlive_a_restart')).toBe(true);
expect(known.size).toBe(fs.readdirSync(path.join(__dirname, '../../../src/db/migrations')).length);
});
@@ -43,6 +43,7 @@ const UNNUMBERED = [
'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',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -0,0 +1,98 @@
/**
* FerrataTilesRepository: the cells of the via ferrata grid that
* FerratasService keeps between restarts, read by key and written as a batch.
*/
import { FerrataTiles } from '../../../../src/db/entities/FerrataTiles.entity';
import type { FerrataTilesRepository } from '../../../../src/db/repositories/FerrataTiles.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { findRow, findRows, insertRows } from '../../../helpers/factories/rows';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let tiles: FerrataTilesRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
tiles = t.repo(FerrataTiles);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
const empty = '{"ferratas":[],"truncated":false}';
describe('FerrataTilesRepository.findByKeys', () => {
it('FERTILEREPO-001: answers the stored cells among the keys and leaves the others out', async () => {
await insertRows(t, FerrataTiles, [
{ tile_key: '942,224', payload: empty, fetched_at: 100 },
{ tile_key: '942,225', payload: '{"ferratas":[],"truncated":true}', fetched_at: 200 },
{ tile_key: '950,230', payload: empty, fetched_at: 300 },
]);
const rows = await tiles.findByKeys(['942,224', '942,225', '943,224']);
expect(rows.sort((a, b) => a.tile_key.localeCompare(b.tile_key))).toEqual([
{ tile_key: '942,224', payload: empty, fetched_at: 100 },
{ tile_key: '942,225', payload: '{"ferratas":[],"truncated":true}', fetched_at: 200 },
]);
});
it('FERTILEREPO-002: no keys asks for nothing', async () => {
await insertRows(t, FerrataTiles, [{ tile_key: '942,224', payload: empty, fetched_at: 100 }]);
expect(await tiles.findByKeys([])).toEqual([]);
});
it('FERTILEREPO-003: a cell on the other side of the globe keeps its negative key', async () => {
await insertRows(t, FerrataTiles, [{ tile_key: '-1800,-3600', payload: empty, fetched_at: 5 }]);
expect(await tiles.findByKeys(['-1800,-3600'])).toEqual([
{ tile_key: '-1800,-3600', payload: empty, fetched_at: 5 },
]);
});
});
describe('FerrataTilesRepository.upsertTiles', () => {
it('FERTILEREPO-004: writes a row per new cell', async () => {
await tiles.upsertTiles([
{ tile_key: '942,224', payload: empty, fetched_at: 100 },
{ tile_key: '942,225', payload: empty, fetched_at: 100 },
]);
const rows = await findRows(t, FerrataTiles, {}, { tile_key: 'asc' });
expect(rows).toEqual([
{ tile_key: '942,224', payload: empty, fetched_at: 100 },
{ tile_key: '942,225', payload: empty, fetched_at: 100 },
]);
});
it('FERTILEREPO-005: a cell stored before gets the new payload and fetch time, still as one row', async () => {
await insertRows(t, FerrataTiles, [{ tile_key: '942,224', payload: empty, fetched_at: 100 }]);
const refreshed = '{"ferratas":[],"truncated":true}';
await tiles.upsertTiles([
{ tile_key: '942,224', payload: refreshed, fetched_at: 900 },
{ tile_key: '942,225', payload: empty, fetched_at: 900 },
]);
expect(await findRows(t, FerrataTiles, { tile_key: '942,224' })).toHaveLength(1);
expect(await findRow(t, FerrataTiles, { tile_key: '942,224' })).toEqual({
tile_key: '942,224',
payload: refreshed,
fetched_at: 900,
});
expect(await findRow(t, FerrataTiles, { tile_key: '942,225' })).toMatchObject({ fetched_at: 900 });
});
it('FERTILEREPO-006: an empty batch writes nothing', async () => {
await tiles.upsertTiles([]);
expect(await findRows(t, FerrataTiles)).toEqual([]);
});
});