@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/views.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { serialize } from "./canonical.js";
|
|
6
6
|
import { evaluate, parseCondition } from "./conditions.js";
|
|
7
7
|
import { splitTop } from "./lex.js";
|
|
8
|
+
import { MemoryRecords } from "./records.js";
|
|
8
9
|
import { Engine } from "./rules.js";
|
|
9
10
|
import { CrubalError, readTree } from "./tree.js";
|
|
10
11
|
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -16,7 +17,7 @@ const rankOf = (a) => {
|
|
|
16
17
|
};
|
|
17
18
|
/** Read shapes, filters, views (with a body), roles and `shown to` rules. */
|
|
18
19
|
export function readViews(source) {
|
|
19
|
-
const set = { shapes: new Map(), filters: new Map(), views: new Map(), roles: new Map(), shown: new Map() };
|
|
20
|
+
const set = { shapes: new Map(), filters: new Map(), views: new Map(), roles: new Map(), shown: new Map(), shownFor: new Map() };
|
|
20
21
|
for (const n of readTree(source)) {
|
|
21
22
|
if (n.kind !== "pair" || !n.key)
|
|
22
23
|
continue;
|
|
@@ -81,6 +82,8 @@ export function readViews(source) {
|
|
|
81
82
|
indexLine: get("index")?.line ?? n.line,
|
|
82
83
|
show: get("show")?.value ?? "public",
|
|
83
84
|
fresh: get("fresh")?.value ?? "write",
|
|
85
|
+
...(get("partition") ? { partition: get("partition")?.value.trim() } : {}),
|
|
86
|
+
...(get("version") ? { version: get("version")?.value.trim() } : {}),
|
|
84
87
|
});
|
|
85
88
|
}
|
|
86
89
|
else if (n.key === "role") {
|
|
@@ -96,15 +99,39 @@ export function readViews(source) {
|
|
|
96
99
|
else if (n.key === "rule") {
|
|
97
100
|
const then = get("then")?.value ?? "";
|
|
98
101
|
const m = then.match(/^(\S+) shown to (\S+)$/);
|
|
99
|
-
if (m)
|
|
100
|
-
|
|
102
|
+
if (m) {
|
|
103
|
+
const scope = get("for")?.value.trim() ?? "everywhere";
|
|
104
|
+
if (scope === "everywhere")
|
|
105
|
+
set.shown.set(m[1], m[2]);
|
|
106
|
+
else
|
|
107
|
+
set.shownFor.set(m[1], [...(set.shownFor.get(m[1]) ?? []), { for: scope, audience: m[2] }]);
|
|
108
|
+
}
|
|
101
109
|
}
|
|
102
110
|
}
|
|
103
111
|
return set;
|
|
104
112
|
}
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
-
|
|
113
|
+
/**
|
|
114
|
+
* The lowest audience that may see a facet (12.4): a show rule whose `for` covers the record decides for it,
|
|
115
|
+
* a class over a kind or level, else the rule for everywhere; no rule, the public.
|
|
116
|
+
*/
|
|
117
|
+
export function shownAudience(set, facet, record, dict) {
|
|
118
|
+
const key = facet.replace(/^\w+::/, "");
|
|
119
|
+
if (record) {
|
|
120
|
+
const scoped = set.shownFor.get(key) ?? [];
|
|
121
|
+
// A record wears its kind's base class without saying so (chapter 5).
|
|
122
|
+
const base = dict?.kinds.get(record.kind)?.base;
|
|
123
|
+
const byClass = scoped.find((s) => (record.wears ?? []).includes(s.for) || s.for === base);
|
|
124
|
+
if (byClass)
|
|
125
|
+
return byClass.audience;
|
|
126
|
+
const byKind = scoped.find((s) => s.for === record.kind || s.for === record.level);
|
|
127
|
+
if (byKind)
|
|
128
|
+
return byKind.audience;
|
|
129
|
+
}
|
|
130
|
+
return set.shown.get(key);
|
|
131
|
+
}
|
|
132
|
+
/** Can an audience see a facet, of this record when one is given? */
|
|
133
|
+
export function visibleTo(set, facet, audience, record, dict) {
|
|
134
|
+
const min = shownAudience(set, facet, record, dict);
|
|
108
135
|
return !min || rankOf(audience) >= rankOf(min);
|
|
109
136
|
}
|
|
110
137
|
/** The keys a slot's source reads. */
|
|
@@ -134,16 +161,30 @@ export function checkViews(set, dict) {
|
|
|
134
161
|
});
|
|
135
162
|
}
|
|
136
163
|
for (const s of set.shapes.values()) {
|
|
137
|
-
for (const sl of s.slots)
|
|
164
|
+
for (const sl of s.slots) {
|
|
165
|
+
// A page shape's slot may hold another view (12.1), run with the page's record as its context.
|
|
166
|
+
if (sl.from.startsWith("view:")) {
|
|
167
|
+
if (!set.views.has(sl.from.slice(5)))
|
|
168
|
+
throw new CrubalError(`the slot ${sl.to} holds the view ${sl.from.slice(5)}, which doesn't exist`, sl.line);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
138
171
|
for (const k of slotKeys(sl.from))
|
|
139
172
|
if (!sl.from.startsWith('"') || k)
|
|
140
173
|
if (!defined(k) && k !== "id")
|
|
141
174
|
throw new CrubalError(`the slot ${sl.to} reads ${k}, which isn't a facet`, sl.line);
|
|
175
|
+
}
|
|
142
176
|
}
|
|
177
|
+
for (const f of set.filters.values())
|
|
178
|
+
for (const d of f.dependent)
|
|
179
|
+
if (!defined(d))
|
|
180
|
+
throw new CrubalError(`the filter ${f.name} depends on ${d}, which isn't a facet`, f.line);
|
|
143
181
|
for (const v of set.views.values()) {
|
|
144
182
|
for (const s of v.shapes)
|
|
145
|
-
if (s
|
|
183
|
+
if (!CORE_SHAPES.includes(s) && !set.shapes.has(s))
|
|
146
184
|
throw new CrubalError(`${v.name} names the shape ${s}, which doesn't exist`, v.line);
|
|
185
|
+
// A list is the whole selection (12.2): a level, classes, a condition or curation would contradict it.
|
|
186
|
+
if (v.list && (v.level || v.wears.length || v.when || v.pin.length || v.hide.length || v.order.length))
|
|
187
|
+
throw new CrubalError(`${v.name} selects a list, so it takes no level, wears, when, pin, hide or order`, v.line);
|
|
147
188
|
for (const f of v.filters)
|
|
148
189
|
if (!set.filters.has(f))
|
|
149
190
|
throw new CrubalError(`${v.name} names the filter ${f}, which doesn't exist`, v.line);
|
|
@@ -172,6 +213,12 @@ export function checkViews(set, dict) {
|
|
|
172
213
|
}
|
|
173
214
|
}
|
|
174
215
|
}
|
|
216
|
+
/** The shapes the core defines (12.1, 12.5): the portable one, and structured data for the public. */
|
|
217
|
+
export const CORE_SHAPES = ["crubal:portable", "crubal:jsonld"];
|
|
218
|
+
/** A view's partition: what it declares, else `controlled` above member and `none` otherwise (12.2). */
|
|
219
|
+
export function partitionOf(v) {
|
|
220
|
+
return v.partition ?? (rankOf(v.show) > rankOf("member") ? "controlled" : "none");
|
|
221
|
+
}
|
|
175
222
|
function plainText(v) {
|
|
176
223
|
if (typeof v === "string")
|
|
177
224
|
return v;
|
|
@@ -188,11 +235,14 @@ function label(key) {
|
|
|
188
235
|
export class Views {
|
|
189
236
|
dict;
|
|
190
237
|
set;
|
|
191
|
-
records = new
|
|
238
|
+
records = new MemoryRecords();
|
|
192
239
|
engine;
|
|
193
|
-
|
|
240
|
+
/** Over a record set of the caller's (a store's working set), or an empty memory set to `put` into. */
|
|
241
|
+
constructor(dict, set, records) {
|
|
194
242
|
this.dict = dict;
|
|
195
243
|
this.set = set;
|
|
244
|
+
if (records)
|
|
245
|
+
this.records = records;
|
|
196
246
|
this.engine = new Engine(dict, []);
|
|
197
247
|
}
|
|
198
248
|
put(r) {
|
|
@@ -202,12 +252,21 @@ export class Views {
|
|
|
202
252
|
const up = r.of ? this.records.get(r.of) : undefined;
|
|
203
253
|
return { ...(up ? this.effective(up) : {}), ...r.values };
|
|
204
254
|
}
|
|
255
|
+
/** The classes a record wears, those of the records above it included (5.5). */
|
|
256
|
+
wearsOf(r) {
|
|
257
|
+
const up = r.of ? this.records.get(r.of) : undefined;
|
|
258
|
+
return [...(up ? this.wearsOf(up) : []), ...(r.wears ?? [])];
|
|
259
|
+
}
|
|
205
260
|
below(r, level) {
|
|
206
|
-
return
|
|
261
|
+
return this.records.childrenOf(r.id, "of", level);
|
|
207
262
|
}
|
|
208
263
|
/** The values an address reads, as a list: own values, or a lower level's (`unit::price`). */
|
|
209
264
|
valuesAt(r, address) {
|
|
210
265
|
const m = address.match(/^(\w+)::(.+)$/);
|
|
266
|
+
// A level prefix reads the records below at that level, or the record itself when it sits there
|
|
267
|
+
// (a standing-alone lower record answers for its own level, 1.7).
|
|
268
|
+
if (m && r.level === m[1])
|
|
269
|
+
return this.valuesAt(r, m[2]);
|
|
211
270
|
if (m)
|
|
212
271
|
return this.below(r, m[1]).flatMap((u) => this.valuesAt(u, m[2]));
|
|
213
272
|
const v = this.effective(r)[address];
|
|
@@ -224,13 +283,13 @@ export class Views {
|
|
|
224
283
|
return (f.values ?? []).some((x) => x.value === v);
|
|
225
284
|
return true;
|
|
226
285
|
}
|
|
227
|
-
canSee(key, audience, caller) {
|
|
228
|
-
if (!visibleTo(this.set, key, audience))
|
|
286
|
+
canSee(key, audience, caller, record) {
|
|
287
|
+
if (!visibleTo(this.set, key, audience, record, this.dict))
|
|
229
288
|
return false;
|
|
230
289
|
if (!caller || rankOf(audience) <= rankOf("member"))
|
|
231
290
|
return true;
|
|
232
291
|
// Above member, a person sees what members see plus what their roles grant (default deny).
|
|
233
|
-
if (visibleTo(this.set, key, "member"))
|
|
292
|
+
if (visibleTo(this.set, key, "member", record, this.dict))
|
|
234
293
|
return true;
|
|
235
294
|
const held = caller.values.roles;
|
|
236
295
|
const names = (Array.isArray(held) ? held : held ? [held] : []).map((x) => (isObj(x) && "$link" in x ? String(x.$link) : String(x)));
|
|
@@ -241,7 +300,7 @@ export class Views {
|
|
|
241
300
|
}
|
|
242
301
|
slot(r, s, audience, caller) {
|
|
243
302
|
const keys = slotKeys(s.from);
|
|
244
|
-
if (!keys.every((k) => this.canSee(k, audience, caller)))
|
|
303
|
+
if (!keys.every((k) => this.canSee(k, audience, caller, r)))
|
|
245
304
|
return undefined;
|
|
246
305
|
if (s.from.startsWith('"'))
|
|
247
306
|
return this.engine.template(s.from, this.effective(r));
|
|
@@ -270,6 +329,97 @@ export class Views {
|
|
|
270
329
|
}
|
|
271
330
|
return this.fit(s.from, v, audience) ? v : undefined;
|
|
272
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* What a condition reads of a record (6.3): the envelope under `crubal:`, and bare `id` and `status`
|
|
334
|
+
* too unless a facet of that name holds a value, which then wins.
|
|
335
|
+
*/
|
|
336
|
+
conditionValues(r) {
|
|
337
|
+
const status = r.status?.state ?? "active";
|
|
338
|
+
return { id: r.id, status, "crubal:id": r.id, "crubal:status": status, ...this.effective(r) };
|
|
339
|
+
}
|
|
340
|
+
/** The values of the view's order keys as text, and the pin position: what an index orders rows by (12.7). */
|
|
341
|
+
sortOf(v, r) {
|
|
342
|
+
const vals = this.effective(r);
|
|
343
|
+
const sort = v.order.map((o) => plainText((vals[o.key] ?? "")));
|
|
344
|
+
const at = v.pin.indexOf(r.id);
|
|
345
|
+
return at >= 0 ? { sort, pin: at + 1 } : { sort };
|
|
346
|
+
}
|
|
347
|
+
/** The buckets a record falls into, per filter of the view (12.6): what an index counts from. */
|
|
348
|
+
bucketsFor(v, r) {
|
|
349
|
+
const out = {};
|
|
350
|
+
for (const fname of v.filters) {
|
|
351
|
+
const f = this.set.filters.get(fname);
|
|
352
|
+
if (!f)
|
|
353
|
+
continue;
|
|
354
|
+
const names = this.bucketsOf(f).filter((b) => this.inBucket(r, f, b)).map((b) => b.name);
|
|
355
|
+
if (names.length)
|
|
356
|
+
out[fname] = names;
|
|
357
|
+
// A dependent facet (12.3): its values, one bucket each, so an index can count them inside this filter's.
|
|
358
|
+
for (const dep of f.dependent) {
|
|
359
|
+
const vals = this.valuesAt(r, dep).filter((x) => this.fit(dep, x, "public")).map(plainText);
|
|
360
|
+
if (vals.length)
|
|
361
|
+
out[dep] = [...new Set(vals)];
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
/** Dependent counts (12.3) over records already narrowed by the other filters. */
|
|
367
|
+
nestedCounts(v, pool) {
|
|
368
|
+
const out = {};
|
|
369
|
+
for (const fname of v.filters) {
|
|
370
|
+
const f = this.set.filters.get(fname);
|
|
371
|
+
if (!f?.dependent.length)
|
|
372
|
+
continue;
|
|
373
|
+
const rows = pool(fname);
|
|
374
|
+
const per = {};
|
|
375
|
+
for (const b of this.bucketsOf(f)) {
|
|
376
|
+
const inB = rows.filter((r) => this.inBucket(r, f, b));
|
|
377
|
+
const byDep = {};
|
|
378
|
+
for (const dep of f.dependent) {
|
|
379
|
+
const tally = {};
|
|
380
|
+
for (const r of inB)
|
|
381
|
+
for (const val of new Set(this.valuesAt(r, dep).map(plainText)))
|
|
382
|
+
tally[val] = (tally[val] ?? 0) + 1;
|
|
383
|
+
byDep[dep] = tally;
|
|
384
|
+
}
|
|
385
|
+
per[b.name] = byDep;
|
|
386
|
+
}
|
|
387
|
+
out[fname] = per;
|
|
388
|
+
}
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
/** Does a view select this record (its own tests, before curation and the shape's required slots)? */
|
|
392
|
+
selects(v, r, opts) {
|
|
393
|
+
if (v.level && r.level !== v.level)
|
|
394
|
+
return false;
|
|
395
|
+
// An archived record is left out unless the view selects on status explicitly (1.12).
|
|
396
|
+
if (r.status?.state === "archived" && !(v.when && /\bstatus\b/.test(v.when)))
|
|
397
|
+
return false;
|
|
398
|
+
if (v.wears.some((w) => !this.wearsOf(r).includes(w)))
|
|
399
|
+
return false;
|
|
400
|
+
if (v.hide.includes(r.id))
|
|
401
|
+
return false;
|
|
402
|
+
const vals = this.conditionValues(r);
|
|
403
|
+
if (v.when && evaluate(parseCondition(v.when), vals) !== true)
|
|
404
|
+
return false;
|
|
405
|
+
if (v.of) {
|
|
406
|
+
const ctx = opts.context;
|
|
407
|
+
if (!ctx)
|
|
408
|
+
return false;
|
|
409
|
+
const cvals = this.conditionValues(ctx);
|
|
410
|
+
const cond = v.of.replace(/context\.(\w+)/g, (_, k) => JSON.stringify(cvals[k] === undefined ? "" : plainText(cvals[k])));
|
|
411
|
+
if (evaluate(parseCondition(cond), vals) !== true)
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
/** The records a list holds, in its order (12.2): the links in its `crubal:items`. */
|
|
417
|
+
listed(v) {
|
|
418
|
+
const list = v.list ? this.records.get(v.list) : undefined;
|
|
419
|
+
const items = list?.values["crubal:items"];
|
|
420
|
+
const ids = (Array.isArray(items) ? items : items === undefined ? [] : [items]).map((x) => (isObj(x) && "$link" in x ? String(x.$link) : String(x)));
|
|
421
|
+
return ids.flatMap((id) => this.records.get(id) ?? []).filter((r) => r.status?.state !== "archived");
|
|
422
|
+
}
|
|
273
423
|
inBucket(r, f, b) {
|
|
274
424
|
return this.valuesAt(r, f.facet).some((v) => {
|
|
275
425
|
const t = plainText(v);
|
|
@@ -289,30 +439,17 @@ export class Views {
|
|
|
289
439
|
/** The records a view selects, curated and ordered, before filters and paging. */
|
|
290
440
|
select(v, opts = {}) {
|
|
291
441
|
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
442
|
const shape = this.set.shapes.get(v.shapes[0] ?? "");
|
|
315
|
-
|
|
443
|
+
const fills = (r) => (shape?.slots ?? []).every((s) => !s.required || this.slot(r, s, audience, opts.caller) !== undefined);
|
|
444
|
+
if (v.list) {
|
|
445
|
+
// A hand-made list (12.2): its records in its order, whatever their levels; no curation applies.
|
|
446
|
+
const listed = this.listed(v).filter(fills);
|
|
447
|
+
return opts.only ? listed.filter((r) => opts.only.includes(r.id)) : listed;
|
|
448
|
+
}
|
|
449
|
+
const pool = opts.only ? opts.only.flatMap((id) => this.records.get(id) ?? []) : v.level ? this.records.atLevel(v.level) : this.records.all();
|
|
450
|
+
let rows = pool.filter((r) => this.selects(v, r, opts));
|
|
451
|
+
// A record whose required slot is empty is dropped from the view.
|
|
452
|
+
rows = rows.filter(fills);
|
|
316
453
|
const cmp = (a, b) => {
|
|
317
454
|
for (const o of v.order) {
|
|
318
455
|
const x = plainText((this.effective(a)[o.key] ?? ""));
|
|
@@ -325,13 +462,27 @@ export class Views {
|
|
|
325
462
|
const pinned = v.pin.map((id) => rows.find((r) => r.id === id)).filter((r) => !!r);
|
|
326
463
|
return [...pinned, ...rows.filter((r) => !v.pin.includes(r.id)).sort(cmp)];
|
|
327
464
|
}
|
|
465
|
+
/** The roles whose `see` a slot needs above member (12.6 role marks): none when members may see it. */
|
|
466
|
+
marksFor(s, audience) {
|
|
467
|
+
if (rankOf(audience) <= rankOf("member"))
|
|
468
|
+
return [];
|
|
469
|
+
const keys = slotKeys(s.from);
|
|
470
|
+
if (keys.every((k) => visibleTo(this.set, k, "member")))
|
|
471
|
+
return [];
|
|
472
|
+
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();
|
|
473
|
+
}
|
|
328
474
|
/** A view's projection for an audience (or a caller), with filter counts and a page. */
|
|
329
475
|
project(name, opts = {}) {
|
|
330
476
|
const v = this.set.views.get(name);
|
|
331
477
|
if (!v)
|
|
332
478
|
throw new Error(`no view ${name}`);
|
|
333
|
-
|
|
334
|
-
const
|
|
479
|
+
// A caller's audience is read off their edges (14.4) when access is at hand; a stated value otherwise.
|
|
480
|
+
const audience = opts.caller ? (opts.access ? opts.access.audienceOf(opts.caller.id) : String(opts.caller.values.audience ?? "member")) : (opts.audience ?? v.show);
|
|
481
|
+
const partition = partitionOf(v);
|
|
482
|
+
let all = this.select(v, { ...opts, audience });
|
|
483
|
+
// A partitioned view serves a caller only the rows their edges match (12.6, 14.10).
|
|
484
|
+
if (partition !== "none" && opts.access && opts.caller)
|
|
485
|
+
all = all.filter((r) => opts.access.standsIn(opts.caller.id, r.id, partition));
|
|
335
486
|
const picks = opts.picks ?? {};
|
|
336
487
|
const passes = (r, except) => Object.entries(picks).every(([fname, chosen]) => {
|
|
337
488
|
if (fname === except)
|
|
@@ -340,32 +491,169 @@ export class Views {
|
|
|
340
491
|
return !f || this.bucketsOf(f).some((b) => chosen.includes(b.name) && this.inBucket(r, f, b));
|
|
341
492
|
});
|
|
342
493
|
const counts = {};
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
494
|
+
if (!opts.all) {
|
|
495
|
+
for (const fname of v.filters) {
|
|
496
|
+
const f = this.set.filters.get(fname);
|
|
497
|
+
if (!f)
|
|
498
|
+
continue;
|
|
499
|
+
const pool = all.filter((r) => passes(r, fname));
|
|
500
|
+
counts[fname] = Object.fromEntries(this.bucketsOf(f).map((b) => [b.name, pool.filter((r) => this.inBucket(r, f, b)).length]));
|
|
501
|
+
}
|
|
349
502
|
}
|
|
503
|
+
const nested = opts.all ? {} : this.nestedCounts(v, (fname) => all.filter((r) => passes(r, fname)));
|
|
350
504
|
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);
|
|
505
|
+
const pages = opts.all ? 1 : Math.max(1, Math.ceil(chosen.length / v.page));
|
|
506
|
+
const page = opts.all ? 1 : (opts.page ?? 1);
|
|
507
|
+
const onPage = opts.all ? chosen : chosen.slice((page - 1) * v.page, page * v.page);
|
|
354
508
|
const pickList = Object.entries(picks).filter(([, c]) => c.length);
|
|
355
509
|
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
|
|
510
|
+
const [shapeName = "", ...more] = v.shapes;
|
|
357
511
|
const rows = onPage.map((r) => {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
for (const s of
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
512
|
+
const row = this.shaped(r, shapeName, audience, opts);
|
|
513
|
+
// Further shapes (12.1) share the selection: their output rides under `shapes`, served on request.
|
|
514
|
+
const shapes = {};
|
|
515
|
+
for (const s of more) {
|
|
516
|
+
const out = this.shaped(r, s, audience, opts);
|
|
517
|
+
delete out.record;
|
|
518
|
+
if (Object.keys(out).length)
|
|
519
|
+
shapes[s] = s === "crubal:jsonld" ? out.jsonld : s === "crubal:portable" ? out.values : out;
|
|
365
520
|
}
|
|
521
|
+
if (Object.keys(shapes).length)
|
|
522
|
+
row.shapes = shapes;
|
|
523
|
+
// A partitioned view's row carries its record's ancestors, so an index can match edges to it.
|
|
524
|
+
if (partition !== "none" && opts.access)
|
|
525
|
+
row.ancestors = opts.access.ancestors(r.id);
|
|
366
526
|
return row;
|
|
367
527
|
});
|
|
368
|
-
return { rows, records: onPage.map((r) => r.id), counts, pages, indexable };
|
|
528
|
+
return { rows, records: onPage.map((r) => r.id), counts, ...(Object.keys(nested).length ? { nested } : {}), pages, indexable };
|
|
529
|
+
}
|
|
530
|
+
/** One record in one shape: a core shape's object, or the shape's slots with their role marks. */
|
|
531
|
+
shaped(r, shapeName, audience, opts) {
|
|
532
|
+
if (shapeName === "crubal:portable")
|
|
533
|
+
return { record: r.id, values: this.portable(r, audience, opts.caller) };
|
|
534
|
+
if (shapeName === "crubal:jsonld") {
|
|
535
|
+
const ld = this.jsonld(r, audience);
|
|
536
|
+
return ld ? { record: r.id, jsonld: ld } : { record: r.id };
|
|
537
|
+
}
|
|
538
|
+
const row = { record: r.id };
|
|
539
|
+
const marks = {};
|
|
540
|
+
for (const s of this.set.shapes.get(shapeName)?.slots ?? []) {
|
|
541
|
+
// A slot holding a view (12.1): that view's rows, with this record as their context.
|
|
542
|
+
if (s.from.startsWith("view:")) {
|
|
543
|
+
const inner = this.nestedRows(s.from.slice(5), r, audience, opts);
|
|
544
|
+
if (inner.length)
|
|
545
|
+
row[s.to] = inner;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
const val = this.slot(r, s, audience, opts.caller);
|
|
549
|
+
if (val !== undefined)
|
|
550
|
+
row[s.to] = val;
|
|
551
|
+
const m = opts.caller ? [] : this.marksFor(s, audience);
|
|
552
|
+
if (val !== undefined && m.length)
|
|
553
|
+
marks[s.to] = m.length === 1 ? m[0] : m;
|
|
554
|
+
}
|
|
555
|
+
if (Object.keys(marks).length)
|
|
556
|
+
row.marks = marks;
|
|
557
|
+
return row;
|
|
558
|
+
}
|
|
559
|
+
/** A nested view's rows for a page record (12.1, 12.2 `of`): every page, in the view's order. */
|
|
560
|
+
nestedRows(name, context, audience, opts) {
|
|
561
|
+
return this.project(name, { audience, context, all: true, ...(opts.caller ? { caller: opts.caller } : {}), ...(opts.access ? { access: opts.access } : {}) }).rows;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Structured data (12.5): schema.org JSON-LD for the public from the kind's type per level (11.2) and
|
|
565
|
+
* each facet's `same-as` in the schema prefix; nothing for any other audience.
|
|
566
|
+
*/
|
|
567
|
+
jsonld(r, audience = "public") {
|
|
568
|
+
if (audience !== "public")
|
|
569
|
+
return undefined;
|
|
570
|
+
const out = { "@context": "https://schema.org/" };
|
|
571
|
+
const kind = this.dict.kinds.get(this.dict.levels.get(r.level) ?? "");
|
|
572
|
+
const type = typeof kind?.schema === "string" ? kind.schema : kind?.schema?.[r.level];
|
|
573
|
+
if (type)
|
|
574
|
+
out["@type"] = type.replace(/^schema:/, "");
|
|
575
|
+
for (const [key, v] of Object.entries(this.effective(r))) {
|
|
576
|
+
if (!this.canSee(key, "public", undefined, r))
|
|
577
|
+
continue;
|
|
578
|
+
const f = this.dict.facets.get(key);
|
|
579
|
+
const same = f?.sameAs.find((s) => s.startsWith("schema:"));
|
|
580
|
+
if (!same)
|
|
581
|
+
continue;
|
|
582
|
+
const vals = (Array.isArray(v) ? v : [v]).filter((x) => this.fit(key, x, "public") && !(isObj(x) && "$empty" in x));
|
|
583
|
+
if (!vals.length)
|
|
584
|
+
continue;
|
|
585
|
+
const prop = same.slice(7);
|
|
586
|
+
const plain = vals.map((x) => {
|
|
587
|
+
if (isObj(x) && "$money" in x)
|
|
588
|
+
return { price: String(x.$money), priceCurrency: String(x.currency) };
|
|
589
|
+
if (isObj(x) && "$qty" in x)
|
|
590
|
+
return `${x.$qty} ${x.unit}`;
|
|
591
|
+
if (isObj(x) && "$link" in x)
|
|
592
|
+
return { "@id": String(x.$link) };
|
|
593
|
+
if (isObj(x) && "$image" in x)
|
|
594
|
+
return String(x.$image);
|
|
595
|
+
return plainText(x);
|
|
596
|
+
});
|
|
597
|
+
out[prop] = plain.length === 1 ? plain[0] : plain;
|
|
598
|
+
}
|
|
599
|
+
return out;
|
|
600
|
+
}
|
|
601
|
+
streams = new Map();
|
|
602
|
+
/** A view's version, as its stream names it. */
|
|
603
|
+
versionOf(name) {
|
|
604
|
+
return this.set.views.get(name)?.version ?? "";
|
|
605
|
+
}
|
|
606
|
+
/** A new version of a view: its stream starts over at the next publish (12.6). */
|
|
607
|
+
revise(name, version) {
|
|
608
|
+
const v = this.set.views.get(name);
|
|
609
|
+
if (!v)
|
|
610
|
+
throw new Error(`no view ${name}`);
|
|
611
|
+
v.version = version;
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* The projection stream (12.6): the entries since the last publish of this view for this audience, one
|
|
615
|
+
* per row that is new or changed, and one removal per row that left. Numbered per view and audience; a
|
|
616
|
+
* new version of the view starts over at 1, its first entry marked `reset`.
|
|
617
|
+
*/
|
|
618
|
+
stream(name, opts = {}) {
|
|
619
|
+
const v = this.set.views.get(name);
|
|
620
|
+
if (!v)
|
|
621
|
+
throw new Error(`no view ${name}`);
|
|
622
|
+
const audience = opts.audience ?? v.show;
|
|
623
|
+
const key = `${name}|${audience}`;
|
|
624
|
+
const version = v.version ?? "";
|
|
625
|
+
const projection = version ? `${name} /${version}` : name;
|
|
626
|
+
const had = this.streams.get(key);
|
|
627
|
+
const last = had && had.version === version ? had : { version, rows: new Map(), seq: 0, log: [], published: false };
|
|
628
|
+
const reset = !!had && had.version !== version;
|
|
629
|
+
const now = new Map();
|
|
630
|
+
for (const row of this.project(name, { ...opts, audience, all: true }).rows)
|
|
631
|
+
now.set(String(row.record), row);
|
|
632
|
+
const out = [];
|
|
633
|
+
const next = { version, rows: new Map(), seq: last.seq, log: [...last.log], published: true };
|
|
634
|
+
for (const [id, row] of now) {
|
|
635
|
+
const rec = this.records.get(id);
|
|
636
|
+
const buckets = rec ? this.bucketsFor(v, rec) : {};
|
|
637
|
+
const order = rec ? this.sortOf(v, rec) : { sort: [] };
|
|
638
|
+
const s = serialize({ row, buckets, order: order });
|
|
639
|
+
next.rows.set(id, s);
|
|
640
|
+
if (last.rows.get(id) !== s)
|
|
641
|
+
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 } : {}) });
|
|
642
|
+
}
|
|
643
|
+
for (const id of last.rows.keys())
|
|
644
|
+
if (!now.has(id))
|
|
645
|
+
out.push({ projection, audience, seq: ++next.seq, record: id, removed: true });
|
|
646
|
+
if (reset && out[0])
|
|
647
|
+
out[0].reset = true;
|
|
648
|
+
next.log.push(...out);
|
|
649
|
+
this.streams.set(key, next);
|
|
650
|
+
return out;
|
|
651
|
+
}
|
|
652
|
+
/** The current version's entries after `seq`, for an index resuming from its cursor (12.6.2). */
|
|
653
|
+
entriesSince(name, seq, audience) {
|
|
654
|
+
const v = this.set.views.get(name);
|
|
655
|
+
const s = this.streams.get(`${name}|${audience ?? v?.show ?? "public"}`);
|
|
656
|
+
return (s?.log ?? []).filter((e) => e.seq > seq);
|
|
369
657
|
}
|
|
370
658
|
/** The portable shape (12.1): key, label, type and value for every value the audience may see. */
|
|
371
659
|
portable(r, audience = "public", caller) {
|
|
@@ -387,13 +675,21 @@ export class Views {
|
|
|
387
675
|
if (v.of)
|
|
388
676
|
keys.push(...parseConditionKeys(v.of.replace(/context\.\w+/g, '""')));
|
|
389
677
|
keys.push(...v.order.map((o) => o.key));
|
|
678
|
+
if (v.list)
|
|
679
|
+
keys.push("crubal:items");
|
|
390
680
|
for (const s of v.shapes)
|
|
391
|
-
for (const sl of this.set.shapes.get(s)?.slots ?? [])
|
|
392
|
-
|
|
681
|
+
for (const sl of this.set.shapes.get(s)?.slots ?? []) {
|
|
682
|
+
if (sl.from.startsWith("view:"))
|
|
683
|
+
keys.push(...this.readSet(sl.from.slice(5)));
|
|
684
|
+
else
|
|
685
|
+
keys.push(...slotKeys(sl.from));
|
|
686
|
+
}
|
|
687
|
+
for (const f of v.filters)
|
|
688
|
+
keys.push(...(this.set.filters.get(f)?.dependent ?? []));
|
|
393
689
|
for (const f of v.filters)
|
|
394
690
|
keys.push(this.set.filters.get(f)?.facet ?? f);
|
|
395
691
|
for (const k of [...keys])
|
|
396
|
-
if (this.set.shown.has(k))
|
|
692
|
+
if (this.set.shown.has(k) || this.set.shownFor.has(k))
|
|
397
693
|
keys.push(k);
|
|
398
694
|
return new Set(keys.filter((k) => k !== "id"));
|
|
399
695
|
}
|
|
@@ -404,17 +700,46 @@ export class Views {
|
|
|
404
700
|
return [];
|
|
405
701
|
const reads = this.readSet(name);
|
|
406
702
|
const out = new Set();
|
|
703
|
+
// The envelope is in every read set (12.6): a status, wears, within or of change stales the row.
|
|
704
|
+
const was = this.records.get(write.id);
|
|
705
|
+
const envelope = ["status", "wears", "within", "of"].some((m) => serialize((write[m] ?? "")) !== serialize((was?.[m] ?? "")));
|
|
706
|
+
if (envelope)
|
|
707
|
+
out.add(write.id);
|
|
407
708
|
for (const key of Object.keys(write.values)) {
|
|
408
709
|
if (reads.has(key) && (!v.level || write.level === v.level))
|
|
409
710
|
out.add(write.id);
|
|
410
711
|
if (reads.has(`${write.level}::${key}`) && write.of)
|
|
411
712
|
out.add(write.of);
|
|
412
713
|
if (reads.has(key) && v.level && write.level !== v.level) {
|
|
413
|
-
for (const r of this.records.
|
|
414
|
-
|
|
415
|
-
out.add(r.id);
|
|
714
|
+
for (const r of this.records.childrenOf(write.id, "of", v.level))
|
|
715
|
+
out.add(r.id);
|
|
416
716
|
}
|
|
417
717
|
}
|
|
718
|
+
// A write to the list's items (12.2) stales every row: those it holds now, and those it will hold.
|
|
719
|
+
if (v.list && write.id === v.list) {
|
|
720
|
+
for (const r of this.listed(v))
|
|
721
|
+
out.add(r.id);
|
|
722
|
+
const items = write.values["crubal:items"];
|
|
723
|
+
for (const x of Array.isArray(items) ? items : items === undefined ? [] : [items])
|
|
724
|
+
out.add(isObj(x) && "$link" in x ? String(x.$link) : String(x));
|
|
725
|
+
out.delete(write.id);
|
|
726
|
+
}
|
|
727
|
+
// A page whose nested view holds, or would hold, the written record is stale (12.1).
|
|
728
|
+
for (const s of v.shapes)
|
|
729
|
+
for (const sl of this.set.shapes.get(s)?.slots ?? []) {
|
|
730
|
+
if (!sl.from.startsWith("view:"))
|
|
731
|
+
continue;
|
|
732
|
+
const inner = this.set.views.get(sl.from.slice(5));
|
|
733
|
+
if (!inner)
|
|
734
|
+
continue;
|
|
735
|
+
for (const page of this.select(v, { audience: v.show })) {
|
|
736
|
+
const o = { audience: v.show, context: page };
|
|
737
|
+
const is = was ? this.selects(inner, was, o) : false;
|
|
738
|
+
const will = this.selects(inner, write, o);
|
|
739
|
+
if (is || will)
|
|
740
|
+
out.add(page.id);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
418
743
|
return [...out];
|
|
419
744
|
}
|
|
420
745
|
}
|