fix(ski-routing): no promise in a condition and no await in a loop, ski route type from its schema

ski-areas.service checks the pending load and download against null and
undefined, the area refresh lane recurses instead of awaiting in a loop,
TourSkiRoute is inferred from its schema, the ways answer is read by
complete === false, and /v1/routes no longer needs a truncated field.
This commit is contained in:
Maurice
2026-10-20 21:43:46 +02:00
parent d3f574f225
commit 6c66cff365
7 changed files with 42 additions and 37 deletions
+7 -9
View File
@@ -23,7 +23,6 @@ import {
type PaddleLock,
type PaddleRegionBoundary,
type PaddleRegionGraphStats,
type SkiRouteOptions,
type TourChange,
type TourFixedLeg,
type TourSourceHintChange,
@@ -194,20 +193,19 @@ export const TOUR_HIKING_PREFERENCES: JsonColumn<HikingPreferences | null> = {
fallback: () => null,
};
/** What a ski tour's legs were planned with: the piste router's options and the piste data build they ran on. */
export interface TourSkiRoute {
options: SkiRouteOptions;
build: string | null;
/** The legs the piste router planned, by the index of the point each starts at; absent where the save did not say. */
planned_legs?: number[];
}
/**
* What a ski tour's legs were planned with: the piste router's options and the
* piste data build they ran on. planned_legs names the legs the piste router
* planned, by the index of the point each starts at; absent where the save did not say.
*/
const tourSkiRouteSchema = z.object({
options: skiRouteOptionsSchema,
build: z.string().nullable(),
planned_legs: skiPlannedLegsSchema.optional(),
});
export type TourSkiRoute = z.infer<typeof tourSkiRouteSchema>;
/** A ski tour's piste router options. A broken row reads as none, and the planner offers the defaults. */
export const TOUR_SKI_ROUTE: JsonColumn<TourSkiRoute | null> = {
column: 'tours.ski_options',
@@ -45,7 +45,7 @@ export class TrekPlacesLayersService {
return this.read(
'ways',
() => trekPlacesWays(ids),
(answer) => (answer.complete ? null : { failure: answer.failure }),
(answer) => (answer.complete === false ? { failure: answer.failure } : null),
);
}
+16 -14
View File
@@ -835,17 +835,20 @@ export type TrekWaysAnswer =
export async function trekPlacesWays(ids: readonly number[]): Promise<TrekWaysAnswer> {
const wanted = [...new Set(ids)].filter((id) => osmIdSchema.safeParse(id).success);
const starts = Array.from({ length: Math.ceil(wanted.length / WAYS_MAX_IDS) }, (_, index) => index * WAYS_MAX_IDS);
return starts.reduce<Promise<TrekWaysAnswer>>(async (previous, start) => {
const done = await previous;
if (!done.complete) return done;
try {
const part = await trekPlacesWaysPart(wanted.slice(start, start + WAYS_MAX_IDS));
return { complete: true, ways: [...done.ways, ...part] };
} catch (failure: unknown) {
if (start === 0) throw failure;
return { complete: false, ways: done.ways, unanswered: wanted.slice(start), failure };
}
}, Promise.resolve<TrekWaysAnswer>({ complete: true, ways: [] }));
return starts.reduce<Promise<TrekWaysAnswer>>(
async (previous, start) => {
const done = await previous;
if (!done.complete) return done;
try {
const part = await trekPlacesWaysPart(wanted.slice(start, start + WAYS_MAX_IDS));
return { complete: true, ways: [...done.ways, ...part] };
} catch (failure: unknown) {
if (start === 0) throw failure;
return { complete: false, ways: done.ways, unanswered: wanted.slice(start), failure };
}
},
Promise.resolve<TrekWaysAnswer>({ complete: true, ways: [] }),
);
}
/**
@@ -869,7 +872,7 @@ export async function trekPlacesRoutes(
},
{ maxBytes: ROUTES_MAX_BYTES, timeoutMs: ROUTES_TIMEOUT_MS, breaker: layersBreaker },
);
const answer = elementsAnswerSchema.extend({ truncated: z.unknown() }).safeParse(body);
const answer = elementsAnswerSchema.extend({ truncated: z.unknown().optional() }).safeParse(body);
if (!answer.success) throw new Error('TREK Places API answered /v1/routes without elements');
return {
elements: validRows(answer.data.elements, trekRouteElementSchema),
@@ -952,8 +955,7 @@ export async function trekPlacesWinterMeta(): Promise<WinterMeta> {
}
export type TrekPlacesWinterIndex =
| { status: 'not_modified' }
| { status: 'ok'; index: WinterIndex; sha256: string; dropped: number };
{ status: 'not_modified' } | { status: 'ok'; index: WinterIndex; sha256: string; dropped: number };
/**
* Every ski area with its box, size and checksum, unless the index the caller
+9 -8
View File
@@ -53,16 +53,17 @@ export class SkiAreasJob implements OnApplicationBootstrap {
if (changed.length === 0) return;
const pending = [...changed];
let fetched = 0;
// One area at a time per lane, the next asked only once the last is in.
const fetchOne = async (): Promise<void> => {
while (!this.areas.placesHeldBack()) {
const area = pending.shift();
if (!area) return;
try {
if (!(await this.areas.ensureArea(area.row)).stale) fetched += 1;
} catch {
// The kept copy stays; the next planner to need the area asks again.
}
if (this.areas.placesHeldBack()) return;
const area = pending.shift();
if (!area) return;
try {
if (!(await this.areas.ensureArea(area.row)).stale) fetched += 1;
} catch {
// The kept copy stays; the next planner to need the area asks again.
}
return fetchOne();
};
await Promise.all(Array.from({ length: REFRESH_CONCURRENCY }, fetchOne));
const left = pending.length;
@@ -193,7 +193,7 @@ export class SkiAreasService implements OnApplicationBootstrap {
/** The index of ski areas: in memory, else as kept on disk, else fetched. Null when none can be had. */
index(): Promise<SkiIndex | null> {
if (this.current) return Promise.resolve(this.current);
if (!this.loading) {
if (this.loading === null) {
this.loading = this.loadIndex().finally(() => {
this.loading = null;
});
@@ -309,7 +309,7 @@ export class SkiAreasService implements OnApplicationBootstrap {
return { file, stale: false };
}
let download = this.downloads.get(row.id);
if (!download) {
if (download === undefined) {
download = this.download(row, file).finally(() => this.downloads.delete(row.id));
this.downloads.set(row.id, download);
}
@@ -112,8 +112,12 @@ export class TourTrailGradeService {
const layer = await this.layers.wayTags(ids);
let rest = ids;
if (layer.ok) {
const open = new Set(layer.value.complete ? [] : layer.value.unanswered);
this.keepAll(ids.filter((id) => !open.has(id)), layer.value.ways, now);
const open = new Set(layer.value.complete === false ? layer.value.unanswered : []);
this.keepAll(
ids.filter((id) => !open.has(id)),
layer.value.ways,
now,
);
rest = ids.filter((id) => open.has(id));
}
const parts = Array.from({ length: Math.ceil(rest.length / WAYS_PER_QUERY) }, (_, index) =>
+1 -1
View File
@@ -117,7 +117,7 @@ export class ToursMcp {
@Tool({
name: 'list_tours',
description:
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski and winter tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski and winter tour; a ride has no time worked out, so its duration is null), where it came from (origin: trek, gpx for an imported GPX, KML or KMZ file, or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the discipline of a ski tour (ski_discipline: alpine, snowboard, cross_country, touring for ski touring, snowshoe or winter_hike; snowshoeing and winter hiking are ski tours, a hike is summer only; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a planned ski tour has its legs planned by the piste router along the runs, lifts and trails of OpenStreetMap where that data reaches (ski_options: the router options it was planned with, ski_route_build: the piste data build, ski_planned_legs: the legs it planned, each by the index of the control point it starts at, null when the save did not say) and drawn straight elsewhere, an imported one is kept as recorded; its duration is the estimate of the router only when every leg was planned by it, else null, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day) and the stops they run between (stage_stops, in route order with key and coordinates: stage n runs from stop n-1 to stop n, and is known by the key of the stop it starts at), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), the time a tour without a planned time (an import) is estimated to take (time_estimate: minutes and basis, din33466 for a hike by the hiking signpost rule DIN 33466 from its distance, ascent and descent, run_pace for a run at the pace entered; worked out on reading and without breaks, never a recorded or planned time; minutes null when the track has no heights, and time_estimate null where nothing estimates it, a tour the planner timed among them, whose duration is its time), and the days of the trip each tour is on (tour_stage_key names the stage a day holds by its starting stop, null where the day holds the whole tour; a stage may also be on no day yet).',
"List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski and winter tours planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski and winter tour; a ride has no time worked out, so its duration is null), where it came from (origin: trek, gpx for an imported GPX, KML or KMZ file, or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the discipline of a ski tour (ski_discipline: alpine, snowboard, cross_country, touring for ski touring, snowshoe or winter_hike; snowshoeing and winter hiking are ski tours, a hike is summer only; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a planned ski tour has its legs planned by the piste router along the runs, lifts and trails of OpenStreetMap where that data reaches (ski_options: the router options it was planned with, ski_route_build: the piste data build, ski_planned_legs: the legs it planned, each by the index of the control point it starts at, null when the save did not say) and drawn straight elsewhere, an imported one is kept as recorded; its duration is the router's estimate only when every leg was planned by it, else null, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day) and the stops they run between (stage_stops, in route order with key and coordinates: stage n runs from stop n-1 to stop n, and is known by the key of the stop it starts at), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), the time a tour without a planned time (an import) is estimated to take (time_estimate: minutes and basis, din33466 for a hike by the hiking signpost rule DIN 33466 from its distance, ascent and descent, run_pace for a run at the pace entered; worked out on reading and without breaks, never a recorded or planned time; minutes null when the track has no heights, and time_estimate null where nothing estimates it, a tour the planner timed among them, whose duration is its time), and the days of the trip each tour is on (tour_stage_key names the stage a day holds by its starting stop, null where the day holds the whole tour; a stage may also be on no day yet).",
inputSchema: { tripId: idSchema },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),