feat: duration seconds; required fields; type-validated insertions/updates/retrievals
- Duration literals now accept s/m/h/d/w (was m/h/d/w): 'BEHAVES { ttl 30s }'
is 30s, and 'within 30s' temporal expressions parse.
- Definition fields are REQUIRED by default ('field: type'); 'field: type?'
marks a field optional. addNode enforces presence on insert, getSchema
exposes per-field requiredness, and updateNodeData still validates the
provided fields' types.
- Provider-returned edges are validated against the fact's declared typing:
a value-carrying fact must return { value, possibility } with a value of the
declared type (bare-number shorthand is rejected); a non-value fact must not
carry a value; and every possibility must lie in [0, 1]. Violations throw a
clear provider-authoring error instead of silently injecting malformed edges.
Tests: DSLRuntimeTyping (duration units, required-field enforcement, schema
requiredness, value-type + shape + possibility validation).
This commit is contained in:
@@ -20,9 +20,9 @@ import { Arbiter } from '@arbiter/core';
|
||||
import { DSLRuntime } from '../src/runtime/DSLRuntime.js';
|
||||
|
||||
const BASE_DSL = `
|
||||
definition Employee { id: string level: number active: boolean }
|
||||
definition Group { id: string }
|
||||
definition Doc { id: string }
|
||||
definition Employee { id: string? level: number? active: boolean? }
|
||||
definition Group { id: string? }
|
||||
definition Doc { id: string? }
|
||||
fact member_of(user: Employee, group: Group)
|
||||
fact *owns(user: Employee, doc: Doc)
|
||||
fact *user_score(user: Employee, value: number)
|
||||
@@ -164,8 +164,8 @@ describe('DSLRuntime', () => {
|
||||
|
||||
it('derives transitive required facts through evidence composition', async () => {
|
||||
const dsl = `
|
||||
definition Employee { id: string }
|
||||
definition Doc { id: string }
|
||||
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) }
|
||||
@@ -191,9 +191,9 @@ describe('DSLRuntime', () => {
|
||||
|
||||
it('derives transitive required facts through a condition-step chain', () => {
|
||||
const dsl = `
|
||||
definition Employee { id: string }
|
||||
definition Group { id: string }
|
||||
definition Doc { id: string }
|
||||
definition Employee { id: string? }
|
||||
definition Group { id: string? }
|
||||
definition Doc { id: string? }
|
||||
fact *member_of(user: Employee, group: Group)
|
||||
fact *can_view(group: Group, doc: Doc)
|
||||
fact *banned(group: Group)
|
||||
|
||||
@@ -14,8 +14,8 @@ import { Arbiter } from '@arbiter/core';
|
||||
import { DSLRuntime } from '../src/runtime/DSLRuntime.js';
|
||||
|
||||
const BASE_DSL = `
|
||||
definition Employee { id: string }
|
||||
definition Doc { id: string }
|
||||
definition Employee { id: string? }
|
||||
definition Doc { id: string? }
|
||||
fact *owns(user: Employee, doc: Doc)
|
||||
evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }
|
||||
`;
|
||||
@@ -102,8 +102,8 @@ describe('DSLRuntime provider-result caching', () => {
|
||||
|
||||
it('invalidateProviderCache() clears all or per relation', async () => {
|
||||
const dsl = `
|
||||
definition Employee { id: string }
|
||||
definition Doc { id: string }
|
||||
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) }
|
||||
@@ -151,8 +151,8 @@ describe('DSLRuntime provider-result caching', () => {
|
||||
|
||||
it('uses the DSL-declared fact TTL (BEHAVES { ttl X })', async () => {
|
||||
const dsl = `
|
||||
definition Employee { id: string }
|
||||
definition Doc { id: string }
|
||||
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) }
|
||||
`;
|
||||
|
||||
@@ -14,8 +14,8 @@ 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 }
|
||||
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) }
|
||||
@@ -73,8 +73,8 @@ describe('DSLRuntime extended', () => {
|
||||
// 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 }
|
||||
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) }
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* tests/DSLRuntimeTyping.test.js — duration seconds, required fields, and
|
||||
* type validation of insertions / updates / provider retrievals.
|
||||
*
|
||||
* - Duration literals now accept s/m/h/d/w: `BEHAVES { ttl 30s }` is 30s.
|
||||
* - Definition fields are REQUIRED by default (`field: type`); `field: type?`
|
||||
* marks a field optional. addNode enforces presence on insert.
|
||||
* - Provider-returned edges are validated against the fact's declared typing:
|
||||
* a value-carrying fact must return { value, possibility } with a value of
|
||||
* the declared type, and possibilities must lie in [0, 1].
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Arbiter } from '@arbiter/core';
|
||||
import { DSLRuntime } from '../src/runtime/DSLRuntime.js';
|
||||
|
||||
describe('DSLRuntime typing', () => {
|
||||
it('accepts seconds/minutes/hours/days/weeks in duration literals', async () => {
|
||||
const dsl = `
|
||||
definition Employee { id: string? }
|
||||
definition Doc { id: string? }
|
||||
fact *a(user: Employee, amount: number) BEHAVES { ttl 30s }
|
||||
fact *b(user: Employee, amount: number) BEHAVES { ttl 2m }
|
||||
fact *c(user: Employee, amount: number) BEHAVES { ttl 1h }
|
||||
fact *d(user: Employee, amount: number) BEHAVES { ttl 3d }
|
||||
fact *e(user: Employee, amount: number) BEHAVES { ttl 1w }
|
||||
`;
|
||||
const rt = new DSLRuntime(new Arbiter()).compile(dsl, 'rt-units');
|
||||
assert.equal(rt.relations.get('a').ttlMs, 30_000);
|
||||
assert.equal(rt.relations.get('b').ttlMs, 120_000);
|
||||
assert.equal(rt.relations.get('c').ttlMs, 3_600_000);
|
||||
assert.equal(rt.relations.get('d').ttlMs, 259_200_000);
|
||||
assert.equal(rt.relations.get('e').ttlMs, 604_800_000);
|
||||
});
|
||||
|
||||
it('enforces required definition fields on node insert', () => {
|
||||
const rt = new DSLRuntime(new Arbiter()).compile(`
|
||||
definition Employee { id: string level: number active: boolean? }
|
||||
`, 'rt-req');
|
||||
// id and level are required (no `?`); active is optional.
|
||||
assert.throws(() => rt.addNode('u:1', 'Employee', { level: 3 }), /missing required field 'Employee.id'/);
|
||||
assert.throws(() => rt.addNode('u:2', 'Employee', { id: 'u:2' }), /missing required field 'Employee.level'/);
|
||||
rt.addNode('u:3', 'Employee', { id: 'u:3', level: 5 }); // both required, no active -> ok
|
||||
rt.addNode('u:4', 'Employee', { id: 'u:4', level: 5, active: true });
|
||||
});
|
||||
|
||||
it('exposes requiredness in the schema snapshot', () => {
|
||||
const rt = new DSLRuntime(new Arbiter()).compile(`
|
||||
definition Employee { id: string level: number? }
|
||||
`, 'rt-schema-req');
|
||||
const employee = rt.getSchema().types.find(t => t.name === 'Employee');
|
||||
assert.equal(employee.fields.find(f => f.name === 'id').required, true);
|
||||
assert.equal(employee.fields.find(f => f.name === 'level').required, false);
|
||||
});
|
||||
|
||||
it('validates a provider-returned value against the declared value type', async () => {
|
||||
const rt = new DSLRuntime(new Arbiter()).compile(`
|
||||
definition Employee { id: string? }
|
||||
definition Doc { id: string? }
|
||||
fact *balance(user: Employee, amount: number)
|
||||
evidence can_spend(user: Employee, doc: Doc) { balance(user, 1) }
|
||||
`, 'rt-valuetype');
|
||||
rt.addNode('u:1', 'Employee', {});
|
||||
rt.addNode('doc:9', 'Doc', {});
|
||||
rt.registerFact('balance', async () => ({ possibility: 1.0, value: 'high' }));
|
||||
await assert.rejects(() => rt.check('u:1', 'can_spend', 'doc:9'), /must be number/);
|
||||
});
|
||||
|
||||
it('requires a value for a value-carrying fact (no bare-number shorthand)', async () => {
|
||||
const rt = new DSLRuntime(new Arbiter()).compile(`
|
||||
definition Employee { id: string? }
|
||||
definition Doc { id: string? }
|
||||
fact *balance(user: Employee, amount: number)
|
||||
evidence can_spend(user: Employee, doc: Doc) { balance(user, 1) }
|
||||
`, 'rt-valshape');
|
||||
rt.addNode('u:1', 'Employee', {});
|
||||
rt.addNode('doc:9', 'Doc', {});
|
||||
rt.registerFact('balance', async () => 0.9);
|
||||
await assert.rejects(() => rt.check('u:1', 'can_spend', 'doc:9'), /value-carrying fact/);
|
||||
rt.registerFact('balance', async () => ({ possibility: 1.0 })); // missing value
|
||||
await assert.rejects(() => rt.check('u:1', 'can_spend', 'doc:9'), /must supply a 'value'/);
|
||||
});
|
||||
|
||||
it('rejects a provider-returned possibility outside [0, 1]', async () => {
|
||||
const rt = new DSLRuntime(new Arbiter()).compile(`
|
||||
definition Employee { id: string? }
|
||||
definition Doc { id: string? }
|
||||
fact *owns(user: Employee, doc: Doc)
|
||||
evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }
|
||||
`, 'rt-poss');
|
||||
rt.addNode('u:1', 'Employee', {});
|
||||
rt.addNode('doc:9', 'Doc', {});
|
||||
rt.registerFact('owns', async () => ({ possibility: 2.0 }));
|
||||
await assert.rejects(() => rt.check('u:1', 'can_read', 'doc:9'), /invalid possibility/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user