@cruba/crubal 0.1.0 → 0.3.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.
Files changed (89) hide show
  1. package/README.md +30 -2
  2. package/dist/bin/crubal.js +70 -4
  3. package/dist/bin/crubal.js.map +1 -1
  4. package/dist/src/access.d.ts +126 -0
  5. package/dist/src/access.js +517 -0
  6. package/dist/src/access.js.map +1 -0
  7. package/dist/src/address.d.ts +29 -0
  8. package/dist/src/address.js +59 -0
  9. package/dist/src/address.js.map +1 -0
  10. package/dist/src/arrival.d.ts +55 -0
  11. package/dist/src/arrival.js +124 -0
  12. package/dist/src/arrival.js.map +1 -0
  13. package/dist/src/bundle.d.ts +66 -0
  14. package/dist/src/bundle.js +753 -0
  15. package/dist/src/bundle.js.map +1 -0
  16. package/dist/src/conditions.d.ts +7 -1
  17. package/dist/src/conditions.js +52 -11
  18. package/dist/src/conditions.js.map +1 -1
  19. package/dist/src/corpus.js +568 -34
  20. package/dist/src/corpus.js.map +1 -1
  21. package/dist/src/defs.d.ts +37 -2
  22. package/dist/src/defs.js +95 -14
  23. package/dist/src/defs.js.map +1 -1
  24. package/dist/src/dictionary.d.ts +15 -0
  25. package/dist/src/dictionary.js +72 -13
  26. package/dist/src/dictionary.js.map +1 -1
  27. package/dist/src/exchange.d.ts +204 -11
  28. package/dist/src/exchange.js +762 -45
  29. package/dist/src/exchange.js.map +1 -1
  30. package/dist/src/forms.d.ts +16 -0
  31. package/dist/src/forms.js +169 -0
  32. package/dist/src/forms.js.map +1 -0
  33. package/dist/src/index.d.ts +19 -6
  34. package/dist/src/index.js +18 -5
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/lifecycle.d.ts +84 -0
  37. package/dist/src/lifecycle.js +415 -0
  38. package/dist/src/lifecycle.js.map +1 -0
  39. package/dist/src/log.d.ts +78 -0
  40. package/dist/src/log.js +506 -0
  41. package/dist/src/log.js.map +1 -0
  42. package/dist/src/migrate.d.ts +24 -1
  43. package/dist/src/migrate.js +104 -9
  44. package/dist/src/migrate.js.map +1 -1
  45. package/dist/src/model.d.ts +33 -1
  46. package/dist/src/model.js +158 -2
  47. package/dist/src/model.js.map +1 -1
  48. package/dist/src/packages.d.ts +44 -0
  49. package/dist/src/packages.js +137 -3
  50. package/dist/src/packages.js.map +1 -1
  51. package/dist/src/print-text.js +22 -0
  52. package/dist/src/print-text.js.map +1 -1
  53. package/dist/src/publish.d.ts +27 -0
  54. package/dist/src/publish.js +275 -0
  55. package/dist/src/publish.js.map +1 -0
  56. package/dist/src/read-text.d.ts +2 -0
  57. package/dist/src/read-text.js +72 -4
  58. package/dist/src/read-text.js.map +1 -1
  59. package/dist/src/records.d.ts +32 -0
  60. package/dist/src/records.js +104 -0
  61. package/dist/src/records.js.map +1 -0
  62. package/dist/src/review.d.ts +22 -0
  63. package/dist/src/review.js +198 -0
  64. package/dist/src/review.js.map +1 -0
  65. package/dist/src/rules.d.ts +38 -3
  66. package/dist/src/rules.js +184 -30
  67. package/dist/src/rules.js.map +1 -1
  68. package/dist/src/schedule.d.ts +24 -0
  69. package/dist/src/schedule.js +126 -0
  70. package/dist/src/schedule.js.map +1 -0
  71. package/dist/src/std-packages.d.ts +9 -0
  72. package/dist/src/std-packages.js +268 -0
  73. package/dist/src/std-packages.js.map +1 -0
  74. package/dist/src/store.d.ts +38 -7
  75. package/dist/src/store.js +166 -24
  76. package/dist/src/store.js.map +1 -1
  77. package/dist/src/tree.d.ts +3 -0
  78. package/dist/src/tree.js +36 -0
  79. package/dist/src/tree.js.map +1 -1
  80. package/dist/src/units-table.d.ts +1 -0
  81. package/dist/src/units-table.js +200 -0
  82. package/dist/src/units-table.js.map +1 -0
  83. package/dist/src/units.d.ts +42 -4
  84. package/dist/src/units.js +91 -30
  85. package/dist/src/units.js.map +1 -1
  86. package/dist/src/views.d.ts +116 -6
  87. package/dist/src/views.js +484 -74
  88. package/dist/src/views.js.map +1 -1
  89. package/package.json +5 -3
package/dist/src/views.js CHANGED
@@ -4,7 +4,10 @@
4
4
  // a view's read set decides which projection rows go stale.
5
5
  import { serialize } from "./canonical.js";
6
6
  import { evaluate, parseCondition } from "./conditions.js";
7
+ import { facetOf, leafOf, linkStep, rowStep } from "./address.js";
8
+ export { facetOf, leafOf } from "./address.js";
7
9
  import { splitTop } from "./lex.js";
10
+ import { MemoryRecords } from "./records.js";
8
11
  import { Engine } from "./rules.js";
9
12
  import { CrubalError, readTree } from "./tree.js";
10
13
  const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -16,7 +19,7 @@ const rankOf = (a) => {
16
19
  };
17
20
  /** Read shapes, filters, views (with a body), roles and `shown to` rules. */
