Compare commits

...

3 Commits

Author SHA1 Message Date
3cbaabed34 Updated prefix 2026-03-13 01:16:35 +01:00
3882e3b0a6 Cleanup and rename 2026-03-13 00:01:14 +01:00
c90be4a981 Saving GTFS-RT data to Parquet 2026-03-13 00:01:00 +01:00
19 changed files with 1031 additions and 6393 deletions

View File

@@ -1,6 +1,18 @@
# Database Configuration (TimescaleDB) # Parquet storage
POSTGRES_HOST=localhost PARQUET_DIR=./data
POSTGRES_PORT=5432 PARQUET_ROLL_MINUTES=5
POSTGRES_DB=iot_data SAVE_ALL_VEHICLE_SNAPSHOTS=true
POSTGRES_USER=postgres SAVE_ALL_VEHICLE_POSITIONS=true
POSTGRES_PASSWORD=example
# Object storage (S3-compatible) upload
S3_ENABLED=true
S3_BUCKET=test-bucket
S3_REGION=us-east-1
S3_ENDPOINT=http://localhost:2600
S3_PREFIX=parquet
S3_FORCE_PATH_STYLE=true
S3_DELETE_LOCAL_AFTER_UPLOAD=true
S3_UPLOAD_RETRIES=3
S3_UPLOAD_RETRY_BASE_MS=1000
S3_ACCESS_KEY_ID=test
S3_SECRET_ACCESS_KEY=test123!

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
node_modules/ node_modules/
.env .env
dist/ dist/
data/**/*.parquet
*.log

View File

