Recipe index (forge + station, merged at load) — Tasks
Status: approved Branch: 027-recipe-index
Status is set by the human, never by the agent. proposed → approved (opens step 4 · Implement, entered through plan mode) → implemented.
The task half of plan.md. Every task is TDD: write the failing test, watch it fail for the right reason, write the minimal code, watch it pass, refactor green, commit. Commands run from the repo root. Run one test file with pnpm vitest run <path>; the whole suite with pnpm test; types with pnpm typecheck; lint with pnpm lint; the build gate with pnpm build; the VitePress spec gate with pnpm docs:build. Regenerate the committed index with pnpm --filter @gw2priory/api build && pnpm --filter @gw2priory/api generate:recipe-index. Every commit message is imperative, scoped (api: / specs:), and ends with the Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> trailer.
Global Constraints in plan.md apply to every task and are not repeated per task. Dependency order: schema + empty stub (T1) → index service (T2) → wire into resolver + migrate walk tests (T3) → pure generator (T4) → CLI + generate real data + guard (T5) → equivalence (T6) → real-data coverage (T7) → verify (T8). The empty stub in T1 keeps the resolver on its live path (identical to today) until T5 fills it, so T2–T4 land without changing observable behaviour.
T1 — RecipeIndexFile schema + committed empty stub
Satisfies: R1, R2 (shape), R3 (fields).
Files: Create apps/api/src/static-data/recipe-index.schema.ts, apps/api/src/static-data/data/station-recipes.ts; Test apps/api/src/static-data/recipe-index.schema.test.ts.
- [ ] Step 1 — RED. Assert the schema accepts a well-formed file and rejects a malformed one:
import { RecipeIndexFileSchema } from '../recipe-index.schema';
it('accepts a well-formed index and rejects a bad station entry', () => {
const ok = { meta: { gw2Build: 205780, rootIds: [30698], entryCount: 1 },
index: { '46741': [{ outputItemId: 46741, outputCount: 1, ingredients: [{ itemId: 19740, count: 20 }], disciplines: ['Tailor'], minRating: 450, type: 'Refinement' }] } };
expect(RecipeIndexFileSchema.parse(ok)).toEqual(ok);
expect(() => RecipeIndexFileSchema.parse({ ...ok, index: { '46741': [{ outputItemId: 46741 }] } })).toThrow();
});Run: pnpm vitest run apps/api/src/static-data/recipe-index.schema.test.ts → FAIL (module missing).
- [ ] Step 2 — GREEN. Create
recipe-index.schema.ts:
import { z } from 'zod';
export const StationRecipeSchema = z.object({
outputItemId: z.number().int().positive(), outputCount: z.number().int().positive(),
ingredients: z.array(z.object({ itemId: z.number().int().positive(), count: z.number().int().positive() })),
disciplines: z.array(z.string()), minRating: z.number().int().nonnegative(), type: z.string(),
});
export const RecipeIndexFileSchema = z.object({
meta: z.object({ gw2Build: z.number().int().nonnegative(), rootIds: z.array(z.number().int().positive()), entryCount: z.number().int().nonnegative() }),
index: z.record(z.string(), z.array(StationRecipeSchema)),
});
export type StationRecipe = z.infer<typeof StationRecipeSchema>;
export type RecipeIndexFile = z.infer<typeof RecipeIndexFileSchema>;- [ ] Step 3 — Empty stub. Create
data/station-recipes.tsso downstream code compiles; an emptyindexmeans everyrecipesFormisses → the resolver stays on its live path until T5:
// GENERATED by `pnpm --filter @gw2priory/api generate:recipe-index`. Do not edit by hand.
import type { RecipeIndexFile } from '../recipe-index.schema';
export const stationRecipeIndex = {
meta: { gw2Build: 0, rootIds: [], entryCount: 0 },
index: {},
} satisfies RecipeIndexFile;[ ] Step 4 — GREEN + types. Run:
pnpm vitest run apps/api/src/static-data/recipe-index.schema.test.ts && pnpm --filter @gw2priory/api typecheck→ PASS.[ ] Step 5 — Teeth. Break one schema field (e.g. make
outputItemIdaz.string()), watch the reject case fail, restore.[ ] Step 6 — Commit.
git add apps/api/src/static-data/recipe-index.schema.ts apps/api/src/static-data/recipe-index.schema.test.ts apps/api/src/static-data/data/station-recipes.ts
git commit -m "api: add RecipeIndexFile schema + empty station-recipes stub
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"T2 — RecipeIndexService.recipesFor (forge-first merge, live fallback)
Satisfies: R3, R4, SC4.
Files: Create apps/api/src/static-data/recipe-index.service.ts; Test apps/api/src/static-data/recipe-index.service.test.ts.
- [ ] Step 1 — RED. Construct the service directly with a fake index + stubbed curated/station; assert the four cases and the
LegendaryComponentfilter, with a station-call spy proving hits do zero live reads:
import { describe, expect, it, vi } from 'vitest';
import { RecipeIndexService } from '../recipe-index.service';
const damask = { outputItemId: 46741, outputCount: 1, ingredients: [{ itemId: 19740, count: 20 }], disciplines: ['Tailor'], minRating: 450, type: 'Refinement' };
function make(indexEntries: Record<string, unknown[]>) {
const station = { getRecipes: vi.fn().mockResolvedValue([]) };
const curated = { getRecipe: vi.fn().mockReturnValue(null) };
const data = { meta: { gw2Build: 1, rootIds: [30703], entryCount: 0 }, index: indexEntries };
return { svc: new RecipeIndexService(curated as never, station as never, data as never), station, curated };
}
describe('recipesFor', () => {
it('station hit → station option, no live read', async () => {
const { svc, station } = make({ '46741': [damask] });
const opts = await svc.recipesFor(46741);
expect(opts).toHaveLength(1); expect(opts[0].source).toBe('station');
expect(station.getRecipes).not.toHaveBeenCalled();
});
it('forge hit → mystic-forge option, station not consulted', async () => {
const { svc, station } = make({});
svc /* curated */; // curated.getRecipe stubbed to return a forge recipe for 30703:
// (set curated.getRecipe mock in `make` per-id; asserts opts[0].source === 'mystic-forge')
// ...
expect(station.getRecipes).not.toHaveBeenCalled();
});
it('leaf present as [] → returns [], no live read', async () => {
const { svc, station } = make({ '19721': [] });
expect(await svc.recipesFor(19721)).toEqual([]);
expect(station.getRecipes).not.toHaveBeenCalled();
});
it('absent id → one live getRecipes', async () => {
const { svc, station } = make({});
station.getRecipes.mockResolvedValue([damask]);
const opts = await svc.recipesFor(99999);
expect(station.getRecipes).toHaveBeenCalledTimes(1); expect(opts).toHaveLength(1);
});
it('LegendaryComponent recipe is filtered out', async () => {
const { svc } = make({ '29180': [{ ...damask, outputItemId: 29180, type: 'LegendaryComponent' }] });
expect(await svc.recipesFor(29180)).toEqual([]);
});
});Run: pnpm vitest run apps/api/src/static-data/recipe-index.service.test.ts → FAIL (module missing).
- [ ] Step 2 — GREEN. Create
recipe-index.service.ts. It takes the index data through an injection token (so the CLI can override it in T5) and ownstoOptions— the single normalise + filter home (lifted from the resolver'sbuildRecipes):
import { Inject, Injectable } from '@nestjs/common';
import type { RecipeOption } from '@gw2priory/recipe-graph';
// biome-ignore lint/style/useImportType: value import — Nest DI needs the runtime reference.
import { CuratedRecipeService } from './curated-recipe.service';
// biome-ignore lint/style/useImportType: value import — Nest DI needs the runtime reference.
import { StationDataService } from './station-data.service';
import type { RecipeIndexFile, StationRecipe } from './recipe-index.schema';
export const RECIPE_INDEX_DATA = 'RECIPE_INDEX_DATA';
const LEGENDARY_COMPONENT = 'LegendaryComponent'; // 007 T7/R12 — precursor-crafting path stays a leaf
@Injectable()
export class RecipeIndexService {
private readonly index: Map<number, StationRecipe[]>;
constructor(
private readonly curated: CuratedRecipeService,
private readonly station: StationDataService,
@Inject(RECIPE_INDEX_DATA) data: RecipeIndexFile,
) {
this.index = new Map(Object.entries(data.index).map(([k, v]) => [Number(k), v]));
}
async recipesFor(id: number): Promise<RecipeOption[]> {
const forge = this.curated.getRecipe(id); // local
const cached = this.index.get(id); // committed file
if (forge === null && cached === undefined) { // MISS → live (forge is null off-closure)
return this.toOptions(null, await this.station.getRecipes(id));
}
return this.toOptions(forge, cached ?? []); // HIT (forge and/or station); [] → leaf
}
private toOptions(forge: ReturnType<CuratedRecipeService['getRecipe']>, station: StationRecipe[]): RecipeOption[] {
const options: RecipeOption[] = [];
if (forge !== null) options.push({ source: 'mystic-forge', outputCount: forge.outputCount, ingredients: forge.ingredients, provenance: forge.source });
for (const sr of station) {
if (sr.type === LEGENDARY_COMPONENT) continue;
options.push({ source: 'station', outputCount: sr.outputCount, ingredients: sr.ingredients.map((i) => ({ kind: 'item' as const, itemId: i.itemId, count: i.count })), provenance: `${sr.disciplines.join('/')} @${sr.minRating}` });
}
return options;
}
}(Match the exact RecipeOption shape used in recipe-graph.service.ts's current buildRecipes — copy its mystic-forge/station mappings verbatim so equivalence holds in T6.)
[ ] Step 3 — GREEN. Run the test file → PASS. Fill in the forge-hit case's curated stub.
[ ] Step 4 — Teeth. Remove the
LegendaryComponentcontinue, watch that case fail, restore.[ ] Step 5 — Commit.
api: add RecipeIndexService.recipesFor (forge-first merge, live fallback).
T3 — Wire the index into the resolver; migrate the walk tests
Satisfies: R4, R5 (structure).
Files: Modify apps/api/src/static-data/static-data.module.ts, apps/api/src/recipe-graph/recipe-graph.service.ts, apps/api/src/recipe-graph/recipe-graph.service.test.ts.
- [ ] Step 1 — Provider. In
static-data.module.tsaddRecipeIndexServiceand the data provider, and export it:
import { RECIPE_INDEX_DATA, RecipeIndexService } from './recipe-index.service';
import { stationRecipeIndex } from './data/station-recipes';
// providers: [...existing, { provide: RECIPE_INDEX_DATA, useValue: stationRecipeIndex }, RecipeIndexService]
// exports: [...existing, RecipeIndexService][ ] Step 2 — Swap the resolver. In
recipe-graph.service.ts: replace thecurated+stationconstructor params withrecipeIndex: RecipeIndexService; inexpand, replaceconst recipes = await this.buildRecipes(id)withconst recipes = await this.recipeIndex.recipesFor(id); deletebuildRecipesand the now-unusedcurated/station/LEGENDARY_COMPONENT_TYPEsymbols. Keepenrich(itemData) andresolvePriced(gw2) unchanged.[ ] Step 3 — Migrate the walk tests (RED then GREEN). In
recipe-graph.service.test.ts, changebuildServiceto provide a stubbedRecipeIndexService(itsrecipesFor(id)returns theRecipeOption[]the old curated/station stubs implied) instead ofCuratedRecipeService/StationDataService. The union/order/LegendaryComponentassertions are now covered byrecipe-index.service.test.ts(T2) — remove them here; keep the walk tests (T4 base cases, T5 dedup/diamond, T6 cycle, T8 enrichment) rewired torecipesFor. Run the file red first (old provider missing), then green.[ ] Step 4 — Integration tests still green.
recipe-graph.service.warm-cache.test.tsand...bifrost.test.tsuse the real module + a fakefetch; with the empty stub index everyrecipesForstill goes live, so their fetch-count assertions hold unchanged. Confirm:
Run: pnpm vitest run apps/api/src/recipe-graph && pnpm --filter @gw2priory/api typecheck Expected: PASS.
- [ ] Step 5 — Commit.
api: resolve recipes through RecipeIndexService (empty index → live).
T4 — Pure buildRecipeIndex
Satisfies: R6 (logic), SC5.
Files: Create apps/api/src/static-data/generate-recipe-index.ts; Test apps/api/src/static-data/generate-recipe-index.test.ts.
- [ ] Step 1 — RED. With fakes: records every reached non-forge id, leaves as
[], skips forge-covered ids, sorted keys, deterministic across two runs:
import { buildRecipeIndex } from '../generate-recipe-index';
const closure = { 30703: [46741, 19721], 46741: [19740], 19721: [], 19740: [] }; // adjacency for the fake resolve
const deps = {
rootIds: [30703], gw2Build: 205780,
resolve: async (id: number) => Object.keys(collectReachable(id, closure)).map(Number), // returns reached ids
isForgeCovered: (id: number) => id === 30703, // Sunrise is forge
getStationRecipes: async (id: number) => (id === 46741 ? [DAMASK] : []),
};
it('skips forge-covered ids, records leaves as [], sorts keys, is deterministic', async () => {
const a = await buildRecipeIndex(deps);
expect(Object.keys(a.index)).toEqual(['19721', '19740', '46741']); // sorted, no 30703 (forge)
expect(a.index['19721']).toEqual([]);
expect(a.index['46741']).toEqual([DAMASK]);
expect(a.meta.gw2Build).toBe(205780);
const b = await buildRecipeIndex(deps);
expect(JSON.stringify(a.index)).toBe(JSON.stringify(b.index)); // byte-stable payload
});Run: pnpm vitest run apps/api/src/static-data/generate-recipe-index.test.ts → FAIL (module missing).
- [ ] Step 2 — GREEN. Create
generate-recipe-index.ts— a pure function; the shape ofresolvematches whatever the CLI passes (a reached-id collector over the real resolver):
import type { RecipeIndexFile } from './recipe-index.schema';
export interface BuildDeps {
rootIds: readonly number[]; gw2Build: number;
resolve: (id: number) => Promise<number[]>; // reached ids for a root (via the live resolver)
isForgeCovered: (id: number) => boolean; // curated.getRecipe(id) !== null
getStationRecipes: (id: number) => Promise<RecipeIndexFile['index'][string]>;
}
export async function buildRecipeIndex(deps: BuildDeps): Promise<RecipeIndexFile> {
const reached = new Set<number>();
for (const root of deps.rootIds) for (const id of await deps.resolve(root)) reached.add(id);
const index: RecipeIndexFile['index'] = {};
for (const id of [...reached].sort((a, b) => a - b)) {
if (deps.isForgeCovered(id)) continue; // forge-covered → lives in the package, skip
index[String(id)] = await deps.getStationRecipes(id); // real recipes or [] (leaf)
}
return { meta: { gw2Build: deps.gw2Build, rootIds: [...deps.rootIds], entryCount: Object.keys(index).length }, index };
}[ ] Step 3 — GREEN. Run the file → PASS.
[ ] Step 4 — Teeth. Drop the
isForgeCoveredskip, watch the "no 30703" assertion fail, restore.[ ] Step 5 — Commit.
api: add pure buildRecipeIndex (skip forge, leaves as [], deterministic).
T5 — Generator CLI + generate the real committed index + guard test
Satisfies: R6, SC5.
Files: Create apps/api/src/static-data/generate-recipe-index.cli.ts, apps/api/src/static-data/recipe-index.data.test.ts; Modify apps/api/package.json, apps/api/src/static-data/data/station-recipes.ts (regenerated).
- [ ] Step 1 — CLI. Create
generate-recipe-index.cli.ts, mirroringgenerate-openapi.cli.ts. Boot an application context that overridesRECIPE_INDEX_DATAwith an empty index (sorecipesForresolves fully live, even on a re-run against stale committed data), pullRecipeGraphService+StationDataService+CuratedRecipeService+Gw2Service, and write the module:
const app = await NestFactory.createApplicationContext(/* a module that provides RECIPE_INDEX_DATA = EMPTY */);
const graph = app.get(RecipeGraphService), station = app.get(StationDataService), curated = app.get(CuratedRecipeService), gw2 = app.get(Gw2Service);
const file = await buildRecipeIndex({
rootIds: GEN1_IDS, gw2Build: (await gw2.build()).id, // GET /v2/build — add a thin gw2.build() read if absent
resolve: async (id) => Object.keys((await graph.resolve(id)).nodes).map(Number),
isForgeCovered: (id) => curated.getRecipe(id) !== null,
getStationRecipes: async (id) => (await station.getRecipes(id)).map(toStationRecipeShape),
});
writeFileSync('apps/api/src/static-data/data/station-recipes.ts', renderModule(file)); // sorted keys, satisfies RecipeIndexFile, 2-space, trailing newline(renderModule emits the // GENERATED … header + export const stationRecipeIndex = {…} satisfies RecipeIndexFile. Add gw2.build() → GET /v2/build if the client lacks it — a one-line uncached read.)
[ ] Step 2 — Script. Add to
apps/api/package.json:"generate:recipe-index": "node dist/static-data/generate-recipe-index.cli.js".[ ] Step 3 — Generate. Run it against the live API and commit the output:
Run: pnpm --filter @gw2priory/api build && pnpm --filter @gw2priory/api generate:recipe-index Expected: data/station-recipes.ts now holds ~103−forge entries (~9 real + leaves as []), meta.gw2Build set, keys sorted, no forge-covered id present.
- [ ] Step 4 — Guard test (RED then GREEN). Create
recipe-index.data.test.ts— validates the committed module (mirrors the curated package's guard test):
import { stationRecipeIndex } from '../data/station-recipes';
import { RecipeIndexFileSchema } from '../recipe-index.schema';
import { GEN1_IDS } from '../../legendaries/legendaries.data';
it('committed index validates, keys sorted, covers the Gen-1 roots, no forge id', () => {
expect(() => RecipeIndexFileSchema.parse(stationRecipeIndex)).not.toThrow();
const keys = Object.keys(stationRecipeIndex.index).map(Number);
expect(keys).toEqual([...keys].sort((a, b) => a - b));
expect(stationRecipeIndex.meta.rootIds).toEqual([...GEN1_IDS]);
for (const id of GEN1_IDS) expect(stationRecipeIndex.index[String(id)]).toBeUndefined(); // legendaries are forge, not here
});Run: pnpm vitest run apps/api/src/static-data/recipe-index.data.test.ts && pnpm --filter @gw2priory/api typecheck → PASS.
- [ ] Step 5 — Commit.
git add apps/api/src/static-data/generate-recipe-index.cli.ts apps/api/src/static-data/recipe-index.data.test.ts apps/api/package.json apps/api/src/static-data/data/station-recipes.ts
git commit -m "api: generate committed station-recipes index + guard test
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"T6 — Equivalence: index path deep-equals live path
Satisfies: R5, SC3.
Files: Create apps/api/src/recipe-graph/recipe-graph.service.index-equivalence.test.ts.
- [ ] Step 1 — RED. With twin fakes for one root: build the resolver once with an index that mirrors a fake live API, and once with an empty index + the same fake
fetchserving station/recipes; assert the tworesolve(root)graphs deep-equal.
it('R5/SC3: resolving from the index deep-equals resolving live', async () => {
const live = await resolveWithEmptyIndex(ROOT); // fetch serves /recipes/search + /recipes + /items
const viaIndex = await resolveWithCommittedLike(ROOT); // fetch THROWS on /recipes/search + /recipes
expect(viaIndex).toEqual(live);
});Run the file → FAIL, then build the two harness helpers to make it pass. No live network.
[ ] Step 2 — GREEN. Provide the twin fixtures (the index built from the same station rows the fake
fetchserves). PASS.[ ] Step 3 — Teeth. Perturb one ingredient count in the index fixture, watch the deep-equal fail, restore.
[ ] Step 4 — Commit.
api: assert index-resolve equals live-resolve (R5).
T7 — Real-data coverage: zero station reads for Gen-1
Satisfies: SC1, SC2.
Files: Create apps/api/src/static-data/recipe-index.coverage.test.ts.
- [ ] Step 1 — RED (against real committed data). Build the real module; stub
fetchto throw on/v2/recipes/searchand/v2/recipes, and serve stub/v2/items?ids=metadata. Resolve each of the 21 Gen-1 roots; assert every resolve succeeds → zero station reads. Add oneresolvePriced(the MCP/ranking path) for a Gen-1 legendary, serving/v2/commerce/prices, asserting still zero/recipes*reads (SC2).
it('SC1: all 21 Gen-1 roots resolve with zero /recipes reads', async () => {
const fetchFn = spyThatThrowsOnRecipes(); // throws on /recipes/search and /recipes; serves /items
const graph = moduleWith(fetchFn).get(RecipeGraphService);
for (const id of GEN1_IDS) await expect(graph.resolve(id)).resolves.toBeDefined();
expect(recipeCalls(fetchFn)).toBe(0);
});
it('SC2: resolvePriced(Sunrise) makes zero /recipes reads', async () => { /* serve /commerce/prices; assert 0 recipe reads */ });Run the file → this is the moment a stale/incomplete committed index would fail (a missing closure id would hit the forbidden fetch).
[ ] Step 2 — GREEN. With the T5-generated data, both pass. If a root throws, the index is incomplete → regenerate (T5) rather than editing the file by hand.
[ ] Step 3 — Commit.
api: real-data coverage — Gen-1 resolves with zero station reads (SC1/SC2).
T8 — Verify, traceability, contract-unchanged, status
Satisfies: R7, R8, R9, SC6; closes the spec.
Files: Modify specs/027-recipe-index/spec.md (traceability + status).
- [ ] Step 1 — Full gates.
Run: pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm docs:build Expected: all green. Any failure → superpowers:systematic-debugging before proceeding.
- [ ] Step 2 — Contract unchanged (R8). Confirm
apps/api/openapi.jsonis untouched by this branch:
Run: git diff --exit-code -- apps/api/openapi.json Expected: no diff (exit 0). (Run the repo's verify:contract if present.)
[ ] Step 3 — Traceability. Fill the table in
spec.md: map each P1/P2 scenario and SC1–SC6 to the concrete test id created above (e.g.SC1 → recipe-index.coverage.test.ts "all 21 Gen-1 roots resolve with zero /recipes reads";SC4 → recipe-index.service.test.ts "leaf present as [] …"). Every row filled; no blanks.[ ] Step 4 — Provenance note. Record the actual closure size and
meta.gw2Buildof the generated index (a dated line in the PR description or aresearch.mdF-note) — the numbers the coverage test now guards.[ ] Step 5 — Status → implemented. On the human's instruction, transcribe
Status: implementedintospec.md(inside this branch, part of the PR diff — Definition of done). Commit.
git add specs/027-recipe-index/spec.md
git commit -m "specs: 027 traceability complete; mark implemented
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"- [ ] Step 6 — Review + PR.
superpowers:requesting-code-review, then open the PR once green.
Notes
Staging area for decisions and surprises found during implementation — including anything that turned out differently from plan.md. Move each into spec.md, research.md, or docs/ before closing the feature; this section is not a home.
- If moving
buildRecipesintoRecipeIndexService(T3) forces changes to the T4–T8 walk tests beyond a provider swap, that's a signal the walk and the recipe-source were more entangled than the plan assumed — record it here and reconcile the plan. - The
gw2.build()read added in T5 (if the client lacked it) is a new client method — note whether it warrants a line indocs/architecture/gw2-api.mdat capture.