Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@

- Add `GtfsSqlJs.fromZipData(zipData, options?)` static method for loading from pre-loaded ZIP data (`ArrayBuffer` or `Uint8Array`)
- Keep `GtfsSqlJs.fromZip(zipPath, options?)` strictly typed as `string` for paths/URLs
- Remove `as unknown as Crypto` double cast in checksum module; call `webcrypto.subtle.digest()` directly
- Remove `as BufferSource` cast in checksum module; pass `ArrayBuffer` directly to `sha256Digest()`
- Extract shared `isNodeEnvironment()` helper into `utils/env.ts`, replacing inline `typeof process` checks in zip-loader and gtfs-rt-loader
- Narrow `loadGTFSZip()` parameter from `string | ArrayBuffer | Uint8Array` to `ArrayBuffer | Uint8Array` (string path was dead code)
- Replace `unknown[]` with proper `ProtobufTimeRange[]` and `ProtobufEntitySelector[]` types in gtfs-rt-loader
- Refactor `convertKeysToSnakeCase` to use `Object.entries()`, removing `for..in` loop with `as Record<string, unknown>` cast
- Replace `as Record<string, unknown>` widening casts on `stmt.getAsObject()` across all query files with proper `ParamsObject` type from sql.js
- Replace non-null assertions (`!`) with optional chaining (`?.`) for `Map.get()` calls in rt-trip-updates and stop-times
- Replace `this.SQL!.Database()` non-null assertion with explicit guard in gtfs-sqljs

## 0.2.2