@@ -1,871 +0,0 @@
# GTFS API Documentation
Comprehensive API documentation for the Skopje Bus Tracker GTFS-based API.
## Table of Contents
- [Overview](#overview)
- [OpenAPI Specification](#openapi-specification)
- [Base URL](#base-url)
- [GTFS Static Data Endpoints](#gtfs-static-data-endpoints)
- [Real-Time Data Endpoints](#real-time-data-endpoints)
- [Historical Data Endpoints](#historical-data-endpoints)
- [Statistics & Analytics Endpoints](#statistics--analytics-endpoints)
- [Data Models](#data-models)
- [Error Handling](#error-handling)
- [Rate Limiting](#rate-limiting)
---
## Overview
This API provides access to Skopje public transport data based on the **GTFS (General Transit Feed Specification)** standard. It combines static GTFS data with real-time information and historical analytics stored in TimescaleDB.
### Key Features
- **GTFS-compliant** static data (stops, routes)
- **Real-time** vehicle positions and arrival predictions
- **Historical** time-series data for analytics
- **Statistical** analysis of delays and patterns
### Upstream API
This application consumes data from the **ModeShift GTFS API**. For documentation on the upstream API endpoints:
- See **[UPSTREAM-API-DOCUMENTATION.md](UPSTREAM-API-DOCUMENTATION.md)**
---
## OpenAPI Specification
A complete **OpenAPI 3.0** specification is available at [openapi.yaml](openapi.yaml). This machine-readable specification includes:
- All API endpoints with parameters and responses
- Request/response schemas and data models
- Examples for all operations
- Error response formats
### Using the OpenAPI Spec
**Interactive Documentation (Local):**
Once the server is running, visit:
```
http://localhost:3000/api-docs.html
```
This provides an interactive Swagger UI where you can:
- Browse all API endpoints
- View request/response schemas
- Try out API calls directly from the browser
- Download the OpenAPI specification
**Interactive Documentation with Swagger UI (Alternative):**
```bash
# Using Docker
docker run -p 8080:8080 -e SWAGGER_JSON=/openapi.yaml -v $(pwd)/openapi.yaml:/openapi.yaml swaggerapi/swagger-ui
# Or use online editor
# Visit: https://editor.swagger.io/
# Then paste the contents of openapi.yaml
```
**Generate Client Libraries:**
```bash
# Install OpenAPI Generator
npm install -g @openapitools/openapi-generator-cli
# Generate TypeScript client
openapi-generator-cli generate -i openapi.yaml -g typescript-fetch -o ./client/typescript
# Generate Python client
openapi-generator-cli generate -i openapi.yaml -g python -o ./client/python
# Generate Go client
openapi-generator-cli generate -i openapi.yaml -g go -o ./client/go
```
**VS Code Integration:**
Install the "OpenAPI (Swagger) Editor" extension to view and edit the spec with validation and IntelliSense.
---
## Base URL
```
http://localhost:3000
```
For production, replace with your deployed server URL.
---
## GTFS Static Data Endpoints
### Get Configuration
Retrieve configuration including default stop and route information.
**Endpoint:** `GET /api/config`
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `stopId` | string | No | Specific stop ID to query (defaults to configured default) |
| `routeId` | string | No | Specific route ID to query (defaults to configured default) |
**Response:**
```json
{
"stop": {
"id": "1563",
"code": "363",
"name": "KARPOS II - STIV NAUMOV",
"lat": 41.97964,
"lon": 21.41283
},
"route": {
"id": "125",
"shortName": "7",
"longName": "Centar - GTC - Karpos 4 - Karposh 2"
},
"defaults": {
"stopId": "1563",
"routeId": "125"
}
}
```
**Example:**
```bash
curl "http://localhost:3000/api/config?stopId=1571&routeId=125"
```
---
### Get All Stops
Retrieve all GTFS stops in the system.
**Endpoint:** `GET /api/stops`
**Query Parameters:** None
**Response:**
```json
[
{
"id": "1563",
"code": "363",
"name": "KARPOS II - STIV NAUMOV",
"lat": 41.97964,
"lon": 21.41283
},
{
"id": "1571",
"code": "371",
"name": "AMERICAN COLLEGE - VASIL GLAVINOV",
"lat": 42.00437,
"lon": 21.39687
}
]
```
**Example:**
```bash
curl "http://localhost:3000/api/stops"
```
**Use Cases:**
- Populate stop selection dropdowns
- Build stop search functionality
- Generate maps with all stops
---
### Get All Routes
Retrieve all GTFS routes in the system.
**Endpoint:** `GET /api/routes`
**Query Parameters:** None
**Response:**
```json
[
{
"id": "125",
"shortName": "7",
"longName": "Centar - GTC - Karpos 4 - Karposh 2"
},
{
"id": "128",
"shortName": "2",
"longName": "Kisela Voda - Centar - 11 Oktomvri - Avtokomanda"
}
]
```
**Example:**
```bash
curl "http://localhost:3000/api/routes"
```
**Use Cases:**
- Display available routes
- Filter by route number
- Route selection interfaces
---
## Real-Time Data Endpoints
### Get Real-Time Arrivals
Get upcoming bus arrivals at a specific stop with real-time predictions.
**Endpoint:** `GET /api/arrivals`
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `stopId` | string | No | Stop ID to query (defaults to configured default) |
| `routeId` | string | No | Filter by specific route ID (defaults to configured default) |
**Response:**
```json
[
{
"arrivalTime": "2026-02-07T14:35:00.000Z",
"scheduledTime": "2026-02-07T14:33:00.000Z",
"minutesUntil": 8,
"delaySeconds": 120,
"headsign": "Karpos 2",
"isRealtime": true,
"realtimeState": "UPDATED"
},
{
"arrivalTime": "2026-02-07T14:48:00.000Z",
"scheduledTime": "2026-02-07T14:48:00.000Z",
"minutesUntil": 21,
"delaySeconds": 0,
"headsign": "Centar",
"isRealtime": true,
"realtimeState": "SCHEDULED"
}
]
```
**Fields:**
- `arrivalTime`: Predicted arrival time (ISO 8601)
- `scheduledTime`: Originally scheduled time (ISO 8601)
- `minutesUntil`: Minutes until arrival (can be negative if bus passed)
- `delaySeconds`: Delay in seconds (positive = late, negative = early)
- `headsign`: Destination shown on bus
- `isRealtime`: Whether prediction is based on real-time data
- `realtimeState`: `SCHEDULED`, `UPDATED`, or `CANCELED`
**Example:**
```bash
curl "http://localhost:3000/api/arrivals?stopId=1563&routeId=125"
```
**Notes:**
- Returns arrivals from 2 minutes ago up to configured minutes ahead
- Results are sorted by arrival time
- Data is also logged to database for historical analysis
---
### Get Real-Time Vehicle Positions
Get current positions of all vehicles on a specific route.
**Endpoint:** `GET /api/vehicles`
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `routeId` | string | No | Route ID to filter vehicles (defaults to configured default) |
**Response:**
```json
[
{
"id": 12345,
"vehicleId": "MK-SK-1234",
"label": "1234",
"lat": 41.9981,
"lon": 21.4254,
"bearing": 87.5,
"speed": 15.3,
"timestamp": "2026-02-07T14:27:15Z",
"tripId": "trip_789",
"currentStopSequence": 0,
"currentStatus": "IN_TRANSIT_TO"
}
]
```
**Fields:**
- `id`: Internal vehicle database ID
- `vehicleId`: Vehicle identification number (license plate)
- `label`: Bus inventory number (shown on bus)
- `lat`/`lon`: Current GPS coordinates
- `bearing`: Direction of travel (0-360 degrees, 0=North)
- `speed`: Current speed in km/h
- `timestamp`: Last position update time
- `tripId`: GTFS trip ID vehicle is currently serving
- `currentStatus`: `IN_TRANSIT_TO`, `STOPPED_AT`, or `UNKNOWN`
**Example:**
```bash
curl "http://localhost:3000/api/vehicles?routeId=125"
```
**Notes:**
- Combines GTFS-RT trip updates with JSON vehicle API
- Vehicle positions are logged to database
- Updates typically every 10-30 seconds from source
---
## Historical Data Endpoints
### Get Database Statistics
Get overview statistics about stored historical data.
**Endpoint:** `GET /api/stats/db`
**Query Parameters:** None
**Response:**
```json
{
"vehiclePositions": {
"totalRecords": 1523847,
"oldestRecord": "2026-01-15T08:00:00.000Z",
"newestRecord": "2026-02-07T14:27:30.000Z",
"dataRetentionDays": 90
},
"arrivalRecords": {
"totalRecords": 456231,
"oldestRecord": "2026-01-15T08:00:00.000Z",
"newestRecord": "2026-02-07T14:27:30.000Z",
"dataRetentionDays": 90
},
"databaseSize": "2.3 GB",
"status": "healthy"
}
```
**Example:**
```bash
curl "http://localhost:3000/api/stats/db"
```
---
### Get Vehicle Position History
Retrieve historical GPS positions for a specific vehicle.
**Endpoint:** `GET /api/history/vehicle/:vehicleId`
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `vehicleId` | string | Yes | Vehicle identification number or label |
**Query Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `hours` | integer | No | 24 | Number of hours to look back |
**Response:**
```json
[
{
"time": "2026-02-07T10:00:00.000Z",
"vehicleId": "MK-SK-1234",
"routeId": "125",
"tripId": "trip_789",
"latitude": 41.9981,
"longitude": 21.4254,
"speed": 15.3,
"bearing": 87.5,
"currentStatus": "IN_TRANSIT_TO"
}
]
```
**Example:**
```bash
curl "http://localhost:3000/api/history/vehicle/1234?hours=12"
```
**Use Cases:**
- Visualize vehicle path on map
- Analyze driving patterns
- Calculate actual travel times
---
### Get Route Vehicle History
Get historical positions of all vehicles that served a specific route.
**Endpoint:** `GET /api/history/route/:routeId/vehicles`
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `routeId` | string | Yes | GTFS route ID |
**Query Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `hours` | integer | No | 24 | Number of hours to look back |
**Response:**
```json
[
{
"time": "2026-02-07T10:00:00.000Z",
"vehicleId": "1234",
"routeId": "125",
"tripId": "trip_789",
"latitude": 41.9981,
"longitude": 21.4254,
"speed": 15.3,
"bearing": 87.5
}
]
```
**Example:**
```bash
curl "http://localhost:3000/api/history/route/125/vehicles?hours=6"
```
**Use Cases:**
- Service frequency analysis
- Route coverage visualization
- Fleet utilization tracking
---
### Get Stop Arrival History
Retrieve historical arrival records for a specific stop.
**Endpoint:** `GET /api/history/stop/:stopId/arrivals`
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `stopId` | string | Yes | GTFS stop ID |
**Query Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `routeId` | string | No | (all) | Filter by specific route |
| `hours` | integer | No | 24 | Number of hours to look back |
**Response:**
```json
[
{
"time": "2026-02-07T10:15:00.000Z",
"stopId": "1563",
"routeId": "125",
"scheduledTime": "2026-02-07T10:15:00.000Z",
"predictedTime": "2026-02-07T10:17:30.000Z",
"delaySeconds": 150,
"isRealtime": true,
"headsign": "Karpos 2"
}
]
```
**Example:**
```bash
curl "http://localhost:3000/api/history/stop/1563/arrivals?routeId=125&hours=48"
```
---
## Statistics & Analytics Endpoints
### Get Route Delay Statistics
Get delay statistics and performance metrics for a route.
**Endpoint:** `GET /api/stats/route/:routeId/delays`
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `routeId` | string | Yes | GTFS route ID |
**Query Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `hours` | integer | No | 24 | Time window for analysis |
**Response:**
```json
{
"routeId": "125",
"periodHours": 24,
"statistics": {
"totalArrivals": 287,
"avgDelaySeconds": 62,
"medianDelaySeconds": 45,
"minDelaySeconds": -30,
"maxDelaySeconds": 420,
"stdDeviation": 78.5,
"onTimePercentage": 68.3,
"earlyPercentage": 8.7,
"latePercentage": 23.0,
"veryLatePercentage": 4.2
},
"thresholds": {
"onTime": "±5 minutes",
"veryLate": ">10 minutes"
}
}
```
**Example:**
```bash
curl "http://localhost:3000/api/stats/route/125/delays?hours=72"
```
**Definitions:**
- **On-time**: Within ±5 minutes of schedule
- **Early**: More than 5 minutes early
- **Late**: 5-10 minutes late
- **Very Late**: More than 10 minutes late
---
### Get Stop Delay Statistics
Get delay statistics for a specific stop.
**Endpoint:** `GET /api/stats/stop/:stopId/delays`
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `stopId` | string | Yes | GTFS stop ID |
**Query Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `hours` | integer | No | 24 | Time window for analysis |
**Response:**
```json
{
"stopId": "1563",
"periodHours": 24,
"statistics": {
"totalArrivals": 145,
"avgDelaySeconds": 72,
"medianDelaySeconds": 60,
"onTimePercentage": 62.1,
"routeBreakdown": [
{
"routeId": "125",
"routeName": "7",
"arrivals": 89,
"avgDelaySeconds": 65
},
{
"routeId": "128",
"routeName": "2",
"arrivals": 56,
"avgDelaySeconds": 82
}
]
}
}
```
**Example:**
```bash
curl "http://localhost:3000/api/stats/stop/1563/delays?hours=48"
```
**Use Cases:**
- Compare reliability across stops
- Identify problematic locations
- Plan transfer reliability
---
### Get Route Hourly Pattern
Analyze delay patterns by hour of day for a route.
**Endpoint:** `GET /api/stats/route/:routeId/hourly`
**Path Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `routeId` | string | Yes | GTFS route ID |
**Query Parameters:**
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `days` | integer | No | 7 | Number of days to analyze |
**Response:**
```json
{
"routeId": "125",
"periodDays": 7,
"hourlyPattern": [
{
"hour": 6,
"arrivals": 42,
"avgDelaySeconds": 15,
"onTimePercentage": 85.7
},
{
"hour": 7,
"arrivals": 89,
"avgDelaySeconds": 45,
"onTimePercentage": 71.9
},
{
"hour": 8,
"arrivals": 124,
"avgDelaySeconds": 125,
"onTimePercentage": 52.4
}
]
}
```
**Example:**
```bash
curl "http://localhost:3000/api/stats/route/125/hourly?days=14"
```
**Use Cases:**
- Identify rush hour patterns
- Compare morning vs evening reliability
- Optimize schedules based on actual performance
---
## Data Models
### GTFS Stop
```typescript
interface GtfsStop {
stop_id: string; // Unique stop identifier
stop_code: string; // Public-facing stop code
stop_name: string; // Stop name
stop_lat: number; // Latitude (WGS84)
stop_lon: number; // Longitude (WGS84)
}
```
### GTFS Route
```typescript
interface GtfsRoute {
route_id: string; // Unique route identifier
route_short_name: string; // Public route number (e.g., "7")
route_long_name: string; // Full route name
route_type?: string; // GTFS route type (0=Tram, 3=Bus, etc.)
}
```
### Vehicle Position
```typescript
interface VehiclePosition {
time: Date; // Timestamp (ISO 8601)
vehicle_id: string; // Vehicle identifier
route_id: string; // Route being served
trip_id?: string; // Current trip ID
latitude: number; // GPS latitude
longitude: number; // GPS longitude
speed?: number; // Speed in km/h
bearing?: number; // Direction (0-360°)
current_status: string; // IN_TRANSIT_TO, STOPPED_AT, UNKNOWN
}
```
### Arrival Record
```typescript
interface ArrivalRecord {
time: Date; // Record timestamp
stop_id: string; // Stop ID
route_id: string; // Route ID
scheduled_time: Date; // Scheduled arrival time
predicted_time?: Date; // Real-time predicted time
actual_time?: Date; // Actual arrival time (if observed)
delay_seconds: number; // Delay in seconds
is_realtime: boolean; // Based on real-time data
headsign?: string; // Trip headsign
}
```
---
## Error Handling
All endpoints return standard HTTP status codes:
| Status Code | Description |
|-------------|-------------|
| `200` | Success |
| `404` | Resource not found (e.g., invalid stop/route ID) |
| `500` | Server error |
**Error Response Format:**
```json
{
"error": "Description of the error",
"details": "Additional error details (when available)"
}
```
**Example Error:**
```bash
curl "http://localhost:3000/api/config?stopId=invalid"
```
Response (404):
```json
{
"error": "Stop invalid not found"
}
```
---
## Rate Limiting
Currently, there is no rate limiting implemented. For production deployments, consider:
- Implementing per-IP rate limits
- Using caching for frequently accessed endpoints
- Load balancing for high-traffic scenarios
**Recommended Polling Intervals:**
- Real-time arrivals: 10-30 seconds
- Vehicle positions: 10-30 seconds
- Historical data: As needed (not time-sensitive)
- Statistics: 5-60 minutes (depending on use case)
---
## GTFS Data Update
The GTFS static data (stops, routes, schedules) is loaded from the `gtfs/` directory on server startup.
**To update GTFS data:**
```bash
bun run setup-gtfs.ts
```
This downloads the latest GTFS feed and extracts it. Restart the server to load the new data.
---
## TimescaleDB Setup
Historical data and analytics require TimescaleDB. See the [README.md](README.md) for setup instructions.
**Quick start:**
```bash
cd infrastructure
docker compose up -d
```
The database automatically:
- Creates hypertables for time-series data
- Sets up 90-day data retention
- Builds continuous aggregates for hourly metrics
- Maintains indexes for fast queries
---
## Example Use Cases
### Building a Real-Time Tracker
```javascript
// 1. Get all stops
const stops = await fetch('/api/stops').then(r => r.json());
// 2. Get all routes
const routes = await fetch('/api/routes').then(r => r.json());
// 3. Poll for arrivals every 15 seconds
setInterval(async () => {
const arrivals = await fetch('/api/arrivals?stopId=1563&routeId=125')
.then(r => r.json());
updateUI(arrivals);
}, 15000);
// 4. Poll for vehicle positions
setInterval(async () => {
const vehicles = await fetch('/api/vehicles?routeId=125')
.then(r => r.json());
updateMap(vehicles);
}, 15000);
```
### Building Analytics Dashboard
```javascript
// Get delay statistics for route
const stats = await fetch('/api/stats/route/125/delays?hours=168')
.then(r => r.json());
// Get hourly patterns
const pattern = await fetch('/api/stats/route/125/hourly?days=30')
.then(r => r.json());
// Get stop performance comparison
const stopStats = await fetch('/api/stats/stop/1563/delays?hours=168')
.then(r => r.json());
// Display charts with the data
renderDelayChart(stats);
renderHourlyPattern(pattern);
renderStopComparison(stopStats);
```
---
## Additional Resources
- **OpenAPI Specification**: [openapi.yaml](openapi.yaml) - Machine-readable API spec
- **Upstream API Documentation**: [UPSTREAM-API-DOCUMENTATION.md](UPSTREAM-API-DOCUMENTATION.md) - ModeShift GTFS API docs
- **Swagger UI Demo**: https://editor.swagger.io/ (paste openapi.yaml content)
- **GTFS Specification**: https://gtfs.org/
- **GTFS-RT Reference**: https://gtfs.org/realtime/
- **TimescaleDB Documentation**: https://docs.timescale.com/
- **Project README**: [README.md](README.md)
---
## Support
For issues or questions:
1. Check the [README.md](README.md) for setup instructions
2. Review this documentation
3. Check the source code in [server.ts](server.ts) and [lib/gtfs.ts](lib/gtfs.ts)
---
**Last Updated:** February 7, 2026
**API Version:** 1.0.0

233
README.md
View File

@@ -1,217 +1,68 @@
# Skopje Bus Tracker # OpenJSP Bus Tracker
Real-time bus tracking for Skopje public transport. Modular system supporting any stop and route. Real-time Skopje public transport tracking with Bun, GTFS/GTFS-RT ingestion, parquet persistence, and optional S3-compatible segment upload.
## What Is In This Repo
- `bus-tracker-json.ts`: terminal tracker for one stop + one route.
- `index.ts`: continuous collector for multiple routes/stops.
- `lib/storage.ts`: parquet write layer with rolling segments and optional S3 upload.
- `lib/gtfs.ts`: GTFS CSV loading helpers.
- `config.ts`: API base URL, defaults, and tracker timing.
## Requirements
- Bun 1.x+
- Network access to the configured GTFS/JSON upstream APIs
## Quick Start ## Quick Start
```bash ```bash
npm install bun install
npm run setup-gtfs # Download latest GTFS data bun run typecheck
npm run web
``` ```
Open **http://localhost:3000** Run single stop/route terminal tracker:
Visit **http://localhost:3000/analytics.html** for historical data and performance analytics.
## TimescaleDB Setup
The application uses TimescaleDB for storing time-series data (vehicle positions, arrivals, delays).
### Start the database:
```bash ```bash
cd infrastructure bun run bus-tracker-json.ts
docker compose up -d
``` ```
### Configure environment: Run with custom stop and route IDs:
Create a `.env` file (or use the defaults):
```bash ```bash
POSTGRES_HOST=localhost bun run bus-tracker-json.ts --stop 1571 --route 125
POSTGRES_PORT=5432
POSTGRES_DB=iot_data
POSTGRES_USER=postgres
POSTGRES_PASSWORD=example
``` ```
The database will automatically: Run background collection pipeline:
- Create hypertables for efficient time-series queries
- Set up compression and retention policies (90 days)
- Build continuous aggregates for hourly metrics
- Index data for fast queries
### Analytics Features:
- **Vehicle Position History**: Track individual buses over time
- **Delay Analysis**: On-time performance, average delays, patterns
- **Hourly Patterns**: See when buses are typically late/early
- **Route Statistics**: Reliability scores, service quality metrics
- **Stop Performance**: Compare delays across different stops
### Background Tracker:
For continuous data collection without keeping the web interface open:
```bash ```bash
npm run track bun run start
``` ```
This automatically tracks these popular routes every 30 seconds: ## Environment
- Routes: 2, 4, 5, 7, 15, 21, 22, 24
- Private routes: 12П, 19П, 22П, 45П, 52П, 54П, 61П, 9П
Data is stored in TimescaleDB for historical analysis. The tracker runs indefinitely until stopped with Ctrl+C. Copy `.env.example` to `.env` and adjust values as needed.
## Features Key variables:
- **Fully Modular Web Interface**: Select any stop and route via UI controls or URL parameters - `PARQUET_DIR`: local output directory for parquet files.
- **Dynamic Tracking**: Change stops/routes without restarting the server - `PARQUET_ROLL_MINUTES`: segment rotation interval.
- Interactive map with live vehicle positions - `SAVE_ALL_VEHICLE_SNAPSHOTS`: save full raw vehicle feed snapshots.
- Real-time arrivals with delays - `SAVE_ALL_VEHICLE_POSITIONS`: persist all vehicle positions (not only route-matched).
- **Time-Series Data Storage**: Historical tracking with TimescaleDB - `S3_ENABLED`: enable object storage upload.
- **Analytics Dashboard**: Delay statistics, hourly patterns, performance metrics - `S3_BUCKET`, `S3_REGION`, `S3_ENDPOINT`, `S3_PREFIX`: object storage target.
- 5-second auto-refresh (web), 10-second (terminal) - `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`: object storage credentials.
- CLI arguments for terminal tracker - `S3_DELETE_LOCAL_AFTER_UPLOAD`: delete local parquet after successful upload.
- Configurable defaults via [config.ts](config.ts) - `S3_UPLOAD_RETRIES`, `S3_UPLOAD_RETRY_BASE_MS`: upload retry behavior.
- Shareable URLs with stop/route parameters
## Commands ## Scripts
```bash - `bun run start`: collector entrypoint (`index.ts`).
npm run setup-gtfs # Download GTFS data - `bun run typecheck`: TypeScript no-emit check.
npm run find -- --stop "american" # Find stop IDs by name
npm run find -- --route "7" # Find route IDs by number/name
npm run web # Web interface at http://localhost:3000
npm run tracker # Terminal interface (default)
npm run tracker -- --stop 1571 --route 125 # Custom stop/route
npm run track # Background tracker for popular routes (30s intervals)
npm start # Same as web
```
### Finding Stop and Route IDs ## Notes
Not sure which Stop ID or Route ID to use? Use the find command: - Generated parquet files are intentionally ignored by git (`data/**/*.parquet`).
- The collector rotates segments and uploads each closed segment when S3 is enabled.
```bash - On process shutdown (`SIGINT`/`SIGTERM`), writers are flushed so the current segment is finalized.
# Find stops by name (case-insensitive)
npm run find -- --stop "american"
npm run find -- --stop "центар"
# Find routes by number or name
npm run find -- --route "7"
npm run find -- --route "линија"
```
### Web Interface Usage
1. **Default tracking**: Open `http://localhost:3000` (loads default stop/route, can be changed in UI)
2. **Direct URL**: `http://localhost:3000?stopId=1571&routeId=125` (bookmarkable)
3. **Change tracking**: Use the controls at the top to enter different Stop ID and Route ID
4. **Share**: Copy URL after selecting a stop/route to share with others
### CLI Arguments
Terminal tracker supports custom stop and route:
```bash
npm run tracker -- --stop <stopId> --route <routeId>
npm run tracker -- --help
```
### API Endpoints
**This Application's API:**
- Complete docs: **[API-DOCUMENTATION.md](API-DOCUMENTATION.md)**
- Interactive docs: http://localhost:3000/api-docs.html (when server is running)
- OpenAPI spec: **[openapi.yaml](openapi.yaml)**
**Upstream ModeShift GTFS API:**
- Documentation: **[UPSTREAM-API-DOCUMENTATION.md](UPSTREAM-API-DOCUMENTATION.md)**
- Provider: ModeShift (Skopje public transport data)
#### Quick Reference
Query parameters for custom tracking:
```
GET /api/config?stopId=1571&routeId=125
GET /api/arrivals?stopId=1571&routeId=125
GET /api/vehicles?routeId=125
GET /api/stops # All stops
GET /api/routes # All routes
# Historical Data APIs
GET /api/stats/db # Database statistics
GET /api/history/vehicle/:vehicleId?hours=24
GET /api/history/route/:routeId/vehicles?hours=24
GET /api/history/stop/:stopId/arrivals?routeId=125&hours=24
GET /api/stats/route/:routeId/delays?hours=24
GET /api/stats/stop/:stopId/delays?hours=24
GET /api/stats/route/:routeId/hourly?days=7
```
## Configuration
Edit [config.ts](config.ts) to set defaults:
```typescript
export const config: AppConfig = {
defaultStop: {
stopId: '1571',
name: 'АМЕРИКАН КОЛЕЏ-КОН ЦЕНТАР',
lat: 41.98057556152344,
lon: 21.457794189453125,
},
defaultRoute: {
routeId: '125',
shortName: '7',
name: 'ЛИНИЈА 7',
},
server: {
port: 3000,
},
tracking: {
refreshInterval: {
web: 5000, // 5 seconds
terminal: 10000, // 10 seconds
},
minutesAhead: 90,
}, + analytics)
bus-tracker-json.ts # Terminal tracker (CLI args)
lib/
gtfs.ts # GTFS loader
database.ts # TimescaleDB time-series storage
public/
index.html # Live tracker UI
analytics.html # Analytics dashboard
infrastructure/
compose.yml # TimescaleDB Docker setup
gtfs/ ure
```
bus/
├── config.ts # Configuration (stops, routes, timing)
├── setup-gtfs.ts # GTFS data downloader
├── find-stops-routes.ts # Helper to find Stop/Route IDs
├── server.ts # Web server (modular API)
├── bus-tracker-json.ts # Terminal tracker (CLI args)
├── lib/gtfs.ts # GTFS loader
├── public/index.html # Frontend (modular UI)
└─**TimescaleDB (PostgreSQL)** for time-series data
- Leaflet.js + OpenStreetMap
- Chart.js for analytics visualizations
- GTFS + GTFS-RT Protocol Buffers
- Docker Compose for database
## Stack
- Node.js + Express + TypeScript
- Leaflet.js + OpenStreetMap
- GTFS + GTFS-RT Protocol Buffers
## License
MIT

View File

@@ -1,556 +0,0 @@
# ModeShift GTFS API Documentation
Documentation for the upstream ModeShift GTFS API used by this Skopje Bus Tracker application.
## Table of Contents
- [Overview](#overview)
- [OpenAPI Specification](#openapi-specification)
- [Base URL](#base-url)
- [Authentication](#authentication)
- [Endpoints](#endpoints)
- [GTFS-RT Trip Updates](#gtfs-rt-trip-updates)
- [Public Vehicles (JSON)](#public-vehicles-json)
- [Nearby Stop Times](#nearby-stop-times)
- [Data Models](#data-models)
- [Usage Examples](#usage-examples)
---
## Overview
The ModeShift API provides real-time transit data for Skopje public transport based on the **GTFS (General Transit Feed Specification)** and **GTFS-Realtime** standards.
**Provider:** ModeShift
**API Type:** GTFS + GTFS-Realtime
**Data Format:** JSON and Protocol Buffers
**Coverage:** Skopje, North Macedonia
---
## OpenAPI Specification
A complete **OpenAPI 3.0** specification for the ModeShift API is available at [upstream-openapi.yaml](upstream-openapi.yaml).
This machine-readable specification includes:
- All upstream API endpoints
- Request/response schemas
- Parameter validation
- Examples for each operation
You can view it interactively using:
- [Swagger Editor](https://editor.swagger.io/) - Paste the YAML content
- [Redoc](https://redocly.github.io/redoc/) - For a clean documentation view
- VS Code with OpenAPI extensions
---
## Base URL
```
https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e
```
The base URL includes a tenant/agency identifier (`9814b106-2afe-47c8-919b-bdec6a5e521e`) specific to Skopje's transit system.
---
## Authentication
Currently, the API appears to be publicly accessible without authentication tokens. Requests can be made directly to the endpoints.
---
## Endpoints
### GTFS-RT Trip Updates
Get real-time trip updates in GTFS-Realtime format (Protocol Buffers).
**Endpoint:** `GET /transport/gtfsrt/tripupdates.pb`
**Full URL:**
```
https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/gtfsrt/tripupdates.pb
```
**Response Format:** Protocol Buffers (binary)
**Content-Type:** `application/octet-stream`
**Description:**
Returns a GTFS-Realtime FeedMessage containing TripUpdate entities. This follows the official [GTFS-Realtime specification](https://gtfs.org/realtime/).
**Response Structure (Protobuf):**
```protobuf
message FeedMessage {
FeedHeader header = 1;
repeated FeedEntity entity = 2;
}
message FeedEntity {
required string id = 1;
optional TripUpdate trip_update = 3;
}
message TripUpdate {
optional TripDescriptor trip = 1;
optional VehicleDescriptor vehicle = 3;
repeated StopTimeUpdate stop_time_update = 2;
}
```
**Example Usage (JavaScript):**
```javascript
import GtfsRealtimeBindings from 'gtfs-realtime-bindings';
const response = await fetch(
'https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/gtfsrt/tripupdates.pb'
);
const buffer = await response.arrayBuffer();
const feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(
new Uint8Array(buffer)
);
for (const entity of feed.entity) {
if (entity.tripUpdate) {
console.log('Trip:', entity.tripUpdate.trip.tripId);
console.log('Route:', entity.tripUpdate.trip.routeId);
console.log('Vehicle:', entity.tripUpdate.vehicle?.id);
}
}
```
**Key Fields:**
- `trip.tripId`: GTFS trip identifier
- `trip.routeId`: GTFS route identifier
- `vehicle.id`: Vehicle identification number
- `vehicle.label`: Public-facing vehicle number
- `stopTimeUpdate[]`: Array of stop time predictions
---
### Public Vehicles (JSON)
Get current positions and status of all active vehicles in JSON format.
**Endpoint:** `GET /transport/public/vehicles`
**Full URL:**
```
https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/public/vehicles
```
**Response Format:** JSON
**Description:**
Returns an array of all currently active vehicles with their real-time GPS positions, speed, bearing, and status information.
**Response Structure:**
```json
[
{
"id": 12345,
"identificationNumber": "MK-SK-1234",
"inventoryNumber": 1234,
"positionLatitude": 41.9981,
"positionLongitude": 21.4254,
"positionBearing": 87.5,
"positionSpeed": 15.3,
"positionModifiedAt": "2026-02-07T14:27:15Z",
"status": 2
},
{
"id": 12346,
"identificationNumber": "MK-SK-5678",
"inventoryNumber": 5678,
"positionLatitude": 42.0012,
"positionLongitude": 21.4301,
"positionBearing": 180.0,
"positionSpeed": 0.0,
"positionModifiedAt": "2026-02-07T14:27:10Z",
"status": 1
}
]
```
**Fields:**
| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Internal database identifier for the vehicle |
| `identificationNumber` | string | Vehicle license plate or identification (e.g., "MK-SK-1234") |
| `inventoryNumber` | integer | Public-facing bus number shown on the vehicle |
| `positionLatitude` | number | Current GPS latitude (WGS84) |
| `positionLongitude` | number | Current GPS longitude (WGS84) |
| `positionBearing` | number | Direction of travel in degrees (0-360, 0=North, 90=East) |
| `positionSpeed` | number | Current speed in km/h |
| `positionModifiedAt` | string | ISO 8601 timestamp of last position update |
| `status` | integer | Vehicle status code (1=stopped, 2=in transit) |
**Status Codes:**
- `1` - Vehicle is stopped
- `2` - Vehicle is in transit
**Example Usage:**
```bash
curl "https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/public/vehicles"
```
**Use Cases:**
- Display all vehicles on a map
- Match vehicles to routes using trip update data
- Track real-time vehicle movements
---
### Nearby Stop Times
Get arrival predictions for stops near a specific location.
**Endpoint:** `GET /transport/planner/stops/nearbyTimes`
**Full URL:**
```
https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/planner/stops/nearbyTimes
```
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `latitude` | number | Yes | Search center latitude (WGS84) |
| `longitude` | number | Yes | Search center longitude (WGS84) |
| `radius` | integer | Yes | Search radius in meters |
**Response Format:** JSON
**Description:**
Returns stops within the specified radius along with their upcoming arrivals, including real-time predictions.
**Response Structure:**
```json
[
{
"id": 1563,
"code": "363",
"name": "KARPOS II - STIV NAUMOV",
"latitude": 41.97964,
"longitude": 21.41283,
"patterns": [
{
"routeId": 125,
"routeShortName": "7",
"routeLongName": "Centar - GTC - Karpos 4 - Karposh 2",
"headsign": "Karpos 2",
"stopTimes": [
{
"serviceDay": 1738972800,
"scheduledArrival": 52380,
"realtimeArrival": 52500,
"arrivalDelay": 120,
"realtime": true,
"realtimeState": "UPDATED",
"headsign": "Karpos 2"
},
{
"serviceDay": 1738972800,
"scheduledArrival": 53280,
"realtimeArrival": 53280,
"arrivalDelay": 0,
"realtime": true,
"realtimeState": "SCHEDULED",
"headsign": "Karpos 2"
}
]
}
]
}
]
```
**Fields:**
**Stop Level:**
| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | GTFS stop ID |
| `code` | string | Public stop code |
| `name` | string | Stop name |
| `latitude` | number | Stop latitude |
| `longitude` | number | Stop longitude |
| `patterns[]` | array | Array of route patterns serving this stop |
**Pattern Level:**
| Field | Type | Description |
|-------|------|-------------|
| `routeId` | integer | GTFS route ID |
| `routeShortName` | string | Public route number (e.g., "7") |
| `routeLongName` | string | Full route name |
| `headsign` | string | Direction/destination |
| `stopTimes[]` | array | Array of upcoming arrivals |
**Stop Time Level:**
| Field | Type | Description |
|-------|------|-------------|
| `serviceDay` | integer | Unix timestamp (seconds) of service day start (midnight) |
| `scheduledArrival` | integer | Seconds since midnight for scheduled arrival time |
| `realtimeArrival` | integer | Seconds since midnight for predicted arrival time |
| `arrivalDelay` | integer | Delay in seconds (positive=late, negative=early) |
| `realtime` | boolean | Whether prediction is based on real-time data |
| `realtimeState` | string | State: `SCHEDULED`, `UPDATED`, `CANCELED` |
| `headsign` | string | Trip headsign (destination) |
**Calculating Arrival Time:**
```javascript
const arrival = new Date(
(stopTime.serviceDay + stopTime.realtimeArrival) * 1000
);
```
**Example Usage:**
```bash
# Get arrivals near American College
curl "https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/planner/stops/nearbyTimes?latitude=42.00437&longitude=21.39687&radius=50"
```
**Use Cases:**
- Display upcoming arrivals at a stop
- Show real-time delay information
- Calculate minutes until arrival
- Filter by specific routes
---
## Data Models
### GTFS Stop
```typescript
interface Stop {
id: number; // GTFS stop_id (as integer)
code: string; // Public stop code
name: string; // Stop name
latitude: number; // WGS84 latitude
longitude: number; // WGS84 longitude
patterns: Pattern[]; // Routes serving this stop
}
```
### Route Pattern
```typescript
interface Pattern {
routeId: number; // GTFS route_id (as integer)
routeShortName: string; // Public route number
routeLongName: string; // Full route name
headsign: string; // Direction/destination
stopTimes: StopTime[]; // Upcoming arrivals
}
```
### Stop Time
```typescript
interface StopTime {
serviceDay: number; // Unix timestamp of service day start
scheduledArrival: number; // Seconds since midnight (scheduled)
realtimeArrival: number; // Seconds since midnight (predicted)
arrivalDelay: number; // Delay in seconds
realtime: boolean; // Based on real-time data
realtimeState: string; // SCHEDULED, UPDATED, CANCELED
headsign: string; // Trip headsign
}
```
### Vehicle
```typescript
interface Vehicle {
id: number; // Internal ID
identificationNumber: string; // License plate
inventoryNumber: number; // Bus number
positionLatitude: number; // GPS latitude
positionLongitude: number; // GPS longitude
positionBearing: number; // Direction (0-360°)
positionSpeed: number; // Speed in km/h
positionModifiedAt: string; // ISO 8601 timestamp
status: number; // 1=stopped, 2=in_transit
}
```
---
## Usage Examples
### Complete Real-Time Tracker Flow
```javascript
// 1. Get a specific stop's arrivals
const stopLat = 41.97964;
const stopLon = 21.41283;
const radius = 50;
const nearbyResponse = await fetch(
`https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/planner/stops/nearbyTimes?latitude=${stopLat}&longitude=${stopLon}&radius=${radius}`
);
const stops = await nearbyResponse.json();
// 2. Process arrivals for a specific route
const targetRouteId = 125;
const now = new Date();
for (const stop of stops) {
for (const pattern of stop.patterns) {
if (pattern.routeId === targetRouteId) {
for (const stopTime of pattern.stopTimes) {
const arrivalTime = new Date(
(stopTime.serviceDay + stopTime.realtimeArrival) * 1000
);
const minutesUntil = Math.floor(
(arrivalTime.getTime() - now.getTime()) / 60000
);
console.log(`Route ${pattern.routeShortName} - ${minutesUntil} min`);
console.log(`Delay: ${stopTime.arrivalDelay} seconds`);
}
}
}
}
// 3. Get all vehicle positions
const vehiclesResponse = await fetch(
'https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/public/vehicles'
);
const vehicles = await vehiclesResponse.json();
// 4. Get trip updates to match vehicles to routes
const tripUpdatesResponse = await fetch(
'https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/gtfsrt/tripupdates.pb'
);
const buffer = await tripUpdatesResponse.arrayBuffer();
const feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(
new Uint8Array(buffer)
);
// 5. Build vehicle-to-route mapping
const vehicleRouteMap = new Map();
for (const entity of feed.entity) {
if (entity.tripUpdate?.vehicle?.id && entity.tripUpdate?.trip?.routeId) {
vehicleRouteMap.set(
entity.tripUpdate.vehicle.id,
entity.tripUpdate.trip.routeId
);
}
}
// 6. Filter vehicles by route
const routeVehicles = vehicles.filter(v => {
const vehicleKey = v.identificationNumber || v.inventoryNumber.toString();
return vehicleRouteMap.get(vehicleKey) == targetRouteId;
});
console.log(`Found ${routeVehicles.length} vehicles on route ${targetRouteId}`);
```
### Polling for Updates
```javascript
// Recommended polling intervals
const VEHICLE_UPDATE_INTERVAL = 10000; // 10 seconds
const ARRIVAL_UPDATE_INTERVAL = 15000; // 15 seconds
const TRIP_UPDATE_INTERVAL = 10000; // 10 seconds
// Poll for vehicle positions
setInterval(async () => {
const vehicles = await fetch(vehiclesEndpoint).then(r => r.json());
updateMap(vehicles);
}, VEHICLE_UPDATE_INTERVAL);
// Poll for arrival predictions
setInterval(async () => {
const arrivals = await fetch(nearbyTimesEndpoint).then(r => r.json());
updateArrivalBoard(arrivals);
}, ARRIVAL_UPDATE_INTERVAL);
```
---
## GTFS Static Data
The API uses GTFS static data which should be downloaded separately. Static data includes:
- **stops.txt**: All stop locations and names
- **routes.txt**: All route definitions
- **trips.txt**: Trip schedules
- **stop_times.txt**: Stop time schedules
- **calendar.txt**: Service calendar
- **shapes.txt**: Route shapes (geographic paths)
**Note:** The static GTFS feed download URL should be obtained from ModeShift or the transit agency.
---
## Rate Limiting & Best Practices
1. **Polling Frequency**:
- Don't poll more frequently than every 10 seconds
- Cache responses when appropriate
- Use conditional requests when possible
2. **Radius Parameter**:
- Use smallest radius that meets your needs (typically 50-100m)
- Larger radius = more data returned = slower response
3. **Error Handling**:
- Handle network errors gracefully
- Implement exponential backoff for failures
- Parse protobuf data carefully (can be empty)
4. **Data Volume**:
- Vehicle positions endpoint returns ALL vehicles (100+)
- Filter client-side rather than making multiple requests
- Consider caching vehicle positions
---
## Common Issues & Solutions
### Empty GTFS-RT Feed
**Problem:** `tripupdates.pb` returns empty or zero-length buffer
**Solution:** This can happen during off-peak hours or system maintenance. Handle gracefully and retry later.
### Vehicle Matching
**Problem:** Vehicles in JSON don't match trip updates
**Solution:** Try matching by both `identificationNumber` and `inventoryNumber`. Some vehicles may not have active trips.
### Time Zone Issues
**Problem:** Arrival times are incorrect
**Solution:** The API uses Europe/Skopje timezone. Service day timestamps are at midnight local time.
### Delay Calculation
**Problem:** Delays seem wrong
**Solution:** Use `realtimeArrival - scheduledArrival` (already provided as `arrivalDelay` field).
---
## Related Documentation
- **GTFS Specification**: https://gtfs.org/
- **GTFS-Realtime**: https://gtfs.org/realtime/
- **Protocol Buffers**: https://protobuf.dev/
- **gtfs-realtime-bindings**: https://www.npmjs.com/package/gtfs-realtime-bindings
---
## Support
For issues with the ModeShift API:
- Contact ModeShift support
- Check ModeShift documentation at https://www.modeshift.app/
For issues with this tracker application:
- See [README.md](README.md)
- Check [API-DOCUMENTATION.md](API-DOCUMENTATION.md) for the local API
---
**Last Updated:** February 7, 2026
**API Provider:** ModeShift
**Coverage:** Skopje, North Macedonia

270
bun.lock
View File

@@ -5,16 +5,16 @@
"": { "": {
"name": "skopje-bus-api-client", "name": "skopje-bus-api-client",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
"express": "^4.18.2", "express": "^4.18.2",
"pg": "^8.18.0", "hyparquet": "^1.25.1",
"hyparquet-writer": "^0.13.0",
"protobufjs": "^7.5.4", "protobufjs": "^7.5.4",
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"@types/pg": "^8.11.0",
"protobufjs-cli": "^2.0.0", "protobufjs-cli": "^2.0.0",
"ts-node": "^10.9.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
}, },
"optionalDependencies": { "optionalDependencies": {
@@ -23,6 +23,86 @@
}, },
}, },
"packages": { "packages": {
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1008.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/credential-provider-node": "^3.972.20", "@aws-sdk/middleware-bucket-endpoint": "^3.972.7", "@aws-sdk/middleware-expect-continue": "^3.972.7", "@aws-sdk/middleware-flexible-checksums": "^3.973.5", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-location-constraint": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-sdk-s3": "^3.972.19", "@aws-sdk/middleware-ssec": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/signature-v4-multi-region": "^3.996.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.6", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/eventstream-serde-browser": "^4.2.11", "@smithy/eventstream-serde-config-resolver": "^4.3.11", "@smithy/eventstream-serde-node": "^4.2.11", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-blob-browser": "^4.2.12", "@smithy/hash-node": "^4.2.11", "@smithy/hash-stream-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/md5-js": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-retry": "^4.4.40", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.39", "@smithy/util-defaults-mode-node": "^4.2.42", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-stream": "^4.5.17", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-w/SIRD25v2zVMbkn8CYIxUsac8yf5Jghkhw5j7EsNWdJhl56m/nWpUX7t1etFUW1cnzpFjZV0lXt0dNFSnbXwA=="],
"@aws-sdk/core": ["@aws-sdk/core@3.973.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ=="],
"@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.972.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-HKZIZLbRyvzo/bXZU7Zmk6XqU+1C9DjI56xd02vwuDIxedxBEqP17t9ExhbP9QFeNq/a3l9GOcyirFXxmbDhmw=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-MBAMW6YELzE1SdkOniqr51mrjapQUv8JXSGxtwRjQV0mwVDutVsn22OPAUt4RcLRvdiHQmNBDEFP9iTeSVCOlA=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.19", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/node-http-handler": "^4.4.14", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.17", "tslib": "^2.6.2" } }, "sha512-9EJROO8LXll5a7eUFqu48k6BChrtokbmgeMWmsH7lBb6lVbtjslUYz/ShLi+SHkYzTomiGBhmzTW7y+H4BxsnA=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.19", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/credential-provider-env": "^3.972.17", "@aws-sdk/credential-provider-http": "^3.972.19", "@aws-sdk/credential-provider-login": "^3.972.19", "@aws-sdk/credential-provider-process": "^3.972.17", "@aws-sdk/credential-provider-sso": "^3.972.19", "@aws-sdk/credential-provider-web-identity": "^3.972.19", "@aws-sdk/nested-clients": "^3.996.9", "@aws-sdk/types": "^3.973.5", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-pVJVjWqVrPqjpFq7o0mCmeZu1Y0c94OCHSYgivdCD2wfmYVtBbwQErakruhgOD8pcMcx9SCqRw1pzHKR7OGBcA=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.19", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.9", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-jOXdZ1o+CywQKr6gyxgxuUmnGwTTnY2Kxs1PM7fI6AYtDWDnmW/yKXayNqkF8KjP1unflqMWKVbVt5VgmE3L0g=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.20", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.17", "@aws-sdk/credential-provider-http": "^3.972.19", "@aws-sdk/credential-provider-ini": "^3.972.19", "@aws-sdk/credential-provider-process": "^3.972.17", "@aws-sdk/credential-provider-sso": "^3.972.19", "@aws-sdk/credential-provider-web-identity": "^3.972.19", "@aws-sdk/types": "^3.973.5", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-0xHca2BnPY0kzjDYPH7vk8YbfdBPpWVS67rtqQMalYDQUCBYS37cZ55K6TuFxCoIyNZgSCFrVKr9PXC5BVvQQw=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-c8G8wT1axpJDgaP3xzcy+q8Y1fTi9A2eIQJvyhQ9xuXrUZhlCfXbC0vM9bM1CUXiZppFQ1p7g0tuUMvil/gCPg=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.19", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.9", "@aws-sdk/token-providers": "3.1008.0", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-kVjQsEU3b///q7EZGrUzol9wzwJFKbEzqJKSq82A9ShrUTEO7FNylTtby3sPV19ndADZh1H3FB3+5ZrvKtEEeg=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.19", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.9", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-BV1BlTFdG4w4tAihxN7iXDBoNcNewXD4q8uZlNQiUrnqxwGWUhKHODIQVSPlQGxXClEj+63m+cqZskw+ESmeZg=="],
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.11", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-goX+axlJ6PQlRnzE2bQisZ8wVrlm6dXJfBzMJhd8LhAIBan/w1Kl73fJnalM/S+18VnpzIHumyV6DtgmvqG5IA=="],
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-mvWqvm61bmZUKmmrtl2uWbokqpenY3Mc3Jf4nXB/Hse6gWxLPaCQThmhPBDzsPSV8/Odn8V6ovWt3pZ7vy4BFQ=="],
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.973.5", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/crc64-nvme": "^3.972.4", "@aws-sdk/types": "^3.973.5", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.11", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-middleware": "^4.2.11", "@smithy/util-stream": "^4.5.17", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Dp3hqE5W6hG8HQ3Uh+AINx9wjjqYmFHbxede54sGj3akx/haIQrkp85lNdTdC+ouNUcSYNiuGkzmyDREfHX1Gg=="],
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ=="],
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-vdK1LJfffBp87Lj0Bw3WdK1rJk9OLDYdQpqoKgmpIZPe+4+HawZ6THTbvjhJt4C4MNnRrHTKHQjkwBiIpDBoig=="],
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w=="],
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.19", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-stream": "^4.5.17", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-/CtOHHVFg4ZuN6CnLnYkrqWgVEnbOBC4kNiKa+4fldJ9cioDt3dD/f5vpq0cWLOXwmGL2zgVrVxNhjxWpxNMkg=="],
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-G9clGVuAml7d8DYzY6DnRi7TIIDRvZ3YpqJPz/8wnWS5fYx/FNWNmkO6iJVlVkQg9BfeMzd+bVPtPJOvC4B+nQ=="],
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.20", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@smithy/core": "^3.23.9", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-retry": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.9", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.6", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-retry": "^4.4.40", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.39", "@smithy/util-defaults-mode-node": "^4.2.42", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-+RpVtpmQbbtzFOKhMlsRcXM/3f1Z49qTOHaA8gEpHOYruERmog6f2AUtf/oTRLCWjR9H2b3roqryV/hI7QMW8w=="],
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.7", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.19", "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-mYhh7FY+7OOqjkYkd6+6GgJOsXK1xBWmuR+c5mxJPj2kr5TBNeZq+nUvE9kANWAux5UxDVrNOSiEM/wlHzC3Lg=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1008.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.9", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-TulwlHQBWcJs668kNUDMZHN51DeLrDsYT59Ux4a/nbvr025gM6HjKJJ3LvnZccam7OS/ZKUVkWomCneRQKJbBg=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="],
"@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.972.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA=="],
"@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.4", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" } }, "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="],
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw=="],
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.6", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/types": "^3.973.5", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-iF7G0prk7AvmOK64FcLvc/fW+Ty1H+vttajL7PvJFReU8urMxfYmynTTuFKDTA76Wgpq3FzTPKwabMQIXQHiXQ=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
@@ -31,14 +111,6 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@jsdoc/salty": ["@jsdoc/salty@0.2.9", "", { "dependencies": { "lodash": "^4.17.21" } }, "sha512-yYxMVH7Dqw6nO0d5NIV8OQWnitU8k6vXH8NtgqAfIa/IUqRMxRv/NUJJ08VEKbAakwxlgBl5PJdrU0dMPStsnw=="], "@jsdoc/salty": ["@jsdoc/salty@0.2.9", "", { "dependencies": { "lodash": "^4.17.21" } }, "sha512-yYxMVH7Dqw6nO0d5NIV8OQWnitU8k6vXH8NtgqAfIa/IUqRMxRv/NUJJ08VEKbAakwxlgBl5PJdrU0dMPStsnw=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
@@ -61,13 +133,107 @@
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="], "@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="],
"@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="], "@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="],
"@tsconfig/node14": ["@tsconfig/node14@1.0.3", "", {}, "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow=="], "@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.3", "", { "dependencies": { "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw=="],
"@tsconfig/node16": ["@tsconfig/node16@1.0.4", "", {}, "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA=="], "@smithy/config-resolver": ["@smithy/config-resolver@4.4.11", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw=="],
"@smithy/core": ["@smithy/core@3.23.11", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.19", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-952rGf7hBRnhUIaeLp6q4MptKW8sPFe5VvkoZ5qIzFAtx6c/QZ/54FS3yootsyUSf9gJX/NBqEBNdNR7jMIlpQ=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg=="],
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
"@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A=="],
"@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q=="],
"@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.12", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA=="],
"@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.12", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ=="],
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.15", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A=="],
"@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.13", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g=="],
"@smithy/hash-node": ["@smithy/hash-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w=="],
"@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw=="],
"@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="],
"@smithy/md5-js": ["@smithy/md5-js@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ=="],
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.25", "", { "dependencies": { "@smithy/core": "^3.23.11", "@smithy/middleware-serde": "^4.2.14", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-dqjLwZs2eBxIUG6Qtw8/YZ4DvzHGIf0DA18wrgtfP6a50UIO7e2nY0FPdcbv5tVJKqWCCU5BmGMOUwT7Puan+A=="],
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.42", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/service-error-classification": "^4.2.12", "@smithy/smithy-client": "^4.12.5", "@smithy/types": "^4.13.1", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.12", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-vbwyqHRIpIZutNXZpLAozakzamcINaRCpEy1MYmK6xBeW3xN+TyPRA123GjXnuxZIjc9848MRRCugVMTXxC4Eg=="],
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.14", "", { "dependencies": { "@smithy/core": "^3.23.11", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-+CcaLoLa5apzSRtloOyG7lQvkUw2ZDml3hRh4QiG9WyEPfW5Ke/3tPOPiPjUneuT59Tpn8+c3RVaUvvkkwqZwg=="],
"@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw=="],
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.12", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw=="],
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.16", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ULC8UCS/HivdCB3jhi+kLFYe4B5gxH2gi9vHBfEIiRrT2jfKiZNiETJSlzRtE6B26XbBHjPtc8iZKSNqMol9bw=="],
"@smithy/property-provider": ["@smithy/property-provider@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A=="],
"@smithy/protocol-http": ["@smithy/protocol-http@5.3.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw=="],
"@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg=="],
"@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw=="],
"@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1" } }, "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ=="],
"@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.7", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw=="],
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.12", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.12.5", "", { "dependencies": { "@smithy/core": "^3.23.11", "@smithy/middleware-endpoint": "^4.4.25", "@smithy/middleware-stack": "^4.2.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-stream": "^4.5.19", "tslib": "^2.6.2" } }, "sha512-UqwYawyqSr/aog8mnLnfbPurS0gi4G7IYDcD28cUIBhsvWs1+rQcL2IwkUQ+QZ7dibaoRzhNF99fAQ9AUcO00w=="],
"@smithy/types": ["@smithy/types@4.13.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g=="],
"@smithy/url-parser": ["@smithy/url-parser@4.2.12", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA=="],
"@smithy/util-base64": ["@smithy/util-base64@4.3.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="],
"@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="],
"@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="],
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.41", "", { "dependencies": { "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.5", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-M1w1Ux0rSVvBOxIIiqbxvZvhnjQ+VUjJrugtORE90BbadSTH+jsQL279KRL3Hv0w69rE7EuYkV/4Lepz/NBW9g=="],
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.44", "", { "dependencies": { "@smithy/config-resolver": "^4.4.11", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/smithy-client": "^4.12.5", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YPze3/lD1KmWuZsl9JlfhcgGLX7AXhSoaCDtiPntUjNW5/YY0lOHjkcgxyE9x/h5vvS1fzDifMGjzqnNlNiqOQ=="],
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig=="],
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="],
"@smithy/util-middleware": ["@smithy/util-middleware@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ=="],
"@smithy/util-retry": ["@smithy/util-retry@4.2.12", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ=="],
"@smithy/util-stream": ["@smithy/util-stream@4.5.19", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.4.16", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-v4sa+3xTweL1CLO2UP0p7tvIMH/Rq1X4KKOxd568mpe6LSLMQCnDHs4uv7m3ukpl3HvcN2JH6jiCS0SNRXKP/w=="],
"@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="],
"@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@smithy/util-waiter": ["@smithy/util-waiter@4.2.13", "", { "dependencies": { "@smithy/abort-controller": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ=="],
"@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="],
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
@@ -89,8 +255,6 @@
"@types/node": ["@types/node@20.19.32", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA=="], "@types/node": ["@types/node@20.19.32", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Ez8QE4DMfhjjTsES9K2dwfV258qBui7qxUsoaixZDiTzbde4U12e1pXGNu/ECsUIOi5/zoCxAQxIhQnaUQ2VvA=="],
"@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="],
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
@@ -105,12 +269,8 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
@@ -121,6 +281,8 @@
"body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="], "body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="],
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
@@ -145,8 +307,6 @@
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
"create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@@ -155,8 +315,6 @@
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
"diff": ["diff@4.0.4", "", {}, "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@@ -193,6 +351,10 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-xml-builder": ["fast-xml-builder@1.1.2", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-NJAmiuVaJEjVa7TjLZKlYd7RqmzOC91EtPFXHvlTcqBVo50Qh7XV5IwvXi1c7NRz2Q/majGX9YLcwJtWgHjtkA=="],
"fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="],
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], "finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
@@ -223,6 +385,10 @@
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"hyparquet": ["hyparquet@1.25.1", "", {}, "sha512-CXcN/u6RdQqsK8IphUptpAEqY8IzgwzHY+MuXX+2wpoWTumfxPVr6JYbbywsNsiAl9aEbM5sRtxkwRBa22b49w=="],
"hyparquet-writer": ["hyparquet-writer@0.13.0", "", { "dependencies": { "hyparquet": "1.25.1" } }, "sha512-4Vl2RxwQPjMhCOZ2mCNK/WY3lJiiy+NlUugdsqBPTTy9sw3Uu7sJPU3UMiy7+aRAbY+y2K5dRJuNGTmQ8PdXFQ=="],
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
@@ -245,8 +411,6 @@
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"make-error": ["make-error@1.3.6", "", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="],
"markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": "bin/markdown-it.mjs" }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="], "markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": "bin/markdown-it.mjs" }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="],
"markdown-it-anchor": ["markdown-it-anchor@8.6.7", "", { "peerDependencies": { "@types/markdown-it": "*", "markdown-it": "*" } }, "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA=="], "markdown-it-anchor": ["markdown-it-anchor@8.6.7", "", { "peerDependencies": { "@types/markdown-it": "*", "markdown-it": "*" } }, "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA=="],
@@ -289,32 +453,10 @@
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-expression-matcher": ["path-expression-matcher@1.1.3", "", {}, "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ=="],
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
"pg": ["pg@8.18.0", "", { "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ=="],
"pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="],
"pg-connection-string": ["pg-connection-string@2.11.0", "", {}, "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-pool": ["pg-pool@3.11.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w=="],
"pg-protocol": ["pg-protocol@1.11.0", "", {}, "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g=="],
"pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
"pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="],
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="],
"postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="],
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"prelude-ls": ["prelude-ls@1.1.2", "", {}, "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w=="], "prelude-ls": ["prelude-ls@1.1.2", "", {}, "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w=="],
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
@@ -355,19 +497,19 @@
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"strnum": ["strnum@2.2.0", "", {}, "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"ts-node": ["ts-node@10.9.2", "", { "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", "@tsconfig/node16": "^1.0.2", "acorn": "^8.4.1", "acorn-walk": "^8.1.1", "arg": "^4.1.0", "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", "v8-compile-cache-lib": "^3.0.1", "yn": "3.1.1" }, "peerDependencies": { "@swc/core": ">=1.2.50", "@swc/wasm": ">=1.2.50", "@types/node": "*", "typescript": ">=2.7" }, "optionalPeers": ["@swc/core", "@swc/wasm"], "bin": { "ts-node": "dist/bin.js", "ts-script": "dist/bin-script-deprecated.js", "ts-node-cwd": "dist/bin-cwd.js", "ts-node-esm": "dist/bin-esm.js", "ts-node-script": "dist/bin-script.js", "ts-node-transpile-only": "dist/bin-transpile.js" } }, "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.3.2", "", { "dependencies": { "prelude-ls": "~1.1.2" } }, "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg=="], "type-check": ["type-check@0.3.2", "", { "dependencies": { "prelude-ls": "~1.1.2" } }, "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg=="],
@@ -387,8 +529,6 @@
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"v8-compile-cache-lib": ["v8-compile-cache-lib@3.0.1", "", {}, "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
@@ -397,9 +537,11 @@
"xmlcreate": ["xmlcreate@2.0.4", "", {}, "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg=="], "xmlcreate": ["xmlcreate@2.0.4", "", {}, "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="], "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="], "@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
@@ -408,5 +550,17 @@
"gtfs-realtime-bindings/protobufjs-cli": ["protobufjs-cli@1.2.0", "", { "dependencies": { "chalk": "^4.0.0", "escodegen": "^1.13.0", "espree": "^9.0.0", "estraverse": "^5.1.0", "glob": "^8.0.0", "jsdoc": "^4.0.0", "minimist": "^1.2.0", "semver": "^7.1.2", "tmp": "^0.2.1", "uglify-js": "^3.7.7" }, "peerDependencies": { "protobufjs": "^7.0.0" }, "bin": { "pbjs": "bin/pbjs", "pbts": "bin/pbts" } }, "sha512-+YvqJEmsmZHGzE5j0tvEzFeHm0sX7pzRFpyj7+GazhkS4Y0r+jgbioVvFxxSWIlPzUel/lxeOnLChBmV8NmyHA=="], "gtfs-realtime-bindings/protobufjs-cli": ["protobufjs-cli@1.2.0", "", { "dependencies": { "chalk": "^4.0.0", "escodegen": "^1.13.0", "espree": "^9.0.0", "estraverse": "^5.1.0", "glob": "^8.0.0", "jsdoc": "^4.0.0", "minimist": "^1.2.0", "semver": "^7.1.2", "tmp": "^0.2.1", "uglify-js": "^3.7.7" }, "peerDependencies": { "protobufjs": "^7.0.0" }, "bin": { "pbjs": "bin/pbjs", "pbts": "bin/pbts" } }, "sha512-+YvqJEmsmZHGzE5j0tvEzFeHm0sX7pzRFpyj7+GazhkS4Y0r+jgbioVvFxxSWIlPzUel/lxeOnLChBmV8NmyHA=="],
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
} }
} }

