Skip to content

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:
ts
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:
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.ts so downstream code compiles; an empty index means every recipesFor misses → the resolver stays on its live path until T5:
ts
// 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 outputItemId a z.string()), watch the reject case fail, restore.

  • [ ] Step 6 — Commit.

bash
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 LegendaryComponent filter, with a station-call spy proving hits do zero live reads:
ts
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 owns toOptions — the single normalise + filter home (lifted from the resolver's buildRecipes):
ts
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 LegendaryComponent continue, 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.ts add RecipeIndexService and the data provider, and export it:
ts
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 the curated + station constructor params with recipeIndex: RecipeIndexService; in expand, replace const recipes = await this.buildRecipes(id) with const recipes = await this.recipeIndex.recipesFor(id); delete buildRecipes and the now-unused curated/station/LEGENDARY_COMPONENT_TYPE symbols. Keep enrich (itemData) and resolvePriced (gw2) unchanged.

  • [ ] Step 3 — Migrate the walk tests (RED then GREEN). In recipe-graph.service.test.ts, change buildService to provide a stubbed RecipeIndexService (its recipesFor(id) returns the RecipeOption[] the old curated/station stubs implied) instead of CuratedRecipeService/StationDataService. The union/order/LegendaryComponent assertions are now covered by recipe-index.service.test.ts (T2) — remove them here; keep the walk tests (T4 base cases, T5 dedup/diamond, T6 cycle, T8 enrichment) rewired to recipesFor. Run the file red first (old provider missing), then green.

  • [ ] Step 4 — Integration tests still green. recipe-graph.service.warm-cache.test.ts and ...bifrost.test.ts use the real module + a fake fetch; with the empty stub index every recipesFor still 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:
ts
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 of resolve matches whatever the CLI passes (a reached-id collector over the real resolver):
ts
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 isForgeCovered skip, 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, mirroring generate-openapi.cli.ts. Boot an application context that overrides RECIPE_INDEX_DATA with an empty index (so recipesFor resolves fully live, even on a re-run against stale committed data), pull RecipeGraphService + StationDataService + CuratedRecipeService + Gw2Service, and write the module:
ts
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):
ts
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.
bash
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 fetch serving station/recipes; assert the two resolve(root) graphs deep-equal.
ts
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 fetch serves). 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 fetch to throw on /v2/recipes/search and /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 one resolvePriced (the MCP/ranking path) for a Gen-1 legendary, serving /v2/commerce/prices, asserting still zero /recipes* reads (SC2).
ts
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.json is 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.gw2Build of the generated index (a dated line in the PR description or a research.md F-note) — the numbers the coverage test now guards.

  • [ ] Step 5 — Status → implemented. On the human's instruction, transcribe Status: implemented into spec.md (inside this branch, part of the PR diff — Definition of done). Commit.

bash
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 buildRecipes into RecipeIndexService (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 in docs/architecture/gw2-api.md at capture.