🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

mta-js

Package Overview
Dependencies
Maintainers
1
Versions
7
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

mta-js - npm Package Compare versions

Comparing version
1.0.1
to
2.0.0
+13
-18
examples/vercel-route.ts

@@ -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";

{
"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(...)`

@@ -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 @@ }

@@ -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;

@@ -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 @@

#!/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")};`;
}