View File

@@ -1,347 +0,0 @@
import { loadGtfsStops, loadGtfsRoutes, GtfsStop, GtfsRoute } from './lib/gtfs';
import { config } from './config';
// ============================================================================
// CLI Arguments
// ============================================================================
function parseArgs() {
const args = process.argv.slice(2);
let stopId = config.defaultStop.stopId;
let routeId = config.defaultRoute.routeId;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--stop' && args[i + 1]) {
stopId = args[i + 1];
i++;
} else if (args[i] === '--route' && args[i + 1]) {
routeId = args[i + 1];
i++;
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
Usage: npm run tracker [options]
Options:
--stop <stopId> Stop ID to track (default: ${config.defaultStop.stopId})
--route <routeId> Route ID to track (default: ${config.defaultRoute.routeId})
--help, -h Show this help message
Examples:
npm run tracker
npm run tracker -- --stop 1571 --route 125
`);
process.exit(0);
}
}
return { stopId, routeId };
}
const { stopId: TARGET_STOP_ID, routeId: TARGET_ROUTE_ID } = parseArgs();
// ============================================================================
// Configuration
// ============================================================================
// ============================================================================
// Additional Types
// ============================================================================
interface StopTime {
scheduledArrival: number;
scheduledDeparture: number;
realtimeArrival: number;
realtimeDeparture: number;
arrivalDelay: number;
departureDelay: number;
timepoint: boolean;
realtime: boolean;
realtimeState: string;
serviceDay: number;
headsign: string;
}
interface Pattern {
routeId: number;
index: number;
stopTimes: StopTime[];
}
interface StopArrivalData {
id: number;
patterns: Pattern[];
distance: number;
}
interface BusArrival {
tripId: string;
routeName: string;
stopName: string;
headsign: string;
arrivalTime: Date;
scheduledTime: Date;
delaySeconds: number;
minutesUntilArrival: number;
isApproaching: boolean;
realtimeState: string;
isRealtime: boolean;
}
// ============================================================================
// Real-time Data Fetching
// ============================================================================
async function getNextBuses(
stops: Map<string, GtfsStop>,
routes: Map<string, GtfsRoute>
): Promise<BusArrival[]> {
const targetStop = stops.get(TARGET_STOP_ID);
if (!targetStop) {
throw new Error(`Stop ${TARGET_STOP_ID} not found in GTFS data`);
}
// Fetch nearby arrivals using JSON API
const radius = 50; // 50 meters
const nearbyUrl = `${config.baseUrl}/transport/planner/stops/nearbyTimes?latitude=${targetStop.stop_lat}&longitude=${targetStop.stop_lon}&radius=${radius}`;
const response = await fetch(nearbyUrl);
const nearbyData = await response.json() as StopArrivalData[];
const now = new Date();
const maxTime = new Date(now.getTime() + config.tracking.minutesAhead * 60000);
const arrivals: BusArrival[] = [];
// Get route info
const targetRoute = routes.get(TARGET_ROUTE_ID);
// Process the nearby data
for (const stopData of nearbyData) {
if (stopData.id.toString() !== TARGET_STOP_ID) continue;
for (const pattern of stopData.patterns) {
// Filter by target route
if (pattern.routeId.toString() !== TARGET_ROUTE_ID) continue;
const routeInfo = routes.get(pattern.routeId.toString());
if (!routeInfo) continue;
for (const stopTime of pattern.stopTimes) {
// Convert service day + seconds to actual timestamp
const serviceDay = new Date(stopTime.serviceDay * 1000);
const arrivalTime = new Date(serviceDay.getTime() + stopTime.realtimeArrival * 1000);
const scheduledTime = new Date(serviceDay.getTime() + stopTime.scheduledArrival * 1000);
// Only include buses arriving within our time window
if (arrivalTime > maxTime || arrivalTime < now) continue;
const minutesUntil = Math.floor((arrivalTime.getTime() - now.getTime()) / 60000);
arrivals.push({
tripId: `${pattern.routeId}.${pattern.index}`,
routeName: `${routeInfo.route_short_name} - ${routeInfo.route_long_name}`,
stopName: targetStop.stop_name,
headsign: stopTime.headsign,
arrivalTime: arrivalTime,
scheduledTime: scheduledTime,
delaySeconds: stopTime.arrivalDelay,
minutesUntilArrival: minutesUntil,
isApproaching: minutesUntil <= 5 && minutesUntil >= 0,
realtimeState: stopTime.realtimeState,
isRealtime: stopTime.realtime,
});
}
}
}
// Sort by arrival time
arrivals.sort((a, b) => a.arrivalTime.getTime() - b.arrivalTime.getTime());
return arrivals;
}
// ============================================================================
// Display Functions
// ============================================================================
function formatArrival(arrival: BusArrival, index: number): string {
const delayMin = Math.floor(arrival.delaySeconds / 60);
let delayText = "";
if (delayMin > 0) {
delayText = ` [${delayMin}min LATE]`;
} else if (delayMin < -3) {
delayText = ` [${Math.abs(delayMin)}min EARLY]`;
} else {
delayText = " [ON TIME]";
}
const timeStr = arrival.arrivalTime.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
let status = "";
if (arrival.minutesUntilArrival <= 0) {
status = " >> ARRIVING NOW!";
} else if (arrival.isApproaching) {
status = " >> APPROACHING";
}
const minutesText = arrival.minutesUntilArrival === 0
? "NOW"
: `${arrival.minutesUntilArrival} min`;
const realtimeIndicator = arrival.isRealtime ? "[LIVE]" : "[SCHED]";
return ` ${index + 1}. ${timeStr} (in ${minutesText}) ${delayText} ${realtimeIndicator}${status}`;
}
async function displayBusSchedule(
stops: Map<string, GtfsStop>,
routes: Map<string, GtfsRoute>
) {
console.clear();
const targetStop = stops.get(TARGET_STOP_ID);
const targetRoute = routes.get(TARGET_ROUTE_ID);
console.log("=".repeat(75));
console.log(` BUS TRACKER - SKOPJE PUBLIC TRANSPORT`);
console.log("=".repeat(75));
console.log(` Route: ${targetRoute?.route_short_name} - ${targetRoute?.route_long_name}`);
console.log(` Stop: ${targetStop?.stop_name} (Code: ${targetStop?.stop_code})`);
console.log(` Location: ${targetStop?.stop_lat.toFixed(5)}, ${targetStop?.stop_lon.toFixed(5)}`);
console.log("=".repeat(75));
console.log(` Updated: ${new Date().toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})}\n`);
try {
const arrivals = await getNextBuses(stops, routes);
if (arrivals.length === 0) {
console.log(` No buses scheduled in the next ${config.tracking.minutesAhead} minutes.\n`);
console.log(" This could mean:");
console.log(" - No buses are currently running on this route");
console.log(" - The next bus is more than 90 minutes away");
console.log(" - Service has ended for the day\n");
return;
}
console.log(` Found ${arrivals.length} upcoming bus${arrivals.length > 1 ? 'es' : ''}:\n`);
// Show all buses (or first 10 if too many)
const showCount = Math.min(arrivals.length, 10);
arrivals.slice(0, showCount).forEach((arrival, index) => {
console.log(formatArrival(arrival, index));
if (arrival.headsign && index < 5) {
console.log(` Direction: ${arrival.headsign}`);
}
});
if (arrivals.length > showCount) {
console.log(`\n ... and ${arrivals.length - showCount} more later`);
}
// Highlight the next bus
const nextBus = arrivals[0];
if (nextBus) {
console.log("\n" + "-".repeat(75));
console.log(" NEXT BUS:");
console.log("-".repeat(75));
if (nextBus.minutesUntilArrival <= 0) {
console.log(" >> BUS IS ARRIVING NOW! HEAD TO THE STOP! <<");
} else if (nextBus.minutesUntilArrival <= 2) {
console.log(` >> Bus arriving in ${nextBus.minutesUntilArrival} minute${nextBus.minutesUntilArrival > 1 ? 's' : ''}! Run! <<`);
} else if (nextBus.minutesUntilArrival <= 5) {
console.log(` >> Bus arriving in ${nextBus.minutesUntilArrival} minutes - Time to head to the stop <<`);
} else if (nextBus.minutesUntilArrival <= 15) {
console.log(` >> ${nextBus.minutesUntilArrival} minutes - Start getting ready <<`);
} else {
console.log(` >> ${nextBus.minutesUntilArrival} minutes - You have time to relax <<`);
}
console.log(` Direction: ${nextBus.headsign}`);
console.log(` Data source: ${nextBus.isRealtime ? 'Real-time tracking' : 'Scheduled times'}`);
const delayMin = Math.floor(nextBus.delaySeconds / 60);
if (delayMin > 5) {
console.log(` WARNING: Bus is running ${delayMin} minutes LATE`);
} else if (delayMin < -3) {
console.log(` NOTE: Bus is ${Math.abs(delayMin)} minutes EARLY`);
}
}
} catch (error) {
console.log(`\n ERROR: ${error}\n`);
console.log(" Please check:");
console.log(" - Your internet connection");
console.log(" - The API is accessible");
console.log(" - The GTFS files are present in ./gtfs/ directory\n");
}
console.log("\n" + "=".repeat(75));
console.log(` Auto-refresh: Every ${config.tracking.refreshInterval.terminal / 1000} seconds`);
console.log(" Press Ctrl+C to stop");
console.log("=".repeat(75));
}
// ============================================================================
// Main Program
// ============================================================================
async function startMonitoring() {
console.log("Loading GTFS data...\n");
try {
const stops = loadGtfsStops();
const routes = loadGtfsRoutes();
console.log(`Loaded ${stops.size} stops`);
console.log(`Loaded ${routes.size} routes\n`);
// Verify target stop and route exist
const targetStop = stops.get(TARGET_STOP_ID);
const targetRoute = routes.get(TARGET_ROUTE_ID);
if (!targetStop) {
console.error(`ERROR: Stop ${TARGET_STOP_ID} not found in GTFS data`);
process.exit(1);
}
if (!targetRoute) {
console.error(`ERROR: Route ${TARGET_ROUTE_ID} not found in GTFS data`);
process.exit(1);
}
console.log("Configuration validated");
console.log("Starting bus monitor...\n");
// Initial display
await displayBusSchedule(stops, routes);
// Set up periodic refresh
setInterval(async () => {
await displayBusSchedule(stops, routes);
}, config.tracking.refreshInterval.terminal);
} catch (error) {
console.error(`Failed to start: ${error}`);
console.error("\nPlease ensure:");
console.error(" 1. GTFS files exist in ./gtfs/ directory");
console.error(" 2. Files include: stops.txt and routes.txt");
console.error(" 3. Node.js has permission to read the files");
process.exit(1);
}
}
// Start the application
startMonitoring();

View File

@@ -1,110 +0,0 @@
#!/usr/bin/env ts-node
/**
* Helper script to find Stop IDs and Route IDs
* Usage:
* npm run find -- --stop "american"
* npm run find -- --route "7"
*/
import { loadGtfsStops, loadGtfsRoutes } from './lib/gtfs';
function parseArgs() {
const args = process.argv.slice(2);
let searchStop = '';
let searchRoute = '';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--stop' && args[i + 1]) {
searchStop = args[i + 1].toLowerCase();
i++;
} else if (args[i] === '--route' && args[i + 1]) {
searchRoute = args[i + 1].toLowerCase();
i++;
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
Usage: npm run find -- [options]
Options:
--stop <keyword> Search for stops by name (case-insensitive)
--route <keyword> Search for routes by name or number
--help, -h Show this help message
Examples:
npm run find -- --stop "american"
npm run find -- --route "7"
npm run find -- --stop "center"
npm run find -- --route "linija"
`);
process.exit(0);
}
}
return { searchStop, searchRoute };
}
async function main() {
const { searchStop, searchRoute } = parseArgs();
if (!searchStop && !searchRoute) {
console.log('Please specify --stop or --route. Use --help for usage information.');
process.exit(1);
}
console.log('Loading GTFS data...\n');
const stops = loadGtfsStops();
const routes = loadGtfsRoutes();
if (searchStop) {
console.log(`=== Searching for stops matching "${searchStop}" ===\n`);
const matches = Array.from(stops.values())
.filter(stop => stop.stop_name.toLowerCase().includes(searchStop))
.slice(0, 20); // Limit to 20 results
if (matches.length === 0) {
console.log('No stops found.');
} else {
console.log(`Found ${matches.length} stop(s):\n`);
matches.forEach(stop => {
console.log(`Stop ID: ${stop.stop_id}`);
console.log(` Name: ${stop.stop_name}`);
console.log(` Code: ${stop.stop_code}`);
console.log(` Location: ${stop.stop_lat}, ${stop.stop_lon}`);
console.log('');
});
if (matches.length === 20) {
console.log('(Showing first 20 results, refine your search for more specific results)\n');
}
}
}
if (searchRoute) {
console.log(`=== Searching for routes matching "${searchRoute}" ===\n`);
const matches = Array.from(routes.values())
.filter(route =>
route.route_short_name?.toLowerCase().includes(searchRoute) ||
route.route_long_name?.toLowerCase().includes(searchRoute)
)
.slice(0, 20); // Limit to 20 results
if (matches.length === 0) {
console.log('No routes found.');
} else {
console.log(`Found ${matches.length} route(s):\n`);
matches.forEach(route => {
console.log(`Route ID: ${route.route_id}`);
console.log(` Number: ${route.route_short_name}`);
console.log(` Name: ${route.route_long_name}`);
console.log('');
});
if (matches.length === 20) {
console.log('(Showing first 20 results, refine your search for more specific results)\n');
}
}
}
console.log('Use these IDs in the web interface or terminal tracker.');
}
main();

