Skip to content

Tasks 031 — Backend caching mechanism & browser caching ​

Execution skill: superpowers:subagent-driven-development — one implementer per task, then a two-stage review (spec compliance, then code quality). superpowers:test-driven-development applies inside every task: no production code before a failing test that demands it. Reach for superpowers:systematic-debugging on any surprise rather than guessing.

Derived from plan.md (approved). Each task is small, independently verifiable, and reviewed as its own diff. Split where a reviewer could reject one task while approving its neighbour — not where the work merely changes subject. A task is done only when it satisfies the definition of done in CLAUDE.md.

Global Constraints in plan.md apply to every task and are not repeated per task. Reminder for subagent implementers (ponytail is active but does not reach you): make the smallest change that satisfies the criterion — no speculative abstraction — and follow the nestjs.md conventions, notably the DI value-import rule (G2) and colocated tests (G7).


T1 — The caching mechanism: Surface A/B decorators + a global interceptor ​

Satisfies: R1, R2, R3, P2 #1, P2 #2, SC1.

Files

  • Create: apps/api/src/caching/cache-policy.decorator.ts
  • Create: apps/api/src/caching/cache-control.interceptor.ts
  • Create: apps/api/src/caching/caching.module.ts
  • Test: apps/api/src/caching/cache-control.interceptor.test.ts
  • Test: apps/api/src/caching/caching.module.test.ts
  • Modify: apps/api/src/app.module.ts (add CachingModule to imports)

Interfaces produced (later tasks rely on these exact names):

  • CACHE_POLICY_KEY: string, type CachePolicy = { kind: 'public'; maxAge: number } | { kind: 'private' }, PRIVATE_NO_STORE: string, SurfaceA(maxAge: number), SurfaceB() — from cache-policy.decorator.ts.

  • CacheControlInterceptor — from cache-control.interceptor.ts. CachingModule — from caching.module.ts.

  • [ ] RED — interceptor behaviour. Write cache-control.interceptor.test.ts with synthetic controllers and register the interceptor directly (no module), so this file tests the interceptor in isolation:

