Skip to content

Plan 006 — Static data module ​

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 "how is item X made?" and "what is item X — buyable or a gated grind?" answerable in the api for any Gen 1 legendary and its ingredients. Today the 005 Gw2Service exposes raw items/recipes/prices/ searchRecipes, but nothing supplies the Mystic Forge recipes the GW2 API structurally omits, nor classifies leaves. This plan stands up a curated, version-controlled Forge dataset and three read services so the 007 recipe-graph resolver has single-item facts to walk — and does so with a schema that later absorbs Gen 2/3 weapons, armor, trinkets, back items, and runes as data, not rework.

Approach ​

Two deliverables, wired together.

1 — A shared curated package, packages/legendary-recipes. It holds the Mystic Forge recipes as a JSON data file plus a source-.ts Zod schema and validated loader. The JSON/.ts split is forced by a verified runtime constraint (research.md F12): the SWC-built api runs as plain node dist/main.js, and Node 26 refuses to type-strip a packages/* .ts file once pnpm symlinks it under node_modules. So the api loads the data through a JSON subpath (require('@gw2priory/legendary-recipes/data'), measured working) and imports the CuratedRecipe type only (erased by SWC). The .ts schema + loader are consumed by Vitest (the R3 guard test, which resolves the symlink to the real path and transforms it) and any future source consumer — never by the running api. Runtime Zod validation is unnecessary in the api hot path because the guard test validates the JSON against the schema in CI; the api's typed view is one guard-test-justified assertion at the module boundary, not an unexplained any.

2 — StaticDataModule in apps/api/src/static-data, three single-purpose services composed explicitly by consumers (no hidden merge):

  • CuratedRecipeService — pure, synchronous; owns the JSON dataset; getRecipe / legendaries.
  • ItemDataService — over Gw2Service; metadata(ids) and classify (the AccountBound rule, R10).
  • StationDataService — over Gw2Service; getRecipes(outputItemId) returning all station recipes.

Decomposition (bite-sized TDD steps are tasks.md, Step 3 — this is the deliverable-level outline):

  1. Scaffold packages/legendary-recipes + schema + guard test — package.json, src/schema.ts, src/index.ts (validated loadDataset), a seed data/gen1-weapons.json (Bifrost + the shared gifts), and the R3 guard test (valid data parses; a malformed entry fails). Wire the package into apps/api deps. Deliverable: package resolves at typecheck + test; guard test green.
  2. Curate the full Gen 1 dataset — fill data/gen1-weapons.json with all 21 legendaries + every shared/weapon gift, exact wiki-cited quantities and a source per entry, and legendaryOutputIds. Extend the guard test to assert completeness (21 legendaries; Eternity = Sunrise + Twilight + 5 Pile of Crystalline Dust + 10 Philosopher's Stone; precursors absent). Deliverable: SC1 satisfied.
  3. CuratedRecipeService — load the JSON subpath at runtime; getRecipe, legendaries. Deliverable: curated lookups from a Nest module (P1 #1–#6, SC4-curated).
  4. ItemDataService — metadata + classify, Gw2Service mocked. Deliverable: P2 #1–#3, SC3.
  5. StationDataService — getRecipes via searchRecipes → recipes, all/empty. Deliverable: P3 #1–#3, SC4-station, SC5.
  6. StaticDataModule + app wiring — providers/exports; register in AppModule; DI singleton smoke test mirroring gw2.service.test.ts. Deliverable: module boots; SC6 holds by construction.

Architecture ​

apps/api/src/static-data/
  static-data.module.ts ── imports Gw2Module; provides+exports the three services
    ├─ CuratedRecipeService ──(runtime JSON)──▶ @gw2priory/legendary-recipes/data (gen1-weapons.json)
    │                          (type-only)   ──▶ @gw2priory/legendary-recipes  (CuratedRecipe, Dataset)
    ├─ ItemDataService     ──▶ Gw2Service.items          (005)
    └─ StationDataService  ──▶ Gw2Service.searchRecipes + Gw2Service.recipes (005)

packages/legendary-recipes/           (source .ts + JSON; consumed by Vitest/tsc/web)
  src/schema.ts   ── Zod: CuratedRecipeSchema, IngredientSchema (item|currency), DatasetSchema
  src/index.ts    ── re-exports schema/types + loadDataset() (validates the JSON)
  src/index.test.ts ── R3 guard test
  data/gen1-weapons.json ── { legendaryOutputIds, recipes }   (the api's runtime source)
  → depends on @gw2priory/domain (ItemId), zod

Dependency direction is one-way: apps/api → packages/legendary-recipes → packages/domain; and the three services → Gw2Service. No service reaches the GW2 API directly (stack.md).

Tech stack ​

  • NestJS 11 (@nestjs/common 11.1.28) — DI + module, matching gw2/health. @nestjs/testing for DI smoke + provider-override mocking.
  • Zod 4 (zod 4.4.3, already an api dep) — the curated-data schema and R3 guard. No new external dep.
  • Vitest 4 — colocated *.test.ts; the api project already emits decorator metadata via unplugin-swc.
  • TypeScript 7 / SWC — api typecheck via tsc --noEmit, runtime via the SWC build (stack.md).
  • New internal package @gw2priory/legendary-recipes (private, type: module, no build) — mirrors packages/domain; depends on zod and @gw2priory/domain. Not an external dependency.

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.

From spec.md 006 and research.md (feature-specific):

  • In-memory only (R8). No Postgres, no Redis; static data is served from the curated package and the 005 client's existing caches.
  • The CuratedRecipe schema (R2) carries outputItemId, outputCount, a method enum (every 006 entry = 'mystic-forge'; reserves 'discipline' | 'vendor' | 'collection'), an ingredients[] discriminated union { kind:'item'; itemId; count } (all 006 emits) with { kind:'currency'; currencyId; count } reserved, and a source URL. Seams only — no non-forge recipe curated, no currency logic built (Out of scope).
  • Classifier (R10): gated ⟺ flags[] includes 'AccountBound'; SoulBindOnUse/NoSell/ AccountBindOnUse alone do not imply gated. /v2/commerce/prices membership is the authoritative cross-check the pricing layer will read (kept out of 006).
  • StationDataService.getRecipes returns ALL recipes for an output (R6) — recipes/search yields number[] and outputs can have several (e.g. Bolt of Damask). Never drop one.
  • The api loads packages/legendary-recipes only as JSON at runtime (F12) — never the source .ts under node_modules. Type-only imports of the package are fine (SWC erases them).
  • All 21 Gen 1 legendaries, exact wiki-cited quantities, a source per curated recipe (R2, SC1).
  • Zero files under docs/superpowers/ (SC6). All artifacts live in specs/006-static-data/.

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
packages/legendary-recipes/package.jsonnewPackage manifest: private, type: module, exports { ".": "./src/index.ts", "./data": "./data/gen1-weapons.json" }; deps zod, @gw2priory/domain: workspace:*.
packages/legendary-recipes/src/schema.tsnewZod IngredientSchema (item|currency union), MethodSchema, CuratedRecipeSchema, DatasetSchema; inferred CuratedRecipe, Ingredient, Dataset types.
packages/legendary-recipes/src/index.tsnewRe-export schema/types; loadDataset() imports the JSON and validates it against DatasetSchema.
packages/legendary-recipes/src/index.test.tsnewR3 guard test (SC2): valid dataset parses; malformed entries fail; completeness — 21 legendaries, Eternity shape, precursors absent (SC1).
packages/legendary-recipes/data/gen1-weapons.jsonnewThe curated dataset { legendaryOutputIds: number[], recipes: CuratedRecipe[] } — the api's runtime source.
apps/api/package.jsonmodifiedAdd dep "@gw2priory/legendary-recipes": "workspace:*" (F4/monorepo.md: a consumer must declare it).
apps/api/src/static-data/curated-recipe.service.tsnewCuratedRecipeService: load JSON subpath, Map by outputItemId; getRecipe, legendaries.
apps/api/src/static-data/curated-recipe.service.test.tsnewP1 #1–#6, SC4-curated against the real package.
apps/api/src/static-data/item-data.service.tsnewItemDataService: metadata(ids) (map Gw2Item→ItemMeta), classify (R10).
apps/api/src/static-data/item-data.service.test.tsnewP2 #1–#3, SC3 with Gw2Service mocked.
apps/api/src/static-data/station-data.service.tsnewStationDataService: getRecipes via searchRecipes→recipes→StationRecipe[].
apps/api/src/static-data/station-data.service.test.tsnewP3 #1–#3, SC4-station, SC5 with Gw2Service mocked.
apps/api/src/static-data/static-data.module.tsnewStaticDataModule: imports Gw2Module; provides + exports the three services.
apps/api/src/static-data/static-data.module.test.tsnewDI singleton smoke (mirrors gw2.service.test.ts).
apps/api/src/app.module.tsmodifiedAdd StaticDataModule to imports.

Data & contracts ​

Package @gw2priory/legendary-recipes (src/schema.ts):

ts
const IngredientSchema = z.discriminatedUnion('kind', [
  z.object({ kind: z.literal('item'),     itemId:     z.number().int().positive(), count: z.number().int().positive() }),
  z.object({ kind: z.literal('currency'), currencyId: z.number().int().positive(), count: z.number().int().positive() }),
]);
const MethodSchema = z.enum(['mystic-forge', 'discipline', 'vendor', 'collection']);
const CuratedRecipeSchema = z.object({
  outputItemId: z.number().int().positive(),
  outputCount:  z.number().int().positive(),
  method:       MethodSchema,
  ingredients:  z.array(IngredientSchema).min(1),
  source:       z.string().url(),
});
const DatasetSchema = z.object({
  legendaryOutputIds: z.array(z.number().int().positive()),
  recipes:            z.array(CuratedRecipeSchema),
});
type CuratedRecipe = z.infer<typeof CuratedRecipeSchema>;
type Dataset       = z.infer<typeof DatasetSchema>;

outputItemId/itemId are branded ItemId (from @gw2priory/domain, F4) in the exported types via the loader's typed view; currencyId stays number until a currency-consuming spec adds a CurrencyId brand. Verified anchors (research V1/V5): Gift of Fortune 19626, Gift of Mastery 19674, Mystic Clover 19675, Obsidian Shard 19925, Bloodstone Shard 20797, Gift of Exploration 19677, Gift of Battle 19678, Ecto 19721, The Legend 29180, The Bifrost 30698.

Service contracts (apps/api/src/static-data):

ts
// CuratedRecipeService — no I/O
getRecipe(id: ItemId): CuratedRecipe | null
legendaries(): CuratedRecipe[]

// ItemDataService — via Gw2Service
interface ItemMeta { id: ItemId; name: string; type: string; rarity: string; flags: string[]; vendorValue: number }
metadata(ids: ItemId[]): Promise<ItemMeta[]>            // missing ids omitted (206/404 semantics)
classify(item: { flags: string[] }): 'buyable' | 'gated' // gated ⟺ flags.includes('AccountBound')

// StationDataService — via Gw2Service
interface StationRecipe { outputItemId: ItemId; outputCount: number; ingredients: { itemId: ItemId; count: number }[]; disciplines: string[]; minRating: number }
getRecipes(outputItemId: ItemId): Promise<StationRecipe[]>  // [] means leaf

The api's runtime typed view of the JSON — const data = dataset as Dataset — is the single guard-test-justified assertion (F12): the R3 guard proves the JSON matches DatasetSchema in CI, so no runtime Zod parse runs in the api. It carries a comment pointing at the guard test; it is not an any.

Test strategy ​

  • Guard test (R3/SC2/SC1), Vitest, package project. Imports loadDataset() from source; asserts the real JSON parses, that hand-built malformed entries (count: 0, missing source, unknown method, duplicate outputItemId) throw, and completeness — all 21 legendaryOutputIds resolve to a recipe, Eternity's ingredients are Sunrise + Twilight + 5 Pile of Crystalline Dust + 10 Philosopher's Stone, and no Gen 1 precursor id has a recipe.
  • CuratedRecipeService (P1, SC4-curated), api project. Instantiated against the real package (no mock — it's pure data). Bifrost expands to Precursor + 3 gifts; a shared gift returns exact counts; a precursor and an unknown id return null; legendaries() returns exactly the 21; Eternity via P1 #6.
  • ItemDataService (P2, SC3) / StationDataService (P3, SC4-station, SC5), api project. Gw2Service is replaced with .overrideProvider(Gw2Service).useValue({...}) returning the 005 __fixtures__ (items.json, recipes.json, recipe-search.json) plus a fixture for a multi-recipe output (Bolt of Damask 46742 → 2) for P3 #2. Classification uses the real gated-input flags[] captured in research V4.
  • Module DI smoke (R7), api project. Compiles StaticDataModule and asserts each service resolves as a singleton, mirroring gw2.service.test.ts.
  • SC6 is enforced by the existing tests/workflow/repo-invariants.test.ts; this plan writes nothing under docs/superpowers/, so it needs no new test — stated here so the criterion is not orphaned.
  • Deliberately not unit-tested: live GW2 API behaviour (owned by 005, mocked here); pricing/cost/days (out of scope); the CJS-requires-JSON mechanism (proven by research F12's spike, exercised end-to-end by the module smoke test rather than a bespoke unit test).

Alternatives considered ​

  • Curated data as source .ts imported by the api at runtime — rejected: Node 26 refuses to type-strip under node_modules (research F12, measured). Data is JSON; schema/loader stay .ts.
  • Give the package a build step (emit JS) — rejected: breaks monorepo.md's "packages are source, not builds", and adds CI/build wiring; the JSON subpath avoids it entirely.
  • Put the dataset inside apps/api/src — rejected: contradicts R1 and the future-proofing intent (the shared package is the one home shared gifts dedupe into, F8).
  • Unified getRecipe over both sources / a materialised graph — rejected during brainstorming: two explicit services (no hidden merge) chosen for ForgeDataService reusability; graph-building is 007.
  • StationDataService.getRecipe returning one recipe — rejected: drops craft paths the optimizer must min over (adequacy check, Bolt of Damask); returns all.

Risks ​

  • Curation accuracy (the maintenance point, domain.md). 21 legendaries × several gifts of hand-entered quantities drift with patches and typos. Mitigation: the R3 guard test enforces structure and completeness; every recipe carries a source URL; exact shared-gift/id anchors are pinned from research V1/V5. Numeric per-weapon-gift correctness beyond structure is a human-review item, not machine-provable.
  • CJS→JSON interop. The api is type: commonjs (SWC → require); the package is type: module. The JSON subpath was proven loadable via require (F12). Mitigation: the module smoke test exercises the real import path at test time; if it regresses, systematic-debugging per the constitution.
  • First workspace-package consumer. No app consumes a package yet; the dep declaration + resolution is new ground. Mitigation: mirror packages/domain exactly; typecheck + Vitest + the smoke test cover resolution at all three layers.

Open questions ​

  • Service name CuratedRecipeService (was ForgeDataService) — applied this session as a naming seam; the human may revert it. Not blocking; a rename touches only static-data/ files.
  • None that block implementation: every [NEEDS VERIFICATION] has a verdict in research.md, and no [NEEDS CLARIFICATION] remains open.