diff --git a/apps/api/src/services/transit/adapters/__tests__/hafas-mgate.test.ts b/apps/api/src/services/transit/adapters/__tests__/hafas-mgate.test.ts index 95324c394..f127706b8 100644 --- a/apps/api/src/services/transit/adapters/__tests__/hafas-mgate.test.ts +++ b/apps/api/src/services/transit/adapters/__tests__/hafas-mgate.test.ts @@ -648,3 +648,39 @@ describe("departure board age", () => { ).toBeUndefined(); }); }); + +describe("HAFAS protocol version", () => { + async function versionFor(entry: RegistryEntry): Promise { + const { createClient } = await import("hafas-client"); + mockHafasClient.locations.mockResolvedValue([]); + const { hafasMgateAdapter } = await import( + "@integrations/transit-dynamic-registry/hafas-mgate" + ); + await hafasMgateAdapter.searchByName?.(entry, "Aachen Hbf", 5); + const calls = vi.mocked(createClient).mock.calls; + return (calls.at(-1)?.[0] as { ver?: unknown } | undefined)?.ver; + } + + it("reads a version the catalogue names `version`", async () => { + const entry = { + ...MOCK_ENTRY, + id: "de/db-smartrbl-hafas-mgate", + options: { ...MOCK_ENTRY.options, version: "1.18" }, + }; + expect(await versionFor(entry)).toBe("1.18"); + }); + + it("uses hafas-client's version for an entry that states none", async () => { + const entry = { ...MOCK_ENTRY, id: "de/avv-hafas-mgate" }; + expect(await versionFor(entry)).toBe("1.26"); + }); + + it("prefers the catalogue's `ver`", async () => { + const entry = { + ...MOCK_ENTRY, + id: "de/example-hafas-mgate", + options: { ...MOCK_ENTRY.options, ver: "1.34", version: "1.18" }, + }; + expect(await versionFor(entry)).toBe("1.34"); + }); +}); diff --git a/apps/api/src/services/transit/registry/__tests__/manager.test.ts b/apps/api/src/services/transit/registry/__tests__/manager.test.ts index 48ce7ea44..5f12d74c3 100644 --- a/apps/api/src/services/transit/registry/__tests__/manager.test.ts +++ b/apps/api/src/services/transit/registry/__tests__/manager.test.ts @@ -71,6 +71,20 @@ describe("RegistryManager (registry singleton)", () => { expect(registry.entryCount).toBe(0); }); + it("leaves out entries whose endpoint no longer exists", async () => { + vi.mocked(fetchRegistryEntries).mockResolvedValue([ + makeEntry("de/mobil-nrw-hafas-mgate", "mobil:"), + makeEntry("de/db-regio-hafas-mgate", "dbregio:"), + makeEntry("de/kvb-hafas-mgate", "kvb:"), + ]); + + await registry.initialize(); + + expect(registry.findByPrefix("mobil:")).toBeNull(); + expect(registry.findByPrefix("dbregio:")).toBeNull(); + expect(registry.findByPrefix("kvb:")?.id).toBe("de/kvb-hafas-mgate"); + }); + it("unsupported protocol (otpRest) → filtered out, entryCount = 0", async () => { const otpRestEntry = makeEntry("us/some-otp-rest", "some:", "otpRest"); vi.mocked(fetchRegistryEntries).mockResolvedValue([otpRestEntry]); diff --git a/integrations/transit-dynamic-registry/hafas-mgate.ts b/integrations/transit-dynamic-registry/hafas-mgate.ts index 3aa9c5b83..572ef35e1 100644 --- a/integrations/transit-dynamic-registry/hafas-mgate.ts +++ b/integrations/transit-dynamic-registry/hafas-mgate.ts @@ -98,6 +98,15 @@ function buildProducts(rawProducts: TransportApiProduct[]): Array<{ })); } +/** + * HAFAS protocol versions for catalogue entries that state none, where the + * endpoint rejects hafas-client's request without one ("Invalid version"). + * Taken from hafas-client's own profile for the same endpoint. + */ +const PROTOCOL_VERSION_FALLBACKS: Readonly> = { + "de/avv-hafas-mgate": "1.26", +}; + // biome-ignore lint/suspicious/noExplicitAny: hafas-client uses untyped profiles function buildProfile(entry: RegistryEntry): Record { const opts = entry.options; @@ -109,7 +118,9 @@ function buildProfile(entry: RegistryEntry): Record { if (opts.endpoint && registryEndpointIsUsable(opts)) profile.endpoint = opts.endpoint; if (opts.auth) profile.auth = opts.auth; if (opts.client) profile.client = opts.client; - if (opts.ver) profile.ver = opts.ver; + // Some catalogue entries name the protocol version `version` rather than `ver`. + const ver = opts.ver ?? opts.version ?? PROTOCOL_VERSION_FALLBACKS[entry.id]; + if (ver) profile.ver = ver; if (opts.ext) profile.ext = opts.ext; if (opts.checksumSalt) profile.salt = opts.checksumSalt; if (opts.micMacSalt) profile.micMacSalt = opts.micMacSalt; diff --git a/integrations/transit-dynamic-registry/index.ts b/integrations/transit-dynamic-registry/index.ts index 1e0718e3f..cdd88bb63 100644 --- a/integrations/transit-dynamic-registry/index.ts +++ b/integrations/transit-dynamic-registry/index.ts @@ -1,3 +1,4 @@ +import type { BBox } from "@openmapx/core"; import { captureTransitContributors, type IntegrationContext, @@ -11,6 +12,7 @@ import { getAdapter } from "./adapters.js"; import { fetchRegistryEntries, setCache, setGithubToken } from "./fetcher.js"; import { setRedis } from "./hafas-mgate.js"; import { RegistryManager, registry } from "./registry.js"; +import type { RegistryEntry } from "./registry-types.js"; // Provider receipts belong to each captured catalog entry; infrastructure disclosures // remain in the manifest rather than replacing the actual operator credit. @@ -36,6 +38,19 @@ function buildAttributionFor( ]; } +/** + * The area an entry is the source for: its live coverage, else its regular + * coverage. ÖBB's is Austria, though its timetable spans Europe. + */ +function homeArea(entry: RegistryEntry): BBox { + const tiers = entry.coverage.tiers; + return ( + tiers.find((tier) => tier.level === "realtimeCoverage")?.bbox ?? + tiers.find((tier) => tier.level === "regularCoverage")?.bbox ?? + entry.coverage.bbox + ); +} + export async function setup(ctx: IntegrationContext): Promise { const generation = new AbortController(); const githubToken = ctx.config.githubToken as string | undefined; @@ -106,7 +121,7 @@ export async function setup(ctx: IntegrationContext): Promise { ctx.registerTransitProvider({ id: `dyn:${entry.id}`, prefix: entry.prefix, - coverage: { bbox: entry.coverage.bbox }, + coverage: { bbox: entry.coverage.bbox, home: homeArea(entry) }, priority: 5, role: "enrichment", attribution: entryAttribution, diff --git a/integrations/transit-dynamic-registry/registry.ts b/integrations/transit-dynamic-registry/registry.ts index a91b04628..569913f20 100644 --- a/integrations/transit-dynamic-registry/registry.ts +++ b/integrations/transit-dynamic-registry/registry.ts @@ -15,6 +15,16 @@ const SUPPRESSED_IDS = new Set([ "no/entur-otp", ]); +/** + * Catalogue entries whose endpoint no longer exists. Each fails every request, + * which would mark every board in its area as possibly missing departures. + * Checked 2026-10-31: neither host resolves any more. + */ +const RETIRED_ENDPOINT_IDS = new Set([ + "de/db-regio-hafas-mgate", // bnav.hafas.de + "de/mobil-nrw-hafas-mgate", // nrw.hafas.de +]); + /** Protocols we currently have adapters for */ const SUPPORTED_ADAPTER_PROTOCOLS = new Set(["hafasMgate", "otpGraphQl"]); @@ -55,7 +65,10 @@ export class RegistryManager { private index(all: RegistryEntry[]): void { // Filter out suppressed entries and protocols without adapters const usable = all.filter( - (e) => !SUPPRESSED_IDS.has(e.id) && SUPPORTED_ADAPTER_PROTOCOLS.has(e.protocol), + (e) => + !SUPPRESSED_IDS.has(e.id) && + !RETIRED_ENDPOINT_IDS.has(e.id) && + SUPPORTED_ADAPTER_PROTOCOLS.has(e.protocol), ); // Check for prefix collisions diff --git a/integrations/transit/__tests__/stop-search-coverage.test.ts b/integrations/transit/__tests__/stop-search-coverage.test.ts index 55f7e48b1..9aab1f3c6 100644 --- a/integrations/transit/__tests__/stop-search-coverage.test.ts +++ b/integrations/transit/__tests__/stop-search-coverage.test.ts @@ -153,6 +153,54 @@ describe("stop-name search coverage", () => { } }); + describe("for a place", () => { + const koeln = { lat: 50.94, lng: 6.97 }; + const homed = (entry: ReturnType, home: [number, number, number, number]) => { + Object.assign(entry.provider, { coverage: { bbox: [-10, 35, 30, 60], home } }); + return entry; + }; + + it("isn't partial when a provider fails away from its home area", async () => { + // ÖBB covers Europe's long-distance trains but is the source for Austria. + const oebb = homed(failing("oebb"), [9.5, 46.3, 17.3, 49.1]); + const { transit } = orchestrator([answering("a"), oebb]); + + const result = await transit.searchByNameRaw("Central", 30, undefined, undefined, koeln); + + expect(result.data.map((s) => s.id)).toEqual(["a:central"]); + expect(result.freshness.isPartial).toBeUndefined(); + }); + + it("is partial when a provider fails in its home area", async () => { + const kvb = homed(failing("kvb"), [6.8, 50.8, 7.2, 51.1]); + const { transit } = orchestrator([answering("a"), kvb]); + + const result = await transit.searchByNameRaw("Central", 30, undefined, undefined, koeln); + + expect(result.freshness.isPartial).toBe(true); + }); + + it("is partial when a provider without a home area fails or cools down", async () => { + for (const [entries, options] of [ + [[answering("a"), failing("b")], {}], + [[answering("a"), answering("b")], { coolingDown: ["b"] }], + ] as const) { + const { transit } = orchestrator([...entries], options); + const result = await transit.searchByNameRaw("Central", 30, undefined, undefined, koeln); + expect(result.freshness.isPartial).toBe(true); + } + }); + + it("still rejects when no provider answers", async () => { + const oebb = homed(failing("oebb"), [9.5, 46.3, 17.3, 49.1]); + const { transit } = orchestrator([oebb]); + + await expect( + transit.searchByNameRaw("Central", 30, undefined, undefined, koeln), + ).rejects.toThrow("Transit stop sources unavailable"); + }); + }); + it("passes partial coverage through the deduplicated search", async () => { const { transit } = orchestrator([answering("a"), failing("b")]); diff --git a/integrations/transit/orchestrator.ts b/integrations/transit/orchestrator.ts index 6a6c860b5..740e568e2 100644 --- a/integrations/transit/orchestrator.ts +++ b/integrations/transit/orchestrator.ts @@ -65,6 +65,13 @@ function getProviderBbox(p: TransitProvider): BBox | null { return null; } +/** Whether a point lies in the area the provider is the source for. */ +function providerHomeContains(p: TransitProvider, point: { lat: number; lng: number }): boolean { + if (!("bbox" in p.coverage)) return true; + const [west, south, east, north] = p.coverage.home ?? p.coverage.bbox; + return point.lng >= west && point.lng <= east && point.lat >= south && point.lat <= north; +} + function providerOverlapsBbox(p: TransitProvider, bbox: BBox): boolean { const pBbox = getProviderBbox(p); if (!pBbox) return true; // `{ all: true }` matches everywhere @@ -497,6 +504,8 @@ export function createTransitOrchestrator(ctx: IntegrationContext) { * distance/name filtering is applied. * * A provider that fails or is in health cooldown makes the answer partial. + * With `place`, only one whose home area holds the place does: ÖBB failing + * doesn't leave gaps in Köln, where it only adds trains other sources list. * When providers should have answered and none did, the search rejects, so an * outage is never reported as "no stop by that name". */ @@ -505,6 +514,7 @@ export function createTransitOrchestrator(ctx: IntegrationContext) { limit: number, bbox?: BBox, context?: ProviderCallContext, + place?: { lat: number; lng: number }, ): Promise> { const candidates = ( bbox ? providersOverlapping(bbox) : collectProviders().sort((a, b) => a.priority - b.priority) @@ -531,17 +541,20 @@ export function createTransitOrchestrator(ctx: IntegrationContext) { // A cancelled caller must see its cancellation, not a provider outage. context?.signal.throwIfAborted(); - const ok = results - .map((r) => (r.status === "fulfilled" ? r.value : null)) - .filter((v): v is MobilityResult => v != null); - const unavailable = healthy.length + coolingDown.length - ok.length; - if (unavailable > 0 && ok.length === 0) throw new Error("Transit stop sources unavailable"); + const answers = results.map((r) => (r.status === "fulfilled" ? r.value : null)); + const ok = answers.filter((v): v is MobilityResult => v != null); + const unavailable = [...coolingDown, ...healthy.filter((_, i) => answers[i] == null)]; + if (unavailable.length > 0 && ok.length === 0) + throw new Error("Transit stop sources unavailable"); + const gaps = place + ? unavailable.some((p) => providerHomeContains(p, place)) + : unavailable.length > 0; const freshness = mergeFreshness(...ok.map((r) => r.freshness)); return { data: ok.flatMap((r) => r.data), attributions: contributorsForRecords(ok.flatMap((r) => r.data)), - freshness: unavailable > 0 ? { ...freshness, isPartial: true } : freshness, + freshness: gaps ? { ...freshness, isPartial: true } : freshness, }; } diff --git a/integrations/transit/place-transit.ts b/integrations/transit/place-transit.ts index 98abdbc89..436a87eff 100644 --- a/integrations/transit/place-transit.ts +++ b/integrations/transit/place-transit.ts @@ -595,7 +595,7 @@ export function createPlaceTransit( // providers indexing either form are found, then deduplicate by stop id. const variants = [...new Set(names.flatMap((name) => getQueryVariants(name)))]; const settled = await Promise.allSettled( - variants.map((v) => orchestrator.searchByNameRaw(v, 30, placeBbox)), + variants.map((v) => orchestrator.searchByNameRaw(v, 30, placeBbox, undefined, { lat, lng })), ); const variantResults = settled.flatMap((r) => (r.status === "fulfilled" ? [r.value] : [])); const failed = settled.find((r): r is PromiseRejectedResult => r.status === "rejected"); diff --git a/packages/integration-framework/src/contracts/transit-provider.ts b/packages/integration-framework/src/contracts/transit-provider.ts index a353725a1..9d255a541 100644 --- a/packages/integration-framework/src/contracts/transit-provider.ts +++ b/packages/integration-framework/src/contracts/transit-provider.ts @@ -184,7 +184,12 @@ export type TimetableEntry = Departure; export interface TransitProvider { readonly id: string; readonly prefix: string; - readonly coverage: { bbox: BBox } | { all: true }; + /** + * Where the provider has data. `home`, within `bbox`, is the area it is the + * source for, such as a regional operator's own network, where `bbox` also + * spans the long-distance trains it sells. Its failure leaves gaps only there. + */ + readonly coverage: { bbox: BBox; home?: BBox } | { all: true }; readonly priority: number; /** Operation policy role. */ readonly role: TransitProviderRole;