18
21
  export function readViews(source) {
19
- const set = { shapes: new Map(), filters: new Map(), views: new Map(), roles: new Map(), shown: new Map() };
22
+ const set = { shapes: new Map(), filters: new Map(), views: new Map(), roles: new Map(), shown: new Map(), shownFor: new Map() };
20
23
  for (const n of readTree(source)) {
21
24
  if (n.kind !== "pair" || !n.key)
22
25
  continue;
@@ -81,6 +84,8 @@ export function readViews(source) {
81
84
  indexLine: get("index")?.line ?? n.line,
82
85
  show: get("show")?.value ?? "public",
83
86
  fresh: get("fresh")?.value ?? "write",
87
+ ...(get("partition") ? { partition: get("partition")?.value.trim() } : {}),
88
+ ...(get("version") ? { version: get("version")?.value.trim() } : {}),
84
89
  });
85
90
  }
86
91
  else if (n.key === "role") {
@@ -96,15 +101,39 @@ export function readViews(source) {
96
101
  else if (n.key === "rule") {
97
102
  const then = get("then")?.value ?? "";
98
103
  const m = then.match(/^(\S+) shown to (\S+)$/);
99
- if (m)
100
- set.shown.set(m[1], m[2]);
104
+ if (m) {
105
+ const scope = get("for")?.value.trim() ?? "everywhere";
106
+ if (scope === "everywhere")
107
+ set.shown.set(m[1], m[2]);
108
+ else
109
+ set.shownFor.set(m[1], [...(set.shownFor.get(m[1]) ?? []), { for: scope, audience: m[2] }]);
110
+ }
101
111
  }
102
112
  }
103
113
  return set;
104
114
  }
