Files
gbrain/test/schema-pack-query-cache-invalidator.test.ts
T
Garry TanandClaude Opus 4.7 e8ea179256 v0.40.6.0 Phase 1 foundations — pack-lock + mutate-audit + cache invalidation + lint rules + best-effort
Six new primitives that Phase 2's withMutation skeleton (next commit) depends on.
No consumers yet; all callers wire up in Phase 4. Foundations ship first per
codex C1 phase-ordering finding from /plan-eng-review.

1.1 pack-lock.ts (18 cases)
  Atomic acquire via openSync(path, 'wx') = O_CREAT|O_EXCL. Kernel-level
  atomic, NO TOCTOU window. Codex C8 caught that page-lock.ts:79+96 has
  existsSync+writeFileSync (TOCTOU) — we deliberately do NOT copy it.
  Stale detection via TTL (60s default) + kill(pid, 0) liveness probe.
  TTL refresh every 10s while withPackLock(fn) runs so long DB-aware
  lint/stats on big brains don't go stale. --force = "steal stale lock"
  (NOT "skip locking"). Lock path per-pack so two packs never block.

1.2 mutate-audit.ts (13 cases)
  ISO-week JSONL at ~/.gbrain/audit/schema-mutations-YYYY-Www.jsonl.
  Privacy redacted per D20: type names → sha8, prefixes → first slug
  segment only. Matches candidate-audit.ts privacy posture. Both verbose
  surfaces gate on GBRAIN_SCHEMA_AUDIT_VERBOSE=1 (same env). Logs BOTH
  success AND failure events so Phase 9's schema_pack_writability doctor
  check has signal to read (closes codex C11). summarizeMutations()
  primitive shipped for cross-surface parity between doctor + future
  audit CLI.

1.3 registry.ts cache invalidation + stat-mtime TTL (10 cases)
  invalidatePackCache(name?) walks the extends-chain reverse-graph
  (every cached entry whose chain contains name is evicted). This is the
  codex C6 fix — pre-v0.40.6, editing a parent pack silently left
  children stale because cache identity was child-bytes-only. New
  per-name CacheEntry tracks the file-stat snapshot of every file in
  the extends chain. tryCachedPack(name) is the TTL-gated fast path:
  inside STAT_TTL_MS (1000ms default, env GBRAIN_PACK_STAT_TTL_MS)
  returns cached without statting. Outside the window: stats every file
  and cascade-invalidates on any mtime change (D11 cross-process
  detection). resolvePack reference-equality preserved on byte-identical
  re-build. ASCII state-machine diagram in file header (D9).

1.4 best-effort.ts (4 cases)
  loadActivePackBestEffort(ctx) returns ResolvedPack | null. Single
  source of truth for the 4 T1.5 wiring sites (Phase 8). null means
  "EMPTY FILTER" semantics, NOT "fall back to hardcoded defaults" —
  pack-load failure must be loud, per D4. Never throws.

1.5 lint-rules.ts (35 cases)
  11 pure rule functions extracted from CLI handlers per codex C13/D16.
  9 file-plane rules + 2 DB-aware rules (extractable_empty_corpus, mutation_count_anomaly).
  Phase 2 withMutation pre-write gate composes file-plane subset.
  runAllLintRules() returns {ok, errors, warnings} structured report
  ready for CLI + MCP.

1.6 query-cache-invalidator.ts (4 cases)
  invalidateQueryCache(engine, sourceId?) DELETEs query_cache rows so
  cached search results bound to old page types don't survive a
  schema mutation. Reuses SemanticQueryCache.clear() so we don't
  reinvent the PGLite+Postgres parity. Codex C9 fix.

Tests: 84 new cases across 6 test files. All 153 schema-pack tests green.

Plan: ~/.claude/plans/system-instruction-you-are-working-recursive-thacker.md
Closes: half of T2-T7 from the plan's Implementation Tasks JSONL.
Successor to: closed PR #1321 (community PR; author garrytan-agents).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:15:00 -07:00

84 lines
3.1 KiB
TypeScript

// v0.40.6.0 — query-cache-invalidator.ts contract tests.
//
// Pins the C9 fix: schema mutations DELETE the query_cache for the
// affected source so cached search results bound to old page types
// don't survive a `sync --apply`.
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { invalidateQueryCache } from '../src/core/schema-pack/query-cache-invalidator.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
async function seedCacheRow(sourceId: string, queryText: string): Promise<void> {
// Use raw INSERT to bypass the semantic-similarity path. We're testing the
// CLEAR behavior, not the LOOKUP behavior.
await engine.executeRaw(
`INSERT INTO query_cache (id, query_text, source_id, knobs_hash, embedding, results, meta, ttl_seconds, created_at)
VALUES ($1, $2, $3, 'v3:test', NULL, '[]'::jsonb, '{}'::jsonb, 3600, now())`,
[`${sourceId}-${queryText}-id`, queryText, sourceId],
);
}
async function countCacheRows(sourceId?: string): Promise<number> {
const sql = sourceId
? `SELECT COUNT(*)::int AS n FROM query_cache WHERE source_id = $1`
: `SELECT COUNT(*)::int AS n FROM query_cache`;
const rows = await engine.executeRaw<{ n: number }>(sql, sourceId ? [sourceId] : []);
return rows[0]?.n ?? 0;
}
describe('invalidateQueryCache', () => {
it('clears all rows for a given source_id', async () => {
await seedCacheRow('source-a', 'q1');
await seedCacheRow('source-a', 'q2');
await seedCacheRow('source-b', 'q1');
expect(await countCacheRows('source-a')).toBe(2);
const result = await invalidateQueryCache(engine, 'source-a');
expect(result.rows_invalidated).toBe(2);
expect(await countCacheRows('source-a')).toBe(0);
expect(await countCacheRows('source-b')).toBe(1);
});
it('clears all rows when sourceId is omitted', async () => {
await seedCacheRow('source-a', 'q1');
await seedCacheRow('source-b', 'q2');
const result = await invalidateQueryCache(engine);
expect(result.rows_invalidated).toBe(2);
expect(await countCacheRows()).toBe(0);
});
it('is idempotent on empty cache', async () => {
const r1 = await invalidateQueryCache(engine, 'source-a');
const r2 = await invalidateQueryCache(engine, 'source-a');
expect(r1.rows_invalidated).toBe(0);
expect(r2.rows_invalidated).toBe(0);
});
it('returns {rows_invalidated: 0} silently if engine call fails (never throws)', async () => {
// Build a stub engine whose executeRaw always throws.
const broken = {
kind: 'pglite',
executeRaw: async () => { throw new Error('synthetic'); },
} as unknown as PGLiteEngine;
const result = await invalidateQueryCache(broken, 'source-a');
expect(result.rows_invalidated).toBe(0);
});
});