UNPKG

gtfs

Version:

Import GTFS transit data into SQLite and query routes, stops, times, fares and more

1,318 lines (1,310 loc) 102 kB
import { S as tripUpdates, b as vehiclePositions, t as models_exports, v as serviceAlertInformedEntities, x as stopTimeUpdates, y as serviceAlerts } from "./models-2shTGms8.js"; import path from "node:path"; import { createReadStream, existsSync, lstatSync, mkdtempSync } from "node:fs"; import { cp, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; import { parse } from "csv-parse"; import fs from "fs"; import Database$1 from "better-sqlite3"; import { homedir, tmpdir } from "node:os"; import { compact, filter, get, groupBy, last, noop, omit, omitBy, orderBy, pick, snakeCase, sortBy, without } from "lodash-es"; import sanitize from "sanitize-filename"; import StreamZip from "node-stream-zip"; import { clearLine, cursorTo } from "node:readline"; import * as colors from "yoctocolors"; import { feature, featureCollection } from "@turf/helpers"; import GtfsRealtimeBindings from "gtfs-realtime-bindings"; import sqlString from "sqlstring-sqlite"; import Long from "long"; import { stringify } from "csv-stringify"; //#region src/lib/log-utils.ts /** * Creates a logging function based on configuration settings * @param {Config} config - Configuration object containing logging preferences * @returns {LogFunction} Logging function that writes to stdout, or noop if verbose is false * @example * const logger = log({ verbose: true }); * logger('Processing...', true); // Overwrites current line * logger('Done!'); // Writes on new line */ function log(config) { if (config.verbose === false) return noop; if (config.logFunction) return config.logFunction; return (text, overwrite = false) => { if (overwrite && process.stdout.isTTY) { clearLine(process.stdout, 0); cursorTo(process.stdout, 0); } else process.stdout.write("\n"); process.stdout.write(text); }; } /** * Creates a warning logging function * @param {Config} config - Configuration object containing logging preferences * @returns {(text: string) => void} Function that logs formatted warning messages * @example * const warnLogger = logWarning(config); * warnLogger('Resource not found'); // Outputs yellow warning message */ function logWarning(config) { if (config.logFunction) return config.logFunction; return (text) => { process.stdout.write(`\n${formatWarning(text)}\n`); }; } /** * Creates an error logging function * @param {Config} config - Configuration object containing logging preferences * @returns {(text: string) => void} Function that logs formatted error messages * @example * const errorLogger = logError(config); * errorLogger('Failed to connect'); // Outputs red error message */ function logError(config) { if (config.logFunction) return config.logFunction; return (text) => { process.stdout.write(`\n${formatError(text)}\n`); }; } /** * Formats warning text with yellow color and underline * @param {string} text - The warning message to format * @returns {string} Formatted warning message in yellow with underlined "Warning" prefix * @example * const formattedWarning = formatWarning('Resource not found'); * console.log(formattedWarning); // Yellow "Warning: Resource not found" */ function formatWarning(text) { return colors.yellow(`${colors.underline("Warning")}: ${text}`); } /** * Formats error text with red color and underline * @param {Error | string} error - The error object or message to format * @returns {string} Formatted error message in red with underlined "Error" prefix * @example * const formattedError = formatError(new Error('Connection failed')); * console.log(formattedError); // Red "Error: Connection failed" */ function formatError(error) { const cleanMessage = (error instanceof Error ? error.message : error).replace(/^Error:\s*/i, ""); return colors.red(`${colors.underline("Error")}: ${cleanMessage}`); } /** * Formats an error's stack trace for terminal output * @param {Error | string} error - The error object or message to format * @returns {string} Dimmed stack trace, or an empty string if none is available * @example * const formattedStack = formatStackTrace(new Error('Connection failed')); * console.error(formattedStack); */ function formatStackTrace(error) { if (error instanceof Error && error.stack) return colors.dim(error.stack); return ""; } //#endregion //#region src/lib/file-utils.ts const homeDirectory = homedir(); /** * Attempts to parse and load configuration from various sources * Priority: 1. CLI config path 2. CLI direct args 3. ./config.json * @param {ConfigArgs} argv - Command line arguments * @throws {Error} If configuration cannot be found or parsed * @returns {Promise<Record<string, any>>} Parsed configuration object * @example * const config = await getConfig({ configPath: './my-config.json' }); */ async function getConfig(argv) { let config; let data; try { if (argv.configPath) { data = await readFile(path.resolve(untildify(argv.configPath)), "utf8"); config = Object.assign(JSON.parse(data), argv); } else if (argv.gtfsPath || argv.gtfsUrl || argv.sqlitePath) config = { agencies: [...argv.gtfsPath ? [{ path: argv.gtfsPath }] : [], ...argv.gtfsUrl ? [{ url: argv.gtfsUrl }] : []], ...omit(argv, ["path", "url"]) }; else if (existsSync(path.resolve("./config.json"))) { data = await readFile(path.resolve("./config.json"), "utf8"); config = Object.assign(JSON.parse(data), argv); log(config)("Using configuration from ./config.json"); } else throw new Error("Cannot find configuration file. Use config-sample.json as a starting point, pass --configPath option."); return config; } catch (error) { if (error instanceof SyntaxError) throw new Error(`Cannot parse configuration file. Check to ensure that it is valid JSON. Error: ${error.message}`, { cause: error }); throw error; } } /** * Prepares a directory for saving files by clearing its contents * @param {string} exportPath - Path to the directory to prepare * @returns {Promise<void>} * @example * await prepDirectory('./output'); */ async function prepDirectory(exportPath) { await rm(exportPath, { recursive: true, force: true }); await mkdir(exportPath, { recursive: true }); } /** * Extracts contents of a zip file to specified directory * @param {string} zipfilePath - Path to the zip file * @param {string} exportPath - Directory to extract contents to * @returns {Promise<void>} * @throws {Error} If zip file cannot be opened or extracted * @example * await unzip('./data.zip', './extracted'); */ async function unzip(zipfilePath, exportPath) { try { const zip = new StreamZip.async({ file: zipfilePath }); await zip.extract(null, exportPath); await zip.close(); } catch (error) { throw new Error(`Failed to extract zip file: ${error instanceof Error ? error.message : "Unknown error"}`, { cause: error }); } } /** * Generates a safe folder name from input string * Converts to snake_case and removes unsafe characters * @param {string} folderName - Input string to convert to folder name * @returns {string} Sanitized folder name * @example * generateFolderName('My Folder!') // returns 'my_folder' */ function generateFolderName(folderName) { if (!folderName || typeof folderName !== "string") throw new Error("Folder name must be a non-empty string"); return snakeCase(sanitize(folderName)); } /** * Converts a tilde path to a full path * @param pathWithTilde The path to convert * @returns The full path */ function untildify(pathWithTilde) { return homeDirectory ? pathWithTilde.replace(/^~(?=$|\/|\\)/, homeDirectory) : pathWithTilde; } /** * Creates a new, unique temporary directory and returns its path * @returns {string} Path to the newly created temporary directory */ function temporaryDirectory() { return mkdtempSync(path.join(tmpdir(), "gtfs-")); } //#endregion //#region src/lib/errors.ts let GtfsErrorCategory = /* @__PURE__ */ function(GtfsErrorCategory) { GtfsErrorCategory["CONFIG"] = "config"; GtfsErrorCategory["DOWNLOAD"] = "download"; GtfsErrorCategory["ZIP"] = "zip"; GtfsErrorCategory["VALIDATION"] = "validation"; GtfsErrorCategory["DATABASE"] = "database"; GtfsErrorCategory["PARSE"] = "parse"; GtfsErrorCategory["QUERY"] = "query"; GtfsErrorCategory["INTERNAL"] = "internal"; return GtfsErrorCategory; }({}); /** * Error codes are a public API contract and must remain stable across * minor/patch releases. */ let GtfsErrorCode = /* @__PURE__ */ function(GtfsErrorCode) { GtfsErrorCode["GTFS_DOWNLOAD_HTTP"] = "GTFS_DOWNLOAD_HTTP"; GtfsErrorCode["GTFS_DOWNLOAD_FAILED"] = "GTFS_DOWNLOAD_FAILED"; GtfsErrorCode["GTFS_ZIP_INVALID"] = "GTFS_ZIP_INVALID"; GtfsErrorCode["GTFS_REQUIRED_FIELD_MISSING"] = "GTFS_REQUIRED_FIELD_MISSING"; GtfsErrorCode["GTFS_INVALID_DATE"] = "GTFS_INVALID_DATE"; GtfsErrorCode["GTFS_CONFIG_INVALID"] = "GTFS_CONFIG_INVALID"; GtfsErrorCode["DB_OPEN_FAILED"] = "DB_OPEN_FAILED"; GtfsErrorCode["GTFS_DB_OPERATION_FAILED"] = "GTFS_DB_OPERATION_FAILED"; GtfsErrorCode["GTFS_JSON_INVALID"] = "GTFS_JSON_INVALID"; GtfsErrorCode["GTFS_UNSUPPORTED_FILE_TYPE"] = "GTFS_UNSUPPORTED_FILE_TYPE"; GtfsErrorCode["GTFS_CSV_PARSE_FAILED"] = "GTFS_CSV_PARSE_FAILED"; GtfsErrorCode["GTFS_QUERY_INVALID"] = "GTFS_QUERY_INVALID"; return GtfsErrorCode; }({}); let GtfsWarningCode = /* @__PURE__ */ function(GtfsWarningCode) { GtfsWarningCode["GTFS_DUPLICATE_PRIMARY_KEY"] = "GTFS_DUPLICATE_PRIMARY_KEY"; return GtfsWarningCode; }({}); var GtfsError = class extends Error { code; category; isOperational; statusCode; details; constructor(message, options) { super(message, { cause: options.cause }); this.name = "GtfsError"; this.code = options.code; this.category = options.category; this.isOperational = options.isOperational ?? true; this.statusCode = options.statusCode; this.details = options.details; } }; function isGtfsError(error) { if (!error || typeof error !== "object") return false; const candidate = error; return candidate.name === "GtfsError" && typeof candidate.message === "string" && typeof candidate.code === "string" && typeof candidate.category === "string" && typeof candidate.isOperational === "boolean"; } function isGtfsValidationError(error) { return isGtfsError(error) && error.category === "validation"; } function toGtfsError(error, fallback) { if (isGtfsError(error)) return error; return new GtfsError(fallback.message, { ...fallback, cause: error }); } function createImportReport() { return { errors: [], warnings: [], errorCountsByCode: {}, warningCountsByCode: {} }; } function addImportError(report, error) { report.errors.push(error); report.errorCountsByCode[error.code] = (report.errorCountsByCode[error.code] ?? 0) + 1; } function addImportWarning(report, warning) { report.warnings.push(warning); report.warningCountsByCode[warning.code] = (report.warningCountsByCode[warning.code] ?? 0) + 1; } function formatGtfsError(error, options = { verbosity: "developer" }) { if (!isGtfsError(error)) { const message = error instanceof Error ? error.message : String(error); return options.verbosity === "user" ? message : `UNKNOWN_ERROR: ${message}`; } if (options.verbosity === "user") return error.message; return [ `${error.code}: ${error.message}`, `category=${error.category}`, error.statusCode !== void 0 ? `statusCode=${error.statusCode}` : null, error.details ? `details=${JSON.stringify(error.details)}` : null ].filter(Boolean).join(" | "); } //#endregion //#region src/lib/db.ts const dbs = {}; function setupDb(sqlitePath) { const db = new Database$1(untildify(sqlitePath)); db.pragma("journal_mode = OFF"); db.pragma("synchronous = OFF"); db.pragma("temp_store = MEMORY"); db.pragma("cache_size = -256000"); dbs[sqlitePath] = db; return db; } function openDb(config = null) { if (config) { const { sqlitePath = ":memory:", db } = config; if (db) return db; if (dbs[sqlitePath]) return dbs[sqlitePath]; return setupDb(sqlitePath); } if (Object.keys(dbs).length === 0) return setupDb(":memory:"); if (Object.keys(dbs).length === 1) { const filename = Object.keys(dbs)[0]; return dbs[filename]; } if (Object.keys(dbs).length > 1) throw new GtfsError("Multiple databases open, please specify which one to use.", { code: "GTFS_DB_OPERATION_FAILED", category: "database", details: { openDatabaseCount: Object.keys(dbs).length } }); throw new GtfsError("Unable to find database connection.", { code: "GTFS_DB_OPERATION_FAILED", category: "database" }); } function closeDb(db = null) { if (Object.keys(dbs).length === 0) throw new GtfsError("No database connection. Call `openDb(config)` before using any methods.", { code: "GTFS_DB_OPERATION_FAILED", category: "database" }); if (!db) { if (Object.keys(dbs).length > 1) throw new GtfsError("Multiple database connections. Pass the db you want to close as a parameter to `closeDb`.", { code: "GTFS_DB_OPERATION_FAILED", category: "database", details: { openDatabaseCount: Object.keys(dbs).length } }); db = dbs[Object.keys(dbs)[0]]; } db.close(); delete dbs[db.name]; } function deleteDb(db = null) { if (Object.keys(dbs).length === 0) throw new GtfsError("No database connection. Call `openDb(config)` before using any methods.", { code: "GTFS_DB_OPERATION_FAILED", category: "database" }); if (!db) { if (Object.keys(dbs).length > 1) throw new GtfsError("Multiple database connections. Pass the db you want to delete as a parameter to `deleteDb`.", { code: "GTFS_DB_OPERATION_FAILED", category: "database", details: { openDatabaseCount: Object.keys(dbs).length } }); db = dbs[Object.keys(dbs)[0]]; } db.close(); if (db.name !== ":memory:") fs.unlinkSync(db.name); delete dbs[db.name]; } //#endregion //#region src/lib/geojson-utils.ts /** * Validates if a string is valid JSON * @param {string} string - The string to validate as JSON * @returns {boolean} True if string is valid JSON, false otherwise * @example * isValidJSON('{"key": "value"}') // returns true * isValidJSON('invalid json') // returns false */ function isValidJSON(string) { try { JSON.parse(string); return true; } catch { return false; } } /** * Validates if an array of positions forms a valid LineString * @param {Position[]} [lineString] - Array of coordinate pairs * @returns {boolean} True if lineString is valid, false otherwise */ function isValidLineString(lineString) { if (!lineString || lineString.length <= 1) return false; if (lineString.length === 2) { const [[x1, y1], [x2, y2]] = lineString; return !(x1 === x2 && y1 === y2); } return true; } /** * Consolidates shape groups into unique line segments * @param {Shape[][]} shapeGroups - Array of shape point groups * @returns {Position[][]} Array of consolidated line strings */ function consolidateShapes(shapeGroups) { const keys = /* @__PURE__ */ new Set(); const segmentsArray = shapeGroups.map((shapes) => shapes.reduce((memo, point, idx) => { if (idx > 0) { const prevPoint = shapes[idx - 1]; memo.push([[prevPoint.shape_pt_lon, prevPoint.shape_pt_lat], [point.shape_pt_lon, point.shape_pt_lat]]); } return memo; }, [])); const consolidatedLineStrings = []; for (const segments of segmentsArray) { consolidatedLineStrings.push([]); for (const segment of segments) { const [[x1, y1], [x2, y2]] = segment; const key = x1 < x2 || x1 === x2 && y1 <= y2 ? `${x1},${y1},${x2},${y2}` : `${x2},${y2},${x1},${y1}`; const currentLine = last(consolidatedLineStrings); if (!currentLine || keys.has(key)) { consolidatedLineStrings.push([]); continue; } if (currentLine.length === 0) currentLine.push(segment[0]); currentLine.push(segment[1]); keys.add(key); } } return filter(consolidatedLineStrings, isValidLineString); } /** * Formats a color string to hex format * @param {string | null | undefined} color - Color string to format * @returns {string | undefined} Formatted hex color or undefined * @example * formatHexColor('FF0000') // returns '#FF0000' */ function formatHexColor(color) { if (!color) return void 0; return `#${color}`; } /** * Formats properties object by cleaning null values and formatting colors * @param {Record<string, unknown>} properties - Properties object to format * @returns {Record<string, unknown>} Formatted properties object */ function formatProperties(properties) { const formattedProperties = omitBy(properties, (value) => value == null); const formattedRouteColor = formatHexColor(properties.route_color); const formattedRouteTextColor = formatHexColor(properties.route_text_color); if (formattedRouteColor) formattedProperties.route_color = formattedRouteColor; if (formattedRouteTextColor) formattedProperties.route_text_color = formattedRouteTextColor; if (properties.routes && Array.isArray(properties.routes)) formattedProperties.routes = properties.routes.map((route) => formatProperties(route)); return formattedProperties; } /** * Converts GTFS shapes to GeoJSON Feature * @param {Shape[]} shapes - Array of GTFS shapes * @param {Record<string, unknown>} [properties={}] - Properties to add to the feature * @returns {Feature} GeoJSON Feature with MultiLineString geometry */ function shapesToGeoJSONFeature(shapes, properties = {}) { return feature({ type: "MultiLineString", coordinates: consolidateShapes(Object.values(groupBy(shapes, "shape_id")).map((shapeGroup) => sortBy(shapeGroup, "shape_pt_sequence"))) }, formatProperties(properties)); } /** * Converts GTFS stops to GeoJSON FeatureCollection * @param {Stop[]} stops - Array of GTFS stops * @returns {FeatureCollection} GeoJSON FeatureCollection of Point features */ function stopsToGeoJSONFeatureCollection(stops) { return featureCollection(compact(stops.map((stop) => { if (!stop.stop_lon || !stop.stop_lat) return; return feature({ type: "Point", coordinates: [stop.stop_lon, stop.stop_lat] }, formatProperties(omit(stop, ["stop_lat", "stop_lon"]))); }))); } //#endregion //#region src/lib/utils.ts /** * Validates the configuration object for GTFS import * @param config The configuration object to validate * @throws Error if agencies are missing or if agency lacks both url and path * @returns The validated config object */ function validateConfigForImport(config) { if (!config.agencies || config.agencies.length === 0) throw new GtfsError("No `agencies` specified in config", { code: "GTFS_CONFIG_INVALID", category: "config", details: { field: "agencies" } }); for (const [index, agency] of config.agencies.entries()) if (!agency.path && !agency.url) throw new GtfsError(`No Agency \`url\` or \`path\` specified in config for agency index ${index}.`, { code: "GTFS_CONFIG_INVALID", category: "config", details: { agencyIndex: index } }); return config; } /** * Initializes configuration with default values * @param initialConfig The user-provided configuration * @returns Merged configuration with defaults */ function setDefaultConfig(initialConfig) { return { sqlitePath: ":memory:", ignoreDuplicates: false, ignoreErrors: false, gtfsRealtimeExpirationSeconds: 0, verbose: true, downloadTimeout: 3e4, ...initialConfig }; } /** * Converts a Long timestamp to ISO date string * @param longDate Object containing high, low, and unsigned values * @returns ISO formatted date string */ function convertLongTimeToDate(longDate) { const { high, low, unsigned } = longDate; return (/* @__PURE__ */ new Date(Long.fromBits(low, high, unsigned).toNumber() * 1e3)).toISOString(); } /** * Converts time string in HH:mm:ss format to seconds since midnight * @param time Time string in HH:mm:ss format * @returns Number of seconds since midnight, or null if invalid format */ function calculateSecondsFromMidnight(time) { if (!time || typeof time !== "string") return null; const [hours, minutes, seconds] = time.split(":").map(Number); if ([ hours, minutes, seconds ].some(isNaN) || minutes >= 60 || seconds >= 60) return null; return hours * 3600 + minutes * 60 + seconds; } /** * Ensures time components have leading zeros (e.g., "9:5:1" -> "09:05:01") * @param time Time string in HH:mm:ss format * @returns Formatted time string with leading zeros, or null if invalid format */ function padLeadingZeros(time) { const split = time.split(":").map((d) => String(Number(d)).padStart(2, "0")); if (split.length !== 3) return null; return split.join(":"); } /** * Formats SQL SELECT clause from array of field names or field mapping object * @param fields Array of field names or object mapping source to alias * @returns Formatted SELECT clause */ function formatSelectClause(fields) { if (Array.isArray(fields)) return `SELECT ${fields.length > 0 ? fields.map((fieldName) => sqlString.escapeId(fieldName)).join(", ") : "*"}`; return `SELECT ${Object.entries(fields).map((key) => `${sqlString.escapeId(key[0])} AS ${sqlString.escapeId(key[1])}`).join(", ")}`; } /** * Formats SQL JOIN clause from array of join configurations * @param joinObject Array of join options * @returns Formatted JOIN clause */ function formatJoinClause(joinObject) { return joinObject.map((data) => `${data.type ? data.type + " JOIN" : "INNER JOIN"} ${sqlString.escapeId(data.table)} ON ${data.on}`).join(" "); } /** * Converts degrees to radians * @param angle Angle in degrees * @returns Angle in radians */ function degree2radian(angle) { return angle * Math.PI / 180; } /** * Converts radians to degrees * @param angle Angle in radians * @returns Angle in degrees */ function radian2degree(angle) { return angle / Math.PI * 180; } const EARTH_RADIUS_METERS = 6371e3; /** * Creates SQL WHERE clause for geographic bounding box search * @param latitudeDegree Center latitude in degrees * @param longitudeDegree Center longitude in degrees * @param boundingBoxSideMeters Size of bounding box in meters * @returns SQL WHERE clause for bounding box search */ function formatWhereClauseBoundingBox(latitudeDegree, longitudeDegree, boundingBoxSideMeters) { const lat = Number(latitudeDegree); const lon = Number(longitudeDegree); if (isNaN(lat) || isNaN(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) throw new GtfsError("Invalid latitude or longitude values", { code: "GTFS_QUERY_INVALID", category: "query", details: { latitudeDegree, longitudeDegree, boundingBoxSideMeters } }); const latitudeRadian = degree2radian(lat); const radiusFromLatitude = Math.cos(latitudeRadian) * EARTH_RADIUS_METERS; const halfSide = boundingBoxSideMeters / 2; const deltaLatitude = radian2degree(halfSide / EARTH_RADIUS_METERS); const deltaLongitude = radian2degree(halfSide / radiusFromLatitude); return [`stop_lat BETWEEN ${lat - deltaLatitude} AND ${lat + deltaLatitude}`, `stop_lon BETWEEN ${lon - deltaLongitude} AND ${lon + deltaLongitude}`].join(" AND "); } /** * Formats SQL WHERE clause for a single key-value pair * @param key Column name * @param value Single value, array of values, or null * @returns Formatted WHERE clause condition */ function formatWhereClause(key, value) { if (Array.isArray(value)) { let whereClause = `${sqlString.escapeId(key)} IN (${value.filter((v) => v !== null).map((v) => sqlString.escape(v)).join(", ")})`; if (value.includes(null)) whereClause = `(${whereClause} OR ${sqlString.escapeId(key)} IS NULL)`; return whereClause; } if (value === null) return `${sqlString.escapeId(key)} IS NULL`; return `${sqlString.escapeId(key)} = ${sqlString.escape(value)}`; } /** * Formats complete SQL WHERE clause from query object * @param query Object containing column-value pairs * @returns Formatted WHERE clause or empty string if no conditions */ function formatWhereClauses(query) { if (Object.keys(query).length === 0) return ""; return `WHERE ${Object.entries(query).map(([key, value]) => formatWhereClause(key, value)).join(" AND ")}`; } /** * Formats SQL ORDER BY clause from array of sorting criteria * @param orderBy Array of [column, direction] tuples * @returns Formatted ORDER BY clause */ function formatOrderByClause(orderBy) { let orderByClause = ""; if (orderBy.length > 0) { orderByClause += "ORDER BY "; orderByClause += orderBy.map(([key, value]) => { const direction = value === "DESC" ? "DESC" : "ASC"; return `${sqlString.escapeId(key)} ${direction}`; }).join(", "); } return orderByClause; } /** * Gets day of week name from YYYYMMDD date number * @param date Date in YYYYMMDD format * @returns Lowercase day name (sunday-saturday) */ function getDayOfWeekFromDate(date) { const DAYS_OF_WEEK = [ "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday" ]; if (!Number.isInteger(date) || date.toString().length !== 8) throw new GtfsError("Date must be in YYYYMMDD format", { code: "GTFS_INVALID_DATE", category: "validation", details: { value: date } }); const year = Math.floor(date / 1e4); const month = Math.floor(date % 1e4 / 100); const day = date % 100; const dateObj = new Date(year, month - 1, day); if (dateObj.toString() === "Invalid Date") throw new GtfsError("Invalid date", { code: "GTFS_INVALID_DATE", category: "validation", details: { value: date } }); return DAYS_OF_WEEK[dateObj.getDay()]; } /** * Formats a numeric value according to the decimal precision rules of the specified currency, * without any currency symbols or separators. * @param value The numeric value to format (e.g., 10.5) * @param currency The ISO 4217 currency code (e.g., 'USD', 'JPY', 'EUR') * @returns The formatted string with appropriate decimal places * Examples: * - formatCurrency(10.5, 'USD') => '10.50' // USD uses 2 decimal places * - formatCurrency(10.5, 'JPY') => '10' // JPY uses 0 decimal places * - formatCurrency(10.523, 'BHD') => '10.523' // BHD uses 3 decimal places */ function formatCurrency(value, currency) { const parts = new Intl.NumberFormat(void 0, { style: "currency", currency }).formatToParts(value); const integerPart = parts.find((part) => part.type === "integer")?.value ?? "0"; const fractionPart = parts.find((part) => part.type === "fraction")?.value ?? ""; return `${integerPart}${fractionPart !== "" ? `.${fractionPart}` : ""}`; } /** * Gets the timestamp column name for a given column name * @param columnName The column name * @returns The timestamp column name */ function getTimestampColumnName(columnName) { return columnName.endsWith("time") ? `${columnName}stamp` : `${columnName}_timestamp`; } /** * Applies a prefix to a value if the column should be prefixed and the value is not null * @param value The value to prefix * @param columnShouldBePrefixed Whether the column should be prefixed * @param prefix The prefix to apply * @returns The value with the prefix applied if the column should be prefixed and the value is not null */ function applyPrefixToValue(value, columnShouldBePrefixed, prefix) { if (!columnShouldBePrefixed || prefix === void 0 || value === null || value === void 0) return value; return `${prefix}${value}`; } /** * Pluralizes a word based on the count * @param singularWord The singular word * @param pluralWord The plural word * @param count The count of the word * @returns The pluralized word */ function pluralize(singularWord, pluralWord, count) { return count === 1 ? singularWord : pluralWord; } /** * Runs an async callback for each item in an array one at a time, in order * @param items Items to iterate over * @param callback Async function called with each item in turn * @returns Array of results in the same order as `items` */ async function mapSeries(items, callback) { const results = []; for (const item of items) results.push(await callback(item)); return results; } //#endregion //#region src/lib/import-gtfs-realtime.ts const BATCH_SIZE$1 = 1e3; const MAX_RETRIES = 3; const RETRY_DELAY = 1e3; /** * Prepares a field value for database insertion */ function prepareRealtimeFieldValue(entity, column, task) { if (column.name === "created_timestamp") return task.currentTimestamp; if (column.name === "expiration_timestamp") return task.currentTimestamp + task.gtfsRealtimeExpirationSeconds; const baseValue = column.source === void 0 ? column.default : get(entity, column.source, column.default); const prefixedValue = applyPrefixToValue(baseValue?.__isLong__ ? convertLongTimeToDate(baseValue) : baseValue, column.prefix, task.prefix); return column.type === "json" ? JSON.stringify(prefixedValue) : prefixedValue; } /** * Creates a prepared statement for a model */ function createPreparedStatement(db, model) { const columns = model.schema.map((column) => column.name); const placeholders = model.schema.map(() => "?").join(", "); return db.prepare(`REPLACE INTO ${model.filenameBase} (${columns.join(", ")}) VALUES (${placeholders})`); } /** * Processes entities in batches */ async function processBatch(items, batchSize, processor) { let totalRecordCount = 0; let totalErrorCount = 0; for (let i = 0; i < items.length; i += batchSize) { const batch = items.slice(i, i + batchSize); try { const result = await processor(batch); totalRecordCount += result.recordCount; totalErrorCount += result.errorCount; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); totalErrorCount += batch.length; console.error(`Batch processing error: ${errorMessage}`); } } return { recordCount: totalRecordCount, errorCount: totalErrorCount }; } /** * Fetches GTFS Realtime data */ async function fetchGtfsRealtimeData(type, task) { const urlConfig = getUrlConfig(type, task); if (!urlConfig) return null; task.log(`Importing - GTFS-Realtime from ${urlConfig.url}`); for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) try { const response = await fetch(urlConfig.url, { method: "GET", redirect: "follow", headers: { "User-Agent": "node-gtfs", ...urlConfig.headers ?? {}, "Accept-Encoding": "gzip" }, signal: task.downloadTimeout ? AbortSignal.timeout(task.downloadTimeout) : void 0 }); if (!response.ok) throw new GtfsError(`HTTP ${response.status}: ${response.statusText}`, { code: "GTFS_DOWNLOAD_HTTP", category: "download", statusCode: response.status, details: { url: urlConfig.url, status: response.status, statusText: response.statusText } }); const buffer = await response.arrayBuffer(); const message = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(new Uint8Array(buffer)); return GtfsRealtimeBindings.transit_realtime.FeedMessage.toObject(message, { enums: String, longs: String, bytes: String, defaults: false, arrays: true, objects: true, oneofs: true }); } catch (error) { const gtfsError = toGtfsError(error, { message: error instanceof Error ? error.message : String(error), code: "GTFS_DOWNLOAD_FAILED", category: "download", details: { type, url: urlConfig.url } }); if (attempt === MAX_RETRIES) { if (task.ignoreErrors) { task.logError(`Failed to fetch ${type} after ${MAX_RETRIES} attempts: ${gtfsError.message}`); if (task.report) addImportError(task.report, gtfsError); return null; } throw gtfsError; } task.logWarning(`Attempt ${attempt} failed for ${type}: ${gtfsError.message}`); await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY * attempt)); } return null; } /** * Gets URL configuration for a specific realtime type */ function getUrlConfig(type, task) { switch (type) { case "alerts": return task.realtimeAlerts; case "tripupdates": return task.realtimeTripUpdates; case "vehiclepositions": return task.realtimeVehiclePositions; default: return; } } /** * Creates a processor for service alerts */ function createServiceAlertsProcessor(db, task) { const alertStmt = createPreparedStatement(db, serviceAlerts); const informedEntityStmt = createPreparedStatement(db, serviceAlertInformedEntities); const deleteInformedEntitiesStmt = db.prepare(`DELETE FROM ${serviceAlertInformedEntities.filenameBase} WHERE alert_id = ?`); return async (batch) => { let recordCount = 0; let errorCount = 0; db.transaction(() => { for (const entity of batch) try { const alertId = applyPrefixToValue(entity.id, true, task.prefix); deleteInformedEntitiesStmt.run(alertId); const alertValues = serviceAlerts.schema.map((column) => prepareRealtimeFieldValue(entity, column, task)); alertStmt.run(alertValues); recordCount++; if (entity.alert?.informedEntity?.length) for (const informedEntity of entity.alert.informedEntity) { informedEntity.parent = entity; const entityValues = serviceAlertInformedEntities.schema.map((column) => prepareRealtimeFieldValue(informedEntity, column, task)); informedEntityStmt.run(entityValues); recordCount++; } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); errorCount++; task.logWarning(`Alert processing error: ${errorMessage}`); } })(); return { recordCount, errorCount }; }; } /** * Creates a processor for trip updates */ function createTripUpdatesProcessor(db, task) { const tripUpdateStmt = createPreparedStatement(db, tripUpdates); const stopTimeStmt = createPreparedStatement(db, stopTimeUpdates); const deleteStopTimesByTripStmt = db.prepare(`DELETE FROM ${stopTimeUpdates.filenameBase} WHERE trip_id = ? AND trip_start_time IS ?`); return async (batch) => { let recordCount = 0; let errorCount = 0; db.transaction(() => { for (const entity of batch) try { const tripUpdateValues = tripUpdates.schema.map((column) => prepareRealtimeFieldValue(entity, column, task)); tripUpdateStmt.run(tripUpdateValues); recordCount++; if (entity.tripUpdate?.stopTimeUpdate?.length) { const tripId = applyPrefixToValue(entity.tripUpdate?.trip?.tripId ?? null, true, task.prefix); const tripStartTime = entity.tripUpdate?.trip?.startTime ?? null; if (tripId !== null) deleteStopTimesByTripStmt.run(tripId, tripStartTime); for (const stopTimeUpdate of entity.tripUpdate.stopTimeUpdate) { stopTimeUpdate.parent = entity; const stopTimeValues = stopTimeUpdates.schema.map((column) => prepareRealtimeFieldValue(stopTimeUpdate, column, task)); stopTimeStmt.run(stopTimeValues); recordCount++; } } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); errorCount++; task.logWarning(`Trip update processing error: ${errorMessage}`); } })(); return { recordCount, errorCount }; }; } /** * Creates a processor for vehicle positions */ function createVehiclePositionsProcessor(db, task) { const vehiclePositionStmt = createPreparedStatement(db, vehiclePositions); return async (batch) => { let recordCount = 0; let errorCount = 0; db.transaction(() => { for (const entity of batch) try { const fieldValues = vehiclePositions.schema.map((column) => prepareRealtimeFieldValue(entity, column, task)); vehiclePositionStmt.run(fieldValues); recordCount++; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); errorCount++; task.logWarning(`Vehicle position processing error: ${errorMessage}`); } })(); return { recordCount, errorCount }; }; } /** * Removes expired GTFS-Realtime data */ function removeExpiredRealtimeData(config) { const db = openDb(config); log(config)(`Removing expired GTFS-Realtime data`); db.transaction(() => { for (const table of [ "vehicle_positions", "trip_updates", "stop_time_updates", "service_alerts", "service_alert_informed_entities" ]) db.prepare(`DELETE FROM ${table} WHERE expiration_timestamp <= strftime('%s','now')`).run(); })(); log(config)(`Removed expired GTFS-Realtime data\r`, true); } /** * Updates GTFS Realtime data */ async function updateGtfsRealtimeData(task) { if (!task.realtimeAlerts && !task.realtimeTripUpdates && !task.realtimeVehiclePositions) return; const [alertsData, tripUpdatesData, vehiclePositionsData] = await Promise.all([ task.realtimeAlerts?.url ? fetchGtfsRealtimeData("alerts", task) : null, task.realtimeTripUpdates?.url ? fetchGtfsRealtimeData("tripupdates", task) : null, task.realtimeVehiclePositions?.url ? fetchGtfsRealtimeData("vehiclepositions", task) : null ]); const db = openDb({ sqlitePath: task.sqlitePath }); const recordCounts = { alerts: 0, tripupdates: 0, vehiclepositions: 0 }; if (alertsData?.entity?.length) recordCounts.alerts = (await processBatch(alertsData.entity, BATCH_SIZE$1, createServiceAlertsProcessor(db, task))).recordCount; if (tripUpdatesData?.entity?.length) recordCounts.tripupdates = (await processBatch(tripUpdatesData.entity, BATCH_SIZE$1, createTripUpdatesProcessor(db, task))).recordCount; if (vehiclePositionsData?.entity?.length) recordCounts.vehiclepositions = (await processBatch(vehiclePositionsData.entity, BATCH_SIZE$1, createVehiclePositionsProcessor(db, task))).recordCount; task.log(`GTFS-Realtime import complete: ${recordCounts.alerts} alerts, ${recordCounts.tripupdates} trip updates, ${recordCounts.vehiclepositions} vehicle positions`); } /** * Main function to update GTFS Realtime data */ async function updateGtfsRealtime(initialConfig) { const config = setDefaultConfig(initialConfig); validateConfigForImport(config); try { openDb(config); const agencyCount = config.agencies.length; log(config)(`Starting GTFS-Realtime refresh for ${pluralize("agency", "agencies", agencyCount)} using SQLite database at ${config.sqlitePath}`); removeExpiredRealtimeData(config); await mapSeries(config.agencies, async (agency) => { let task; try { task = { realtimeAlerts: agency.realtimeAlerts, realtimeTripUpdates: agency.realtimeTripUpdates, realtimeVehiclePositions: agency.realtimeVehiclePositions, downloadTimeout: config.downloadTimeout, gtfsRealtimeExpirationSeconds: config.gtfsRealtimeExpirationSeconds, ignoreErrors: config.ignoreErrors, sqlitePath: config.sqlitePath, prefix: agency.prefix, currentTimestamp: Math.floor(Date.now() / 1e3), log: log(config), logWarning: logWarning(config), logError: logError(config) }; await updateGtfsRealtimeData(task); } catch (error) { const gtfsError = toGtfsError(error, { message: error instanceof Error ? error.message : String(error), code: "GTFS_DB_OPERATION_FAILED", category: "database", details: { sqlitePath: task?.sqlitePath ?? config.sqlitePath } }); if (config.ignoreErrors) { logError(config)(formatGtfsError(gtfsError)); if (task?.report) addImportError(task.report, gtfsError); } else throw gtfsError; } }); log(config)(`Completed GTFS-Realtime refresh for ${pluralize("agency", "agencies", agencyCount)}\n`); } catch (error) { if (error.code === "SQLITE_CANTOPEN") { const dbOpenError = new GtfsError(`Unable to open sqlite database "${config.sqlitePath}" defined as \`sqlitePath\` config.json. Ensure the parent directory exists or remove \`sqlitePath\` from config.json.`, { code: "DB_OPEN_FAILED", category: "database", details: { sqlitePath: config.sqlitePath, dbCode: error.code }, cause: error }); logError(config)(dbOpenError.message); throw dbOpenError; } throw toGtfsError(error, { message: error instanceof Error ? error.message : String(error), code: "GTFS_DB_OPERATION_FAILED", category: "database" }); } } //#endregion //#region src/lib/import-gtfs.ts function reportTaskError(task, error) { if (task.report) addImportError(task.report, error); } const getTextFiles = async (folderPath) => { return (await readdir(folderPath)).filter((filename) => filename.slice(-3) === "txt"); }; const downloadGtfsFiles = async (task) => { if (!task.url) throw new GtfsError("No `url` specified in config", { code: "GTFS_CONFIG_INVALID", category: "config" }); task.log(`Downloading GTFS from ${task.url}`); task.path = `${task.downloadDir}/gtfs.zip`; try { const response = await fetch(task.url, { method: "GET", redirect: "follow", headers: { "User-Agent": "node-gtfs", ...task.headers }, signal: task.downloadTimeout ? AbortSignal.timeout(task.downloadTimeout) : void 0 }); if (!response.ok) throw new GtfsError(`Unable to download GTFS from ${task.url}. Got status ${response.status}.`, { code: "GTFS_DOWNLOAD_HTTP", category: "download", statusCode: response.status, details: { url: task.url, status: response.status, statusText: response.statusText } }); const buffer = await response.arrayBuffer(); await writeFile(task.path, Buffer.from(buffer)); task.log("Download successful"); } catch (error) { throw toGtfsError(error, { message: `Unable to download GTFS from ${task.url}.`, code: "GTFS_DOWNLOAD_FAILED", category: "download", details: { url: task.url } }); } }; const extractGtfsFiles = async (task) => { if (!task.path) throw new GtfsError("No `path` specified in config", { code: "GTFS_CONFIG_INVALID", category: "config", details: { field: "path" } }); const gtfsPath = untildify(task.path); task.log(`Importing static GTFS from ${task.path}\r`); if (path.extname(gtfsPath) === ".zip") try { await unzip(gtfsPath, task.downloadDir); if ((await getTextFiles(task.downloadDir)).length === 0) { const folders = (await readdir(task.downloadDir)).filter((filename) => !["__MACOSX"].includes(filename)).map((filename) => path.join(task.downloadDir, filename)).filter((source) => lstatSync(source).isDirectory()); if (folders.length > 1) throw new GtfsError(`More than one subfolder found in zip file at \`${task.path}\`. Ensure that .txt files are in the top level of the zip file, or in a single subdirectory.`, { code: "GTFS_ZIP_INVALID", category: "zip", details: { path: task.path, folderCount: folders.length } }); else if (folders.length === 0) throw new GtfsError(`No .txt files found in \`${task.path}\`. Ensure that .txt files are in the top level of the zip file, or in a single subdirectory.`, { code: "GTFS_ZIP_INVALID", category: "zip", details: { path: task.path } }); const subfolderName = folders[0]; const directoryTextFiles = await getTextFiles(subfolderName); if (directoryTextFiles.length === 0) throw new GtfsError(`No .txt files found in \`${task.path}\`. Ensure that .txt files are in the top level of the zip file, or in a single subdirectory.`, { code: "GTFS_ZIP_INVALID", category: "zip", details: { path: task.path, subfolderName } }); await Promise.all(directoryTextFiles.map(async (fileName) => rename(path.join(subfolderName, fileName), path.join(task.downloadDir, fileName)))); } } catch (error) { const wrappedError = toGtfsError(error, { message: `Unable to unzip file ${task.path}`, code: "GTFS_ZIP_INVALID", category: "zip", details: { path: task.path } }); task.logError(formatGtfsError(wrappedError)); throw wrappedError; } else try { await cp(gtfsPath, task.downloadDir, { recursive: true }); } catch (error) { throw new GtfsError(`Unable to load files from path \`${gtfsPath}\` defined in configuration. Verify that path exists and contains GTFS files.`, { code: "GTFS_DOWNLOAD_FAILED", category: "download", details: { path: gtfsPath }, cause: error }); } }; /** * Reads agency.txt from disk after extraction to find the single agency_id for a feed. * Returns the raw agency_id string if there is exactly one agency with a non-empty * agency_id, or undefined otherwise. */ const getSingleAgencyId = (downloadDir, csvOptions, logWarning) => new Promise((resolve) => { const filepath = path.join(downloadDir, "agency.txt"); if (!existsSync(filepath)) { resolve(void 0); return; } const rows = []; const parser = parse({ columns: true, relax_quotes: true, trim: true, skip_empty_lines: true, bom: true, ...csvOptions }); parser.on("readable", () => { let record; while (record = parser.read()) rows.push(record); }); parser.on("end", () => { if (rows.length !== 1) { resolve(void 0); return; } resolve(rows[0].agency_id?.trim() || void 0); }); parser.on("error", (err) => { logWarning(`Unable to parse agency.txt for \`fillEmptyAgencyId\`: ${err.message}`); resolve(void 0); }); createReadStream(filepath).pipe(parser); }); const createGtfsTables = (db) => { for (const model of Object.values(models_exports)) { if (!model.schema) continue; const sqlColumnCreateStatements = []; for (const column of model.schema) { const checks = []; if (column.min !== void 0 && column.max !== void 0) checks.push(`${column.name} >= ${column.min} AND ${column.name} <= ${column.max}`); else if (column.min !== void 0) checks.push(`${column.name} >= ${column.min}`); else if (column.max !== void 0) checks.push(`${column.name} <= ${column.max}`); if (column.type === "integer") checks.push(`(TYPEOF(${column.name}) = 'integer' OR ${column.name} IS NULL)`); else if (column.type === "real") checks.push(`(TYPEOF(${column.name}) = 'real' OR ${column.name} IS NULL)`); const required = column.required ? "NOT NULL" : ""; const columnDefault = column.default ? "DEFAULT " + column.default : ""; const columnCollation = column.nocase ? "COLLATE NOCASE" : ""; const checkClause = checks.length > 0 ? `CHECK(${checks.join(" AND ")})` : ""; sqlColumnCreateStatements.push(`${column.name} ${column.type} ${checkClause} ${required} ${columnDefault} ${columnCollation}`); if (column.type === "time") sqlColumnCreateStatements.push(`${getTimestampColumnName(column.name)} INTEGER GENERATED ALWAYS AS ( CASE WHEN ${column.name} IS NULL OR ${column.name} = '' THEN NULL ELSE CAST( substr(${column.name}, 1, instr(${column.name}, ':') - 1) * 3600 + substr(${column.name}, instr(${column.name}, ':') + 1, 2) * 60 + substr(${column.name}, -2) AS INTEGER ) END ) STORED`); } const primaryColumns = model.schema.filter((column) => column.primary); if (primaryColumns.length > 0) sqlColumnCreateStatements.push(`PRIMARY KEY (${primaryColumns.map(({ name }) => name).join(", ")})`); db.prepare(`DROP TABLE IF EXISTS ${model.filenameBase};`).run(); db.prepare(`CREATE TABLE ${model.filenameBase} (${sqlColumnCreateStatements.join(", ")});`).run(); } }; const SPARSE_COLUMN_MAX_DENSITY = .1; const createGtfsIndex = (db, tableName, columnName, partial) => { const predicate = partial ? ` WHERE ${columnName} IS NOT NULL` : ""; db.prepare(`CREATE INDEX idx_${tableName}_${columnName} ON ${tableName} (${columnName})${predicate};`).run(); }; const createGtfsIndexes = (db) => { for (const model of Object.values(models_exports)) { if (!model.schema) continue; const indexedColumns = []; for (const column of model.schema) { if (column.index) indexedColumns.push(column.name); if (column.type === "time") indexedColumns.push(getTimestampColumnName(column.name)); } if (indexedColumns.length === 0) continue; const { rowCount } = db.prepare(`SELECT COUNT(*) AS rowCount FROM ${model.filenameBase}`).get(); if (rowCount === 0) { for (const columnName of indexedColumns) createGtfsIndex(db, model.filenameBase, columnName, false); continue; } const counts = db.prepare(`SELECT ${indexedColumns.map((columnName, index) => `COUNT(${columnName}) AS c${index}`).join(", ")} FROM ${model.filenameBase}`).get(); for (const [index, columnName] of indexedColumns.entries()) { const density = counts[`c${index}`] / rowCount; createGtfsIndex(db, model.filenameBase, columnName, density <= SPARSE_COLUMN_MAX_DENSITY); } } }; const AGENCY_ID_BACKFILL_MODELS = /* @__PURE__ */ new Set([ "agency", "routes", "fare_attributes", "trip_capacity", "rider_trip", "ridership" ]); function shouldBackfillAgencyId(model, formattedLine, columnIndexes) { if (AGENCY_ID_BACKFILL_MODELS.has(model.filenameBase)) return true; if (model.filenameBase === "attributions") { const routeIdIndex = columnIndexes.get("route_id"); const tripIdIndex = columnIndexes.get("trip_id"); return (routeIdIndex === void 0 || formattedLine[routeIdIndex] == null) && (tripIdIndex === void 0 || formattedLine[tripIdIndex] == null); } return false; } const formatGtfsLine = (line, model, totalLineCount, fillEmptyAgencyId, agencyId, columnIndexes) => { const lineNumber = totalLineCount + 1; const formattedLine = new Array(model.schema.length); const filenameBase = model.filenameBase; const filenameExtension = model.filenameExtension; for (let index = 0; index < model.schema.length; index++) { const { name, type, required } = model.schema[index]; let value = line[name]; if (value === "" || value === void 0 || value === null) { formattedLine[index] = null; if (required) throw new GtfsError(`Missing required value in ${filenameBase}.${filenameExtension} for ${name} on line ${lineNumber}.`, { code: "GTFS_REQUIRED_FIELD_MISSING", category: "validation", details: { file: `${filenameBase}.${filenameExtension}`, line: lineNumber, column: name } }); continue; } if (type === "date") { value = value?.toString().replace(/-/g, ""); if (value.length !== 8) throw new GtfsError(`Invalid date in ${filenameBase}.${filenameExtension} for ${name} on line ${lineNumber}.`, { code: "GTFS_INVALID_DATE", category: "validation", details: { file: `${filenameBase}.${filenameExtension}`, line: lineNumber, column: name, value } }); } else if (type === "time") value = padLeadingZeros(value); if (type === "json") value = JSON.stringify(value); formattedLine[index] = value; } const agencyIdIndex = columnIndexes.get("agency_id"); if (fillEmptyAgencyId && agencyId !== void 0 && agencyIdIndex !== void 0 && formattedLine[agencyIdIndex] == null && shouldBackfillAg