105
- /** Can an audience see a facet? (`shown to` names the lowest audience that may.) */
106
- function visibleTo(set, facet, audience) {
107
- const min = set.shown.get(facet.replace(/^\w+::/, ""));
115
+ /**
116
+ * The lowest audience that may see a facet (12.4): a show rule whose `for` covers the record decides for it,
117
+ * a class over a kind or level, else the rule for everywhere; no rule, the public.
118
+ */
119
+ export function shownAudience(set, facet, record, dict) {
120
+ const key = facet.replace(/^\w+::/, "");
121
+ if (record) {
122
+ const scoped = set.shownFor.get(key) ?? [];
123
+ // A record wears its kind's base class without saying so (chapter 5).
124
+ const base = dict?.kinds.get(record.kind)?.base;
125
+ const byClass = scoped.find((s) => (record.wears ?? []).includes(s.for) || s.for === base);
126
+ if (byClass)
127
+ return byClass.audience;
128
+ const byKind = scoped.find((s) => s.for === record.kind || s.for === record.level);
129
+ if (byKind)
130
+ return byKind.audience;
131
+ }
132
+ return set.shown.get(key);
133
+ }
134
+ /** Can an audience see a facet, of this record when one is given? */
135
+ export function visibleTo(set, facet, audience, record, dict) {
136
+ const min = shownAudience(set, facet, record, dict);
108
137
  return !min || rankOf(audience) >= rankOf(min);
109
138
  }
110
139
  /** The keys a slot's source reads. */
@@ -116,11 +145,11 @@ function slotKeys(from) {
116
145
  }
117
146
  // ─── Publish checks (12.8) ───────────────────────────────────────────────────
118
147
  export function checkViews(set, dict) {
119
- const defined = (k) => dict.facets.has(k.replace(/^\w+::/, "").replace(/^parts\[\*\]\./, ""));
148
+ const defined = (k) => facetOf(k, dict) !== undefined;
120
149
  for (const f of set.filters.values()) {
121
150
  if (!defined(f.facet))
122
151
  throw new CrubalError(`the filter ${f.name} reads ${f.facet}, which isn't a facet`, f.line);
123
- const facet = dict.facets.get(f.facet.replace(/^\w+::/, ""));
152
+ const facet = facetOf(f.facet, dict);
124
153
  const seen = new Map();
125
154
  f.buckets?.forEach((b, i) => {
126
155
  for (const v of b.values ?? []) {
@@ -134,16 +163,30 @@ export function checkViews(set, dict) {
134
163
  });
135
164
  }
136
165
  for (const s of set.shapes.values()) {
137
- for (const sl of s.slots)
166
+ for (const sl of s.slots) {
167
+ // A page shape's slot may hold another view (12.1), run with the page's record as its context.
168
+ if (sl.from.startsWith("view:")) {
169
+ if (!set.views.has(sl.from.slice(5)))
170
+ throw new CrubalError(`the slot ${sl.to} holds the view ${sl.from.slice(5)}, which doesn't exist`, sl.line);
171
+ continue;
172
+ }
138
173
  for (const k of slotKeys(sl.from))
139
174
  if (!sl.from.startsWith('"') || k)
140
175
  if (!defined(k) && k !== "id")
141
176
  throw new CrubalError(`the slot ${sl.to} reads ${k}, which isn't a facet`, sl.line);
177
+ }
142
178
  }
179
+ for (const f of set.filters.values())
180
+ for (const d of f.dependent)
181
+ if (!defined(d))
182
+ throw new CrubalError(`the filter ${f.name} depends on ${d}, which isn't a facet`, f.line);
143
183
  for (const v of set.views.values()) {
144
184
  for (const s of v.shapes)
145
- if (s !== "crubal:portable" && !set.shapes.has(s))
185
+ if (!CORE_SHAPES.includes(s) && !set.shapes.has(s))
146
186
  throw new CrubalError(`${v.name} names the shape ${s}, which doesn't exist`, v.line);
187
+ // A list is the whole selection (12.2): a level, classes, a condition or curation would contradict it.
188
+ if (v.list && (v.level || v.wears.length || v.when || v.pin.length || v.hide.length || v.order.length))
189
+ throw new CrubalError(`${v.name} selects a list, so it takes no level, wears, when, pin, hide or order`, v.line);
147
190
  for (const f of v.filters)
148
191
  if (!set.filters.has(f))
149
192
  throw new CrubalError(`${v.name} names the filter ${f}, which doesn't exist`, v.line);
@@ -158,7 +201,7 @@ export function checkViews(set, dict) {
158
201
  ...v.shapes.flatMap((s) => (set.shapes.get(s)?.slots ?? []).flatMap((sl) => slotKeys(sl.from))),
159
202
  ...v.filters.map((f) => set.filters.get(f)?.facet ?? f),
160
203
  ];
161
- const hidden = reads.find((k) => !visibleTo(set, k, "public"));
204
+ const hidden = reads.find((k) => !visibleTo(set, leafOf(k, dict), "public"));
162
205
  if (hidden)
163
206
  throw new CrubalError(`the public view ${v.name} reads ${hidden}, which show rules hide from public`, v.line);
164
207
  }
@@ -172,6 +215,12 @@ export function checkViews(set, dict) {
172
215
  }
173
216
  }
174
217
  }
218
+ /** The shapes the core defines (12.1, 12.5): the portable one, and structured data for the public. */
219
+ export const CORE_SHAPES = ["crubal:portable", "crubal:jsonld"];
220
+ /** A view's partition: what it declares, else `controlled` above member and `none` otherwise (12.2). */
221
+ export function partitionOf(v) {
222
+ return v.partition ?? (rankOf(v.show) > rankOf("member") ? "controlled" : "none");
223
+ }
175
224
  function plainText(v) {
176
225
  if (typeof v === "string")
177
226
  return v;
@@ -188,11 +237,14 @@ function label(key) {
188
237
  export class Views {
189
238
  dict;
190
239
  set;
191
- records = new Map();
240
+ records = new MemoryRecords();
192
241
  engine;
193
- constructor(dict, set) {
242
+ /** Over a record set of the caller's (a store's working set), or an empty memory set to `put` into. */
243
+ constructor(dict, set, records) {
194
244
  this.dict = dict;
195
245
  this.set = set;
246
+ if (records)
247
+ this.records = records;
196
248
  this.engine = new Engine(dict, []);
197
249
  }
198
250
  put(r) {
@@ -202,12 +254,50 @@ export class Views {
202
254
  const up = r.of ? this.records.get(r.of) : undefined;
203
255
  return { ...(up ? this.effective(up) : {}), ...r.values };
204
256
  }
257
+ /** The classes a record wears, those of the records above it included (5.5). */
258
+ wearsOf(r) {
259
+ const up = r.of ? this.records.get(r.of) : undefined;
260
+ return [...(up ? this.wearsOf(up) : []), ...(r.wears ?? [])];
261
+ }
205
262
  below(r, level) {
206
- return [...this.records.values()].filter((x) => x.of === r.id && x.level === level);
263
+ return this.records.childrenOf(r.id, "of", level);
264
+ }
265
+ /**
266
+ * The records a link facet points at: shared parts, read through (1.6). A link into a vocabulary (`wd`,
267
+ * `gpc`) or to a url points at no record here and reads nothing.
268
+ */
269
+ linked(r, link) {
270
+ const f = this.dict.facets.get(link);
271
+ if (!f || f.type !== "link" || !f.to || !(this.dict.kinds.has(f.to) || this.dict.classes.has(f.to)))
272
+ return [];
273
+ const v = this.effective(r)[link];
274
+ const items = v === undefined ? [] : Array.isArray(v) ? v : [v];
275
+ return items.flatMap((x) => {
276
+ const id = isObj(x) && "$link" in x ? String(x.$link) : typeof x === "string" ? x : "";
277
+ const t = id ? this.records.get(id) : undefined;
278
+ return t && t.status?.state !== "archived" ? [t] : [];
279
+ });
280
+ }
281
+ /** The rows of a compound on a record, as objects. */
282
+ rowsOf(r, compound) {
283
+ const v = this.effective(r)[compound];
284
+ return (v === undefined ? [] : Array.isArray(v) ? v : [v]).filter(isObj);
207
285
  }
208
286
  /** The values an address reads, as a list: own values, or a lower level's (`unit::price`). */
209
287
  valuesAt(r, address) {
288
+ // A step through a link facet reads the linked records (shared parts, 1.6), each by the rest of the address.
289
+ const ls = linkStep(address, this.dict);
290
+ if (ls)
291
+ return this.linked(r, ls.link).flatMap((t) => this.valuesAt(t, ls.rest));
292
+ // A step into a compound's rows reads the child on every row (1.10 with the wildcard).
293
+ const rs = rowStep(address, this.dict);
294
+ if (rs)
295
+ return this.rowsOf(r, rs.compound).flatMap((row) => { const c = rs.child ? row[rs.child] : row; return c === undefined ? [] : Array.isArray(c) ? c : [c]; });
210
296
  const m = address.match(/^(\w+)::(.+)$/);
297
+ // A level prefix reads the records below at that level, or the record itself when it sits there
298
+ // (a standing-alone lower record answers for its own level, 1.7).
299
+ if (m && r.level === m[1])
300
+ return this.valuesAt(r, m[2]);
211
301
  if (m)
212
302
  return this.below(r, m[1]).flatMap((u) => this.valuesAt(u, m[2]));
213
303
  const v = this.effective(r)[address];
@@ -219,29 +309,30 @@ export class Views {
219
309
  return true;
220
310
  if (isObj(v) && "$empty" in v)
221
311
  return v.$empty === "n/a";
222
- const f = this.dict.facets.get(key.replace(/^\w+::/, ""));
312
+ const f = facetOf(key, this.dict);
223
313
  if (f?.type === "choice" && typeof v === "string")
224
314
  return (f.values ?? []).some((x) => x.value === v);
225
315
  return true;
226
316
  }
227
- canSee(key, audience, caller) {
228
- if (!visibleTo(this.set, key, audience))
317
+ canSee(address, audience, caller, record) {
318
+ const key = leafOf(address, this.dict);
319
+ if (!visibleTo(this.set, key, audience, record, this.dict))
229
320
  return false;
230
321
  if (!caller || rankOf(audience) <= rankOf("member"))
231
322
  return true;
232
323
  // Above member, a person sees what members see plus what their roles grant (default deny).
233
- if (visibleTo(this.set, key, "member"))
324
+ if (visibleTo(this.set, key, "member", record, this.dict))
234
325
  return true;
235
326
  const held = caller.values.roles;
236
327
  const names = (Array.isArray(held) ? held : held ? [held] : []).map((x) => (isObj(x) && "$link" in x ? String(x.$link) : String(x)));
237
328
  return names.some((n) => {
238
329
  const role = this.set.roles.get(n);
239
- return role && role.see.includes(key.replace(/^\w+::/, "")) && rankOf(role.audience) <= rankOf(audience);
330
+ return role && role.see.includes(key) && rankOf(role.audience) <= rankOf(audience);
240
331
  });
241
332
  }
242
333
  slot(r, s, audience, caller) {
243
334
  const keys = slotKeys(s.from);
244
- if (!keys.every((k) => this.canSee(k, audience, caller)))
335
+ if (!keys.every((k) => this.canSee(k, audience, caller, r)))
245
336
  return undefined;
246
337
  if (s.from.startsWith('"'))
247
338
  return this.engine.template(s.from, this.effective(r));
@@ -260,16 +351,144 @@ export class Views {
260
351
  return sorted.at(-1);
261
352
  return items;
262
353
  }
263
- const f = this.dict.facets.get(s.from.replace(/^\w+::/, ""));
264
- const v = s.from.includes("::") ? this.valuesAt(r, s.from) : this.effective(r)[s.from];
265
- if (v === undefined)
354
+ const f = facetOf(s.from, this.dict);
355
+ const through = s.from.includes("::") || linkStep(s.from, this.dict) !== null || rowStep(s.from, this.dict) !== null;
356
+ const v = through ? this.valuesAt(r, s.from) : this.effective(r)[s.from];
357
+ if (v === undefined || (Array.isArray(v) && !v.length))
266
358
  return undefined;
267
359
  if (Array.isArray(v)) {
268
360
  const kept = v.filter((x) => this.fit(s.from, x, audience));
269
- return kept.length ? (f?.many || s.from.includes("::") ? kept : kept[0]) : undefined;
361
+ return kept.length ? (f?.many || through ? kept : kept[0]) : undefined;
270
362
  }
271
363
  return this.fit(s.from, v, audience) ? v : undefined;
272
364
  }
365
+ /**
366
+ * What a condition reads of a record (6.3): the envelope under `crubal:`, and bare `id` and `status`
367
+ * too unless a facet of that name holds a value, which then wins.
368
+ */
369
+ conditionValues(r) {
370
+ const status = r.status?.state ?? "active";
371
+ const own = this.effective(r);
372
+ const out = { id: r.id, status, "crubal:id": r.id, "crubal:status": status, ...own };
373
+ // A compound's rows, addressed `X[*].child`, as lists of the child's values.
374
+ const rowKeys = (vals, prefix) => {
375
+ const added = {};
376
+ for (const [key, v] of Object.entries(vals)) {
377
+ if (this.dict.facets.get(key)?.type !== "compound")
378
+ continue;
379
+ for (const row of (Array.isArray(v) ? v : [v]).filter(isObj))
380
+ for (const [child, cv] of Object.entries(row))
381
+ (added[`${prefix}${key}[*].${child}`] ??= []).push(...(Array.isArray(cv) ? cv : [cv]));
382
+ }
383
+ return added;
384
+ };
385
+ Object.assign(out, rowKeys(own, ""));
386
+ // The linked records' values, addressed through the link (`works[*].genre`, `works.genre`), so a
387
+ // condition reads a shared part as it reads a level below (1.6); their compounds' rows likewise.
388
+ for (const key of Object.keys(own)) {
389
+ if (this.dict.facets.get(key)?.type !== "link")
390
+ continue;
391
+ const targets = this.linked(r, key);
392
+ if (!targets.length)
393
+ continue;
394
+ const merged = {};
395
+ for (const t of targets) {
396
+ const tv = this.effective(t);
397
+ for (const [k, v] of Object.entries(tv))
398
+ (merged[k] ??= []).push(...(Array.isArray(v) ? v : [v]));
399
+ for (const [k, v] of Object.entries(rowKeys(tv, "")))
400
+ (merged[k] ??= []).push(...v);
401
+ }
402
+ for (const [k, list] of Object.entries(merged)) {
403
+ out[`${key}[*].${k}`] = list;
404
+ out[`${key}.${k}`] = list.length === 1 ? list[0] : list;
405
+ }
406
+ }
407
+ return out;
408
+ }
409
+ /** The values of the view's order keys as text, and the pin position: what an index orders rows by (12.7). */
410
+ sortOf(v, r) {
411
+ const vals = this.effective(r);
412
+ const sort = v.order.map((o) => plainText((vals[o.key] ?? "")));
413
+ const at = v.pin.indexOf(r.id);
414
+ return at >= 0 ? { sort, pin: at + 1 } : { sort };
415
+ }
416
+ /** The buckets a record falls into, per filter of the view (12.6): what an index counts from. */
417
+ bucketsFor(v, r) {
418
+ const out = {};
419
+ for (const fname of v.filters) {
420
+ const f = this.set.filters.get(fname);
421
+ if (!f)
422
+ continue;
423
+ const names = this.bucketsOf(f).filter((b) => this.inBucket(r, f, b)).map((b) => b.name);
424
+ if (names.length)
425
+ out[fname] = names;
426
+ // A dependent facet (12.3): its values, one bucket each, so an index can count them inside this filter's.
427
+ for (const dep of f.dependent) {
428
+ const vals = this.valuesAt(r, dep).filter((x) => this.fit(dep, x, "public")).map(plainText);
429
+ if (vals.length)
430
+ out[dep] = [...new Set(vals)];
431
+ }
432
+ }
433
+ return out;
434
+ }
435
+ /** Dependent counts (12.3) over records already narrowed by the other filters. */
436
+ nestedCounts(v, pool) {
437
+ const out = {};
438
+ for (const fname of v.filters) {
439
+ const f = this.set.filters.get(fname);
440
+ if (!f?.dependent.length)
441
+ continue;
442
+ const rows = pool(fname);
443
+ const per = {};
444
+ for (const b of this.bucketsOf(f)) {
445
+ const inB = rows.filter((r) => this.inBucket(r, f, b));
446
+ const byDep = {};
447
+ for (const dep of f.dependent) {
448
+ const tally = {};
449
+ for (const r of inB)
450
+ for (const val of new Set(this.valuesAt(r, dep).map(plainText)))
451
+ tally[val] = (tally[val] ?? 0) + 1;
452
+ byDep[dep] = tally;
453
+ }
454
+ per[b.name] = byDep;
455
+ }
456
+ out[fname] = per;
457
+ }
458
+ return out;
459
+ }
460
+ /** Does a view select this record (its own tests, before curation and the shape's required slots)? */
461
+ selects(v, r, opts) {
462
+ if (v.level && r.level !== v.level)
463
+ return false;
464
+ // An archived record is left out unless the view selects on status explicitly (1.12).
465
+ if (r.status?.state === "archived" && !(v.when && /\bstatus\b/.test(v.when)))
466
+ return false;
467
+ if (v.wears.some((w) => !this.wearsOf(r).includes(w)))
468
+ return false;
469
+ if (v.hide.includes(r.id))
470
+ return false;
471
+ const vals = this.conditionValues(r);
472
+ if (v.when && evaluate(parseCondition(v.when), vals) !== true)
473
+ return false;
474
+ if (v.of) {
475
+ const ctx = opts.context;
476
+ if (!ctx)
477
+ return false;
478
+ const cvals = this.conditionValues(ctx);
479
+ const cond = v.of.replace(/context\.(\w+)/g, (_, k) => JSON.stringify(cvals[k] === undefined ? "" : plainText(cvals[k])));
480
+ if (evaluate(parseCondition(cond), vals) !== true)
481
+ return false;
482
+ }
483
+ return true;
484
+ }
485
+ /** The records a list holds, in its order (12.2): the links in its `crubal:items`. */
486
+ listed(v) {
487
+ const list = v.list ? this.records.get(v.list) : undefined;
488
+ const items = list?.values["crubal:items"];
489
+ const ids = (Array.isArray(items) ? items : items === undefined ? [] : [items]).map((x) => (isObj(x) && "$link" in x ? String(x.$link) : String(x)));
490
+ return ids.flatMap((id) => this.records.get(id) ?? []).filter((r) => r.status?.state !== "archived");
491
+ }
273
492
  inBucket(r, f, b) {
274
493
  return this.valuesAt(r, f.facet).some((v) => {
275
494
  const t = plainText(v);
@@ -283,36 +502,23 @@ export class Views {
283
502
  bucketsOf(f) {
284
503
  if (f.buckets)
285
504
  return f.buckets;
286
- const facet = this.dict.facets.get(f.facet.replace(/^\w+::/, ""));
505
+ const facet = facetOf(f.facet, this.dict);
287
506
  return (facet?.values ?? []).map((v) => ({ name: v.value, values: [v.value] }));
288
507
  }
289
508
  /** The records a view selects, curated and ordered, before filters and paging. */
290
509
  select(v, opts = {}) {
291
510
  const audience = opts.audience ?? v.show;
292
- let rows = [...this.records.values()].filter((r) => {
293
- if (v.level && r.level !== v.level)
294
- return false;
295
- if (v.wears.some((w) => !(r.wears ?? []).includes(w)))
296
- return false;
297
- if (v.hide.includes(r.id))
298
- return false;
299
- const vals = { ...this.effective(r), id: r.id };
300
- if (v.when && evaluate(parseCondition(v.when), vals) !== true)
301
- return false;
302
- if (v.of) {
303
- const ctx = opts.context;
304
- if (!ctx)
305
- return false;
306
- const cvals = { ...this.effective(ctx), id: ctx.id };
307
- const cond = v.of.replace(/context\.(\w+)/g, (_, k) => JSON.stringify(cvals[k] === undefined ? "" : plainText(cvals[k])));
308
- if (evaluate(parseCondition(cond), vals) !== true)
309
- return false;
310
- }
311
- return true;
312
- });
313
- // A record whose required slot is empty is dropped from the view.
314
511
  const shape = this.set.shapes.get(v.shapes[0] ?? "");
315
- rows = rows.filter((r) => (shape?.slots ?? []).every((s) => !s.required || this.slot(r, s, audience, opts.caller) !== undefined));
512
+ const fills = (r) => (shape?.slots ?? []).every((s) => !s.required || this.slot(r, s, audience, opts.caller) !== undefined);
513
+ if (v.list) {
514
+ // A hand-made list (12.2): its records in its order, whatever their levels; no curation applies.
515
+ const listed = this.listed(v).filter(fills);
516
+ return opts.only ? listed.filter((r) => opts.only.includes(r.id)) : listed;
517
+ }
518
+ const pool = opts.only ? opts.only.flatMap((id) => this.records.get(id) ?? []) : v.level ? this.records.atLevel(v.level) : this.records.all();
519
+ let rows = pool.filter((r) => this.selects(v, r, opts));
520
+ // A record whose required slot is empty is dropped from the view.
521
+ rows = rows.filter(fills);
316
522
  const cmp = (a, b) => {
317
523
  for (const o of v.order) {
318
524
  const x = plainText((this.effective(a)[o.key] ?? ""));
@@ -325,13 +531,27 @@ export class Views {
325
531
  const pinned = v.pin.map((id) => rows.find((r) => r.id === id)).filter((r) => !!r);
326
532
  return [...pinned, ...rows.filter((r) => !v.pin.includes(r.id)).sort(cmp)];
327
533
  }
534
+ /** The roles whose `see` a slot needs above member (12.6 role marks): none when members may see it. */
535
+ marksFor(s, audience) {
536
+ if (rankOf(audience) <= rankOf("member"))
537
+ return [];
538
+ const keys = slotKeys(s.from);
539
+ if (keys.every((k) => visibleTo(this.set, k, "member")))
540
+ return [];
541
+ return [...this.set.roles.values()].filter((r) => rankOf(r.audience) <= rankOf(audience) && keys.every((k) => visibleTo(this.set, k, "member") || r.see.includes(k.replace(/^\w+::/, "")))).map((r) => r.name).sort();
542
+ }
328
543
  /** A view's projection for an audience (or a caller), with filter counts and a page. */
329
544
  project(name, opts = {}) {
330
545
  const v = this.set.views.get(name);
331
546
  if (!v)
332
547
  throw new Error(`no view ${name}`);
333
- const audience = opts.caller ? String(opts.caller.values.audience ?? "member") : (opts.audience ?? v.show);
334
- const all = this.select(v, { ...opts, audience });
548
+ // A caller's audience is read off their edges (14.4) when access is at hand; a stated value otherwise.
549
+ const audience = opts.caller ? (opts.access ? opts.access.audienceOf(opts.caller.id) : String(opts.caller.values.audience ?? "member")) : (opts.audience ?? v.show);
550
+ const partition = partitionOf(v);
551
+ let all = this.select(v, { ...opts, audience });
552
+ // A partitioned view serves a caller only the rows their edges match (12.6, 14.10).
553
+ if (partition !== "none" && opts.access && opts.caller)
554
+ all = all.filter((r) => opts.access.standsIn(opts.caller.id, r.id, partition));
335
555
  const picks = opts.picks ?? {};
336
556
  const passes = (r, except) => Object.entries(picks).every(([fname, chosen]) => {
337
557
  if (fname === except)
@@ -340,32 +560,169 @@ export class Views {
340
560
  return !f || this.bucketsOf(f).some((b) => chosen.includes(b.name) && this.inBucket(r, f, b));
341
561
  });
342
562
  const counts = {};
343
- for (const fname of v.filters) {
344
- const f = this.set.filters.get(fname);
345
- if (!f)
346
- continue;
347
- const pool = all.filter((r) => passes(r, fname));
348
- counts[fname] = Object.fromEntries(this.bucketsOf(f).map((b) => [b.name, pool.filter((r) => this.inBucket(r, f, b)).length]));
563
+ if (!opts.all) {
564
+ for (const fname of v.filters) {
565
+ const f = this.set.filters.get(fname);
566
+ if (!f)
567
+ continue;
568
+ const pool = all.filter((r) => passes(r, fname));
569
+ counts[fname] = Object.fromEntries(this.bucketsOf(f).map((b) => [b.name, pool.filter((r) => this.inBucket(r, f, b)).length]));
570
+ }
349
571
  }
572
+ const nested = opts.all ? {} : this.nestedCounts(v, (fname) => all.filter((r) => passes(r, fname)));
350
573
  const chosen = all.filter((r) => passes(r));
351
- const pages = Math.max(1, Math.ceil(chosen.length / v.page));
352
- const page = opts.page ?? 1;
353
- const onPage = chosen.slice((page - 1) * v.page, page * v.page);
574
+ const pages = opts.all ? 1 : Math.max(1, Math.ceil(chosen.length / v.page));
575
+ const page = opts.all ? 1 : (opts.page ?? 1);
576
+ const onPage = opts.all ? chosen : chosen.slice((page - 1) * v.page, page * v.page);
354
577
  const pickList = Object.entries(picks).filter(([, c]) => c.length);
355
578
  const indexable = page === 1 && (pickList.length === 0 || (pickList.length === 1 && v.index.includes(pickList[0]?.[0]) && pickList[0]?.[1].length === 1));
356
- const shapeName = v.shapes[0] ?? "";
579
+ const [shapeName = "", ...more] = v.shapes;
357
580
  const rows = onPage.map((r) => {
358
- if (shapeName === "crubal:portable")
359
- return { record: r.id, values: this.portable(r, audience, opts.caller) };
360
- const row = { record: r.id };
361
- for (const s of this.set.shapes.get(shapeName)?.slots ?? []) {
362
- const val = this.slot(r, s, audience, opts.caller);
363
- if (val !== undefined)
364
- row[s.to] = val;
581
+ const row = this.shaped(r, shapeName, audience, opts);
582
+ // Further shapes (12.1) share the selection: their output rides under `shapes`, served on request.
583
+ const shapes = {};
584
+ for (const s of more) {
585
+ const out = this.shaped(r, s, audience, opts);
586
+ delete out.record;
587
+ if (Object.keys(out).length)
588
+ shapes[s] = s === "crubal:jsonld" ? out.jsonld : s === "crubal:portable" ? out.values : out;
365
589
  }
590
+ if (Object.keys(shapes).length)
591
+ row.shapes = shapes;
592
+ // A partitioned view's row carries its record's ancestors, so an index can match edges to it.
593
+ if (partition !== "none" && opts.access)
594
+ row.ancestors = opts.access.ancestors(r.id);
366
595
  return row;
367
596
  });
368
- return { rows, records: onPage.map((r) => r.id), counts, pages, indexable };
597
+ return { rows, records: onPage.map((r) => r.id), counts, ...(Object.keys(nested).length ? { nested } : {}), pages, indexable };
598
+ }
599
+ /** One record in one shape: a core shape's object, or the shape's slots with their role marks. */
600
+ shaped(r, shapeName, audience, opts) {
601
+ if (shapeName === "crubal:portable")
602
+ return { record: r.id, values: this.portable(r, audience, opts.caller) };
603
+ if (shapeName === "crubal:jsonld") {
604
+ const ld = this.jsonld(r, audience);
605
+ return ld ? { record: r.id, jsonld: ld } : { record: r.id };
606
+ }
607
+ const row = { record: r.id };
608
+ const marks = {};
609
+ for (const s of this.set.shapes.get(shapeName)?.slots ?? []) {
610
+ // A slot holding a view (12.1): that view's rows, with this record as their context.
611
+ if (s.from.startsWith("view:")) {
612
+ const inner = this.nestedRows(s.from.slice(5), r, audience, opts);
613
+ if (inner.length)
614
+ row[s.to] = inner;
615
+ continue;
616
+ }
617
+ const val = this.slot(r, s, audience, opts.caller);
618
+ if (val !== undefined)
619
+ row[s.to] = val;
620
+ const m = opts.caller ? [] : this.marksFor(s, audience);
621
+ if (val !== undefined && m.length)
622
+ marks[s.to] = m.length === 1 ? m[0] : m;
623
+ }
624
+ if (Object.keys(marks).length)
625
+ row.marks = marks;
626
+ return row;
627
+ }
628
+ /** A nested view's rows for a page record (12.1, 12.2 `of`): every page, in the view's order. */
629
+ nestedRows(name, context, audience, opts) {
630
+ return this.project(name, { audience, context, all: true, ...(opts.caller ? { caller: opts.caller } : {}), ...(opts.access ? { access: opts.access } : {}) }).rows;
631
+ }
632
+ /**
633
+ * Structured data (12.5): schema.org JSON-LD for the public from the kind's type per level (11.2) and
634
+ * each facet's `same-as` in the schema prefix; nothing for any other audience.
635
+ */
636
+ jsonld(r, audience = "public") {
637
+ if (audience !== "public")
638
+ return undefined;
639
+ const out = { "@context": "https://schema.org/" };
640
+ const kind = this.dict.kinds.get(this.dict.levels.get(r.level) ?? "");
641
+ const type = typeof kind?.schema === "string" ? kind.schema : kind?.schema?.[r.level];
642
+ if (type)
643
+ out["@type"] = type.replace(/^schema:/, "");
644
+ for (const [key, v] of Object.entries(this.effective(r))) {
645
+ if (!this.canSee(key, "public", undefined, r))
646
+ continue;
647
+ const f = this.dict.facets.get(key);
648
+ const same = f?.sameAs.find((s) => s.startsWith("schema:"));
649
+ if (!same)
650
+ continue;
651
+ const vals = (Array.isArray(v) ? v : [v]).filter((x) => this.fit(key, x, "public") && !(isObj(x) && "$empty" in x));
652
+ if (!vals.length)
653
+ continue;
654
+ const prop = same.slice(7);
655
+ const plain = vals.map((x) => {
656
+ if (isObj(x) && "$money" in x)
657
+ return { price: String(x.$money), priceCurrency: String(x.currency) };
658
+ if (isObj(x) && "$qty" in x)
659
+ return `${x.$qty} ${x.unit}`;
660
+ if (isObj(x) && "$link" in x)
661
+ return { "@id": String(x.$link) };
662
+ if (isObj(x) && "$image" in x)
663
+ return String(x.$image);
664
+ return plainText(x);
665
+ });
666
+ out[prop] = plain.length === 1 ? plain[0] : plain;
667
+ }
668
+ return out;
669
+ }
670
+ streams = new Map();
671
+ /** A view's version, as its stream names it. */
672
+ versionOf(name) {
673
+ return this.set.views.get(name)?.version ?? "";
674
+ }
675
+ /** A new version of a view: its stream starts over at the next publish (12.6). */
676
+ revise(name, version) {
677
+ const v = this.set.views.get(name);
678
+ if (!v)
679
+ throw new Error(`no view ${name}`);
680
+ v.version = version;
681
+ }
682
+ /**
683
+ * The projection stream (12.6): the entries since the last publish of this view for this audience, one
684
+ * per row that is new or changed, and one removal per row that left. Numbered per view and audience; a
685
+ * new version of the view starts over at 1, its first entry marked `reset`.
686
+ */
687
+ stream(name, opts = {}) {
688
+ const v = this.set.views.get(name);
689
+ if (!v)
690
+ throw new Error(`no view ${name}`);
691
+ const audience = opts.audience ?? v.show;
692
+ const key = `${name}|${audience}`;
693
+ const version = v.version ?? "";
694
+ const projection = version ? `${name} /${version}` : name;
695
+ const had = this.streams.get(key);
696
+ const last = had && had.version === version ? had : { version, rows: new Map(), seq: 0, log: [], published: false };
697
+ const reset = !!had && had.version !== version;
698
+ const now = new Map();
699
+ for (const row of this.project(name, { ...opts, audience, all: true }).rows)
700
+ now.set(String(row.record), row);
701
+ const out = [];
702
+ const next = { version, rows: new Map(), seq: last.seq, log: [...last.log], published: true };
703
+ for (const [id, row] of now) {
704
+ const rec = this.records.get(id);
705
+ const buckets = rec ? this.bucketsFor(v, rec) : {};
706
+ const order = rec ? this.sortOf(v, rec) : { sort: [] };
707
+ const s = serialize({ row, buckets, order: order });
708
+ next.rows.set(id, s);
709
+ if (last.rows.get(id) !== s)
710
+ out.push({ projection, audience, seq: ++next.seq, record: id, row, ...(Object.keys(buckets).length ? { buckets } : {}), ...(order.sort.length ? { sort: order.sort } : {}), ...(order.pin ? { pin: order.pin } : {}) });
711
+ }
712
+ for (const id of last.rows.keys())
713
+ if (!now.has(id))
714
+ out.push({ projection, audience, seq: ++next.seq, record: id, removed: true });
715
+ if (reset && out[0])
716
+ out[0].reset = true;
717
+ next.log.push(...out);
718
+ this.streams.set(key, next);
719
+ return out;
720
+ }
721
+ /** The current version's entries after `seq`, for an index resuming from its cursor (12.6.2). */
722
+ entriesSince(name, seq, audience) {
723
+ const v = this.set.views.get(name);
724
+ const s = this.streams.get(`${name}|${audience ?? v?.show ?? "public"}`);
725
+ return (s?.log ?? []).filter((e) => e.seq > seq);
369
726
  }
370
727
  /** The portable shape (12.1): key, label, type and value for every value the audience may see. */
371
728
  portable(r, audience = "public", caller) {
@@ -387,13 +744,21 @@ export class Views {
387
744
  if (v.of)
388
745
  keys.push(...parseConditionKeys(v.of.replace(/context\.\w+/g, '""')));
389
746
  keys.push(...v.order.map((o) => o.key));
747
+ if (v.list)
748
+ keys.push("crubal:items");
390
749
  for (const s of v.shapes)
391
- for (const sl of this.set.shapes.get(s)?.slots ?? [])
392
- keys.push(...slotKeys(sl.from));
750
+ for (const sl of this.set.shapes.get(s)?.slots ?? []) {
751
+ if (sl.from.startsWith("view:"))
752
+ keys.push(...this.readSet(sl.from.slice(5)));
753
+ else
754
+ keys.push(...slotKeys(sl.from));
755
+ }
756
+ for (const f of v.filters)
757
+ keys.push(...(this.set.filters.get(f)?.dependent ?? []));
393
758
  for (const f of v.filters)
394
759
  keys.push(this.set.filters.get(f)?.facet ?? f);
395
760
  for (const k of [...keys])
396
- if (this.set.shown.has(k))
761
+ if (this.set.shown.has(k) || this.set.shownFor.has(k))
397
762
  keys.push(k);
398
763
  return new Set(keys.filter((k) => k !== "id"));
399
764
  }
@@ -404,17 +769,62 @@ export class Views {
404
769
  return [];
405
770
  const reads = this.readSet(name);
406
771
  const out = new Set();
772
+ // The envelope is in every read set (12.6): a status, wears, within or of change stales the row.
773
+ const was = this.records.get(write.id);
774
+ const envelope = ["status", "wears", "within", "of"].some((m) => serialize((write[m] ?? "")) !== serialize((was?.[m] ?? "")));
775
+ if (envelope)
776
+ out.add(write.id);
407
777
  for (const key of Object.keys(write.values)) {
408
778
  if (reads.has(key) && (!v.level || write.level === v.level))
409
779
  out.add(write.id);
410
780
  if (reads.has(`${write.level}::${key}`) && write.of)
411
781
  out.add(write.of);
412
782
  if (reads.has(key) && v.level && write.level !== v.level) {
413
- for (const r of this.records.values())
414
- if (r.of === write.id && r.level === v.level)
415
- out.add(r.id);
783
+ for (const r of this.records.childrenOf(write.id, "of", v.level))
784
+ out.add(r.id);
416
785
  }
417
786
  }
787
+ // A write to a shared part stales the rows that read it through a link (1.6, 12.6): the linking records.
788
+ for (const read of reads) {
789
+ const ls = linkStep(read, this.dict);
790
+ if (!ls)
791
+ continue;
792
+ // Only a write of what the link points at (its kind, or a class it wears) can be a shared part here.
793
+ const to = this.dict.facets.get(ls.link)?.to;
794
+ if (!to || !(to === write.kind || (write.wears ?? []).includes(to)))
795
+ continue;
796
+ if (!envelope && !(leafOf(ls.rest, this.dict) in write.values))
797
+ continue;
798
+ const linkers = [...this.records.byKey(ls.link, { $link: write.id }), ...this.records.byKey(ls.link, [{ $link: write.id }])];
799
+ for (const r of linkers)
800
+ if (!v.level || r.level === v.level)
801
+ out.add(r.id);
802
+ }
803
+ // A write to the list's items (12.2) stales every row: those it holds now, and those it will hold.
804
+ if (v.list && write.id === v.list) {
805
+ for (const r of this.listed(v))
806
+ out.add(r.id);
807
+ const items = write.values["crubal:items"];
808
+ for (const x of Array.isArray(items) ? items : items === undefined ? [] : [items])
809
+ out.add(isObj(x) && "$link" in x ? String(x.$link) : String(x));
810
+ out.delete(write.id);
811
+ }
812
+ // A page whose nested view holds, or would hold, the written record is stale (12.1).
813
+ for (const s of v.shapes)
814
+ for (const sl of this.set.shapes.get(s)?.slots ?? []) {
815
+ if (!sl.from.startsWith("view:"))
816
+ continue;
817
+ const inner = this.set.views.get(sl.from.slice(5));
818
+ if (!inner)
819
+ continue;
820
+ for (const page of this.select(v, { audience: v.show })) {
821
+ const o = { audience: v.show, context: page };
822
+ const is = was ? this.selects(inner, was, o) : false;
823
+ const will = this.selects(inner, write, o);
824
+ if (is || will)
825
+ out.add(page.id);
826
+ }
827
+ }
418
828
  return [...out];
419
829
  }
420
830
  }