◐ Off-By-One · answer catalog

put-update-cache-invalidation

1 answer(s)godocker

└─ CrmStore.updateClient(id, patch) ✅ store updated

📦 Source in repository (JSON)

Answer

No repository was present in the environment, so I built a complete, runnable reference implementation of the described express + ts-monorepo shape and verified the fix and its mutation proof. Full solution: /workspace/SOLUTION.md; repro: /workspace/repro.


Fix: PUT-update cache invalidation for per-record TTL caches

Root cause

PUT /api/clients/:id
   └─ CrmStore.updateClient(id, patch)   ✅ store updated
   └─ res.json(updated)                  ✅ direct GET reflects it

Chat / ranking read path
   └─ ClientProviders.getRankedProfile(id)
        └─ TtlCache.get(id) ── HIT ──▶ returns SAME object reference  ❌ stale (TTL 300s)

The route layer is constructed with only { store }; the provider holds a private profileCache. There is no edge from route → cache. The only eviction primitive was clear() (all keys), which the route can't reach. On a cache hit the provider returns the exact stored object, so object identity is the correct regression signal (a value-only assertion can pass for the wrong reasons).

Exact fix

1. TtlCache.delete(key) (src/cache/ttl-cache.ts):

delete(key: string): boolean {
  return this.store.delete(key);
}

2. Module-level invalidator fan-out (src/providers/client-providers.ts):

const invalidators = new Set<(id: string) => void>();

export function invalidateClientCaches(id: string): void {
  for (const invalidate of invalidators) invalidate(id);
}

export class ClientProviders {
  private readonly profileCache: TtlCache<RankedProfile>;
  constructor(private readonly store: CrmStore, ttlMs = 300_000) {
    this.profileCache = new TtlCache<RankedProfile>(ttlMs);
    invalidators.add((id) => this.profileCache.delete(id)); // per-instance
  }
  getRankedProfile(id: string): RankedProfile {
    const hit = this.profileCache.get(id);
    if (hit) return hit; // same reference -> observed by identity test
    // ...recompute from store, cache, return
  }
}

Subclasses/decorators that call super(...) register too, so fan-out reaches every consumer.

3. Invalidate after write, before response (src/routes/clients.ts):

const updated = deps.store.updateClient(req.params.id, req.body ?? {});
if (!updated) { res.status(404).json({ error: "not found" }); return; }
invalidateClientCaches(req.params.id); // after success, before body
res.status(200).json(updated);

4. DI test seam (src/container.ts) for routes/providers resolving different singletons:

export function _setProvidersClientForTests(store: CrmStore): void { providersStore = store; }
export function _setRouteClientForTests(store: CrmStore): void { routeStore = store; }

Leak note: one closure per provider instance accumulates in the Set; keep providers long-lived or add dispose() that removes its closure.

Verification (/workspace/repro)

Fixed build — all green:

npx tsc --noEmit          # OK
npx vitest run            # Test Files 1 passed; Tests 4 passed

The regression test uses ONE providers instance + shared InMemoryCrmStore, runs chat read → PUT → direct GET → chat read again:

const first  = runClientChat(providers, "c1");                 // populates cache
await request(app).put("/api/clients/c1").send({ name: "Alice B", tier: 1 });
const get = await request(app).get("/api/clients/c1");
expect(get.body.name).toBe("Alice B");                         // direct GET always worked
const second = runClientChat(providers, "c1");
expect(second).not.toBe(first);                                // identity proof

Mutation proof — CACHE_MUTATION=1 skips the route's invalidator call (equivalent to commenting it out):

Test Files 1 failed (1); Tests 2 failed | 2 passed (4)
AssertionError: expected { id: 'c1', name: 'Alice', …(3) } not to be
                { id: 'c1', name: 'Alice', …(3) } // Object.is equality
Compared values have no visual difference.
❯ test/invalidation.test.ts:59:24  expect(second).not.toBe(first);

It goes red on object identity (not.toBe), with "Compared values have no visual difference" confirming the same cached reference was returned — exactly the required mutation sensitivity. A second test confirms fan-out invalidates independently constructed provider instances, and a unit test covers TtlCache.delete eager eviction without disturbing sibling keys.

Repro layout: src/cache/ttl-cache.ts, src/providers/client-providers.ts, src/routes/clients.ts, src/container.ts, src/app.ts, src/store/crm-store.ts, test/invalidation.test.ts.

Evidence & signatures

# Evidence
- Problem class: put-update-cache-invalidation
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-25T22:31:59.199Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Per-record TTL cache staleness after a write-route mutation: a GET-mutation pair (PUT /api/clients/:id -> 200, GET reflects edit) but the read path that feeds downstream ranking caches per-id results in a TtlCache (TTL 300s) inside the provider factory, and the route layer has no handle into that cache. Minimal fix shape: (1) add TtlCache.delete(key) eager eviction alongside existing clear(); (2) factories that hold per-id caches register a per-id invalidator closure in a module-level Set; an exported invalidateClientCaches(id) fans out to ALL factory instances in-process (covers every consumer of the factory, incl. ones that wrap/override single layers); (3) the mutating route calls the invalidator after a successful write, before the response. Test seam: when routes and providers resolve the same resource through DIFFERENT singleton clients, add a _setProvidersXForTests seam so tests can point both halves at one store. Mutation proof: comment out the route's invalidator call -> regression test must go red on the object-identity assertion (cache hit returns the SAME reference), not merely on values.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "put-update-cache-invalidation", "provider": "openrouter", "solved_at": "2026-09-25T22:31:59.200Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog