Skip to content

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; [] stops

toOptions(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. Use unknown plus 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-error without 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" and baseUrl are removed in TS 7. Use "nodenext" (or "bundler" for Vite/bundler-resolved code) plus paths: { "*": ["./*"] } in place of baseUrl.
  • TypeScript's default lib includes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), or document/window resolve everywhere, silently defeating profile isolation. A profile that needs DOM adds "DOM", "DOM.Iterable" on top of that floor.
  • experimentalDecorators and emitDecoratorMetadata are supported for typecheck only — tsc --noEmit accepts decorator syntax and resolves metadata types, but --noEmit never emits design:paramtypes at runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; see stack.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, 429 on 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 as Authorization: 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-side localStorage is 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) ​

PathChangeResponsibility
apps/api/src/static-data/recipe-index.schema.tsnewZod 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.tsnew (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.tsnew@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.tsmodifyAdd RecipeIndexService to providers and exports.
apps/api/src/recipe-graph/recipe-graph.service.tsmodifyConstructor 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) ​

PathChangeResponsibility
apps/api/src/static-data/generate-recipe-index.tsnewPure 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.tsnewEntry: 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.jsonmodifyAdd "generate:recipe-index": "node dist/static-data/generate-recipe-index.cli.js".

Tests (R7) ​

PathChangeResponsibility
apps/api/src/static-data/recipe-index.service.test.tsnewrecipesFor 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.tsnewGuard: 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.tsnewbuildRecipeIndex 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.tsnewFake 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.tsnewLoads 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.tsmodifyThe 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 four recipesFor cases (forge hit / station hit / leaf-[] / miss→live) with a station-call spy proving hits do zero live reads; LegendaryComponent filtered (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, rootIds correct, 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 fetch and 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 recipesFor stubbed.
  • Invariants (SC6): pnpm typecheck / lint / test / build green; no any; the docs/superpowers count-stays-zero and Global-Constraints-verbatim guards hold.
  • Not directly tested: the live GET /v2/build and live station responses the generator reads — exercised by running generate:recipe-index by hand during implementation and committing its output; the build id is recorded in meta. 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 in recipesFor removes 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 .json file + swc --copy-files — rejected: adds a build flag the Docker image must carry; a .ts module under src compiles to dist for free and is typed at build (research F12).
  • Filtering LegendaryComponent at generation — rejected: splits the domain rule; store raw, filter once in toOptions (R3).

Risks ​

  • Resolver-test migration. Moving buildRecipes into RecipeIndexService re-homes the union/order/ filter tests from recipe-graph.service.test.ts to recipe-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 RecipeIndexService normally loads the committed file. Mitigation: the CLI wires an empty-index RecipeIndexService (all misses → live); the pure buildRecipeIndex is 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 the index payload. See Open questions.
  • Staleness on a patch. The gw2Build stamp surfaces drift; documented pnpm --filter @gw2priory/api generate:recipe-index; accepted (spec R6).
  • A malformed/hand-edited committed module. Mitigation: satisfies fails pnpm build; the guard test fails pnpm test — two fences before runtime.

Open questions ​

  • SC5 wording vs the provenance date — plan decision: gw2Build in the file, generated-at in the git commit, determinism test scoped to index. Confirm, or prefer date-in-file with the test excluding meta. — 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.