Skip to content

Commit 34d9805

Browse files
committed
Structure all error messages
1 parent 7b14634 commit 34d9805

13 files changed

Lines changed: 659 additions & 91 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Updated
11+
- Dependency Updates
12+
13+
### Changed
14+
- Structure all error messages
15+
816
## [4.18.3] - 2026-01-21
917

1018
### Updated

src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ export {
1818
generateFolderName,
1919
untildify,
2020
} from './lib/file-utils.ts';
21+
export {
22+
GtfsError,
23+
GtfsErrorCategory,
24+
GtfsErrorCode,
25+
GtfsWarningCode,
26+
isGtfsError,
27+
isGtfsValidationError,
28+
formatGtfsError,
29+
} from './lib/errors.ts';
30+
export type { GtfsWarning, ImportReport } from './lib/errors.ts';
2131

2232
// Standard GTFS
2333
export { getAgencies } from './lib/gtfs/agencies.ts';

src/lib/db.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from 'fs';
33
import Database from 'better-sqlite3';
44

55
import { untildify } from './file-utils.ts';
6+
import { GtfsError, GtfsErrorCategory, GtfsErrorCode } from './errors.ts';
67

78
const dbs: { [key: string]: Database.Database } = {};
89

@@ -49,25 +50,42 @@ export function openDb(
4950
}
5051

5152
if (Object.keys(dbs).length > 1) {
52-
throw new Error(
53+
throw new GtfsError(
5354
'Multiple databases open, please specify which one to use.',
55+
{
56+
code: GtfsErrorCode.GTFS_DB_OPERATION_FAILED,
57+
category: GtfsErrorCategory.DATABASE,
58+
details: { openDatabaseCount: Object.keys(dbs).length },
59+
},
5460
);
5561
}
5662

57-
throw new Error('Unable to find database connection.');
63+
throw new GtfsError('Unable to find database connection.', {
64+
code: GtfsErrorCode.GTFS_DB_OPERATION_FAILED,
65+
category: GtfsErrorCategory.DATABASE,
66+
});
5867
}
5968

