Plan 027 — Gen-1 recipe index (committed forge + station, merged at load)
Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Goal
Make the Gen-1 recipe trees resolve from a committed index instead of a live searchRecipes per node, so a cold-process resolve, ranking, or priory_recipe_tree MCP call issues zero station-recipe upstream reads (research V1/F4: the 103-id closure, ~94 leaves). A RecipeIndexService becomes the single recipe-source authority: it merges the authored forge recipes (the existing package) with a generated station file into one recursive lookup, recipesFor(id), and falls back to the live path for ids outside the closure. No HTTP contract changes; the win is entirely behind that one lookup.
Approach
One authority, two sources, merged in memory. research F6 established the constraint: forge recipes (Sunrise, gifts) are absent from the GW2 API — they're hand-authored in @gw2priory/legendary-recipes. Station recipes (Bolt of Damask) are fetched. So there are two irreducible inputs. A new RecipeIndexService owns their union: at load it holds the authored forge recipes (via the existing CuratedRecipeService) and a committed station file; recipesFor(id) returns the merged RecipeOption[], or falls back to a live read when id is in neither source.
Membership is presence, and it needs no separate structure. recipesFor(id) checks forge first (curated.getRecipe(id), a free local lookup) and the station file second:
recipesFor(id):
forge = curated.getRecipe(id) // CuratedRecipe | null (local)
station = stationIndex.get(id) // StationRecipe[] | undefined (committed file)
if (forge === null && station === undefined) return toOptions(null, await liveStation.getRecipes(id)) // MISS → live
return toOptions(forge, station ?? []) // HIT → recurse; [] stopstoOptions(curated, station[]) is the single home of the normalise-and-filter rule — it maps a CuratedRecipe to a mystic-forge RecipeOption, maps each StationRecipe to a station option, and drops LegendaryComponent types (007 T7/R12). A leaf (Ecto) is station = [], present → toOptions returns [] → the walk stops, no live call. An absent id (non-Gen-1) hits neither → live.
The station file excludes forge-covered ids — no legendaries in it. Because membership checks forge first, a legendary's presence comes from the package, so its (empty) station result need not be stored. The generator therefore records a station entry only for reached ids that are not forge-covered: the ~9 discipline crafts (real recipes) and the ~60 raw leaves (as []). Sunrise and the gifts never appear in the station file — the "why is a legendary in a station file" smell is gone by construction; the merged closure is still complete (forge-covered ids ∪ station-file ids = 103).
The generator reuses the resolver in live mode. A CLI (mirroring generate-openapi) boots a Nest context wired with an empty station index (so recipesFor resolves fully live), resolves the 21 Gen-1 roots via the real RecipeGraphService, unions their node ids into the closure, records stationLive.getRecipes(id) for each non-forge-covered reached id, and writes a deterministic station-recipes.ts (numerically-sorted keys, leaves as []) stamped with the GW2 build id from /v2/build. Committed and reviewed like openapi.json; regenerated by hand on a patch.
The committed file is a .ts module. research F12: a .ts under node_modules can't be runtime-loaded by the swc-built api, but a .ts under apps/api/src compiles to dist normally. So the generator writes data/station-recipes.ts (export const stationRecipeIndex = {…} satisfies RecipeIndexFile); it is typed at build (a bad regen fails pnpm build) and validated by a guard test against a Zod schema — the same pattern @gw2priory/legendary-recipes uses, no runtime Zod parse.
Architecture
RecipeGraphService.expand(id) [walk unchanged: dedup, cycle guard, ingredient recursion]
└─ recipeIndex.recipesFor(id) [CHANGED: was inline curated ∪ live-station]
├─ forge = curated.getRecipe(id) ── local (package)
├─ station = stationIndex.get(id) ── committed station-recipes.ts
├─ both absent → toOptions(null, await liveStation.getRecipes(id)) ← live fallback (off-closure)
└─ else → toOptions(forge, station ?? []) ← 0 network; [] stops the walk
RecipeIndexService (single recipe-source authority)
injects: CuratedRecipeService (forge) + StationDataService (live fallback)
loads: data/station-recipes.ts { meta, index } keys = non-forge closure ids
owns: toOptions(curated, station[]) — normalise + LegendaryComponent filter (one home)
generate-recipe-index.cli → NestFactory.createApplicationContext (empty-index wiring → resolve goes live)
→ resolve 21 roots (RecipeGraphService) → closure id set
→ for each reached id NOT forge-covered: record stationLive.getRecipes(id) (leaves → [])
→ GET /v2/build → meta.gw2Build
→ write data/station-recipes.ts (sorted)RecipeGraphService swaps its curated+station injections for one recipeIndex; itemData (enrichment) and gw2 (pricing in resolvePriced) are unchanged. StaticDataModule gains the RecipeIndexService provider.
Tech stack
NestJS 11 (@nestjs/core NestFactory.createApplicationContext for the generator), the existing RecipeGraphService / CuratedRecipeService / StationDataService / Gw2Client, the RecipeOption type from @gw2priory/recipe-graph, Zod 4 (guard-test validation of the committed file), Vitest. Generator wiring mirrors apps/api/src/generate-openapi.ts + .cli.ts + the generate:openapi script. No new dependencies.
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
moduleResolution: "node"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.md's api build model.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS (TypeScript).apps/web— React (TypeScript).packages/*— shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.- Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
- Vitest everywhere, both apps.
- Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
- Static data (items, station recipes) is immutable: cache hard.
- Prices are volatile: short TTL, recomputed live.
- GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec,
429on overflow. Batch up to 200 ids per?ids=call. - All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
- API keys are user secrets: never logged, never persisted server-side, never returned to the client. In the MVP the key is held client-side — the browser's
localStorage— and sent per request asAuthorization: Bearer; the api forwards it to GW2 and stores nothing at rest. Encryption at rest applies only if/when server-side key storage is introduced; no such storage exists today. Client-sidelocalStorageis plaintext and readable by any script on the origin (XSS) — a deliberate MVP limitation, established by spec 016 (client-custody, revisit before adding write-scoped or higher-value keys).
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.
File Structure
Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.
Backend — the merged recipe source (R1–R5)
| Path | Change | Responsibility |
|---|---|---|
apps/api/src/static-data/recipe-index.schema.ts | new | Zod StationRecipeSchema (mirrors the StationRecipe interface) + RecipeIndexFileSchema ({ meta: { gw2Build, rootIds, entryCount }, index: Record<string, StationRecipe[]> }) + the exported RecipeIndexFile type. Used by the guard test, not at runtime. |
apps/api/src/static-data/data/station-recipes.ts | new (generated) | export const stationRecipeIndex = {…} satisfies RecipeIndexFile. index holds one entry per non-forge-covered reached id — ~9 real station recipes + raw leaves as []. No legendaries/gifts. Regenerated by the CLI. |
apps/api/src/static-data/recipe-index.service.ts | new | @Injectable RecipeIndexService. Injects CuratedRecipeService + StationDataService; builds stationIndex: Map<number, StationRecipe[]> from the committed module. recipesFor(id): Promise<RecipeOption[]> — forge ∪ cached-station, live fallback on miss (see Approach). Private toOptions(curated, station[]) — the single normalise + LegendaryComponent-filter home. |
apps/api/src/static-data/static-data.module.ts | modify | Add RecipeIndexService to providers and exports. |
apps/api/src/recipe-graph/recipe-graph.service.ts | modify | Constructor swaps curated + station for recipeIndex; expand calls await this.recipeIndex.recipesFor(id) in place of the inline buildRecipes. buildRecipes (union + filter) is deleted — its logic moved to RecipeIndexService.toOptions. enrich (itemData) and resolvePriced (gw2.prices) unchanged. |
Backend — the generator (R6)
| Path | Change | Responsibility |
|---|---|---|
apps/api/src/static-data/generate-recipe-index.ts | new | Pure buildRecipeIndex({ resolve, getStationRecipes, isForgeCovered, gw2Build, rootIds }): RecipeIndexFile — resolve each root (live), union node ids, and for each reached id where !isForgeCovered(id) record getStationRecipes(id) (leaves → []); assemble { meta, index } with numerically-sorted keys. Dependency-injected — no Nest, no network in its unit test. |
apps/api/src/static-data/generate-recipe-index.cli.ts | new | Entry: NestFactory.createApplicationContext wired so RecipeIndexService starts with an empty index (resolution goes fully live); pull RecipeGraphService, StationDataService, CuratedRecipeService, Gw2Service; gw2Build from GET /v2/build; call buildRecipeIndex; write data/station-recipes.ts (2-space, trailing newline, sorted). Mirrors generate-openapi.cli.ts. |
apps/api/package.json | modify | Add "generate:recipe-index": "node dist/static-data/generate-recipe-index.cli.js". |
Tests (R7)
| Path | Change | Responsibility |
|---|---|---|
apps/api/src/static-data/recipe-index.service.test.ts | new | recipesFor with a fake station index + stubbed curated/station: forge hit (legendary → its forge option, station not called — spy); station hit (Bolt of Damask → station option); leaf ([] present → returns [], station not called); miss (neither → one live getRecipes); LegendaryComponent filtered (R2/R3/R4, SC4). |
apps/api/src/static-data/recipe-index.data.test.ts | new | Guard: the committed station-recipes.ts parses against RecipeIndexFileSchema; keys numerically sorted; every entry well-formed; meta.rootIds equals the 21 Gen-1 ids; no forge-covered id appears (mirrors the curated package's guard-test pattern). |
apps/api/src/static-data/generate-recipe-index.test.ts | new | buildRecipeIndex with fakes: records every non-forge reached id, leaves as [], skips forge-covered ids, keys sorted, meta populated; two runs → identical index (SC5). |
apps/api/src/recipe-graph/recipe-graph.service.index-equivalence.test.ts | new | Fake index mirroring a fake live API: resolving a root with the index (fetch throws on /recipes/search + /recipes) deep-equals resolving it live (empty index, fetch serves) — R5/SC3, no live network. |
apps/api/src/static-data/recipe-index.coverage.test.ts | new | Loads the real committed station-recipes.ts; resolves each of the 21 Gen-1 roots with fetch throwing on /recipes/search + /recipes and serving stub /items?ids=; asserts every resolve succeeds → zero station reads, full coverage (SC1/SC2). |
apps/api/src/recipe-graph/recipe-graph.service.test.ts | modify | The T4–T8 walk/dedup/cycle tests now stub RecipeIndexService.recipesFor (instead of CuratedRecipeService/StationDataService); the union/order/LegendaryComponent assertions move to recipe-index.service.test.ts. Walk semantics (dedup, cycle, ingredient recursion, enrichment) stay and stay green. |
Data & contracts
Committed module (station-recipes.ts), guard-validated by RecipeIndexFileSchema:
StationRecipe = { outputItemId: number, outputCount: number,
ingredients: { itemId: number, count: number }[],
disciplines: string[], minRating: number, type: string } // disciplines = stable ids (research F7)
RecipeIndexFile = {
meta: { gw2Build: number, rootIds: number[], entryCount: number },
index: Record<string, StationRecipe[]> // key = output id; ONLY non-forge reached ids; leaves = []
}Runtime authority — RecipeIndexService.recipesFor(id: number): Promise<RecipeOption[]>, the single method the resolver calls. Present (forge or station) → merged options (a [] result stops the walk); neither → one live getRecipes then merge. RecipeOption (from @gw2priory/recipe-graph) and the walk output are byte-identical to today (R5).
No change to /recipe-graph/:itemId, /legendaries/ranking, priory_recipe_tree, apps/api/openapi.json, the curated package, or item-metadata enrichment (R8).
Test strategy
RecipeIndexService(unit, fakes): the fourrecipesForcases (forge hit / station hit / leaf-[]/ miss→live) with a station-call spy proving hits do zero live reads;LegendaryComponentfiltered (R2–R4, SC4). This file now owns the union/order/filter assertions lifted from the resolver tests.- Guard (unit): the committed module validates against the schema, keys sorted,
rootIdscorrect, no forge id present (SC5 shape; catches a hand-edit or a bad regen at test time). buildRecipeIndex(unit, fakes): determinism (two runs identical), sorted keys, leaves as[], forge-covered ids skipped, closure coverage of every reached non-forge id (SC5). No Nest, no network.- Equivalence (unit, twin fakes): index path deep-equals live path for a root (R5/SC3).
- Coverage against real data (integration, stubbed fetch): the committed index resolves all 21 roots with zero station reads; a missing closure id throws on the forbidden
fetchand fails (SC1/SC2) — the guard that a stale regen can't ship silently. - Resolver walk (unit, retained): dedup, cycle guard, ingredient recursion, enrichment stay green with
recipesForstubbed. - Invariants (SC6):
pnpm typecheck/lint/test/buildgreen; noany; thedocs/superpowerscount-stays-zero and Global-Constraints-verbatim guards hold. - Not directly tested: the live
GET /v2/buildand live station responses the generator reads — exercised by runninggenerate:recipe-indexby hand during implementation and committing its output; the build id is recorded inmeta. The latency win is an observation; SC1/SC2 assert the zero-call mechanism instead.
Alternatives considered
- Station-only index behind
StationDataService.getRecipes(the first approved plan) — rejected: it forces every legendary/gift into the station file as[](else they'd miss → live), which is the "legendary in a station file" smell; and it leaves the resolver doing a two-source merge per node. Checking forge first inrecipesForremoves both. - One merged file committed on disk (forge + station in a single generated file) — rejected: copies the hand-authored forge recipes into a generated artifact → duplication and drift. The load-time merge keeps forge in its one authored home.
- A Postgres/Redis store, or a boot-warmed full ~13k-recipe fetch — rejected in the spec (net-new subsystem / rebuilt on every spin-down).
- A
.jsonfile +swc --copy-files— rejected: adds a build flag the Docker image must carry; a.tsmodule undersrccompiles todistfor free and is typed at build (research F12). - Filtering
LegendaryComponentat generation — rejected: splits the domain rule; store raw, filter once intoOptions(R3).
Risks
- Resolver-test migration. Moving
buildRecipesintoRecipeIndexServicere-homes the union/order/ filter tests fromrecipe-graph.service.test.tstorecipe-index.service.test.ts. Mitigation: TDD the move task-by-task; the equivalence (R5) and coverage (SC1) tests are the backstop that behaviour is unchanged; the walk tests (dedup/cycle) stay put. - Generator circular wiring. The generator must resolve live while
RecipeIndexServicenormally loads the committed file. Mitigation: the CLI wires an empty-indexRecipeIndexService(all misses → live); the purebuildRecipeIndexis unit-tested with fakes, so only the thin CLI touches live. - Byte-stability vs a provenance date. Mitigation:
gw2Build(patch-stable) in the file, the date in the git commit; the determinism test compares theindexpayload. See Open questions. - Staleness on a patch. The
gw2Buildstamp surfaces drift; documentedpnpm --filter @gw2priory/api generate:recipe-index; accepted (spec R6). - A malformed/hand-edited committed module. Mitigation:
satisfiesfailspnpm build; the guard test failspnpm test— two fences before runtime.
Open questions
- SC5 wording vs the provenance date — plan decision:
gw2Buildin the file, generated-at in the git commit, determinism test scoped toindex. Confirm, or prefer date-in-file with the test excludingmeta. — human. - Regeneration cadence / ownership on a patch — a process, not code; the stamp surfaces staleness, nothing enforces regen (auto-freshness is spec Out-of-scope). Accept, or schedule a follow-up. — human.