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— overGw2Service;metadata(ids)andclassify(theAccountBoundrule, R10).StationDataService— overGw2Service;getRecipes(outputItemId)returning all station recipes.
Decomposition (bite-sized TDD steps are tasks.md, Step 3 — this is the deliverable-level outline):
- Scaffold
packages/legendary-recipes+ schema + guard test —package.json,src/schema.ts,src/index.ts(validatedloadDataset), a seeddata/gen1-weapons.json(Bifrost + the shared gifts), and the R3 guard test (valid data parses; a malformed entry fails). Wire the package intoapps/apideps. Deliverable: package resolves at typecheck + test; guard test green. - Curate the full Gen 1 dataset — fill
data/gen1-weapons.jsonwith all 21 legendaries + every shared/weapon gift, exact wiki-cited quantities and asourceper entry, andlegendaryOutputIds. 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. CuratedRecipeService— load the JSON subpath at runtime;getRecipe,legendaries. Deliverable: curated lookups from a Nest module (P1 #1–#6, SC4-curated).ItemDataService—metadata+classify,Gw2Servicemocked. Deliverable: P2 #1–#3, SC3.StationDataService—getRecipesviasearchRecipes→recipes, all/empty. Deliverable: P3 #1–#3, SC4-station, SC5.StaticDataModule+ app wiring — providers/exports; register inAppModule; DI singleton smoke test mirroringgw2.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), zodDependency 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/common11.1.28) — DI + module, matchinggw2/health.@nestjs/testingfor DI smoke + provider-override mocking. - Zod 4 (
zod4.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 viaunplugin-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) — mirrorspackages/domain; depends onzodand@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. 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.
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
CuratedRecipeschema (R2) carriesoutputItemId,outputCount, amethodenum (every 006 entry ='mystic-forge'; reserves'discipline' | 'vendor' | 'collection'), aningredients[]discriminated union{ kind:'item'; itemId; count }(all 006 emits) with{ kind:'currency'; currencyId; count }reserved, and asourceURL. Seams only — no non-forge recipe curated, no currency logic built (Out of scope). - Classifier (R10):
gated ⟺ flags[] includes 'AccountBound';SoulBindOnUse/NoSell/AccountBindOnUsealone do not imply gated./v2/commerce/pricesmembership is the authoritative cross-check the pricing layer will read (kept out of 006). StationDataService.getRecipesreturns ALL recipes for an output (R6) —recipes/searchyieldsnumber[]and outputs can have several (e.g. Bolt of Damask). Never drop one.- The api loads
packages/legendary-recipesonly as JSON at runtime (F12) — never the source.tsundernode_modules. Type-only imports of the package are fine (SWC erases them). - All 21 Gen 1 legendaries, exact wiki-cited quantities, a
sourceper curated recipe (R2, SC1). - Zero files under
docs/superpowers/(SC6). All artifacts live inspecs/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.
| Path | Change | Responsibility |
|---|---|---|
packages/legendary-recipes/package.json | new | Package manifest: private, type: module, exports { ".": "./src/index.ts", "./data": "./data/gen1-weapons.json" }; deps zod, @gw2priory/domain: workspace:*. |
packages/legendary-recipes/src/schema.ts | new | Zod IngredientSchema (item|currency union), MethodSchema, CuratedRecipeSchema, DatasetSchema; inferred CuratedRecipe, Ingredient, Dataset types. |
packages/legendary-recipes/src/index.ts | new | Re-export schema/types; loadDataset() imports the JSON and validates it against DatasetSchema. |
packages/legendary-recipes/src/index.test.ts | new | R3 guard test (SC2): valid dataset parses; malformed entries fail; completeness — 21 legendaries, Eternity shape, precursors absent (SC1). |
packages/legendary-recipes/data/gen1-weapons.json | new | The curated dataset { legendaryOutputIds: number[], recipes: CuratedRecipe[] } — the api's runtime source. |
apps/api/package.json | modified | Add dep "@gw2priory/legendary-recipes": "workspace:*" (F4/monorepo.md: a consumer must declare it). |
apps/api/src/static-data/curated-recipe.service.ts | new | CuratedRecipeService: load JSON subpath, Map by outputItemId; getRecipe, legendaries. |
apps/api/src/static-data/curated-recipe.service.test.ts | new | P1 #1–#6, SC4-curated against the real package. |
apps/api/src/static-data/item-data.service.ts | new | ItemDataService: metadata(ids) (map Gw2Item→ItemMeta), classify (R10). |
apps/api/src/static-data/item-data.service.test.ts | new | P2 #1–#3, SC3 with Gw2Service mocked. |
apps/api/src/static-data/station-data.service.ts | new | StationDataService: getRecipes via searchRecipes→recipes→StationRecipe[]. |
apps/api/src/static-data/station-data.service.test.ts | new | P3 #1–#3, SC4-station, SC5 with Gw2Service mocked. |
apps/api/src/static-data/static-data.module.ts | new | StaticDataModule: imports Gw2Module; provides + exports the three services. |
apps/api/src/static-data/static-data.module.test.ts | new | DI singleton smoke (mirrors gw2.service.test.ts). |
apps/api/src/app.module.ts | modified | Add StaticDataModule to imports. |
Data & contracts
Package @gw2priory/legendary-recipes (src/schema.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):
// 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 leafThe 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, missingsource, unknownmethod, duplicateoutputItemId) throw, and completeness — all 21legendaryOutputIdsresolve 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 returnnull;legendaries()returns exactly the 21; Eternity via P1 #6.ItemDataService(P2, SC3) /StationDataService(P3, SC4-station, SC5), api project.Gw2Serviceis 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 Damask46742→ 2) for P3 #2. Classification uses the real gated-inputflags[]captured in research V4.- Module DI smoke (R7), api project. Compiles
StaticDataModuleand asserts each service resolves as a singleton, mirroringgw2.service.test.ts. - SC6 is enforced by the existing
tests/workflow/repo-invariants.test.ts; this plan writes nothing underdocs/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
.tsimported by the api at runtime — rejected: Node 26 refuses to type-strip undernode_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
getRecipeover both sources / a materialised graph — rejected during brainstorming: two explicit services (no hidden merge) chosen forForgeDataServicereusability; graph-building is 007. StationDataService.getRecipereturning one recipe — rejected: drops craft paths the optimizer mustminover (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
sourceURL; 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 istype: module. The JSON subpath was proven loadable viarequire(F12). Mitigation: the module smoke test exercises the real import path at test time; if it regresses,systematic-debuggingper the constitution. - First workspace-package consumer. No app consumes a package yet; the dep declaration + resolution is new ground. Mitigation: mirror
packages/domainexactly; typecheck + Vitest + the smoke test cover resolution at all three layers.
Open questions
- Service name
CuratedRecipeService(wasForgeDataService) — applied this session as a naming seam; the human may revert it. Not blocking; a rename touches onlystatic-data/files. - None that block implementation: every
[NEEDS VERIFICATION]has a verdict inresearch.md, and no[NEEDS CLARIFICATION]remains open.