fe162251fc
The higher-order DSL+Core wrapper now covers the full contract the DSL informs, beyond the typed mutations already present: - getSchema(): serializable introspection of the compiled type system — entity types/fields, facts (params + injectable flag), evidence (with transitive dependsOn), and registered providers. relationNames() lists all declared relations. (The DSL's type system was always present; this exposes it programmatically.) - registerFact(relation, fn) / unregisterFact / registeredFacts: per-relation async providers that retrieve missing partial-graph edges; per-check factProviders merge OVER registered ones. - Bounded fixed-point provider retrieval loop (maxProviderRounds): each round invokes providers for required facts whose edges are not yet injected. A provider may return edges for relations other than its own — those satisfy the other required facts and can unblock later rounds. - check() now type-validates FACT relations too (not just evidence); edge normalization preserves a provider edge's own relation name. - removeNode / removeRelation passthroughs; require() throws on denial for middleware. - Field typing extended to the DSL's full value-type universe (timestamp/duration accept number or string; object/any accept anything). Tests: DSLRuntimeExt (schema, registration, merge, fixed-point, require, removal, fact-check validation, timestamp typing).
148 lines
6.3 KiB
JavaScript
148 lines
6.3 KiB
JavaScript
/**
|
|
* tests/DSLRuntimeExt.test.js — extended DSLRuntime capabilities:
|
|
* - schema introspection (getSchema)
|
|
* - per-relation provider registration (registerFact/unregisterFact)
|
|
* - provider merging (registered + per-check overrides)
|
|
* - bounded fixed-point provider retrieval loop (edges satisfy other facts)
|
|
* - require() throw-on-deny
|
|
* - removal passthroughs and fact-relation check validation
|
|
* - timestamp/duration field typing
|
|
*/
|
|
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 level: number active: boolean }
|
|
definition Doc { id: string created: timestamp }
|
|
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) }
|
|
`;
|
|
|
|
function makeRuntime() {
|
|
return new DSLRuntime(new Arbiter()).compile(BASE_DSL, 'rt-ext');
|
|
}
|
|
|
|
describe('DSLRuntime extended', () => {
|
|
it('exposes a serializable schema snapshot', () => {
|
|
const rt = makeRuntime();
|
|
const schema = rt.getSchema();
|
|
assert.ok(Array.isArray(schema.types));
|
|
const employee = schema.types.find(t => t.name === 'Employee');
|
|
assert.ok(employee);
|
|
assert.ok(employee.fields.some(f => f.name === 'level' && f.type === 'number'));
|
|
const owns = schema.facts.find(f => f.name === 'owns');
|
|
assert.equal(owns.injectable, true);
|
|
assert.equal(owns.params[1].type, 'Doc');
|
|
const can_open = schema.evidence.find(e => e.name === 'can_open');
|
|
assert.ok(can_open.dependsOn.includes('owns'));
|
|
assert.deepEqual(schema.providers, []);
|
|
assert.ok(rt.relationNames().includes('owns') && rt.relationNames().includes('can_read'));
|
|
});
|
|
|
|
it('registers, lists, and unregisters per-relation providers', () => {
|
|
const rt = makeRuntime();
|
|
rt.registerFact('owns', async () => 0.8);
|
|
assert.deepEqual(rt.registeredFacts(), ['owns']);
|
|
rt.registerFact('banned', async () => 0);
|
|
assert.deepEqual(rt.registeredFacts().sort(), ['banned', 'owns']);
|
|
rt.unregisterFact('banned');
|
|
assert.deepEqual(rt.registeredFacts(), ['owns']);
|
|
assert.throws(() => rt.registerFact('owns', 'not a function'), /must be a function/);
|
|
});
|
|
|
|
it('merges registered providers with per-check overrides', async () => {
|
|
const rt = makeRuntime();
|
|
rt.registerFact('owns', async () => 0.5);
|
|
rt.registerFact('banned', async () => 0);
|
|
rt.addNode('u:1', 'Employee', {});
|
|
rt.addNode('doc:9', 'Doc', {});
|
|
// registered owns (0.5) wins over nothing; per-check banned overrides
|
|
const res = await rt.check('u:1', 'can_open', 'doc:9', {
|
|
factProviders: { banned: async () => 0 }
|
|
});
|
|
assert.equal(res.possibility, 0.5);
|
|
assert.deepEqual(res.providedFacts.sort(), ['banned', 'owns']);
|
|
});
|
|
|
|
it('runs providers to a fixed point when edges satisfy other required facts', async () => {
|
|
// can_open needs owns (injectable). A registered owns provider returns an
|
|
// edge for a DIFFERENT injectable fact that can_open also requires via
|
|
// composition — here we add a transitive requirement to prove the loop.
|
|
const dsl = `
|
|
definition Employee { id: string }
|
|
definition Doc { id: string }
|
|
fact *owns(user: Employee, doc: Doc)
|
|
fact *granted(user: Employee, doc: Doc)
|
|
evidence base_read(user: Employee, doc: Doc) { owns(user, doc) }
|
|
evidence can_open(user: Employee, doc: Doc) { WHEN base_read(user, doc) UNLESS granted(user, doc) }
|
|
`;
|
|
const rt = new DSLRuntime(new Arbiter()).compile(dsl, 'rt-loop');
|
|
rt.addNode('u:1', 'Employee', {});
|
|
rt.addNode('doc:9', 'Doc', {});
|
|
let ownsCalls = 0;
|
|
let grantedCalls = 0;
|
|
rt.registerFact('owns', async () => {
|
|
ownsCalls++;
|
|
// First round the owns provider also supplies the granted edge (a
|
|
// fixed-point dependency: granted needs owns to have been retrieved).
|
|
return [
|
|
{ src: 'u:1', relation: 'owns', dst: 'doc:9', possibility: 0.9 },
|
|
{ src: 'u:1', relation: 'granted', dst: 'doc:9', possibility: 0 }
|
|
];
|
|
});
|
|
rt.registerFact('granted', async () => { grantedCalls++; return 0; });
|
|
const res = await rt.check('u:1', 'can_open', 'doc:9', { maxProviderRounds: 3 });
|
|
assert.equal(res.possibility, 0.9);
|
|
// granted was satisfied by the owns provider's extra edge, so its own
|
|
// provider was never needed in a later round.
|
|
assert.equal(grantedCalls, 0);
|
|
assert.ok(ownsCalls >= 1);
|
|
assert.deepEqual(res.providedFacts, ['owns']);
|
|
assert.deepEqual(res.missingFacts, []);
|
|
});
|
|
|
|
it('require() throws on denial and returns the result on grant', async () => {
|
|
const rt = makeRuntime();
|
|
rt.addNode('u:1', 'Employee', {});
|
|
rt.addNode('doc:9', 'Doc', {});
|
|
rt.registerFact('owns', async () => 0.9);
|
|
const ok = await rt.require('u:1', 'can_read', 'doc:9');
|
|
assert.equal(ok.possibility, 0.9);
|
|
rt.registerFact('owns', async () => 0);
|
|
await assert.rejects(
|
|
() => rt.require('u:1', 'can_read', 'doc:9'),
|
|
(err) => err.result && err.result.possibility === 0 && /denied/.test(err.message)
|
|
);
|
|
});
|
|
|
|
it('passes through node/relation removal', () => {
|
|
const rt = makeRuntime();
|
|
rt.addNode('u:1', 'Employee', {});
|
|
rt.addNode('doc:9', 'Doc', {});
|
|
rt.addRelation('u:1', 'owns', 'doc:9', { possibility: 1.0 });
|
|
rt.removeRelation('u:1', 'owns', 'doc:9');
|
|
assert.equal(rt.arbiter.check('u:1', 'owns', 'doc:9').possibility, 0);
|
|
rt.removeNode('u:1');
|
|
assert.equal(rt.arbiter.nodeIdByKey.has('u:1'), false);
|
|
});
|
|
|
|
it('validates fact-relation check endpoints like evidence', async () => {
|
|
const rt = makeRuntime();
|
|
rt.addNode('u:1', 'Employee', {});
|
|
rt.addNode('doc:9', 'Doc', {});
|
|
// can_read is evidence; owns is a fact — checking a fact still validates.
|
|
await assert.rejects(() => rt.check('u:1', 'owns', 'u:1', {}), /expected 'Doc'/);
|
|
});
|
|
|
|
it('accepts timestamp field values and rejects mistyped ones', () => {
|
|
const rt = makeRuntime();
|
|
rt.addNode('doc:9', 'Doc', { created: 1720000000000 });
|
|
rt.updateNodeData('doc:9', { created: '2026-08-03T00:00:00Z' });
|
|
assert.throws(() => rt.addNode('doc:8', 'Doc', { created: {} }), /must be timestamp/);
|
|
});
|
|
});
|