@cruba/crubal 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +97 -2
- package/dist/bin/crubal.d.ts +2 -0
- package/dist/bin/crubal.js +103 -0
- package/dist/bin/crubal.js.map +1 -0
- 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/browser.d.ts +10 -0
- package/dist/src/browser.js +13 -0
- package/dist/src/browser.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/canonical.d.ts +6 -0
- package/dist/src/canonical.js +47 -0
- package/dist/src/canonical.js.map +1 -0
- package/dist/src/conditions.d.ts +28 -0
- package/dist/src/conditions.js +216 -0
- package/dist/src/conditions.js.map +1 -0
- package/dist/src/corpus.d.ts +8 -0
- package/dist/src/corpus.js +1207 -0
- package/dist/src/corpus.js.map +1 -0
- package/dist/src/defs.d.ts +151 -0
- package/dist/src/defs.js +611 -0
- package/dist/src/defs.js.map +1 -0
- package/dist/src/dictionary.d.ts +65 -0
- package/dist/src/dictionary.js +557 -0
- package/dist/src/dictionary.js.map +1 -0
- package/dist/src/exchange.d.ts +236 -0
- package/dist/src/exchange.js +1035 -0
- package/dist/src/exchange.js.map +1 -0
- 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 +27 -0
- package/dist/src/index.js +29 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lex.d.ts +21 -0
- package/dist/src/lex.js +142 -0
- package/dist/src/lex.js.map +1 -0
- 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 +111 -0
- package/dist/src/migrate.js +286 -0
- package/dist/src/migrate.js.map +1 -0
- package/dist/src/model.d.ts +68 -0
- package/dist/src/model.js +188 -0
- package/dist/src/model.js.map +1 -0
- package/dist/src/packages.d.ts +69 -0
- package/dist/src/packages.js +232 -0
- package/dist/src/packages.js.map +1 -0
- package/dist/src/print-text.d.ts +7 -0
- package/dist/src/print-text.js +216 -0
- package/dist/src/print-text.js.map +1 -0
- 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 +10 -0
- package/dist/src/read-text.js +385 -0
- package/dist/src/read-text.js.map +1 -0
- 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 +96 -0
- package/dist/src/rules.js +611 -0
- package/dist/src/rules.js.map +1 -0
- 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 +164 -0
- package/dist/src/store.js +538 -0
- package/dist/src/store.js.map +1 -0
- package/dist/src/tree.d.ts +35 -0
- package/dist/src/tree.js +159 -0
- package/dist/src/tree.js.map +1 -0
- 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 -0
- package/dist/src/units.js +98 -0
- package/dist/src/units.js.map +1 -0
- package/dist/src/views.d.ts +216 -0
- package/dist/src/views.js +761 -0
- package/dist/src/views.js.map +1 -0
- package/package.json +60 -4
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
// views.ts: the Views level (spec chapter 12). Shapes map facets into display slots like a send
|
|
2
|
+
// contract's writes; views select records, curate, order and page them; filters are sub-indexes whose
|
|
3
|
+
// counts follow the other picks; audiences and show rules decide visibility, and roles narrow an audience;
|
|
4
|
+
// a view's read set decides which projection rows go stale.
|
|
5
|
+
import { serialize } from "./canonical.js";
|
|
6
|
+
import { evaluate, parseCondition } from "./conditions.js";
|
|
7
|
+
import { splitTop } from "./lex.js";
|
|
8
|
+
import { MemoryRecords } from "./records.js";
|
|
9
|
+
import { Engine } from "./rules.js";
|
|
10
|
+
import { CrubalError, readTree } from "./tree.js";
|
|
11
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
12
|
+
const unq = (s) => (s.trim().startsWith('"') ? JSON.parse(s.trim()) : s.trim());
|
|
13
|
+
export const AUDIENCES = ["public", "member", "staff", "owner"];
|
|
14
|
+
const rankOf = (a) => {
|
|
15
|
+
const i = AUDIENCES.indexOf(a);
|
|
16
|
+
return i < 0 ? AUDIENCES.length : i;
|
|
17
|
+
};
|
|
18
|
+
/** Read shapes, filters, views (with a body), roles and `shown to` rules. */
|
|
19
|
+
export function readViews(source) {
|
|
20
|
+
const set = { shapes: new Map(), filters: new Map(), views: new Map(), roles: new Map(), shown: new Map(), shownFor: new Map() };
|
|
21
|
+
for (const n of readTree(source)) {
|
|
22
|
+
if (n.kind !== "pair" || !n.key)
|
|
23
|
+
continue;
|
|
24
|
+
const name = n.value.trim();
|
|
25
|
+
const get = (k) => n.children.find((c) => c.key === k);
|
|
26
|
+
if (n.key === "shape") {
|
|
27
|
+
const slots = (get("slots")?.children ?? []).map((l) => {
|
|
28
|
+
const at = l.text.lastIndexOf(" -> ");
|
|
29
|
+
if (at < 0)
|
|
30
|
+
throw new CrubalError(`a slot is "from -> slot": ${l.text}`, l.line);
|
|
31
|
+
let from = l.text.slice(0, at).trim();
|
|
32
|
+
const required = from.startsWith("!");
|
|
33
|
+
if (required)
|
|
34
|
+
from = from.slice(1);
|
|
35
|
+
return { from, to: l.text.slice(at + 4).trim(), required, line: l.line };
|
|
36
|
+
});
|
|
37
|
+
set.shapes.set(name, { name, slots, line: n.line });
|
|
38
|
+
}
|
|
39
|
+
else if (n.key === "filter") {
|
|
40
|
+
const b = get("buckets");
|
|
41
|
+
const buckets = b?.children.map((c) => {
|
|
42
|
+
// A bucket line is `name: values`; the name may be quoted, with spaces ("Like new": NM, VG).
|
|
43
|
+
const m = c.text.match(/^("(?:[^"\\]|\\.)*"|[^:]+):\s+(.*)$/);
|
|
44
|
+
if (!m)
|
|
45
|
+
throw new CrubalError(`a bucket is "name: values": ${c.text}`, c.line);
|
|
46
|
+
const label = unq(m[1]);
|
|
47
|
+
const rest = m[2].trim();
|
|
48
|
+
const range = rest.match(/^(-?[\d.]+)\.\.(-?[\d.]+)$/);
|
|
49
|
+
return range ? { name: label, range: [Number(range[1]), Number(range[2])] } : { name: label, values: splitTop(rest, ",", c.line).map(unq) };
|
|
50
|
+
});
|
|
51
|
+
set.filters.set(name, {
|
|
52
|
+
name,
|
|
53
|
+
facet: get("facet")?.value ?? name,
|
|
54
|
+
...(buckets ? { buckets } : {}),
|
|
55
|
+
dependent: (get("dependent")?.value ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
|
56
|
+
line: n.line,
|
|
57
|
+
bucketLines: b?.children.map((c) => c.line) ?? [],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
else if (n.key === "view" && n.children.length) {
|
|
61
|
+
const sel = get("select");
|
|
62
|
+
const s = (k) => sel?.children.find((c) => c.key === k)?.value;
|
|
63
|
+
const list = (k) => (get(k)?.value ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
64
|
+
set.views.set(name, {
|
|
65
|
+
name,
|
|
66
|
+
line: n.line,
|
|
67
|
+
...(s("level") ? { level: s("level") } : {}),
|
|
68
|
+
wears: (s("wears") ?? "").split(",").map((x) => x.trim()).filter(Boolean),
|
|
69
|
+
...(s("when") ? { when: s("when") } : {}),
|
|
70
|
+
...(s("list") ? { list: s("list") } : {}),
|
|
71
|
+
...(get("of") ? { of: get("of")?.value } : {}),
|
|
72
|
+
pin: list("pin"),
|
|
73
|
+
hide: list("hide"),
|
|
74
|
+
order: list("order").map((o) => {
|
|
75
|
+
const [key = "", dir] = o.split(/\s+/);
|
|
76
|
+
return { key, dir: dir === "after" ? "after" : "before" };
|
|
77
|
+
}),
|
|
78
|
+
page: Number(get("page")?.value ?? 24),
|
|
79
|
+
shapes: list("shape"),
|
|
80
|
+
filters: list("filters"),
|
|
81
|
+
index: list("index"),
|
|
82
|
+
indexLine: get("index")?.line ?? n.line,
|
|
83
|
+
show: get("show")?.value ?? "public",
|
|
84
|
+
fresh: get("fresh")?.value ?? "write",
|
|
85
|
+
...(get("partition") ? { partition: get("partition")?.value.trim() } : {}),
|
|
86
|
+
...(get("version") ? { version: get("version")?.value.trim() } : {}),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
else if (n.key === "role") {
|
|
90
|
+
const see = get("may")?.children.find((c) => c.key === "see");
|
|
91
|
+
set.roles.set(name, {
|
|
92
|
+
name,
|
|
93
|
+
audience: get("audience")?.value ?? "member",
|
|
94
|
+
see: (see?.value ?? "").split(",").map((x) => x.trim()).filter(Boolean),
|
|
95
|
+
seeLine: see?.line ?? n.line,
|
|
96
|
+
line: n.line,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
else if (n.key === "rule") {
|
|
100
|
+
const then = get("then")?.value ?? "";
|
|
101
|
+
const m = then.match(/^(\S+) shown to (\S+)$/);
|
|
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
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return set;
|
|
112
|
+
}
|
|
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);
|
|
135
|
+
return !min || rankOf(audience) >= rankOf(min);
|
|
136
|
+
}
|
|
137
|
+
/** The keys a slot's source reads. */
|
|
138
|
+
function slotKeys(from) {
|
|
139
|
+
if (from.startsWith('"'))
|
|
140
|
+
return [...JSON.parse(from).matchAll(/\{([^{}]+)\}/g)].flatMap((m) => m[1].split("??").map((k) => k.trim().replace(/\?$/, "")));
|
|
141
|
+
const agg = from.match(/^(?:count|least|most|sum|best|worst|join) of (\S+)$/);
|
|
142
|
+
return [agg ? agg[1] : from];
|
|
143
|
+
}
|
|
144
|
+
// ─── Publish checks (12.8) ───────────────────────────────────────────────────
|
|
145
|
+
export function checkViews(set, dict) {
|
|
146
|
+
const defined = (k) => dict.facets.has(k.replace(/^\w+::/, "").replace(/^parts\[\*\]\./, ""));
|
|
147
|
+
for (const f of set.filters.values()) {
|
|
148
|
+
if (!defined(f.facet))
|
|
149
|
+
throw new CrubalError(`the filter ${f.name} reads ${f.facet}, which isn't a facet`, f.line);
|
|
150
|
+
const facet = dict.facets.get(f.facet.replace(/^\w+::/, ""));
|
|
151
|
+
const seen = new Map();
|
|
152
|
+
f.buckets?.forEach((b, i) => {
|
|
153
|
+
for (const v of b.values ?? []) {
|
|
154
|
+
if (facet?.values && !facet.values.some((x) => x.value === v))
|
|
155
|
+
throw new CrubalError(`${v} is not a value of ${facet.key}`, f.bucketLines[i]);
|
|
156
|
+
const other = seen.get(v);
|
|
157
|
+
if (other)
|
|
158
|
+
throw new CrubalError(`${v} is in both ${other} and ${b.name}`, f.bucketLines[i]);
|
|
159
|
+
seen.set(v, b.name);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
for (const s of set.shapes.values()) {
|
|
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
|
+
}
|
|
171
|
+
for (const k of slotKeys(sl.from))
|
|
172
|
+
if (!sl.from.startsWith('"') || k)
|
|
173
|
+
if (!defined(k) && k !== "id")
|
|
174
|
+
throw new CrubalError(`the slot ${sl.to} reads ${k}, which isn't a facet`, sl.line);
|
|
175
|
+
}
|
|
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);
|
|
181
|
+
for (const v of set.views.values()) {
|
|
182
|
+
for (const s of v.shapes)
|
|
183
|
+
if (!CORE_SHAPES.includes(s) && !set.shapes.has(s))
|
|
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);
|
|
188
|
+
for (const f of v.filters)
|
|
189
|
+
if (!set.filters.has(f))
|
|
190
|
+
throw new CrubalError(`${v.name} names the filter ${f}, which doesn't exist`, v.line);
|
|
191
|
+
for (const i of v.index)
|
|
192
|
+
if (!v.filters.includes(i))
|
|
193
|
+
throw new CrubalError(`${v.name} indexes ${i}, which isn't one of its filters`, v.indexLine);
|
|
194
|
+
for (const c of v.wears)
|
|
195
|
+
if (!dict.classes.has(c))
|
|
196
|
+
throw new CrubalError(`${v.name} selects by ${c}, which isn't a class`, v.line);
|
|
197
|
+
if (v.show === "public") {
|
|
198
|
+
const reads = [
|
|
199
|
+
...v.shapes.flatMap((s) => (set.shapes.get(s)?.slots ?? []).flatMap((sl) => slotKeys(sl.from))),
|
|
200
|
+
...v.filters.map((f) => set.filters.get(f)?.facet ?? f),
|
|
201
|
+
];
|
|
202
|
+
const hidden = reads.find((k) => !visibleTo(set, k, "public"));
|
|
203
|
+
if (hidden)
|
|
204
|
+
throw new CrubalError(`the public view ${v.name} reads ${hidden}, which show rules hide from public`, v.line);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const r of set.roles.values()) {
|
|
208
|
+
for (const k of r.see) {
|
|
209
|
+
if (!defined(k))
|
|
210
|
+
throw new CrubalError(`${r.name} grants see: ${k}, which isn't a facet`, r.seeLine);
|
|
211
|
+
if (!visibleTo(set, k, r.audience))
|
|
212
|
+
throw new CrubalError(`${r.name} grants see: ${k}, which the ${r.audience} audience can't see`, r.seeLine);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
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
|
+
}
|
|
222
|
+
function plainText(v) {
|
|
223
|
+
if (typeof v === "string")
|
|
224
|
+
return v;
|
|
225
|
+
if (isObj(v) && "$link" in v)
|
|
226
|
+
return `&${v.$link}`;
|
|
227
|
+
if (isObj(v) && "$value" in v)
|
|
228
|
+
return plainText(v.$value);
|
|
229
|
+
return serialize(v);
|
|
230
|
+
}
|
|
231
|
+
function label(key) {
|
|
232
|
+
const words = key.replace(/^.*:/, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
|
|
233
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
234
|
+
}
|
|
235
|
+
export class Views {
|
|
236
|
+
dict;
|
|
237
|
+
set;
|
|
238
|
+
records = new MemoryRecords();
|
|
239
|
+
engine;
|
|
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) {
|
|
242
|
+
this.dict = dict;
|
|
243
|
+
this.set = set;
|
|
244
|
+
if (records)
|
|
245
|
+
this.records = records;
|
|
246
|
+
this.engine = new Engine(dict, []);
|
|
247
|
+
}
|
|
248
|
+
put(r) {
|
|
249
|
+
this.records.set(r.id, r);
|
|
250
|
+
}
|
|
251
|
+
effective(r) {
|
|
252
|
+
const up = r.of ? this.records.get(r.of) : undefined;
|
|
253
|
+
return { ...(up ? this.effective(up) : {}), ...r.values };
|
|
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
|
+
}
|
|
260
|
+
below(r, level) {
|
|
261
|
+
return this.records.childrenOf(r.id, "of", level);
|
|
262
|
+
}
|
|
263
|
+
/** The values an address reads, as a list: own values, or a lower level's (`unit::price`). */
|
|
264
|
+
valuesAt(r, address) {
|
|
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]);
|
|
270
|
+
if (m)
|
|
271
|
+
return this.below(r, m[1]).flatMap((u) => this.valuesAt(u, m[2]));
|
|
272
|
+
const v = this.effective(r)[address];
|
|
273
|
+
return v === undefined ? [] : Array.isArray(v) ? v : [v];
|
|
274
|
+
}
|
|
275
|
+
/** Is a value fit for this audience's eyes? New, unknown and held values never reach the public. */
|
|
276
|
+
fit(key, v, audience) {
|
|
277
|
+
if (audience !== "public")
|
|
278
|
+
return true;
|
|
279
|
+
if (isObj(v) && "$empty" in v)
|
|
280
|
+
return v.$empty === "n/a";
|
|
281
|
+
const f = this.dict.facets.get(key.replace(/^\w+::/, ""));
|
|
282
|
+
if (f?.type === "choice" && typeof v === "string")
|
|
283
|
+
return (f.values ?? []).some((x) => x.value === v);
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
canSee(key, audience, caller, record) {
|
|
287
|
+
if (!visibleTo(this.set, key, audience, record, this.dict))
|
|
288
|
+
return false;
|
|
289
|
+
if (!caller || rankOf(audience) <= rankOf("member"))
|
|
290
|
+
return true;
|
|
291
|
+
// Above member, a person sees what members see plus what their roles grant (default deny).
|
|
292
|
+
if (visibleTo(this.set, key, "member", record, this.dict))
|
|
293
|
+
return true;
|
|
294
|
+
const held = caller.values.roles;
|
|
295
|
+
const names = (Array.isArray(held) ? held : held ? [held] : []).map((x) => (isObj(x) && "$link" in x ? String(x.$link) : String(x)));
|
|
296
|
+
return names.some((n) => {
|
|
297
|
+
const role = this.set.roles.get(n);
|
|
298
|
+
return role && role.see.includes(key.replace(/^\w+::/, "")) && rankOf(role.audience) <= rankOf(audience);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
slot(r, s, audience, caller) {
|
|
302
|
+
const keys = slotKeys(s.from);
|
|
303
|
+
if (!keys.every((k) => this.canSee(k, audience, caller, r)))
|
|
304
|
+
return undefined;
|
|
305
|
+
if (s.from.startsWith('"'))
|
|
306
|
+
return this.engine.template(s.from, this.effective(r));
|
|
307
|
+
const agg = s.from.match(/^(count|least|most|sum|best|worst|join) of (\S+)$/);
|
|
308
|
+
if (agg) {
|
|
309
|
+
const items = this.valuesAt(r, agg[2]).filter((v) => !(isObj(v) && "$empty" in v));
|
|
310
|
+
if (agg[1] === "count")
|
|
311
|
+
return String(items.length);
|
|
312
|
+
if (!items.length)
|
|
313
|
+
return undefined;
|
|
314
|
+
const amount = (v) => Number(isObj(v) ? (v.$money ?? v.$qty ?? NaN) : v);
|
|
315
|
+
const sorted = [...items].sort((a, b) => amount(a) - amount(b));
|
|
316
|
+
if (agg[1] === "least")
|
|
317
|
+
return sorted[0];
|
|
318
|
+
if (agg[1] === "most")
|
|
319
|
+
return sorted.at(-1);
|
|
320
|
+
return items;
|
|
321
|
+
}
|
|
322
|
+
const f = this.dict.facets.get(s.from.replace(/^\w+::/, ""));
|
|
323
|
+
const v = s.from.includes("::") ? this.valuesAt(r, s.from) : this.effective(r)[s.from];
|
|
324
|
+
if (v === undefined)
|
|
325
|
+
return undefined;
|
|
326
|
+
if (Array.isArray(v)) {
|
|
327
|
+
const kept = v.filter((x) => this.fit(s.from, x, audience));
|
|
328
|
+
return kept.length ? (f?.many || s.from.includes("::") ? kept : kept[0]) : undefined;
|
|
329
|
+
}
|
|
330
|
+
return this.fit(s.from, v, audience) ? v : undefined;
|
|
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
|
+
}
|
|
423
|
+
inBucket(r, f, b) {
|
|
424
|
+
return this.valuesAt(r, f.facet).some((v) => {
|
|
425
|
+
const t = plainText(v);
|
|
426
|
+
if (b.values)
|
|
427
|
+
return b.values.includes(t);
|
|
428
|
+
if (b.range)
|
|
429
|
+
return Number.parseFloat(t) >= b.range[0] && Number.parseFloat(t) <= b.range[1];
|
|
430
|
+
return false;
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
bucketsOf(f) {
|
|
434
|
+
if (f.buckets)
|
|
435
|
+
return f.buckets;
|
|
436
|
+
const facet = this.dict.facets.get(f.facet.replace(/^\w+::/, ""));
|
|
437
|
+
return (facet?.values ?? []).map((v) => ({ name: v.value, values: [v.value] }));
|
|
438
|
+
}
|
|
439
|
+
/** The records a view selects, curated and ordered, before filters and paging. */
|
|
440
|
+
select(v, opts = {}) {
|
|
441
|
+
const audience = opts.audience ?? v.show;
|
|
442
|
+
const shape = this.set.shapes.get(v.shapes[0] ?? "");
|
|
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);
|
|
453
|
+
const cmp = (a, b) => {
|
|
454
|
+
for (const o of v.order) {
|
|
455
|
+
const x = plainText((this.effective(a)[o.key] ?? ""));
|
|
456
|
+
const y = plainText((this.effective(b)[o.key] ?? ""));
|
|
457
|
+
if (x !== y)
|
|
458
|
+
return (x < y ? -1 : 1) * (o.dir === "after" ? -1 : 1);
|
|
459
|
+
}
|
|
460
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
461
|
+
};
|
|
462
|
+
const pinned = v.pin.map((id) => rows.find((r) => r.id === id)).filter((r) => !!r);
|
|
463
|
+
return [...pinned, ...rows.filter((r) => !v.pin.includes(r.id)).sort(cmp)];
|
|
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
|
+
}
|
|
474
|
+
/** A view's projection for an audience (or a caller), with filter counts and a page. */
|
|
475
|
+
project(name, opts = {}) {
|
|
476
|
+
const v = this.set.views.get(name);
|
|
477
|
+
if (!v)
|
|
478
|
+
throw new Error(`no view ${name}`);
|
|
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));
|
|
486
|
+
const picks = opts.picks ?? {};
|
|
487
|
+
const passes = (r, except) => Object.entries(picks).every(([fname, chosen]) => {
|
|
488
|
+
if (fname === except)
|
|
489
|
+
return true;
|
|
490
|
+
const f = this.set.filters.get(fname);
|
|
491
|
+
return !f || this.bucketsOf(f).some((b) => chosen.includes(b.name) && this.inBucket(r, f, b));
|
|
492
|
+
});
|
|
493
|
+
const counts = {};
|
|
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
|
+
}
|
|
502
|
+
}
|
|
503
|
+
const nested = opts.all ? {} : this.nestedCounts(v, (fname) => all.filter((r) => passes(r, fname)));
|
|
504
|
+
const chosen = all.filter((r) => passes(r));
|
|
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);
|
|
508
|
+
const pickList = Object.entries(picks).filter(([, c]) => c.length);
|
|
509
|
+
const indexable = page === 1 && (pickList.length === 0 || (pickList.length === 1 && v.index.includes(pickList[0]?.[0]) && pickList[0]?.[1].length === 1));
|
|
510
|
+
const [shapeName = "", ...more] = v.shapes;
|
|
511
|
+
const rows = onPage.map((r) => {
|
|
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;
|
|
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);
|
|
526
|
+
return row;
|
|
527
|
+
});
|
|
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);
|
|
657
|
+
}
|
|
658
|
+
/** The portable shape (12.1): key, label, type and value for every value the audience may see. */
|
|
659
|
+
portable(r, audience = "public", caller) {
|
|
660
|
+
return Object.entries(r.values).flatMap(([key, v]) => {
|
|
661
|
+
if (!this.canSee(key, audience, caller))
|
|
662
|
+
return [];
|
|
663
|
+
const f = this.dict.facets.get(key);
|
|
664
|
+
return [{ key, label: f?.label ?? label(key), type: f?.type ?? "raw", value: v }];
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
/** The keys a view reads (12.6), with level prefixes kept. */
|
|
668
|
+
readSet(name) {
|
|
669
|
+
const v = this.set.views.get(name);
|
|
670
|
+
if (!v)
|
|
671
|
+
return new Set();
|
|
672
|
+
const keys = [];
|
|
673
|
+
if (v.when)
|
|
674
|
+
keys.push(...parseConditionKeys(v.when));
|
|
675
|
+
if (v.of)
|
|
676
|
+
keys.push(...parseConditionKeys(v.of.replace(/context\.\w+/g, '""')));
|
|
677
|
+
keys.push(...v.order.map((o) => o.key));
|
|
678
|
+
if (v.list)
|
|
679
|
+
keys.push("crubal:items");
|
|
680
|
+
for (const s of v.shapes)
|
|
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 ?? []));
|
|
689
|
+
for (const f of v.filters)
|
|
690
|
+
keys.push(this.set.filters.get(f)?.facet ?? f);
|
|
691
|
+
for (const k of [...keys])
|
|
692
|
+
if (this.set.shown.has(k) || this.set.shownFor.has(k))
|
|
693
|
+
keys.push(k);
|
|
694
|
+
return new Set(keys.filter((k) => k !== "id"));
|
|
695
|
+
}
|
|
696
|
+
/** Which of a view's rows a write makes stale: the written record, or what reads it through. */
|
|
697
|
+
staleAfter(name, write) {
|
|
698
|
+
const v = this.set.views.get(name);
|
|
699
|
+
if (!v)
|
|
700
|
+
return [];
|
|
701
|
+
const reads = this.readSet(name);
|
|
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);
|
|
708
|
+
for (const key of Object.keys(write.values)) {
|
|
709
|
+
if (reads.has(key) && (!v.level || write.level === v.level))
|
|
710
|
+
out.add(write.id);
|
|
711
|
+
if (reads.has(`${write.level}::${key}`) && write.of)
|
|
712
|
+
out.add(write.of);
|
|
713
|
+
if (reads.has(key) && v.level && write.level !== v.level) {
|
|
714
|
+
for (const r of this.records.childrenOf(write.id, "of", v.level))
|
|
715
|
+
out.add(r.id);
|
|
716
|
+
}
|
|
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
|
+
}
|
|
743
|
+
return [...out];
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
function parseConditionKeys(text) {
|
|
747
|
+
const out = [];
|
|
748
|
+
const walk = (e) => {
|
|
749
|
+
if (e.op === "test")
|
|
750
|
+
out.push(e.path.replace(/^(count|least|most|sum) of /, ""));
|
|
751
|
+
else if (e.op === "not")
|
|
752
|
+
walk(e.a);
|
|
753
|
+
else {
|
|
754
|
+
walk(e.a);
|
|
755
|
+
walk(e.b);
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
walk(parseCondition(text));
|
|
759
|
+
return out;
|
|
760
|
+
}
|
|
761
|
+
//# sourceMappingURL=views.js.map
|