ts
import {
  Controller,
  Get,
  InternalServerErrorException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
  FastifyAdapter,
  type NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import { afterEach, describe, expect, it } from 'vitest';
import { CacheControlInterceptor } from './cache-control.interceptor';
import { SurfaceA, SurfaceB } from './cache-policy.decorator';

@Controller('a')
class SurfaceAController {
  @Get()
  @SurfaceA(600)
  ok() {
    return { ok: true };
  }
}

@Controller('b')
@SurfaceB()
class SurfaceBController {
  @Get()
  ok() {
    return { ok: true };
  }
}

@Controller('mixed')
@SurfaceB()
class MixedController {
  @Get()
  @SurfaceA(600)
  ok() {
    return { ok: true };
  }
}

@Controller('undeclared')
class UndeclaredController {
  @Get()
  ok() {
    return { ok: true };
  }
}

@Controller('boom')
class ThrowController {
  @Get()
  fail(): never {
    throw new InternalServerErrorException('boom');
  }
}

@Controller('a-boom')
class SurfaceAThrowController {
  @Get()
  @SurfaceA(600)
  fail(): never {
    throw new InternalServerErrorException('boom');
  }
}

async function buildApp(): Promise<NestFastifyApplication> {
  const ref = await Test.createTestingModule({
    controllers: [
      SurfaceAController,
      SurfaceBController,
      MixedController,
      UndeclaredController,
      ThrowController,
      SurfaceAThrowController,
    ],
  }).compile();
  const app = ref.createNestApplication<NestFastifyApplication>(
    new FastifyAdapter(),
  );
  app.useGlobalInterceptors(new CacheControlInterceptor(new Reflector()));
  await app.init();
  await app.getHttpAdapter().getInstance().ready();
  return app;
}

describe('031 T1 — CacheControlInterceptor', () => {
  let app: NestFastifyApplication | undefined;
  afterEach(async () => {
    if (app) {
      await app.close();
      app = undefined;
    }
  });

  const cc = async (url: string) => {
    app = await buildApp();
    const res = await app.inject({ method: 'GET', url });
    return { status: res.statusCode, header: res.headers['cache-control'] };
  };

  it('SC1: Surface A → public, max-age', async () => {
    expect(await cc('/a')).toEqual({ status: 200, header: 'public, max-age=600' });
  });

  it('SC1: method @SurfaceA overrides class @SurfaceB', async () => {
    expect(await cc('/mixed')).toEqual({
      status: 200,
      header: 'public, max-age=600',
    });
  });

  it('SC1/R2: Surface B → private, no-store', async () => {
    expect(await cc('/b')).toEqual({ status: 200, header: 'private, no-store' });
  });

  it('SC1/R2: undeclared → private, no-store (fail-safe)', async () => {
    expect(await cc('/undeclared')).toEqual({
      status: 200,
      header: 'private, no-store',
    });
  });

  it('SC1/R3: error → private, no-store (never public)', async () => {
    expect(await cc('/boom')).toEqual({
      status: 500,
      header: 'private, no-store',
    });
  });

  it('SC1/R3: Surface A that throws → private, no-store', async () => {
    expect(await cc('/a-boom')).toEqual({
      status: 500,
      header: 'private, no-store',
    });
  });
});
  • [ ] RED — run it, watch it fail for the right reason (the caching/* modules do not exist yet), not a harness typo.

Run: pnpm vitest run --project api cache-control.interceptor Expected: FAIL — cannot resolve ./cache-control.interceptor / ./cache-policy.decorator.

  • [ ] GREEN — cache-policy.decorator.ts. The two-surface vocabulary, nothing more:
ts
import { SetMetadata } from '@nestjs/common';

/** Metadata key the interceptor reads to learn a route's cache surface. */
export const CACHE_POLICY_KEY = 'cache-policy';

/** The two surfaces of the epic invariant — no third knob. */
export type CachePolicy =
  | { readonly kind: 'public'; readonly maxAge: number }
  | { readonly kind: 'private' };

/** Surface B header value: never stored by any cache. */
export const PRIVATE_NO_STORE = 'private, no-store';

/** Surface A — user-agnostic, cacheable for `maxAge` seconds (browser now, shared edge later). */
export const SurfaceA = (maxAge: number) => {
  const policy: CachePolicy = { kind: 'public', maxAge };
  return SetMetadata(CACHE_POLICY_KEY, policy);
};

/** Surface B — per-user or volatile; never cached. */
export const SurfaceB = () => {
  const policy: CachePolicy = { kind: 'private' };
  return SetMetadata(CACHE_POLICY_KEY, policy);
};
  • [ ] GREEN — cache-control.interceptor.ts. Fail-safe first, success-only override:
ts
import {
  type CallHandler,
  type ExecutionContext,
  Injectable,
  type NestInterceptor,
} from '@nestjs/common';
// biome-ignore lint/style/useImportType: value import — Nest DI needs the runtime reference.
import { Reflector } from '@nestjs/core';
import type { FastifyReply } from 'fastify';
import { type Observable, tap } from 'rxjs';
import {
  CACHE_POLICY_KEY,
  type CachePolicy,
  PRIVATE_NO_STORE,
} from './cache-policy.decorator';

/**
 * Stamps `Cache-Control` on every HTTP response from a per-route surface policy (spec 031).
 * Fail-safe: `private, no-store` unless a *successful* response declares Surface A — so errors,
 * undeclared routes, and `@Res()` handlers all stay uncacheable.
 */
@Injectable()
export class CacheControlInterceptor implements NestInterceptor {
  constructor(private readonly reflector: Reflector) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    if (context.getType() !== 'http') {
      return next.handle();
    }

    const reply = context.switchToHttp().getResponse<FastifyReply>();
    // Fail-safe first: an error path never runs the success `tap` below, so this value stands.
    if (!reply.sent) {
      reply.header('cache-control', PRIVATE_NO_STORE);
    }

    const policy = this.reflector.getAllAndOverride<CachePolicy | undefined>(
      CACHE_POLICY_KEY,
      [context.getHandler(), context.getClass()],
    );

    return next.handle().pipe(
      tap(() => {
        if (!reply.sent) {
          reply.header('cache-control', cacheControlFor(policy));
        }
      }),
    );
  }
}

function cacheControlFor(policy: CachePolicy | undefined): string {
  return policy?.kind === 'public'
    ? `public, max-age=${policy.maxAge}`
    : PRIVATE_NO_STORE;
}
  • [ ] GREEN — caching.module.ts. Registers the interceptor globally via DI:
ts
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { CacheControlInterceptor } from './cache-control.interceptor';

/** Registers the cache-control interceptor globally (spec 031). */
@Module({
  providers: [{ provide: APP_INTERCEPTOR, useClass: CacheControlInterceptor }],
})
export class CachingModule {}
  • [ ] GREEN — run the interceptor test, watch it pass.

Run: pnpm vitest run --project api cache-control.interceptor Expected: PASS (6 tests).

  • [ ] GREEN — wire it in app.module.ts (add to imports, keeping AppModule a manifest — G6):
ts
import { CachingModule } from './caching/caching.module';
// …
imports: [
  CachingModule,
  HealthModule,
  // …existing modules…
],
  • [ ] GREEN — caching.module.test.ts. Proves the module wires the interceptor globally (distinct from the isolated interceptor test above):
ts
import { Controller, Get } from '@nestjs/common';
import {
  FastifyAdapter,
  type NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import { afterEach, describe, expect, it } from 'vitest';
import { CachingModule } from './caching.module';

@Controller('wired')
class WiredController {
  @Get()
  ok() {
    return { ok: true };
  }
}

describe('031 T1 — CachingModule', () => {
  let app: NestFastifyApplication | undefined;
  afterEach(async () => {
    if (app) {
      await app.close();
      app = undefined;
    }
  });

  it('registers the interceptor globally (an undeclared route gets the fail-safe header)', async () => {
    const ref = await Test.createTestingModule({
      imports: [CachingModule],
      controllers: [WiredController],
    }).compile();
    app = ref.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
    await app.init();
    await app.getHttpAdapter().getInstance().ready();

    const res = await app.inject({ method: 'GET', url: '/wired' });
    expect(res.headers['cache-control']).toBe('private, no-store');
  });
});
  • [ ] Confirm the tests have teeth. Temporarily make cacheControlFor always return PRIVATE_NO_STORE; watch the "Surface A → public" and "method overrides class" tests fail; restore. Then run the whole api suite to confirm no existing test regressed.

Run: pnpm vitest run --project api Expected: PASS (all existing + the new caching tests).

  • [ ] Commit.
bash
git add apps/api/src/caching apps/api/src/app.module.ts
git commit -m "api: add cache-control interceptor + Surface A/B policy mechanism"

Verified by: cache-control.interceptor.test.ts (Surface A / method-over-class / Surface B / undeclared / error / Surface-A-that-throws) and caching.module.test.ts (global registration).


T2 — Declare every endpoint's surface ​

Satisfies: R4, R5, R6, P1 #2, P1 #4, P2 #3, SC3, SC5; and the whole-suite green of SC6.

Files

  • Test: apps/api/src/caching/cache-policy.declarations.test.ts
  • Modify: apps/api/src/legendaries/legendaries.controller.ts (class @SurfaceB(), method @SurfaceA(LEGENDARIES_MAX_AGE) on list, and the LEGENDARIES_MAX_AGE constant)
  • Modify: apps/api/src/account/account.controller.ts (class @SurfaceB())
  • Modify: apps/api/src/commerce/commerce.controller.ts (class @SurfaceB())
  • Modify: apps/api/src/recipe-graph/recipe-graph.controller.ts (class @SurfaceB())
  • Modify: apps/api/src/assistant/assistant.controller.ts (class @SurfaceB())
  • Modify: apps/api/src/health/health.controller.ts (class @SurfaceB())

Interfaces consumed: SurfaceA, SurfaceB, CACHE_POLICY_KEY, CachePolicy (from T1). Interfaces produced: LEGENDARIES_MAX_AGE: number (from legendaries.controller.ts).

  • [ ] RED — declarations test. Assert each endpoint's resolved policy (method-over-class):
ts
import type { Type } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { describe, expect, it } from 'vitest';
import { AccountController } from '../account/account.controller';
import { AssistantController } from '../assistant/assistant.controller';
import { CommerceController } from '../commerce/commerce.controller';
import { HealthController } from '../health/health.controller';
import {
  LEGENDARIES_MAX_AGE,
  LegendariesController,
} from '../legendaries/legendaries.controller';
import { RecipeGraphController } from '../recipe-graph/recipe-graph.controller';
import { type CachePolicy, CACHE_POLICY_KEY } from './cache-policy.decorator';

const reflector = new Reflector();

function routePolicy(controller: Type, method: string): CachePolicy | undefined {
  // A prototype method is a Function (an object) — the target getAllAndOverride reads metadata from.
  const handler = (controller.prototype as Record<string, unknown>)[method] as object;
  return reflector.getAllAndOverride<CachePolicy>(CACHE_POLICY_KEY, [handler, controller]);
}

describe('031 T2 — per-endpoint cache surface', () => {
  it('R4/SC1: GET /legendaries is Surface A with the legendaries max-age', () => {
    expect(routePolicy(LegendariesController, 'list')).toEqual({
      kind: 'public',
      maxAge: LEGENDARIES_MAX_AGE,
    });
  });

  it('R4: GET /legendaries/ranking inherits Surface B from the class', () => {
    expect(routePolicy(LegendariesController, 'ranking')).toEqual({ kind: 'private' });
  });

  it('R4: account routes are Surface B', () => {
    expect(routePolicy(AccountController, 'get')).toEqual({ kind: 'private' });
    expect(routePolicy(AccountController, 'materials')).toEqual({ kind: 'private' });
    expect(routePolicy(AccountController, 'wallet')).toEqual({ kind: 'private' });
  });

  it('R4: assistant ask is Surface B', () => {
    expect(routePolicy(AssistantController, 'ask')).toEqual({ kind: 'private' });
  });

  it('R5: commerce prices is Surface B (volatile — not cached this spec)', () => {
    expect(routePolicy(CommerceController, 'list')).toEqual({ kind: 'private' });
  });

  it('R6: health is Surface B', () => {
    expect(routePolicy(HealthController, 'getHealth')).toEqual({ kind: 'private' });
  });

  it('R5/SC3: recipe-graph is Surface B for now (Spec 032 flips it to A)', () => {
    expect(routePolicy(RecipeGraphController, 'get')).toEqual({ kind: 'private' });
  });
});
  • [ ] RED — run it, watch it fail because no controller carries a policy yet.

Run: pnpm vitest run --project api cache-policy.declarations Expected: FAIL — every routePolicy(...) is undefined.

  • [ ] GREEN — legendaries.controller.ts. Add the constant + both decorators. Add the import import { SurfaceA, SurfaceB } from '../caching/cache-policy.decorator';.
ts
/**
 * Browser-cache TTL for the committed legendary catalogue (spec 031). Static data that changes only on
 * a deploy; 1h matches ArenaNet's own /v2/items TTL. Tunable — no cache-busting, so a browser can hold
 * the catalogue up to this long after a deploy.
 */
export const LEGENDARIES_MAX_AGE = 3600;

@Controller('legendaries')
@SurfaceB()
export class LegendariesController {
  // …constructor unchanged…

  @Get()
  @SurfaceA(LEGENDARIES_MAX_AGE)
  @ZodResponse({ status: 200, type: LegendaryListDto })
  list(@Query() query: LegendariesQueryDto): Promise<Legendary[]> {
    // …unchanged…
  }

  // ranking() unchanged — inherits the class-level @SurfaceB().
}
  • [ ] GREEN — the five pure-Surface-B controllers. In each of account, commerce, recipe-graph, assistant, health controllers, add import { SurfaceB } from '../caching/cache-policy.decorator'; and @SurfaceB() under @Controller(...). Example (account.controller.ts):
ts
import { SurfaceB } from '../caching/cache-policy.decorator';
// …
@Controller('account')
@SurfaceB()
export class AccountController {
  // …unchanged…
}
  • [ ] GREEN — run the declarations test, watch it pass.

Run: pnpm vitest run --project api cache-policy.declarations Expected: PASS.

  • [ ] Confirm teeth. Temporarily change legendaries.controller.ts's method decorator to @SurfaceB(); watch the "GET /legendaries is Surface A" assertion fail; restore.

  • [ ] Update spec.md traceability rows to name the real tests (cache-control.interceptor.test.ts, caching.module.test.ts, cache-policy.declarations.test.ts) in place of the generic descriptions, so the table is a transcription (CLAUDE.md Definition of Done).

  • [ ] Full green gate (SC6). Run the whole CI set from the repo root; all must pass. verify:contract confirms openapi.json is byte-unchanged (R9/SC4) — the metadata decorators are not Swagger decorators, so the contract must not move.

Run: pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm verify:contract && pnpm docs:build Expected: all PASS; git status shows no change to apps/api/openapi.json.

  • [ ] Commit.
bash
git add apps/api/src
git commit -m "api: declare cache surface on every endpoint (legendaries public, rest private)"

Verified by: cache-policy.declarations.test.ts; the unchanged apps/api/openapi.json under verify:contract; the full-suite green.


Notes ​

Staging area for decisions and surprises found during implementation — including anything that turned out differently from what plan.md assumed. Move each one into spec.md, research.md, or docs/ before closing the feature; this section is not a home.

  • McpController stays unstamped (plan Risks): @Res() handlers bypass the post-handler interceptor, and POST/405 are not cache-eligible. If any surprise shows the interceptor throwing on the MCP path, that is a real bug — the reply.sent guard exists to prevent it; debug with superpowers:systematic-debugging, do not remove the guard blindly.
  • reply.sent: if the installed Fastify version types this differently, prefer the typed member it does expose over an any cast — the Global Constraints forbid any.
  • SC2 (browser-cache reuse) is observational — record it against the real deploy in docs/architecture/deploy.md's SC log at step 6, not as a unit test.