@cruba/crubal 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -2
- package/dist/bin/crubal.js +70 -4
- package/dist/bin/crubal.js.map +1 -1
- package/dist/src/access.d.ts +126 -0
- package/dist/src/access.js +517 -0
- package/dist/src/access.js.map +1 -0
- package/dist/src/arrival.d.ts +55 -0
- package/dist/src/arrival.js +124 -0
- package/dist/src/arrival.js.map +1 -0
- package/dist/src/bundle.d.ts +66 -0
- package/dist/src/bundle.js +753 -0
- package/dist/src/bundle.js.map +1 -0
- package/dist/src/conditions.d.ts +7 -1
- package/dist/src/conditions.js +52 -11
- package/dist/src/conditions.js.map +1 -1
- package/dist/src/corpus.js +568 -34
- package/dist/src/corpus.js.map +1 -1
- package/dist/src/defs.d.ts +37 -2
- package/dist/src/defs.js +95 -14
- package/dist/src/defs.js.map +1 -1
- package/dist/src/dictionary.d.ts +15 -0
- package/dist/src/dictionary.js +72 -13
- package/dist/src/dictionary.js.map +1 -1
- package/dist/src/exchange.d.ts +202 -11
- package/dist/src/exchange.js +759 -45
- package/dist/src/exchange.js.map +1 -1
- package/dist/src/forms.d.ts +16 -0
- package/dist/src/forms.js +169 -0
- package/dist/src/forms.js.map +1 -0
- package/dist/src/index.d.ts +18 -6
- package/dist/src/index.js +17 -5
- package/dist/src/index.js.map +1 -1
- package/dist/src/lifecycle.d.ts +84 -0
- package/dist/src/lifecycle.js +415 -0
- package/dist/src/lifecycle.js.map +1 -0
- package/dist/src/log.d.ts +78 -0
- package/dist/src/log.js +506 -0
- package/dist/src/log.js.map +1 -0
- package/dist/src/migrate.d.ts +24 -1
- package/dist/src/migrate.js +104 -9
- package/dist/src/migrate.js.map +1 -1
- package/dist/src/model.d.ts +33 -1
- package/dist/src/model.js +158 -2
- package/dist/src/model.js.map +1 -1
- package/dist/src/packages.d.ts +44 -0
- package/dist/src/packages.js +137 -3
- package/dist/src/packages.js.map +1 -1
- package/dist/src/print-text.js +22 -0
- package/dist/src/print-text.js.map +1 -1
- package/dist/src/publish.d.ts +27 -0
- package/dist/src/publish.js +275 -0
- package/dist/src/publish.js.map +1 -0
- package/dist/src/read-text.d.ts +2 -0
- package/dist/src/read-text.js +72 -4
- package/dist/src/read-text.js.map +1 -1
- package/dist/src/records.d.ts +31 -0
- package/dist/src/records.js +72 -0
- package/dist/src/records.js.map +1 -0
- package/dist/src/review.d.ts +22 -0
- package/dist/src/review.js +198 -0
- package/dist/src/review.js.map +1 -0
- package/dist/src/rules.d.ts +34 -2
- package/dist/src/rules.js +147 -22
- package/dist/src/rules.js.map +1 -1
- package/dist/src/schedule.d.ts +24 -0
- package/dist/src/schedule.js +126 -0
- package/dist/src/schedule.js.map +1 -0
- package/dist/src/std-packages.d.ts +9 -0
- package/dist/src/std-packages.js +268 -0
- package/dist/src/std-packages.js.map +1 -0
- package/dist/src/store.d.ts +38 -7
- package/dist/src/store.js +166 -24
- package/dist/src/store.js.map +1 -1
- package/dist/src/tree.d.ts +3 -0
- package/dist/src/tree.js +36 -0
- package/dist/src/tree.js.map +1 -1
- package/dist/src/units-table.d.ts +1 -0
- package/dist/src/units-table.js +200 -0
- package/dist/src/units-table.js.map +1 -0
- package/dist/src/units.d.ts +42 -4
- package/dist/src/units.js +91 -30
- package/dist/src/units.js.map +1 -1
- package/dist/src/views.d.ts +108 -6
- package/dist/src/views.js +389 -64
- package/dist/src/views.js.map +1 -1
- package/package.json +5 -3
package/dist/src/corpus.js
CHANGED
|
@@ -5,17 +5,26 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { canonical } from "./canonical.js";
|
|
7
7
|
import { emptyDictionary, readDefinitions, splitCaseInput } from "./defs.js";
|
|
8
|
-
import { land, matchParts, rawOf, readContracts, receive, replay, reviewByKey, validate, verifyChain } from "./store.js";
|
|
9
|
-
import { checkRecipe, readRecipes, restamp } from "./migrate.js";
|
|
8
|
+
import { land, matchParts, proposeSplit, rawOf, readContracts, receive, replay, reviewByKey, validate, verifyChain } from "./store.js";
|
|
9
|
+
import { checkRecipe, dryRun, readRecipes, restamp } from "./migrate.js";
|
|
10
10
|
import { checkContracts, Exchange, readRoles, verifyBorder } from "./exchange.js";
|
|
11
11
|
import { checkPackages } from "./packages.js";
|
|
12
12
|
import { checkViews, readViews, Views } from "./views.js";
|
|
13
|
+
import { formOf } from "./forms.js";
|
|
14
|
+
import { Journal, rebuild, redactable, redactableWrite, stateJson } from "./log.js";
|
|
15
|
+
import { decide as decideReview } from "./review.js";
|
|
16
|
+
import { resetUnits } from "./units.js";
|
|
17
|
+
import { arrive, decide } from "./arrival.js";
|
|
18
|
+
import { adopt, apply as applyInstructions, archive, attach, clone, detach, emptyStore, giveBack, issue, merge, split, unarchive } from "./lifecycle.js";
|
|
19
|
+
import { Access, checkPermissions, readAccess } from "./access.js";
|
|
20
|
+
import { readPiece } from "./lex.js";
|
|
13
21
|
import { readUnder } from "./read-text.js";
|
|
14
22
|
import { readTree } from "./tree.js";
|
|
23
|
+
import { MemoryRecords } from "./records.js";
|
|
15
24
|
import { checkRules, Engine, readRules } from "./rules.js";
|
|
16
25
|
import { checkPackage, checkRecord, facetJson, foldRecord, resolve, resolvedJson } from "./dictionary.js";
|
|
17
26
|
import { serialize } from "./canonical.js";
|
|
18
|
-
import { checkWrapper } from "./model.js";
|
|
27
|
+
import { checkWrapper, parseJson } from "./model.js";
|
|
19
28
|
import { printText } from "./print-text.js";
|
|
20
29
|
import { readText } from "./read-text.js";
|
|
21
30
|
import { CrubalError } from "./tree.js";
|
|
@@ -26,11 +35,11 @@ const LEVEL_FOLDERS = {
|
|
|
26
35
|
store: ["reader", "reader-errors", "dictionary", "dictionary-errors", "rules", "rules-errors", "store", "store-errors"],
|
|
27
36
|
exchange: [
|
|
28
37
|
"reader", "reader-errors", "dictionary", "dictionary-errors", "rules", "rules-errors", "store", "store-errors",
|
|
29
|
-
"exchange", "exchange-errors", "package-errors",
|
|
38
|
+
"exchange", "exchange-errors", "package-errors", "forms",
|
|
30
39
|
],
|
|
31
40
|
views: [
|
|
32
41
|
"reader", "reader-errors", "dictionary", "dictionary-errors", "rules", "rules-errors", "store", "store-errors",
|
|
33
|
-
"exchange", "exchange-errors", "package-errors", "views", "views-errors",
|
|
42
|
+
"exchange", "exchange-errors", "package-errors", "forms", "views", "views-errors",
|
|
34
43
|
],
|
|
35
44
|
};
|
|
36
45
|
/** The shared definitions a folder's cases run under: the reader package, then the folder's own files. */
|
|
@@ -49,7 +58,7 @@ function loadDictionary(root, folder) {
|
|
|
49
58
|
return dict;
|
|
50
59
|
}
|
|
51
60
|
function copy(d) {
|
|
52
|
-
return { facets: new Map(d.facets), classes: new Map(d.classes), levels: new Map(d.levels), vary: new Map(d.vary), trust: [...d.trust], facetTrust: new Map(d.facetTrust) };
|
|
61
|
+
return { facets: new Map(d.facets), classes: new Map(d.classes), kinds: new Map(d.kinds), levels: new Map(d.levels), vary: new Map(d.vary), varyLines: new Map(d.varyLines), trust: [...d.trust], facetTrust: new Map(d.facetTrust), identify: new Map(d.identify), match: new Map(d.match), archive: new Map(d.archive) };
|
|
53
62
|
}
|
|
54
63
|
/** Each expected entry must match an actual one on every field it states; entry sets must be the same. */
|
|
55
64
|
function compareOutcomes(actual, expected) {
|
|
@@ -89,7 +98,7 @@ function runDictionaryCase(folder, caseDir, name, shared) {
|
|
|
89
98
|
}
|
|
90
99
|
}
|
|
91
100
|
if (existsSync(longFile)) {
|
|
92
|
-
const expected = serialize(
|
|
101
|
+
const expected = serialize(parseJson(readFileSync(join(caseDir, "expected.json"), "utf8")));
|
|
93
102
|
for (const [label, text] of [["shorthand", input], ["long form", readFileSync(longFile, "utf8")]]) {
|
|
94
103
|
const d = readDefinitions(text);
|
|
95
104
|
checkPackage(d);
|
|
@@ -106,7 +115,7 @@ function runDictionaryCase(folder, caseDir, name, shared) {
|
|
|
106
115
|
checkPackage(dict);
|
|
107
116
|
const resolveLine = defs.match(/^resolve: (\S+)/m);
|
|
108
117
|
if (resolveLine) {
|
|
109
|
-
const expected =
|
|
118
|
+
const expected = parseJson(readFileSync(join(caseDir, "expected.json"), "utf8"));
|
|
110
119
|
const got = serialize(resolvedJson(resolve(dict, [resolveLine[1]])));
|
|
111
120
|
return got === serialize(expected.resolved) ? { folder, name, pass: true } : { folder, name, pass: false, detail: `got ${got}` };
|
|
112
121
|
}
|
|
@@ -121,8 +130,8 @@ function runDictionaryCase(folder, caseDir, name, shared) {
|
|
|
121
130
|
const problem = compareOutcomes(actual, JSON.parse(readFileSync(outcomesFile, "utf8")));
|
|
122
131
|
return problem ? { folder, name, pass: false, detail: problem } : { folder, name, pass: true };
|
|
123
132
|
}
|
|
124
|
-
const expected =
|
|
125
|
-
checkWrapper(expected);
|
|
133
|
+
const expected = parseJson(readFileSync(join(caseDir, "expected.json"), "utf8"));
|
|
134
|
+
checkWrapper(expected, dict);
|
|
126
135
|
const want = canonical(expected, dict);
|
|
127
136
|
const got = canonical(w, dict);
|
|
128
137
|
if (got !== want)
|
|
@@ -161,8 +170,8 @@ function runCase(folder, dir, name, dict) {
|
|
|
161
170
|
}
|
|
162
171
|
}
|
|
163
172
|
try {
|
|
164
|
-
const expected =
|
|
165
|
-
checkWrapper(expected);
|
|
173
|
+
const expected = parseJson(readFileSync(join(caseDir, "expected.json"), "utf8"));
|
|
174
|
+
checkWrapper(expected, dict);
|
|
166
175
|
const want = canonical(expected, dict);
|
|
167
176
|
const got = readText(input, dict);
|
|
168
177
|
const gotC = canonical(got, dict);
|
|
@@ -225,6 +234,9 @@ function runRulesCase(folder, caseDir, name, shared, sharedRules) {
|
|
|
225
234
|
checkPackage(dict);
|
|
226
235
|
checkRules(rules, dict);
|
|
227
236
|
const engine = new Engine(dict, rules);
|
|
237
|
+
const nowFile = join(caseDir, "now.txt");
|
|
238
|
+
if (existsSync(nowFile))
|
|
239
|
+
engine.now = readFileSync(nowFile, "utf8").trim();
|
|
228
240
|
const altFile = join(caseDir, "alternatives.json");
|
|
229
241
|
const alts = existsSync(altFile) ? JSON.parse(readFileSync(altFile, "utf8")) : {};
|
|
230
242
|
const last = new Map();
|
|
@@ -256,6 +268,7 @@ function subset(actual, expected, label = "") {
|
|
|
256
268
|
return null;
|
|
257
269
|
}
|
|
258
270
|
function runStoreCase(folder, caseDir, name, shared) {
|
|
271
|
+
resetUnits();
|
|
259
272
|
const file = (f) => join(caseDir, f);
|
|
260
273
|
const json = (f) => JSON.parse(readFileSync(file(f), "utf8"));
|
|
261
274
|
const pass = { folder, name, pass: true };
|
|
@@ -275,6 +288,31 @@ function runStoreCase(folder, caseDir, name, shared) {
|
|
|
275
288
|
return e.line === want ? pass : fail(`refused at line ${e.line} (${e.message}), expected line ${want}`);
|
|
276
289
|
}
|
|
277
290
|
}
|
|
291
|
+
// Redaction (8.5.2): per entry, whether it may go whole, else which of its writes may, and why not.
|
|
292
|
+
if (existsSync(file("expected-redact.json"))) {
|
|
293
|
+
const entries = json("log.json");
|
|
294
|
+
const got = {};
|
|
295
|
+
for (const e of entries) {
|
|
296
|
+
const whole = redactable(e);
|
|
297
|
+
const writes = Array.isArray(e.writes) ? e.writes.filter((w) => typeof w === "object" && w !== null && !Array.isArray(w)) : [];
|
|
298
|
+
got[String(e.log)] = whole === null ? "redactable" : e.witnessed === true ? whole : writes.map((w) => redactableWrite(e, w) ?? "redactable");
|
|
299
|
+
}
|
|
300
|
+
const want = json("expected-redact.json");
|
|
301
|
+
for (const [k, v] of Object.entries(want))
|
|
302
|
+
if (serialize((got[k] ?? "∅")) !== serialize(v))
|
|
303
|
+
return fail(`${k}: got ${serialize((got[k] ?? "∅"))}`);
|
|
304
|
+
return pass;
|
|
305
|
+
}
|
|
306
|
+
// Rebuild (8.5.3): the log alone gives back the state.
|
|
307
|
+
if (existsSync(file("expected-state.json"))) {
|
|
308
|
+
const got = stateJson(rebuild(json("log.json")));
|
|
309
|
+
const want = json("expected-state.json");
|
|
310
|
+
for (const [k, v] of Object.entries(want)) {
|
|
311
|
+
if (serialize((got[k] ?? "∅")) !== serialize(v))
|
|
312
|
+
return fail(`${k}: ${firstDiff(serialize((got[k] ?? "∅")), serialize(v))}`);
|
|
313
|
+
}
|
|
314
|
+
return pass;
|
|
315
|
+
}
|
|
278
316
|
if (existsSync(file("log.json"))) {
|
|
279
317
|
const keys = existsSync(file("keys.json")) ? json("keys.json") : undefined;
|
|
280
318
|
const got = verifyChain(json("log.json"), keys);
|
|
@@ -292,11 +330,210 @@ function runStoreCase(folder, caseDir, name, shared) {
|
|
|
292
330
|
readDefinitions(defs, dict);
|
|
293
331
|
const records = readText(data, dict).records.map((r) => foldRecord(r, dict));
|
|
294
332
|
const one = (r) => canonical({ crubal: "2", records: [r] }, dict);
|
|
333
|
+
// Arrival (9.2a): a sequence of payloads through one contract, each finding or making its record.
|
|
334
|
+
if (existsSync(file("answers.json"))) {
|
|
335
|
+
const text = readFileSync(file("contract.crubal"), "utf8");
|
|
336
|
+
readDefinitions(text, dict);
|
|
337
|
+
const [contract] = readContracts(text);
|
|
338
|
+
if (!contract)
|
|
339
|
+
return fail("no contract");
|
|
340
|
+
// Every arrival is an operation on the journal's draft, logged as entries (8.5).
|
|
341
|
+
const journal = new Journal("test.example", records, "2026-10-15T00:00:00Z");
|
|
342
|
+
const store = journal.draft;
|
|
343
|
+
if (existsSync(file("bindings.json")))
|
|
344
|
+
journal.commit({ migration: "backfill" }, (d) => { for (const [k, v] of Object.entries(json("bindings.json")))
|
|
345
|
+
d.bindings.set(k, String(v)); }, "2026-10-15T00:00:01Z");
|
|
346
|
+
const answersFile = json("answers.json");
|
|
347
|
+
const level = isPlainObject(answersFile) ? String(answersFile.level) : "product";
|
|
348
|
+
const answers = (isPlainObject(answersFile) ? answersFile.answers : answersFile);
|
|
349
|
+
let n = 0;
|
|
350
|
+
const mint = () => `${level.charAt(0).toUpperCase()}-NEW${++n}`;
|
|
351
|
+
const owner = records[0]?.owner ?? "test.example";
|
|
352
|
+
const got = [];
|
|
353
|
+
let held = null;
|
|
354
|
+
answers.forEach((a, i) => {
|
|
355
|
+
const at = `2026-10-15T00:01:${String(i).padStart(2, "0")}Z`;
|
|
356
|
+
if (isPlainObject(a) && isPlainObject(a.decide)) {
|
|
357
|
+
if (!held)
|
|
358
|
+
throw new Error("a decision with nothing held");
|
|
359
|
+
const choice = a.decide.new === true ? { new: true } : { record: String(a.decide.record) };
|
|
360
|
+
const h = held;
|
|
361
|
+
journal.commit({ person: "reviewer" }, (d) => {
|
|
362
|
+
got.push(decide(contract, h, choice, d, dict, { mint, owner, level }).arrival);
|
|
363
|
+
}, at);
|
|
364
|
+
held = null;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
journal.commit({ contract: contract.name, run: `R-${i + 1}` }, (d) => {
|
|
368
|
+
const r = arrive(contract, JSON.stringify(a), level, d, dict, { mint, owner, intakeId: `I-${i + 1}` });
|
|
369
|
+
if (r.arrival.outcome === "held")
|
|
370
|
+
held = { intake: r.intake, ...(r.arrival.ref !== undefined ? { ref: r.arrival.ref } : {}) };
|
|
371
|
+
got.push(r.arrival);
|
|
372
|
+
}, at);
|
|
373
|
+
});
|
|
374
|
+
const want = json("expected-arrival.json");
|
|
375
|
+
if (got.length !== want.length)
|
|
376
|
+
return fail(`${got.length} arrivals, expected ${want.length}: ${serialize(got)}`);
|
|
377
|
+
for (let i = 0; i < want.length; i++) {
|
|
378
|
+
const p = subset(got[i], want[i], `arrival ${i + 1}: `);
|
|
379
|
+
if (p)
|
|
380
|
+
return fail(p);
|
|
381
|
+
}
|
|
382
|
+
if (existsSync(file("expected.json"))) {
|
|
383
|
+
const want2 = json("expected.json");
|
|
384
|
+
for (const w of want2.records) {
|
|
385
|
+
const r = store.records.get(w.id);
|
|
386
|
+
if (!r)
|
|
387
|
+
return fail(`no record ${w.id}`);
|
|
388
|
+
if (one(r) !== one(w))
|
|
389
|
+
return fail(`${w.id}: ${firstDiff(one(r), one(w))}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return pass;
|
|
393
|
+
}
|
|
394
|
+
// Lifecycle (1.7, 1.12) and instructions (10.7): operations in order, then the records.
|
|
395
|
+
if (existsSync(file("ops.crubal"))) {
|
|
396
|
+
// Every operation runs on the journal's draft and is logged; a change the entries don't describe
|
|
397
|
+
// is refused by the journal itself (8.5).
|
|
398
|
+
const journal = new Journal("test.example", records, "2026-10-15T00:00:00Z");
|
|
399
|
+
const store = journal.draft;
|
|
400
|
+
if (existsSync(file("bindings.json")))
|
|
401
|
+
journal.commit({ migration: "backfill" }, (d) => { for (const [k, v] of Object.entries(json("bindings.json")))
|
|
402
|
+
d.bindings.set(k, String(v)); }, "2026-10-15T00:00:01Z");
|
|
403
|
+
const caseRules = readRules(defs);
|
|
404
|
+
const decisionDefs = { dict, contracts: new Map(readContracts(defs).map((c) => [c.name, c])) };
|
|
405
|
+
let adoptFlag;
|
|
406
|
+
let n = 0;
|
|
407
|
+
for (const op of readTree(readFileSync(file("ops.crubal"), "utf8"))) {
|
|
408
|
+
if (op.kind !== "pair" || !op.key)
|
|
409
|
+
continue;
|
|
410
|
+
const piece = readPiece(op.value, op.line);
|
|
411
|
+
const detail = (k) => piece.detail.find(([n]) => n === k)?.[1].text;
|
|
412
|
+
const [a = "", b] = piece.text.split(" -> ").map((s) => s.trim());
|
|
413
|
+
const key = op.key;
|
|
414
|
+
let problem = null;
|
|
415
|
+
const extra = [];
|
|
416
|
+
journal.commit({ person: "tester" }, () => {
|
|
417
|
+
if (key === "archive")
|
|
418
|
+
archive(store, a, detail("reason") ?? "archived", dict, detail("into"));
|
|
419
|
+
else if (key === "unarchive")
|
|
420
|
+
unarchive(store, a, dict);
|
|
421
|
+
else if (key === "merge" && b)
|
|
422
|
+
merge(store, a, b, dict);
|
|
423
|
+
else if (key === "attach" && b)
|
|
424
|
+
attach(store, a, b, dict, detail("edit") !== "no");
|
|
425
|
+
else if (key === "detach")
|
|
426
|
+
detach(store, a, dict);
|
|
427
|
+
else if (key === "instruct")
|
|
428
|
+
issue(store, piece.text, op.children);
|
|
429
|
+
else if (key === "apply")
|
|
430
|
+
applyInstructions(store, a, dict);
|
|
431
|
+
else if (key === "adopt" && b)
|
|
432
|
+
adoptFlag = adopt(store, a, b, caseRules).flagged ?? adoptFlag;
|
|
433
|
+
else if (key === "give-back" && b)
|
|
434
|
+
giveBack(store, a, b);
|
|
435
|
+
else if (key === "clone" && b)
|
|
436
|
+
clone(store, a, b, detail("owner") ?? "test.example");
|
|
437
|
+
else if (key === "split" && b) {
|
|
438
|
+
const list = (k) => (op.children.find((c) => c.key === k)?.value ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
439
|
+
const vals = {};
|
|
440
|
+
for (const c of op.children.find((c) => c.key === "values")?.children ?? [])
|
|
441
|
+
if (c.key)
|
|
442
|
+
vals[c.key] = dict.facets.get(c.key) ? (readUnder(dict.facets.get(c.key), c.value, dict) ?? c.value) : c.value;
|
|
443
|
+
split(store, a, b, { children: list("children"), values: vals, bindings: list("bindings") });
|
|
444
|
+
}
|
|
445
|
+
else if (key === "suggest") {
|
|
446
|
+
// Test scaffolding: a suggestion on a record, as a gated or lands: suggestion run would leave it.
|
|
447
|
+
const r = store.records.get(a);
|
|
448
|
+
const address = op.children.find((c) => c.key === "address")?.value ?? "";
|
|
449
|
+
const f = dict.facets.get(address);
|
|
450
|
+
const raw = op.children.find((c) => c.key === "value")?.value ?? "";
|
|
451
|
+
const value = f ? (readUnder(f, raw, dict) ?? raw) : raw;
|
|
452
|
+
const item = { value, origin: op.children.find((c) => c.key === "origin")?.value ?? "reported", via: op.children.find((c) => c.key === "via")?.value ?? "L-S" };
|
|
453
|
+
store.records.set(a, { ...r, suggestions: { ...(r.suggestions ?? {}), [address]: [...(r.suggestions?.[address] ?? []), item] } });
|
|
454
|
+
}
|
|
455
|
+
else if (key === "hold") {
|
|
456
|
+
// Test scaffolding: a value held by a rule, with its source kept (6.7).
|
|
457
|
+
const r = store.records.get(a);
|
|
458
|
+
const address = op.children.find((c) => c.key === "address")?.value ?? "";
|
|
459
|
+
const v = r.values[address];
|
|
460
|
+
if (v === undefined) {
|
|
461
|
+
problem = `${a} has nothing at ${address} to hold`;
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const { [address]: _v, ...values } = r.values;
|
|
465
|
+
const src = (r.sources?.[address] ?? r.sources?.["*"]);
|
|
466
|
+
store.records.set(a, { ...r, values, held: { ...(r.held ?? {}), [address]: { value: v, rule: op.children.find((c) => c.key === "rule")?.value ?? "rule", ...(src ? { source: src } : {}) } } });
|
|
467
|
+
}
|
|
468
|
+
else if (key === "decide") {
|
|
469
|
+
// A review decision (9.5): the item's key on the line, the decision's fields below it.
|
|
470
|
+
const d = { key: piece.text, decision: "" };
|
|
471
|
+
for (const c of op.children) {
|
|
472
|
+
if (!c.key)
|
|
473
|
+
continue;
|
|
474
|
+
if (c.key === "instances" || c.key === "accepts")
|
|
475
|
+
d[c.key] = c.value.split(",").map((x) => x.trim()).filter(Boolean);
|
|
476
|
+
else if (c.key === "value" && typeof d.address === "string" && dict.facets.get(d.address))
|
|
477
|
+
d.value = readUnder(dict.facets.get(d.address), c.value, dict) ?? c.value;
|
|
478
|
+
else
|
|
479
|
+
d[c.key] = c.key === "factor" || c.key === "offset" ? c.value : (dict.facets.get(c.key) ? (readUnder(dict.facets.get(c.key), c.value, dict) ?? c.value) : c.value);
|
|
480
|
+
}
|
|
481
|
+
extra.push(...decideReview(d, store, decisionDefs));
|
|
482
|
+
}
|
|
483
|
+
else if (key === "write") {
|
|
484
|
+
const r = store.records.get(a);
|
|
485
|
+
if (!r) {
|
|
486
|
+
problem = `write to an unknown record ${a}`;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const values = { ...r.values };
|
|
490
|
+
for (const c of op.children) {
|
|
491
|
+
const f = c.key ? dict.facets.get(c.key) : undefined;
|
|
492
|
+
if (c.key)
|
|
493
|
+
values[c.key] = f ? (readUnder(f, c.value, dict) ?? c.value) : c.value;
|
|
494
|
+
}
|
|
495
|
+
store.records.set(a, { ...r, values });
|
|
496
|
+
}
|
|
497
|
+
else
|
|
498
|
+
problem = `unknown op ${key}`;
|
|
499
|
+
}, `2026-10-15T00:01:${String(++n).padStart(2, "0")}Z`, extra);
|
|
500
|
+
if (problem)
|
|
501
|
+
return fail(problem);
|
|
502
|
+
}
|
|
503
|
+
if (existsSync(file("expected-flag.json"))) {
|
|
504
|
+
const want = json("expected-flag.json");
|
|
505
|
+
if (want.adopt !== (adoptFlag ?? null))
|
|
506
|
+
return fail(`adopt flagged ${adoptFlag ?? "nothing"}, expected ${serialize(want.adopt)}`);
|
|
507
|
+
}
|
|
508
|
+
if (existsSync(file("expected-entries.json"))) {
|
|
509
|
+
const got = journal.entries.map(({ at: _at, owner: _o, ...e }) => e);
|
|
510
|
+
const wantE = json("expected-entries.json");
|
|
511
|
+
if (serialize(got) !== serialize(wantE))
|
|
512
|
+
return fail(`entries: ${firstDiff(serialize(got), serialize(wantE))}`);
|
|
513
|
+
}
|
|
514
|
+
const want = json("expected.json");
|
|
515
|
+
for (const w of want.records) {
|
|
516
|
+
const r = store.records.get(w.id);
|
|
517
|
+
if (!r)
|
|
518
|
+
return fail(`no record ${w.id}`);
|
|
519
|
+
if (one(r) !== one(w))
|
|
520
|
+
return fail(`${w.id}: ${firstDiff(one(r), one(w))}`);
|
|
521
|
+
}
|
|
522
|
+
if (want.review !== undefined && serialize(store.review) !== serialize(want.review))
|
|
523
|
+
return fail(`review: got ${serialize(store.review)}`);
|
|
524
|
+
if (want.bindings !== undefined && serialize(Object.fromEntries(store.bindings)) !== serialize(want.bindings))
|
|
525
|
+
return fail(`bindings: got ${serialize(Object.fromEntries(store.bindings))}`);
|
|
526
|
+
return pass;
|
|
527
|
+
}
|
|
295
528
|
if (existsSync(file("recipe.crubal"))) {
|
|
296
529
|
const [recipe] = readRecipes(readFileSync(file("recipe.crubal"), "utf8"));
|
|
297
530
|
if (!recipe)
|
|
298
531
|
return fail("no recipe");
|
|
299
532
|
checkRecipe(recipe, dict);
|
|
533
|
+
if (existsSync(file("expected-dry-run.json"))) {
|
|
534
|
+
const got = dryRun(records, recipe, dict);
|
|
535
|
+
return serialize(got) === serialize(json("expected-dry-run.json")) ? pass : fail(`dry run: ${serialize(got)}`);
|
|
536
|
+
}
|
|
300
537
|
const { records: out, review } = restamp(records, recipe, dict);
|
|
301
538
|
const want = json("expected.json");
|
|
302
539
|
const got = { ...JSON.parse(canonical({ crubal: "2", package: recipe.to, records: out }, dict)), review };
|
|
@@ -374,6 +611,10 @@ function runStoreCase(folder, caseDir, name, shared) {
|
|
|
374
611
|
const want = json("expected.json");
|
|
375
612
|
return serialize(got) === serialize(want) ? pass : fail(`got ${serialize(got)}`);
|
|
376
613
|
}
|
|
614
|
+
if (existsSync(file("observations.json")) && existsSync(file("expected-proposal.json"))) {
|
|
615
|
+
const got = proposeSplit(records, json("observations.json"));
|
|
616
|
+
return serialize(got) === serialize(json("expected-proposal.json")) ? pass : fail(`proposal: ${serialize(got)}`);
|
|
617
|
+
}
|
|
377
618
|
if (existsSync(file("observations.json"))) {
|
|
378
619
|
const want = json("expected-validation.json");
|
|
379
620
|
const rec = records.find((r) => r.id === want.record);
|
|
@@ -405,8 +646,34 @@ function runExchangeCase(folder, caseDir, name, shared, root) {
|
|
|
405
646
|
if (folder === "exchange-errors" || folder === "package-errors") {
|
|
406
647
|
const want = Number(readFileSync(file("error.txt"), "utf8").trim().split(/\s/)[0]);
|
|
407
648
|
try {
|
|
408
|
-
if (folder === "package-errors")
|
|
649
|
+
if (folder === "package-errors") {
|
|
409
650
|
checkPackages(input);
|
|
651
|
+
checkPackage(readDefinitions(input));
|
|
652
|
+
}
|
|
653
|
+
else if (/^(edge|permissions|ownership): /m.test(input)) {
|
|
654
|
+
// Access (14.12, 14.13): the declarations, every record's owner, and every edge, in order.
|
|
655
|
+
const { defs, data } = splitCaseInput(input);
|
|
656
|
+
const d = copy(shared);
|
|
657
|
+
readDefinitions(defs, d);
|
|
658
|
+
const { perms, edges } = readAccess(defs);
|
|
659
|
+
checkPermissions(perms, d, new Set(readContracts(defs).map((c) => c.name)));
|
|
660
|
+
const recs = data.trim() ? readText(data, d).records.map((r) => foldRecord(r, d)) : [];
|
|
661
|
+
const access = new Access(d, perms, new MemoryRecords(recs.map((r) => [r.id, r])), [], "test.example");
|
|
662
|
+
// A record's refusal names the line its header was written at.
|
|
663
|
+
const lineOf = (id) => {
|
|
664
|
+
const i = input.split("\n").findIndex((l) => new RegExp(`^\\w+: ${id}(\\s|$)`).test(l));
|
|
665
|
+
return i < 0 ? undefined : i + 1;
|
|
666
|
+
};
|
|
667
|
+
access.checkWithin(lineOf);
|
|
668
|
+
for (const r of recs) {
|
|
669
|
+
access.checkOwnership(r, lineOf(r.id));
|
|
670
|
+
access.checkWears(r, lineOf(r.id));
|
|
671
|
+
}
|
|
672
|
+
for (const e of edges) {
|
|
673
|
+
access.checkEdge(e);
|
|
674
|
+
access.edges.set(e.edge, e);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
410
677
|
else {
|
|
411
678
|
const d = copy(shared);
|
|
412
679
|
readDefinitions(input, d);
|
|
@@ -431,24 +698,145 @@ function runExchangeCase(folder, caseDir, name, shared, root) {
|
|
|
431
698
|
if (defs.trim())
|
|
432
699
|
readDefinitions(defs, dict);
|
|
433
700
|
checkPackage(dict);
|
|
701
|
+
// Access (chapter 14): edges over the records, then questions.
|
|
702
|
+
if (existsSync(file("ask.crubal"))) {
|
|
703
|
+
const accessText = readFileSync(file("access.crubal"), "utf8");
|
|
704
|
+
readDefinitions(accessText, dict);
|
|
705
|
+
const { perms, edges } = readAccess(accessText);
|
|
706
|
+
checkPermissions(perms, dict, new Set(readContracts(accessText).map((c) => c.name)));
|
|
707
|
+
const recs = readText(data, dict).records.map((r) => foldRecord(r, dict));
|
|
708
|
+
const now = existsSync(file("now.txt")) ? readFileSync(file("now.txt"), "utf8").trim() : undefined;
|
|
709
|
+
const access = new Access(dict, perms, new MemoryRecords(recs.map((r) => [r.id, r])), edges, "test.example", now);
|
|
710
|
+
access.checkWithin();
|
|
711
|
+
const got = [];
|
|
712
|
+
for (const q of readTree(readFileSync(file("ask.crubal"), "utf8"))) {
|
|
713
|
+
if (q.kind !== "pair" || !q.key)
|
|
714
|
+
continue;
|
|
715
|
+
const words = q.value.trim().split(/\s+/);
|
|
716
|
+
const ask = `${q.key}: ${q.value.trim()}`;
|
|
717
|
+
if (q.key === "may") {
|
|
718
|
+
// may: <caller> <verb> <target> on <record>
|
|
719
|
+
const [caller = "", verb = "", target = "", , record = ""] = words;
|
|
720
|
+
got.push({ ask, result: access.may(caller, verb, target, record) });
|
|
721
|
+
}
|
|
722
|
+
else if (q.key === "mode") {
|
|
723
|
+
const [caller = "", , record = ""] = words;
|
|
724
|
+
got.push({ ask, result: access.effective(caller, record).mode });
|
|
725
|
+
}
|
|
726
|
+
else if (q.key === "stands") {
|
|
727
|
+
// stands: <caller> <partition…> on <record>
|
|
728
|
+
const on = words.indexOf("on");
|
|
729
|
+
got.push({ ask, result: access.standsIn(words[0] ?? "", words[on + 1] ?? "", words.slice(1, on).join(" ")) });
|
|
730
|
+
}
|
|
731
|
+
else if (q.key === "gated") {
|
|
732
|
+
const [caller = "", , record = ""] = words;
|
|
733
|
+
got.push({ ask, result: access.gated(caller, record)?.edge ?? false });
|
|
734
|
+
}
|
|
735
|
+
else if (q.key === "ancestors") {
|
|
736
|
+
got.push({ ask, result: access.ancestors(words[0] ?? "") });
|
|
737
|
+
}
|
|
738
|
+
else if (q.key === "audience") {
|
|
739
|
+
got.push({ ask, result: access.audienceOf(words[0] ?? "") });
|
|
740
|
+
}
|
|
741
|
+
else
|
|
742
|
+
return fail(`unknown ask ${q.key}`);
|
|
743
|
+
}
|
|
744
|
+
const want = json("expected-access.json");
|
|
745
|
+
if (got.length !== want.length)
|
|
746
|
+
return fail(`${got.length} answers, expected ${want.length}`);
|
|
747
|
+
for (let i = 0; i < want.length; i++) {
|
|
748
|
+
const p = subset(got[i], want[i], `${want[i].ask}: `);
|
|
749
|
+
if (p)
|
|
750
|
+
return fail(p);
|
|
751
|
+
}
|
|
752
|
+
return pass;
|
|
753
|
+
}
|
|
434
754
|
const shared_ = readFileSync(join(root, "exchange", "_contracts.crubal"), "utf8");
|
|
435
755
|
const contracts = [...readContracts(shared_), ...readContracts(defs)];
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
|
|
756
|
+
// A case that defines views lets its stream contracts (12.6.2) carry their projection streams.
|
|
757
|
+
const ownViews = readViews(defs);
|
|
758
|
+
checkContracts(contracts, dict, "", ownViews.views.size ? new Set(ownViews.views.keys()) : undefined);
|
|
759
|
+
const ex = new Exchange(dict, contracts, readRoles(defs), readRules(defs));
|
|
760
|
+
if (ownViews.views.size) {
|
|
761
|
+
checkViews(ownViews, dict);
|
|
762
|
+
ex.views = new Views(dict, ownViews);
|
|
763
|
+
}
|
|
764
|
+
for (const r of readText(data, dict).records.map((x) => foldRecord(x, dict))) {
|
|
439
765
|
ex.put(r);
|
|
766
|
+
ex.views?.put(r);
|
|
767
|
+
}
|
|
768
|
+
// With access.crubal, the border consults access (chapter 14): permission to run, gates, consumer edges.
|
|
769
|
+
if (existsSync(file("access.crubal"))) {
|
|
770
|
+
const accessText = readFileSync(file("access.crubal"), "utf8");
|
|
771
|
+
const { perms, edges } = readAccess(accessText);
|
|
772
|
+
const now = existsSync(file("now.txt")) ? readFileSync(file("now.txt"), "utf8").trim() : undefined;
|
|
773
|
+
ex.access = new Access(dict, perms, ex.records, edges, "test.example", now);
|
|
774
|
+
}
|
|
775
|
+
// Every run is a transaction (8.5): the border works on a journal's draft and commits each run.
|
|
776
|
+
const journal = new Journal("test.example", [], "2026-01-01T00:00:00Z");
|
|
777
|
+
ex.useJournal(journal);
|
|
778
|
+
journal.commit({ migration: "backfill" }, () => { }, "2026-01-01T00:00:00Z");
|
|
440
779
|
// The runs, in order.
|
|
441
780
|
const results = [];
|
|
442
781
|
let current = {};
|
|
782
|
+
const parseCursor = (s) => (s ? Object.fromEntries(s.split(",").map((p) => p.trim().split("=")).map(([v, n]) => [v ?? "", Number(n)])) : undefined);
|
|
443
783
|
const flush = () => {
|
|
784
|
+
// The send split (docs/design/runs.md): prepare, then acted or failed; undo names its cause.
|
|
785
|
+
if (current.prepare && current.on) {
|
|
786
|
+
results.push(ex.prepare(current.prepare, current.on, current.caller));
|
|
787
|
+
current = {};
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
if (current.acted) {
|
|
791
|
+
results.push(ex.acted(current.acted));
|
|
792
|
+
current = {};
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (current.failed) {
|
|
796
|
+
results.push(ex.failed(current.failed, current.reason ?? "failed"));
|
|
797
|
+
current = {};
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (current.undo) {
|
|
801
|
+
results.push(ex.undo(current.undo, current.caller));
|
|
802
|
+
current = {};
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (current.decideRun) {
|
|
806
|
+
results.push(ex.decideRun(current.decideRun, current.decision === "drop" ? "drop" : "retry", current.caller));
|
|
807
|
+
current = {};
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (current.tick) {
|
|
811
|
+
results.push(ex.tick(current.tick, current.now ?? "", current.caller));
|
|
812
|
+
current = {};
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (current.stream) {
|
|
816
|
+
results.push(ex.streamRun(current.stream, current.caller, parseCursor(current.cursor)));
|
|
817
|
+
current = {};
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (current.ack) {
|
|
821
|
+
results.push(ex.ack(current.ack, parseCursor(current.cursor) ?? {}));
|
|
822
|
+
current = {};
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (current.arrive) {
|
|
826
|
+
results.push(ex.arriveRun(current.arrive, readFileSync(file(current.answer ?? "answer.json"), "utf8"), current.level ?? "product", current.caller, current.on));
|
|
827
|
+
current = {};
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
444
830
|
if (!current.run || !current.on)
|
|
445
831
|
return;
|
|
446
832
|
const c = contracts.find((x) => x.name === current.run);
|
|
447
833
|
if (current.offer) {
|
|
448
|
-
results.push(ex.take(current.run, current.offer, current.on, json("presenter.json")));
|
|
834
|
+
results.push(ex.take(current.run, current.offer, current.on, json(current.presenter ?? "presenter.json")));
|
|
449
835
|
}
|
|
450
|
-
else if (c?.kind === "receive") {
|
|
451
|
-
|
|
836
|
+
else if (c?.kind === "receive" || c?.kind === "form" || current.forRun) {
|
|
837
|
+
// A receive lands its payload; a form is a receive whose source is a person (7.2), its answers the
|
|
838
|
+
// payload. A late answer names its run (7.5): the receive closes it and lands with its via.
|
|
839
|
+
results.push(ex.receiveRun(current.run, current.on, readFileSync(file(current.answer ?? "answer.json"), "utf8"), current.caller, current.forRun));
|
|
452
840
|
}
|
|
453
841
|
else {
|
|
454
842
|
results.push(ex.run(current.run, current.on, current.caller));
|
|
@@ -460,12 +848,66 @@ function runExchangeCase(folder, caseDir, name, shared, root) {
|
|
|
460
848
|
flush();
|
|
461
849
|
current.run = n.value;
|
|
462
850
|
}
|
|
851
|
+
else if (n.key === "arrive") {
|
|
852
|
+
flush();
|
|
853
|
+
current.arrive = n.value;
|
|
854
|
+
}
|
|
855
|
+
else if (n.key === "stream") {
|
|
856
|
+
flush();
|
|
857
|
+
current.stream = n.value;
|
|
858
|
+
}
|
|
859
|
+
else if (n.key === "ack") {
|
|
860
|
+
flush();
|
|
861
|
+
current.ack = n.value;
|
|
862
|
+
}
|
|
863
|
+
else if (n.key === "prepare" || n.key === "acted" || n.key === "failed" || n.key === "undo") {
|
|
864
|
+
flush();
|
|
865
|
+
current[n.key] = n.value;
|
|
866
|
+
}
|
|
867
|
+
else if (n.key === "decide-run") {
|
|
868
|
+
flush();
|
|
869
|
+
current.decideRun = n.value;
|
|
870
|
+
}
|
|
871
|
+
else if (n.key === "decision")
|
|
872
|
+
current.decision = n.value;
|
|
873
|
+
else if (n.key === "reason")
|
|
874
|
+
current.reason = n.value;
|
|
875
|
+
else if (n.key === "for-run")
|
|
876
|
+
current.forRun = n.value;
|
|
877
|
+
else if (n.key === "cursor")
|
|
878
|
+
current.cursor = n.value;
|
|
879
|
+
else if (n.key === "tick") {
|
|
880
|
+
flush();
|
|
881
|
+
current.tick = n.value;
|
|
882
|
+
}
|
|
883
|
+
else if (n.key === "now")
|
|
884
|
+
current.now = n.value;
|
|
885
|
+
else if (n.key === "revise") {
|
|
886
|
+
flush();
|
|
887
|
+
const [view = "", version = ""] = n.value.split(" /");
|
|
888
|
+
ex.views?.revise(view.trim(), version.trim());
|
|
889
|
+
}
|
|
463
890
|
else if (n.key === "on")
|
|
464
891
|
current.on = n.value;
|
|
892
|
+
else if (n.key === "level")
|
|
893
|
+
current.level = n.value;
|
|
894
|
+
else if (n.key === "answer")
|
|
895
|
+
current.answer = n.value;
|
|
465
896
|
else if (n.key === "caller")
|
|
466
897
|
current.caller = n.value;
|
|
467
898
|
else if (n.key === "offer")
|
|
468
899
|
current.offer = n.value;
|
|
900
|
+
else if (n.key === "presenter")
|
|
901
|
+
current.presenter = n.value;
|
|
902
|
+
else if (n.key === "give") {
|
|
903
|
+
// The owner's own offer (7.8), as its host would serve it.
|
|
904
|
+
flush();
|
|
905
|
+
results.push(ex.offer(n.value, "test.example"));
|
|
906
|
+
}
|
|
907
|
+
else if (n.key === "expire") {
|
|
908
|
+
flush();
|
|
909
|
+
results.push(ex.expire(n.value));
|
|
910
|
+
}
|
|
469
911
|
else if (n.key === "write") {
|
|
470
912
|
flush();
|
|
471
913
|
const r = ex.get(n.value);
|
|
@@ -478,6 +920,7 @@ function runExchangeCase(folder, caseDir, name, shared, root) {
|
|
|
478
920
|
values[c.key] = f ? (readUnder(f, c.value, dict) ?? c.value) : c.value;
|
|
479
921
|
}
|
|
480
922
|
ex.put({ ...r, values });
|
|
923
|
+
ex.views?.put({ ...r, values });
|
|
481
924
|
}
|
|
482
925
|
}
|
|
483
926
|
flush();
|
|
@@ -493,6 +936,24 @@ function runExchangeCase(folder, caseDir, name, shared, root) {
|
|
|
493
936
|
return fail(`alternatives/suggestions: got ${extra(got)}`);
|
|
494
937
|
return pass;
|
|
495
938
|
}
|
|
939
|
+
if (existsSync(file("expected-entries.json"))) {
|
|
940
|
+
const got = journal.entries.map(({ at: _at, owner: _o, ...e }) => e);
|
|
941
|
+
const wantE = json("expected-entries.json");
|
|
942
|
+
if (serialize(got) !== serialize(wantE))
|
|
943
|
+
return fail(`entries: ${firstDiff(serialize(got), serialize(wantE))}`);
|
|
944
|
+
}
|
|
945
|
+
if (existsSync(file("expected-edges.json"))) {
|
|
946
|
+
const got = [...(ex.access?.edges.values() ?? [])].map(({ line: _l, ...e }) => e);
|
|
947
|
+
const want = json("expected-edges.json");
|
|
948
|
+
for (const w of want) {
|
|
949
|
+
const a = got.find((e) => e.edge === w.edge);
|
|
950
|
+
if (!a)
|
|
951
|
+
return fail(`no edge ${w.edge}`);
|
|
952
|
+
const p = subset(a, w, `${w.edge}: `);
|
|
953
|
+
if (p)
|
|
954
|
+
return fail(p);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
496
957
|
const wantRaw = json("expected-run.json");
|
|
497
958
|
const want = (Array.isArray(wantRaw) ? wantRaw : [wantRaw]);
|
|
498
959
|
if (results.length !== want.length)
|
|
@@ -593,7 +1054,7 @@ function runViewsCase(folder, caseDir, name, shared, root) {
|
|
|
593
1054
|
checkPackage(dict);
|
|
594
1055
|
const set = readViews(sharedViews);
|
|
595
1056
|
const own = readViews(defs);
|
|
596
|
-
for (const k of ["shapes", "filters", "views", "roles", "shown"])
|
|
1057
|
+
for (const k of ["shapes", "filters", "views", "roles", "shown", "shownFor"])
|
|
597
1058
|
for (const [key, val] of own[k])
|
|
598
1059
|
set[k].set(key, val);
|
|
599
1060
|
checkViews(set, dict);
|
|
@@ -602,10 +1063,25 @@ function runViewsCase(folder, caseDir, name, shared, root) {
|
|
|
602
1063
|
const records = readText(text, dict).records.map((r) => foldRecord(r, dict));
|
|
603
1064
|
for (const r of records)
|
|
604
1065
|
views.put(r);
|
|
605
|
-
const byId = new
|
|
1066
|
+
const byId = new MemoryRecords(records.map((r) => [r.id, r]));
|
|
606
1067
|
const viewName = run.view;
|
|
607
|
-
|
|
1068
|
+
// Partitioned views (12.6): the case's edges decide which rows a caller is served.
|
|
1069
|
+
let access;
|
|
1070
|
+
if (existsSync(file("access.crubal"))) {
|
|
1071
|
+
const { perms, edges } = readAccess(readFileSync(file("access.crubal"), "utf8"));
|
|
1072
|
+
access = new Access(dict, perms, byId, edges, "test.example");
|
|
1073
|
+
}
|
|
1074
|
+
const opts = { picks: run.picks, ...(run.context ? { context: byId.get(run.context) } : {}), ...(run.page ? { page: run.page } : {}), ...(access ? { access } : {}) };
|
|
608
1075
|
const same = (got, want, label) => (serialize(got) === serialize(want) ? pass : fail(`${label}: got ${serialize(got)}`));
|
|
1076
|
+
if (existsSync(file("expected-stream.json"))) {
|
|
1077
|
+
const first = views.stream(viewName, opts);
|
|
1078
|
+
for (const w of readText(readFileSync(file("write.crubal"), "utf8"), dict).records) {
|
|
1079
|
+
const was = byId.get(w.id);
|
|
1080
|
+
views.put(was ? { ...was, values: { ...was.values, ...w.values } } : foldRecord(w, dict));
|
|
1081
|
+
}
|
|
1082
|
+
const second = views.stream(viewName, opts);
|
|
1083
|
+
return same([first, second], json("expected-stream.json"), "stream");
|
|
1084
|
+
}
|
|
609
1085
|
if (existsSync(file("expected-stale.json"))) {
|
|
610
1086
|
views.project(viewName, opts);
|
|
611
1087
|
const stale = new Set();
|
|
@@ -626,7 +1102,9 @@ function runViewsCase(folder, caseDir, name, shared, root) {
|
|
|
626
1102
|
}
|
|
627
1103
|
const p = views.project(viewName, opts);
|
|
628
1104
|
if (existsSync(file("expected-counts.json")))
|
|
629
|
-
return same({ records: p.records, counts: p.counts, indexable: p.indexable }, json("expected-counts.json"), "counts");
|
|
1105
|
+
return same({ records: p.records, counts: p.counts, ...(p.nested ? { nested: p.nested } : {}), indexable: p.indexable }, json("expected-counts.json"), "counts");
|
|
1106
|
+
if (existsSync(file("expected-jsonld.json")))
|
|
1107
|
+
return same(p.rows, json("expected-jsonld.json"), "jsonld");
|
|
630
1108
|
if (existsSync(file("expected-pages.json")))
|
|
631
1109
|
return same({ pages: p.pages, records: p.records, indexable: p.indexable }, json("expected-pages.json"), "pages");
|
|
632
1110
|
const wantFile = existsSync(file("expected-portable.json")) ? "expected-portable.json" : "expected-projection.json";
|
|
@@ -637,6 +1115,56 @@ function runViewsCase(folder, caseDir, name, shared, root) {
|
|
|
637
1115
|
}
|
|
638
1116
|
}
|
|
639
1117
|
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1118
|
+
/**
|
|
1119
|
+
* Forms as data (7.7, docs/design/index-and-forms.md): `form.crubal` names the contract, the record and
|
|
1120
|
+
* optionally the caller (several `form:` blocks give several forms); `access.crubal` makes editability
|
|
1121
|
+
* follow edges and gates; `arrival.json` is a held arrival whose candidates the form offers;
|
|
1122
|
+
* `expected-form.json` is the form (or the forms) exactly.
|
|
1123
|
+
*/
|
|
1124
|
+
function runFormsCase(folder, caseDir, name, shared) {
|
|
1125
|
+
const file = (f) => join(caseDir, f);
|
|
1126
|
+
const json = (f) => JSON.parse(readFileSync(file(f), "utf8"));
|
|
1127
|
+
const pass = { folder, name, pass: true };
|
|
1128
|
+
const fail = (detail) => ({ folder, name, pass: false, detail });
|
|
1129
|
+
try {
|
|
1130
|
+
const input = readFileSync(file("input.crubal"), "utf8");
|
|
1131
|
+
const { defs, data } = splitCaseInput(input);
|
|
1132
|
+
const dict = copy(shared);
|
|
1133
|
+
if (defs.trim())
|
|
1134
|
+
readDefinitions(defs, dict);
|
|
1135
|
+
const contracts = readContracts(defs);
|
|
1136
|
+
const records = data.trim() ? readText(data, dict).records.map((r) => foldRecord(r, dict)) : [];
|
|
1137
|
+
const byId = new MemoryRecords(records.map((r) => [r.id, r]));
|
|
1138
|
+
let access;
|
|
1139
|
+
if (existsSync(file("access.crubal"))) {
|
|
1140
|
+
const { perms, edges } = readAccess(readFileSync(file("access.crubal"), "utf8"));
|
|
1141
|
+
access = new Access(dict, perms, byId, edges, "test.example");
|
|
1142
|
+
}
|
|
1143
|
+
const arrival = existsSync(file("arrival.json")) ? json("arrival.json") : undefined;
|
|
1144
|
+
// form.crubal reads like run.crubal: `form:` starts one, `record:` and `caller:` belong to it.
|
|
1145
|
+
const asks = [];
|
|
1146
|
+
for (const n of readTree(readFileSync(file("form.crubal"), "utf8"))) {
|
|
1147
|
+
if (n.key === "form")
|
|
1148
|
+
asks.push({ form: n.value.trim() });
|
|
1149
|
+
else if ((n.key === "record" || n.key === "caller") && asks.length)
|
|
1150
|
+
asks[asks.length - 1][n.key] = n.value.trim();
|
|
1151
|
+
}
|
|
1152
|
+
const forms = [];
|
|
1153
|
+
for (const ask of asks) {
|
|
1154
|
+
const c = contracts.find((x) => x.name === ask.form);
|
|
1155
|
+
if (!c)
|
|
1156
|
+
return fail(`no contract ${ask.form}`);
|
|
1157
|
+
const rec = ask.record ? byId.get(ask.record) : undefined;
|
|
1158
|
+
forms.push(formOf(c, rec, dict, { records: byId, ...(ask.caller ? { caller: ask.caller } : {}), ...(access ? { access } : {}), ...(arrival ? { arrival } : {}) }));
|
|
1159
|
+
}
|
|
1160
|
+
const want = json("expected-form.json");
|
|
1161
|
+
const got = Array.isArray(want) ? forms : (forms[0] ?? {});
|
|
1162
|
+
return serialize(got) === serialize(want) ? pass : fail(`form: ${firstDiff(serialize(got), serialize(want))}`);
|
|
1163
|
+
}
|
|
1164
|
+
catch (e) {
|
|
1165
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
640
1168
|
/** Run one level's corpus folders under `root`. */
|
|
641
1169
|
export function runCorpus(root, level) {
|
|
642
1170
|
const folders = LEVEL_FOLDERS[level];
|
|
@@ -653,17 +1181,23 @@ export function runCorpus(root, level) {
|
|
|
653
1181
|
continue;
|
|
654
1182
|
if (!["input.crubal", "log.json", "border.json"].some((f) => existsSync(join(dir, name, f))))
|
|
655
1183
|
continue;
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
1184
|
+
if (folder === "store" && existsSync(join(dir, name, "expected-redact.json"))) {
|
|
1185
|
+
results.push(runStoreCase(folder, join(dir, name), name, dict));
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
const r = folder === "forms"
|
|
1189
|
+
? runFormsCase(folder, join(dir, name), name, dict)
|
|
1190
|
+
: folder.startsWith("views")
|
|
1191
|
+
? runViewsCase(folder, join(dir, name), name, dict, root)
|
|
1192
|
+
: folder.startsWith("exchange") || folder === "package-errors"
|
|
1193
|
+
? runExchangeCase(folder, join(dir, name), name, dict, root)
|
|
1194
|
+
: folder.startsWith("store")
|
|
1195
|
+
? runStoreCase(folder, join(dir, name), name, dict)
|
|
1196
|
+
: folder.startsWith("rules")
|
|
1197
|
+
? runRulesCase(folder, join(dir, name), name, dict, sharedRules)
|
|
1198
|
+
: folder.startsWith("dictionary")
|
|
1199
|
+
? runDictionaryCase(folder, join(dir, name), name, dict)
|
|
1200
|
+
: runCase(folder, dir, name, dict);
|
|
667
1201
|
if (r)
|
|
668
1202
|
results.push(r);
|
|
669
1203
|
}
|