View File

@@ -1,18 +1,15 @@
#!/usr/bin/env bun
/**
* Background tracker for popular bus routes in Skopje
* Continuously monitors GTFS-RT feeds and stores data to TimescaleDB
*/
import GtfsRealtimeBindings from 'gtfs-realtime-bindings'; import GtfsRealtimeBindings from 'gtfs-realtime-bindings';
import { config } from './config'; import { config } from './config';
import { GtfsRoute, GtfsStop, loadGtfsRoutes, loadGtfsStops } from './lib/gtfs'; import { GtfsRoute, GtfsStop, loadGtfsRoutes, loadGtfsStops } from './lib/gtfs';
import { import {
initDatabase, initStorage,
logVehiclePositions, logVehiclePositions,
logVehicleFeedSnapshot,
logArrival, logArrival,
closeStorage,
VehicleFeedSnapshot,
VehiclePosition VehiclePosition
} from './lib/database'; } from './lib/storage';
// Popular routes to track // Popular routes to track
const TRACKED_ROUTES = [ const TRACKED_ROUTES = [
@@ -29,8 +26,6 @@ const TRACKED_ROUTES = [
{ id: '204', name: 'Route 12 П' }, { id: '204', name: 'Route 12 П' },
]; ];
// Key stops to monitor for arrival predictions and delay tracking
// These are the busiest transit points in Skopje
const MONITORED_STOPS = [ const MONITORED_STOPS = [
// Central area and major transit hubs // Central area and major transit hubs
'1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15',
@@ -50,15 +45,23 @@ const MONITORED_STOPS = [
const REFRESH_INTERVAL = 30000; // 30 seconds const REFRESH_INTERVAL = 30000; // 30 seconds
const ARRIVAL_STOP_CAP = 150; // Max stops to query per cycle const ARRIVAL_STOP_CAP = 150; // Max stops to query per cycle
const SAVE_ALL_VEHICLE_SNAPSHOTS = (process.env.SAVE_ALL_VEHICLE_SNAPSHOTS ?? 'true').toLowerCase() === 'true';
const SAVE_ALL_VEHICLE_POSITIONS = (process.env.SAVE_ALL_VEHICLE_POSITIONS ?? 'true').toLowerCase() === 'true';
const VERBOSE_TRACKER_LOGS = (process.env.VERBOSE_TRACKER_LOGS ?? 'false').toLowerCase() === 'true';
let stats = { let stats = {
cycles: 0, cycles: 0,
lastUpdate: new Date(), lastUpdate: new Date(),
vehiclesTracked: 0, vehiclesTracked: 0,
vehicleSnapshots: 0,
arrivalsLogged: 0, arrivalsLogged: 0,
errors: 0, errors: 0,
}; };
function toOptionalNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
// Load GTFS stops data // Load GTFS stops data
const stops = loadGtfsStops(); const stops = loadGtfsStops();
const stopsByCode = new Map<string, GtfsStop>(); const stopsByCode = new Map<string, GtfsStop>();
@@ -101,7 +104,29 @@ async function trackVehicles() {
} }
const allVehicles = await vehiclesResponse.json() as any[]; const allVehicles = await vehiclesResponse.json() as any[];
if (VERBOSE_TRACKER_LOGS) {
console.log(` Found ${allVehicles.length} total vehicles`); console.log(` Found ${allVehicles.length} total vehicles`);
}
if (SAVE_ALL_VEHICLE_SNAPSHOTS && allVehicles.length > 0) {
const captureTime = Date.now();
const snapshots: VehicleFeedSnapshot[] = allVehicles.map((vehicle) => ({
timestamp: captureTime,
sourceTimestamp: toOptionalNumber(vehicle.positionModifiedAt),
vehicleId: vehicle.identificationNumber?.toString() || undefined,
inventoryNumber: vehicle.inventoryNumber?.toString() || undefined,
latitude: toOptionalNumber(vehicle.positionLatitude),
longitude: toOptionalNumber(vehicle.positionLongitude),
speed: toOptionalNumber(vehicle.positionSpeed),
bearing: toOptionalNumber(vehicle.positionBearing),
status: toOptionalNumber(vehicle.status),
rawJson: JSON.stringify(vehicle),
}));
await logVehicleFeedSnapshot(snapshots);
stats.vehicleSnapshots += snapshots.length;
console.log(` [OK] Logged ${snapshots.length} full-vehicle snapshots`);
}
// Fetch trip updates to match vehicles to routes // Fetch trip updates to match vehicles to routes
const tripUpdatesResponse = await fetch(config.apiEndpoints.gtfsRtTripUpdates); const tripUpdatesResponse = await fetch(config.apiEndpoints.gtfsRtTripUpdates);
@@ -129,9 +154,9 @@ async function trackVehicles() {
return; return;
} }
// Build map of vehicle IDs to route IDs for tracked routes // Build map of vehicle IDs to route IDs from GTFS trip updates.
const vehicleRouteMap = new Map<string, { routeId: string, tripId: string }>(); const vehicleRouteMap = new Map<string, { routeId: string, tripId: string }>();
const trackedRouteIds = new Set(TRACKED_ROUTES.map(r => r.id)); const trackedRouteSet = new Set(TRACKED_ROUTES.map(r => r.id));
for (const entity of feed.entity) { for (const entity of feed.entity) {
if (!entity.tripUpdate) continue; if (!entity.tripUpdate) continue;
@@ -139,8 +164,9 @@ async function trackVehicles() {
const tripUpdate = entity.tripUpdate; const tripUpdate = entity.tripUpdate;
const routeId = tripUpdate.trip?.routeId; const routeId = tripUpdate.trip?.routeId;
// Only process tracked routes // In "all positions" mode keep every route; otherwise keep tracked routes only.
if (!routeId || !trackedRouteIds.has(routeId)) continue; if (!routeId) continue;
if (!SAVE_ALL_VEHICLE_POSITIONS && !trackedRouteSet.has(routeId)) continue;
// Get vehicle ID and label - store both as keys // Get vehicle ID and label - store both as keys
const vehicleId = tripUpdate.vehicle?.id; const vehicleId = tripUpdate.vehicle?.id;
@@ -158,22 +184,11 @@ async function trackVehicles() {
} }
} }
console.log(` Matched ${vehicleRouteMap.size} vehicles to tracked routes`); if (VERBOSE_TRACKER_LOGS) {
console.log(` Matched ${vehicleRouteMap.size} vehicles to GTFS routes`);
// Debug: Show sample vehicle IDs from both sources
if (vehicleRouteMap.size > 0) {
const sampleGtfsIds = Array.from(vehicleRouteMap.keys()).slice(0, 5);
console.log(` Sample GTFS-RT vehicle IDs: ${sampleGtfsIds.join(', ')}`);
} }
if (allVehicles.length > 0) { // Prepare vehicle positions.
const sampleJsonIds = allVehicles.slice(0, 5).map(v =>
`${v.identificationNumber || v.inventoryNumber || 'unknown'}`
);
console.log(` Sample JSON API vehicle IDs: ${sampleJsonIds.join(', ')}`);
}
// Filter and prepare vehicle positions for tracked routes
const positions: VehiclePosition[] = []; const positions: VehiclePosition[] = [];
const now = Date.now(); const now = Date.now();
@@ -183,16 +198,17 @@ async function trackVehicles() {
if (!identificationNumber && !inventoryNumber) continue; if (!identificationNumber && !inventoryNumber) continue;
// Try multiple matching strategies // Try multiple matching strategies.
let routeInfo = vehicleRouteMap.get(identificationNumber) || const routeInfo = vehicleRouteMap.get(identificationNumber) || vehicleRouteMap.get(inventoryNumber);
vehicleRouteMap.get(inventoryNumber); const includeVehicle = SAVE_ALL_VEHICLE_POSITIONS || !!routeInfo;
if (!includeVehicle) continue;
if (routeInfo) {
positions.push({ positions.push({
timestamp: now, timestamp: now,
vehicleId: identificationNumber || inventoryNumber, vehicleId: identificationNumber || inventoryNumber,
routeId: routeInfo.routeId, routeId: routeInfo?.routeId || 'UNKNOWN',
tripId: routeInfo.tripId, tripId: routeInfo?.tripId,
latitude: vehicle.positionLatitude, latitude: vehicle.positionLatitude,
longitude: vehicle.positionLongitude, longitude: vehicle.positionLongitude,
speed: vehicle.positionSpeed, speed: vehicle.positionSpeed,
@@ -200,12 +216,11 @@ async function trackVehicles() {
currentStatus: vehicle.status === 2 ? 'IN_TRANSIT_TO' : 'UNKNOWN', currentStatus: vehicle.status === 2 ? 'IN_TRANSIT_TO' : 'UNKNOWN',
}); });
} }
}
// Log to database // Persist current cycle positions
if (positions.length > 0) { if (positions.length > 0) {
await logVehiclePositions(positions); await logVehiclePositions(positions);
console.log(` [OK] Logged ${positions.length} vehicle positions`); console.log(` [OK] Logged ${positions.length} vehicle positions${SAVE_ALL_VEHICLE_POSITIONS ? ' (all vehicles mode)' : ''}`);
stats.vehiclesTracked += positions.length; stats.vehiclesTracked += positions.length;
} else { } else {
console.log(` [WARN] No vehicles found for tracked routes`); console.log(` [WARN] No vehicles found for tracked routes`);
@@ -321,10 +336,10 @@ async function trackArrivals() {
console.log(` [OK] Logged ${arrivalsLogged} new arrivals (${duplicates} duplicates skipped, ${arrivalsFound} total found)`); console.log(` [OK] Logged ${arrivalsLogged} new arrivals (${duplicates} duplicates skipped, ${arrivalsFound} total found)`);
stats.arrivalsLogged += arrivalsLogged; stats.arrivalsLogged += arrivalsLogged;
} else { } else {
console.log(` [INFO] Found ${arrivalsFound} arrivals but all were duplicates (already in database)`); console.log(` [INFO] Found ${arrivalsFound} arrivals but all were duplicates (already recorded)`);
} }
if (matchedRouteCounts.size > 0) { if (VERBOSE_TRACKER_LOGS && matchedRouteCounts.size > 0) {
const matchedSummary = Array.from(matchedRouteCounts.entries()) const matchedSummary = Array.from(matchedRouteCounts.entries())
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.slice(0, 10) .slice(0, 10)
@@ -333,7 +348,7 @@ async function trackArrivals() {
console.log(` [DEBUG] Matched route IDs: ${matchedSummary}`); console.log(` [DEBUG] Matched route IDs: ${matchedSummary}`);
} }
if (unmatchedRoutes.size > 0) { if (VERBOSE_TRACKER_LOGS && unmatchedRoutes.size > 0) {
const topUnmatched = Array.from(unmatchedRoutes.entries()) const topUnmatched = Array.from(unmatchedRoutes.entries())
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.slice(0, 8) .slice(0, 8)
@@ -358,6 +373,7 @@ function printStats() {
console.log(`Monitoring up to ${ARRIVAL_STOP_CAP} key stops for arrival data`); console.log(`Monitoring up to ${ARRIVAL_STOP_CAP} key stops for arrival data`);
console.log(`\nCycles completed: ${stats.cycles}`); console.log(`\nCycles completed: ${stats.cycles}`);
console.log(`Vehicle positions tracked: ${stats.vehiclesTracked}`); console.log(`Vehicle positions tracked: ${stats.vehiclesTracked}`);
console.log(`Full vehicle snapshots logged: ${stats.vehicleSnapshots}`);
console.log(`Arrival predictions logged: ${stats.arrivalsLogged}`); console.log(`Arrival predictions logged: ${stats.arrivalsLogged}`);
console.log(`Errors: ${stats.errors}`); console.log(`Errors: ${stats.errors}`);
console.log(`Last update: ${stats.lastUpdate.toLocaleString()}`); console.log(`Last update: ${stats.lastUpdate.toLocaleString()}`);
@@ -369,11 +385,11 @@ function printStats() {
async function main() { async function main() {
console.log('\nStarting Background Bus Tracker for Popular Routes & Stops\n'); console.log('\nStarting Background Bus Tracker for Popular Routes & Stops\n');
// Initialize database // Initialize storage
try { try {
await initDatabase(); await initStorage();
} catch (error) { } catch (error) {
console.error('Failed to initialize database:', error); console.error('Failed to initialize storage:', error);
console.log('Continuing without data logging...'); console.log('Continuing without data logging...');
} }
@@ -396,9 +412,17 @@ async function main() {
}, 5 * 60 * 1000); }, 5 * 60 * 1000);
// Handle graceful shutdown // Handle graceful shutdown
process.on('SIGINT', () => { process.on('SIGINT', async () => {
console.log('\n\nShutting down tracker...'); console.log('\n\nShutting down tracker...');
printStats(); printStats();
await closeStorage();
process.exit(0);
});
process.on('SIGTERM', async () => {
console.log('\n\nReceived SIGTERM, closing tracker...');
printStats();
await closeStorage();
process.exit(0); process.exit(0);
}); });
} }

View File

@@ -1,24 +0,0 @@
services:
timescaledb:
image: timescale/timescaledb:latest-pg17
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-example}
POSTGRES_DB: ${POSTGRES_DB:-iot_data}
ports:
- "5432:5432"
volumes:
- timescaledb-data:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:latest
ports:
- "6379:6379"
restart: unless-stopped
volumes:
- redis-data:/data
volumes:
timescaledb-data:
redis-data:

View File

@@ -1,464 +0,0 @@
import { Pool, PoolClient, QueryResult } from 'pg';
// PostgreSQL connection pool
const pool = new Pool({
host: process.env.POSTGRES_HOST || 'localhost',
port: parseInt(process.env.POSTGRES_PORT || '5432'),
database: process.env.POSTGRES_DB || 'iot_data',
user: process.env.POSTGRES_USER || 'postgres',
password: process.env.POSTGRES_PASSWORD || 'example',
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// Test connection
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err);
});
// Create tables with TimescaleDB hypertables
export async function initDatabase() {
const client = await pool.connect();
try {
// Enable TimescaleDB extension
await client.query('CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE');
// Vehicle positions time-series
await client.query(`
CREATE TABLE IF NOT EXISTS vehicle_positions (
time TIMESTAMPTZ NOT NULL,
vehicle_id TEXT NOT NULL,
route_id TEXT NOT NULL,
trip_id TEXT,
latitude DOUBLE PRECISION NOT NULL,
longitude DOUBLE PRECISION NOT NULL,
speed DOUBLE PRECISION,
bearing DOUBLE PRECISION,
current_status TEXT,
PRIMARY KEY (time, vehicle_id)
)
`);
// Convert to hypertable if not already
try {
await client.query(`
SELECT create_hypertable('vehicle_positions', 'time',
if_not_exists => TRUE,
chunk_time_interval => INTERVAL '1 day'
)
`);
} catch (e: any) {
if (!e.message?.includes('already a hypertable')) {
console.warn('Note: vehicle_positions hypertable setup:', e.message);
}
}
// Create indexes
await client.query(`
CREATE INDEX IF NOT EXISTS idx_vehicle_positions_vehicle
ON vehicle_positions(vehicle_id, time DESC)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_vehicle_positions_route
ON vehicle_positions(route_id, time DESC)
`);
// Arrival records time-series
await client.query(`
CREATE TABLE IF NOT EXISTS arrival_records (
time TIMESTAMPTZ NOT NULL,
stop_id TEXT NOT NULL,
route_id TEXT NOT NULL,
trip_id TEXT,
scheduled_time TIMESTAMPTZ NOT NULL,
predicted_time TIMESTAMPTZ,
actual_time TIMESTAMPTZ,
delay_seconds INTEGER,
is_realtime BOOLEAN,
headsign TEXT,
PRIMARY KEY (time, stop_id, route_id, scheduled_time)
)
`);
// Convert to hypertable
try {
await client.query(`
SELECT create_hypertable('arrival_records', 'time',
if_not_exists => TRUE,
chunk_time_interval => INTERVAL '1 day'
)
`);
} catch (e: any) {
if (!e.message?.includes('already a hypertable')) {
console.warn('Note: arrival_records hypertable setup:', e.message);
}
}
// Create indexes
await client.query(`
CREATE INDEX IF NOT EXISTS idx_arrival_records_stop
ON arrival_records(stop_id, time DESC)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS idx_arrival_records_route
ON arrival_records(route_id, time DESC)
`);
// Service metrics (aggregated hourly)
await client.query(`
CREATE TABLE IF NOT EXISTS service_metrics (
hour_timestamp TIMESTAMPTZ NOT NULL,
route_id TEXT NOT NULL,
stop_id TEXT NOT NULL DEFAULT '',
total_arrivals INTEGER DEFAULT 0,
on_time_arrivals INTEGER DEFAULT 0,
late_arrivals INTEGER DEFAULT 0,
avg_delay_seconds DOUBLE PRECISION DEFAULT 0,
max_delay_seconds INTEGER DEFAULT 0,
active_vehicles INTEGER DEFAULT 0,
PRIMARY KEY (hour_timestamp, route_id, stop_id)
)
`);
// Route performance summary
await client.query(`
CREATE TABLE IF NOT EXISTS route_stats (
route_id TEXT PRIMARY KEY,
route_name TEXT,
last_updated TIMESTAMPTZ,
total_trips INTEGER DEFAULT 0,
avg_delay_seconds DOUBLE PRECISION DEFAULT 0,
reliability_score DOUBLE PRECISION DEFAULT 0
)
`);
// Create continuous aggregates for better performance
try {
await client.query(`
CREATE MATERIALIZED VIEW IF NOT EXISTS hourly_route_delays
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 hour', time) AS bucket,
route_id,
COUNT(*) as arrival_count,
AVG(delay_seconds) as avg_delay,
MAX(delay_seconds) as max_delay,
COUNT(*) FILTER (WHERE ABS(delay_seconds) <= 120) as on_time_count
FROM arrival_records
WHERE delay_seconds IS NOT NULL
GROUP BY bucket, route_id
WITH NO DATA
`);
// Refresh policy
await client.query(`
SELECT add_continuous_aggregate_policy('hourly_route_delays',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour',
if_not_exists => TRUE
)
`);
} catch (e: any) {
if (!e.message?.includes('already exists')) {
console.warn('Note: continuous aggregate setup:', e.message);
}
}
// Set up retention policy (keep 90 days)
try {
await client.query(`
SELECT add_retention_policy('vehicle_positions',
INTERVAL '90 days',
if_not_exists => TRUE
)
`);
await client.query(`
SELECT add_retention_policy('arrival_records',
INTERVAL '90 days',
if_not_exists => TRUE
)
`);
} catch (e: any) {
console.warn('Note: retention policy setup:', e.message);
}
console.log('[OK] TimescaleDB initialized successfully');
} finally {
client.release();
}
}
// Insert vehicle position
export interface VehiclePosition {
timestamp: number;
vehicleId: string;
routeId: string;
tripId?: string;
latitude: number;
longitude: number;
speed?: number;
bearing?: number;
currentStatus?: string;
}
export async function logVehiclePosition(position: VehiclePosition) {
try {
await pool.query(`
INSERT INTO vehicle_positions
(time, vehicle_id, route_id, trip_id, latitude, longitude, speed, bearing, current_status)
VALUES (to_timestamp($1::double precision / 1000), $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (time, vehicle_id) DO NOTHING
`, [
position.timestamp,
position.vehicleId,
position.routeId,
position.tripId || null,
position.latitude,
position.longitude,
position.speed || null,
position.bearing || null,
position.currentStatus || null
]);
} catch (error) {
console.error('Failed to log vehicle position:', error);
}
}
// Batch insert vehicle positions
export async function logVehiclePositions(positions: VehiclePosition[]) {
if (positions.length === 0) return;
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const pos of positions) {
await client.query(`
INSERT INTO vehicle_positions
(time, vehicle_id, route_id, trip_id, latitude, longitude, speed, bearing, current_status)
VALUES (to_timestamp($1::double precision / 1000), $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (time, vehicle_id) DO NOTHING
`, [
pos.timestamp,
pos.vehicleId,
pos.routeId,
pos.tripId || null,
pos.latitude,
pos.longitude,
pos.speed || null,
pos.bearing || null,
pos.currentStatus || null
]);
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
console.error('Failed to batch log vehicle positions:', error);
} finally {
client.release();
}
}
// Insert arrival record
export interface ArrivalRecord {
timestamp: number;
stopId: string;
routeId: string;
tripId?: string;
scheduledTime: number;
predictedTime?: number;
actualTime?: number;
delaySeconds?: number;
isRealtime: boolean;
headsign?: string;
}
export async function logArrival(arrival: ArrivalRecord) {
try {
await pool.query(`
INSERT INTO arrival_records
(time, stop_id, route_id, trip_id, scheduled_time, predicted_time, actual_time, delay_seconds, is_realtime, headsign)
VALUES (
to_timestamp($1::double precision / 1000),
$2, $3, $4,
to_timestamp($5::double precision / 1000),
to_timestamp($6::double precision / 1000),
to_timestamp($7::double precision / 1000),
$8, $9, $10
)
ON CONFLICT (time, stop_id, route_id, scheduled_time) DO UPDATE SET
predicted_time = EXCLUDED.predicted_time,
delay_seconds = EXCLUDED.delay_seconds,
is_realtime = EXCLUDED.is_realtime
`, [
arrival.timestamp,
arrival.stopId,
arrival.routeId,
arrival.tripId || null,
arrival.scheduledTime,
arrival.predictedTime || null,
arrival.actualTime || null,
arrival.delaySeconds || null,
arrival.isRealtime,
arrival.headsign || null
]);
} catch (error) {
console.error('Failed to log arrival:', error);
}
}
// Query historical vehicle positions
export async function getVehicleHistory(vehicleId: string, startTime: number, endTime: number) {
const result = await pool.query(`
SELECT
EXTRACT(EPOCH FROM time) * 1000 as timestamp,
latitude, longitude, speed, bearing, current_status
FROM vehicle_positions
WHERE vehicle_id = $1
AND time BETWEEN to_timestamp($2::double precision / 1000) AND to_timestamp($3::double precision / 1000)
ORDER BY time ASC
`, [vehicleId, startTime, endTime]);
return result.rows;
}
// Query route vehicle positions in time range
export async function getRouteVehiclePositions(routeId: string, startTime: number, endTime: number) {
const result = await pool.query(`
SELECT
EXTRACT(EPOCH FROM time) * 1000 as timestamp,
vehicle_id, latitude, longitude, speed, bearing, current_status
FROM vehicle_positions
WHERE route_id = $1
AND time BETWEEN to_timestamp($2::double precision / 1000) AND to_timestamp($3::double precision / 1000)
ORDER BY time ASC
`, [routeId, startTime, endTime]);
return result.rows;
}
// Query arrival history for a stop
export async function getStopArrivalHistory(stopId: string, routeId: string, startTime: number, endTime: number) {
const result = await pool.query(`
SELECT
EXTRACT(EPOCH FROM time) * 1000 as timestamp,
EXTRACT(EPOCH FROM scheduled_time) * 1000 as scheduled_time,
EXTRACT(EPOCH FROM predicted_time) * 1000 as predicted_time,
EXTRACT(EPOCH FROM actual_time) * 1000 as actual_time,
delay_seconds, is_realtime, headsign
FROM arrival_records
WHERE stop_id = $1 AND route_id = $2
AND time BETWEEN to_timestamp($3::double precision / 1000) AND to_timestamp($4::double precision / 1000)
ORDER BY time ASC
`, [stopId, routeId, startTime, endTime]);
return result.rows;
}
// Get route delay statistics
export async function getRouteDelayStats(routeId: string, hours: number = 24) {
const result = await pool.query(`
SELECT
COUNT(*) as total_arrivals,
AVG(delay_seconds) as avg_delay,
MIN(delay_seconds) as min_delay,
MAX(delay_seconds) as max_delay,
COUNT(*) FILTER (WHERE ABS(delay_seconds) <= 120) as on_time_count,
COUNT(*) FILTER (WHERE delay_seconds > 120) as late_count,
COUNT(*) FILTER (WHERE delay_seconds < -120) as early_count
FROM arrival_records
WHERE route_id = $1
AND time >= NOW() - INTERVAL '1 hour' * $2
`, [routeId, hours]);
return result.rows[0];
}
// Get stop delay statistics
export async function getStopDelayStats(stopId: string, hours: number = 24) {
const result = await pool.query(`
SELECT
route_id,
COUNT(*) as total_arrivals,
AVG(delay_seconds) as avg_delay,
COUNT(*) FILTER (WHERE ABS(delay_seconds) <= 120) as on_time_count
FROM arrival_records
WHERE stop_id = $1
AND time >= NOW() - INTERVAL '1 hour' * $2
AND delay_seconds IS NOT NULL
GROUP BY route_id
`, [stopId, hours]);
return result.rows;
}
// Get hourly pattern for a route
export async function getRouteHourlyPattern(routeId: string, days: number = 7) {
const result = await pool.query(`
SELECT
EXTRACT(HOUR FROM ar.time) as hour,
COUNT(*) as arrival_count,
AVG(ar.delay_seconds) as avg_delay,
COUNT(DISTINCT vp.vehicle_id) as unique_vehicles
FROM arrival_records ar
LEFT JOIN vehicle_positions vp ON ar.trip_id = vp.trip_id
AND ABS(EXTRACT(EPOCH FROM (ar.time - vp.time))) < 60
WHERE ar.route_id = $1
AND ar.time >= NOW() - INTERVAL '1 day' * $2
GROUP BY hour
ORDER BY hour
`, [routeId, days]);
return result.rows;
}
// Cleanup old data (already handled by retention policy, but keep for manual cleanup)
export async function cleanupOldData(daysToKeep: number = 90) {
const client = await pool.connect();
try {
const result1 = await client.query(`
DELETE FROM vehicle_positions
WHERE time < NOW() - INTERVAL '1 day' * $1
`, [daysToKeep]);
const result2 = await client.query(`
DELETE FROM arrival_records
WHERE time < NOW() - INTERVAL '1 day' * $1
`, [daysToKeep]);
console.log(`Cleaned up old data: ${result1.rowCount} positions, ${result2.rowCount} arrivals`);
} finally {
client.release();
}
}
// Get database statistics
export async function getDatabaseStats() {
const client = await pool.connect();
try {
const positionCount = await client.query('SELECT COUNT(*) as count FROM vehicle_positions');
const arrivalCount = await client.query('SELECT COUNT(*) as count FROM arrival_records');
const oldestPosition = await client.query('SELECT MIN(time) as oldest FROM vehicle_positions');
const newestPosition = await client.query('SELECT MAX(time) as newest FROM vehicle_positions');
return {
vehiclePositions: parseInt(positionCount.rows[0].count),
arrivalRecords: parseInt(arrivalCount.rows[0].count),
oldestRecord: oldestPosition.rows[0].oldest,
newestRecord: newestPosition.rows[0].newest,
dbType: 'TimescaleDB',
host: pool.options.host,
database: pool.options.database
};
} finally {
client.release();
}
}
// Close database
export async function closeDatabase() {
await pool.end();
}

668
lib/storage.ts Normal file
View File

@@ -0,0 +1,668 @@
import { promises as fs } from 'fs';
import * as fsSync from 'fs';
import * as path from 'path';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { ByteWriter, ParquetWriter } from 'hyparquet-writer';
import type { SchemaElement } from 'hyparquet';
import type { Writer } from 'hyparquet-writer';
const env = process.env;
const DATA_DIR = env.PARQUET_DIR ?? path.join(process.cwd(), 'data');
const ROLL_MINUTES = Number.parseInt(env.PARQUET_ROLL_MINUTES ?? '5', 10);
const ROLL_INTERVAL_MS = Math.max(1, Number.isNaN(ROLL_MINUTES) ? 5 : ROLL_MINUTES) * 60_000;
const OBJECT_STORAGE_BUCKET = env.S3_BUCKET;
const OBJECT_STORAGE_REGION = env.S3_REGION ?? 'us-east-1';
const OBJECT_STORAGE_ENDPOINT = env.S3_ENDPOINT;
const OBJECT_STORAGE_PREFIX = (env.S3_PREFIX ?? 'parquet').replace(/^\/+|\/+$/g, '');
const OBJECT_STORAGE_FORCE_PATH_STYLE = (env.S3_FORCE_PATH_STYLE ?? 'true').toLowerCase() === 'true';
const OBJECT_STORAGE_ACCESS_KEY = env.S3_ACCESS_KEY_ID;
const OBJECT_STORAGE_SECRET_KEY = env.S3_SECRET_ACCESS_KEY;
const OBJECT_STORAGE_ENABLED =
(env.S3_ENABLED ?? 'false').toLowerCase() === 'true' &&
!!OBJECT_STORAGE_BUCKET &&
!!OBJECT_STORAGE_ACCESS_KEY &&
!!OBJECT_STORAGE_SECRET_KEY;
const DELETE_LOCAL_AFTER_UPLOAD = (env.S3_DELETE_LOCAL_AFTER_UPLOAD ?? 'false').toLowerCase() === 'true';
const OBJECT_STORAGE_UPLOAD_RETRIES = Math.max(1, Number.parseInt(env.S3_UPLOAD_RETRIES ?? '3', 10) || 3);
const OBJECT_STORAGE_UPLOAD_RETRY_BASE_MS = Math.max(100, Number.parseInt(env.S3_UPLOAD_RETRY_BASE_MS ?? '1000', 10) || 1000);
type BasicType = 'BOOLEAN' | 'INT32' | 'INT64' | 'FLOAT' | 'DOUBLE' | 'STRING' | 'TIMESTAMP';
type ColumnSource = {
name: string;
data: Array<string | number | bigint | boolean | Date | null>;
type: BasicType;
nullable?: boolean;
};
type IncrementalWriter = {
write: (options: { columnData: ColumnSource[]; rowGroupSize?: number | number[]; pageSize?: number }) => void;
finish: () => void;
};
type SegmentMeta = {
segmentId: string;
vehicleFile: string;
arrivalFile: string;
snapshotFile: string;
vehicleRows: number;
arrivalRows: number;
snapshotRows: number;
};
const VEHICLE_SCHEMA: SchemaElement[] = [
{ name: 'root', num_children: 9 },
{ name: 'timestamp', type: 'INT64', repetition_type: 'REQUIRED' },
{ name: 'vehicleId', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'REQUIRED' },
{ name: 'routeId', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'REQUIRED' },
{ name: 'tripId', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'OPTIONAL' },
{ name: 'latitude', type: 'DOUBLE', repetition_type: 'REQUIRED' },
{ name: 'longitude', type: 'DOUBLE', repetition_type: 'REQUIRED' },
{ name: 'speed', type: 'DOUBLE', repetition_type: 'OPTIONAL' },
{ name: 'bearing', type: 'DOUBLE', repetition_type: 'OPTIONAL' },
{ name: 'currentStatus', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'OPTIONAL' },
];
const ARRIVAL_SCHEMA: SchemaElement[] = [
{ name: 'root', num_children: 10 },
{ name: 'timestamp', type: 'INT64', repetition_type: 'REQUIRED' },
{ name: 'stopId', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'REQUIRED' },
{ name: 'routeId', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'REQUIRED' },
{ name: 'tripId', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'OPTIONAL' },
{ name: 'scheduledTime', type: 'INT64', repetition_type: 'REQUIRED' },
{ name: 'predictedTime', type: 'INT64', repetition_type: 'OPTIONAL' },
{ name: 'actualTime', type: 'INT64', repetition_type: 'OPTIONAL' },
{ name: 'delaySeconds', type: 'INT32', repetition_type: 'OPTIONAL' },
{ name: 'isRealtime', type: 'BOOLEAN', repetition_type: 'REQUIRED' },
{ name: 'headsign', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'OPTIONAL' },
];
const SNAPSHOT_SCHEMA: SchemaElement[] = [
{ name: 'root', num_children: 10 },
{ name: 'timestamp', type: 'INT64', repetition_type: 'REQUIRED' },
{ name: 'sourceTimestamp', type: 'INT64', repetition_type: 'OPTIONAL' },
{ name: 'vehicleId', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'OPTIONAL' },
{ name: 'inventoryNumber', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'OPTIONAL' },
{ name: 'latitude', type: 'DOUBLE', repetition_type: 'OPTIONAL' },
{ name: 'longitude', type: 'DOUBLE', repetition_type: 'OPTIONAL' },
{ name: 'speed', type: 'DOUBLE', repetition_type: 'OPTIONAL' },
{ name: 'bearing', type: 'DOUBLE', repetition_type: 'OPTIONAL' },
{ name: 'status', type: 'INT32', repetition_type: 'OPTIONAL' },
{ name: 'rawJson', type: 'BYTE_ARRAY', converted_type: 'UTF8', repetition_type: 'REQUIRED' },
];
let initialized = false;
let vehicleWriter: IncrementalWriter | undefined;
let arrivalWriter: IncrementalWriter | undefined;
let snapshotWriter: IncrementalWriter | undefined;
let vehicleWriteQueue: Promise<void> = Promise.resolve();
let arrivalWriteQueue: Promise<void> = Promise.resolve();
let snapshotWriteQueue: Promise<void> = Promise.resolve();
let rotationQueue: Promise<void> = Promise.resolve();
let rollTimer: ReturnType<typeof setInterval> | undefined;
let currentSegmentId = '';
let currentVehicleFile = '';
let currentArrivalFile = '';
let currentSnapshotFile = '';
let currentSegmentRows = { vehicle: 0, arrival: 0, snapshot: 0 };
const writes = {
vehiclePositions: 0,
arrivalRecords: 0,
vehicleSnapshots: 0,
};
const s3Client = OBJECT_STORAGE_ENABLED
? new S3Client({
region: OBJECT_STORAGE_REGION,
endpoint: OBJECT_STORAGE_ENDPOINT,
forcePathStyle: OBJECT_STORAGE_FORCE_PATH_STYLE,
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
credentials: {
accessKeyId: OBJECT_STORAGE_ACCESS_KEY as string,
secretAccessKey: OBJECT_STORAGE_SECRET_KEY as string,
},
})
: undefined;
export interface VehiclePosition {
timestamp: number;
vehicleId: string;
routeId: string;
tripId?: string;
latitude: number;
longitude: number;
speed?: number;
bearing?: number;
currentStatus?: string;
}
export interface ArrivalRecord {
timestamp: number;
stopId: string;
routeId: string;
tripId?: string;
scheduledTime: number;
predictedTime?: number;
actualTime?: number;
delaySeconds?: number;
isRealtime: boolean;
headsign?: string;
}
export interface VehicleFeedSnapshot {
timestamp: number;
sourceTimestamp?: number;
vehicleId?: string;
inventoryNumber?: string;
latitude?: number;
longitude?: number;
speed?: number;
bearing?: number;
status?: number;
rawJson: string;
}
function segmentId(): string {
return new Date().toISOString().replace(/[.:]/g, '-');
}
function partitionPathFromSegmentId(id: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})T/.exec(id);
if (match) {
const [, year, month, day] = match;
return path.join(`year=${year}`, `month=${month}`, `day=${day}`);
}
const now = new Date();
const year = now.getUTCFullYear().toString();
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
const day = String(now.getUTCDate()).padStart(2, '0');
return path.join(`year=${year}`, `month=${month}`, `day=${day}`);
}
function buildPartitionedFilePath(prefix: string, id: string): string {
return path.join(DATA_DIR, prefix, partitionPathFromSegmentId(id), `${prefix}-${id}.parquet`);
}
function buildVehicleFile(id: string): string {
return buildPartitionedFilePath('vehicle_positions', id);
}
function buildArrivalFile(id: string): string {
return buildPartitionedFilePath('arrival_records', id);
}
function buildSnapshotFile(id: string): string {
return buildPartitionedFilePath('vehicle_snapshots', id);
}
function createFileWriter(filename: string): Writer {
const writer = new ByteWriter() as unknown as Writer & { index: number };
const chunkSize = 1_000_000;
fsSync.mkdirSync(path.dirname(filename), { recursive: true });
fsSync.writeFileSync(filename, '', { flag: 'w' });
const flush = () => {
const chunk = new Uint8Array(writer.buffer, 0, writer.index);
fsSync.writeFileSync(filename, chunk, { flag: 'a' });
writer.index = 0;
};
writer.ensure = (size: number) => {
if (writer.index > chunkSize) {
flush();
}
if (writer.index + size > writer.buffer.byteLength) {
const newSize = Math.max(writer.buffer.byteLength * 2, writer.index + size);
const newBuffer = new ArrayBuffer(newSize);
new Uint8Array(newBuffer).set(new Uint8Array(writer.buffer));
writer.buffer = newBuffer;
writer.view = new DataView(writer.buffer);
}
};
writer.getBuffer = () => {
throw new Error('getBuffer not supported for file writer');
};
writer.getBytes = () => {
throw new Error('getBytes not supported for file writer');
};
writer.finish = () => {
flush();
};
return writer;
}
function createParquetWriter(filename: string, schema: SchemaElement[]): IncrementalWriter {
return new ParquetWriter({ writer: createFileWriter(filename), schema }) as unknown as IncrementalWriter;
}
function queuedWrite(queue: Promise<void>, task: () => Promise<void>): Promise<void> {
return queue.then(task).catch((error) => {
console.error('Parquet write failed:', error);
});
}
function toInt64(value: number): bigint {
return BigInt(Math.trunc(value));
}
function toNullableInt64(value: number | undefined): bigint | null {
return value == null ? null : toInt64(value);
}
function toVehicleColumns(positions: VehiclePosition[]): ColumnSource[] {
return [
{ name: 'timestamp', data: positions.map((p) => toInt64(p.timestamp)), type: 'INT64', nullable: false },
{ name: 'vehicleId', data: positions.map((p) => p.vehicleId), type: 'STRING', nullable: false },
{ name: 'routeId', data: positions.map((p) => p.routeId), type: 'STRING', nullable: false },
{ name: 'tripId', data: positions.map((p) => p.tripId ?? null), type: 'STRING' },
{ name: 'latitude', data: positions.map((p) => p.latitude), type: 'DOUBLE', nullable: false },
{ name: 'longitude', data: positions.map((p) => p.longitude), type: 'DOUBLE', nullable: false },
{ name: 'speed', data: positions.map((p) => p.speed ?? null), type: 'DOUBLE' },
{ name: 'bearing', data: positions.map((p) => p.bearing ?? null), type: 'DOUBLE' },
{ name: 'currentStatus', data: positions.map((p) => p.currentStatus ?? null), type: 'STRING' },
];
}
function toArrivalColumns(arrivals: ArrivalRecord[]): ColumnSource[] {
return [
{ name: 'timestamp', data: arrivals.map((a) => toInt64(a.timestamp)), type: 'INT64', nullable: false },
{ name: 'stopId', data: arrivals.map((a) => a.stopId), type: 'STRING', nullable: false },
{ name: 'routeId', data: arrivals.map((a) => a.routeId), type: 'STRING', nullable: false },
{ name: 'tripId', data: arrivals.map((a) => a.tripId ?? null), type: 'STRING' },
{ name: 'scheduledTime', data: arrivals.map((a) => toInt64(a.scheduledTime)), type: 'INT64', nullable: false },
{ name: 'predictedTime', data: arrivals.map((a) => toNullableInt64(a.predictedTime)), type: 'INT64' },
{ name: 'actualTime', data: arrivals.map((a) => toNullableInt64(a.actualTime)), type: 'INT64' },
{ name: 'delaySeconds', data: arrivals.map((a) => (a.delaySeconds == null ? null : Math.trunc(a.delaySeconds))), type: 'INT32' },
{ name: 'isRealtime', data: arrivals.map((a) => a.isRealtime), type: 'BOOLEAN', nullable: false },
{ name: 'headsign', data: arrivals.map((a) => a.headsign ?? null), type: 'STRING' },
];
}
function toSnapshotColumns(snapshots: VehicleFeedSnapshot[]): ColumnSource[] {
return [
{ name: 'timestamp', data: snapshots.map((s) => toInt64(s.timestamp)), type: 'INT64', nullable: false },
{ name: 'sourceTimestamp', data: snapshots.map((s) => toNullableInt64(s.sourceTimestamp)), type: 'INT64' },
{ name: 'vehicleId', data: snapshots.map((s) => s.vehicleId ?? null), type: 'STRING' },
{ name: 'inventoryNumber', data: snapshots.map((s) => s.inventoryNumber ?? null), type: 'STRING' },
{ name: 'latitude', data: snapshots.map((s) => (s.latitude == null ? null : s.latitude)), type: 'DOUBLE' },
{ name: 'longitude', data: snapshots.map((s) => (s.longitude == null ? null : s.longitude)), type: 'DOUBLE' },
{ name: 'speed', data: snapshots.map((s) => (s.speed == null ? null : s.speed)), type: 'DOUBLE' },
{ name: 'bearing', data: snapshots.map((s) => (s.bearing == null ? null : s.bearing)), type: 'DOUBLE' },
{ name: 'status', data: snapshots.map((s) => (s.status == null ? null : Math.trunc(s.status))), type: 'INT32' },
{ name: 'rawJson', data: snapshots.map((s) => s.rawJson), type: 'STRING', nullable: false },
];
}
function startNewSegment(id: string): void {
currentSegmentId = id;
currentVehicleFile = buildVehicleFile(id);
currentArrivalFile = buildArrivalFile(id);
currentSnapshotFile = buildSnapshotFile(id);
currentSegmentRows = { vehicle: 0, arrival: 0, snapshot: 0 };
vehicleWriter = createParquetWriter(currentVehicleFile, VEHICLE_SCHEMA);
arrivalWriter = createParquetWriter(currentArrivalFile, ARRIVAL_SCHEMA);
snapshotWriter = createParquetWriter(currentSnapshotFile, SNAPSHOT_SCHEMA);
}
async function uploadFileToObjectStorage(filePath: string): Promise<boolean> {
if (!OBJECT_STORAGE_ENABLED || !s3Client || !OBJECT_STORAGE_BUCKET) {
return true;
}
const keyPrefix = OBJECT_STORAGE_PREFIX ? `${OBJECT_STORAGE_PREFIX}/` : '';
const relativePath = path.relative(DATA_DIR, filePath);
const normalizedRelativePath =
!relativePath.startsWith('..') && !path.isAbsolute(relativePath)
? relativePath.split(path.sep).join('/')
: path.basename(filePath);
const key = `${keyPrefix}${normalizedRelativePath}`;
const body = await fs.readFile(filePath);
for (let attempt = 1; attempt <= OBJECT_STORAGE_UPLOAD_RETRIES; attempt += 1) {
try {
await s3Client.send(
new PutObjectCommand({
Bucket: OBJECT_STORAGE_BUCKET,
Key: key,
Body: body,
ContentLength: body.byteLength,
ContentType: 'application/octet-stream',
})
);
console.log(`[OK] Uploaded parquet to object storage: s3://${OBJECT_STORAGE_BUCKET}/${key}`);
return true;
} catch (error) {
const status = (error as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
const shouldRetry = !!status && status >= 500;
const canRetry = shouldRetry && attempt < OBJECT_STORAGE_UPLOAD_RETRIES;
console.error(
`[ERROR] Upload failed for ${filePath} (attempt ${attempt}/${OBJECT_STORAGE_UPLOAD_RETRIES}):`,
error
);
if (!canRetry) {
break;
}
const backoffMs = OBJECT_STORAGE_UPLOAD_RETRY_BASE_MS * attempt;
await new Promise((resolve) => setTimeout(resolve, backoffMs));
}
}
return false;
}
async function deleteLocalFile(filePath: string): Promise<void> {
await fs.unlink(filePath).catch((error: unknown) => {
const code = (error as { code?: string }).code;
if (code !== 'ENOENT') {
console.warn(`[WARN] Failed to delete local parquet file ${filePath}:`, error);
}
});
}
async function finalizeSegment(reason: string, reopen: boolean): Promise<void> {
if (!initialized) {
return;
}
await Promise.all([vehicleWriteQueue, arrivalWriteQueue, snapshotWriteQueue]);
const meta: SegmentMeta = {
segmentId: currentSegmentId,
vehicleFile: currentVehicleFile,
arrivalFile: currentArrivalFile,
snapshotFile: currentSnapshotFile,
vehicleRows: currentSegmentRows.vehicle,
arrivalRows: currentSegmentRows.arrival,
snapshotRows: currentSegmentRows.snapshot,
};
if (vehicleWriter) {
vehicleWriter.finish();
vehicleWriter = undefined;
}
if (arrivalWriter) {
arrivalWriter.finish();
arrivalWriter = undefined;
}
if (snapshotWriter) {
snapshotWriter.finish();
snapshotWriter = undefined;
}
const hasRows = meta.vehicleRows > 0 || meta.arrivalRows > 0 || meta.snapshotRows > 0;
try {
if (hasRows) {
const uploadResults = await Promise.all([
uploadFileToObjectStorage(meta.vehicleFile),
uploadFileToObjectStorage(meta.arrivalFile),
uploadFileToObjectStorage(meta.snapshotFile),
]);
const allUploaded = uploadResults.every(Boolean);
if (OBJECT_STORAGE_ENABLED && DELETE_LOCAL_AFTER_UPLOAD && allUploaded) {
await Promise.all([
deleteLocalFile(meta.vehicleFile),
deleteLocalFile(meta.arrivalFile),
deleteLocalFile(meta.snapshotFile),
]);
console.log(`[OK] Deleted local parquet segment after upload: ${meta.segmentId}`);
} else if (OBJECT_STORAGE_ENABLED && DELETE_LOCAL_AFTER_UPLOAD && !allUploaded) {
console.warn(`[WARN] Kept local parquet segment due to upload errors: ${meta.segmentId}`);
}
} else if (OBJECT_STORAGE_ENABLED && DELETE_LOCAL_AFTER_UPLOAD) {
await Promise.all([
deleteLocalFile(meta.vehicleFile),
deleteLocalFile(meta.arrivalFile),
deleteLocalFile(meta.snapshotFile),
]);
}
} finally {
if (reopen) {
startNewSegment(segmentId());
console.log(`[OK] Rotated parquet segment (${reason}) from ${meta.segmentId}`);
}
}
}
function enqueueRotation(reason: string, reopen: boolean): Promise<void> {
rotationQueue = rotationQueue.then(() => finalizeSegment(reason, reopen)).catch((error) => {
console.error('Parquet rotation failed:', error);
});
return rotationQueue;
}
function startRollTimer(): void {
if (rollTimer) {
clearInterval(rollTimer);
}
rollTimer = setInterval(() => {
void enqueueRotation('interval', true);
}, ROLL_INTERVAL_MS);
}
async function ensureInitialized(): Promise<void> {
if (initialized) {
return;
}
await fs.mkdir(DATA_DIR, { recursive: true });
startNewSegment(segmentId());
startRollTimer();
initialized = true;
const storageInfo = OBJECT_STORAGE_ENABLED
? `, object storage enabled (bucket=${OBJECT_STORAGE_BUCKET}, deleteLocal=${DELETE_LOCAL_AFTER_UPLOAD})`
: ', object storage disabled';
console.log(`[OK] Hyparquet storage ready at ${DATA_DIR}, roll=${ROLL_INTERVAL_MS / 60000}m${storageInfo}`);
}
export async function initStorage(): Promise<void> {
await ensureInitialized();
}
export async function initDatabase(): Promise<void> {
await initStorage();
}
export async function logVehiclePosition(position: VehiclePosition): Promise<void> {
await logVehiclePositions([position]);
}
export async function logVehiclePositions(positions: VehiclePosition[]): Promise<void> {
if (positions.length === 0) {
return;
}
await ensureInitialized();
await rotationQueue;
vehicleWriteQueue = queuedWrite(vehicleWriteQueue, async () => {
const writer = vehicleWriter;
if (!writer) {
return;
}
writer.write({
columnData: toVehicleColumns(positions),
rowGroupSize: positions.length,
});
currentSegmentRows.vehicle += positions.length;
writes.vehiclePositions += positions.length;
});
await vehicleWriteQueue;
}
export async function logArrival(arrival: ArrivalRecord): Promise<void> {
await ensureInitialized();
await rotationQueue;
arrivalWriteQueue = queuedWrite(arrivalWriteQueue, async () => {
const writer = arrivalWriter;
if (!writer) {
return;
}
writer.write({
columnData: toArrivalColumns([arrival]),
rowGroupSize: 1,
});
currentSegmentRows.arrival += 1;
writes.arrivalRecords += 1;
});
await arrivalWriteQueue;
}
export async function logVehicleFeedSnapshot(snapshots: VehicleFeedSnapshot[]): Promise<void> {
if (snapshots.length === 0) {
return;
}
await ensureInitialized();
await rotationQueue;
snapshotWriteQueue = queuedWrite(snapshotWriteQueue, async () => {
const writer = snapshotWriter;
if (!writer) {
return;
}
writer.write({
columnData: toSnapshotColumns(snapshots),
rowGroupSize: snapshots.length,
});
currentSegmentRows.snapshot += snapshots.length;
writes.vehicleSnapshots += snapshots.length;
});
await snapshotWriteQueue;
}
export async function getVehicleHistory(_vehicleId: string, _startTime: number, _endTime: number) {
return [];
}
export async function getRouteVehiclePositions(_routeId: string, _startTime: number, _endTime: number) {
return [];
}
export async function getStopArrivalHistory(_stopId: string, _routeId: string, _startTime: number, _endTime: number) {
return [];
}
export async function getRouteDelayStats(_routeId: string, _hours: number = 24) {
return {
total_arrivals: 0,
avg_delay: null,
min_delay: null,
max_delay: null,
on_time_count: 0,
late_count: 0,
early_count: 0,
};
}
export async function getStopDelayStats(_stopId: string, _hours: number = 24) {
return [];
}
export async function getRouteHourlyPattern(_routeId: string, _days: number = 7) {
return [];
}
export async function cleanupOldData(_daysToKeep: number = 90): Promise<void> {
console.log('cleanupOldData skipped: parquet segment mode');
}
export async function getStorageStats() {
await ensureInitialized();
const [vehicleInfo, arrivalInfo, snapshotInfo] = await Promise.all([
fs.stat(currentVehicleFile).catch(() => null),
fs.stat(currentArrivalFile).catch(() => null),
fs.stat(currentSnapshotFile).catch(() => null),
]);
return {
vehiclePositions: writes.vehiclePositions,
arrivalRecords: writes.arrivalRecords,
vehicleSnapshots: writes.vehicleSnapshots,
oldestRecord: null,
newestRecord: null,
storageType: 'hyparquet(rolling-write)',
host: OBJECT_STORAGE_ENABLED ? 'object-storage+local' : 'local-filesystem',
storagePath: DATA_DIR,
rolling: {
minutes: ROLL_INTERVAL_MS / 60000,
currentSegmentId,
},
objectStorage: {
enabled: OBJECT_STORAGE_ENABLED,
bucket: OBJECT_STORAGE_BUCKET ?? null,
region: OBJECT_STORAGE_REGION,
endpoint: OBJECT_STORAGE_ENDPOINT ?? null,
prefix: OBJECT_STORAGE_PREFIX,
deleteLocalAfterUpload: DELETE_LOCAL_AFTER_UPLOAD,
},
files: {
vehicle: {
path: currentVehicleFile,
bytes: vehicleInfo?.size ?? 0,
modifiedAt: vehicleInfo?.mtime ?? null,
},
arrivals: {
path: currentArrivalFile,
bytes: arrivalInfo?.size ?? 0,
modifiedAt: arrivalInfo?.mtime ?? null,
},
snapshots: {
path: currentSnapshotFile,
bytes: snapshotInfo?.size ?? 0,
modifiedAt: snapshotInfo?.mtime ?? null,
},
},
};
}
export async function getDatabaseStats() {
return getStorageStats();
}
export async function closeStorage(): Promise<void> {
if (!initialized) {
return;
}
if (rollTimer) {
clearInterval(rollTimer);
rollTimer = undefined;
}
await Promise.all([vehicleWriteQueue, arrivalWriteQueue, snapshotWriteQueue]);
await enqueueRotation('shutdown', false);
initialized = false;
}
export async function closeDatabase(): Promise<void> {
await closeStorage();
}

2058
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
{ {
"name": "skopje-bus-tracker", "name": "openjsp",
"version": "1.0.0", "version": "1.0.0",
"description": "Real-time bus tracking for Skopje public transport", "description": "Real-time bus tracking for Skopje public transport",
"main": "server.ts", "main": "index.ts",
"scripts": { "scripts": {
"start": "bun run server.ts", "start": "bun run index.ts",
"track": "bun run background-tracker.ts" "typecheck": "bunx tsc --noEmit"
}, },
"keywords": [ "keywords": [
"skopje", "skopje",
@@ -19,8 +19,10 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
"express": "^4.18.2", "express": "^4.18.2",
"pg": "^8.18.0", "hyparquet": "^1.25.1",
"hyparquet-writer": "^0.13.0",
"protobufjs": "^7.5.4" "protobufjs": "^7.5.4"
}, },
"optionalDependencies": { "optionalDependencies": {
@@ -29,9 +31,7 @@
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^20.10.0", "@types/node": "^20.10.0",
"@types/pg": "^8.11.0",
"protobufjs-cli": "^2.0.0", "protobufjs-cli": "^2.0.0",
"ts-node": "^10.9.1",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -1,500 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bus Analytics - Skopje</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 28px;
margin-bottom: 5px;
}
.header .subtitle {
opacity: 0.9;
font-size: 14px;
}
.nav {
background: white;
padding: 10px 20px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.nav a {
color: #667eea;
text-decoration: none;
margin-right: 20px;
font-weight: 600;
}
.nav a:hover {
text-decoration: underline;
}
.controls {
background: white;
padding: 15px 20px;
margin: 20px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.control-group label {
font-size: 12px;
color: #666;
font-weight: 600;
}
.control-group select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 250px;
}
.btn {
padding: 8px 20px;
background: #667eea;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
margin-top: 20px;
}
.btn:hover {
background: #5568d3;
}
.container {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stats-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.stats-card h3 {
font-size: 14px;
color: #666;
margin-bottom: 10px;
}
.stats-card .value {
font-size: 32px;
font-weight: bold;
color: #667eea;
}
.stats-card .unit {
font-size: 14px;
color: #999;
}
.charts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 20px;
}
.chart-container {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.chart-container h2 {
font-size: 18px;
color: #333;
margin-bottom: 15px;
}
.loading {
text-align: center;
padding: 40px;
color: #999;
}
.db-stats {
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 15px;
margin: 20px;
border-radius: 4px;
}
.db-stats h3 {
color: #333;
margin-bottom: 10px;
font-size: 16px;
}
.db-stats p {
color: #666;
font-size: 14px;
margin: 5px 0;
}
</style>
</head>
<body>
<div class="header">
<h1>Bus Analytics Dashboard</h1>
<div class="subtitle">Historical performance and delay analysis</div>
</div>
<div class="nav">
<a href="/">← Back to Live Tracker</a>
<a href="#" onclick="loadData(); return false;">Refresh Data</a>
</div>
<div class="controls">
<div class="control-group">
<label for="routeId">Bus Route</label>
<select id="routeId">
<option value="">Loading routes...</option>
</select>
</div>
<button class="btn" onclick="loadAnalytics()">Load Analytics</button>
</div>
<div class="container">
<div id="dbStats" class="db-stats" style="display: none;">
<h3>Database Status</h3>
<p id="dbInfo">Loading...</p>
</div>
<div id="statsCards" class="stats-cards">
<!-- Stats will be inserted here -->
</div>
<div class="charts">
<div class="chart-container">
<h2>Delay Distribution</h2>
<canvas id="delayChart"></canvas>
</div>
<div class="chart-container">
<h2>Hourly Service Pattern (Last 7 Days)</h2>
<canvas id="hourlyChart"></canvas>
</div>
<div class="chart-container">
<h2>On-Time Performance</h2>
<canvas id="performanceChart"></canvas>
</div>
</div>
</div>
<script>
let currentRouteId = null;
let delayChart = null;
let hourlyChart = null;
let performanceChart = null;
// Load stops and routes
async function loadRoutes() {
try {
const response = await fetch('/api/routes');
const routes = await response.json();
const routeSelect = document.getElementById('routeId');
routes.sort((a, b) => {
const aNum = parseInt(a.shortName) || 9999;
const bNum = parseInt(b.shortName) || 9999;
return aNum - bNum;
});
routeSelect.innerHTML = '<option value="">Select a route...</option>' +
routes.map(route =>
`<option value="${route.id}">${route.shortName} - ${route.longName}</option>`
).join('');
} catch (error) {
console.error('Failed to load routes:', error);
}
}
// Load database statistics
async function loadDbStats() {
try {
const response = await fetch('/api/stats/db');
const stats = await response.json();
document.getElementById('dbStats').style.display = 'block';
document.getElementById('dbInfo').innerHTML = `
<strong>Records:</strong> ${stats.vehiclePositions.toLocaleString()} vehicle positions,
${stats.arrivalRecords.toLocaleString()} arrival records<br>
<strong>Time Range:</strong> ${stats.oldestRecord ? new Date(stats.oldestRecord).toLocaleString() : 'N/A'}
to ${stats.newestRecord ? new Date(stats.newestRecord).toLocaleString() : 'N/A'}
`;
} catch (error) {
console.error('Failed to load DB stats:', error);
}
}
// Load analytics for selected route
async function loadAnalytics() {
const routeId = document.getElementById('routeId').value;
if (!routeId) {
alert('Please select a route');
return;
}
currentRouteId = routeId;
await Promise.all([
loadDelayStats(),
loadHourlyPattern(),
loadDbStats()
]);
}
// Load delay statistics
async function loadDelayStats() {
try {
const response = await fetch(`/api/stats/route/${currentRouteId}/delays?hours=24`);
const stats = await response.json();
// Check if we have data
if (!stats.total_arrivals || stats.total_arrivals === 0) {
document.getElementById('statsCards').innerHTML = `
<div class="stats-card" style="grid-column: 1 / -1; text-align: center; padding: 40px;">
<h3 style="color: #999; font-size: 18px; margin-bottom: 10px;">No Arrival Data Yet</h3>
<p style="color: #666; font-size: 14px;">Start tracking a stop to collect arrival data.<br>Go to the <a href="/" style="color: #667eea;">Live Tracker</a> and select a stop and route.</p>
</div>
`;
// Clear charts
if (performanceChart) performanceChart.destroy();
if (delayChart) delayChart.destroy();
if (hourlyChart) hourlyChart.destroy();
return;
}
// Update stats cards
const cardsHTML = `
<div class="stats-card">
<h3>Total Arrivals (24h)</h3>
<div class="value">${stats.total_arrivals || 0}</div>
</div>
<div class="stats-card">
<h3>Average Delay</h3>
<div class="value">${stats.avg_delay ? Math.round(stats.avg_delay / 60) : 0}</div>
<div class="unit">minutes</div>
</div>
<div class="stats-card">
<h3>On-Time Rate</h3>
<div class="value">${stats.total_arrivals > 0 ? Math.round((stats.on_time_count / stats.total_arrivals) * 100) : 0}%</div>
</div>
<div class="stats-card">
<h3>Maximum Delay</h3>
<div class="value">${stats.max_delay ? Math.round(stats.max_delay / 60) : 0}</div>
<div class="unit">minutes</div>
</div>
`;
document.getElementById('statsCards').innerHTML = cardsHTML;
// Update performance chart
if (performanceChart) {
performanceChart.destroy();
}
const ctx = document.getElementById('performanceChart');
performanceChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['On Time (±2 min)', 'Late (>2 min)', 'Early (<-2 min)'],
datasets: [{
data: [
stats.on_time_count || 0,
stats.late_count || 0,
stats.early_count || 0
],
backgroundColor: [
'#4caf50',
'#ff6b6b',
'#2196f3'
]
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
// Update delay distribution chart
if (delayChart) {
delayChart.destroy();
}
const delayCtx = document.getElementById('delayChart');
delayChart = new Chart(delayCtx, {
type: 'bar',
data: {
labels: ['Early', 'On Time', 'Late'],
datasets: [{
label: 'Number of Arrivals',
data: [
stats.early_count || 0,
stats.on_time_count || 0,
stats.late_count || 0
],
backgroundColor: [
'#2196f3',
'#4caf50',
'#ff6b6b'
]
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
} catch (error) {
console.error('Failed to load delay stats:', error);
}
}
// Load hourly pattern
async function loadHourlyPattern() {
try {
const response = await fetch(`/api/stats/route/${currentRouteId}/hourly?days=7`);
const pattern = await response.json();
if (hourlyChart) {
hourlyChart.destroy();
}
const ctx = document.getElementById('hourlyChart');
hourlyChart = new Chart(ctx, {
type: 'line',
data: {
labels: pattern.map(p => `${p.hour}:00`),
datasets: [
{
label: 'Average Delay (seconds)',
data: pattern.map(p => p.avg_delay || 0),
borderColor: '#ff6b6b',
backgroundColor: 'rgba(255, 107, 107, 0.1)',
yAxisID: 'y',
},
{
label: 'Number of Arrivals',
data: pattern.map(p => p.arrival_count || 0),
borderColor: '#667eea',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
yAxisID: 'y1',
}
]
},
options: {
responsive: true,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: 'Delay (seconds)'
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: 'Arrivals'
},
grid: {
drawOnChartArea: false,
},
},
}
}
});
} catch (error) {
console.error('Failed to load hourly pattern:', error);
}
}
// Initialize
async function init() {
await loadRoutes();
await loadDbStats();
// Load default route analytics if available
const response = await fetch('/api/config');
const config = await response.json();
if (config.defaults.routeId) {
document.getElementById('routeId').value = config.defaults.routeId;
await loadAnalytics();
}
}
init();
</script>
</body>
</html>

View File

@@ -1,642 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Skopje Bus Tracker</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header h1 {
font-size: 28px;
margin-bottom: 5px;
}
.header .subtitle {
opacity: 0.9;
font-size: 14px;
}
.controls {
background: white;
padding: 15px 20px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.control-group label {
font-size: 12px;
color: #666;
font-weight: 600;
}
.control-group input,
.control-group select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 250px;
}
.control-group input:focus,
.control-group select:focus {
outline: none;
border-color: #667eea;
}
.btn {
padding: 8px 20px;
background: #667eea;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: background 0.2s;
margin-top: 20px;
}
.btn:hover {
background: #5568d3;
}
.container {
display: flex;
height: calc(100vh - 180px);
}
.sidebar {
width: 350px;
background: white;
padding: 20px;
overflow-y: auto;
box-shadow: 2px 0 10px rgba(0,0,0,0.1);
}
.map-container {
flex: 1;
position: relative;
}
#map {
width: 100%;
height: 100%;
}
.info-card {
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
}
.info-card h3 {
color: #333;
margin-bottom: 10px;
font-size: 16px;
}
.info-card p {
color: #666;
font-size: 14px;
margin: 5px 0;
}
.arrivals-section {
margin-top: 20px;
}
.arrivals-section h2 {
color: #333;
font-size: 18px;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #eee;
}
.arrival-item {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 15px;
margin-bottom: 10px;
transition: all 0.3s;
}
.arrival-item:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.arrival-item.approaching {
border-left: 4px solid #ff6b6b;
background: #fff5f5;
}
.arrival-item.soon {
border-left: 4px solid #ffa500;
background: #fff8e1;
}
.arrival-time {
font-size: 24px;
font-weight: bold;
color: #333;
margin-bottom: 5px;
}
.arrival-minutes {
color: #667eea;
font-weight: 600;
font-size: 16px;
}
.arrival-details {
font-size: 13px;
color: #666;
margin-top: 8px;
}
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
margin-right: 5px;
}
.badge-live {
background: #4caf50;
color: white;
}
.badge-scheduled {
background: #9e9e9e;
color: white;
}
.badge-late {
background: #ff6b6b;
color: white;
}
.badge-early {
background: #2196f3;
color: white;
}
.badge-ontime {
background: #4caf50;
color: white;
}
.no-buses {
text-align: center;
padding: 30px;
color: #999;
}
.loading {
text-align: center;
padding: 20px;
color: #667eea;
}
.last-update {
text-align: center;
color: #999;
font-size: 12px;
padding: 10px;
border-top: 1px solid #eee;
margin-top: 10px;
}
.stats {
display: flex;
justify-content: space-around;
margin-bottom: 20px;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #667eea;
}
.stat-label {
font-size: 12px;
color: #999;
margin-top: 5px;
}
.nav-link {
padding: 8px 20px;
background: rgba(255,255,255,0.2);
border-radius: 4px;
text-decoration: none;
color: white;
font-size: 14px;
white-space: nowrap;
}
.nav-link:hover {
background: rgba(255,255,255,0.3);
}
</style>
</head>
<body>
<div class="header">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h1>Skopje Bus Tracker</h1>
<div class="subtitle" id="routeInfo">Select stop and route to track</div>
</div>
<a href="/analytics.html" class="nav-link">View Analytics</a>
</div>
</div>
<div class="controls">
<div class="control-group">
<label for="stopId">Bus Stop</label>
<select id="stopId">
<option value="">Loading stops...</option>
</select>
</div>
<div class="control-group">
<label for="routeId">Bus Route</label>
<select id="routeId">
<option value="">Loading routes...</option>
</select>
</div>
<button class="btn" onclick="loadTracker()">Track Bus</button>
</div>
<div class="container">
<div class="sidebar">
<div class="info-card">
<h3>Target Stop</h3>
<p id="stopName">Loading...</p>
<p id="stopCode"></p>
</div>
<div class="stats">
<div class="stat-item">
<div class="stat-value" id="busCount">-</div>
<div class="stat-label">Active Buses</div>
</div>
<div class="stat-item">
<div class="stat-value" id="arrivalCount">-</div>
<div class="stat-label">Upcoming</div>
</div>
</div>
<div class="arrivals-section">
<h2>Upcoming Arrivals</h2>
<div id="arrivalsList" class="loading">
Loading arrivals...
</div>
</div>
<div class="last-update" id="lastUpdate">
Last updated: -
</div>
</div>
<div class="map-container">
<div id="map"></div>
</div>
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
let map;
let stopMarker;
let vehicleMarkers = [];
let config = null;
let currentStopId = null;
let currentRouteId = null;
let refreshInterval = null;
// Get URL parameters
function getUrlParams() {
const params = new URLSearchParams(window.location.search);
return {
stopId: params.get('stopId'),
routeId: params.get('routeId')
};
}
// Update URL without reload
function updateUrl(stopId, routeId) {
const url = new URL(window.location);
url.searchParams.set('stopId', stopId);
url.searchParams.set('routeId', routeId);
window.history.pushState({}, '', url);
}
// Initialize map
async function initMap() {
if (map) {
map.remove();
}
// Get configuration with selected stop and route
const queryParams = currentStopId && currentRouteId
? `?stopId=${currentStopId}&routeId=${currentRouteId}`
: '';
const response = await fetch('/api/config' + queryParams);
config = await response.json();
// Update header
document.getElementById('routeInfo').textContent =
`${config.route.shortName} - ${config.route.longName} at ${config.stop.name}`;
// Update sidebar info
document.getElementById('stopName').textContent = config.stop.name;
document.getElementById('stopCode').textContent = `Stop Code: ${config.stop.code}`;
// Initialize map centered on stop
map = L.map('map').setView([config.stop.lat, config.stop.lon], 14);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(map);
// Add stop marker
const stopIcon = L.divIcon({
html: '<div style="background: #667eea; width: 30px; height: 30px; border-radius: 50%; border: 3px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.3);"></div>',
className: '',
iconSize: [30, 30],
iconAnchor: [15, 15]
});
stopMarker = L.marker([config.stop.lat, config.stop.lon], { icon: stopIcon })
.addTo(map)
.bindPopup(`<b>${config.stop.name}</b><br>Stop Code: ${config.stop.code}`);
}
// Update vehicle positions
async function updateVehicles() {
if (!currentRouteId) return;
try {
const response = await fetch(`/api/vehicles?routeId=${currentRouteId}`);
const vehicles = await response.json();
document.getElementById('busCount').textContent = vehicles.length;
// Remove old markers
vehicleMarkers.forEach(marker => map.removeLayer(marker));
vehicleMarkers = [];
// Add new markers
vehicles.forEach(vehicle => {
const label = vehicle.label || vehicle.vehicleId || 'Bus';
const speedKmh = vehicle.speed ? (vehicle.speed * 3.6).toFixed(1) : '0.0'; // Convert m/s to km/h
// Create arrow icon rotated by bearing
const busIcon = L.divIcon({
html: `<div style="transform: rotate(${vehicle.bearing}deg); width: 30px; height: 30px;">
<svg width="30" height="30" viewBox="0 0 30 30">
<path d="M15 5 L25 25 L15 20 L5 25 Z" fill="#ff6b6b" stroke="white" stroke-width="2"/>
</svg>
</div>`,
className: '',
iconSize: [30, 30],
iconAnchor: [15, 15]
});
const marker = L.marker([vehicle.lat, vehicle.lon], { icon: busIcon })
.addTo(map)
.bindPopup(`
<b>Bus ${label}</b><br>
Vehicle ID: ${vehicle.vehicleId}<br>
Speed: ${speedKmh} km/h<br>
Bearing: ${vehicle.bearing}°<br>
Status: ${vehicle.currentStatus}<br>
Updated: ${new Date(vehicle.timestamp).toLocaleTimeString()}
`);
vehicleMarkers.push(marker);
});
} catch (error) {
console.error('Failed to update vehicles:', error);
}
}
// Update arrivals
async function updateArrivals() {
if (!currentStopId || !currentRouteId) return;
try {
const response = await fetch(`/api/arrivals?stopId=${currentStopId}&routeId=${currentRouteId}`);
const arrivals = await response.json();
const arrivalsList = document.getElementById('arrivalsList');
document.getElementById('arrivalCount').textContent = arrivals.length;
if (arrivals.length === 0) {
arrivalsList.innerHTML = '<div class="no-buses">No buses scheduled in the next 90 minutes</div>';
return;
}
arrivalsList.innerHTML = arrivals.slice(0, 10).map((arrival, index) => {
const arrivalTime = new Date(arrival.arrivalTime);
const delayMin = Math.floor(arrival.delaySeconds / 60);
let delayBadge = '';
if (delayMin > 2) {
delayBadge = `<span class="badge badge-late">${delayMin}min late</span>`;
} else if (delayMin < -2) {
delayBadge = `<span class="badge badge-early">${Math.abs(delayMin)}min early</span>`;
} else {
delayBadge = `<span class="badge badge-ontime">On time</span>`;
}
const realtimeBadge = arrival.isRealtime
? '<span class="badge badge-live">LIVE</span>'
: '<span class="badge badge-scheduled">SCHEDULED</span>';
let cssClass = 'arrival-item';
if (arrival.minutesUntil <= 2) {
cssClass += ' approaching';
} else if (arrival.minutesUntil <= 10) {
cssClass += ' soon';
}
const minutesText = arrival.minutesUntil <= 0
? 'ARRIVING NOW'
: `in ${arrival.minutesUntil} min`;
return `
<div class="${cssClass}">
<div class="arrival-time">${arrivalTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })}</div>
<div class="arrival-minutes">${minutesText}</div>
<div class="arrival-details">
${realtimeBadge}
${delayBadge}
<br>
Direction: ${arrival.headsign}
</div>
</div>
`;
}).join('');
document.getElementById('lastUpdate').textContent =
`Last updated: ${new Date().toLocaleTimeString()}`;
} catch (error) {
console.error('Failed to update arrivals:', error);
document.getElementById('arrivalsList').innerHTML =
'<div class="no-buses">Failed to load arrivals</div>';
}
}
// Load tracker for selected stop and route
async function loadTracker() {
const stopId = document.getElementById('stopId').value.trim();
const routeId = document.getElementById('routeId').value.trim();
if (!stopId || !routeId) {
alert('Please select both a stop and a route');
return;
}
// Clear existing interval
if (refreshInterval) {
clearInterval(refreshInterval);
}
// Update current selection
currentStopId = stopId;
currentRouteId = routeId;
// Update URL
updateUrl(stopId, routeId);
// Initialize tracker
document.getElementById('arrivalsList').innerHTML = '<div class="loading">Loading arrivals...</div>';
try {
await initMap();
await updateVehicles();
await updateArrivals();
// Refresh data every 5 seconds
refreshInterval = setInterval(async () => {
await updateVehicles();
await updateArrivals();
}, 5000);
} catch (error) {
console.error('Failed to load tracker:', error);
alert('Failed to load tracker. Please try another stop or route.');
}
}
// Load stops and routes
async function loadStopsAndRoutes() {
try {
// Load stops
const stopsResponse = await fetch('/api/stops');
const stops = await stopsResponse.json();
const stopSelect = document.getElementById('stopId');
stops.sort((a, b) => a.name.localeCompare(b.name));
stopSelect.innerHTML = '<option value="">Select a stop...</option>' +
stops.map(stop =>
`<option value="${stop.id}">${stop.name} (${stop.code})</option>`
).join('');
// Load routes
const routesResponse = await fetch('/api/routes');
const routes = await routesResponse.json();
const routeSelect = document.getElementById('routeId');
routes.sort((a, b) => {
const aNum = parseInt(a.shortName) || 9999;
const bNum = parseInt(b.shortName) || 9999;
return aNum - bNum;
});
routeSelect.innerHTML = '<option value="">Select a route...</option>' +
routes.map(route =>
`<option value="${route.id}">${route.shortName} - ${route.longName}</option>`
).join('');
} catch (error) {
console.error('Failed to load stops and routes:', error);
}
}
// Start application
async function start() {
// Load stops and routes first
await loadStopsAndRoutes();
// Check URL parameters
const params = getUrlParams();
if (params.stopId && params.routeId) {
// Load from URL
currentStopId = params.stopId;
currentRouteId = params.routeId;
document.getElementById('stopId').value = params.stopId;
document.getElementById('routeId').value = params.routeId;
await loadTracker();
} else {
// Load defaults
const response = await fetch('/api/config');
const defaultConfig = await response.json();
document.getElementById('stopId').value = defaultConfig.defaults.stopId;
document.getElementById('routeId').value = defaultConfig.defaults.routeId;
// Auto-load defaults
await loadTracker();
}
}
start();
</script>
</body>
</html>

416
server.ts
View File

@@ -1,416 +0,0 @@
import express, { Request, Response } from 'express';
import GtfsRealtimeBindings from 'gtfs-realtime-bindings';
import { loadGtfsStops, loadGtfsRoutes } from './lib/gtfs';
import { config, StopConfig, RouteConfig } from './config';
import {
initDatabase,
logVehiclePosition,
logVehiclePositions,
logArrival,
getDatabaseStats,
cleanupOldData,
getVehicleHistory,
getRouteVehiclePositions,
getStopArrivalHistory,
getRouteDelayStats,
getStopDelayStats,
getRouteHourlyPattern
} from './lib/database';
const app = express();
// Initialize database
try {
initDatabase();
console.log('Database initialized successfully');
} catch (error) {
console.error('Failed to initialize database:', error);
console.log('Continuing without data logging...');
}
// Load GTFS data
const stops = loadGtfsStops();
const routes = loadGtfsRoutes();
// Serve static files
import * as path from 'path';
app.use(express.static(path.join(__dirname, 'public')));
// Serve OpenAPI specification
app.get('/openapi.yaml', (req: Request, res: Response) => {
res.sendFile(path.join(__dirname, 'openapi.yaml'));
});
// API Endpoints
app.get('/api/config', (req: Request, res: Response) => {
const stopId = (req.query.stopId as string) || config.defaultStop.stopId;
const routeId = (req.query.routeId as string) || config.defaultRoute.routeId;
const stop = stops.get(stopId);
const route = routes.get(routeId);
res.json({
stop: {
id: stopId,
code: stop?.stop_code,
name: stop?.stop_name,
lat: stop?.stop_lat,
lon: stop?.stop_lon,
},
route: {
id: routeId,
shortName: route?.route_short_name,
longName: route?.route_long_name,
},
defaults: {
stopId: config.defaultStop.stopId,
routeId: config.defaultRoute.routeId,
}
});
});
app.get('/api/stops', (req: Request, res: Response) => {
const stopsList = Array.from(stops.values()).map(stop => ({
id: stop.stop_id,
code: stop.stop_code,
name: stop.stop_name,
lat: stop.stop_lat,
lon: stop.stop_lon,
}));
res.json(stopsList);
});
app.get('/api/routes', (req: Request, res: Response) => {
const routesList = Array.from(routes.values()).map(route => ({
id: route.route_id,
shortName: route.route_short_name,
longName: route.route_long_name,
}));
res.json(routesList);
});
app.get('/api/arrivals', async (req: Request, res: Response) => {
try {
const stopId = (req.query.stopId as string) || config.defaultStop.stopId;
const routeId = (req.query.routeId as string) || config.defaultRoute.routeId;
const stop = stops.get(stopId);
if (!stop) {
return res.status(404).json({ error: `Stop ${stopId} not found` });
}
const radius = 50;
const nearbyUrl = `${config.baseUrl}/transport/planner/stops/nearbyTimes?latitude=${stop.stop_lat}&longitude=${stop.stop_lon}&radius=${radius}`;
const response = await fetch(nearbyUrl);
const nearbyData = await response.json() as any[];
const now = new Date();
const arrivals: any[] = [];
for (const stopData of nearbyData) {
if (stopData.id.toString() !== stopId) continue;
for (const pattern of stopData.patterns) {
if (pattern.routeId.toString() !== routeId) continue;
const routeInfo = routes.get(pattern.routeId.toString());
for (const stopTime of pattern.stopTimes) {
const serviceDay = new Date(stopTime.serviceDay * 1000);
const arrivalTime = new Date(serviceDay.getTime() + stopTime.realtimeArrival * 1000);
const scheduledTime = new Date(serviceDay.getTime() + stopTime.scheduledArrival * 1000);
const minutesUntil = Math.floor((arrivalTime.getTime() - now.getTime()) / 60000);
if (minutesUntil >= -2 && minutesUntil <= config.tracking.minutesAhead) {
arrivals.push({
arrivalTime: arrivalTime.toISOString(),
scheduledTime: scheduledTime.toISOString(),
minutesUntil: minutesUntil,
delaySeconds: stopTime.arrivalDelay,
headsign: stopTime.headsign,
isRealtime: stopTime.realtime,
realtimeState: stopTime.realtimeState,
});
// Log arrival to database (use scheduled time as primary timestamp to avoid duplicates)
try {
logArrival({
timestamp: scheduledTime.getTime(),
stopId,
routeId,
scheduledTime: scheduledTime.getTime(),
predictedTime: arrivalTime.getTime(),
delaySeconds: stopTime.arrivalDelay,
isRealtime: stopTime.realtime,
headsign: stopTime.headsign,
});
} catch (dbError) {
// Fail silently if database is not available
}
}
}
}
}
arrivals.sort((a, b) => new Date(a.arrivalTime).getTime() - new Date(b.arrivalTime).getTime());
res.json(arrivals);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch arrivals' });
}
});
app.get('/api/vehicles', async (req: Request, res: Response) => {
try {
const routeId = (req.query.routeId as string) || config.defaultRoute.routeId;
// Fetch all vehicles from JSON API
const vehiclesResponse = await fetch(config.apiEndpoints.vehiclesJson);
if (!vehiclesResponse.ok) {
throw new Error(`HTTP error! status: ${vehiclesResponse.status}`);
}
const allVehicles = await vehiclesResponse.json() as any[];
// Fetch trip updates to find which vehicles are on our route
const tripUpdatesResponse = await fetch(config.apiEndpoints.gtfsRtTripUpdates);
if (!tripUpdatesResponse.ok) {
console.warn('Could not fetch trip updates, returning all vehicles');
// Return all vehicles with basic info
res.json(allVehicles.slice(0, 20).map((v: any) => ({
id: v.id,
vehicleId: v.identificationNumber,
label: v.inventoryNumber,
lat: v.positionLatitude,
lon: v.positionLongitude,
bearing: v.positionBearing,
speed: v.positionSpeed,
timestamp: v.positionModifiedAt,
tripId: '',
currentStopSequence: 0,
currentStatus: 'UNKNOWN',
})));
return;
}
const buffer = await tripUpdatesResponse.arrayBuffer();
if (buffer.byteLength === 0) {
console.warn('Empty trip updates feed');
res.json([]);
return;
}
let feed;
try {
feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(
new Uint8Array(buffer)
);
} catch (decodeError) {
console.error('Failed to decode GTFS-RT feed:', decodeError);
res.json([]);
return;
}
// Find trip IDs and vehicle IDs for our route
const routeVehicleIds = new Set<string>();
const vehicleTripMap = new Map<string, any>();
for (const entity of feed.entity) {
if (!entity.tripUpdate) continue;
const tripUpdate = entity.tripUpdate;
// Filter by target route
if (tripUpdate.trip?.routeId !== routeId) continue;
// Get vehicle ID if available
if (tripUpdate.vehicle?.id) {
routeVehicleIds.add(tripUpdate.vehicle.id);
vehicleTripMap.set(tripUpdate.vehicle.id, {
tripId: tripUpdate.trip.tripId,
routeId: tripUpdate.trip.routeId,
});
}
// Also try vehicle label
if (tripUpdate.vehicle?.label) {
routeVehicleIds.add(tripUpdate.vehicle.label);
vehicleTripMap.set(tripUpdate.vehicle.label, {
tripId: tripUpdate.trip.tripId,
routeId: tripUpdate.trip.routeId,
});
}
}
// Filter vehicles by route
const activeVehicles: any[] = [];
for (const vehicle of allVehicles) {
// Try to match by identification number or inventory number
const vehicleId = vehicle.identificationNumber || vehicle.inventoryNumber?.toString();
if (vehicleId && (routeVehicleIds.has(vehicleId) || routeVehicleIds.has(vehicle.inventoryNumber?.toString()))) {
const tripInfo = vehicleTripMap.get(vehicleId) || vehicleTripMap.get(vehicle.inventoryNumber?.toString());
activeVehicles.push({
id: vehicle.id,
vehicleId: vehicle.identificationNumber,
label: vehicle.inventoryNumber,
lat: vehicle.positionLatitude,
lon: vehicle.positionLongitude,
bearing: vehicle.positionBearing,
speed: vehicle.positionSpeed,
timestamp: vehicle.positionModifiedAt,
tripId: tripInfo?.tripId || '',
currentStopSequence: 0,
currentStatus: vehicle.status === 2 ? 'IN_TRANSIT_TO' : 'UNKNOWN',
});
}
}
console.log(`Found ${routeVehicleIds.size} vehicle IDs for route ${routeId}`);
console.log(`Matched ${activeVehicles.length} vehicles from JSON API`);
// Log vehicle positions to database
try {
const positions = activeVehicles.map(v => ({
timestamp: Date.now(),
vehicleId: v.vehicleId || v.label,
routeId,
tripId: v.tripId,
latitude: v.lat,
longitude: v.lon,
speed: v.speed,
bearing: v.bearing,
currentStatus: v.currentStatus,
}));
logVehiclePositions(positions);
} catch (dbError) {
// Fail silently if database is not available
}
res.json(activeVehicles);
} catch (error) {
console.error('Error fetching vehicles:', error);
res.status(500).json({ error: 'Failed to fetch vehicles', details: String(error) });
}
});
// Historical Data API Endpoints
app.get('/api/stats/db', async (req: Request, res: Response) => {
try {
const stats = await getDatabaseStats();
res.json(stats);
} catch (error) {
res.status(500).json({ error: 'Database not available' });
}
});
app.get('/api/history/vehicle/:vehicleId', async (req: Request, res: Response) => {
try {
const vehicleId = req.params.vehicleId;
const hours = parseInt(req.query.hours as string) || 24;
const endTime = Date.now();
const startTime = endTime - (hours * 60 * 60 * 1000);
const history = await getVehicleHistory(vehicleId, startTime, endTime);
res.json(history);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch vehicle history' });
}
});
app.get('/api/history/route/:routeId/vehicles', async (req: Request, res: Response) => {
try {
const routeId = req.params.routeId;
const hours = parseInt(req.query.hours as string) || 24;
const endTime = Date.now();
const startTime = endTime - (hours * 60 * 60 * 1000);
const positions = await getRouteVehiclePositions(routeId, startTime, endTime);
res.json(positions);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch route vehicle history' });
}
});
app.get('/api/history/stop/:stopId/arrivals', async (req: Request, res: Response) => {
try {
const stopId = req.params.stopId;
const routeId = (req.query.routeId as string) || '';
const hours = parseInt(req.query.hours as string) || 24;
const endTime = Date.now();
const startTime = endTime - (hours * 60 * 60 * 1000);
const arrivals = await getStopArrivalHistory(stopId, routeId, startTime, endTime);
res.json(arrivals);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch arrival history' });
}
});
app.get('/api/stats/route/:routeId/delays', async (req: Request, res: Response) => {
try {
const routeId = req.params.routeId;
const hours = parseInt(req.query.hours as string) || 24;
const stats = await getRouteDelayStats(routeId, hours);
res.json(stats);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch delay stats' });
}
});
app.get('/api/stats/stop/:stopId/delays', async (req: Request, res: Response) => {
try {
const stopId = req.params.stopId;
const hours = parseInt(req.query.hours as string) || 24;
const stats = await getStopDelayStats(stopId, hours);
res.json(stats);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch delay stats' });
}
});
app.get('/api/stats/route/:routeId/hourly', async (req: Request, res: Response) => {
try {
const routeId = req.params.routeId;
const days = parseInt(req.query.days as string) || 7;
const pattern = await getRouteHourlyPattern(routeId, days);
res.json(pattern);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch hourly pattern' });
}
});
// Start server
app.listen(config.server.port, () => {
console.log(`
===========================================================================
BUS TRACKER WEB APP
===========================================================================
Server running at: http://localhost:${config.server.port}
Pages:
- Main Tracker: http://localhost:${config.server.port}
- Analytics: http://localhost:${config.server.port}/analytics.html
- API Docs: http://localhost:${config.server.port}/api-docs.html
Features:
- Real-time bus arrivals and vehicle tracking
- Interactive map interface
- Historical analytics and delay statistics
- Complete REST API with OpenAPI specification
Press Ctrl+C to stop the server
===========================================================================
`);
});

View File

@@ -1,86 +0,0 @@
#!/usr/bin/env ts-node
/**
* Downloads and extracts GTFS static data
* Run: npm run setup-gtfs
*/
import * as fs from 'fs';
import * as path from 'path';
import * as https from 'https';
import { execSync } from 'child_process';
const GTFS_ZIP_URL = 'https://www.modeshift.app/api/v1/9814b106-2afe-47c8-919b-bdec6a5e521e/transport/gtfs/gtfs.zip';
const GTFS_DIR = path.join(__dirname, 'gtfs');
const ZIP_FILE = path.join(__dirname, 'gtfs.zip');
async function downloadFile(url: string, dest: string): Promise<void> {
return new Promise((resolve, reject) => {
console.log(`Downloading GTFS data from ${url}...`);
const file = fs.createWriteStream(dest);
https.get(url, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
if (response.headers.location) {
https.get(response.headers.location, (redirectResponse) => {
redirectResponse.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}).on('error', reject);
}
} else {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}
}).on('error', (err) => {
fs.unlink(dest, () => reject(err));
});
});
}
async function extractZip(zipPath: string, destDir: string): Promise<void> {
console.log(`Extracting GTFS data to ${destDir}...`);
// Create directory if it doesn't exist
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
// Use unzip command (available on most Linux/Mac systems)
try {
execSync(`unzip -o "${zipPath}" -d "${destDir}"`, { stdio: 'inherit' });
} catch (error) {
throw new Error('Failed to extract zip. Make sure unzip is installed.');
}
}
async function main() {
try {
// Download GTFS zip
await downloadFile(GTFS_ZIP_URL, ZIP_FILE);
console.log('✓ Download complete');
// Extract zip
await extractZip(ZIP_FILE, GTFS_DIR);
console.log('✓ Extraction complete');
// Clean up zip file
fs.unlinkSync(ZIP_FILE);
console.log('✓ Cleanup complete');
// List extracted files
const files = fs.readdirSync(GTFS_DIR);
console.log(`\n✓ GTFS data ready! Files extracted:\n${files.map(f => ` - ${f}`).join('\n')}`);
} catch (error) {
console.error('Error setting up GTFS data:', error);
process.exit(1);
}
}
main();

View File

@@ -3,6 +3,7 @@
"target": "ES2020", "target": "ES2020",
"module": "commonjs", "module": "commonjs",
"lib": ["ES2020"], "lib": ["ES2020"],
"types": ["node"],
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./", "rootDir": "./",
"strict": true, "strict": true,
@@ -16,6 +17,6 @@
"noImplicitAny": true, "noImplicitAny": true,
"strictNullChecks": true "strictNullChecks": true
}, },
"include": ["*.ts"], "include": ["**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }