Skip to content

Plan 032 — Recipe graph: cacheable, priceless, live station recipes ​

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 ​

GET /recipe-graph/:itemId becomes a priceless, user-agnostic, keyless artifact a shared edge cache can hold for hours, and the recipe graph is resolved with no committed station snapshot and no regeneration script: station recipes come live from ArenaNet through the existing no-expiry client cache, while the curated Mystic-Forge / precursor recipes stay in the app-side dataset. The client (Spec 033) fetches prices and merges them; this plan removes the server-side price fold and the sync machinery, nothing more.

Approach ​

Backend — priceless endpoint. The controller returns RecipeGraphService.resolve(itemId) — the flat, deduplicated ResolvedGraph ({ rootId, nodes }) it already produces — instead of resolvePriced(). resolvePriced() and pricing.ts stay: the MCP priory_recipe_tree tool (Surface B, Wave 3) still uses them. A new RecipeGraphDto validates the flat shape, reusing the existing GraphNodeSchema; the priced Zod DTOs (PricedTreeNodeSchema, PlanSummarySchema, DecisionSchema, RecipeTreeDto), no longer response-validated by any route, are removed. The OpenAPI document is regenerated and its recursive-$ref assertion (generate-openapi.test.ts) is updated to the flat, non-recursive shape.

Backend — live station recipes, no committed index. RecipeIndexService drops the RECIPE_INDEX_DATA injection and its in-memory index Map. recipesFor(id) becomes: curated forge if present (short-circuit — those ids are API-absent, so no live search is wasted on them), otherwise one live StationDataService.getRecipes(id). That live path already exists and is served from the existing no-expiry gw2-client caches (staticCache/searchCache) — no new cache is built. Deleted: data/station-recipes.ts, the generate-recipe-index builder + CLI + its test, recipe-index.schema.ts

  • its test, the recipe-index.coverage/recipe-index.data tests, and the generate:recipe-index package script. StationRecipe is re-sourced from station-data.service.ts (already a structurally identical exported interface).

Backend — Surface A. The route is declared Surface A; the Cache-Control header is emitted by Spec 031's mechanism. This plan does not add a header scheme — it makes the body eligible (no prices, no per-user data) and records the declaration; the per-route wiring lands when 031's seam is available.

Frontend — consume the flat graph, structure only. The legendary detail view is the sole /recipe-graph consumer. useRecipeTree fetches the flat graph and projects it into a priceless tree by a canonical first-recipe expansion (there is no price to pick the cheapest by), returning a tree-shaped structure whose price fields are absent. LegendaryTree, ShoppingList, and LegendarySummary render structure (item, rarity, icon, quantity, recipe provenance) and guard the now-absent price/decision/profit fields — the money UI returns in Spec 033. The orval client regenerates from the new OpenAPI. Ranking (/legendaries/ranking, a different endpoint) is untouched.

Cold-resolve ceiling. The resolver's sequential DFS makes a cold first-resolve of a large tree slow (measured ~40 s for The Bifrost — research.md Q2). This is accepted; the resolve seam carries a ponytail: comment naming the ceiling and the deferred upgrade path (parallelise the DFS).

Architecture ​

Surface A (priceless, cacheable)          Surface B (priced — unchanged, Wave 3)
  RecipeGraphController.get(:itemId)         McpTools.priory_recipe_tree
        │ resolve()                                │ resolvePriced()
        ▼                                          ▼
  RecipeGraphService ──────────────────────────────┘  (resolve + resolvePriced share expand/enrich)
        │ recipesFor()            │ metadata() (batched enrich)
        ▼                         ▼
  RecipeIndexService          ItemDataService
   ├─ CuratedRecipeService (forge/precursor — app-side, unchanged)
   └─ StationDataService.getRecipes() → Gw2Service (live /v2/recipes[/search], no-expiry cache)

Web:  LegendaryDetailPage → useRecipeTree (fetch flat graph → project priceless tree)
        → LegendaryDetailView → { LegendaryTree, ShoppingList, LegendarySummary }  (structure only)

The committed station-recipes.ts and the RECIPE_INDEX_DATA provider are removed from this diagram; recipesFor now reaches StationDataService directly for any non-curated id.

Tech stack ​

Existing stack only — no new dependency (Global Constraint below).

  • apps/api: NestJS, nestjs-zod, Zod, Vitest. GW2 access via the existing Gw2Client/Gw2Service.
  • apps/web: React, TanStack Query, orval (generated client), Zod, Vitest.
  • docs: VitePress (docs:build compiles specs/*.md).

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.

PathChangeResponsibility
apps/api/src/static-data/data/station-recipes.tsdeletethe committed station index (warm-up snapshot)
apps/api/src/static-data/generate-recipe-index.tsdeletepure index builder
apps/api/src/static-data/generate-recipe-index.cli.tsdeletelive-resolving sync CLI
apps/api/src/static-data/generate-recipe-index.test.tsdeletebuilder test
apps/api/src/static-data/recipe-index.schema.tsdeleteRecipeIndexFile/committed-StationRecipe schema
apps/api/src/static-data/recipe-index.schema.test.tsdeletethat schema's test
apps/api/src/static-data/recipe-index.data.test.tsdeletevalidates the committed data
apps/api/src/static-data/recipe-index.coverage.test.tsdeleteasserts committed index covers the Gen-1 closure
apps/api/src/static-data/recipe-index.service.tsmodifydrop RECIPE_INDEX_DATA + index Map; recipesFor = curated ∪ live station; import StationRecipe from station-data.service
apps/api/src/static-data/recipe-index.service.test.tsmodify2-arg construction; assert curated-then-live behaviour
apps/api/src/static-data/static-data.module.tsmodifyremove the RECIPE_INDEX_DATA provider + station-recipes import
apps/api/src/static-data/static-data.module.test.tsmodifydrop the RECIPE_INDEX_DATA provider assertion
apps/api/src/recipe-graph/recipe-graph.controller.tsmodifyreturn resolve() (priceless), typed to ResolvedGraph; keep the 404 mapping
apps/api/src/recipe-graph/recipe-graph.controller.test.tsmodifyassert priceless body, keyless, 404
apps/api/src/recipe-graph/recipe-graph.schema.tsmodifyadd RecipeGraphDto (ResolvedGraph, reusing GraphNodeSchema); remove the priced DTOs
apps/api/src/recipe-graph/recipe-graph.schema.test.tsmodifyvalidate the priceless DTO
apps/api/src/recipe-graph/recipe-graph.service.tsmodifyponytail: comment at the resolve seam (cold-resolve ceiling); no logic change to resolve()/resolvePriced()
apps/api/src/recipe-graph/recipe-graph.service.index-equivalence.test.tsmodify2-arg construction; refocus to live resolution
apps/api/src/recipe-graph/recipe-graph.service.warm-cache.test.tsmodify2-arg construction (proves SC4: 2nd resolve = 0 fetches)
apps/api/src/mcp/mcp.tools.test.tsmodifycorrect the SC15 "same value the REST route serves" comment (R9)
apps/api/src/generate-openapi.test.tsmodifyassert the flat (non-recursive) /recipe-graph schema
apps/api/package.jsonmodifyremove the generate:recipe-index script
apps/web/src/api/generated/**regenerateorval client + models from the new OpenAPI
apps/web/src/api/useRecipeTree.tsmodifyfetch the flat graph, project a priceless tree (first-recipe expansion)
apps/web/src/api/__tests__/useRecipeTree.test.tsxmodifyflat-graph fetch + projection
apps/web/src/features/legendaries/LegendaryDetailView.tsxmodifyconsume the projected priceless tree
apps/web/src/features/legendaries/LegendaryTree.tsxmodifyrender node structure; guard absent price/decision
apps/web/src/features/legendaries/ShoppingList.tsxmodifyrender the materials list; no cost column (returns in 033)
apps/web/src/features/legendaries/LegendarySummary.tsxmodifyhide/placeholder the profit summary (no prices yet)
apps/web/src/features/legendaries/__tests__/* , fixtures.tsmodifypriceless fixtures + assertions for the above
docs/gaps/curated-recipes-workspace-package.mdcreatepark the "curated dataset is still a workspace package" divergence
apps/api/src/conventions/guards.tsmodify (comment only)drop the stale RECIPE_INDEX_DATA example if no guard test depends on it

Keep, explicitly (do not delete): pricing.ts, project-tree.ts, resolvePriced(), curated-recipe.service.ts, and @gw2priory/legendary-recipes — all still used (MCP path / curated data).

Data & contracts ​

The /recipe-graph/:itemId response changes from the recursive PricedTreeNode to the flat ResolvedGraph. Full OpenAPI is in spec.md (Endpoints & contract). Shape:

ts
// response body — reuses GraphNodeSchema already in recipe-graph.schema.ts
interface ResolvedGraph {
  rootId: number;
  nodes: Record<number, GraphNode>; // string keys in JSON; every reachable id, resolved once
}
// GraphNode: { id, name|null, rarity|null, vendorValue|null, classification: 'buyable'|'gated'|null,
//              leaf, recipes: RecipeOption[] }  — RecipeOption.ingredients is the item|currency edge union

The DTO is class RecipeGraphDto extends createZodDto(ResolvedGraphSchema) where ResolvedGraphSchema = z.object({ rootId: z.number().int().positive(), nodes: z.record(z.string(), GraphNodeSchema) }). The OpenAPI for it is additionalProperties + a oneOf edge union — not recursive, so generate-openapi.test.ts's recursive-$ref (SC12) assertion is replaced.

Test strategy ​

  • Priceless body (SC1/P1 #1), keyless (SC2/P1 #2), 404 (P1 #4) — recipe-graph.controller.test.ts through HTTP: the body has rootId/nodes, none of the price fields, is identical across keys, and an unknown id 404s.
  • Complete graph (P1 #3) — recipe-graph.service.test.ts: a node reachable only via a non-first recipe still appears in nodes with all its options.
  • Live == former index (P2 #1 / SC3 / R6) — recipe-graph.service.index-equivalence.test.ts, refocused: with a stubbed live StationDataService, resolving yields the same graph the index gave. The one-time real confirmation is the discovery spike (research.md R7); this test is the regression guard with fixtures.
  • Server cache (P2 #2 / SC4) — recipe-graph.service.warm-cache.test.ts: 2nd resolve issues 0 fetches.
  • Curated returned (P2 #3) — recipe-index.service.test.ts: a forge-covered id returns its curated recipe and does not hit the live station path.
  • Deletions absent (SC5) — a guard test (grep/fs) asserting station-recipes.ts, generate-recipe-index*, and the generate:recipe-index script are gone.
  • OpenAPI — generate-openapi.test.ts asserts the flat /recipe-graph schema.
  • Web — useRecipeTree.test.tsx (flat fetch + projection); legendary-detail tests + fixtures.ts updated to priceless data; components render structure with no price UI.
  • CI gate (SC6) — pnpm lint && pnpm test && pnpm build && pnpm docs:build, all green on this branch.

Not tested directly: the ~40 s cold-resolve latency (a discovery measurement, not a CI assertion — a network-timing test would be flaky); the Cache-Control header (Spec 031 owns it). Surface-A cacheability is proven indirectly by SC1/SC2 (no volatile/per-user data in the body).

Alternatives considered ​

  • Keep the committed index (spec option A) — rejected: the human chose to delete it (option B); the snapshot is a manual regen step the epic removes, and its data is immutable so live-then-cache reproduces it.
  • Parallelise the resolver now — deferred (not rejected): a bigger, riskier diff on the correctness-critical DFS; the cold cost is amortised (no-TTL cache + edge cache + Spec 031 warm origin). Marked as the documented upgrade path.
  • Keep the PricedTreeNode shape with prices nulled — rejected: it prunes to one recipe per node, so Spec 033 could not re-pick the cheapest path (lossy). The flat graph keeps every option.
  • Warm the cache on boot — rejected: cannot warm a fixed set when the endpoint serves any item (gen 2/3, arbitrary ids).

Risks ​

  • Cold first-resolve timeout — the accepted ceiling. Mitigation: no-TTL server cache (immutable recipes), Surface-A edge cache (031), Spec 031 warm origin; upgrade path is DFS parallelisation.
  • FE detail page looks sparse until 033 — the price/decision/profit UI is gone in the interim. This is the agreed intermediate state (flat-graph + strip-price-UI decision); Spec 033 restores it.
  • OpenAPI ↔ orval drift — regenerate the OpenAPI and the web client in the same task; typecheck + generate-openapi.test.ts gate it.
  • Over-deletion of priced code — pricing.ts/resolvePriced must survive for the MCP tool. The File Structure's explicit "keep" list and mcp.tools.test.ts guard against it.
  • Coordination with 031 — 032 merges without the header; the per-route declaration is wired when 031's mechanism lands (recorded in spec.md Coordination).

Open questions ​

None blocking. All spec.md markers are resolved in research.md (Q1 keep no-TTL cache; Q2 delete + live, sequential now; R7/SC3 confirmed). The only deferred item is resolver parallelisation — a future optimisation, not a prerequisite for this plan.