This guide explains how automation agents and human contributors should work with the StationAPI repository so releases stay predictable, auditable, and safe. Update this file whenever you change the workflow or behavior it documents.
src/– The Worker itself (stationapi-worker, wasm32 only).lib.rsholds the endpoints,index.rsparses the embedded CSVs into in-memory indexes,repository.rsimplements the repository traits against those indexes, andgraphql/holds the async-graphql types and resolvers.index.rsalso holds the spatial grid used by every coordinate lookup — see Coordinate lookups below.schema/public.graphql– The published GraphQL schema. CI diffs the Worker's SDL against this file, so an unintended change fails the build.build.rs– Stagesgenerated/*.csv(falling back todata/*.csv) intoOUT_DIRand pre-convertsstation_station_typesinto a fixed-width binary.wrangler.jsonc– Staging and production deployment settings.stationapi/src/domain/– Entity definitions and repository abstractions.repository/providesasync_trait-based interfaces, andnormalize.rscontains text normalization for search.stationapi/src/use_case/– Application logic.interactor/query.rsimplements theQueryUseCasecontract defined intraits/query.rs;dto/converts entities intomodeltypes (this is where IPA and TTS segments are built).stationapi/src/model.rs– The values the API returns. Formerly generated from.proto; kept as the layer between entities and GraphQL types.preprocessor/– Build-time CLI that assemblesgenerated/*.csvfromdata/*.csv, the GTFS feeds, and the Tokyu ODPT JSON.data/– Canonical CSV datasets. Files follow theN!table.csvnaming scheme. Detailed instructions are indata/README.md.data_validator/– CLI that verifies cross-file constraints (cargo run -p data_validator).Makefile– Convenience targets (make helplists them all).
The Worker is the workspace root package. stationapi, preprocessor, and data_validator are native workspace members, so type-checking and linting are split by target (see make check / make clippy).
- Rust: Use the stable toolchain (
rustup default stable) plus the wasm target (rustup target add wasm32-unknown-unknown). worker-build(cargo install worker-build --locked) andwranglerare needed to build and run the Worker.- No database. The data is embedded into the WASM binary at build time.
- Environment variables:
ODPT_ACCESS_TOKEN– ODPT consumer key used to download authenticated data such as Seibu Bus GTFS, Keio Bus GTFS, Tokyu Bus JSON, and the Tokyu-operated Ota, Shinagawa, and Meguro community bus GTFS feeds. Only used bypreprocessor.DISABLE_BUS_FEATURE– set totrueto build rail-only data.
- Keep local secrets in
.env.local(git-ignored) and export them before runningmake data.
- Local development
make databuildsgenerated/*.csvfromdata/*.csv, the GTFS feeds, and the Tokyu ODPT JSON. Feeds already extracted underdata/*-GTFS/are reused; the ODPT JSON is cached for seven days.make buildcompiles the Worker,make devserves it onhttp://127.0.0.1:8787.GET /__pinganswers without touching the data,GET /__healthreports index sizes,GET /serves GraphiQL, andGET /__schemareturns the SDL.
- Deploying
-
A branch determines the target, and the workflow file encodes it.
devtriggersdeploy_staging.yml(staging,stationapi-stg);mastertriggersdeploy_production.yml(production,stationapi). No other branch deploys anywhere.build_worker.ymlonly builds and verifies; it runs on pull requests and on pushes to every branch exceptdevandmaster, which the deploy workflows already cover. -
Never pick the environment with an expression. Unlike
if:, a job'senvironmentapplies whenever the job runs, so a computed name puts every branch and pull request into that environment's deployment history and hands them every secret it holds,CLOUDFLARE_API_TOKENincluded. Each deploy workflow therefore hard-codes oneenvironment, andbuild_worker.ymldeclares none.workflow_dispatchhas no branch filter, so the deploy jobs also carry anif:pinning them to their branch. -
The build steps live in
.github/actions/build-worker, a composite action all three workflows share, so verification and deployment build identically. A local action needs a checkout first, so each workflow runsactions/checkoutwithpersist-credentials: false(the default leavesGITHUB_TOKENin.git/config, readable by the third-party codecargo installandnpxexecute) and then calls the action. -
A deploy must not ship truncated data.
preprocessoronly warns when a feed fails, so the deploy workflows passfail-on-missing-bus-feeds: trueand fail on any feed that did not import. Without it, an empty or expiredODPT_ACCESS_TOKENsilently produces a dataset holding only Toei Bus and ships it.build_worker.ymlhas no environment and therefore no token, so it warns instead — the schema and bundle-size checks do not depend on bus data. -
Required secrets.
CLOUDFLARE_ACCOUNT_IDis a repository secret.CLOUDFLARE_API_TOKENandODPT_ACCESS_TOKENare environment secrets in bothstagingandproduction. wrangler cannot mint the API token — it has no such command, and thewrangler loginOAuth token lacks theAPI Tokens Writescope thatPOST /user/tokensrequires — so create it in the dashboard. -
Scope the API token to what a deploy actually needs. The
Edit Cloudflare Workerstemplate is convenient but also grantsWorkers KV Storage: Edit,Workers R2 Storage: Edit, andWorkers Tail: Read, none of which this Worker uses —wrangler.jsoncdeclares no bindings at all. Build a custom token holding only:- Account —
Workers Scripts: Edit,Account Settings: Read - Zone (
trainlcd.app) —Workers Routes: Edit, which registers the custom domains - User —
User Details: Read,User Memberships: Read
Do not trim below that. Wrangler resolves the account through the user endpoints, and dropping them surfaces as an opaque
code 10000authentication error rather than a permission message. SettingCLOUDFLARE_ACCOUNT_IDreduces how often wrangler needs the membership lookup but does not remove it. - Account —
-
Local deploys use
make deploy(staging) andmake deploy-production(production). Both refuse to run outside their branch. wrangler is invoked throughnpxpinned toWRANGLER_VERSION, which appears in theMakefile, in both deploy workflows, and as the composite action's default; keep the four in step. wrangler 4 warns when--envis omitted with multiple environments defined, so the staging target passes--env=""explicitly. -
wrangler deployalways runs thebuild.commandinwrangler.jsonc(worker-build --release); wrangler offers no flag to skip it. Anything that deploys therefore needs the Rust toolchain, the wasm32 target, andworker-buildonPATH. -
The data lives inside the WASM binary, so a data change needs a rebuild and a redeploy. It is not picked up at runtime.
-
A custom domain cannot be registered twice. When moving a domain, remove it from the old Worker and deploy that first.
-
- CSV load order depends on the numeric prefix (
1!,2!, ...). When adding datasets, choose a prefix that preserves cross-file dependencies. - Column sets live in
preprocessor/src/rail.rs(*_COLUMNS). Update them alongside any CSV column change;generated/*.csvmust keep the same column order becausesrc/index.rsreads it by name andbuild.rsby position. - Columns whose name starts with
#are notes and are not loaded. - Through-service junction stations – When a train type runs through a station where its lines connect, add a
5!station_station_types.csvrow for every line-specificstation_cdat that station, even when those rows share onestation_g_cd. The only exception is when the train type explicitly identifies a direction or line-specific operation that excludes one side. Omitting either ID makes the train type selectable from only one line in the app. For example, Hida at Gifu must include both the Takayama Main Line station (1141601) and the Tokaido Main Line station (1150239). Audit both sides whenever adding or editing a through-service pattern. data_validatorcurrently verifies that5!station_station_types.csvreferences valid station and type IDs, and that order-sensitive station sequences in3!stations.csvstay intact underORDER BY e_sort, station_cd(e.g. the Toei Oedo Line's Tochomae rows, whose misordering silently drops the station from ETA estimation). Extend the validator when new cross-references or order-sensitive spots are introduced and keep the process fail-fast (panic on invalid data).
- Tests –
make testruns the unit tests for every native crate, pluscargo test -p stationapi-worker. The Worker only runs on Workers, butsrc/index.rsis a pure in-memory data structure that builds and executes natively, so its tests (including the grid-versus-full-scan differential check) run here. They need no external services. - Type checks –
make checkcovers the native crates and the wasm32 target separately. The Worker also compiles for the host, but only runs on Workers. - Linting and formatting –
make fmtandmake clippybefore committing (clippy covers the wasm32 target too). Resolve new Clippy warnings unless an existing#![allow]covers the case. - Schema – Changing a GraphQL type changes the SDL. Update
schema/public.graphqlin the same change; CI compares it against the running Worker's/__schemaand fails on any difference. That diff is exactly the client-visible impact. - Data verification – Execute
cargo run -p data_validatorwhenever CSVs change and record results in pull requests. - IPA coverage audit – Execute
make ipa-auditwhen English or romanized CSV names change. This is a read-only report fordata/2!lines.csv,data/3!stations.csv, anddata/4!types.csv; it does not fail validation, but highlights unresolved tokens and example names so the IPA dictionary can be extended deliberately. - Endpoint benchmarks –
make bench(orpython3 .claude/skills/benchmark-gql/bench.py) replays everyQueryfield against production (gql.trainlcd.app, scriptstationapi) and staging (gql-stg.trainlcd.app, scriptstationapi-stg) and writes a Markdown report underbenchmarks/. Both environments embed the same data, so any difference is implementation — which makes this the way to see what adev-to-masterrelease will do to performance before it ships. Besides client latency it records the Worker'scpuTime, read fromwrangler tail --format jsonand matched to each request bycf-ray; the tail is filtered on a per-run request header, so production's live traffic does not leak into the sample. Collecting CPU time needs theworkers_tail (read)scope, and the run sends hundreds of real requests to production — it is not a routine check. Add a case to.claude/skills/benchmark-gql/queries.jsonwhenever aQueryfield is added, and never edit an existing case's variables: the reports are meant to stay comparable across runs.
- Stations –
station,stations,stationGroupStations,stationsNearby,lineStations,stationsByName,lineGroupStations,lineListStations,lineGroupListStations.QueryInteractorenriches stations with lines, companies, station numbers, and train types.lineStationsresolves the line's local train-type group (railkind0/1 or apriority > 0type); when no such group exists — bus lines only carryBusRoute(kind7,priority0) variants — it falls back to the line's plain typeless station list so bus stop listings never return empty. - Lines –
line,lines,linesByName. Results include company data and computed line symbols based on repository helpers. - Routes –
routes,connectedRoutes,estimateArrivalTimes,trainRoute. Paging tokens are currently empty (pagination not implemented). trainRoute– Takes the line group's stops from the repository before any enrichment, slices them to the requestedfromStationId–toStationIdrange (reversing when the request runs backwards), and only then attaches lines, companies, station numbers, train types, and nearby bus routes. Enrichment is per-station and independent, so slicing first does not change any segment; enriching the whole line group first made a three-station request cost the same as a 250-station one. Keep the order — the cost of this query must stay proportional to the requested range, not to the line group.- Coordinate lookups –
index::nearest(k nearest, used bystationsNearby) andindex::within_radius(everything inside a radius, used by the nearby-bus-stop enrichment) both go through a per-transport-type grid index (Grid, CSR over 0.05° cells) instead of scanning the whole station table.nearestsearches a radius, widens it while fewer thanlimitstations fall inside, and stops once the radius covers the index — anything outside a radius that already holdslimithits cannot be in the toplimit. WithtransportTypeomitted it returns rail stations first and bus stops after, each group sorted by distance — the pre-Workers SQL'sORDER BY transport_type, distance. The limit applies to the merged order, sonearestfills it with rail and only asks the bus grid for the remaining slots; a location withlimitrail stations returns no bus stops at all. Ties on distance break onstation_cdso the order does not depend on an unstable sort. Every station lookup by coordinates runs on every request that enriches rail stations with nearby bus routes, so keep new coordinate queries on the grid rather than adding another full scan. - Train types –
stationTrainTypes,routeTypes. Train types aggregate by line group and include related lines plus optional train type metadata. Rail variants useTrainTypeKind::{Default, Branch, Rapid, Express, LimitedExpress, HighSpeedRapid, CommuterRapid}(0-6); bus variants useBusRoute(7), which represents a(route_id, shape_id)operation pattern (e.g. 循環 / 短ターン / 支線) generated automatically from the configured GTFS bus feeds (Toei Bus, Seibu Bus, Keio Bus) and the converted Tokyu Bus JSON. - Default rail train types –
preprocessorfills every active rail line containing at least one station with nostation_station_typesrow with a deterministic, complete all-stop group. The generated rows exist only ingenerated/*.csv; canonical CSV files remain unchanged.type_cd=100represents 「普通」 andtype_cd=101represents 「各駅停車」. An existing 100/101 assignment on the line takes precedence; otherwise the label is selected per line throughLOCAL_SERVICE_RAIL_LINE_IDSinpreprocessor/src/rail.rs. Generatedline_group_cdvalues use1,000,000,000 + line_cd; generation fails on a collision. Bus lines are excluded and continue to use their GTFS-derivedBusRoutegroups. - GTFS bus integration –
preprocessor/src/gtfs/reads the GTFS feeds into an in-memory representation and then projects them onto the sharedstations/lines/types/station_station_typestables (gtfs/integrate.rs). Only routes, stops, trips, and stop_times are read; calendar, shapes, feed_info, and agencies do not affect the output. Every configured GTFS feed is imported, including Seibu Bus and Keio Bus (both downloaded from ODPT withODPT_ACCESS_TOKEN). Tokyu Bus ordinary-routeBusroutePattern,BusstopPole, andBusTimetableJSON are converted into the same representation; pattern IDs becomeshape_idvalues so route variants remain queryable as bus TrainTypes. The Tokyu-operated Ota, Shinagawa, and Meguro community buses use their official GTFS feeds and matching JSON routes are excluded to prevent duplicates.ODPT_ACCESS_TOKENis required for authenticated sources; without it those feeds are skipped with a warning rather than failing the build. Stops whose Tokyu JSON records omit coordinates remain available to name and route queries but not coordinate searches.transport_type(0: rail, 1: bus) on bothstationsandlineskeeps rail and bus records queryable side by side. GTFS IDs are namespaced per feed before import to avoid cross-operator collisions.line_cd(100,000,000+),station_cd/station_g_cd(200,000,000+), and bustype_cd/line_group_cd(100,000,000+) are all deterministic fnv1a hashes that stay clear of the rail data ranges. Disable the entire bus pipeline withDISABLE_BUS_FEATURE=true. - Bus stop translations (readings & English) – GTFS-JP
translations.txtlayouts differ per feed, soload_gtfs_translationsresolves columns by header name (Seibu ships 6 columns withoutrecord_sub_id; Keio and the Tokyu community feeds ship 7) and indexes eachstop_nametranslation under both keys it may use:record_id(== the stop_id, Seibu — with the "-NN" pole suffix also mapped to the parent stop_id) andfield_value(== the Japanese stop_name, Keio / Tokyu community, whererecord_idis left empty).import_gtfs_stopsthen looks a stop's translation up by stop_id first, then by name. Keying only byrecord_id(the previous behavior) silently dropped every field_value-keyed feed, leavingstation_name_kfilled with the kanji stop_name andstation_name_rempty. Readings arriving as half-width katakana (ニシハチオウジ, Keio / Tokyu community) are folded to full-width viaromaji::to_fullwidth_katakana()before storage. - Bus English-name fallback – When a feed provides no English (
en) translation for a stop — e.g. Tokyu Bus ordinary-route JSON, which carries onlydc:titleandodpt:kana—src/domain/romaji.rs::romaji_display_name()derives a modified-Hepburn romanization (with macrons for long vowels, matching the curated rail style: Tōkyō / Kyōto / Shin-Ōsaka) from the kana reading, and the GTFS reader fillsstop_name_rwith it. The fallback never overwrites a realenvalue, and a reading with no convertible kana staysNULLrather than emitting a partial transcription. Becausestop_name_ris the single upstream source that fans out into thestationsprojection,search_by_name, and the romanized bus route/headsign names, this supplements every English-facing surface at once. When projecting intostations,station_name_rnis filled with the plain-ASCII spelling viaromaji::strip_macrons()(Tōkyō → Tokyo), mirroring the rail dataset's_r(macron) /_rn(macron-free) column pair. - TTS metadata –
Station,StationNested,Line,LineNested,TrainType, andTrainTypeNestedexposename_ipa/name_roman_ipaplusname_tts_segmentsfor multi-segment pronunciation output. Usename_tts_segmentswhen clients need per-token SSML construction for mixed-language names such asKasai-Rinkai Park. - Connected routes –
GetConnectedRoutesperforms a bounded breadth-first search across train-type line groups. Transfers join at a shared station group, route order and per-stop pass metadata are preserved, and each returned candidate receives a deterministic virtual line-group ID in the upper half of theuint32range. Revisiting station groups and already-used train types is rejected to prevent cycles. Exploration loads only line-group ID, station-station-type ID, station-group ID, and pass metadata; full station rows are fetched after the result set is fixed. The search is additionally capped at eight train types, 4,096 expanded states, 65,536 evaluated candidates, and 32 results to bound computation and result size. - Changes to the published contract require coordinated updates to
schema/public.graphql, the async-graphql types insrc/graphql/, and, when the shape of a value changes,stationapi/src/model.rsand the DTO conversions.
This repository is a colocated jj/git checkout — .jj/ and .git/ sit side by side. Agents run every version-control operation through jj. Do not run a git command that writes (commit, switch, branch, push, rebase, stash): jj re-imports the Git refs on its next invocation, so a Git-side change is either abandoned or resurfaces as a divergent change. gh remains the tool for pull requests, and GitHub Actions keeps consuming the Git side unchanged.
trunk()resolves todev@origin, a per-repository revset alias. Prefer it to a hard-coded branch name. jj 0.38.0 and later keep repository config outside the repository, so.jj/repo/config.tomlis not the file to edit — runjj config path --repoto locate it,jj config list 'revset-aliases."trunk()"'to check it, andjj config set --repo 'revset-aliases."trunk()"' dev@originto (re-)create it on a fresh clone.- There is no staging area and no untracked file. The working copy is itself a commit, and jj snapshots every file under the root on each command (
snapshot.auto-track = "all()"), so a scratch file lands in the change unless.gitignorecovers it. Nothing corresponds togit add, so readjj statusbefore describing a change and remove what does not belong —jj restore <path>to drop it,jj splitto move it into a commit of its own. - Bookmarks are jj's branches, and they do not follow new commits. After committing, move the bookmark yourself (
jj bookmark set <name> -r @-); forgetting it makes the next push a no-op. - Never rewrite a pushed commit without asking.
jj describe,jj squash, andjj rebaserewrite history in place, and the nextjj git pushmoves the remote bookmark with force-with-lease semantics. Confirm with the user first, exactly as for a Git force push. jj undoreverses the last operation andjj op loglists them; prefer both to reconstructing state by hand.
A typical change:
jj git fetch # refresh dev@origin and the other remote bookmarks
jj new 'trunk()' # start a new change on top of dev@origin
# ... edit files; jj snapshots them automatically ...
jj status # confirm exactly what the change contains
jj commit -m "日本語の単文" # describe @ and open a fresh empty working copy on top
jj bookmark create feature/<description> -r @-
jj git push -b feature/<description>Equivalents for the operations this guide and .claude/skills/create-pr rely on:
| Purpose | Command |
|---|---|
| Repository root | jj root |
| Working-copy state | jj status |
| History | jj log (jj log -r 'trunk()..@' for the current change set) |
Bookmark closest to @ |
jj log -r 'heads(::@ & bookmarks())' --no-graph -T 'local_bookmarks.map(|b| b.name()).join("\n") ++ "\n"' — the trailing ++ "\n" is what separates one revision's output from the next; without it several heads print as one run-on line. May print more than one name (several bookmarks on one commit, or several heads above @). Count the lines first; on more than one, present the candidates and ask which to use instead of taking the first. |
| Commit subjects on a bookmark | jj log -r 'dev@origin..<bookmark>@origin' --no-graph -T 'description.first_line() ++ "\n"' |
| Files changed against a base | jj diff --name-only --from 'dev@origin' --to '<bookmark>@origin' |
Rebase onto the latest dev |
jj git fetch && jj rebase -d 'trunk()' |
CONTRIBUTING.md still documents the Git workflow, because outside contributors are not required to install jj. Keep the two aligned in intent — base branch, naming convention, and pull-request rules are identical; only the commands differ.
- Git-flow – Follow Git-flow with
devserving as this repository'sdevelopbranch. Create ordinary work bookmarks from the latesttrunk()(dev@origin) withjj new 'trunk()', use thefeature/<description>naming convention, and target their pull requests todev. Do not create or target a bookmark nameddevelop. Version Control (Jujutsu) above has the full command sequence. - Pull requests – Assign every pull request to
@TinyKittenwhen creating it, open it as ready for review rather than as a draft, and use.github/pull_request_template.mdwithout omitting or replacing its sections or checklists. - Prioritize quality and performance over implementation speed – Always favor code quality and runtime performance over velocity. Be mindful of algorithmic complexity and look for opportunities to replace O(n×m) linear scans with O(n+m) indexed lookups (e.g., HashMaps). The indexes are rebuilt on every isolate start and every request scans them, so prefer indexed lookups over repeated full scans. When a change affects performance, document the before/after complexity and query plan impact in the pull request.
- Document the commands you executed (for example,
make fmt && make clippy && make test) and their outcomes in every pull request. - For data pipeline or schema updates, add architectural notes under
docs/and synchronize README references so onboarding materials stay accurate. - When modifying
QueryInteractor, ensure the enrichment steps (companies, train types, line symbols) still behave as expected. Double-check helper methods such asupdate_station_vec_with_attributesandbuild_route_tree_map. - Introducing new tables, endpoints, or feature flags must come with matching updates to this document and any other affected guidance.
Keep this guide aligned with the repository. If a workflow, environment requirement, or endpoint changes, update AGENTS.md in the same pull request so automation agents and contributors work from current instructions.