feat: duration seconds; required fields; type-validated insertions/updates/retrievals
CI / publish (push) Successful in 9s
CI / test (push) Successful in 19s

- 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:
John Dvorak
2026-08-03 16:00:49 -07:00
parent aa38fbfd8c
commit 9111c4b20d
8 changed files with 856 additions and 707 deletions
+5 -2
View File
@@ -54,11 +54,14 @@ Definition "A type definition"
}
Field
= name:Identifier _ ":" _ fieldType:Type _ isArray:("[]")? _ behavior:Behavior? _ cache:CacheDirective? {
= name:Identifier _ ":" _ fieldType:Type optional:("?")? _ isArray:("[]")? _ behavior:Behavior? _ cache:CacheDirective? {
return {
type: "Field",
name,
fieldType,
// `field: type` is REQUIRED on node insert; `field: type?` is optional.
// Presence is enforced by the DSLRuntime when a node is created.
required: !optional,
isArray: !!isArray,
behavior: behavior || null,
cache: cache || null
@@ -392,7 +395,7 @@ Boolean "A boolean literal"
= value:("true" / "false") { return { type: "Literal", value: value === "true" }; }
Duration "A time duration literal"
= value:([0-9]+ ("h" / "d" / "w" / "m")) { return { type: "Literal", value: text(), unit: text().slice(-1) }; }
= value:([0-9]+ ("s" / "m" / "h" / "d" / "w")) { return { type: "Literal", value: text(), unit: text().slice(-1) }; }
// -- Core Tokens & Whitespace --
File diff suppressed because it is too large Load Diff
+52 -17
View File
@@ -103,7 +103,8 @@ export class DSLRuntime {
fields: [...fields.entries()].map(([fieldName, f]) => ({
name: fieldName,
type: f.type,
isArray: f.isArray
isArray: f.isArray,
required: f.required !== false
}))
}));
const facts = [...this.relations.entries()]
@@ -132,7 +133,7 @@ export class DSLRuntime {
for (const def of this.program.definitions || []) {
const fields = new Map();
for (const field of def.fields || []) {
fields.set(field.name, { type: field.fieldType, isArray: !!field.isArray });
fields.set(field.name, { type: field.fieldType, isArray: !!field.isArray, required: field.required !== false });
}
this.types.set(def.name, { fields });
}
@@ -302,7 +303,7 @@ export class DSLRuntime {
const b = behavior.behavior || behavior;
if (b && b.behaviorType === 'ttl' && b.duration) {
const n = parseInt(String(b.duration.value), 10);
const mult = { h: 3600_000, d: 86_400_000, w: 604_800_000, m: 60_000 }[b.duration.unit];
const mult = { s: 1000, m: 60_000, h: 3600_000, d: 86_400_000, w: 604_800_000 }[b.duration.unit];
if (!Number.isNaN(n) && mult) return n * mult;
}
return null;
@@ -313,30 +314,59 @@ export class DSLRuntime {
* array of edge objects) into an array of partial-graph edge objects. The
* destination follows the DSL fact's declared shape: unary and value-carrying
* facts are self-edges on the subject; binary entity facts go subject → object.
* Provider-returned edges are validated against the fact's declared typing:
* a value-carrying fact must return an object with a value of the declared
* type, and possibilities must be in [0, 1]. A violation throws — it is a
* provider-authoring error, not a denial.
*/
_normalizeProviderEdges(result, factMeta, user, object) {
_normalizeProviderEdges(result, factMeta, fact, user, object) {
const edges = Array.isArray(result) ? result : [result];
const secondParamType = factMeta.params[1] && factMeta.params[1].type;
const defaultDst = factMeta.params.length >= 2 && this._isValueType(secondParamType)
? user
: (factMeta.params.length >= 2 ? object : user);
const isValueFact = factMeta.params.length >= 2 && this._isValueType(secondParamType);
const defaultDst = isValueFact ? user : (factMeta.params.length >= 2 ? object : user);
const label = `provider for '${fact}'`;
const out = [];
for (const edge of edges) {
const normalized = typeof edge === 'boolean' || typeof edge === 'number'
? { src: user, dst: defaultDst, possibility: edge === true ? 1 : edge }
: {
...(edge.relation ? { relation: edge.relation } : {}),
src: edge.src ?? user,
dst: edge.dst ?? defaultDst,
possibility: edge.possibility ?? 1,
...(edge.value !== undefined ? { value: edge.value } : {}),
...(edge.reliability !== undefined ? { reliability: edge.reliability } : {})
};
? (() => {
if (isValueFact) {
throw new Error(`DSLRuntime: ${label} is a value-carrying fact — return { value, possibility } (got a bare ${typeof edge === 'number' ? 'number' : 'boolean'})`);
}
const possibility = edge === true ? 1 : edge;
this._checkPossibility(possibility, label);
return { src: user, dst: defaultDst, possibility };
})()
: (() => {
const possibility = edge.possibility ?? 1;
this._checkPossibility(possibility, label);
if (edge.value !== undefined) {
if (!isValueFact) {
throw new Error(`DSLRuntime: ${label} returned a value for a non-value fact '${fact}'`);
}
this._checkScalarValue(secondParamType, edge.value, `${label}.value`);
} else if (isValueFact) {
throw new Error(`DSLRuntime: ${label} must supply a 'value' of type ${secondParamType}`);
}
return {
...(edge.relation ? { relation: edge.relation } : {}),
src: edge.src ?? user,
dst: edge.dst ?? defaultDst,
possibility,
...(edge.value !== undefined ? { value: edge.value } : {}),
...(edge.reliability !== undefined ? { reliability: edge.reliability } : {})
};
})();
out.push(normalized);
}
return out;
}
_checkPossibility(possibility, label) {
if (typeof possibility !== 'number' || !Number.isFinite(possibility) || possibility < 0 || possibility > 1) {
throw new Error(`DSLRuntime: ${label} returned invalid possibility ${possibility} (expected a number in [0, 1])`);
}
}
// ---------------------------------------------------------------------------
// Schema validation helpers
// ---------------------------------------------------------------------------
@@ -405,6 +435,11 @@ export class DSLRuntime {
if (this.types.has(typeName)) {
const { fields } = this.types.get(typeName);
for (const [name, field] of fields) {
// Required fields must be present on insert (`field: type` in the DSL;
// `field?: type` marks a field optional).
if (field.required && data[name] === undefined) {
throw new Error(`DSLRuntime: missing required field '${typeName}.${name}' on node insert`);
}
if (data[name] !== undefined) this._checkFieldValue(field, data[name], `${typeName}.${name}`);
}
} else if (this.strictTypes) {
@@ -634,7 +669,7 @@ export class DSLRuntime {
satisfied.add(fact);
continue;
}
edges = this._normalizeProviderEdges(result, factMeta, user, object);
edges = this._normalizeProviderEdges(result, factMeta, fact, user, object);
if (cachingEnabled && !isPerCheckOverride) this._providerCacheSet(fact, user, object, edges);
} else {
missingFacts.push({ relation: fact, reason: 'no_provider' });