+13
-18
@@ -1,26 +0,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 }); | ||
| } |
+3
-175
| 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 { | ||
@@ -19,5 +12,3 @@ Alert, | ||
| BusVehicleQuery, | ||
| DatabaseStatus, | ||
| Direction, | ||
| GtfsImportSummary, | ||
| MTAEndpoints, | ||
@@ -29,5 +20,2 @@ MTAOptions, | ||
| StopsNearQuery, | ||
| StaticGtfsSeed, | ||
| StaticGtfsImportLimits, | ||
| StaticGtfsImportStrategy, | ||
| TransitMode, | ||
@@ -39,3 +27,2 @@ Vehicle, | ||
| static: GTFSCache; | ||
| readonly database: DatabaseClient; | ||
| readonly subway: SubwayClient; | ||
@@ -53,3 +40,2 @@ readonly bus: BusClient; | ||
| readonly options: MTAOptions; | ||
| private readonly readyPromise: Promise<void>; | ||
| private readonly realtimeCache = new Map<string, { expiresAt: number; feed: GtfsRealtimeFeed }>(); | ||
@@ -74,6 +60,4 @@ private readonly realtimeCacheTtlMs: number; | ||
| }; | ||
| 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); | ||
@@ -86,3 +70,2 @@ this.bus = new BusClient(this); | ||
| async ready() { | ||
| await this.readyPromise; | ||
| return this; | ||
@@ -95,24 +78,2 @@ } | ||
| 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) { | ||
@@ -153,86 +114,2 @@ const now = this.now().getTime(); | ||
| 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<DatabaseStatus> { | ||
| if (this.mta.hostedApiEnabled()) { | ||
| return this.mta.hostedJson<DatabaseStatus>("/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<GtfsImportSummary | undefined> { | ||
| 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<GtfsImportSummary | undefined> { | ||
| await this.mta.ready(); | ||
| if (this.mta.static.hasStaticData(input.mode)) { | ||
| return this.mta.static.importSummary(input.mode); | ||
| } | ||
| return this.importStaticData(input); | ||
| } | ||
| } | ||
| class SubwayClient { | ||
@@ -483,5 +360,3 @@ constructor(private readonly mta: MTA) {} | ||
| 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); | ||
@@ -562,14 +437,2 @@ }); | ||
| 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) { | ||
@@ -628,37 +491,2 @@ return typeof value === "string" && value.length > 0 ? value : undefined; | ||
| async function retryOnWalConflict<T>(operation: () => Promise<T>) { | ||
| 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"; | ||
@@ -665,0 +493,0 @@ export { GTFSCache } from "./src/static-gtfs"; |
+3
-8
| { | ||
| "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", | ||
@@ -22,9 +22,5 @@ "repository": { | ||
| "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" | ||
| }, | ||
@@ -38,3 +34,2 @@ "devDependencies": { | ||
| "dependencies": { | ||
| "@libsql/client": "^0.17.3", | ||
| "csv-parse": "^6.2.1", | ||
@@ -41,0 +36,0 @@ "fflate": "^0.8.2", |
+41
-322
| # mta-js | ||
| A TypeScript client for MTA subway, bus, stop, and alert data with a small normalized API. | ||
| TypeScript client for MTA realtime feeds and the hosted MTA 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"] }); | ||
| ``` | ||
| ## 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: | ||
@@ -32,337 +17,71 @@ ```ts | ||
| 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", | ||
| const lTrain = await mta.subway.arrivals({ | ||
| stopId: "L08", | ||
| route: "L", | ||
| }); | ||
| ``` | ||
| ## Database | ||
| 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. | ||
| 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`. | ||
| ## Direct MTA Feeds | ||
| ```ts | ||
| new MTA({ databaseUrl: ":memory:" }); | ||
| new MTA({ databaseUrl: "file:.mta-cache/gtfs.sqlite" }); | ||
| new MTA({ databaseUrl: "/var/data/mta-gtfs.sqlite" }); | ||
| ``` | ||
| You can still call MTA realtime feeds directly without the hosted API: | ||
| 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", | ||
| busTimeKey: process.env.MTA_BUS_KEY, | ||
| }); | ||
| 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: | ||
| ```ts | ||
| const mta = new MTA({ | ||
| databaseUrl: "https://cdn.example.com/mta-gtfs.sqlite", | ||
| databaseLocalPath: "/tmp/mta-gtfs.sqlite", | ||
| const buses = await mta.bus.arrivals({ | ||
| stopId: "308214", | ||
| route: "M23", | ||
| }); | ||
| ``` | ||
| If you want to pay the hydration cost before handling requests, await readiness during startup: | ||
| 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: "https://cdn.example.com/mta-gtfs.sqlite", | ||
| }); | ||
| 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", | ||
| }); | ||
| 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. | ||
| ```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: | ||
| 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. | ||
| ```sh | ||
| MTA_DATABASE_URL=libsql://your-db.turso.io \ | ||
| MTA_DATABASE_AUTH_TOKEN=... \ | ||
| bun src/cli.ts db import --mode=subway --strategy=core | ||
| ``` | ||
| ## Endpoints | ||
| ## Vercel Workflow Sync | ||
| In production, keep static GTFS imports out of user-facing API requests. `mta-js` provides the database operations, and your app should own the Vercel Workflow setup that runs those operations on a schedule. | ||
| Install and configure Workflow in your Vercel app, then copy a workflow like this into the app. Use generic database environment variables so the backing store can be a libSQL/Turso database today and another supported database later. | ||
| ```ts | ||
| // workflows/sync-mta-gtfs.ts | ||
| import { MTA } from "mta-js"; | ||
| export async function syncMtaGtfs() { | ||
| "use workflow"; | ||
| const schema = await pushSchema(); | ||
| const subway = await importSubwayCore(); | ||
| return { schema, subway }; | ||
| } | ||
| async function pushSchema() { | ||
| "use step"; | ||
| const mta = new MTA({ | ||
| databaseUrl: process.env.MTA_DATABASE_URL, | ||
| databaseAuthToken: process.env.MTA_DATABASE_AUTH_TOKEN, | ||
| databaseLocalPath: "/tmp/mta-sync.sqlite", | ||
| }); | ||
| try { | ||
| return await mta.database.push(); | ||
| } finally { | ||
| mta.close(); | ||
| } | ||
| } | ||
| async function importSubwayCore() { | ||
| "use step"; | ||
| const mta = new MTA({ | ||
| databaseUrl: process.env.MTA_DATABASE_URL, | ||
| databaseAuthToken: process.env.MTA_DATABASE_AUTH_TOKEN, | ||
| databaseLocalPath: "/tmp/mta-sync.sqlite", | ||
| }); | ||
| try { | ||
| return await mta.database.importStaticData({ | ||
| mode: "subway", | ||
| strategy: "core", | ||
| sourceUrl: "https://rrgtfsfeeds.s3.amazonaws.com/gtfs_subway.zip", | ||
| }); | ||
| } finally { | ||
| mta.close(); | ||
| } | ||
| } | ||
| ``` | ||
| Start the workflow from an app route. This route can be invoked manually or by Vercel Cron. | ||
| ```ts | ||
| // app/api/sync-mta-gtfs/route.ts | ||
| import { syncMtaGtfs } from "@/workflows/sync-mta-gtfs"; | ||
| import { start } from "workflow/api"; | ||
| export async function POST() { | ||
| const run = await start(syncMtaGtfs); | ||
| return Response.json({ runId: run.runId }); | ||
| } | ||
| ``` | ||
| ```json | ||
| { | ||
| "crons": [ | ||
| { | ||
| "path": "/api/sync-mta-gtfs", | ||
| "schedule": "0 8 * * *" | ||
| } | ||
| ] | ||
| } | ||
| ``` | ||
| Runtime application routes can stay small and fast: | ||
| ```ts | ||
| // app/api/transit/l/route.ts | ||
| import { MTA, StaticDataMissingError } from "mta-js"; | ||
| const mta = new MTA({ | ||
| databaseUrl: process.env.MTA_DATABASE_URL, | ||
| databaseAuthToken: process.env.MTA_DATABASE_AUTH_TOKEN, | ||
| databaseLocalPath: "/tmp/mta.sqlite", | ||
| busTimeKey: process.env.MTA_BUS_KEY, | ||
| }); | ||
| export async function GET() { | ||
| await mta.ready(); | ||
| const status = await mta.database.status(); | ||
| if (!status.subway.ready) { | ||
| throw new StaticDataMissingError("subway"); | ||
| } | ||
| const arrivals = await mta.subway.arrivals({ | ||
| stopId: "L06", | ||
| route: "L", | ||
| limit: 5, | ||
| }); | ||
| return Response.json({ arrivals }); | ||
| } | ||
| ``` | ||
| Use `strategy: "core"` for normal production serving. It writes far less data than a full schedule import and is enough for stop names, route branding, nearby stops, realtime arrivals, alerts, and bus vehicle calls. Use `strategy: "schedule"` only when you need static schedule lookups from `trips` and `stop_times`. | ||
| 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 | ||
| ```ts | ||
| await mta.static.importZipFromUrl( | ||
| "https://rrgtfsfeeds.s3.amazonaws.com/gtfs_subway.zip", | ||
| "subway", | ||
| ); | ||
| ``` | ||
| Tests and small scripts can seed the cache directly: | ||
| ```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(...)` |
+1
-1
@@ -33,3 +33,3 @@ export class MTAError extends Error { | ||
| 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.`); | ||
| } | ||
@@ -36,0 +36,0 @@ } |
+131
-227
@@ -1,8 +0,8 @@ | ||
| import { Database } from "bun:sqlite"; | ||
| import { parse } from "csv-parse/sync"; | ||
| import { unzipSync } from "fflate"; | ||
| import { parse } from "csv-parse/sync"; | ||
| import { gtfsSchemaSql } from "./schema"; | ||
| import type { | ||
| DatabaseStatus, | ||
| GtfsImportSummary, | ||
| GtfsRouteInput, | ||
| GtfsImportSummary, | ||
| GtfsStopInput, | ||
@@ -12,5 +12,4 @@ GtfsStopTimeInput, | ||
| Route, | ||
| StaticDataStatus, | ||
| StaticGtfsSeed, | ||
| DatabaseStatus, | ||
| StaticDataStatus, | ||
| Stop, | ||
@@ -21,48 +20,23 @@ StopsNearQuery, | ||
| 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; | ||
| routeId: string; | ||
| serviceId?: string; | ||
| headsign?: string; | ||
| directionId?: number; | ||
| }; | ||
| 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; | ||
| }; | ||
| export class GTFSCache { | ||
| readonly db: Database; | ||
| private stops = new Map<string, Stop>(); | ||
| private routes = new Map<string, Route>(); | ||
| private trips = new Map<string, Trip>(); | ||
| private childStopsByParent = new Map<string, Set<string>>(); | ||
| private summaries = new Map<TransitMode, GtfsImportSummary>(); | ||
| 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(); | ||
| } | ||
| constructor(seed?: StaticGtfsSeed, mode?: TransitMode) { | ||
| if (seed) this.importSeed(seed, mode); | ||
| } | ||
| close() { | ||
| this.db.close(false); | ||
| } | ||
| close() {} | ||
| pushSchema() { | ||
| this.createSchema(); | ||
| } | ||
| importSeed(seed: StaticGtfsSeed, mode?: TransitMode) { | ||
@@ -73,3 +47,2 @@ this.importRows({ | ||
| trips: seed.trips ?? [], | ||
| stopTimes: seed.stopTimes ?? [], | ||
| mode, | ||
@@ -82,4 +55,3 @@ }); | ||
| const seed = parseGtfsZip(zipBytes); | ||
| this.importRows({ ...seed, mode }); | ||
| if (mode) this.markImported(mode, undefined, seed); | ||
| this.importSeed(seed, mode); | ||
| } | ||
@@ -92,17 +64,24 @@ | ||
| } | ||
| 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 { | ||
@@ -118,45 +97,7 @@ return { | ||
| 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, | ||
| }; | ||
| return this.summaries.get(mode); | ||
| } | ||
| 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, | ||
| }; | ||
| } | ||
| getStop(id: string): Stop | undefined { | ||
| const row = this.db.query<StopRow, [string]>("select * from stops where id = ?1").get(id); | ||
| return row ? stopFromRow(row) : undefined; | ||
| return this.stops.get(id); | ||
| } | ||
@@ -173,12 +114,11 @@ | ||
| const normalized = idOrShortName.toUpperCase(); | ||
| const row = this.db | ||
| .query<RouteRow, [string, string]>( | ||
| "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<TripRow, [string]>("select * from trips where id = ?1").get(id) ?? undefined; | ||
| getTrip(id: string): Trip | undefined { | ||
| return this.trips.get(id); | ||
| } | ||
@@ -193,6 +133,4 @@ | ||
| 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); | ||
| } | ||
@@ -207,17 +145,18 @@ | ||
| 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<StopRow, [number, number, number, number]>( | ||
| `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) | ||
@@ -230,4 +169,14 @@ .filter((row) => !modes || !row.stop.mode || modes.includes(row.stop.mode)) | ||
| 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, | ||
| }; | ||
| } | ||
@@ -239,90 +188,52 @@ | ||
| 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<string>(); | ||
| 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); | ||
| } | ||
| } | ||
@@ -361,42 +272,35 @@ } | ||
| function stopFromRow(row: StopRow): Stop { | ||
| function stopFromInput(stop: GtfsStopInput, mode?: TransitMode): Stop { | ||
| return { | ||
| id: row.id, | ||
| name: row.name, | ||
| lat: row.lat ?? undefined, | ||
| lon: row.lon ?? undefined, | ||
| parentStation: row.parent_station ?? undefined, | ||
| mode: row.mode ?? undefined, | ||
| id: stop.stop_id, | ||
| name: stop.stop_name, | ||
| lat: numberOrUndefined(stop.stop_lat), | ||
| lon: numberOrUndefined(stop.stop_lon), | ||
| parentStation: stop.parent_station || undefined, | ||
| mode, | ||
| }; | ||
| } | ||
| function routeFromRow(row: RouteRow): Route { | ||
| function routeFromInput(route: GtfsRouteInput): Route { | ||
| return { | ||
| id: row.id, | ||
| shortName: row.short_name ?? undefined, | ||
| longName: row.long_name ?? undefined, | ||
| type: row.type ?? undefined, | ||
| color: row.color ?? undefined, | ||
| textColor: row.text_color ?? undefined, | ||
| id: route.route_id, | ||
| shortName: route.route_short_name || route.route_id, | ||
| longName: route.route_long_name || undefined, | ||
| type: numberOrUndefined(route.route_type), | ||
| color: normalizeColor(route.route_color), | ||
| textColor: normalizeColor(route.route_text_color), | ||
| }; | ||
| } | ||
| function numberOrNull(value: unknown) { | ||
| if (value === undefined || value === null || value === "") return null; | ||
| function numberOrUndefined(value: unknown) { | ||
| if (value === undefined || value === null || value === "") return undefined; | ||
| const number = Number(value); | ||
| return Number.isFinite(number) ? number : null; | ||
| return Number.isFinite(number) ? number : undefined; | ||
| } | ||
| function normalizeColor(value: string | undefined) { | ||
| if (!value) return null; | ||
| if (!value) return undefined; | ||
| return value.startsWith("#") ? value : `#${value}`; | ||
| } | ||
| function inferModeFromRouteType(type?: number): TransitMode | null { | ||
| if (type === 1) return "subway"; | ||
| if (type === 2) return "lirr"; | ||
| if (type === 3) return "bus"; | ||
| return null; | ||
| } | ||
| function distanceMeters(lat1: number, lon1: number, lat2: number, lon2: number) { | ||
@@ -403,0 +307,0 @@ const radius = 6_371_000; |
+1
-4
@@ -9,5 +9,2 @@ export type TransitMode = "subway" | "bus" | "lirr" | "metro-north"; | ||
| busTimeKey?: string; | ||
| databaseUrl?: string; | ||
| databaseAuthToken?: string; | ||
| databaseLocalPath?: string; | ||
| realtimeCacheTtlMs?: number; | ||
@@ -17,2 +14,3 @@ fetch?: typeof fetch; | ||
| staticData?: StaticGtfsSeed; | ||
| staticDataMode?: TransitMode; | ||
| endpoints?: Partial<MTAEndpoints>; | ||
@@ -57,3 +55,2 @@ } | ||
| limits?: StaticGtfsImportLimits; | ||
| rehydrate?: boolean; | ||
| } | ||
@@ -60,0 +57,0 @@ |
-107
| #!/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=<url> [--database-auth-token=<token>]", | ||
| " mta-js db import --mode=subway [--strategy=core|schedule] [--source-url=<url>]", | ||
| ].join("\n"), | ||
| ); | ||
| process.exit(1); | ||
| } |
| 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"); | ||
| } | ||
| } |
| 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")};`; | ||
| } |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 8 instances
4
-20%3
-75%15
-31.82%42372
-41.38%10
-23.08%1229
-33.28%87
-76.36%- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed