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.datatests, and thegenerate:recipe-indexpackage script.StationRecipeis re-sourced fromstation-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 existingGw2Client/Gw2Service. - apps/web: React, TanStack Query,
orval(generated client), Zod, Vitest. - docs: VitePress (
docs:buildcompilesspecs/*.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. 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.
| Path | Change | Responsibility |
|---|---|---|
apps/api/src/static-data/data/station-recipes.ts | delete | the committed station index (warm-up snapshot) |
apps/api/src/static-data/generate-recipe-index.ts | delete | pure index builder |
apps/api/src/static-data/generate-recipe-index.cli.ts | delete | live-resolving sync CLI |
apps/api/src/static-data/generate-recipe-index.test.ts | delete | builder test |
apps/api/src/static-data/recipe-index.schema.ts | delete | RecipeIndexFile/committed-StationRecipe schema |
apps/api/src/static-data/recipe-index.schema.test.ts | delete | that schema's test |
apps/api/src/static-data/recipe-index.data.test.ts | delete | validates the committed data |
apps/api/src/static-data/recipe-index.coverage.test.ts | delete | asserts committed index covers the Gen-1 closure |
apps/api/src/static-data/recipe-index.service.ts | modify | drop RECIPE_INDEX_DATA + index Map; recipesFor = curated ∪ live station; import StationRecipe from station-data.service |
apps/api/src/static-data/recipe-index.service.test.ts | modify | 2-arg construction; assert curated-then-live behaviour |
apps/api/src/static-data/static-data.module.ts | modify | remove the RECIPE_INDEX_DATA provider + station-recipes import |
apps/api/src/static-data/static-data.module.test.ts | modify | drop the RECIPE_INDEX_DATA provider assertion |
apps/api/src/recipe-graph/recipe-graph.controller.ts | modify | return resolve() (priceless), typed to ResolvedGraph; keep the 404 mapping |
apps/api/src/recipe-graph/recipe-graph.controller.test.ts | modify | assert priceless body, keyless, 404 |
apps/api/src/recipe-graph/recipe-graph.schema.ts | modify | add RecipeGraphDto (ResolvedGraph, reusing GraphNodeSchema); remove the priced DTOs |
apps/api/src/recipe-graph/recipe-graph.schema.test.ts | modify | validate the priceless DTO |
apps/api/src/recipe-graph/recipe-graph.service.ts | modify | ponytail: 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.ts | modify | 2-arg construction; refocus to live resolution |
apps/api/src/recipe-graph/recipe-graph.service.warm-cache.test.ts | modify | 2-arg construction (proves SC4: 2nd resolve = 0 fetches) |
apps/api/src/mcp/mcp.tools.test.ts | modify | correct the SC15 "same value the REST route serves" comment (R9) |
apps/api/src/generate-openapi.test.ts | modify | assert the flat (non-recursive) /recipe-graph schema |
apps/api/package.json | modify | remove the generate:recipe-index script |
apps/web/src/api/generated/** | regenerate | orval client + models from the new OpenAPI |
apps/web/src/api/useRecipeTree.ts | modify | fetch the flat graph, project a priceless tree (first-recipe expansion) |
apps/web/src/api/__tests__/useRecipeTree.test.tsx | modify | flat-graph fetch + projection |
apps/web/src/features/legendaries/LegendaryDetailView.tsx | modify | consume the projected priceless tree |
apps/web/src/features/legendaries/LegendaryTree.tsx | modify | render node structure; guard absent price/decision |
apps/web/src/features/legendaries/ShoppingList.tsx | modify | render the materials list; no cost column (returns in 033) |
apps/web/src/features/legendaries/LegendarySummary.tsx | modify | hide/placeholder the profit summary (no prices yet) |
apps/web/src/features/legendaries/__tests__/* , fixtures.ts | modify | priceless fixtures + assertions for the above |
docs/gaps/curated-recipes-workspace-package.md | create | park the "curated dataset is still a workspace package" divergence |
apps/api/src/conventions/guards.ts | modify (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:
// 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 unionThe 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.tsthrough HTTP: the body hasrootId/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 innodeswith all its options. - Live == former index (P2 #1 / SC3 / R6) —
recipe-graph.service.index-equivalence.test.ts, refocused: with a stubbed liveStationDataService, resolving yields the same graph the index gave. The one-time real confirmation is the discovery spike (research.mdR7); 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) assertingstation-recipes.ts,generate-recipe-index*, and thegenerate:recipe-indexscript are gone. - OpenAPI —
generate-openapi.test.tsasserts the flat/recipe-graphschema. - Web —
useRecipeTree.test.tsx(flat fetch + projection); legendary-detail tests +fixtures.tsupdated 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
PricedTreeNodeshape 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.tsgate it. - Over-deletion of priced code —
pricing.ts/resolvePricedmust survive for the MCP tool. The File Structure's explicit "keep" list andmcp.tools.test.tsguard against it. - Coordination with 031 — 032 merges without the header; the per-route declaration is wired when 031's mechanism lands (recorded in
spec.mdCoordination).
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.