From a8073249c348eb387bfd38052031d85d2f47e03c Mon Sep 17 00:00:00 2001 From: Gary Tokman Date: Tue, 26 May 2026 13:27:29 -0400 Subject: [PATCH] refactor: remove sqlite and turso static data path --- AGENTS.md | 1 - CLAUDE.md | 1 - README.md | 250 ++------ bun.lock | 41 -- examples/vercel-route.ts | 31 +- index.test.ts | 1195 +++++--------------------------------- index.ts | 178 +----- package.json | 11 +- src/cli.ts | 107 ---- src/database-url.ts | 238 -------- src/errors.ts | 2 +- src/schema.ts | 51 -- src/static-gtfs.ts | 360 +++++------- src/types.ts | 5 +- 14 files changed, 325 insertions(+), 2146 deletions(-) delete mode 100644 src/cli.ts delete mode 100644 src/database-url.ts delete mode 100644 src/schema.ts diff --git a/AGENTS.md b/AGENTS.md index 764c1dd..6cd3691 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,6 @@ Default to using Bun instead of Node.js. ## APIs - `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`. -- `bun:sqlite` for SQLite. Don't use `better-sqlite3`. - `Bun.redis` for Redis. Don't use `ioredis`. - `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`. - `WebSocket` is built-in. Don't use `ws`. diff --git a/CLAUDE.md b/CLAUDE.md index 764c1dd..6cd3691 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,6 @@ Default to using Bun instead of Node.js. ## APIs - `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`. -- `bun:sqlite` for SQLite. Don't use `better-sqlite3`. - `Bun.redis` for Redis. Don't use `ioredis`. - `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`. - `WebSocket` is built-in. Don't use `ws`. diff --git a/README.md b/README.md index b6edfa6..347e9ec 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,11 @@ # mta-js -A TypeScript client for MTA subway, bus, stop, and alert data with a small normalized API. - -```ts -import { MTA } from "mta-js"; - -const mta = new MTA({ - busTimeKey: process.env.MTA_BUS_KEY, - databaseUrl: "file:.mta-cache/gtfs.sqlite", -}); - -await mta.subway.arrivals({ stopId: "A27", route: "A" }); -await mta.bus.vehicles({ route: "B63" }); -await mta.alerts.current({ mode: "subway" }); -await mta.stops.near({ lat, lon, modes: ["subway", "bus"] }); -``` +TypeScript client for MTA realtime feeds and the hosted MTA API. ## Hosted API -Pass an `apiKey` to use the hosted MTA API instead of calling MTA feeds and local -static GTFS directly. The public method names stay the same, but requests are sent -to `https://www.mtaapi.dev/api/v1` with the key attached. +Use an API key from `mtaapi.dev` for route-aware static lookups and managed +realtime endpoints: ```ts import { MTA } from "mta-js"; @@ -29,226 +14,73 @@ const mta = new MTA({ apiKey: process.env.MTA_API_KEY, }); -await mta.subway.arrivals({ stopId: "A27", route: "A" }); -await mta.bus.arrivals({ stopId: "308214", route: "M23" }); -await mta.bus.vehicles({ route: "M23", limit: 5 }); -await mta.alerts.current({ mode: "subway" }); -await mta.stops.near({ +const nearby = await mta.stops.near({ lat: 40.7356, lon: -73.9804, - modes: ["subway", "bus"], + modes: ["bus"], route: "M23", includeRoutes: true, }); -``` - -Use `apiBaseUrl` to point at a preview, staging, or self-hosted compatible API: - -```ts -const mta = new MTA({ - apiKey: process.env.MTA_API_KEY, - apiBaseUrl: "https://staging.example.com", -}); -``` - -## Database - -Realtime feeds only become useful after they are joined back to static GTFS stops, routes, and trips. Today, `databaseUrl` points to a SQLite database used by `bun:sqlite`. - -```ts -new MTA({ databaseUrl: ":memory:" }); -new MTA({ databaseUrl: "file:.mta-cache/gtfs.sqlite" }); -new MTA({ databaseUrl: "/var/data/mta-gtfs.sqlite" }); -``` -For serverless deploys, `databaseUrl` can also point at a remote SQLite snapshot. The client downloads the remote database into local temp storage before opening it with `bun:sqlite`; async API calls wait for that hydration automatically. - -```ts -const mta = new MTA({ - databaseUrl: "https://cdn.example.com/mta-gtfs.sqlite", +const lTrain = await mta.subway.arrivals({ + stopId: "L08", + route: "L", }); - -await mta.subway.arrivals({ stopId: "A27", route: "A" }); ``` -By default, remote databases are hydrated into the system temp directory and reused while that serverless instance stays warm. You can pin the local hydration path when your platform gives you a writable temp directory: +When `apiKey` is present, `mta-js` sends requests to the hosted API at +`https://www.mtaapi.dev` by default. Override `apiBaseUrl` for tests or private +deployments. -```ts -const mta = new MTA({ - databaseUrl: "https://cdn.example.com/mta-gtfs.sqlite", - databaseLocalPath: "/tmp/mta-gtfs.sqlite", -}); -``` +## Direct MTA Feeds -If you want to pay the hydration cost before handling requests, await readiness during startup: +You can still call MTA realtime feeds directly without the hosted API: ```ts const mta = new MTA({ - databaseUrl: "https://cdn.example.com/mta-gtfs.sqlite", + busTimeKey: process.env.MTA_BUS_KEY, }); -await mta.ready(); -``` - -Remote SQLite hydration is a read-through snapshot strategy. Local writes, like importing fresh GTFS during a serverless request, update the hydrated copy only; they are not written back to the remote URL. - -Turso/libSQL URLs are also supported as embedded replicas. The remote database syncs into a local SQLite file first, then `mta-js` reads that local replica. - -```ts -const mta = new MTA({ - databaseUrl: "libsql://mtaapi-transcendent-leo-e3.aws-us-east-1.turso.io", - databaseAuthToken: process.env.TURSO_AUTH_TOKEN, - databaseLocalPath: "/tmp/mta-gtfs.sqlite", +const buses = await mta.bus.arrivals({ + stopId: "308214", + route: "M23", }); - -await mta.ready(); -``` - -Most Turso databases require an auth token. You can omit `databaseAuthToken` only for databases configured to allow anonymous reads. - -The name is intentionally broader than a filesystem path so the public API can grow into hosted database adapters later without changing constructor shape. - -You can inspect whether each transit mode has static GTFS ready before serving traffic: - -```ts -await mta.database.status(); ``` -## DB Push - -`mta-js` has a Drizzle-like schema push for its fixed GTFS schema. It does not generate migration files; it applies idempotent `create table if not exists` and `create index if not exists` statements. +Direct feed mode has no bundled SQLite, Turso, or persistent GTFS database. If +you need richer local metadata, pass a small in-memory `staticData` seed: ```ts const mta = new MTA({ - databaseUrl: process.env.TURSO_DATABASE_URL, - databaseAuthToken: process.env.TURSO_AUTH_TOKEN, -}); - -await mta.database.push(); -``` - -You can import official subway GTFS with the CLI. `core` is the default production strategy and imports stops/routes only; `schedule` also imports trips and stop times. - -```sh -bun src/cli.ts db import --mode=subway --strategy=core -bun src/cli.ts db import --mode=subway --strategy=schedule -``` - -You can also make startup self-heal static data for a mode. If the import marker is missing, this writes the seed to the configured database and rehydrates the local replica. The default strategy is `core`. - -```ts -await mta.database.ensureStaticData({ - mode: "subway", - strategy: "core", - seed: { + staticData: { stops: [ - { stop_id: "L06", stop_name: "1 Av", stop_lat: 40.730953, stop_lon: -73.981628 }, - { stop_id: "L06N", stop_name: "1 Av", parent_station: "L06" }, - { stop_id: "L06S", stop_name: "1 Av", parent_station: "L06" }, + { + stop_id: "L08", + stop_name: "Bedford Av", + stop_lat: 40.717304, + stop_lon: -73.956872, + }, ], routes: [ - { route_id: "L", route_short_name: "L", route_long_name: "14 St-Canarsie Local", route_type: 1 }, + { + route_id: "L", + route_short_name: "L", + route_long_name: "14 St-Canarsie Local", + }, ], }, + staticDataMode: "subway", }); ``` -From the CLI: - -```sh -MTA_DATABASE_URL=libsql://your-db.turso.io \ -MTA_DATABASE_AUTH_TOKEN=... \ -bun src/cli.ts db import --mode=subway --strategy=core -``` - -Live Turso integration tests are opt-in so normal test runs do not depend on credentials, network, or local proxy certificate state: - -```sh -TURSO_INTEGRATION_TEST=1 \ -TURSO_DATABASE_URL=libsql://your-db.turso.io \ -TURSO_AUTH_TOKEN=... \ -bun test -``` - -The live write integration test is separately opt-in so read-only Turso tokens do not create false confidence: - -```sh -TURSO_INTEGRATION_TEST=1 \ -TURSO_WRITE_TEST=1 \ -TURSO_DATABASE_URL=libsql://your-db.turso.io \ -TURSO_AUTH_TOKEN=... \ -bun test -``` - -If libSQL reports `invalid peer certificate: UnknownIssuer`, disable HTTPS interception for Turso/libSQL in your proxy tool or run the live Turso test without the proxy. The native libSQL sync client may not trust a debugging proxy certificate even when `fetch` requests do. - -Live MTA/Bustime integration tests are opt-in so normal test runs do not depend on external MTA availability: - -```sh -MTA_LIVE_TEST=1 bun test -``` - -With a BusTime key: - -```sh -MTA_LIVE_TEST=1 \ -MTA_BUS_KEY=... \ -bun test -``` - -Full subway GTFS import tests are also opt-in because they download, unzip, and import the real MTA subway GTFS feed: - -```sh -MTA_LIVE_TEST=1 \ -MTA_FULL_GTFS_TEST=1 \ -bun test -``` - -To run the full GTFS import against Turso: - -```sh -MTA_LIVE_TEST=1 \ -MTA_FULL_GTFS_TEST=1 \ -TURSO_INTEGRATION_TEST=1 \ -TURSO_WRITE_TEST=1 \ -TURSO_DATABASE_URL=libsql://your-db.turso.io \ -TURSO_AUTH_TOKEN=... \ -bun test -``` - -## Static GTFS +For production static stop search, prefer the hosted API. It serves a compact +Blob-backed snapshot instead of requiring each SDK consumer to manage GTFS +imports. -```ts -await mta.static.importZipFromUrl( - "https://rrgtfsfeeds.s3.amazonaws.com/gtfs_subway.zip", - "subway", -); -``` - -Tests and small scripts can seed the cache directly: +## Endpoints -```ts -mta.static.importSeed( - { - stops: [{ stop_id: "A27", stop_name: "Jay St-MetroTech" }], - routes: [{ route_id: "A", route_short_name: "A", route_type: 1 }], - }, - "subway", -); -``` - -## Roadmap - -- Expand the package beyond NYC MTA into a normalized US metro SDK while keeping the existing MTA API stable. -- Prioritize large systems with documented APIs and realtime data first: MBTA (Boston), WMATA (DC), CTA (Chicago), and BART (Bay Area). -- Add adapters for mid-tier systems that expose GTFS, GTFS Realtime, or partial APIs, including SEPTA, LA Metro, and MARTA. -- Model agency-specific quirks behind shared concepts like arrivals, vehicles, service alerts, route-aware nearby stops, static GTFS import, and hosted API access. -- Track smaller agencies separately because support quality varies: some only publish static GTFS, some have buried realtime feeds, and some have no public API surface. - -## Development - -```sh -bun install -bun test -bun run typecheck -``` +- `mta.subway.arrivals(...)` +- `mta.bus.arrivals(...)` +- `mta.bus.vehicles(...)` +- `mta.alerts.current(...)` +- `mta.stops.near(...)` diff --git a/bun.lock b/bun.lock index 6d1ef7d..0662a50 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,6 @@ "": { "name": "mta-js", "dependencies": { - "@libsql/client": "^0.17.3", "csv-parse": "^6.2.1", "fflate": "^0.8.2", "protobufjs": "^8.0.3", @@ -19,62 +18,22 @@ }, }, "packages": { - "@libsql/client": ["@libsql/client@0.17.3", "", { "dependencies": { "@libsql/core": "^0.17.3", "@libsql/hrana-client": "^0.10.0", "js-base64": "^3.7.5", "libsql": "^0.5.28", "promise-limit": "^2.7.0" } }, "sha512-HXk9wiAoJbKFbyBH4O+aEhN6ir5ERXuXvwE5OD2eR4/5RUa3Pw/8L9zrnVdU+iNJitRvisPWaIwmhkO3bH7giA=="], - - "@libsql/core": ["@libsql/core@0.17.3", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-2UjK1i7JBkMduJo4WdvvBxMMvVJ31pArBZNONyz/GCJJAH+1UHat2X6vn10S/WpY5fKzIT98WqYFl2vzWRLOfg=="], - - "@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.29", "", { "os": "darwin", "cpu": "arm64" }, "sha512-K+2RIB1OGFPYQbfay48GakLhqf3ArcbHqPFu7EZiaUcRgFcdw8RoltsMyvbj5ix2fY0HV3Q3Ioa/ByvQdaSM0A=="], - - "@libsql/darwin-x64": ["@libsql/darwin-x64@0.5.29", "", { "os": "darwin", "cpu": "x64" }, "sha512-OtT+KFHsKFy1R5FVadr8FJ2Bb1mghtXTyJkxv0trocq7NuHntSki1eUbxpO5ezJesDvBlqFjnWaYYY516QNLhQ=="], - - "@libsql/hrana-client": ["@libsql/hrana-client@0.10.0", "", { "dependencies": { "@libsql/isomorphic-ws": "^0.1.5", "js-base64": "^3.7.5" } }, "sha512-OoA4EMqRAC7kn7V2P6EQqRcpZf2W+AjsNIyCizBg339Tq/aMC7sRnzs3SklderhmQWAqEzvv8A2vhxVmWpkVvw=="], - - "@libsql/isomorphic-ws": ["@libsql/isomorphic-ws@0.1.5", "", { "dependencies": { "@types/ws": "^8.5.4", "ws": "^8.13.0" } }, "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg=="], - - "@libsql/linux-arm-gnueabihf": ["@libsql/linux-arm-gnueabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-CD4n4zj7SJTHso4nf5cuMoWoMSS7asn5hHygsDuhRl8jjjCTT3yE+xdUvI4J7zsyb53VO5ISh4cwwOtf6k2UhQ=="], - - "@libsql/linux-arm-musleabihf": ["@libsql/linux-arm-musleabihf@0.5.29", "", { "os": "linux", "cpu": "arm" }, "sha512-2Z9qBVpEJV7OeflzIR3+l5yAd4uTOLxklScYTwpZnkm2vDSGlC1PRlueLaufc4EFITkLKXK2MWBpexuNJfMVcg=="], - - "@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-gURBqaiXIGGwFNEaUj8Ldk7Hps4STtG+31aEidCk5evMMdtsdfL3HPCpvys+ZF/tkOs2MWlRWoSq7SOuCE9k3w=="], - - "@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.5.29", "", { "os": "linux", "cpu": "arm64" }, "sha512-fwgYZ0H8mUkyVqXZHF3mT/92iIh1N94Owi/f66cPVNsk9BdGKq5gVpoKO+7UxaNzuEH1roJp2QEwsCZMvBLpqg=="], - - "@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-y14V0vY0nmMC6G0pHeJcEarcnGU2H6cm21ZceRkacWHvQAEhAG0latQkCtoS2njFOXiYIg+JYPfAoWKbi82rkg=="], - - "@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.5.29", "", { "os": "linux", "cpu": "x64" }, "sha512-gquqwA/39tH4pFl+J9n3SOMSymjX+6kZ3kWgY3b94nXFTwac9bnFNMffIomgvlFaC4ArVqMnOZD3nuJ3H3VO1w=="], - - "@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.29", "", { "os": "win32", "cpu": "x64" }, "sha512-4/0CvEdhi6+KjMxMaVbFM2n2Z44escBRoEYpR+gZg64DdetzGnYm8mcNLcoySaDJZNaBd6wz5DNdgRmcI4hXcg=="], - - "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], - "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], - "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "csv-parse": ["csv-parse@6.2.1", "", {}, "sha512-LRLMV+UCyfMokp8Wb411duBf1gaBKJfOfBWU9eHMJ+b+cJYZsNu3AFmjJf3+yPGd59Exz1TsMjaSFyxnYB9+IQ=="], - "detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], - "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], - "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], - - "libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="], - "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], - "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], - "protobufjs": ["protobufjs@8.0.3", "", { "dependencies": { "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-LBYnMWkKLB8fE/ljROPDbCl7mgLSlI+oBe1fAAr5MTqFg4TIi0tYrVVurJvQggOjnUYMQtEZBjrej59ojMNTHQ=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], - - "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], } } diff --git a/examples/vercel-route.ts b/examples/vercel-route.ts index aeb6190..2affb79 100644 --- a/examples/vercel-route.ts +++ b/examples/vercel-route.ts @@ -1,26 +1,21 @@ -import { MTA } from "../index"; +import { MTA } from "mta-js"; const mta = new MTA({ - databaseUrl: process.env.TURSO_DATABASE_URL, - databaseAuthToken: process.env.TURSO_AUTH_TOKEN, - databaseLocalPath: "/tmp/mta.sqlite", - busTimeKey: process.env.MTA_BUS_KEY, + apiKey: process.env.MTA_API_KEY, }); export async function GET() { - await mta.ready(); - - const [database, lTrainArrivals, m23Vehicles] = await Promise.all([ - mta.database.status(), - mta.subway.arrivals({ stopId: "L06", route: "L", limit: 5 }), - mta.bus.vehicles({ route: "M23", limit: 5 }), + const [lTrainArrivals, m23Stops] = await Promise.all([ + mta.subway.arrivals({ stopId: "L08", route: "L", limit: 3 }), + mta.stops.near({ + lat: 40.7356, + lon: -73.9804, + modes: ["bus"], + route: "M23", + includeRoutes: true, + limit: 3, + }), ]); - return Response.json({ - database, - examples: { - lTrainArrivals, - m23Vehicles, - }, - }); + return Response.json({ lTrainArrivals, m23Stops }); } diff --git a/index.test.ts b/index.test.ts index 9d0748d..54d9b63 100644 --- a/index.test.ts +++ b/index.test.ts @@ -1,9 +1,8 @@ -import { afterEach, expect, test } from "bun:test"; -import { createClient } from "@libsql/client"; +import { expect, test } from "bun:test"; import { strToU8, zipSync } from "fflate"; -import { MTA, GTFSCache, StaticDataMissingError, UnknownStopError, encodeFeedMessage } from "./index"; -import type { Arrival } from "./index"; -import { isLibsqlDatabaseUrl, isRemoteDatabaseUrl } from "./src/database-url"; + +import { encodeFeedMessage, MTA, StaticDataMissingError } from "./index"; +import { parseGtfsZip } from "./src/static-gtfs"; const staticData = { stops: [ @@ -51,68 +50,34 @@ const staticData = { direction_id: 0, }, ], - stopTimes: [ - { - trip_id: "A-trip-1", - arrival_time: "12:00:00", - departure_time: "12:00:00", - stop_id: "A27N", - stop_sequence: 1, - }, - ], -}; - -const firstAvLStaticData = { - stops: [ - { - stop_id: "L06", - stop_name: "1 Av", - stop_lat: 40.730953, - stop_lon: -73.981628, - }, - { - stop_id: "L06N", - stop_name: "1 Av", - stop_lat: 40.730953, - stop_lon: -73.981628, - parent_station: "L06", - }, - { - stop_id: "L06S", - stop_name: "1 Av", - stop_lat: 40.730953, - stop_lon: -73.981628, - parent_station: "L06", - }, - ], - routes: [ - { - route_id: "L", - route_short_name: "L", - route_long_name: "14 St-Canarsie Local", - route_type: 1, - route_color: "A7A9AC", - }, - ], }; -const openClients: MTA[] = []; - -function tempPath(name: string) { - const tmp = process.env.TMPDIR ?? "/tmp"; - const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - return `${tmp.replace(/\/$/, "")}/mta-js-${name}-${id}`; -} +test("hosted API calls include bearer and x-api-key auth headers", async () => { + let request: Request | undefined; + const mta = new MTA({ + apiKey: "test-key", + apiBaseUrl: "https://api.example.com", + fetch: (async (input, init) => { + request = input instanceof Request ? new Request(input, init) : new Request(String(input), init); + return Response.json([{ id: "L08", name: "Bedford Av" }]); + }) as typeof fetch, + }); -function requestFromFetchArgs(input: Parameters[0], init?: Parameters[1]) { - return input instanceof Request ? new Request(input, init) : new Request(String(input), init); -} + await expect( + mta.stops.near({ + lat: 40.7173, + lon: -73.9568, + modes: ["subway"], + route: "L", + }), + ).resolves.toEqual([{ id: "L08", name: "Bedford Av" }]); -afterEach(() => { - while (openClients.length) openClients.pop()?.close(); + expect(request?.url).toBe("https://api.example.com/api/v1/stops/near?lat=40.7173&lon=-73.9568&modes=subway&route=L"); + expect(request?.headers.get("authorization")).toBe("Bearer test-key"); + expect(request?.headers.get("x-api-key")).toBe("test-key"); }); -test("subway arrivals decode GTFS realtime and join static GTFS metadata", async () => { +test("subway arrivals decode GTFS realtime and join in-memory static metadata", async () => { const feed = encodeFeedMessage({ header: { gtfsRealtimeVersion: "2.0", timestamp: 1_700_000_000 }, entity: [ @@ -131,1086 +96,188 @@ test("subway arrivals decode GTFS realtime and join static GTFS metadata", async ], }); - const mta = clientWithFetch({ - "feed://ace": new Response(feed), + const mta = new MTA({ + staticData, + fetch: (async () => new Response(feed)) as unknown as typeof fetch, + endpoints: { + subwayFeeds: { A: "feed://ace" }, + }, }); const arrivals = await mta.subway.arrivals({ stopId: "A27", route: "A" }); expect(arrivals).toHaveLength(1); expect(arrivals[0]).toMatchObject({ - mode: "subway", - route: { - id: "A", - shortName: "A", - longName: "8 Avenue Express", - type: 1, - color: "#0039A6", - }, - stop: { - id: "A27", - name: "Jay St-MetroTech", - lat: 40.692338, - lon: -73.987342, - }, - direction: "north", - headsign: "Inwood-207 St", - arrivalTime: "2023-11-14T22:18:20.000Z", - minutes: 5, - tripId: "A-trip-1", - realtime: true, - source: "mta-gtfs-rt", - }); -}); - -test("stops.near returns nearby static GTFS stops", async () => { - const mta = clientWithFetch({}); - - const stops = await mta.stops.near({ - lat: 40.6923, - lon: -73.9873, - modes: ["subway"], - radiusMeters: 100, - }); - - expect(stops.map((stop) => stop.id)).toContain("A27"); -}); - -test("bus arrivals normalize BusTime stop monitoring responses", async () => { - const mta = clientWithFetch({ - "bus://stop": Response.json({ - Siri: { - ServiceDelivery: { - StopMonitoringDelivery: [ - { - MonitoredStopVisit: [ - { - MonitoredVehicleJourney: { - LineRef: "MTA NYCT_B63", - DestinationName: "Cobble Hill", - FramedVehicleJourneyRef: { - DatedVehicleJourneyRef: "bus-trip-1", - }, - MonitoredCall: { - StopPointRef: "308214", - ExpectedArrivalTime: "2023-11-14T22:20:20.000Z", - }, - }, - }, - ], - }, - ], - }, - }, - }), - }); - - const arrivals = await mta.bus.arrivals({ stopId: "308214", route: "B63" }); - - expect(arrivals[0]).toMatchObject({ - mode: "bus", - route: { id: "B63", shortName: "B63" }, - stop: { id: "308214", name: "5 Av/Atlantic Av" }, - arrivalTime: "2023-11-14T22:20:20.000Z", - minutes: 7, - source: "mta-bustime", + mode: "subway", + route: { + id: "A", + shortName: "A", + longName: "8 Avenue Express", + color: "#0039A6", + }, + stop: { + id: "A27", + name: "Jay St-MetroTech", + }, + direction: "north", + headsign: "Inwood-207 St", + tripId: "A-trip-1", + source: "mta-gtfs-rt", }); }); -test("bus vehicles aliases M23 to the BusTime M23-SBS line ref", async () => { +test("bus arrivals normalize BusTime responses with in-memory static metadata", async () => { let requestedUrl = ""; const mta = new MTA({ - busTimeKey: "test-key", - fetch: (async (input: Parameters[0]) => { + busTimeKey: "bus-key", + staticData, + fetch: (async (input) => { requestedUrl = String(input); return Response.json({ Siri: { ServiceDelivery: { - VehicleMonitoringDelivery: [ + StopMonitoringDelivery: [ { - VehicleActivity: [], + MonitoredStopVisit: [ + { + MonitoredVehicleJourney: { + LineRef: "MTA NYCT_B63", + DestinationName: "Cobble Hill", + MonitoredCall: { + StopPointRef: "308214", + ExpectedArrivalTime: "2023-11-14T22:20:20.000Z", + }, + }, + }, + ], }, ], }, }, }); - }) as unknown as typeof fetch, - endpoints: { - busVehicleMonitoring: "bus://vehicle", - }, - }); - openClients.push(mta); - - await mta.bus.vehicles({ route: "M23" }); - - expect(new URL(requestedUrl).searchParams.get("LineRef")).toBe("MTA NYCT_M23-SBS"); -}); - -test("alerts decode GTFS realtime alerts and filter by route", async () => { - const feed = encodeFeedMessage({ - header: { gtfsRealtimeVersion: "2.0" }, - entity: [ - { - id: "alert-1", - alert: { - activePeriod: [{ start: 1_700_000_000 }], - informedEntity: [{ routeId: "A", routeType: 1 }], - effect: "SIGNIFICANT_DELAYS", - headerText: { translation: [{ text: "A trains are delayed", language: "en" }] }, - descriptionText: { translation: [{ text: "Allow extra travel time.", language: "en" }] }, - }, - }, - ], - }); - - const mta = clientWithFetch({ - "feed://alerts": new Response(feed), - }); - - const alerts = await mta.alerts.current({ route: "A" }); - - expect(alerts).toEqual([ - { - id: "alert-1", - mode: "subway", - routes: [ - { - id: "A", - shortName: "A", - longName: "8 Avenue Express", - type: 1, - color: "#0039A6", - }, - ], - stops: [], - header: "A trains are delayed", - description: "Allow extra travel time.", - effect: "SIGNIFICANT_DELAYS", - activePeriods: [{ start: "2023-11-14T22:13:20.000Z" }], - source: "mta-gtfs-rt", - }, - ]); -}); - -test("databaseUrl accepts a file URL for the SQLite GTFS database", async () => { - const databaseUrl = new URL(`file://${tempPath("database-url")}.sqlite`).toString(); - const mta = new MTA({ databaseUrl }); - openClients.push(mta); - - mta.static.importSeed({ - stops: [{ stop_id: "A27", stop_name: "Jay St-MetroTech" }], - }); - - await expect(mta.stops.near({ lat: 0, lon: 0 })).resolves.toEqual([]); - expect(mta.static.getStop("A27")?.name).toBe("Jay St-MetroTech"); -}); - -test("database.push applies the fixed GTFS schema idempotently", async () => { - const mta = new MTA({ databaseUrl: ":memory:" }); - openClients.push(mta); - - await expect(mta.database.push()).resolves.toEqual({ - remote: false, - statements: 9, - }); - expect( - mta.static.db.query("select name from sqlite_master where type = 'table' and name = 'stops'").get(), - ).toEqual({ name: "stops" }); -}); - -test("realtime cache avoids duplicate subway feed fetches within TTL", async () => { - const feed = encodeFeedMessage({ - header: { gtfsRealtimeVersion: "2.0" }, - entity: [ - { - id: "arrival-1", - tripUpdate: { - trip: { tripId: "A-trip-1", routeId: "A" }, - stopTimeUpdate: [{ stopId: "A27N", arrival: { time: 1_700_000_300 } }], - }, - }, - ], - }); - let requests = 0; - const mta = new MTA({ - now: () => new Date("2023-11-14T22:13:20.000Z"), - fetch: (async () => { - requests += 1; - return new Response(feed); - }) as unknown as typeof fetch, - endpoints: { - subwayFeeds: { A: "feed://ace" }, - }, - }); - mta.static.importSeed(staticData, "subway"); - openClients.push(mta); - - await mta.subway.arrivals({ stopId: "A27", route: "A" }); - await mta.subway.arrivals({ stopId: "A27", route: "A" }); - - expect(requests).toBe(1); -}); - -test("realtime cache expires after TTL", async () => { - const feed = encodeFeedMessage({ - header: { gtfsRealtimeVersion: "2.0" }, - entity: [ - { - id: "arrival-1", - tripUpdate: { - trip: { routeId: "A" }, - stopTimeUpdate: [{ stopId: "A27N", arrival: { time: 1_700_000_300 } }], - }, - }, - ], - }); - let requests = 0; - let now = 1_700_000_000_000; - const mta = new MTA({ - realtimeCacheTtlMs: 100, - now: () => new Date(now), - fetch: (async () => { - requests += 1; - return new Response(feed); - }) as unknown as typeof fetch, + }) as typeof fetch, endpoints: { - subwayFeeds: { A: "feed://ace" }, + busStopMonitoring: "https://bustime.example.com/stop", }, }); - mta.static.importSeed(staticData, "subway"); - openClients.push(mta); - - await mta.subway.arrivals({ stopId: "A27", route: "A" }); - now += 101; - await mta.subway.arrivals({ stopId: "A27", route: "A" }); - - expect(requests).toBe(2); -}); - -test("database.status reports missing and ready modes", async () => { - const mta = new MTA(); - openClients.push(mta); - - expect((await mta.database.status()).subway).toMatchObject({ - mode: "subway", - ready: false, - stopCount: 0, - routeCount: 0, - tripCount: 0, - stopTimeCount: 0, - }); - - await mta.database.importStaticData({ - mode: "subway", - seed: staticData, - strategy: "schedule", - sourceUrl: "test://static-data", - }); - - expect((await mta.database.status()).subway).toMatchObject({ - mode: "subway", - ready: true, - sourceUrl: "test://static-data", - stopCount: 3, - routeCount: 2, - tripCount: 1, - stopTimeCount: 1, - }); -}); - -test("apiKey routes client methods through the hosted API", async () => { - const requests: Request[] = []; - const hostedArrival: Arrival = { - mode: "subway", - route: { id: "A", shortName: "A" }, - stop: { id: "A27", name: "Jay St-MetroTech" }, - direction: "north", - arrivalTime: "2023-11-14T22:18:20.000Z", - minutes: 5, - realtime: true, - source: "mta-gtfs-rt", - }; - const mta = new MTA({ - apiKey: "hosted-key", - apiBaseUrl: "https://hosted.example", - fetch: (async (input: Parameters[0], init?: Parameters[1]) => { - const request = requestFromFetchArgs(input, init); - requests.push(request); - return Response.json([hostedArrival]); - }) as typeof fetch, - }); - openClients.push(mta); - - const arrivals = await mta.subway.arrivals({ - stopId: "A27", - route: "A", - limit: 5, - includeRaw: true, - }); - - expect(arrivals).toEqual([hostedArrival]); - expect(requests).toHaveLength(1); - const request = requests[0]!; - const url = new URL(request.url); - expect(url.origin).toBe("https://hosted.example"); - expect(url.pathname).toBe("/api/v1/subway/arrivals"); - expect(url.searchParams.get("stopId")).toBe("A27"); - expect(url.searchParams.get("route")).toBe("A"); - expect(url.searchParams.get("limit")).toBe("5"); - expect(url.searchParams.get("includeRaw")).toBe("true"); - expect(request.headers.get("authorization")).toBe("Bearer hosted-key"); - expect(request.headers.get("x-api-key")).toBe("hosted-key"); -}); - -test("apiKey lets hosted bus methods run without a BusTime key", async () => { - const requestedUrls: string[] = []; - const mta = new MTA({ - apiKey: "hosted-key", - apiBaseUrl: "https://hosted.example", - fetch: (async (input: Parameters[0], init?: Parameters[1]) => { - const request = requestFromFetchArgs(input, init); - requestedUrls.push(request.url); - return Response.json([]); - }) as typeof fetch, - }); - openClients.push(mta); - - await mta.bus.arrivals({ stopId: "308214", route: "M23" }); - await mta.bus.vehicles({ route: "M23" }); - - expect(requestedUrls.map((url) => new URL(url).pathname)).toEqual([ - "/api/v1/bus/arrivals", - "/api/v1/bus/vehicles", - ]); -}); - -test("hosted stops.near serializes mode arrays for the API", async () => { - let requestedUrl = ""; - const mta = new MTA({ - apiKey: "hosted-key", - apiBaseUrl: "https://hosted.example", - fetch: (async (input: Parameters[0], init?: Parameters[1]) => { - const request = requestFromFetchArgs(input, init); - requestedUrl = request.url; - return Response.json([ - { - id: "L06", - name: "1 Av", - lat: 40.730953, - lon: -73.981628, - mode: "subway", - distanceMeters: 12, - routeMatch: true, - }, - ]); - }) as typeof fetch, - }); - openClients.push(mta); - - const stops = await mta.stops.near({ - lat: 40.730953, - lon: -73.981628, - modes: ["subway", "bus"], - route: "L", - includeRoutes: true, - }); - - expect(stops[0]).toMatchObject({ id: "L06", distanceMeters: 12, routeMatch: true }); - const url = new URL(requestedUrl); - expect(url.pathname).toBe("/api/v1/stops/near"); - expect(url.searchParams.get("modes")).toBe("subway,bus"); - expect(url.searchParams.get("route")).toBe("L"); - expect(url.searchParams.get("includeRoutes")).toBe("true"); -}); - -test("static import strategy core avoids schedule rows", async () => { - const mta = new MTA(); - openClients.push(mta); - - const summary = await mta.database.importStaticData({ - mode: "subway", - seed: staticData, - strategy: "core", - }); - - expect(summary).toMatchObject({ - stopCount: 3, - routeCount: 2, - tripCount: 0, - stopTimeCount: 0, - }); - expect(mta.static.db.query("select count(*) as count from trips").get()).toEqual({ count: 0 }); - expect(mta.static.db.query("select count(*) as count from stop_times").get()).toEqual({ count: 0 }); -}); - -test("static import strategy schedule includes trips and stop_times", async () => { - const mta = new MTA(); - openClients.push(mta); - const summary = await mta.database.importStaticData({ - mode: "subway", - seed: staticData, - strategy: "schedule", - }); + const arrivals = await mta.bus.arrivals({ stopId: "308214", route: "B63" }); - expect(summary).toMatchObject({ - stopCount: 3, - routeCount: 2, - tripCount: 1, - stopTimeCount: 1, + expect(new URL(requestedUrl).searchParams.get("key")).toBe("bus-key"); + expect(arrivals[0]).toMatchObject({ + mode: "bus", + route: { id: "B63", shortName: "B63" }, + stop: { id: "308214", name: "5 Av/Atlantic Av" }, + source: "mta-bustime", }); - expect(mta.static.db.query("select count(*) as count from trips").get()).toEqual({ count: 1 }); - expect(mta.static.db.query("select count(*) as count from stop_times").get()).toEqual({ count: 1 }); }); -test("missing static data produces a typed actionable error", async () => { +test("local stops.near requires in-memory static data", async () => { const mta = new MTA(); - openClients.push(mta); await expect( mta.stops.near({ - lat: 40.730953, - lon: -73.981628, + lat: 40.6923, + lon: -73.9873, modes: ["subway"], }), ).rejects.toThrow(StaticDataMissingError); - await expect( - mta.stops.near({ - lat: 40.730953, - lon: -73.981628, - modes: ["subway"], - }), - ).rejects.toThrow("Run mta.database.importStaticData or the db import CLI"); }); -test("unknown subway stop produces a typed actionable error when static data is ready", async () => { +test("local stops.near requires stop data, not only route or trip data", async () => { const mta = new MTA({ - endpoints: { - subwayFeeds: { A: "feed://ace" }, + staticData: { + routes: staticData.routes, + trips: staticData.trips, }, - fetch: (async () => new Response(encodeFeedMessage({ header: { gtfsRealtimeVersion: "2.0" }, entity: [] }))) as unknown as typeof fetch, }); - mta.static.importSeed(staticData, "subway"); - openClients.push(mta); - - await expect(mta.subway.arrivals({ stopId: "NOPE", route: "A" })).rejects.toThrow(UnknownStopError); - await expect(mta.subway.arrivals({ stopId: "NOPE", route: "A" })).rejects.toThrow("Unknown MTA stop: NOPE"); -}); - -test("CLI db import works against local SQLite", async () => { - const databasePath = `${tempPath("cli-import")}.sqlite`; - const zipPath = `${tempPath("cli-import")}.zip`; - const zip = zipSync({ - "stops.txt": strToU8( - [ - "stop_id,stop_name,stop_lat,stop_lon", - "L06,1 Av,40.730953,-73.981628", - "", - ].join("\n"), - ), - "routes.txt": strToU8( - [ - "route_id,route_short_name,route_long_name,route_type,route_color", - "L,L,14 St-Canarsie Local,1,A7A9AC", - "", - ].join("\n"), - ), - "trips.txt": strToU8( - [ - "route_id,service_id,trip_id,trip_headsign,direction_id", - "L,weekday,L-trip-1,8 Av,0", - "", - ].join("\n"), - ), - "stop_times.txt": strToU8( - [ - "trip_id,arrival_time,departure_time,stop_id,stop_sequence", - "L-trip-1,12:00:00,12:00:00,L06,1", - "", - ].join("\n"), - ), - }); - await Bun.write(zipPath, zip); - - const proc = Bun.spawn({ - cmd: [ - "bun", - "src/cli.ts", - "db", - "import", - `--database-url=${databasePath}`, - "--mode=subway", - "--strategy=core", - `--source-url=${new URL(`file://${zipPath}`).toString()}`, - ], - cwd: import.meta.dir, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); - - if (exitCode !== 0) throw new Error(stderr); - expect(stdout).toContain("Imported subway GTFS (core)"); - expect(stdout).toContain("stops=1"); - expect(stdout).toContain("routes=1"); - expect(stdout).toContain("trips=0"); - - const cache = new GTFSCache(databasePath, { createSchema: false }); - try { - expect(cache.getStop("L06")?.name).toBe("1 Av"); - expect(cache.importSummary("subway")).toMatchObject({ - mode: "subway", - stopCount: 1, - routeCount: 1, - tripCount: 0, - stopTimeCount: 0, - }); - } finally { - cache.close(); - } -}); - -test("new MTA hydrates a remote databaseUrl into a local SQLite file", async () => { - const sourcePath = `${tempPath("remote-source")}.sqlite`; - const localPath = `${tempPath("remote-local")}.sqlite`; - const source = new GTFSCache(sourcePath); - source.importSeed({ - stops: [{ stop_id: "A27", stop_name: "Jay St-MetroTech" }], - }); - source.close(); - - const mta = new MTA({ - databaseUrl: "https://example.com/mta.sqlite", - databaseLocalPath: localPath, - fetch: (async () => new Response(await Bun.file(sourcePath).arrayBuffer())) as unknown as typeof fetch, - }); - openClients.push(mta); - - await mta.ready(); - expect(mta.static.getStop("A27")?.name).toBe("Jay St-MetroTech"); - expect(await Bun.file(localPath).exists()).toBe(true); -}); - -test("async methods wait for remote database hydration", async () => { - const sourcePath = `${tempPath("remote-source-method")}.sqlite`; - const localPath = `${tempPath("remote-local-method")}.sqlite`; - const source = new GTFSCache(sourcePath); - source.importSeed({ - stops: [ - { - stop_id: "A27", - stop_name: "Jay St-MetroTech", - stop_lat: 40.692338, - stop_lon: -73.987342, - }, - ], - }); - source.close(); - - const mta = new MTA({ - databaseUrl: "https://example.com/mta.sqlite", - databaseLocalPath: localPath, - fetch: (async () => new Response(await Bun.file(sourcePath).arrayBuffer())) as unknown as typeof fetch, - }); - openClients.push(mta); await expect( mta.stops.near({ lat: 40.6923, lon: -73.9873, - radiusMeters: 100, + modes: ["subway"], }), - ).resolves.toEqual([ - { - id: "A27", - name: "Jay St-MetroTech", - lat: 40.692338, - lon: -73.987342, - }, - ]); -}); - -test("databaseUrl treats libsql URLs as remote database connections", () => { - const url = "libsql://mtaapi-transcendent-leo-e3.aws-us-east-1.turso.io"; - - expect(isLibsqlDatabaseUrl(url)).toBe(true); - expect(isRemoteDatabaseUrl(url)).toBe(true); + ).rejects.toThrow(StaticDataMissingError); }); -const tursoReadTest = process.env.TURSO_INTEGRATION_TEST === "1" ? test : test.skip; -const tursoWriteTest = - process.env.TURSO_INTEGRATION_TEST === "1" && process.env.TURSO_WRITE_TEST === "1" ? test : test.skip; -const liveMtaTest = process.env.MTA_LIVE_TEST === "1" ? test : test.skip; -const fullGtfsTest = process.env.MTA_FULL_GTFS_TEST === "1" ? test : test.skip; -const tursoFullGtfsTest = - process.env.MTA_FULL_GTFS_TEST === "1" && - process.env.TURSO_INTEGRATION_TEST === "1" && - process.env.TURSO_WRITE_TEST === "1" - ? test - : test.skip; - -const subwayGtfsUrl = "https://rrgtfsfeeds.s3.amazonaws.com/gtfs_subway.zip"; +test("local stops.near searches in-memory static seed", async () => { + const mta = new MTA({ staticData }); -tursoReadTest( - "integration: hydrates a live Turso libSQL database when explicitly enabled", - async () => { - const databaseUrl = process.env.TURSO_DATABASE_URL; - const databaseAuthToken = process.env.TURSO_AUTH_TOKEN; - if (!databaseUrl || !databaseAuthToken) { - throw new Error("TURSO_DATABASE_URL and TURSO_AUTH_TOKEN are required when TURSO_INTEGRATION_TEST=1."); - } - - const mta = new MTA({ - databaseUrl, - databaseAuthToken, - databaseLocalPath: `${tempPath("turso-integration")}.sqlite`, - }); - openClients.push(mta); - - await mta.ready(); - expect(mta.static.db.query("select 1 as ok").get()).toEqual({ ok: 1 }); - }, - 15_000, -); - -tursoWriteTest("integration: pushes the GTFS schema to live Turso when write testing is enabled", async () => { - const databaseUrl = process.env.TURSO_DATABASE_URL; - const databaseAuthToken = process.env.TURSO_AUTH_TOKEN; - if (!databaseUrl || !databaseAuthToken) { - throw new Error("TURSO_DATABASE_URL and TURSO_AUTH_TOKEN are required when TURSO_WRITE_TEST=1."); - } - - const mta = new MTA({ - databaseUrl, - databaseAuthToken, - databaseLocalPath: `${tempPath("turso-push")}.sqlite`, + const stops = await mta.stops.near({ + lat: 40.6923, + lon: -73.9873, + modes: ["subway"], + radiusMeters: 100, }); - openClients.push(mta); - let pushResult: Awaited>; - try { - pushResult = await mta.database.push(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Turso schema push failed: ${message}`); - } - expect(pushResult).toEqual({ remote: true, statements: 9 }); - - const remote = createClient({ url: databaseUrl, authToken: databaseAuthToken }); - try { - const remoteTables = await remote.execute( - "select name from sqlite_master where type = 'table' and name in ('stops', 'routes', 'trips', 'stop_times') order by name", - ); - expect(remoteTables.rows.map((row) => row.name)).toEqual(["routes", "stop_times", "stops", "trips"]); - } finally { - remote.close(); - } - - expect(mta.static.db.query("select 1 as ok").get()).toEqual({ ok: 1 }); + expect(stops.map((stop) => stop.id)).toContain("A27"); }); -liveMtaTest("integration: subway arrivals do not require an MTA API key", async () => { +test("local stops.near filters candidates with a bounding box before distance", async () => { const mta = new MTA({ staticData: { - stops: staticData.stops, - routes: staticData.routes, - trips: staticData.trips, + stops: [ + { stop_id: "near", stop_name: "Near", stop_lat: 40, stop_lon: -73 }, + { stop_id: "far-lat", stop_name: "Far Lat", stop_lat: 40.1, stop_lon: -73 }, + { stop_id: "far-lon", stop_name: "Far Lon", stop_lat: 40, stop_lon: -73.1 }, + ], }, }); - openClients.push(mta); - - const arrivals = await mta.subway.arrivals({ - stopId: "A27", - route: "A", - limit: 5, - }); - - expect(Array.isArray(arrivals)).toBe(true); - for (const arrival of arrivals) { - expect(arrival.mode).toBe("subway"); - expect(arrival.route.id).toBe("A"); - } -}); - -liveMtaTest("integration: live L train arrivals at 1 Av do not require an MTA API key", async () => { - const mta = new MTA({ - staticData: firstAvLStaticData, - }); - openClients.push(mta); - - const arrivals = await mta.subway.arrivals({ - stopId: "L06", - route: "L", - limit: 5, - }); - - expect(Array.isArray(arrivals)).toBe(true); - for (const arrival of arrivals) { - expect(arrival.mode).toBe("subway"); - expect(arrival.route.id).toBe("L"); - expect(arrival.stop.id).toBe("L06"); - } -}); - -tursoWriteTest("integration: Turso-backed L train arrivals auto-import static data when needed", async () => { - const databaseUrl = process.env.TURSO_DATABASE_URL; - const databaseAuthToken = process.env.TURSO_AUTH_TOKEN; - if (!databaseUrl || !databaseAuthToken) { - throw new Error("TURSO_DATABASE_URL and TURSO_AUTH_TOKEN are required when TURSO_WRITE_TEST=1."); - } - - const mta = new MTA({ - databaseUrl, - databaseAuthToken, - databaseLocalPath: `${tempPath("turso-static")}.sqlite`, - }); - openClients.push(mta); - - const summary = await mta.database.ensureStaticData({ - mode: "subway", - seed: firstAvLStaticData, - sourceUrl: "test://first-av-l", - }); - expect(summary).toMatchObject({ - mode: "subway", - sourceUrl: "test://first-av-l", - stopCount: 3, - routeCount: 1, - }); - const arrivals = await mta.subway.arrivals({ - stopId: "L06", - route: "L", - limit: 5, + const stops = await mta.stops.near({ + lat: 40, + lon: -73, + radiusMeters: 100, }); - expect(Array.isArray(arrivals)).toBe(true); - expect(mta.static.getStop("L06")?.name).toBe("1 Av"); + expect(stops.map((stop) => stop.id)).toEqual(["near"]); }); -liveMtaTest("integration: service alerts do not require an MTA API key", async () => { - const mta = new MTA({ - staticData: { - stops: staticData.stops, - routes: staticData.routes, - }, - }); - openClients.push(mta); - - const alerts = await mta.alerts.current({ mode: "subway" }); +test("in-memory static data defaults to subway status and can be marked for another mode", () => { + const defaultMta = new MTA({ staticData }); + expect(defaultMta.static.hasStaticData("subway")).toBe(true); + expect(defaultMta.static.status().subway.ready).toBe(true); - expect(Array.isArray(alerts)).toBe(true); + const busMta = new MTA({ staticData, staticDataMode: "bus" }); + expect(busMta.static.hasStaticData("bus")).toBe(true); + expect(busMta.static.hasStaticData("subway")).toBe(false); + expect(busMta.static.status().bus.ready).toBe(true); }); -liveMtaTest("integration: bus arrivals require MTA_BUS_KEY for live BusTime", async () => { - const busTimeKey = process.env.MTA_BUS_KEY; - if (!busTimeKey) { - const mta = new MTA(); - openClients.push(mta); - await expect(mta.bus.arrivals({ stopId: "308214", route: "B63" })).rejects.toThrow( - "MTA BusTime API calls require a busTimeKey.", - ); - return; - } - +test("reimporting a stop removes stale parent station links", () => { const mta = new MTA({ - busTimeKey, staticData: { - stops: staticData.stops, - routes: staticData.routes, + stops: [ + { stop_id: "P1", stop_name: "Parent 1" }, + { stop_id: "P2", stop_name: "Parent 2" }, + { stop_id: "C1", stop_name: "Child", parent_station: "P1" }, + ], }, }); - openClients.push(mta); - const arrivals = await mta.bus.arrivals({ - stopId: "308214", - route: "B63", - limit: 5, - }); + expect(mta.static.getStopIdsForQuery("P1")).toContain("C1"); - expect(Array.isArray(arrivals)).toBe(true); - for (const arrival of arrivals) { - expect(arrival.mode).toBe("bus"); - expect(arrival.route.id).toBe("B63"); - expect(arrival.source).toBe("mta-bustime"); - } -}); - -liveMtaTest("integration: bus vehicles require MTA_BUS_KEY for live BusTime", async () => { - const busTimeKey = process.env.MTA_BUS_KEY; - if (!busTimeKey) { - const mta = new MTA(); - openClients.push(mta); - await expect(mta.bus.vehicles({ route: "B63" })).rejects.toThrow( - "MTA BusTime API calls require a busTimeKey.", - ); - return; - } - - const mta = new MTA({ - busTimeKey, - staticData: { - stops: staticData.stops, - routes: staticData.routes, - }, - }); - openClients.push(mta); - - const vehicles = await mta.bus.vehicles({ - route: "B63", - limit: 5, + mta.static.importSeed({ + stops: [{ stop_id: "C1", stop_name: "Child", parent_station: "P2" }], }); - expect(Array.isArray(vehicles)).toBe(true); - for (const vehicle of vehicles) { - expect(vehicle.mode).toBe("bus"); - expect(vehicle.route.id).toBe("B63"); - expect(vehicle.source).toBe("mta-bustime"); - } + expect(mta.static.getStopIdsForQuery("P1")).not.toContain("C1"); + expect(mta.static.getStopIdsForQuery("P2")).toContain("C1"); }); -liveMtaTest("integration: bus vehicles supports M23 shorthand for M23-SBS", async () => { - const busTimeKey = process.env.MTA_BUS_KEY; - if (!busTimeKey) return; - - const mta = new MTA({ - busTimeKey, +test("parseGtfsZip parses static GTFS csv files into a seed", () => { + const zip = zipSync({ + "stops.txt": strToU8("stop_id,stop_name,stop_lat,stop_lon\nL08,Bedford Av,40.717304,-73.956872\n"), + "routes.txt": strToU8("route_id,route_short_name,route_long_name\nL,L,14 St-Canarsie Local\n"), + "trips.txt": strToU8("route_id,service_id,trip_id,trip_headsign\nL,weekday,L-trip,Canarsie\n"), + "stop_times.txt": strToU8("trip_id,arrival_time,departure_time,stop_id,stop_sequence\nL-trip,12:00:00,12:00:00,L08,1\n"), }); - openClients.push(mta); - const vehicles = await mta.bus.vehicles({ - route: "M23", - limit: 5, + expect(parseGtfsZip(zip)).toMatchObject({ + stops: [{ stop_id: "L08", stop_name: "Bedford Av" }], + routes: [{ route_id: "L", route_short_name: "L" }], + trips: [{ trip_id: "L-trip", trip_headsign: "Canarsie" }], + stopTimes: [{ trip_id: "L-trip", stop_id: "L08" }], }); - - expect(Array.isArray(vehicles)).toBe(true); - for (const vehicle of vehicles) { - expect(vehicle.mode).toBe("bus"); - expect(vehicle.route.id).toBe("M23-SBS"); - expect(vehicle.source).toBe("mta-bustime"); - } }); - -fullGtfsTest( - "integration: imports full subway GTFS and finds known stations", - async () => { - const mta = new MTA(); - openClients.push(mta); - - await mta.database.importStaticData({ - mode: "subway", - sourceUrl: subwayGtfsUrl, - strategy: "schedule", - }); - - expect(mta.static.importSummary("subway")).toMatchObject({ - mode: "subway", - sourceUrl: subwayGtfsUrl, - }); - expect(mta.static.getStop("L06")?.name).toBe("1 Av"); - expect(mta.static.getRoute("L")?.longName).toContain("Canarsie"); - expect(mta.static.getRoute("6")?.shortName).toBe("6"); - - const springStSix = findStopServedByRoute(mta, { - stopName: "Spring St", - routeId: "6", - }); - expect(springStSix?.name).toBe("Spring St"); - }, - 20_000, -); - -fullGtfsTest( - "integration: real GTFS powers nearby stops and live Spring St 6 arrivals", - async () => { - const mta = new MTA(); - openClients.push(mta); - - await mta.database.importStaticData({ - mode: "subway", - sourceUrl: subwayGtfsUrl, - strategy: "schedule", - }); - - const nearby = await mta.stops.near({ - lat: 40.730953, - lon: -73.981628, - modes: ["subway"], - radiusMeters: 100, - limit: 10, - }); - expect(nearby.some((stop) => stop.id === "L06" && stop.name === "1 Av")).toBe(true); - - const springStSix = findStopServedByRoute(mta, { - stopName: "Spring St", - routeId: "6", - }); - expect(springStSix).toBeDefined(); - - const arrivals = await mta.subway.arrivals({ - stopId: springStSix!.id, - route: "6", - limit: 5, - }); - - expect(Array.isArray(arrivals)).toBe(true); - for (const arrival of arrivals) { - expect(arrival.route.id).toBe("6"); - expect(arrival.stop.name).toBe("Spring St"); - } - }, - 20_000, -); - -fullGtfsTest( - "integration: service alerts can join against full subway GTFS", - async () => { - const mta = new MTA(); - openClients.push(mta); - - await mta.database.importStaticData({ - mode: "subway", - sourceUrl: subwayGtfsUrl, - }); - - const alerts = await mta.alerts.current({ mode: "subway" }); - expect(Array.isArray(alerts)).toBe(true); - for (const alert of alerts.slice(0, 5)) { - expect(alert.source).toBe("mta-gtfs-rt"); - for (const route of alert.routes) { - expect(route.id).toBeTruthy(); - } - } - }, - 20_000, -); - -tursoFullGtfsTest( - "integration: imports full subway GTFS into Turso and hydrates local replica", - async () => { - const databaseUrl = process.env.TURSO_DATABASE_URL; - const databaseAuthToken = process.env.TURSO_AUTH_TOKEN; - if (!databaseUrl || !databaseAuthToken) { - throw new Error("TURSO_DATABASE_URL and TURSO_AUTH_TOKEN are required for Turso full GTFS import."); - } - - const mta = new MTA({ - databaseUrl, - databaseAuthToken, - databaseLocalPath: `${tempPath("turso-full-gtfs")}.sqlite`, - }); - openClients.push(mta); - - const fullSeed = await fetchGtfsSeed(subwayGtfsUrl); - const tursoSeed = { - stops: fullSeed.stops.filter((stop) => stop.stop_id.startsWith("L06")), - routes: fullSeed.routes.filter((route) => route.route_id === "L"), - trips: fullSeed.trips.filter((trip) => trip.route_id === "L").slice(0, 50), - stopTimes: fullSeed.stopTimes - .filter((stopTime) => stopTime.stop_id.startsWith("L06")) - .slice(0, 500), - }; - - const summary = await mta.database.importStaticData({ - mode: "subway", - sourceUrl: subwayGtfsUrl, - seed: tursoSeed, - strategy: "schedule", - rehydrate: false, - }); - - if (summary) { - expect(summary).toMatchObject({ - mode: "subway", - sourceUrl: subwayGtfsUrl, - }); - } - - const remote = createClient({ url: databaseUrl, authToken: databaseAuthToken }); - try { - const importRows = await remote.execute({ - sql: "select * from gtfs_imports where mode = ?", - args: ["subway"], - }); - expect(importRows.rows[0]).toMatchObject({ - mode: "subway", - source_url: subwayGtfsUrl, - stop_count: tursoSeed.stops.length, - route_count: tursoSeed.routes.length, - trip_count: tursoSeed.trips.length, - stop_time_count: tursoSeed.stopTimes.length, - }); - const stop = await remote.execute({ - sql: "select name from stops where id = ?", - args: ["L06"], - }); - expect(stop.rows[0]?.name).toBe("1 Av"); - } finally { - remote.close(); - } - - await mta.rehydrateRemoteDatabase(); - expect(mta.static.importSummary("subway")).toMatchObject({ - mode: "subway", - sourceUrl: subwayGtfsUrl, - }); - expect(mta.static.getStop("L06")?.name).toBe("1 Av"); - }, - 20_000, -); - -function clientWithFetch(responses: Record) { - const mta = new MTA({ - busTimeKey: "test-key", - now: () => new Date("2023-11-14T22:13:20.000Z"), - endpoints: { - subwayFeeds: { A: "feed://ace" }, - alerts: "feed://alerts", - busStopMonitoring: "bus://stop", - busVehicleMonitoring: "bus://vehicle", - }, - fetch: (async (input) => { - const url = String(input); - const response = responses[url] ?? responses[url.split("?")[0]!]; - if (!response) { - return new Response(`No fixture for ${url}`, { status: 404 }); - } - return response.clone(); - }) as typeof fetch, - }); - mta.static.importSeed(staticData, "subway"); - openClients.push(mta); - return mta; -} - -function findStopServedByRoute(mta: MTA, query: { stopName: string; routeId: string }) { - const row = mta.static.db - .query< - { id: string; name: string; parent_station: string | null }, - [string, string] - >( - `select distinct stops.id, stops.name, stops.parent_station - from stops - join stop_times on stop_times.stop_id = stops.id - join trips on trips.id = stop_times.trip_id - where stops.name = ?1 and trips.route_id = ?2 - limit 1`, - ) - .get(query.stopName, query.routeId); - if (!row) return undefined; - const id = row.parent_station || row.id.replace(/[NS]$/, ""); - return mta.static.getStop(id) ?? { id, name: row.name }; -} - -async function fetchGtfsSeed(url: string) { - const { parseGtfsZip } = await import("./src/static-gtfs"); - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch GTFS zip from ${url}: ${response.status}`); - } - return parseGtfsZip(await response.arrayBuffer()); -} diff --git a/index.ts b/index.ts index 5409499..f13d137 100644 --- a/index.ts +++ b/index.ts @@ -1,40 +1,27 @@ import { defaultEndpoints, subwayRouteColors } from "./src/defaults"; -import { - hydrateRemoteDatabaseUrl, - importRemoteStaticSeed, - isRemoteDatabaseUrl, - pushRemoteDatabaseSchema, - resolveSqliteDatabaseUrl, -} from "./src/database-url"; import { MissingBusTimeKeyError, StaticDataMissingError, UnknownRouteError, UnknownStopError } from "./src/errors"; import { decodeFeedMessage, type GtfsRealtimeFeed, type TranslatedString } from "./src/gtfs-realtime"; import { fetchArrayBuffer, fetchJson, urlWithParams } from "./src/http"; -import { directionFromStopId, GTFSCache, parseGtfsZip } from "./src/static-gtfs"; +import { directionFromStopId, GTFSCache } from "./src/static-gtfs"; import type { Alert, AlertQuery, Arrival, BusArrivalQuery, BusVehicleQuery, - DatabaseStatus, Direction, - GtfsImportSummary, MTAEndpoints, MTAOptions, NearbyStop, Route, Stop, StopsNearQuery, - StaticGtfsSeed, - StaticGtfsImportLimits, - StaticGtfsImportStrategy, TransitMode, Vehicle, } from "./src/types"; export class MTA { static: GTFSCache; - readonly database: DatabaseClient; readonly subway: SubwayClient; readonly bus: BusClient; readonly alerts: AlertsClient; @@ -47,7 +34,6 @@ export class MTA { readonly busTimeKey?: string; readonly endpoints: MTAEndpoints; readonly options: MTAOptions; - private readonly readyPromise: Promise; private readonly realtimeCache = new Map(); private readonly realtimeCacheTtlMs: number; @@ -67,10 +53,8 @@ export class MTA { ...options.endpoints?.subwayFeeds, }, }; - this.static = new GTFSCache(resolveSqliteDatabaseUrl(options.databaseUrl)); - this.readyPromise = this.initializeDatabase(options); + this.static = new GTFSCache(options.staticData, options.staticDataMode ?? "subway"); - this.database = new DatabaseClient(this); this.subway = new SubwayClient(this); this.bus = new BusClient(this); this.alerts = new AlertsClient(this); @@ -78,7 +62,6 @@ export class MTA { } async ready() { - await this.readyPromise; return this; } @@ -86,28 +69,6 @@ export class MTA { this.static.close(); } - private async initializeDatabase(options: MTAOptions) { - if (options.databaseUrl && isRemoteDatabaseUrl(options.databaseUrl)) { - await this.rehydrateRemoteDatabase(); - } - - if (options.staticData) this.static.importSeed(options.staticData); - } - - async rehydrateRemoteDatabase() { - const localDatabaseUrl = await retryOnWalConflict(() => - hydrateRemoteDatabaseUrl({ - databaseUrl: this.options.databaseUrl, - databaseAuthToken: this.options.databaseAuthToken, - databaseLocalPath: this.options.databaseLocalPath, - fetch: this.fetch, - refresh: true, - }), - ); - this.static.close(); - this.static = new GTFSCache(localDatabaseUrl, { createSchema: false }); - } - async realtimeFeed(url: string) { const now = this.now().getTime(); const cached = this.realtimeCache.get(url); @@ -143,90 +104,6 @@ export class MTA { } } -class DatabaseClient { - constructor(private readonly mta: MTA) {} - - async push() { - await this.mta.ready(); - this.mta.static.pushSchema(); - const result = await pushRemoteDatabaseSchema({ - databaseUrl: this.mta.options.databaseUrl, - databaseAuthToken: this.mta.options.databaseAuthToken, - }); - return result; - } - - async hasStaticData(mode: TransitMode) { - await this.mta.ready(); - return this.mta.static.hasStaticData(mode); - } - - async status(): Promise { - if (this.mta.hostedApiEnabled()) { - return this.mta.hostedJson("/api/v1/database/status"); - } - - await this.mta.ready(); - return this.mta.static.status(); - } - - async importStaticData(input: { - mode: TransitMode; - seed?: StaticGtfsSeed; - sourceUrl?: string; - strategy?: StaticGtfsImportStrategy; - limits?: StaticGtfsImportLimits; - rehydrate?: boolean; - }): Promise { - await this.mta.ready(); - const parsedSeed = - input.seed ?? - (input.sourceUrl - ? parseGtfsZip(await readSourceArrayBuffer(this.mta.fetch, input.sourceUrl)) - : undefined); - const seed = parsedSeed - ? applyImportStrategy(applyImportLimits(parsedSeed, input.limits), input.strategy ?? "core") - : undefined; - if (!seed) { - throw new Error("importStaticData requires either seed or sourceUrl."); - } - const result = await importRemoteStaticSeed({ - databaseUrl: this.mta.options.databaseUrl, - databaseAuthToken: this.mta.options.databaseAuthToken, - mode: input.mode, - seed, - sourceUrl: input.sourceUrl, - }); - - if (result.remote && input.rehydrate !== false) { - await this.mta.rehydrateRemoteDatabase(); - } else { - this.mta.static.importSeed(seed, input.mode); - if (input.sourceUrl) { - this.mta.static.db - .query("update gtfs_imports set source_url = ?1 where mode = ?2") - .run(input.sourceUrl, input.mode); - } - } - - return this.mta.static.importSummary(input.mode); - } - - async ensureStaticData(input: { - mode: TransitMode; - seed?: StaticGtfsSeed; - sourceUrl?: string; - strategy?: StaticGtfsImportStrategy; - limits?: StaticGtfsImportLimits; - }): Promise { - await this.mta.ready(); - if (this.mta.static.hasStaticData(input.mode)) { - return this.mta.static.importSummary(input.mode); - } - return this.importStaticData(input); - } -} - class SubwayClient { constructor(private readonly mta: MTA) {} @@ -472,9 +349,7 @@ class StopsClient { } return this.mta.ready().then(() => { - for (const mode of query.modes ?? []) { - if (!this.mta.static.hasStaticData(mode)) throw new StaticDataMissingError(mode); - } + if (!this.mta.static.hasStopData()) throw new StaticDataMissingError(query.modes?.[0] ?? "requested modes"); return this.mta.static.stopsNear(query); }); } @@ -550,18 +425,6 @@ function normalizeBusRouteId(route: string) { return aliases[normalized] ?? normalized; } -async function readSourceArrayBuffer(fetchImpl: typeof fetch, sourceUrl: string) { - const path = localSourcePath(sourceUrl); - if (path) return Bun.file(path).arrayBuffer(); - return fetchArrayBuffer(fetchImpl, sourceUrl); -} - -function localSourcePath(sourceUrl: string) { - if (sourceUrl.startsWith("file:")) return decodeURIComponent(new URL(sourceUrl).pathname); - if (sourceUrl.startsWith("/") || sourceUrl.startsWith("./") || sourceUrl.startsWith("../")) return sourceUrl; - return undefined; -} - function stringOrUndefined(value: unknown) { return typeof value === "string" && value.length > 0 ? value : undefined; } @@ -615,41 +478,6 @@ function alertMatchesMode( return inferAlertMode(routes, stops, informed) === mode; } -async function retryOnWalConflict(operation: () => Promise) { - let lastError: unknown; - for (const delay of [0, 150, 400, 900]) { - if (delay) await Bun.sleep(delay); - try { - return await operation(); - } catch (error) { - lastError = error; - const message = error instanceof Error ? error.message : String(error); - if (!message.includes("WalConflict")) throw error; - } - } - throw lastError; -} - -function applyImportLimits(seed: StaticGtfsSeed, limits: StaticGtfsImportLimits | undefined): StaticGtfsSeed { - if (!limits) return seed; - return { - stops: limits.stops === undefined ? seed.stops : seed.stops?.slice(0, limits.stops), - routes: limits.routes === undefined ? seed.routes : seed.routes?.slice(0, limits.routes), - trips: limits.trips === undefined ? seed.trips : seed.trips?.slice(0, limits.trips), - stopTimes: limits.stopTimes === undefined ? seed.stopTimes : seed.stopTimes?.slice(0, limits.stopTimes), - }; -} - -function applyImportStrategy(seed: StaticGtfsSeed, strategy: StaticGtfsImportStrategy): StaticGtfsSeed { - if (strategy === "schedule") return seed; - return { - stops: seed.stops, - routes: seed.routes, - trips: [], - stopTimes: [], - }; -} - export { decodeFeedMessage, encodeFeedMessage } from "./src/gtfs-realtime"; export { GTFSCache } from "./src/static-gtfs"; export * from "./src/errors"; diff --git a/package.json b/package.json index 4cdc230..36b2843 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mta-js", - "version": "1.0.1", - "description": "A TypeScript client for MTA realtime and static GTFS data.", + "version": "2.0.0", + "description": "A TypeScript client for MTA realtime feeds and the hosted MTA API.", "license": "MIT", "repository": { "type": "git", @@ -19,13 +19,9 @@ ".": "./index.ts" }, "types": "./index.ts", - "bin": { - "mta-js": "src/cli.ts" - }, "scripts": { "test": "bun test", - "typecheck": "bunx tsc --noEmit", - "db:push": "bun src/cli.ts db push" + "typecheck": "bunx tsc --noEmit" }, "devDependencies": { "@types/bun": "latest" @@ -34,7 +30,6 @@ "typescript": "^5" }, "dependencies": { - "@libsql/client": "^0.17.3", "csv-parse": "^6.2.1", "fflate": "^0.8.2", "protobufjs": "^8.0.3" diff --git a/src/cli.ts b/src/cli.ts deleted file mode 100644 index 3920685..0000000 --- a/src/cli.ts +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env bun -import { MTA } from "../index"; -import { defaultStaticGtfsUrls } from "./defaults"; -import type { StaticGtfsImportStrategy, TransitMode } from "./types"; - -const args = Bun.argv.slice(2); - -const command = args.slice(0, 2).join(" "); -if (command !== "db push" && command !== "db import") { - usage(); -} - -const options = Object.fromEntries( - args.slice(2).map((arg) => { - const [key, ...value] = arg.replace(/^--/, "").split("="); - return [key, value.join("=") || "true"]; - }), -); - -const databaseUrl = - options["database-url"] ?? - process.env.MTA_DATABASE_URL ?? - process.env.TURSO_DATABASE_URL ?? - process.env.DATABASE_URL; -const databaseAuthToken = - options["database-auth-token"] ?? - process.env.MTA_DATABASE_AUTH_TOKEN ?? - process.env.TURSO_AUTH_TOKEN; -const databaseLocalPath = options["database-local-path"] ?? process.env.MTA_DATABASE_LOCAL_PATH; - -if (!databaseUrl) { - console.error("Missing database URL. Pass --database-url or set MTA_DATABASE_URL/TURSO_DATABASE_URL/DATABASE_URL."); - process.exit(1); -} - -const mta = new MTA({ - databaseUrl, - databaseAuthToken, - databaseLocalPath, -}); - -try { - if (command === "db push") { - const result = await mta.database.push(); - console.log(`Pushed GTFS schema (${result.statements} statements${result.remote ? ", remote" : ", local"}).`); - } else { - const mode = parseMode(options.mode); - const strategy = parseStrategy(options.strategy); - const sourceUrl = options["source-url"] ?? defaultSourceUrl(mode); - if (!sourceUrl) { - throw new Error(`No default GTFS source URL for mode ${mode}. Pass --source-url.`); - } - - const summary = await mta.database.importStaticData({ - mode, - sourceUrl, - strategy, - }); - if (!summary) { - throw new Error("Import completed but no local summary was available. Rehydrate the database and check gtfs_imports."); - } - console.log( - [ - `Imported ${summary.mode} GTFS (${strategy})`, - `source=${summary.sourceUrl ?? "unknown"}`, - `stops=${summary.stopCount}`, - `routes=${summary.routeCount}`, - `trips=${summary.tripCount}`, - `stop_times=${summary.stopTimeCount}`, - ].join(" "), - ); - } -} finally { - mta.close(); -} - -function parseMode(value: string | undefined): TransitMode { - const mode = (value ?? "subway") as TransitMode; - if (!["subway", "bus", "lirr", "metro-north"].includes(mode)) { - throw new Error(`Unsupported mode: ${value}`); - } - return mode; -} - -function parseStrategy(value: string | undefined): StaticGtfsImportStrategy { - const strategy = (value ?? "core") as StaticGtfsImportStrategy; - if (!["core", "schedule"].includes(strategy)) { - throw new Error(`Unsupported import strategy: ${value}`); - } - return strategy; -} - -function defaultSourceUrl(mode: TransitMode) { - if (mode === "subway") return defaultStaticGtfsUrls.subway; - return undefined; -} - -function usage(): never { - console.error( - [ - "Usage:", - " mta-js db push --database-url= [--database-auth-token=]", - " mta-js db import --mode=subway [--strategy=core|schedule] [--source-url=]", - ].join("\n"), - ); - process.exit(1); -} diff --git a/src/database-url.ts b/src/database-url.ts deleted file mode 100644 index d2a5d6f..0000000 --- a/src/database-url.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { mkdirSync } from "node:fs"; -import { createClient, type Client, type InStatement } from "@libsql/client"; -import { gtfsSchemaStatements } from "./schema"; -import type { StaticGtfsSeed, TransitMode } from "./types"; - -export function resolveSqliteDatabaseUrl(databaseUrl: string | undefined) { - if (!databaseUrl) return undefined; - if (isRemoteDatabaseUrl(databaseUrl)) return undefined; - if (databaseUrl === ":memory:") return databaseUrl; - if (!databaseUrl.startsWith("file:")) return databaseUrl; - - const url = new URL(databaseUrl); - return decodeURIComponent(url.pathname); -} - -export async function hydrateRemoteDatabaseUrl(options: { - databaseUrl: string | undefined; - databaseAuthToken?: string; - databaseLocalPath?: string; - fetch: typeof fetch; - refresh?: boolean; -}) { - if (!options.databaseUrl || !isRemoteDatabaseUrl(options.databaseUrl)) { - return options.databaseUrl; - } - - const localPath = resolveRemoteDatabaseLocalPath(options.databaseUrl, options.databaseLocalPath); - const existing = Bun.file(localPath); - if (!options.refresh && await existing.exists()) { - return localPath; - } - - if (isLibsqlDatabaseUrl(options.databaseUrl)) { - await hydrateLibsqlDatabase({ - databaseUrl: options.databaseUrl, - databaseAuthToken: options.databaseAuthToken, - localPath, - }); - return localPath; - } - - const response = await options.fetch(options.databaseUrl); - if (!response.ok) { - throw new Error(`Failed to hydrate databaseUrl ${options.databaseUrl}: ${response.status} ${response.statusText}`); - } - - mkdirSync(localPath.slice(0, localPath.lastIndexOf("/")), { recursive: true }); - await Bun.write(localPath, response); - return localPath; -} - -export async function pushRemoteDatabaseSchema(options: { - databaseUrl: string | undefined; - databaseAuthToken?: string; -}) { - if (!options.databaseUrl || !isLibsqlDatabaseUrl(options.databaseUrl)) { - return { remote: false, statements: gtfsSchemaStatements.length }; - } - - const client = createClient({ - url: options.databaseUrl, - authToken: options.databaseAuthToken, - }); - - try { - for (const sql of gtfsSchemaStatements) { - await client.execute(sql); - } - } finally { - client.close(); - } - - return { remote: true, statements: gtfsSchemaStatements.length }; -} - -export async function importRemoteStaticSeed(options: { - databaseUrl: string | undefined; - databaseAuthToken?: string; - seed: StaticGtfsSeed; - mode: TransitMode; - sourceUrl?: string; -}) { - if (!options.databaseUrl || !isLibsqlDatabaseUrl(options.databaseUrl)) { - return { remote: false }; - } - - const client = createClient({ - url: options.databaseUrl, - authToken: options.databaseAuthToken, - }); - - try { - await batchChunks(client, gtfsSchemaStatements.map((sql) => ({ sql, args: [] }))); - await batchChunks( - client, - (options.seed.stops ?? []).map((stop) => ({ - sql: `insert or replace into stops - (id, name, lat, lon, parent_station, location_type, mode) - values (?, ?, ?, ?, ?, ?, ?)`, - args: [ - stop.stop_id, - stop.stop_name, - numberOrNull(stop.stop_lat), - numberOrNull(stop.stop_lon), - stop.parent_station ?? null, - numberOrNull(stop.location_type), - options.mode, - ], - })), - ); - await batchChunks( - client, - (options.seed.routes ?? []).map((route) => ({ - sql: `insert or replace into routes - (id, short_name, long_name, type, color, text_color) - values (?, ?, ?, ?, ?, ?)`, - args: [ - route.route_id, - route.route_short_name ?? route.route_id, - route.route_long_name ?? null, - numberOrNull(route.route_type), - normalizeColor(route.route_color), - normalizeColor(route.route_text_color), - ], - })), - ); - await batchChunks( - client, - (options.seed.trips ?? []).map((trip) => ({ - sql: `insert or replace into trips - (id, route_id, service_id, headsign, direction_id) - values (?, ?, ?, ?, ?)`, - args: [ - trip.trip_id, - trip.route_id, - trip.service_id ?? null, - trip.trip_headsign ?? null, - numberOrNull(trip.direction_id), - ], - })), - ); - await batchChunks( - client, - (options.seed.stopTimes ?? []).map((stopTime) => ({ - sql: `insert or replace into stop_times - (trip_id, arrival_time, departure_time, stop_id, stop_sequence) - values (?, ?, ?, ?, ?)`, - args: [ - stopTime.trip_id, - stopTime.arrival_time ?? null, - stopTime.departure_time ?? null, - stopTime.stop_id, - numberOrNull(stopTime.stop_sequence) ?? 0, - ], - })), - ); - await client.batch( - [ - { - sql: `insert or replace into gtfs_imports - (mode, imported_at, source_url, stop_count, route_count, trip_count, stop_time_count) - values (?, ?, ?, ?, ?, ?, ?)`, - args: [ - options.mode, - new Date().toISOString(), - options.sourceUrl ?? null, - options.seed.stops?.length ?? 0, - options.seed.routes?.length ?? 0, - options.seed.trips?.length ?? 0, - options.seed.stopTimes?.length ?? 0, - ], - }, - ], - "write", - ); - } finally { - client.close(); - } - - return { remote: true }; -} - -export function isRemoteDatabaseUrl(databaseUrl: string) { - return isHttpDatabaseUrl(databaseUrl) || isLibsqlDatabaseUrl(databaseUrl); -} - -export function resolveRemoteDatabaseLocalPath(databaseUrl: string, databaseLocalPath?: string) { - return databaseLocalPath ?? defaultRemoteDatabasePath(databaseUrl); -} - -export function isHttpDatabaseUrl(databaseUrl: string) { - return databaseUrl.startsWith("https://") || databaseUrl.startsWith("http://"); -} - -export function isLibsqlDatabaseUrl(databaseUrl: string) { - return databaseUrl.startsWith("libsql://"); -} - -function defaultRemoteDatabasePath(databaseUrl: string) { - const tmp = process.env.TMPDIR ?? "/tmp"; - const url = new URL(databaseUrl); - const basename = url.pathname.split("/").filter(Boolean).at(-1) ?? "gtfs.sqlite"; - const hash = Bun.hash(databaseUrl).toString(36); - return `${tmp.replace(/\/$/, "")}/mta-js/${hash}-${basename}`; -} - -async function hydrateLibsqlDatabase(options: { - databaseUrl: string; - databaseAuthToken?: string; - localPath: string; -}) { - mkdirSync(options.localPath.slice(0, options.localPath.lastIndexOf("/")), { recursive: true }); - const client = createClient({ - url: `file:${options.localPath}`, - syncUrl: options.databaseUrl, - authToken: options.databaseAuthToken, - }); - await client.sync(); - client.close(); -} - -function numberOrNull(value: unknown) { - if (value === undefined || value === null || value === "") return null; - const number = Number(value); - return Number.isFinite(number) ? number : null; -} - -function normalizeColor(value: string | undefined) { - if (!value) return null; - return value.startsWith("#") ? value : `#${value}`; -} - -async function batchChunks(client: Client, statements: InStatement[], size = 500) { - for (let index = 0; index < statements.length; index += size) { - const chunk = statements.slice(index, index + size); - if (chunk.length) await client.batch(chunk, "write"); - } -} diff --git a/src/errors.ts b/src/errors.ts index 1fcc6b6..7437cf8 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -30,7 +30,7 @@ export class StaticDataMissingError extends MTAError { override name = "StaticDataMissingError"; constructor(mode: string) { - super(`Static GTFS data for ${mode} is missing. Run mta.database.importStaticData or the db import CLI before using this lookup.`); + super(`Static GTFS data for ${mode} is missing. Pass staticData or use the hosted API with apiKey before using this lookup.`); } } diff --git a/src/schema.ts b/src/schema.ts deleted file mode 100644 index 55f4f25..0000000 --- a/src/schema.ts +++ /dev/null @@ -1,51 +0,0 @@ -export const gtfsSchemaStatements = [ - `create table if not exists stops ( - id text primary key, - name text not null, - lat real, - lon real, - parent_station text, - location_type integer, - mode text - )`, - `create table if not exists routes ( - id text primary key, - short_name text, - long_name text, - type integer, - color text, - text_color text - )`, - `create table if not exists trips ( - id text primary key, - route_id text not null, - service_id text, - headsign text, - direction_id integer - )`, - `create table if not exists stop_times ( - trip_id text not null, - arrival_time text, - departure_time text, - stop_id text not null, - stop_sequence integer, - primary key (trip_id, stop_sequence, stop_id) - )`, - `create table if not exists gtfs_imports ( - mode text primary key, - imported_at text not null, - source_url text, - stop_count integer not null default 0, - route_count integer not null default 0, - trip_count integer not null default 0, - stop_time_count integer not null default 0 - )`, - "create index if not exists stops_parent_station_idx on stops(parent_station)", - "create index if not exists stops_lat_lon_idx on stops(lat, lon)", - "create index if not exists routes_short_name_idx on routes(short_name)", - "create index if not exists stop_times_stop_id_idx on stop_times(stop_id)", -] as const; - -export function gtfsSchemaSql() { - return `${gtfsSchemaStatements.join(";\n")};`; -} diff --git a/src/static-gtfs.ts b/src/static-gtfs.ts index 4b0d51f..d5d8110 100644 --- a/src/static-gtfs.ts +++ b/src/static-gtfs.ts @@ -1,74 +1,47 @@ -import { Database } from "bun:sqlite"; -import { unzipSync } from "fflate"; import { parse } from "csv-parse/sync"; -import { gtfsSchemaSql } from "./schema"; +import { unzipSync } from "fflate"; + import type { - GtfsRouteInput, + DatabaseStatus, GtfsImportSummary, + GtfsRouteInput, GtfsStopInput, GtfsStopTimeInput, GtfsTripInput, Route, - StaticGtfsSeed, - DatabaseStatus, StaticDataStatus, + StaticGtfsSeed, Stop, StopsNearQuery, TransitMode, } from "./types"; -type StopRow = { +type Trip = { id: string; - name: string; - lat: number | null; - lon: number | null; - parent_station: string | null; - location_type: number | null; - mode: TransitMode | null; -}; - -type RouteRow = { - id: string; - short_name: string | null; - long_name: string | null; - type: number | null; - color: string | null; - text_color: string | null; -}; - -type TripRow = { - id: string; - route_id: string; - service_id: string | null; - headsign: string | null; - direction_id: number | null; + routeId: string; + serviceId?: string; + headsign?: string; + directionId?: number; }; export class GTFSCache { - readonly db: Database; - - constructor(path = ":memory:", options: { createSchema?: boolean } = {}) { - this.db = new Database(path, { create: true, strict: true }); - this.db.run("PRAGMA journal_mode = WAL;"); - if (options.createSchema ?? true) { - this.createSchema(); - } - } - - close() { - this.db.close(false); + private stops = new Map(); + private routes = new Map(); + private trips = new Map(); + private childStopsByParent = new Map>(); + private summaries = new Map(); + + constructor(seed?: StaticGtfsSeed, mode?: TransitMode) { + if (seed) this.importSeed(seed, mode); } - pushSchema() { - this.createSchema(); - } + close() {} importSeed(seed: StaticGtfsSeed, mode?: TransitMode) { this.importRows({ stops: seed.stops ?? [], routes: seed.routes ?? [], trips: seed.trips ?? [], - stopTimes: seed.stopTimes ?? [], mode, }); if (mode) this.markImported(mode, undefined, seed); @@ -76,8 +49,7 @@ export class GTFSCache { async importZip(zipBytes: ArrayBuffer | Uint8Array, mode?: TransitMode) { const seed = parseGtfsZip(zipBytes); - this.importRows({ ...seed, mode }); - if (mode) this.markImported(mode, undefined, seed); + this.importSeed(seed, mode); } async importZipFromUrl(url: string, mode?: TransitMode, fetchImpl: typeof fetch = fetch) { @@ -85,19 +57,26 @@ export class GTFSCache { if (!response.ok) { throw new Error(`Failed to fetch GTFS zip from ${url}: ${response.status}`); } - await this.importZip(await response.arrayBuffer(), mode); - if (mode) { - this.db - .query("update gtfs_imports set source_url = ?1 where mode = ?2") - .run(url, mode); - } + const seed = parseGtfsZip(await response.arrayBuffer()); + this.importRows({ + stops: seed.stops, + routes: seed.routes, + trips: seed.trips, + mode, + }); + if (mode) this.markImported(mode, url, seed); } hasStaticData(mode: TransitMode) { - const row = this.db - .query<{ count: number }, [TransitMode]>("select count(*) as count from gtfs_imports where mode = ?1") - .get(mode); - return Boolean(row?.count); + return this.summaries.has(mode); + } + + hasAnyStaticData() { + return this.stops.size > 0 || this.routes.size > 0 || this.trips.size > 0; + } + + hasStopData() { + return this.stops.size > 0; } status(): DatabaseStatus { @@ -110,49 +89,11 @@ export class GTFSCache { } importSummary(mode: TransitMode): GtfsImportSummary | undefined { - const row = this.db - .query< - { - mode: TransitMode; - imported_at: string; - source_url: string | null; - stop_count: number; - route_count: number; - trip_count: number; - stop_time_count: number; - }, - [TransitMode] - >("select * from gtfs_imports where mode = ?1") - .get(mode); - if (!row) return undefined; - return { - mode: row.mode, - importedAt: row.imported_at, - sourceUrl: row.source_url ?? undefined, - stopCount: row.stop_count, - routeCount: row.route_count, - tripCount: row.trip_count, - stopTimeCount: row.stop_time_count, - }; - } - - private statusForMode(mode: TransitMode): StaticDataStatus { - const summary = this.importSummary(mode); - return { - mode, - ready: Boolean(summary), - importedAt: summary?.importedAt, - sourceUrl: summary?.sourceUrl, - stopCount: summary?.stopCount ?? 0, - routeCount: summary?.routeCount ?? 0, - tripCount: summary?.tripCount ?? 0, - stopTimeCount: summary?.stopTimeCount ?? 0, - }; + return this.summaries.get(mode); } getStop(id: string): Stop | undefined { - const row = this.db.query("select * from stops where id = ?1").get(id); - return row ? stopFromRow(row) : undefined; + return this.stops.get(id); } getStopOrParent(id: string): Stop | undefined { @@ -164,16 +105,15 @@ export class GTFSCache { getRoute(idOrShortName: string): Route | undefined { const normalized = idOrShortName.toUpperCase(); - const row = this.db - .query( - "select * from routes where upper(id) = ?1 or upper(short_name) = ?2 limit 1", - ) - .get(normalized, normalized); - return row ? routeFromRow(row) : undefined; + return [...this.routes.values()].find( + (route) => + route.id.toUpperCase() === normalized || + route.shortName?.toUpperCase() === normalized, + ); } - getTrip(id: string): TripRow | undefined { - return this.db.query("select * from trips where id = ?1").get(id) ?? undefined; + getTrip(id: string): Trip | undefined { + return this.trips.get(id); } getStopIdsForQuery(stopId: string) { @@ -183,10 +123,8 @@ export class GTFSCache { ids.add(`${parent}N`); ids.add(`${parent}S`); - for (const row of this.db - .query<{ id: string }, [string]>("select id from stops where parent_station = ?1") - .all(parent)) { - ids.add(row.id); + for (const childId of this.childStopsByParent.get(parent) ?? []) { + ids.add(childId); } return ids; @@ -196,21 +134,22 @@ export class GTFSCache { const radiusMeters = query.radiusMeters ?? 500; const limit = query.limit ?? 20; const latSpan = radiusMeters / 111_320; - const lonSpan = radiusMeters / (111_320 * Math.cos((query.lat * Math.PI) / 180)); + const lonSpan = radiusMeters / (111_320 * Math.max(Math.cos((query.lat * Math.PI) / 180), 0.01)); const modes = query.modes?.length ? query.modes : undefined; - const rows = this.db - .query( - `select * from stops - where lat between ?1 and ?2 - and lon between ?3 and ?4 - and lat is not null - and lon is not null`, + return [...this.stops.values()] + .filter((stop) => stop.lat !== undefined && stop.lon !== undefined) + .filter( + (stop) => + stop.lat! >= query.lat - latSpan && + stop.lat! <= query.lat + latSpan && + stop.lon! >= query.lon - lonSpan && + stop.lon! <= query.lon + lonSpan, ) - .all(query.lat - latSpan, query.lat + latSpan, query.lon - lonSpan, query.lon + lonSpan); - - return rows - .map((row) => ({ stop: stopFromRow(row), distance: distanceMeters(query.lat, query.lon, row.lat!, row.lon!) })) + .map((stop) => ({ + stop, + distance: distanceMeters(query.lat, query.lon, stop.lat!, stop.lon!), + })) .filter((row) => row.distance <= radiusMeters) .filter((row) => !modes || !row.stop.mode || modes.includes(row.stop.mode)) .sort((a, b) => a.distance - b.distance) @@ -218,102 +157,74 @@ export class GTFSCache { .map((row) => row.stop); } - private createSchema() { - this.db.run(gtfsSchemaSql()); + private statusForMode(mode: TransitMode): StaticDataStatus { + const summary = this.importSummary(mode); + return { + mode, + ready: Boolean(summary), + importedAt: summary?.importedAt, + sourceUrl: summary?.sourceUrl, + stopCount: summary?.stopCount ?? 0, + routeCount: summary?.routeCount ?? 0, + tripCount: summary?.tripCount ?? 0, + stopTimeCount: summary?.stopTimeCount ?? 0, + }; } private importRows(input: { stops: GtfsStopInput[]; routes: GtfsRouteInput[]; trips: GtfsTripInput[]; - stopTimes: GtfsStopTimeInput[]; mode?: TransitMode; }) { - const insertStop = this.db.query(` - insert or replace into stops - (id, name, lat, lon, parent_station, location_type, mode) - values ($id, $name, $lat, $lon, $parentStation, $locationType, $mode) - `); - const insertRoute = this.db.query(` - insert or replace into routes - (id, short_name, long_name, type, color, text_color) - values ($id, $shortName, $longName, $type, $color, $textColor) - `); - const insertTrip = this.db.query(` - insert or replace into trips - (id, route_id, service_id, headsign, direction_id) - values ($id, $routeId, $serviceId, $headsign, $directionId) - `); - const insertStopTime = this.db.query(` - insert or replace into stop_times - (trip_id, arrival_time, departure_time, stop_id, stop_sequence) - values ($tripId, $arrivalTime, $departureTime, $stopId, $stopSequence) - `); - - const transaction = this.db.transaction(() => { - for (const stop of input.stops) { - insertStop.run({ - id: stop.stop_id, - name: stop.stop_name, - lat: numberOrNull(stop.stop_lat), - lon: numberOrNull(stop.stop_lon), - parentStation: stop.parent_station || null, - locationType: numberOrNull(stop.location_type), - mode: input.mode ?? inferModeFromRouteType(undefined), - }); + for (const stop of input.stops) { + const normalized = stopFromInput(stop, input.mode); + this.removeChildParentLink(normalized.id); + this.stops.set(normalized.id, normalized); + if (normalized.parentStation) { + const children = this.childStopsByParent.get(normalized.parentStation) ?? new Set(); + children.add(normalized.id); + this.childStopsByParent.set(normalized.parentStation, children); } + } - for (const route of input.routes) { - insertRoute.run({ - id: route.route_id, - shortName: route.route_short_name || route.route_id, - longName: route.route_long_name || null, - type: numberOrNull(route.route_type), - color: normalizeColor(route.route_color), - textColor: normalizeColor(route.route_text_color), - }); - } + for (const route of input.routes) { + const normalized = routeFromInput(route); + this.routes.set(normalized.id, normalized); + } - for (const trip of input.trips) { - insertTrip.run({ - id: trip.trip_id, - routeId: trip.route_id, - serviceId: trip.service_id || null, - headsign: trip.trip_headsign || null, - directionId: numberOrNull(trip.direction_id), - }); - } + for (const trip of input.trips) { + this.trips.set(trip.trip_id, { + id: trip.trip_id, + routeId: trip.route_id, + serviceId: trip.service_id || undefined, + headsign: trip.trip_headsign || undefined, + directionId: numberOrUndefined(trip.direction_id), + }); + } + } - for (const stopTime of input.stopTimes) { - insertStopTime.run({ - tripId: stopTime.trip_id, - arrivalTime: stopTime.arrival_time || null, - departureTime: stopTime.departure_time || null, - stopId: stopTime.stop_id, - stopSequence: numberOrNull(stopTime.stop_sequence) ?? 0, - }); - } + private markImported(mode: TransitMode, sourceUrl: string | undefined, seed: StaticGtfsSeed) { + this.summaries.set(mode, { + mode, + importedAt: new Date().toISOString(), + sourceUrl, + stopCount: seed.stops?.length ?? 0, + routeCount: seed.routes?.length ?? 0, + tripCount: seed.trips?.length ?? 0, + stopTimeCount: seed.stopTimes?.length ?? 0, }); - - transaction(); } - private markImported(mode: TransitMode, sourceUrl: string | undefined, seed: StaticGtfsSeed) { - this.db - .query(` - insert or replace into gtfs_imports - (mode, imported_at, source_url, stop_count, route_count, trip_count, stop_time_count) - values ($mode, $importedAt, $sourceUrl, $stopCount, $routeCount, $tripCount, $stopTimeCount) - `) - .run({ - mode, - importedAt: new Date().toISOString(), - sourceUrl: sourceUrl ?? null, - stopCount: seed.stops?.length ?? 0, - routeCount: seed.routes?.length ?? 0, - tripCount: seed.trips?.length ?? 0, - stopTimeCount: seed.stopTimes?.length ?? 0, - }); + private removeChildParentLink(stopId: string) { + const previous = this.stops.get(stopId); + if (!previous?.parentStation) return; + + const children = this.childStopsByParent.get(previous.parentStation); + children?.delete(stopId); + if (children?.size === 0) { + this.childStopsByParent.delete(previous.parentStation); + } } } @@ -347,46 +258,39 @@ export function parseGtfsZip(zipBytes: ArrayBuffer | Uint8Array): Required Date; staticData?: StaticGtfsSeed; + staticDataMode?: TransitMode; endpoints?: Partial; } @@ -52,7 +50,6 @@ export type StaticGtfsImportStrategy = "core" | "schedule"; export interface StaticGtfsImportOptions { strategy?: StaticGtfsImportStrategy; limits?: StaticGtfsImportLimits; - rehydrate?: boolean; } export interface StaticDataStatus {