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
@@ -0,0 +1,20 @@
import { EntityRepositoryType, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core';
import { FerrataTilesRepository } from '../repositories/FerrataTiles.repository';
export class FerrataTiles {
[EntityRepositoryType]?: FerrataTilesRepository;
[PrimaryKeyProp]?: 'tile_key';
tile_key!: string;
payload!: string;
fetched_at!: number;
}
export const FerrataTilesSchema = defineEntity({
class: FerrataTiles,
repository: () => FerrataTilesRepository,
properties: {
tile_key: p.text().primary(),
payload: p.text(),
fetched_at: p.integer(),
},
});
+2
View File
@@ -31,6 +31,7 @@ import { DocumentConnectionsSchema } from './DocumentConnections.entity';
import { DocumentProviderFieldsSchema } from './DocumentProviderFields.entity';
import { DocumentProvidersSchema } from './DocumentProviders.entity';
import { DocumentSyncItemsSchema } from './DocumentSyncItems.entity';
import { FerrataTilesSchema } from './FerrataTiles.entity';
import { FileLinksSchema } from './FileLinks.entity';
import { GoogleApiUsageSchema } from './GoogleApiUsage.entity';
import { GooglePlacePhotoMetaSchema } from './GooglePlacePhotoMeta.entity';
@@ -165,6 +166,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
DocumentProviderFieldsSchema,
DocumentProvidersSchema,
DocumentSyncItemsSchema,
FerrataTilesSchema,
FileLinksSchema,
GoogleApiUsageSchema,
GooglePlacePhotoMetaSchema,
+18
View File
@@ -1,4 +1,5 @@
import type { JsonColumn } from '../utils/json-column';
import { FERRATAS_RESULT_CAP, ferrataSchema, type Ferrata } from '@trek/shared';
import { z } from 'zod';
@@ -106,3 +107,20 @@ export const BUDGET_ITEM_TICKET: JsonColumn<{ items: { price?: string | number;
.loose(),
fallback: () => null,
};
/** What one cell of the via ferrata grid holds: its ferratas, and whether the Overpass answer they came from was cut. */
export interface FerrataTilePayload {
ferratas: Ferrata[];
truncated: boolean;
}
/**
* One via ferrata grid cell, as Overpass answered it. Checked against the wire
* contract, so a stored row can go out as it is. A broken row reads as a cell
* never fetched (null), and the next request asks Overpass for it again.
*/
export const FERRATA_TILE_PAYLOAD: JsonColumn<FerrataTilePayload | null> = {
column: 'ferrata_tiles.payload',
schema: z.object({ ferratas: z.array(ferrataSchema).max(FERRATAS_RESULT_CAP), truncated: z.boolean() }).nullable(),
fallback: () => null,
};
@@ -0,0 +1,8 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
export interface FerrataTilesTable {
tile_key: string;
payload: string;
fetched_at: number;
}
+2
View File
@@ -34,6 +34,7 @@ import type { DocumentConnectionsTable } from './DocumentConnections.table';
import type { DocumentProviderFieldsTable } from './DocumentProviderFields.table';
import type { DocumentProvidersTable } from './DocumentProviders.table';
import type { DocumentSyncItemsTable } from './DocumentSyncItems.table';
import type { FerrataTilesTable } from './FerrataTiles.table';
import type { FileLinksTable } from './FileLinks.table';
import type { GoogleApiUsageTable } from './GoogleApiUsage.table';
import type { GooglePlacePhotoMetaTable } from './GooglePlacePhotoMeta.table';
@@ -174,6 +175,7 @@ export interface DB {
document_provider_fields: DocumentProviderFieldsTable;
document_providers: DocumentProvidersTable;
document_sync_items: DocumentSyncItemsTable;
ferrata_tiles: FerrataTilesTable;
file_links: FileLinksTable;
google_api_usage: GoogleApiUsageTable;
google_place_photo_meta: GooglePlacePhotoMetaTable;
@@ -0,0 +1,27 @@
import { Migration } from '@mikro-orm/migrations';
/**
* `ferrata_tiles` keeps what Overpass answered for one cell of the via
* ferrata grid (0.05 degrees a side), so a restart does not send every map
* pan back to the public mirrors.
*
* `tile_key` names the cell by its grid indices (`"<lat index>,<lng index>"`),
* `payload` is the JSON of the cell's ferratas and whether the answer was cut,
* and `fetched_at` is the epoch of that answer in seconds (not ms, so it fits
* the 32-bit integer this column is on Postgres). Nothing references the
* table and nothing deletes from it: an old cell is served and refreshed, not
* dropped.
*/
export class Migration20200101043200_via_ferrata_tiles_outlive_a_restart extends Migration {
override name = 'Migration20200101043200_via_ferrata_tiles_outlive_a_restart';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS ferrata_tiles (
tile_key TEXT PRIMARY KEY NOT NULL,
payload TEXT NOT NULL,
fetched_at INTEGER NOT NULL
)
`);
}
}
@@ -0,0 +1,30 @@
import type { FerrataTiles } from '../entities/FerrataTiles.entity';
import { TrekRepository } from './_shared/trek-repository';
export interface FerrataTileRow {
tile_key: string;
payload: string;
/** When Overpass answered for the cell, as an epoch in seconds. */
fetched_at: number;
}
export class FerrataTilesRepository extends TrekRepository<FerrataTiles> {
/** The stored cells among `keys`, in no particular order; a key never stored is simply absent. */
async findByKeys(keys: readonly string[]): Promise<FerrataTileRow[]> {
if (keys.length === 0) return [];
const rows = await this.find({ tile_key: { $in: [...keys] } }, { fields: ['tile_key', 'payload', 'fetched_at'] });
return rows.map((row) => ({ tile_key: row.tile_key, payload: row.payload, fetched_at: row.fetched_at }));
}
/**
* Writes every cell, replacing the payload and fetch time of one already
* stored. The caller wraps it in a transaction, so a batch lands whole.
*/
async upsertTiles(rows: readonly FerrataTileRow[]): Promise<void> {
if (rows.length === 0) return;
await this.upsertMany(
rows.map((row) => ({ ...row })),
{ onConflictFields: ['tile_key'], onConflictAction: 'merge' },
);
}
}