2026-07-31 13:44:06 -07:00
|
|
|
/**
|
|
|
|
|
* DecisionCache — accidental-state port for authorization decision caching.
|
|
|
|
|
*
|
|
|
|
|
* Wraps the directCheckCache + ruleResultCache + reverse-indexes
|
|
|
|
|
* + dependencyIndex + TTL settings that the engine uses to short-circuit
|
|
|
|
|
* repeated authorization decisions. Implementing this port gives test
|
|
|
|
|
* code an explicit, dependency-injectable seam — and frees the engine
|
|
|
|
|
* from the rule that "function signature lies" (RF-03 closure).
|
|
|
|
|
*
|
|
|
|
|
* The port exposes ONLY the surface used by the engine:
|
|
|
|
|
* - getDirect(key) / setDirect(key, result)
|
|
|
|
|
* - getRule(ruleKey) / setRule(ruleKey, result)
|
|
|
|
|
* - invalidateByRelation(relation)
|
|
|
|
|
* - invalidateByNodeKey(nodeKey) // for NodeManager cache leak fix
|
|
|
|
|
* - invalidateAll()
|
|
|
|
|
*
|
|
|
|
|
* All TTL / clock concerns move into the port implementation so the
|
|
|
|
|
* AuthorizationChecker no longer reads wall-clock from its hot path.
|
|
|
|
|
*
|
|
|
|
|
* The default `ArbiterDecisionCache` implementation forwards every call
|
|
|
|
|
* to the corresponding field on the Arbiter instance — preserving the
|
|
|
|
|
* existing behavior of every test that inspects `arbiter.directCheckCache`
|
|
|
|
|
* directly while moving the read site out of AuthorizationChecker.
|
|
|
|
|
*/
|
|
|
|
|
export class DecisionCache {
|
|
|
|
|
/**
|
|
|
|
|
* @param {object} arbiter The Arbiter instance whose caches we wrap.
|
|
|
|
|
* `null` produces a fully inert cache (every
|
|
|
|
|
* get returns undefined, every set is a no-op)
|
|
|
|
|
* — useful for tests that want to disable caching
|
|
|
|
|
* without constructing a full Arbiter.
|
|
|
|
|
* @param {object} [options]
|
|
|
|
|
* @param {(unit?:string) => number} [options.clock] Time source for TTL
|
|
|
|
|
* checks. Defaults to Date.now (ms) but tests
|
|
|
|
|
* can inject a fake clock.
|
|
|
|
|
*/
|
|
|
|
|
constructor(arbiter, options = {}) {
|
|
|
|
|
this.arbiter = arbiter;
|
2026-08-02 17:50:24 -07:00
|
|
|
// Precedence: an explicit cache-level clock wins; else the arbiter's
|
|
|
|
|
// injected clock (options.clock on the Arbiter); else the wall clock.
|
|
|
|
|
this.clock = options.clock || ((arbiter && typeof arbiter.clock === 'function') ? arbiter.clock.bind(arbiter) : (() => Date.now()));
|
2026-07-31 13:44:06 -07:00
|
|
|
this._enabled = !!arbiter;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** True if this cache can store anything. */
|
|
|
|
|
get enabled() {
|
|
|
|
|
if (!this._enabled) return false;
|
|
|
|
|
const a = this.arbiter;
|
|
|
|
|
return !a.disableCaching;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** True if direct (single-step) caching is active. */
|
|
|
|
|
get directEnabled() {
|
|
|
|
|
if (!this.enabled) return false;
|
|
|
|
|
return !!(this.arbiter.directCheckCache) && !this.arbiter.disableDirectCaching;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** True if rule-result caching is active. */
|
|
|
|
|
get ruleEnabled() {
|
|
|
|
|
if (!this.enabled) return false;
|
|
|
|
|
return !!this.arbiter.ruleResultCache;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Look up a previously-cached direct-check result.
|
|
|
|
|
* Returns undefined on miss / disabled.
|
|
|
|
|
* Distinguishes "expired" from "miss" via the second tuple element.
|
|
|
|
|
*
|
|
|
|
|
* @returns {[result, status]} status is 'hit' | 'expired' | 'miss' | 'disabled'
|
|
|
|
|
*/
|
|
|
|
|
peekDirect(cacheKey) {
|
|
|
|
|
if (!this.directEnabled) return [undefined, 'disabled'];
|
|
|
|
|
const entry = this.arbiter.directCheckCache.get(cacheKey);
|
|
|
|
|
if (!entry) return [undefined, 'miss'];
|
|
|
|
|
if (this.clock() - entry.timestamp >= this.arbiter.directCheckCacheTTL) {
|
|
|
|
|
return [entry.result, 'expired'];
|
|
|
|
|
}
|
|
|
|
|
return [entry.result, 'hit'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Simplified form: returns just the result on hit, undefined otherwise.
|
|
|
|
|
*/
|
|
|
|
|
getDirect(cacheKey) {
|
|
|
|
|
const [result, status] = this.peekDirect(cacheKey);
|
|
|
|
|
return status === 'hit' ? result : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Store a direct-check result. Caller is responsible for key construction
|
|
|
|
|
* (so the cache key derivation logic stays in AuthorizationChecker where
|
|
|
|
|
* the schema lives).
|
|
|
|
|
*/
|
|
|
|
|
setDirect(cacheKey, result) {
|
|
|
|
|
if (!this.directEnabled) return;
|
|
|
|
|
this.arbiter.directCheckCache.set(cacheKey, {
|
|
|
|
|
result,
|
|
|
|
|
timestamp: this.clock()
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Look up a previously-cached rule-evaluation result.
|
|
|
|
|
* Returns undefined on miss / disabled / expired.
|
|
|
|
|
*/
|
|
|
|
|
getRule(ruleCacheKey) {
|
|
|
|
|
if (!this.ruleEnabled) return undefined;
|
|
|
|
|
const entry = this.arbiter.ruleResultCache.get(ruleCacheKey);
|
|
|
|
|
if (!entry) return undefined;
|
2026-08-03 13:26:42 -07:00
|
|
|
// A graph mutation since the entry was computed invalidates it (stale
|
|
|
|
|
// derived result). _graphVersion increments on every relation mutation.
|
|
|
|
|
if (entry.graphVersion !== undefined && entry.graphVersion !== (this.arbiter._graphVersion ?? 0)) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
2026-07-31 13:44:06 -07:00
|
|
|
if (this.clock() - entry.timestamp >= this.arbiter.ruleResultCacheTTL) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
return entry.result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setRule(ruleCacheKey, result) {
|
|
|
|
|
if (!this.ruleEnabled) return;
|
|
|
|
|
this.arbiter.ruleResultCache.set(ruleCacheKey, {
|
|
|
|
|
result,
|
2026-08-03 13:26:42 -07:00
|
|
|
timestamp: this.clock(),
|
|
|
|
|
graphVersion: this.arbiter._graphVersion ?? 0
|
2026-07-31 13:44:06 -07:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Mark a cache key as belonging to a particular relation so that
|
|
|
|
|
* future invalidateByRelation(relation) calls can find it.
|
|
|
|
|
*/
|
|
|
|
|
trackRuleKeyForRelation(relation, cacheKey) {
|
|
|
|
|
const a = this.arbiter;
|
|
|
|
|
if (!a.ruleResultCache || !cacheKey) return;
|
|
|
|
|
let set = a.ruleResultCacheKeysByRelation.get(relation);
|
|
|
|
|
if (!set) {
|
|
|
|
|
set = new Set();
|
|
|
|
|
a.ruleResultCacheKeysByRelation.set(relation, set);
|
|
|
|
|
}
|
|
|
|
|
set.add(cacheKey);
|
|
|
|
|
a.ruleResultCacheRelationByKey.set(cacheKey, relation);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Invalidate every direct + rule-result cache entry that could
|
|
|
|
|
* have been affected by a relation change. This mirrors the old
|
|
|
|
|
* Arbiter._invalidateDirectCheckCache + invalidateRuleResultCacheByRelation
|
|
|
|
|
* pair, fused into one port-level call.
|
|
|
|
|
*/
|
|
|
|
|
invalidateByRelation(relation) {
|
|
|
|
|
const a = this.arbiter;
|
|
|
|
|
if (!a) return;
|
|
|
|
|
if (a.directCheckCache) {
|
|
|
|
|
a._invalidateDirectCheckCache?.(/*srcKey*/ null, relation, /*dstKey*/ null);
|
|
|
|
|
}
|
|
|
|
|
a.invalidateRuleResultCacheByRelation?.(relation);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Invalidate every direct-check entry whose composite key contains
|
|
|
|
|
* a given node id. Replaces the leaky `cacheKey.includes(String(nodeId))`
|
|
|
|
|
* walk that NodeManager used to perform. Now the cache file is the
|
|
|
|
|
* only place that knows its own storage layout.
|
|
|
|
|
*
|
|
|
|
|
* Requires the cache implementation to expose either a key iterator
|
|
|
|
|
* (`keys()`) or a pattern-invalidate method (`invalidateByPattern`).
|
|
|
|
|
* The local @tenere/hyperbolic-lru@1.0.3 provides both. The previous
|
|
|
|
|
* npm hyperbolic-lru@1.0.2 exposed neither, making this method a no-op.
|
|
|
|
|
*/
|
|
|
|
|
invalidateByNodeKey(nodeKey) {
|
|
|
|
|
const a = this.arbiter;
|
|
|
|
|
if (!a?.directCheckCache) return;
|
|
|
|
|
const nodeId = a.nodeIdByKey?.get(nodeKey);
|
|
|
|
|
if (nodeId === undefined) return;
|
2026-08-03 16:35:08 -07:00
|
|
|
// Keys are rolling hashes (not pipe-delimited strings), so node-level
|
|
|
|
|
// invalidation uses the per-node key index populated at cache-set time.
|
|
|
|
|
a._invalidateDirectCheckCacheByNode?.(nodeId);
|
2026-07-31 13:44:06 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Flush every cache. Equivalent to the old
|
|
|
|
|
* `disableCaching = true` test path.
|
|
|
|
|
*/
|
|
|
|
|
invalidateAll() {
|
|
|
|
|
const a = this.arbiter;
|
|
|
|
|
if (!a) return;
|
|
|
|
|
a.directCheckCache?.clear?.();
|
2026-08-03 16:35:08 -07:00
|
|
|
a.directCheckCacheKeysByRelation?.clear?.();
|
|
|
|
|
a.directCheckCacheKeysByNode?.clear?.();
|
|
|
|
|
a.directKeyToRelations?.clear?.();
|
2026-07-31 13:44:06 -07:00
|
|
|
a.invalidateAllRuleResultCache?.();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* NullDecisionCache — every operation is a no-op. Use in tests that want
|
|
|
|
|
* a pure decision flow with no accidental state at all.
|
|
|
|
|
*/
|
|
|
|
|
export class NullDecisionCache {
|
|
|
|
|
constructor() { this._enabled = true; }
|
|
|
|
|
get enabled() { return false; }
|
|
|
|
|
get directEnabled() { return false; }
|
|
|
|
|
get ruleEnabled() { return false; }
|
|
|
|
|
getDirect() { return undefined; }
|
|
|
|
|
setDirect() {}
|
|
|
|
|
getRule() { return undefined; }
|
|
|
|
|
setRule() {}
|
|
|
|
|
trackRuleKeyForRelation() {}
|
|
|
|
|
invalidateByRelation() {}
|
|
|
|
|
invalidateByNodeKey() {}
|
|
|
|
|
invalidateAll() {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default DecisionCache;
|