@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.
- 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/address.d.ts +29 -0
- package/dist/src/address.js +59 -0
- package/dist/src/address.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 +204 -11
- package/dist/src/exchange.js +762 -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 +19 -6
- package/dist/src/index.js +18 -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 +32 -0
- package/dist/src/records.js +104 -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 +38 -3
- package/dist/src/rules.js +184 -30
- 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 +116 -6
- package/dist/src/views.js +484 -74
- package/dist/src/views.js.map +1 -1
- 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
|
-
|
|
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
|
-
/**
|
|
106
|
-
|
|
107
|
-
|
|
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) =>
|
|
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 =
|
|
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
|
|
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
|
|
240
|
+
records = new MemoryRecords();
|
|
192
241
|
engine;
|
|
193
|
-
|
|
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
|
|
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
|
|
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(
|
|
228
|
-
|
|
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
|
|
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 =
|
|
264
|
-
const
|
|
265
|
-
|
|
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 ||
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
334
|
-
const
|
|
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
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
579
|
+
const [shapeName = "", ...more] = v.shapes;
|
|
357
580
|
const rows = onPage.map((r) => {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
for (const s of
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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.
|
|
414
|
-
|
|
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
|
}
|