Skip to content

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 pure projectTree. 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 (imports StaticDataModule) providing RecipeGraphService, the RecipeGraphController, the response DTO, and CycleError. Depends only on the 006 services and the package; never calls Gw2Service directly (R1).

Tech stack ​

  • TypeScript (nodenext), NestJS on the Fastify adapter — existing apps/api stack.
  • nestjs-zod + @nestjs/swagger — existing deps (used by health), reused for the endpoint's validation + OpenAPI. The response DTO is createZodDto(TreeNodeSchema); @ZodResponse documents it and the deterministic openapi.json emit 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 real Gw2Client with a fake fetch for SC7.
  • No new external dependency — only existing repo deps (zod, nestjs-zod, @nestjs/swagger) and one new internal workspace:* 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. 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: 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 .ts workspace package (research.md F9). So @gw2priory/recipe-graph is types-only (src/types.ts, no Zod, no zod dep), and projectTree + the endpoint's Zod schema move into apps/api/src/recipe-graph/ (project-tree.ts + its test, and recipe-graph.schema.ts defines the Zod locally rather than importing it from the package). The rows below that place projectTree/Zod in the package are superseded by this note; everything else stands. (R11; spec updated.)

PathChangeResponsibility
packages/recipe-graph/package.jsonnew@gw2priory/recipe-graph manifest — mirrors legendary-recipes (type: module, exports: { ".": "./src/index.ts" }, zod dep).
packages/recipe-graph/src/schema.tsnewZod schemas + inferred types: RecipeEdge, RecipeOption, GraphNode, ResolvedGraph, TreeNode. The one source of truth for the shape.
packages/recipe-graph/src/project-tree.tsnewprojectTree(graph, chooseRecipe?) — pure DAG→nested-tree projection.
packages/recipe-graph/src/index.tsnewRe-exports schema + project-tree.
packages/recipe-graph/src/project-tree.test.tsnewP2 #1–3 / SC6 — projectTree on hand-built graphs.
apps/api/src/recipe-graph/recipe-graph.errors.tsnewCycleError extends Error (mirrors gw2.errors.ts).
apps/api/src/recipe-graph/recipe-graph.service.tsnewRecipeGraphService.resolve(itemId) — memoized DFS, union+filter, batched enrichment, cycle guard.
apps/api/src/recipe-graph/recipe-graph.schema.tsnewRecipeTreeDto = createZodDto(TreeNodeSchema) — the endpoint's response DTO, driving validation + OpenAPI (mirrors health.schema.ts).
apps/api/src/recipe-graph/recipe-graph.controller.tsnewGET /recipe-graph/:itemId → projectTree(resolve(id)); 400 on bad param, 404 on unknown item.
apps/api/src/recipe-graph/recipe-graph.module.tsnewRecipeGraphModule — imports StaticDataModule; declares the controller; provides+exports RecipeGraphService.
apps/api/src/recipe-graph/recipe-graph.service.test.tsnewP1 #1–7, SC1–SC5, SC8, SC9 — resolver against mocked 006 services.
apps/api/src/recipe-graph/recipe-graph.controller.test.tsnewP3, SC11 — endpoint returns the projected tree; 400/404 paths (mocked service).
apps/api/src/recipe-graph/recipe-graph.service.warm-cache.test.tsnewSC7 — real Gw2Client + fake fetch; resolve twice, assert 0 fetches on the 2nd.
apps/api/src/recipe-graph/recipe-graph.module.test.tsnewR1 — RecipeGraphService + controller resolve via DI from StaticDataModule.
apps/api/src/static-data/station-data.service.tsmodifyAdd type: string to StationRecipe and map r.type (surfacing the already-fetched field for R12).
apps/api/src/static-data/station-data.service.test.tsmodifyAssert StationRecipe.type is surfaced.
apps/api/src/app.module.tsmodifyRegister RecipeGraphModule in the composed app.
apps/api/src/generate-openapi.test.tsmodifySC12 — 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.jsonmodifyAdd @gw2priory/recipe-graph: workspace:* (mirrors the legendary-recipes dep).
root tsconfig.* / pnpm-workspace.yamlmodify (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):

ts
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 the Map as a Record. Modified 006 contract: StationRecipe gains type: 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 metadata null).

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.

jsonc
// 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} from rootId), P2 #2 (shared item rendered at each usage; terminates), P2 #3 / SC6 (default chooser picks one recipe; a passed chooser overrides). Hand-built ResolvedGraph literals; no I/O.
  • Resolver, mocked 006 — CuratedRecipeService/StationDataService/ItemDataService as vi.fn()s returning id-keyed fixtures:
    • P1 #1 / SC1 — Bifrost fixture → root mystic-forge recipe = precursor+Fortune+Mastery+weapon-gift; non-leaves have ≥1 recipe; leaves have recipes: [] + 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, noCycleError.
    • P1 #6 — every node enriched; ItemDataService.metadata called once with all distinct ids; an omitted id → null fields, no throw.
    • P1 #7 — resolve(46742) (Lump of Mithrillium): station returns two recipes, item AccountBound → non-leaf, both recipes kept, classification:'gated' (leaf ⊥ classification — F8).
    • R4 defensive (V3) — a synthetic id returning both a curated and a station recipe → recipes contains both sources (the union both-branch, unexercised by real data).
    • R6 order / R9 propagation — a recipe's ingredients order matches the source (assert on a fixture with a known order); a mocked 006 service that rejects makes resolve reject too (no swallow, no retry).
    • SC8 — a cyclic mock (A→B→A) → resolve throws CycleError naming the id.
    • SC9 — JSON.parse(JSON.stringify(graph)) deep-equals graph; nodes is a plain object.
  • Controller, mocked service — P3 / SC11: GET /recipe-graph/:itemId returns projectTree of the service's graph (BE-side projection); a non-integer param → 400; a null-root-metadata graph → 404.
  • SC7 (integration) — real StaticDataModule + Gw2Service built on a fake fetch counting 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 a GET /recipe-graph/{itemId} path whose 200 response references the tree schema; the committed openapi.json is regenerated and its deterministic-emit guard still passes.
  • R1 (module) — Test.createTestingModule({ imports: [RecipeGraphModule] }) resolves RecipeGraphService and the controller.
  • SC10 — the existing docs/superpowers/ zero-write repo invariant already covers this; no new test.
  • 006 change — station-data.service.test.ts gains one assertion that StationRecipe.type is 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. projectTree runs 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 + projectTree in packages/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-graph mirrors the established per-concern package pattern (legendary-recipes).
  • Filter LegendaryComponent inside 006's StationDataService — rejected: the filter is a 007 policy (R12), not a general 006 fact. 006 stays policy-free and merely surfaces type; the resolver decides.
  • Aggregate-to-root quantities in the resolver — rejected: a shared DAG node has many parents; raw per-recipe counts + outputCount are the correct substrate for 008's fold.

Risks ​

  • Cycle guard vs DAG reuse — a naive visited set 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 — TreeNode is self-referential (children: TreeNode[]); the Zod schema needs z.lazy and a named component so openapi.json emits a $ref cycle 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 — nodes is 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.type change ripples — modifying a 006 type could touch other consumers. Mitigation: additive field only; update station-data.service.test.ts; no existing consumer reads StationRecipe outside 006's own tests.
  • New-package workspace wiring (pnpm glob + tsconfig path alias) — fiddly. Mitigation: copy the legendary-recipes wiring 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.