js-rigor: transitive dependency-index invalidation; multi_hop/parent usage tracking

- invalidateRuleResultCacheByRelation now walks the transitive closure of
  the dependency index (owner -> is_owner -> computed can_read): mutating
  a base relation left computed/multi_hop results stale
- _collectRelationUsages registers multi_hop and parent rule relations
  (parentRelation + subject relation) so their caches invalidate on
  base-relation mutations
- advanced-rule-kinds.test.js: multi_hop (min-over-path, depth-limited),
  computed (userset alias), parent (subject relation on the target's
  parent) through the full check() path under random mutations
This commit is contained in:
John Dvorak
2026-07-31 15:11:42 -07:00
parent 15e9728d00
commit 14f762d4c4
2 changed files with 206 additions and 3 deletions
+28 -3
View File
@@ -471,10 +471,22 @@ export class Arbiter {
invalidateRuleResultCacheByRelation(relation) {
if (!this.ruleResultCache) return;
// Transitive closure over the dependency index: a mutation of a base
// relation (e.g. owner) must invalidate cached results of every rule
// that depends on it through any chain (owner -> is_owner ->
// can_read/computed), not just direct dependents.
const affected = new Set([relation]);
const entry = this.dependencyIndex.get(relation);
if (entry?.all) {
for (const rel of entry.all) affected.add(rel);
const queue = [relation];
while (queue.length) {
const rel = queue.pop();
const entry = this.dependencyIndex.get(rel);
if (!entry || !entry.all) continue;
for (const dep of entry.all) {
if (!affected.has(dep)) {
affected.add(dep);
queue.push(dep);
}
}
}
for (const rel of affected) {
@@ -529,6 +541,19 @@ export class Arbiter {
acc.set(`${rule.relation}|false`, { relation: rule.relation, reverse: false });
}
if (rule.type === 'multi_hop' && rule.relation) {
acc.set(`${rule.relation}|false`, { relation: rule.relation, reverse: false });
}
if (rule.type === 'parent') {
if (rule.parentRelation) {
acc.set(`${rule.parentRelation}|false`, { relation: rule.parentRelation, reverse: false });
}
if (rule.relation && rule.relation !== rule.parentRelation) {
acc.set(`${rule.relation}|false`, { relation: rule.relation, reverse: false });
}
}
const unionRules = Array.isArray(rule.union) ? rule.union : rule.union?.rules;
if (Array.isArray(unionRules)) {
for (const child of unionRules) this._collectRelationUsages(child, acc);