6069
export function closeDb(db: Database.Database | null = null): void {
6170
if (Object.keys(dbs).length === 0) {
62-
throw new Error(
71+
throw new GtfsError(
6372
'No database connection. Call `openDb(config)` before using any methods.',
73+
{
74+
code: GtfsErrorCode.GTFS_DB_OPERATION_FAILED,
75+
category: GtfsErrorCategory.DATABASE,
76+
},
6477
);
6578
}
6679

6780
if (!db) {
6881
if (Object.keys(dbs).length > 1) {
69-
throw new Error(
82+
throw new GtfsError(
7083
'Multiple database connections. Pass the db you want to close as a parameter to `closeDb`.',
84+
{
85+
code: GtfsErrorCode.GTFS_DB_OPERATION_FAILED,
86+
category: GtfsErrorCategory.DATABASE,
87+
details: { openDatabaseCount: Object.keys(dbs).length },
88+
},
7189
);
7290
}
7391

@@ -80,15 +98,24 @@ export function closeDb(db: Database.Database | null = null): void {
8098

8199
export function deleteDb(db: Database.Database | null = null): void {
82100
if (Object.keys(dbs).length === 0) {
83-
throw new Error(
101+
throw new GtfsError(
84102
'No database connection. Call `openDb(config)` before using any methods.',
103+
{
104+
code: GtfsErrorCode.GTFS_DB_OPERATION_FAILED,
105+
category: GtfsErrorCategory.DATABASE,
106+
},
85107
);
86108
}
87109

88110
if (!db) {
89111
if (Object.keys(dbs).length > 1) {
90-
throw new Error(
112+
throw new GtfsError(
91113
'Multiple database connections. Pass the db you want to delete as a parameter to `deleteDb`.',
114+
{
115+
code: GtfsErrorCode.GTFS_DB_OPERATION_FAILED,
116+
category: GtfsErrorCategory.DATABASE,
117+
details: { openDatabaseCount: Object.keys(dbs).length },
118+
},
92119
);
93120
}
94121

src/lib/errors.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
export enum GtfsErrorCategory {
2+
CONFIG = 'config',
3+
DOWNLOAD = 'download',
4+
ZIP = 'zip',
5+
VALIDATION = 'validation',
6+
DATABASE = 'database',
7+
PARSE = 'parse',
8+
QUERY = 'query',
9+
INTERNAL = 'internal',
10+
}
11+
12+
/**
13+
* Error codes are a public API contract and must remain stable across
14+
* minor/patch releases.
15+
*/
16+
export enum GtfsErrorCode {
17+
GTFS_DOWNLOAD_HTTP = 'GTFS_DOWNLOAD_HTTP',
18+
GTFS_DOWNLOAD_FAILED = 'GTFS_DOWNLOAD_FAILED',
19+
GTFS_ZIP_INVALID = 'GTFS_ZIP_INVALID',
20+
GTFS_REQUIRED_FIELD_MISSING = 'GTFS_REQUIRED_FIELD_MISSING',
21+
GTFS_INVALID_DATE = 'GTFS_INVALID_DATE',
22+
GTFS_CONFIG_INVALID = 'GTFS_CONFIG_INVALID',
23+
DB_OPEN_FAILED = 'DB_OPEN_FAILED',
24+
GTFS_DB_OPERATION_FAILED = 'GTFS_DB_OPERATION_FAILED',
25+
GTFS_JSON_INVALID = 'GTFS_JSON_INVALID',
26+
GTFS_UNSUPPORTED_FILE_TYPE = 'GTFS_UNSUPPORTED_FILE_TYPE',
27+
GTFS_CSV_PARSE_FAILED = 'GTFS_CSV_PARSE_FAILED',
28+
GTFS_QUERY_INVALID = 'GTFS_QUERY_INVALID',
29+
}
30+
31+
export enum GtfsWarningCode {
32+
GTFS_DUPLICATE_PRIMARY_KEY = 'GTFS_DUPLICATE_PRIMARY_KEY',
33+
}
34+
35+
export interface GtfsWarning {
36+
code: GtfsWarningCode;
37+
message: string;
38+
details?: Record<string, unknown>;
39+
}
40+
41+
export interface ImportReport {
42+
errors: GtfsError[];
43+
warnings: GtfsWarning[];
44+
errorCountsByCode: Partial<Record<GtfsErrorCode, number>>;
45+
warningCountsByCode: Partial<Record<GtfsWarningCode, number>>;
46+
}
47+
48+
interface GtfsErrorOptions {
49+
code: GtfsErrorCode;
50+
category: GtfsErrorCategory;
51+
isOperational?: boolean;
52+
statusCode?: number;
53+
details?: Record<string, unknown>;
54+
cause?: unknown;
55+
}
56+
57+
export class GtfsError extends Error {
58+
code: GtfsErrorCode;
59+
category: GtfsErrorCategory;
60+
isOperational: boolean;
61+
statusCode?: number;
62+
details?: Record<string, unknown>;
63+
64+
constructor(message: string, options: GtfsErrorOptions) {
65+
super(message, { cause: options.cause });
66+
this.name = 'GtfsError';
67+
this.code = options.code;
68+
this.category = options.category;
69+
this.isOperational = options.isOperational ?? true;
70+
this.statusCode = options.statusCode;
71+
this.details = options.details;
72+
}
73+
}
74+
75+
export function isGtfsError(error: unknown): error is GtfsError {
76+
if (!error || typeof error !== 'object') {
77+
return false;
78+
}
79+
80+
const candidate = error as Partial<GtfsError> & { name?: unknown };
81+
return (
82+
candidate.name === 'GtfsError' &&
83+
typeof candidate.message === 'string' &&
84+
typeof candidate.code === 'string' &&
85+
typeof candidate.category === 'string' &&
86+
typeof candidate.isOperational === 'boolean'
87+
);
88+
}
89+
90+
export function isGtfsValidationError(error: unknown): error is GtfsError {
91+
return isGtfsError(error) && error.category === GtfsErrorCategory.VALIDATION;
92+
}
93+
94+
export function toGtfsError(
95+
error: unknown,
96+
fallback: Omit<GtfsErrorOptions, 'cause'> & { message: string },
97+
): GtfsError {
98+
if (isGtfsError(error)) {
99+
return error;
100+
}
101+
102+
return new GtfsError(fallback.message, {
103+
...fallback,
104+
cause: error,
105+
});
106+
}
107+
108+
export function createImportReport(): ImportReport {
109+
return {
110+
errors: [],
111+
warnings: [],
112+
errorCountsByCode: {},
113+
warningCountsByCode: {},
114+
};
115+
}
116+
117+
export function addImportError(report: ImportReport, error: GtfsError): void {
118+
report.errors.push(error);
119+
report.errorCountsByCode[error.code] =
120+
(report.errorCountsByCode[error.code] ?? 0) + 1;
121+
}
122+
123+
export function addImportWarning(
124+
report: ImportReport,
125+
warning: GtfsWarning,
126+
): void {
127+
report.warnings.push(warning);
128+
report.warningCountsByCode[warning.code] =
129+
(report.warningCountsByCode[warning.code] ?? 0) + 1;
130+
}
131+
132+
export function formatGtfsError(
133+
error: unknown,
134+
options: { verbosity: 'user' | 'developer' } = { verbosity: 'developer' },
135+
) {
136+
if (!isGtfsError(error)) {
137+
const message = error instanceof Error ? error.message : String(error);
138+
return options.verbosity === 'user' ? message : `UNKNOWN_ERROR: ${message}`;
139+
}
140+
141+
if (options.verbosity === 'user') {
142+
return error.message;
143+
}
144+
145+
return [
146+
`${error.code}: ${error.message}`,
147+
`category=${error.category}`,
148+
error.statusCode !== undefined ? `statusCode=${error.statusCode}` : null,
149+
error.details ? `details=${JSON.stringify(error.details)}` : null,
150+
]
151+
.filter(Boolean)
152+
.join(' | ');
153+
}

src/lib/gtfs/calendars.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
formatWhereClauses,
1313
getDayOfWeekFromDate,
1414
} from '../utils.ts';
15+
import { GtfsError, GtfsErrorCategory, GtfsErrorCode } from '../errors.ts';
1516

1617
/*
1718
* Returns an array of calendars that match the query parameters.
@@ -42,7 +43,11 @@ export function getServiceIdsByDate(date: number, options: QueryOptions = {}) {
4243
const db = options.db ?? openDb();
4344

4445
if (!date) {
45-
throw new Error('`date` is a required query parameter');
46+
throw new GtfsError('`date` is a required query parameter', {
47+
code: GtfsErrorCode.GTFS_QUERY_INVALID,
48+
category: GtfsErrorCategory.QUERY,
49+
details: { field: 'date' },
50+
});
4651
}
4752

4853
const dayOfWeek = getDayOfWeekFromDate(date as number);

src/lib/gtfs/stop-times.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
formatSelectClause,
1616
formatWhereClause,
1717
} from '../utils.ts';
18+
import { GtfsError, GtfsErrorCategory, GtfsErrorCode } from '../errors.ts';
1819
import { getServiceIdsByDate } from './calendars.ts';
1920

2021
/*
@@ -41,7 +42,11 @@ export function getStoptimes<Fields extends keyof StopTime>(
4142

4243
if (query.date) {
4344
if (typeof query.date !== 'number') {
44-
throw new Error('`date` must be a number in yyyymmdd format');
45+
throw new GtfsError('`date` must be a number in yyyymmdd format', {
46+
code: GtfsErrorCode.GTFS_QUERY_INVALID,
47+
category: GtfsErrorCategory.QUERY,
48+
details: { field: 'date', value: query.date },
49+
});
4550
}
4651

4752
const serviceIds = getServiceIdsByDate(query.date, options);
@@ -53,7 +58,11 @@ export function getStoptimes<Fields extends keyof StopTime>(
5358

5459
if (query.start_time) {
5560
if (typeof query.start_time !== 'string') {
56-
throw new Error('`start_time` must be a string in HH:mm:ss format');
61+
throw new GtfsError('`start_time` must be a string in HH:mm:ss format', {
62+
code: GtfsErrorCode.GTFS_QUERY_INVALID,
63+
category: GtfsErrorCategory.QUERY,
64+
details: { field: 'start_time', value: query.start_time },
65+
});
5766
}
5867

5968
whereClauses.push(
@@ -63,7 +72,11 @@ export function getStoptimes<Fields extends keyof StopTime>(
6372

6473
if (query.end_time) {
6574
if (typeof query.end_time !== 'string') {
66-
throw new Error('`end_time` must be a string in HH:mm:ss format');
75+
throw new GtfsError('`end_time` must be a string in HH:mm:ss format', {
76+
code: GtfsErrorCode.GTFS_QUERY_INVALID,
77+
category: GtfsErrorCategory.QUERY,
78+
details: { field: 'end_time', value: query.end_time },
79+
});
6780
}
6881

6982
whereClauses.push(

src/lib/gtfs/trips.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
formatSelectClause,
1515
formatWhereClause,
1616
} from '../utils.ts';
17+
import { GtfsError, GtfsErrorCategory, GtfsErrorCode } from '../errors.ts';
1718
import { getServiceIdsByDate } from './calendars.ts';
1819

1920
/*
@@ -41,7 +42,11 @@ export function getTrips<Fields extends keyof Trip>(
4142

4243
if (query.date) {
4344
if (typeof query.date !== 'number') {
44-
throw new Error('`date` must be a number in yyyymmdd format');
45+
throw new GtfsError('`date` must be a number in yyyymmdd format', {
46+
code: GtfsErrorCode.GTFS_QUERY_INVALID,
47+
category: GtfsErrorCategory.QUERY,
48+
details: { field: 'date', value: query.date },
49+
});
4550
}
4651

4752
const serviceIds = getServiceIdsByDate(query.date, options);

0 commit comments

Comments
 (0)