/** * tests/DSLRuntimeCache.test.js — provider-result caching with time expiry. * * Registered providers retrieve missing facts from a data store; caching the * retrieval avoids hammering the store on repeated checks. TTL resolution: * DSL-declared `BEHAVES { ttl }` on a fact > per-fact setFactTTL > * policy default (30s). Per-check factProviders are cache-transparent (one-off * observations: no cache read, no cache write). Registering a provider or * mutating the graph invalidates the cache. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { Arbiter } from '@arbiter/core'; import { DSLRuntime } from '../src/runtime/DSLRuntime.js'; const BASE_DSL = ` definition Employee { id: string } definition Doc { id: string } fact *owns(user: Employee, doc: Doc) evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } `; function makeRuntime(options = {}) { let t = 0; const clock = () => t; const rt = new DSLRuntime(new Arbiter(), { clock, ...options }).compile(BASE_DSL, 'rt-cache'); rt._test_advance = (ms) => { t += ms; }; return rt; } describe('DSLRuntime provider-result caching', () => { it('reuses a registered provider result within the TTL', async () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); let calls = 0; rt.registerFact('owns', async () => { calls++; return 0.9; }); await rt.check('u:1', 'can_read', 'doc:9'); await rt.check('u:1', 'can_read', 'doc:9'); assert.equal(calls, 1, 'provider should be invoked once within TTL'); }); it('re-invokes the provider after the TTL expires', async () => { const rt = makeRuntime(); rt.setFactTTL('owns', 100); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); let calls = 0; let value = 0.9; rt.registerFact('owns', async () => { calls++; return value; }); const first = await rt.check('u:1', 'can_read', 'doc:9'); assert.equal(first.possibility, 0.9); rt._test_advance(50); await rt.check('u:1', 'can_read', 'doc:9'); // within TTL -> cached assert.equal(calls, 1); rt._test_advance(60); // past TTL (110 total) value = 0.4; const after = await rt.check('u:1', 'can_read', 'doc:9'); assert.equal(calls, 2); assert.equal(after.possibility, 0.4); }); it('per-check factProviders override the cache (fresh observation)', async () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); rt.registerFact('owns', async () => 0.9); await rt.check('u:1', 'can_read', 'doc:9'); // Per-check override is cache-transparent: it must NOT be masked by the // cached 0.9, and it must NOT overwrite the cached value. const over = await rt.check('u:1', 'can_read', 'doc:9', { factProviders: { owns: async () => 0.2 } }); assert.equal(over.possibility, 0.2); const next = await rt.check('u:1', 'can_read', 'doc:9'); assert.equal(next.possibility, 0.9, 'registered provider cache untouched by per-check override'); }); it('registerFact invalidates the cached result for that relation', async () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); rt.registerFact('owns', async () => 0.9); await rt.check('u:1', 'can_read', 'doc:9'); rt.registerFact('owns', async () => 0.3); // re-register -> cache invalidated const res = await rt.check('u:1', 'can_read', 'doc:9'); assert.equal(res.possibility, 0.3); }); it('invalidates cached results on graph mutations', async () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); let calls = 0; rt.registerFact('owns', async () => { calls++; return 0.9; }); await rt.check('u:1', 'can_read', 'doc:9'); assert.equal(calls, 1); rt.addRelation('u:1', 'owns', 'doc:9', { possibility: 1.0 }); // mutation clears cache const res = await rt.check('u:1', 'can_read', 'doc:9'); assert.equal(calls, 2, 'graph mutation should invalidate the provider cache'); }); it('invalidateProviderCache() clears all or per relation', async () => { const dsl = ` definition Employee { id: string } definition Doc { id: string } fact *owns(user: Employee, doc: Doc) fact *banned(user: Employee) evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } evidence can_open(user: Employee, doc: Doc) { WHEN can_read(user, doc) UNLESS banned(user) } `; const rt = new DSLRuntime(new Arbiter()).compile(dsl, 'rt-cache2'); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); let ownsCalls = 0, bannedCalls = 0; rt.registerFact('owns', async () => { ownsCalls++; return 0.9; }); rt.registerFact('banned', async () => { bannedCalls++; return 0; }); await rt.check('u:1', 'can_open', 'doc:9'); assert.equal(ownsCalls, 1); assert.equal(bannedCalls, 1); // Invalidate a non-dependency relation: can_open's cache (owns+banned) survives. rt.invalidateProviderCache('does_not_exist'); await rt.check('u:1', 'can_open', 'doc:9'); assert.equal(ownsCalls, 1); assert.equal(bannedCalls, 1); // Invalidate owns only: banned survives, owns re-fetched. rt.invalidateProviderCache('owns'); await rt.check('u:1', 'can_open', 'doc:9'); assert.equal(ownsCalls, 2, 'owns cache cleared by per-relation invalidation'); assert.equal(bannedCalls, 1, 'banned cache survives per-relation invalidation'); // Clear all. rt.invalidateProviderCache(); await rt.check('u:1', 'can_open', 'doc:9'); assert.equal(bannedCalls, 2, 'full invalidation clears every relation'); }); it('policy default TTL applies when no per-fact TTL is set', async () => { const rt = makeRuntime({ policy: { providerCacheTTL: 50 } }); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); let calls = 0; rt.registerFact('owns', async () => { calls++; return 0.9; }); await rt.check('u:1', 'can_read', 'doc:9'); rt._test_advance(40); await rt.check('u:1', 'can_read', 'doc:9'); assert.equal(calls, 1, 'within 50ms policy TTL -> cached'); rt._test_advance(20); await rt.check('u:1', 'can_read', 'doc:9'); assert.equal(calls, 2, 'past 50ms policy TTL -> re-invoked'); }); it('uses the DSL-declared fact TTL (BEHAVES { ttl X })', async () => { const dsl = ` definition Employee { id: string } definition Doc { id: string } fact *balance(user: Employee, amount: number) BEHAVES { ttl 1h } evidence can_spend(user: Employee, doc: Doc) { balance(user, 1) } `; let t = 0; const rt = new DSLRuntime(new Arbiter(), { clock: () => t }).compile(dsl, 'rt-dsl-ttl'); // The DSL declares a 1h TTL for the balance fact. assert.equal(rt.relations.get('balance').ttlMs, 3600_000); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); let calls = 0; rt.registerFact('balance', async () => { calls++; return { possibility: 1.0, value: 50 }; }); await rt.check('u:1', 'can_spend', 'doc:9'); assert.equal(calls, 1); t += 60 * 60 * 1000 - 1; // just under 1h await rt.check('u:1', 'can_spend', 'doc:9'); assert.equal(calls, 1, 'cached within DSL-declared 1h TTL'); t += 2; await rt.check('u:1', 'can_spend', 'doc:9'); assert.equal(calls, 2, 're-invoked past the DSL-declared 1h TTL'); }); it('cacheProviderResults:false bypasses the cache per check', async () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); let calls = 0; rt.registerFact('owns', async () => { calls++; return 0.9; }); await rt.check('u:1', 'can_read', 'doc:9'); assert.equal(calls, 1); // Bypass forces a fresh retrieval without clearing the cache. await rt.check('u:1', 'can_read', 'doc:9', { cacheProviderResults: false }); assert.equal(calls, 2); // Cache still intact for the next default check. await rt.check('u:1', 'can_read', 'doc:9'); assert.equal(calls, 2); }); it('policy.cacheProviderResults:false disables caching globally', async () => { const rt = makeRuntime({ policy: { cacheProviderResults: false } }); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); let calls = 0; rt.registerFact('owns', async () => { calls++; return 0.9; }); await rt.check('u:1', 'can_read', 'doc:9'); await rt.check('u:1', 'can_read', 'doc:9'); assert.equal(calls, 2, 'no caching when disabled globally'); }); });