Plan 007 — Recipe graph resolver
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. This document is the header half (design, structure, contracts, test strategy); the bite-sized TDD task steps are Step 3 (tasks.md), gated on this plan's approval.
Goal
Turn 006's single-item facts into a walkable whole: given any item id, produce the complete dependency graph of how it is made — every recipe path recursed to its leaves, each distinct item resolved once, each node enriched enough to price (008) and to render (the FE) — and expose it two ways: the raw graph in-process for the optimizer, and a display-ready tree over HTTP for the frontend. No prices, no cost, no account state.
Approach
A new NestJS service, RecipeGraphService.resolve(itemId), walks the 006 services depth-first. For each item it unions the curated forge recipe (CuratedRecipeService.getRecipe, 0..1) with every station recipe (StationDataService.getRecipes, 0..N), minus recipes of type: "LegendaryComponent" (the precursor-crafting path — R12/F7), and recurses into every item ingredient. Resolution is memoized by item id: each distinct item is walked, queried, and metadata-fetched exactly once, so the result is a DAG (nodes: Record<number, GraphNode>), not a duplicated tree. After the walk, a single batchedItemDataService.metadata call over the distinct ids enriches every node with name/rarity/vendorValue and its buyable/gated classification; ids the GW2 API omits stay null, never throwing. A cycle-guard (an ancestor-path set, distinct from the resolved set) throws CycleError on a malformed loop — the only condition under which resolve throws.
All recipe options are kept, none chosen — the buy-vs-craft min is 008. Quantities stay raw per-recipe counts; no aggregation. The graph type is plain-JSON (no Map on the wire).
Two consumers, two shapes. The flat id→node DAG is the substrate the optimizer (008) wants — it prices each distinct item once in a memoized fold. The frontend wants a nested tree it can render without reconstruction. projectTree(graph, chooseRecipe?) — a pure function in the shared package — bridges them: it walks the DAG from rootId into a nested { node, count, recipe, children } tree, shared items rendered at each usage, one recipe per node via a default chooser (008 later passes a min-cost chooser). Because it is pure it can run anywhere; for the HTTP endpoint it runs on the BE, so the wire payload is already display-ready.
The endpoint. GET /recipe-graph/:itemId returns projectTree(await resolve(itemId)) — the nested display tree, documented in OpenAPI. resolve() (the raw DAG) stays an in-process method for 008; it is not exposed over HTTP in 007 (YAGNI — 008 is in-process; a raw-DAG route can be added if a consumer ever needs it). The route validates the path param (positive integer → 400), and returns 404 when the root item does not exist (root metadata null); otherwise 200 with the tree. OpenAPI is generated the project's existing way — a Zod schema via nestjs-zod (createZodDto + @ZodResponse), emitted into the committed openapi.json and asserted by the existing generator test.
One small 006 change is required: StationRecipe currently drops the recipe type (station-data.service.ts), but R12's filter needs it. The field is already fetched and validated by the 005 client (Gw2RecipeSchema.type, gw2.schemas.ts:19) — the change only surfaces it. The filter policy stays in the 007 resolver (R12 is a 007 requirement); 006 stays policy-free.
Architecture
apps/web (future FE) ──HTTP──> GET /recipe-graph/:itemId ─┐
│ RecipeGraphController
│ projectTree( resolve(id) ) ← BE-side projection
future 008 optimizer ──in-process──> RecipeGraphService.resolve(id) : ResolvedGraph (DAG)
▼ depends on types + projectTree
@gw2priory/recipe-graph (pure: Zod schemas + inferred types + projectTree)
▲
apps/api/recipe-graph (Controller · Service · Module · CycleError · response DTO)
▼ imports StaticDataModule
apps/api/static-data (006: CuratedRecipeService · StationDataService[+type] · ItemDataService)
▼
apps/api/gw2 (005: Gw2Service — the one budgeted client; caches items/recipes/search, no TTL)@gw2priory/recipe-graph(new package, no Nest, no I/O) — the graph contract as Zod schemas with inferred types (RecipeEdge,RecipeOption,GraphNode,ResolvedGraph,TreeNode) plus the pureprojectTree. One source of truth for the shape, reused by the resolver, the endpoint's OpenAPI DTO, the FE, and 008. Mirrors@gw2priory/legendary-recipes(which likewise ships Zod + inferred types).apps/api/src/recipe-graph—RecipeGraphModule(importsStaticDataModule) providingRecipeGraphService, theRecipeGraphController, the response DTO, andCycleError. Depends only on the 006 services and the package; never callsGw2Servicedirectly (R1).
Tech stack
- TypeScript (
nodenext), NestJS on the Fastify adapter — existingapps/apistack. - nestjs-zod + @nestjs/swagger — existing deps (used by
health), reused for the endpoint's validation + OpenAPI. The response DTO iscreateZodDto(TreeNodeSchema);@ZodResponsedocuments it and the deterministicopenapi.jsonemit already has a guard test. - Zod — already a repo dependency (
legendary-recipes,gw2.schemas). The new package uses it to define the graph schemas and infer the types. - Vitest — unit tests for the resolver (mocked 006), the controller (mocked service), and
projectTree(pure); one integration test wires the realGw2Clientwith a fakefetchfor SC7. - No new external dependency — only existing repo deps (
zod,nestjs-zod,@nestjs/swagger) and one new internalworkspace:*package.
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: encrypted at rest, never logged, never returned to the client.
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.
F12 correction (2026-07-29). Step-4 exploration found the API's SWC runtime cannot load runtime values from a
.tsworkspace package (research.md F9). So@gw2priory/recipe-graphis types-only (src/types.ts, no Zod, nozoddep), andprojectTree+ the endpoint's Zod schema move intoapps/api/src/recipe-graph/(project-tree.ts+ its test, andrecipe-graph.schema.tsdefines the Zod locally rather than importing it from the package). The rows below that placeprojectTree/Zod in the package are superseded by this note; everything else stands. (R11; spec updated.)
| Path | Change | Responsibility |
|---|---|---|
packages/recipe-graph/package.json | new | @gw2priory/recipe-graph manifest — mirrors legendary-recipes (type: module, exports: { ".": "./src/index.ts" }, zod dep). |
packages/recipe-graph/src/schema.ts | new | Zod schemas + inferred types: RecipeEdge, RecipeOption, GraphNode, ResolvedGraph, TreeNode. The one source of truth for the shape. |
packages/recipe-graph/src/project-tree.ts | new | projectTree(graph, chooseRecipe?) — pure DAG→nested-tree projection. |
packages/recipe-graph/src/index.ts | new | Re-exports schema + project-tree. |
packages/recipe-graph/src/project-tree.test.ts | new | P2 #1–3 / SC6 — projectTree on hand-built graphs. |
apps/api/src/recipe-graph/recipe-graph.errors.ts | new | CycleError extends Error (mirrors gw2.errors.ts). |
apps/api/src/recipe-graph/recipe-graph.service.ts | new | RecipeGraphService.resolve(itemId) — memoized DFS, union+filter, batched enrichment, cycle guard. |
apps/api/src/recipe-graph/recipe-graph.schema.ts | new | RecipeTreeDto = createZodDto(TreeNodeSchema) — the endpoint's response DTO, driving validation + OpenAPI (mirrors health.schema.ts). |
apps/api/src/recipe-graph/recipe-graph.controller.ts | new | GET /recipe-graph/:itemId → projectTree(resolve(id)); 400 on bad param, 404 on unknown item. |
apps/api/src/recipe-graph/recipe-graph.module.ts | new | RecipeGraphModule — imports StaticDataModule; declares the controller; provides+exports RecipeGraphService. |
apps/api/src/recipe-graph/recipe-graph.service.test.ts | new | P1 #1–7, SC1–SC5, SC8, SC9 — resolver against mocked 006 services. |
apps/api/src/recipe-graph/recipe-graph.controller.test.ts | new | P3, SC11 — endpoint returns the projected tree; 400/404 paths (mocked service). |
apps/api/src/recipe-graph/recipe-graph.service.warm-cache.test.ts | new | SC7 — real Gw2Client + fake fetch; resolve twice, assert 0 fetches on the 2nd. |
apps/api/src/recipe-graph/recipe-graph.module.test.ts | new | R1 — RecipeGraphService + controller resolve via DI from StaticDataModule. |
apps/api/src/static-data/station-data.service.ts | modify | Add type: string to StationRecipe and map r.type (surfacing the already-fetched field for R12). |
apps/api/src/static-data/station-data.service.test.ts | modify | Assert StationRecipe.type is surfaced. |
apps/api/src/app.module.ts | modify | Register RecipeGraphModule in the composed app. |
apps/api/src/generate-openapi.test.ts | modify | SC12 — assert the OpenAPI doc documents GET /recipe-graph/{itemId} with the tree response schema. |
apps/api/openapi.json (path per generate-openapi.cli.ts) | modify (regenerate) | The committed OpenAPI artifact, regenerated to include the new path. Its deterministic-emit guard test still passes. |
apps/api/package.json | modify | Add @gw2priory/recipe-graph: workspace:* (mirrors the legendary-recipes dep). |
root tsconfig.* / pnpm-workspace.yaml | modify (verify) | Ensure @gw2priory/recipe-graph resolves (path alias + packages/* glob), mirroring legendary-recipes. No change if the glob/alias already covers it. |
Data & contracts
New, in @gw2priory/recipe-graph — defined as Zod schemas, types inferred (z.infer). All plain-JSON-serializable (no Map, no class instances):
interface ResolvedGraph { // resolve() output — the in-process DAG (008)
rootId: number;
nodes: Record<number, GraphNode>; // every DISTINCT item id, resolved once
}
interface GraphNode {
id: number;
name: string | null; // null iff GW2 API omitted the id (206/404)
rarity: string | null;
vendorValue: number | null;
classification: 'buyable' | 'gated' | null; // null iff metadata missing; else 006's classify()
leaf: boolean; // === recipes.length === 0
recipes: RecipeOption[]; // curated ∪ station, minus LegendaryComponent (R12)
}
interface RecipeOption {
source: 'mystic-forge' | 'station';
outputCount: number; // how many of `id` one craft yields
ingredients: RecipeEdge[];
provenance: string; // curated source URL, or station disciplines+minRating
}
type RecipeEdge =
| { kind: 'item'; itemId: number; count: number } // resolvable → nodes[itemId]
| { kind: 'currency'; currencyId: number; count: number }; // terminal, not expanded (armor seam)
interface TreeNode { // projectTree() output — the display tree (FE / endpoint)
node: GraphNode; // full node inlined at each usage — no FE lookups
count: number; // qty the parent recipe needs (root = 1)
recipe: RecipeOption | null; // the chosen recipe whose ingredients are `children`
children: TreeNode[];
}
function projectTree(
graph: ResolvedGraph,
chooseRecipe?: (node: GraphNode) => RecipeOption | null, // default: first option
): TreeNode;RecipeGraphService.resolve(itemId: number): Promise<ResolvedGraph> — internally a Map<number, GraphNode>
- an ancestor-path
Set<number>; returns theMapas aRecord. Modified 006 contract:StationRecipegainstype: string.
Endpoint contract — GET /recipe-graph/:itemId:
200→TreeNode(the projected display tree; default chooser — structural, pre-pricing).400→ param not a positive integer (ZodValidationPipe).404→ item does not exist (root node metadatanull).
Worked example — resolve(30698) (The Bifrost), abbreviated. 13 distinct nodes, depth 3 (shallow today — F6): the DAG the service returns, then the tree the endpoint returns.
// resolve(30698) → ResolvedGraph (DAG, in-process for 008)
{ "rootId": 30698, "nodes": {
"30698": { "id":30698, "name":"The Bifrost", "rarity":"Legendary", "classification":"buyable", "leaf":false,
"recipes":[{ "source":"mystic-forge", "outputCount":1,
"provenance":"https://wiki.guildwars2.com/wiki/The_Bifrost",
"ingredients":[ {"kind":"item","itemId":29180,"count":1}, // The Legend (precursor)
{"kind":"item","itemId":19654,"count":1}, // Gift of The Bifrost
{"kind":"item","itemId":19626,"count":1}, // Gift of Fortune
{"kind":"item","itemId":19674,"count":1} ]}]},// Gift of Mastery
"29180": { "id":29180, "name":"The Legend", "classification":"buyable", "leaf":true, "recipes":[] },
// LegendaryComponent recipe 11134 filtered by R12
"19626": { "id":19626, "name":"Gift of Fortune", "classification":"gated", "leaf":false,
"recipes":[{ "source":"mystic-forge", "outputCount":1, "provenance":"…/Gift_of_Fortune",
"ingredients":[ {"kind":"item","itemId":19673,"count":1}, // Gift of Magic → gated leaf
{"kind":"item","itemId":19672,"count":1}, // Gift of Might → gated leaf
{"kind":"item","itemId":19675,"count":77}, // Mystic Clover → gated leaf
{"kind":"item","itemId":19721,"count":250} ]}]}, // Ectoplasm → buyable leaf
"19721": { "id":19721, "name":"Glob of Ectoplasm", "classification":"buyable", "leaf":true, "recipes":[] }
// … 19674 (Gift of Mastery, non-leaf → 4 gated leaves), 19654/19673/19672/19675 + 4 more gated leaves
}}
// GET /recipe-graph/30698 → TreeNode (display tree, FE renders directly, no lookups)
{ "node":{/* Bifrost */}, "count":1, "recipe":{/* forge */}, "children":[
{ "node":{/* The Legend */}, "count":1, "recipe":null, "children":[] },
{ "node":{/* Gift of Bifrost */}, "count":1, "recipe":null, "children":[] },
{ "node":{/* Gift of Fortune */}, "count":1, "recipe":{/*forge*/}, "children":[
{ "node":{/* Gift of Magic */}, "count":1, "recipe":null, "children":[] },
{ "node":{/* Gift of Might */}, "count":1, "recipe":null, "children":[] },
{ "node":{/* Mystic Clover */}, "count":77, "recipe":null, "children":[] },
{ "node":{/* Ectoplasm */}, "count":250, "recipe":null, "children":[] } ]},
{ "node":{/* Gift of Mastery */}, "count":1, "recipe":{/*forge*/}, "children":[ /* 4 gated leaves */ ] }
]}Test strategy
Unit-first, against mocked 006 services (the resolver never needs a network); projectTree and the controller (with a mocked service) are pure/fast. Fixtures mirror the real ids from research.md.
projectTree(pure, package) — P2 #1 (nested{node,count,recipe,children}fromrootId), P2 #2 (shared item rendered at each usage; terminates), P2 #3 / SC6 (default chooser picks one recipe; a passed chooser overrides). Hand-builtResolvedGraphliterals; no I/O.- Resolver, mocked 006 —
CuratedRecipeService/StationDataService/ItemDataServiceasvi.fn()s returning id-keyed fixtures:- P1 #1 / SC1 — Bifrost fixture → root
mystic-forgerecipe = precursor+Fortune+Mastery+weapon-gift; non-leaves have ≥1 recipe; leaves haverecipes: []+ a classification. - P1 #2 / SC2 — a shared child (Ecto) reachable twice → one node; assert each mock queried once per distinct id (call-count).
- P1 #3 / SC5 —
resolve(46741)(Bolt of Damask): station recipe present → non-leaf, ingredients expanded past the buyable item. - P1 #4 / SC4 — a raw leaf, a gated leaf (Gift of Exploration), and a precursor whose only recipe is a mocked
type:"LegendaryComponent"station recipe → filtered (R12) →leaf,classification:'buyable'. - P1 #5 / SC3 — Eternity fixture → Sunrise+Twilight sub-DAGs expanded, shared sub-nodes single, no
CycleError. - P1 #6 — every node enriched;
ItemDataService.metadatacalled once with all distinct ids; an omitted id →nullfields, no throw. - P1 #7 —
resolve(46742)(Lump of Mithrillium): station returns two recipes, itemAccountBound→ non-leaf, both recipes kept,classification:'gated'(leaf ⊥ classification — F8). - R4 defensive (V3) — a synthetic id returning both a curated and a station recipe →
recipescontains both sources (the union both-branch, unexercised by real data). - R6 order / R9 propagation — a recipe's
ingredientsorder matches the source (assert on a fixture with a known order); a mocked 006 service that rejects makesresolvereject too (no swallow, no retry). - SC8 — a cyclic mock (A→B→A) →
resolvethrowsCycleErrornaming the id. - SC9 —
JSON.parse(JSON.stringify(graph))deep-equalsgraph;nodesis a plain object.
- P1 #1 / SC1 — Bifrost fixture → root
- Controller, mocked service — P3 / SC11:
GET /recipe-graph/:itemIdreturnsprojectTreeof the service's graph (BE-side projection); a non-integer param →400; anull-root-metadata graph →404. - SC7 (integration) — real
StaticDataModule+Gw2Servicebuilt on a fakefetchcounting calls; resolve a small fixture tree twice → 0 fetches on the second resolve (the 005 no-TTL cache — V2). Any ms figure is observed here at step 5, not asserted. - SC12 (OpenAPI) — extend
generate-openapi.test.ts: the emitted doc has aGET /recipe-graph/{itemId}path whose200response references the tree schema; the committedopenapi.jsonis regenerated and its deterministic-emit guard still passes. - R1 (module) —
Test.createTestingModule({ imports: [RecipeGraphModule] })resolvesRecipeGraphServiceand the controller. - SC10 — the existing
docs/superpowers/zero-write repo invariant already covers this; no new test. - 006 change —
station-data.service.test.tsgains one assertion thatStationRecipe.typeis surfaced.
Traceability (spec table) is filled per-task in Step 3 as each named test lands.
Alternatives considered
- Endpoint returns the raw DAG (FE reconstructs the tree) — rejected: pushes useless graph-walking onto every FE consumer.
projectTreeruns BE-side so the wire payload is display-ready; the DAG stays the in-process shape for 008's memoized pricing. - Endpoint returns both DAG and tree — rejected: redundant payload; the FE needs the tree, 008 uses the in-process service. Add a raw-DAG route only if a real HTTP consumer appears.
- Types +
projectTreeinpackages/domain— rejected: the graph model is a distinct, growing concern (schemas + a non-trivial pure projection + tests, and 008 will add priced-tree types). A dedicated@gw2priory/recipe-graphmirrors the established per-concern package pattern (legendary-recipes). - Filter
LegendaryComponentinside 006'sStationDataService— rejected: the filter is a 007 policy (R12), not a general 006 fact. 006 stays policy-free and merely surfacestype; the resolver decides. - Aggregate-to-root quantities in the resolver — rejected: a shared DAG node has many parents; raw per-recipe counts +
outputCountare the correct substrate for 008's fold.
Risks
- Cycle guard vs DAG reuse — a naive
visitedset would flag legitimate shared-node reuse as a cycle. Mitigation: two sets —resolved(reuse OK, the whole point of the DAG) and an ancestor path set (only a repeat on the current path is a cycle). Explicit in the service design + the P1 #5 vs SC8 tests. - Recursive schema → OpenAPI —
TreeNodeis self-referential (children: TreeNode[]); the Zod schema needsz.lazyand a named component soopenapi.jsonemits a$refcycle rather than diverging. Mitigation: register the tree schema under a stable name; SC12's generator test catches a broken emit. - Currency/item id-space collision —
nodesis keyed by item id; a currency id could collide. Mitigation: currency ingredients live on the edge only (kind:'currency'), never added as nodes. No Gen 1 weapon reaches this path (research.md). StationRecipe.typechange ripples — modifying a 006 type could touch other consumers. Mitigation: additive field only; updatestation-data.service.test.ts; no existing consumer readsStationRecipeoutside 006's own tests.- New-package workspace wiring (pnpm glob + tsconfig path alias) — fiddly. Mitigation: copy the
legendary-recipeswiring verbatim; the module test fails loudly if the alias doesn't resolve.
Open questions
- Raw-DAG HTTP route — not exposed in 007 (008 consumes
resolve()in-process; the FE gets the tree). Trivial to add later if a real HTTP consumer needs the un-projected graph. Not a blocker. - SC7 wall-clock target — none set; measured at step 5 (research.md V2). Not a blocker.