Verify that no data is lost on load and save #132
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
gtfs.zone/coloring-book#132
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
When a GTFS feed is imported and then re-exported by gtfs.zone, four data fidelity bugs corrupt the output: (1) stop and shape coordinates are silently rounded to 6 decimal places at import time; (2) all string field values are trimmed of leading/trailing whitespace at import time; (3) every exported CSV file is missing its final newline; and (4) any unrecognized
.txtfiles (e.g. MBTA proprietary extensions) are silently discarded on import and absent from the export. The fix is: remove the artificial rounding and.trim()in both copies ofparseFieldValue, append a trailing\nto every exported CSV, and implement a passthrough mechanism that preserves unrecognized files as opaque blobs through the import→export round-trip.Relevant context
src/modules/gtfs-parser.ts:112-113, insideparseFieldValue(). Any field whose name contains_lator_lonis run throughnum.toFixed(6)before being stored in IndexedDB. The rounding is permanent: the full-precision value is never stored and cannot be recovered on export.src/modules/gtfs-parser.ts:456-459,generateCSVFromRows(). ReturnsPapa.unparse(…)directly; PapaParse does not add a trailing newline after the last row.src/modules/gtfs-database.ts:427,exportCurrentBlobsAsZip(). Same issue:Papa.unparse(rows)is used directly without appending\n.Papa.unparse()accepts anewlineoption but it only controls the between-row separator, not a trailing one. The simplest fix is to append'\n'to the return value of both call sites.Costs / trade-offs
parseFloat()already gives full double precision.Phase 1 — Remove coordinate rounding and whitespace stripping on import
Goal: Stop
parseFieldValue()from truncating coordinate precision and trimming trailing/leading spaces from string fields when reading GTFS files into IndexedDB.Coordinate rounding (confirmed scope from before/after analysis):
stops.txt: 32 stops had lat/lon truncated (e.g.42.3691186→42.369119). Includes parent stations, child platforms, boarding nodes, and bus berths — every stop with a 7th decimal place.shapes.txt: all ~393,217 shape points had lat/lon truncated. No points were added or removed; only precision was lost.Whitespace stripping (confirmed scope from before/after analysis):
stops.txt: 2 stop names had trailing spaces stripped ("Ground level of Elevator 806 ", one door node name).pathways.txt: 11 pathway rows had trailing spaces stripped frompathway_nameandsignposted_asfields (e.g."Public Garden ","Green Line ","Track 8 ").const stringValue = String(value).trim()on line 72 ofgtfs-parser.ts(and identically on line 58 ofgtfs-parser.worker.ts). The.trim()applies to every field value before any type detection — so all string fields are silently stripped.parseFieldValuefunction is duplicated in both files; both copies must be fixed.Steps:
src/workers/gtfs-parser.worker.ts:58, changeconst stringValue = String(value).trim()→const stringValue = String(value). This is the hot path used during import.src/modules/gtfs-parser.ts:72, make the same change. This copy is used during patch replay and other main-thread parsing.src/workers/gtfs-parser.worker.ts:93-94, delete the_lat/_lonrounding block (return parseFloat(num.toFixed(6))). TheparseFloat(stringValue)on line 91 already returns full-precision.src/modules/gtfs-parser.ts:112-113, delete the same_lat/_lonrounding block.fix(gtfs-parser): preserve full coordinate precision and field whitespace on importGotchas:
parseFieldValueis a standalone function copy (not imported from the module) — both files must be changed independently.shape_pt_lat/shape_pt_lonare stored in thefile_blobsstore as JSON, not per-row IDB. The rounding fix still applies because the worker processes those fields before blobs are assembled.Phase 2 — Add trailing newline to all exported CSV files
Goal: Every
.txtfile written into the export zip ends with a\n, satisfying RFC 4180 and GTFS validators.src/modules/gtfs-parser.ts:459, change the return ofgenerateCSVFromRows()to append'\n':src/modules/gtfs-parser.ts:448,makeHeaderOnlyCSV()is returned for empty tables —makeHeaderOnlyCSVwas defined insrc/modules/gtfs-file-registry.tswithout a trailing newline. Fixed it there too.src/modules/gtfs-database.ts:427, the legacy export path also callsPapa.unparse(rows)directly. Appended'\n'there.fix(gtfs-parser): add trailing newline to all exported CSV filesGotchas:
csvbranch atgtfs-database.ts:422that writes raw stored CSV strings directly to the zip. If those blobs were stored without a trailing newline, they will export without one. Check whether that path is still reachable (it handles the old v9 schema); if so, append'\n'there too or note it as out-of-scope legacy.Phase 3 — Passthrough for unrecognized files
Goal: Any
.txtfile in the imported ZIP that the app doesn't recognize (e.g. MBTA extensions:calendar_attributes.txt,checkpoints.txt,directions.txt,facilities.txt,facilities_properties.txt,facilities_properties_definitions.txt,lines.txt,linked_datasets.txt,multi_route_trips.txt,route_patterns.txt,trips_properties.txt,trips_properties_definitions.txt) is stored as an opaque raw-text blob in IndexedDB and re-emitted verbatim on export. Users cannot view or edit these files in the app; they are silently preserved.Relevant context:
src/workers/gtfs-parser.worker.ts:165-174:unknownFiles[]is already identified byisSupportedFile(). The worker currently reads nothing and just pushes the filename. We need it to also read and return the raw content.WorkerDoneMessage(defined insrc/workers/gtfs-parser.worker.ts:27): needs a newpassthroughFiles: { [fileName: string]: string }field.src/modules/gtfs-parser.ts:672-736: The main thread consumes the worker result. After processing known files, it must save passthrough files to IDB and hold them in memory.src/modules/gtfs-database.ts:46-69:GTFSStoreNameunion — add| 'passthrough_files'. Schema version is currently10; bump to11.src/modules/gtfs-database.ts:295-310: Theonupgradeneededhandler creates per-table stores. Thepassthrough_filesstore needs a simplekeyPath: 'fileName'entry here, guarded byif (!db.objectStoreNames.contains('passthrough_files')).src/modules/gtfs-parser.ts:952-1019(exportAsZip): iteratesthis.gtfsData(known files only). Passthrough files must be appended to the zip after this loop.src/modules/gtfs-database.ts:390-435(exportCurrentBlobsAsZip): the pre-upgrade export path; also needs to include passthrough files so they survive a schema migration.src/modules/ui.ts:275-277and404-407: currently shows "Ignoring unknown files: …" notification — update to "Preserving N unknown file(s) for export".Steps:
Database layer (
src/modules/gtfs-database.ts):| 'passthrough_files'to theGTFSStoreNametype (line 69).passthrough_files: { key: string; value: { fileName: string; rawContent: string } }toGTFSDBSchema.dbVersionfrom10to11. In theonupgradeneededhandler, addeddb.createObjectStore('passthrough_files', { keyPath: 'fileName' })in the clean-slate creation block (the handler always wipes and recreates all stores).async savePassthroughFiles(files: Record<string, string>): Promise<void>— opens areadwritetransaction onpassthrough_filesand puts each{ fileName, rawContent }entry.async getAllPassthroughFiles(): Promise<Record<string, string>>— reads all entries and returns a plain{ [fileName]: rawContent }map.clearPassthroughFiles()not needed as a separate method —clearDatabase()already clears all stores includingpassthrough_files.initializeEmpty()callsclearDatabase()and clears the in-memory map.exportCurrentBlobsAsZip(): after the existing loop overfile_blobs, readspassthrough_filesvia raw IDB API (guarded byobjectStoreNames.contains('passthrough_files')for pre-v11 safety) and adds each entry to the zip verbatim.Worker (
src/workers/gtfs-parser.worker.ts):passthroughFiles: { [fileName: string]: string }to theWorkerDoneMessageinterface.!isSupportedFile(fileName), read the raw content withzipContent.files[fileName].async('text')and store it in a localpassthroughFilesmap. KeepunknownFilespopulated too.passthroughFilesin thepost({ type: 'done', ..., passthroughFiles })call.Parser (
src/modules/gtfs-parser.ts):private passthroughFiles: Map<string, string> = new Map()to the class.parseFile(), after the main worker result loop, callthis.gtfsDatabase.savePassthroughFiles(passthroughFiles)and populatethis.passthroughFilesfrom the result.restoreDataFromDatabase(), callthis.gtfsDatabase.getAllPassthroughFiles()and repopulatethis.passthroughFilesso the in-memory map is ready after a page reload.initializeEmpty(),this.passthroughFiles.clear()(DB is cleared byclearDatabase()).exportAsZip(), after the existing file loop, iteratethis.passthroughFilesand add each entry to the zip verbatim.UI (
src/modules/ui.ts):Preserving ${unknownFiles.length} unrecognized file(s) for export: ${unknownFiles.join(', ')}.Gotchas:
exportCurrentBlobsAsZip()— which won't have thepassthrough_filesstore yet (it doesn't exist until after the upgrade). ThegetAllPassthroughFiles()call in that export path must handle the store not existing gracefully (return{}if the store is absent).isSupportedFileis already defined in the worker; the passthrough read just needs to happen in theelsebranch whereunknownFiles.pushis currently the only statement.Phase 4 — Switch stop_sequence to 0-based indexing
Goal: Change
stop_sequencegeneration from 1-based to 0-based indexing so that newly created stop_times sequences start at 0. The GTFS spec requires values to increase along the trip but imposes no minimum, so 0-based is valid. This only affects stop_times rows created or renumbered by the app; imported feeds with 1-based sequences are left as-is.All generation sites are in
src/modules/timetable-database.ts:stop_sequence: index + 1— new stop inserted into an existing trip (arrival/departure path)stop_sequence: index + 1— new stop inserted into an existing trip (linked time path)stop_sequence: index + 1—rebuildStopTimesFromTable(), the full renumber that runs after every timetable saveTemporary placeholder values (
stop_sequence: 1/'1'at lines 242, 358, 556) are immediately overwritten by the renumber — they don't need to change, but update them to0for consistency.Validator gotcha —
src/modules/gtfs-validator.ts:546:With 0-based indexing the first stop has
stop_sequence: 0, which fails this check silently marking valid rows as missing the field. Change the check to an explicit null/undefined test:if (stopTime.stop_sequence == null).Steps:
src/modules/timetable-database.ts, change all threeindex + 1renumber expressions (lines 262, 377, 587) toindex.1/'1'to0/'0'for consistency.src/modules/gtfs-validator.ts:546, changeif (!stopTime.stop_sequence)toif (stopTime.stop_sequence === null || stopTime.stop_sequence === undefined)so that0is accepted as a valid sequence number (ESLint required===over==).fix(timetable-database): use 0-based stop_sequence indexingGotchas:
stop_sequence.sort((a, b) => a.stop_sequence - b.stop_sequence)) is unaffected since 0-based values still sort correctly.Original
I'm a bit nervous that an upload and download of a file doesn't preserve the data. This is critical. Hopefully I'm wrong. If I'm not, we gotta fix it.