mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
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>
84 lines
3.1 KiB
TypeScript
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);
|
|
});
|
|
});
|