Expand Down
14 changes: 2 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 19 additions & 14 deletions src/cache/checksum.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,43 @@
/**
* Get crypto object (browser or Node.js)
* Compute a SHA-256 digest using the Web Crypto API (browser or Node.js 18+).
* Returns the raw hash as an ArrayBuffer.
*/
async function getCrypto(): Promise<Crypto> {
async function sha256Digest(data: ArrayBuffer): Promise<ArrayBuffer> {
// Browser environment
if (typeof crypto !== 'undefined' && crypto.subtle) {
return crypto;
return crypto.subtle.digest('SHA-256', data);
}

// Node.js environment - import webcrypto
// Node.js environment
if (typeof globalThis !== 'undefined') {
try {
const { webcrypto } = await import('crypto');
return webcrypto as unknown as Crypto;
if (webcrypto?.subtle) {
return webcrypto.subtle.digest('SHA-256', data);
}
} catch {
// Fallback for older Node.js versions
throw new Error('Web Crypto API not available');
}
}

throw new Error('Crypto not available in this environment');
throw new Error('Web Crypto API not available in this environment');
}

/**
* Compute SHA-256 checksum of data
* Uses Web Crypto API (available in both browser and Node.js 18+)
*/
export async function computeChecksum(data: ArrayBuffer | Uint8Array): Promise<string> {
// Get crypto instance
const cryptoInstance = await getCrypto();

// Ensure we have a BufferSource for crypto.subtle.digest
// Use type assertion to handle ArrayBufferLike compatibility
const bufferSource = (data instanceof Uint8Array ? data : new Uint8Array(data)) as BufferSource;
const hashBuffer = await cryptoInstance.subtle.digest('SHA-256', bufferSource);
let buffer: ArrayBuffer;
if (data instanceof ArrayBuffer) {
buffer = data;
} else {
// Copy Uint8Array into a fresh ArrayBuffer to avoid SharedArrayBuffer ambiguity
const copy = new ArrayBuffer(data.byteLength);
new Uint8Array(copy).set(data);
buffer = copy;
}
const hashBuffer = await sha256Digest(buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex;
Expand Down
6 changes: 5 additions & 1 deletion src/gtfs-sqljs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,8 +355,12 @@ export class GtfsSqlJs {
options: Omit<GtfsSqlJsOptions, 'zipPath' | 'database'>,
onProgress?: ProgressCallback
): Promise<void> {
if (!this.SQL) {
throw new Error('SQL.js not initialized');
}

// Create new database
this.db = new this.SQL!.Database();
this.db = new this.SQL.Database();

// Apply performance PRAGMAs for bulk loading
this.db.run('PRAGMA synchronous = OFF'); // Skip fsync for performance
Expand Down
54 changes: 28 additions & 26 deletions src/loaders/gtfs-rt-loader.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Database } from 'sql.js';
import protobuf from 'protobufjs';
import { isNodeEnvironment } from '../utils/env';

// Types for protobuf decoded objects
interface ProtobufTranslation {
Expand All @@ -11,10 +12,23 @@ interface ProtobufTranslatedString {
translation: ProtobufTranslation[];
}

interface ProtobufTimeRange {
start?: number;
end?: number;
}

interface ProtobufEntitySelector {
agencyId?: string;
routeId?: string;
routeType?: number;
trip?: ProtobufTripDescriptor;
stopId?: string;
}

interface ProtobufAlert {
id: string;
activePeriod?: unknown[];
informedEntity?: unknown[];
activePeriod?: ProtobufTimeRange[];
informedEntity?: ProtobufEntitySelector[];
cause?: number;
effect?: number;
url?: ProtobufTranslatedString;
Expand Down Expand Up @@ -278,11 +292,7 @@ async function fetchProtobuf(source: string): Promise<Uint8Array> {
}

// Read from local file (Node.js only)
const isNode = typeof process !== 'undefined' &&
process.versions != null &&
process.versions.node != null;

if (isNode) {
if (isNodeEnvironment()) {
try {
const fs = await import('fs');
const buffer = await fs.promises.readFile(source);
Expand Down Expand Up @@ -322,24 +332,16 @@ function camelToSnake(str: string): string {
return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
}

function convertKeysToSnakeCase(obj: unknown): unknown {
if (obj === null || obj === undefined) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(convertKeysToSnakeCase);
}
if (typeof obj === 'object') {
const result: Record<string, unknown> = {};
for (const key in obj as Record<string, unknown>) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
const snakeKey = camelToSnake(key);
result[snakeKey] = convertKeysToSnakeCase((obj as Record<string, unknown>)[key]);
}
}
return result;
/** Convert all keys of a plain object from camelCase to snake_case recursively */
function convertObjectKeysToSnakeCase(obj: object): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
const snakeKey = camelToSnake(key);
result[snakeKey] = (value !== null && typeof value === 'object' && !Array.isArray(value))
? convertObjectKeysToSnakeCase(value as object)
: value;
}
return obj;
return result;
}

// Parse TranslatedString to JSON
Expand All @@ -366,8 +368,8 @@ function insertAlerts(db: Database, alerts: ProtobufAlert[], timestamp: number):

for (const alert of alerts) {
// Convert nested objects to snake_case for database storage
const activePeriodSnake = convertKeysToSnakeCase(alert.activePeriod || []);
const informedEntitySnake = convertKeysToSnakeCase(alert.informedEntity || []);
const activePeriodSnake = (alert.activePeriod ?? []).map(convertObjectKeysToSnakeCase);
const informedEntitySnake = (alert.informedEntity ?? []).map(convertObjectKeysToSnakeCase);

stmt.run([
alert.id,
Expand Down
22 changes: 4 additions & 18 deletions src/loaders/zip-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,16 @@

import JSZip from 'jszip';
import type { ProgressCallback } from '../gtfs-sqljs';
import { isNodeEnvironment } from '../utils/env';

export interface GTFSFiles {
[filename: string]: string;
}

/**
* Fetch and extract GTFS ZIP file
* Works in both browser and Node.js environments
* Extract GTFS files from pre-loaded ZIP data
*/
export async function loadGTFSZip(source: string | ArrayBuffer | Uint8Array): Promise<GTFSFiles> {
let zipData: ArrayBuffer | Uint8Array;

// If source is a string, treat it as URL or file path
if (typeof source === 'string') {
zipData = await fetchZip(source);
} else {
zipData = source;
}

export async function loadGTFSZip(zipData: ArrayBuffer | Uint8Array): Promise<GTFSFiles> {
// Load ZIP file
const zip = await JSZip.loadAsync(zipData);

Expand Down Expand Up @@ -122,12 +113,7 @@ export async function fetchZip(source: string, onProgress?: ProgressCallback): P
}

// For non-URLs, try Node.js fs first, fall back to fetch for browser
// Check if we're in Node.js environment
const isNode = typeof process !== 'undefined' &&
process.versions != null &&
process.versions.node != null;

if (isNode) {
if (isNodeEnvironment()) {
// In Node.js, treat as file path
try {
const fs = await import('fs');
Expand Down
6 changes: 3 additions & 3 deletions src/queries/agencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Agency Query Methods
*/

import type { Database } from 'sql.js';
import type { Database, ParamsObject } from 'sql.js';
import type { Agency } from '../types/gtfs';

export interface AgencyFilters {
Expand Down Expand Up @@ -48,7 +48,7 @@ export function getAgencies(db: Database, filters: AgencyFilters = {}): Agency[]

const agencies: Agency[] = [];
while (stmt.step()) {
const row = stmt.getAsObject() as Record<string, unknown>;
const row = stmt.getAsObject();
agencies.push(rowToAgency(row));
}

Expand All @@ -59,7 +59,7 @@ export function getAgencies(db: Database, filters: AgencyFilters = {}): Agency[]
/**
* Convert database row to Agency object
*/
function rowToAgency(row: Record<string, unknown>): Agency {
function rowToAgency(row: ParamsObject): Agency {
return {
agency_id: String(row.agency_id),
agency_name: String(row.agency_name),
Expand Down
12 changes: 6 additions & 6 deletions src/queries/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Calendar Query Methods
*/

import type { Database } from 'sql.js';
import type { Database, ParamsObject } from 'sql.js';
import type { Calendar, CalendarDate } from '../types/gtfs';

/**
Expand Down Expand Up @@ -64,7 +64,7 @@ export function getCalendarByServiceId(db: Database, serviceId: string): Calenda
stmt.bind([serviceId]);

if (stmt.step()) {
const row = stmt.getAsObject() as Record<string, unknown>;
const row = stmt.getAsObject();
stmt.free();
return rowToCalendar(row);
}
Expand All @@ -82,7 +82,7 @@ export function getCalendarDates(db: Database, serviceId: string): CalendarDate[

const dates: CalendarDate[] = [];
while (stmt.step()) {
const row = stmt.getAsObject() as Record<string, unknown>;
const row = stmt.getAsObject();
dates.push(rowToCalendarDate(row));
}

Expand All @@ -99,7 +99,7 @@ export function getCalendarDatesForDate(db: Database, date: string): CalendarDat

const dates: CalendarDate[] = [];
while (stmt.step()) {
const row = stmt.getAsObject() as Record<string, unknown>;
const row = stmt.getAsObject();
dates.push(rowToCalendarDate(row));
}

Expand All @@ -110,7 +110,7 @@ export function getCalendarDatesForDate(db: Database, date: string): CalendarDat
/**
* Convert database row to Calendar object
*/
function rowToCalendar(row: Record<string, unknown>): Calendar {
function rowToCalendar(row: ParamsObject): Calendar {
return {
service_id: String(row.service_id),
monday: Number(row.monday),
Expand All @@ -128,7 +128,7 @@ function rowToCalendar(row: Record<string, unknown>): Calendar {
/**
* Convert database row to CalendarDate object
*/
function rowToCalendarDate(row: Record<string, unknown>): CalendarDate {
function rowToCalendarDate(row: ParamsObject): CalendarDate {
return {
service_id: String(row.service_id),
date: String(row.date),
Expand Down
Loading