Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -648,3 +648,39 @@ describe("departure board age", () => {
).toBeUndefined();
});
});

describe("HAFAS protocol version", () => {
async function versionFor(entry: RegistryEntry): Promise<unknown> {
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");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
13 changes: 12 additions & 1 deletion integrations/transit-dynamic-registry/hafas-mgate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>> = {
"de/avv-hafas-mgate": "1.26",
};

// biome-ignore lint/suspicious/noExplicitAny: hafas-client uses untyped profiles
function buildProfile(entry: RegistryEntry): Record<string, any> {
const opts = entry.options;
Expand All @@ -109,7 +118,9 @@ function buildProfile(entry: RegistryEntry): Record<string, any> {
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;
Expand Down
17 changes: 16 additions & 1 deletion integrations/transit-dynamic-registry/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { BBox } from "@openmapx/core";
import {
captureTransitContributors,
type IntegrationContext,
Expand All @@ -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.
Expand All @@ -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<void> {
const generation = new AbortController();
const githubToken = ctx.config.githubToken as string | undefined;
Expand Down Expand Up @@ -106,7 +121,7 @@ export async function setup(ctx: IntegrationContext): Promise<void> {
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,
Expand Down
15 changes: 14 additions & 1 deletion integrations/transit-dynamic-registry/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProtocolType>(["hafasMgate", "otpGraphQl"]);

Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions integrations/transit/__tests__/stop-search-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof failing>, 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")]);

Expand Down
25 changes: 19 additions & 6 deletions integrations/transit/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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".
*/
Expand All @@ -505,6 +514,7 @@ export function createTransitOrchestrator(ctx: IntegrationContext) {
limit: number,
bbox?: BBox,
context?: ProviderCallContext,
place?: { lat: number; lng: number },
): Promise<MobilityResult<TransitStop[]>> {
const candidates = (
bbox ? providersOverlapping(bbox) : collectProviders().sort((a, b) => a.priority - b.priority)
Expand All @@ -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<TransitStop[]> => 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<TransitStop[]> => 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,
};
}

Expand Down
2 changes: 1 addition & 1 deletion integrations/transit/place-transit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading