@graview/core 0.1.2 → 0.1.4
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/dist/app.d.ts +9 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js.map +1 -1
- package/dist/capabilities.js +1 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/cli/check/policy.d.ts.map +1 -1
- package/dist/cli/check/policy.js +40 -0
- package/dist/cli/check/policy.js.map +1 -1
- package/dist/cli/index.d.ts +2 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/document/canonical.d.ts +2 -1
- package/dist/document/canonical.d.ts.map +1 -1
- package/dist/document/canonical.js +2 -1
- package/dist/document/canonical.js.map +1 -1
- package/dist/document/compile.d.ts.map +1 -1
- package/dist/document/compile.js +22 -2
- package/dist/document/compile.js.map +1 -1
- package/dist/document/diff.d.ts.map +1 -1
- package/dist/document/diff.js +8 -1
- package/dist/document/diff.js.map +1 -1
- package/dist/document/edit.d.ts +7 -1
- package/dist/document/edit.d.ts.map +1 -1
- package/dist/document/edit.js +58 -2
- package/dist/document/edit.js.map +1 -1
- package/dist/document/index.d.ts +1 -1
- package/dist/document/index.d.ts.map +1 -1
- package/dist/document/migrate.d.ts.map +1 -1
- package/dist/document/migrate.js +3 -2
- package/dist/document/migrate.js.map +1 -1
- package/dist/document/patch.d.ts.map +1 -1
- package/dist/document/patch.js +2 -5
- package/dist/document/patch.js.map +1 -1
- package/dist/document/schema.d.ts +12 -10
- package/dist/document/schema.d.ts.map +1 -1
- package/dist/document/schema.js +2 -0
- package/dist/document/schema.js.map +1 -1
- package/dist/document/to-document.d.ts.map +1 -1
- package/dist/document/to-document.js +5 -1
- package/dist/document/to-document.js.map +1 -1
- package/dist/graph/graph.d.ts +9 -0
- package/dist/graph/graph.d.ts.map +1 -1
- package/dist/graph/graph.js +18 -4
- package/dist/graph/graph.js.map +1 -1
- package/dist/index.d.ts +11 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/integrity.d.ts.map +1 -1
- package/dist/integrity.js +35 -5
- package/dist/integrity.js.map +1 -1
- package/dist/invariants/types.d.ts +1 -1
- package/dist/invariants/types.d.ts.map +1 -1
- package/dist/labels.js +1 -1
- package/dist/labels.js.map +1 -1
- package/dist/mutations/define-mutation.d.ts +22 -2
- package/dist/mutations/define-mutation.d.ts.map +1 -1
- package/dist/mutations/define-mutation.js +21 -8
- package/dist/mutations/define-mutation.js.map +1 -1
- package/dist/mutations/node-ref.d.ts +5 -2
- package/dist/mutations/node-ref.d.ts.map +1 -1
- package/dist/mutations/node-ref.js +28 -7
- package/dist/mutations/node-ref.js.map +1 -1
- package/dist/mutations/types.d.ts +2 -2
- package/dist/mutations/types.d.ts.map +1 -1
- package/dist/ops/log.d.ts +22 -0
- package/dist/ops/log.d.ts.map +1 -1
- package/dist/ops/log.js +14 -0
- package/dist/ops/log.js.map +1 -1
- package/dist/ops/served.d.ts +80 -0
- package/dist/ops/served.d.ts.map +1 -0
- package/dist/ops/served.js +316 -0
- package/dist/ops/served.js.map +1 -0
- package/dist/ops/types.d.ts +8 -0
- package/dist/ops/types.d.ts.map +1 -1
- package/dist/ops/undo.d.ts +6 -0
- package/dist/ops/undo.d.ts.map +1 -1
- package/dist/ops/undo.js +3 -0
- package/dist/ops/undo.js.map +1 -1
- package/dist/ops/withheld.d.ts +63 -8
- package/dist/ops/withheld.d.ts.map +1 -1
- package/dist/ops/withheld.js +198 -22
- package/dist/ops/withheld.js.map +1 -1
- package/dist/ops/writers.d.ts +29 -0
- package/dist/ops/writers.d.ts.map +1 -0
- package/dist/ops/writers.js +98 -0
- package/dist/ops/writers.js.map +1 -0
- package/dist/permissions/sight.d.ts +33 -4
- package/dist/permissions/sight.d.ts.map +1 -1
- package/dist/permissions/sight.js +106 -22
- package/dist/permissions/sight.js.map +1 -1
- package/dist/presence.d.ts +48 -1
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +51 -2
- package/dist/presence.js.map +1 -1
- package/dist/refusal.d.ts +38 -0
- package/dist/refusal.d.ts.map +1 -0
- package/dist/refusal.js +44 -0
- package/dist/refusal.js.map +1 -0
- package/dist/schema/json-schema.d.ts.map +1 -1
- package/dist/schema/json-schema.js +27 -1
- package/dist/schema/json-schema.js.map +1 -1
- package/dist/schema/schema.d.ts +19 -4
- package/dist/schema/schema.d.ts.map +1 -1
- package/dist/schema/schema.js.map +1 -1
- package/dist/schema/types.d.ts +11 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/seen.d.ts +60 -1
- package/dist/seen.d.ts.map +1 -1
- package/dist/seen.js +467 -29
- package/dist/seen.js.map +1 -1
- package/dist/store.d.ts +151 -10
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +228 -40
- package/dist/store.js.map +1 -1
- package/dist/testing.js.map +1 -1
- package/dist/validate-graph.d.ts +2 -1
- package/dist/validate-graph.d.ts.map +1 -1
- package/dist/validate-graph.js +8 -1
- package/dist/validate-graph.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/seen.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { batchesOf, undoneIn } from "./ops/log.js";
|
|
2
2
|
import { checkUndo } from "./ops/undo.js";
|
|
3
|
-
import {
|
|
3
|
+
import { servePrimitives } from "./ops/served.js";
|
|
4
|
+
import { writersOf } from "./ops/writers.js";
|
|
5
|
+
import { namesUnseen, redact, WITHHELD_INTENT } from "./ops/withheld.js";
|
|
4
6
|
import { actingAs, isSystem } from "./permissions/policy.js";
|
|
5
7
|
import { recordsOf, sees } from "./permissions/sight.js";
|
|
6
8
|
const NOTHING = new Set();
|
|
@@ -27,13 +29,19 @@ export function hidesFrom(store, principal) {
|
|
|
27
29
|
* now; one since removed by the kind the log says it was, and so never by
|
|
28
30
|
* an edge it no longer has; and a string that names no record at all (a
|
|
29
31
|
* label in a call's arguments) is nothing to keep from anybody.
|
|
32
|
+
*
|
|
33
|
+
* THE JUDGEMENT FOLLOWS THE STORE (FR-51). It reads the policy, the
|
|
34
|
+
* modules that are off and who made each record as it is asked, not as it
|
|
35
|
+
* was taken: one taken before a commit and asked after it knows the record
|
|
36
|
+
* just made is its maker's own. Asking costs nothing more for that — who
|
|
37
|
+
* made what is an index the log keeps as it goes (`recordsOf`).
|
|
30
38
|
*/
|
|
31
39
|
export function seesId(store, principal) {
|
|
32
|
-
const off = turnedOff(store, principal);
|
|
33
|
-
if (!store.policy?.sees?.length && off.size === 0)
|
|
34
|
-
return () => true;
|
|
35
|
-
const records = recordsOf(store.log);
|
|
36
40
|
return (id) => {
|
|
41
|
+
const off = turnedOff(store, principal);
|
|
42
|
+
if (!store.policy?.sees?.length && off.size === 0)
|
|
43
|
+
return true;
|
|
44
|
+
const records = recordsOf(store.log);
|
|
37
45
|
const node = store.graph.getNode(id);
|
|
38
46
|
if (node)
|
|
39
47
|
return !off.has(node.kind) && sees(store.policy, principal, node, store.graph, records);
|
|
@@ -43,11 +51,371 @@ export function seesId(store, principal) {
|
|
|
43
51
|
return !off.has(kind) && sees(store.policy, principal, { id, kind }, { out: () => [], in: () => [] }, records);
|
|
44
52
|
};
|
|
45
53
|
}
|
|
46
|
-
|
|
54
|
+
const NO_LINKS = { out: () => [], in: () => [] };
|
|
55
|
+
/**
|
|
56
|
+
* ONE SEAT'S SIGHT AS OF NOW, each id judged once: `seesId` for a reading
|
|
57
|
+
* of the store that does not change while it is read. What is off, the
|
|
58
|
+
* records the log knows and each answer are taken once here, where
|
|
59
|
+
* `seesId` reads them again for every id — a served log asks of every
|
|
60
|
+
* string in every op, and most are the same few ids.
|
|
61
|
+
*/
|
|
62
|
+
function pinnedSight(store, principal, judged = new Map(), unnamed) {
|
|
63
|
+
const off = turnedOff(store, principal);
|
|
64
|
+
if (!store.policy?.sees?.length && off.size === 0)
|
|
65
|
+
return () => true;
|
|
66
|
+
const records = recordsOf(store.log);
|
|
67
|
+
return (id) => {
|
|
68
|
+
const held = judged.get(id);
|
|
69
|
+
if (held !== undefined)
|
|
70
|
+
return held;
|
|
71
|
+
let seen;
|
|
72
|
+
const node = store.graph.getNode(id);
|
|
73
|
+
if (node)
|
|
74
|
+
seen = !off.has(node.kind) && sees(store.policy, principal, node, store.graph, records);
|
|
75
|
+
else {
|
|
76
|
+
const kind = records.kindOf(id);
|
|
77
|
+
// A string that names no record is nothing to keep, and not judged: most strings in a log are not ids. A caller that keeps a reading is told it was asked.
|
|
78
|
+
if (kind === undefined) {
|
|
79
|
+
unnamed?.add(id);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
seen = !off.has(kind) && sees(store.policy, principal, { id, kind }, NO_LINKS, records);
|
|
83
|
+
}
|
|
84
|
+
judged.set(id, seen);
|
|
85
|
+
return seen;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Whether a kind's field may be left off a record: declared optional, or not declared at all. Unknown kinds keep every field. */
|
|
89
|
+
function optionalIn(schema) {
|
|
90
|
+
const known = new Map();
|
|
91
|
+
return (kind, field) => {
|
|
92
|
+
const key = `${kind}\u0000${field}`;
|
|
93
|
+
let optional = known.get(key);
|
|
94
|
+
if (optional === undefined) {
|
|
95
|
+
const shape = schema?.tryDefinition(kind)?.fields?.shape;
|
|
96
|
+
const declared = shape?.[field];
|
|
97
|
+
optional = shape !== undefined && (declared === undefined || declared.safeParse?.(undefined).success === true);
|
|
98
|
+
known.set(key, optional);
|
|
99
|
+
}
|
|
100
|
+
return optional;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* WHAT ONE SEAT IS SERVED (FR-55) — the judgement every surface built on
|
|
105
|
+
* the seat view reads, so a snapshot, a log, an answer and the wire cannot
|
|
106
|
+
* disagree about it.
|
|
107
|
+
*
|
|
108
|
+
* Its sight (`seesId`) says which records it may be told of. An id is
|
|
109
|
+
* minted from a label, so a seen record's field that holds the id of one
|
|
110
|
+
* it may not see tells it that record's name: such a field is CLEARED in
|
|
111
|
+
* what the seat is served — the record stays, usable, without it — when
|
|
112
|
+
* the kind's declaration lets a record be without it. When the field is
|
|
113
|
+
* one the record cannot do without, clearing it would serve a record that
|
|
114
|
+
* fails its own declaration, and a client refuses to load that: the record
|
|
115
|
+
* is WITHHELD from the seat whole instead, as if its sight did not reach
|
|
116
|
+
* it — not in the graph, the snapshot, the log's primitives or an edge.
|
|
117
|
+
* Which ids a field names is judged by the seat's sight alone, so a record
|
|
118
|
+
* naming one withheld this way still names a record the seat may see.
|
|
119
|
+
*/
|
|
120
|
+
export function seatLens(store, principal) {
|
|
121
|
+
return lensFor(store, principal).lens;
|
|
122
|
+
}
|
|
123
|
+
/** A seat's lens, and a way to pin it over judgements the caller keeps. */
|
|
124
|
+
function lensFor(store, principal) {
|
|
125
|
+
const optional = optionalIn(store.schema);
|
|
126
|
+
/*
|
|
127
|
+
* A SEAT IS SERVED ITS OWN WORDS (FR-55). A field whose value this seat
|
|
128
|
+
* wrote — or the person an agent acts for — is served as written, though
|
|
129
|
+
* it name a record the seat may not see: saying back what a seat said
|
|
130
|
+
* tells it nothing, and judging it like anybody else's would tell a seat
|
|
131
|
+
* that guessed an id whether the guess was real.
|
|
132
|
+
*/
|
|
133
|
+
const actor = isSystem(principal) ? undefined : actingAs(principal).id;
|
|
134
|
+
const length = () => store.log.length ?? store.log.all().length;
|
|
135
|
+
// The same record served the same way is the same object, so a view read twice is equal by identity.
|
|
136
|
+
const cleared = new WeakMap();
|
|
137
|
+
const graph = store.graph;
|
|
138
|
+
/**
|
|
139
|
+
* The lens over one sight: the seat's own, which follows the store as it
|
|
140
|
+
* is asked, or one pinned to now (`pin`), which judges each id and each
|
|
141
|
+
* record once and is thrown away after the reading it was made for.
|
|
142
|
+
*/
|
|
143
|
+
const over = (sees, writers, now, kindOf, pinned) => {
|
|
144
|
+
const servedWith = (node, writer) => {
|
|
145
|
+
if (!sees(node.id))
|
|
146
|
+
return undefined;
|
|
147
|
+
let without;
|
|
148
|
+
for (const field in node) {
|
|
149
|
+
if (!Object.hasOwn(node, field) || field === "id" || field === "kind" || !namesUnseen(node[field], sees))
|
|
150
|
+
continue;
|
|
151
|
+
if (actor !== undefined && writer(field) === actor)
|
|
152
|
+
continue;
|
|
153
|
+
if (!optional(node.kind, field))
|
|
154
|
+
return undefined;
|
|
155
|
+
(without ??= []).push(field);
|
|
156
|
+
}
|
|
157
|
+
if (!without)
|
|
158
|
+
return node;
|
|
159
|
+
const key = without.join("\u0000");
|
|
160
|
+
const held = cleared.get(node);
|
|
161
|
+
if (held?.without === key)
|
|
162
|
+
return held.node;
|
|
163
|
+
const out = Object.fromEntries(Object.entries(node).filter(([field]) => !without.includes(field)));
|
|
164
|
+
cleared.set(node, { without: key, node: out });
|
|
165
|
+
return out;
|
|
166
|
+
};
|
|
167
|
+
const judge = (node) => {
|
|
168
|
+
const at = now();
|
|
169
|
+
return servedWith(node, (field) => writers().writerAt(node.id, field, at));
|
|
170
|
+
};
|
|
171
|
+
// Pinned, a record is served once however often the reading asks for it: no record or id changes under a pin.
|
|
172
|
+
const servedOnce = new WeakMap();
|
|
173
|
+
const served = pinned
|
|
174
|
+
? (node) => {
|
|
175
|
+
let held = servedOnce.get(node);
|
|
176
|
+
if (!held)
|
|
177
|
+
servedOnce.set(node, (held = { node: judge(node) }));
|
|
178
|
+
return held.node;
|
|
179
|
+
}
|
|
180
|
+
: judge;
|
|
181
|
+
const showsNow = (id) => {
|
|
182
|
+
if (!sees(id))
|
|
183
|
+
return false;
|
|
184
|
+
const node = graph.getNode(id);
|
|
185
|
+
return node === undefined || served(node) !== undefined;
|
|
186
|
+
};
|
|
187
|
+
const shown = pinned?.shown ?? new Map();
|
|
188
|
+
const shows = pinned
|
|
189
|
+
? (id) => {
|
|
190
|
+
let held = shown.get(id);
|
|
191
|
+
if (held === undefined)
|
|
192
|
+
shown.set(id, (held = showsNow(id)));
|
|
193
|
+
return held;
|
|
194
|
+
}
|
|
195
|
+
: showsNow;
|
|
196
|
+
const timeline = graph.outEdges && graph.inEdges
|
|
197
|
+
? {
|
|
198
|
+
present: {
|
|
199
|
+
getNode: (id) => graph.getNode(id),
|
|
200
|
+
outEdges: (id) => graph.outEdges(id),
|
|
201
|
+
inEdges: (id) => graph.inEdges(id),
|
|
202
|
+
},
|
|
203
|
+
log: store.log,
|
|
204
|
+
get writers() {
|
|
205
|
+
return writers();
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
: undefined;
|
|
209
|
+
const lens = {
|
|
210
|
+
sees,
|
|
211
|
+
shows,
|
|
212
|
+
served,
|
|
213
|
+
servedWith,
|
|
214
|
+
optional,
|
|
215
|
+
kindOf,
|
|
216
|
+
...(actor !== undefined ? { actor } : {}),
|
|
217
|
+
...(timeline ? { timeline } : {}),
|
|
218
|
+
// A pinned lens is already of one moment: pinning it again is itself.
|
|
219
|
+
pin: pinned ? () => lens : () => pinWith({ judged: new Map(), shown: new Map() }),
|
|
220
|
+
};
|
|
221
|
+
return lens;
|
|
222
|
+
};
|
|
223
|
+
function pinWith(memo) {
|
|
224
|
+
// Who wrote what is read only when a field names what the seat may not see: a seat that sees all of it never asks.
|
|
225
|
+
let writers;
|
|
226
|
+
const at = length();
|
|
227
|
+
const records = recordsOf(store.log);
|
|
228
|
+
return over(pinnedSight(store, principal, memo.judged, memo.unnamed), () => (writers ??= writersOf(store.log)), () => at, (id) => graph.getNode(id)?.kind ?? records.kindOf(id), memo);
|
|
229
|
+
}
|
|
230
|
+
const lens = over(seesId(store, principal), () => writersOf(store.log), length, (id) => graph.getNode(id)?.kind ?? recordsOf(store.log).kindOf(id));
|
|
231
|
+
return { lens, pinWith };
|
|
232
|
+
}
|
|
233
|
+
const SERVED_LOGS = new WeakMap();
|
|
234
|
+
/**
|
|
235
|
+
* EVERY STRING A SERVED LOG'S READING ASKED ABOUT THAT NAMED NO RECORD THEN,
|
|
236
|
+
* for every seat served from the log: an id a record takes later is one
|
|
237
|
+
* an op served before may name. A string never asked about changed nothing
|
|
238
|
+
* any seat was served — a withheld op drops its sentence, its call and its
|
|
239
|
+
* author unread — so these are all the names that can come to matter.
|
|
240
|
+
* Shared by the seats, so a name one seat asked about only ever sends
|
|
241
|
+
* another to serve its log again; never fewer.
|
|
242
|
+
*/
|
|
243
|
+
const UNNAMED = new WeakMap();
|
|
244
|
+
/** The ids a primitive touches, links' ends among them: what can move a record's sight. */
|
|
245
|
+
const touchedIds = (ops) => {
|
|
246
|
+
const ids = new Set();
|
|
247
|
+
for (const op of ops) {
|
|
248
|
+
for (const primitive of op.primitives) {
|
|
249
|
+
if (primitive.op === "add-node" || primitive.op === "remove-node")
|
|
250
|
+
ids.add(primitive.node.id);
|
|
251
|
+
else if (primitive.op === "patch-node")
|
|
252
|
+
ids.add(primitive.id);
|
|
253
|
+
else {
|
|
254
|
+
ids.add(primitive.edge.from);
|
|
255
|
+
ids.add(primitive.edge.to);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return ids;
|
|
260
|
+
};
|
|
261
|
+
/** The store's log as one seat may read it: every op in its place, the ones it may not see withheld (FR-16), and no id it may not see in any of them (FR-55). */
|
|
47
262
|
export function logSeenBy(store, principal) {
|
|
48
263
|
if (!hidesFrom(store, principal))
|
|
49
264
|
return store.log.all();
|
|
50
|
-
|
|
265
|
+
const all = store.log.all();
|
|
266
|
+
const length = store.log.length ?? all.length;
|
|
267
|
+
const horizon = length - all.length;
|
|
268
|
+
const off = [...turnedOff(store, principal)].sort().join("\u0000");
|
|
269
|
+
const judged = isSystem(principal) ? principal : actingAs(principal);
|
|
270
|
+
const key = `${principal.kind}|${judged.id ?? ""}|${(judged.roles ?? []).join(",")}|${principal.onBehalfOf ? "for" : ""}`;
|
|
271
|
+
let seats = SERVED_LOGS.get(store.log);
|
|
272
|
+
if (!seats)
|
|
273
|
+
SERVED_LOGS.set(store.log, (seats = new Map()));
|
|
274
|
+
const held = seats.get(key);
|
|
275
|
+
const { pinWith } = lensFor(store, principal);
|
|
276
|
+
let unnamed = UNNAMED.get(store.log);
|
|
277
|
+
if (!unnamed)
|
|
278
|
+
UNNAMED.set(store.log, (unnamed = new Set()));
|
|
279
|
+
const grown = held !== undefined && held.horizon === horizon && held.off === off && held.length <= length && (held.length === horizon || all[held.length - 1 - horizon] === held.last);
|
|
280
|
+
if (held && grown) {
|
|
281
|
+
if (held.length === length)
|
|
282
|
+
return held.ops;
|
|
283
|
+
const added = all.slice(held.length - horizon);
|
|
284
|
+
// Judged afresh, to compare with what serving the old ops read.
|
|
285
|
+
const now = pinWith({ judged: new Map(), shown: new Map() });
|
|
286
|
+
let still = true;
|
|
287
|
+
const touched = touchedIds(added);
|
|
288
|
+
for (const id of touched) {
|
|
289
|
+
const sight = held.judgements.judged.get(id);
|
|
290
|
+
const served = held.judgements.shown.get(id);
|
|
291
|
+
if ((sight !== undefined && now.sees(id) !== sight) || (served !== undefined && now.shows(id) !== served)) {
|
|
292
|
+
still = false;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (still) {
|
|
297
|
+
// A record new to the log that an op already served named, when it named nothing: that name may be one to keep now.
|
|
298
|
+
for (const id of touched) {
|
|
299
|
+
if (unnamed.has(id)) {
|
|
300
|
+
still = false;
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (still) {
|
|
306
|
+
// What the old ops read stands: the new ones are judged over it, and what they read is kept with it.
|
|
307
|
+
const ops = [...held.ops, ...redact(added, pinWith(held.judgements))];
|
|
308
|
+
const served = { horizon, off, length, last: all.at(-1), ops, judgements: held.judgements };
|
|
309
|
+
seats.set(key, served);
|
|
310
|
+
return ops;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const judgements = { judged: new Map(), shown: new Map(), unnamed };
|
|
314
|
+
const ops = redact(all, pinWith(judgements));
|
|
315
|
+
seats.set(key, { horizon, off, length, last: all.at(-1), ops, judgements });
|
|
316
|
+
return ops;
|
|
317
|
+
}
|
|
318
|
+
/** Whether a rule's finding or violation is one a seat may be told: about records it is served, naming none it may not see. */
|
|
319
|
+
function violationShown(lens, has, violation) {
|
|
320
|
+
return ((violation.subjectId === undefined || !has(violation.subjectId) || lens.shows(violation.subjectId)) &&
|
|
321
|
+
violation.nodeIds.every((id) => !has(id) || lens.shows(id)) &&
|
|
322
|
+
!namesUnseen(violation, lens.sees));
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* A CHANGE'S ANSWER AS ONE SEAT MAY BE TOLD IT (FR-55) — the diff, the
|
|
326
|
+
* primitives, the ops and the rules it would break or mend of a preview,
|
|
327
|
+
* an apply or an undo, for a surface that hands a seat what its own act
|
|
328
|
+
* did: an act may touch what its own seat may not see. Records and edges
|
|
329
|
+
* it is not served go, a field naming one it may not see is cleared as
|
|
330
|
+
* the view clears it, the ops are redacted as its log is, and a sentence
|
|
331
|
+
* that names what it may not see says only that a change it cannot see
|
|
332
|
+
* happened. With nothing kept from the seat, the answer as it is.
|
|
333
|
+
*/
|
|
334
|
+
export function answerSeenBy(store, principal, answer) {
|
|
335
|
+
if (!hidesFrom(store, principal))
|
|
336
|
+
return answer;
|
|
337
|
+
const lens = seatLens(store, principal);
|
|
338
|
+
const has = (id) => store.graph.getNode(id) !== undefined || recordsOf(store.log).kindOf(id) !== undefined;
|
|
339
|
+
const out = { ...answer };
|
|
340
|
+
if (answer.diff) {
|
|
341
|
+
const diff = answer.diff;
|
|
342
|
+
/*
|
|
343
|
+
* EACH SIDE AS WHO WROTE IT THEN (FR-55): the seat's own words are served
|
|
344
|
+
* as written, and the words a change replaced were somebody's before it.
|
|
345
|
+
* Before is judged as of the first op the answer made (now, for a
|
|
346
|
+
* preview); after as of now — and, for a preview, what it would write
|
|
347
|
+
* is the caller's.
|
|
348
|
+
*/
|
|
349
|
+
const writers = writersOf(store.log);
|
|
350
|
+
const end = store.log.length ?? store.log.all().length;
|
|
351
|
+
const first = answer.ops?.[0]?.seq ?? end;
|
|
352
|
+
const actor = actingAs(principal).id;
|
|
353
|
+
const wroteAt = (id, seq) => (field) => writers.writerAt(id, field, seq);
|
|
354
|
+
const node = (one, writer) => (lens.shows(one.id) ? lens.servedWith(one, writer) : undefined);
|
|
355
|
+
const nodes = (all, writer) => all.flatMap((one) => node(one, writer(one.id)) ?? []);
|
|
356
|
+
const edge = (one) => lens.shows(one.from) && lens.shows(one.to) && !namesUnseen(one, lens.sees);
|
|
357
|
+
const madeBy = (id, fields) => answer.ops ? wroteAt(id, end) : (field) => (fields === undefined || fields.includes(field) ? actor : writers.writerAt(id, field, end));
|
|
358
|
+
const changed = diff.changedNodes.flatMap((change) => {
|
|
359
|
+
const before = node(change.before, wroteAt(change.before.id, first));
|
|
360
|
+
const after = node(change.after, madeBy(change.after.id, change.fields));
|
|
361
|
+
return before && after ? [{ before, after, fields: change.fields.filter((field) => field in before || field in after) }] : [];
|
|
362
|
+
});
|
|
363
|
+
/*
|
|
364
|
+
* A WRITE THAT CHANGED NOTHING BUT WHOSE WORDS THEY ARE. A seat that
|
|
365
|
+
* wrote the value a field already held, naming what it may not see, made
|
|
366
|
+
* those words its own (FR-55): the store's graph did not move, and the
|
|
367
|
+
* seat's view of the record did. Said as a change, so the answer reads
|
|
368
|
+
* as it would had the field held anything else.
|
|
369
|
+
*/
|
|
370
|
+
const listed = new Set(diff.changedNodes.map((change) => change.after.id));
|
|
371
|
+
const patched = new Map();
|
|
372
|
+
for (const primitive of (answer.ops?.flatMap((op) => op.primitives) ?? answer.primitives ?? [])) {
|
|
373
|
+
if (primitive.op !== "patch-node" || listed.has(primitive.id))
|
|
374
|
+
continue;
|
|
375
|
+
patched.set(primitive.id, [...(patched.get(primitive.id) ?? []), ...Object.keys(primitive.after)]);
|
|
376
|
+
}
|
|
377
|
+
const kept = () => [...patched].flatMap(([id, fields]) => {
|
|
378
|
+
const held = store.graph.getNode(id);
|
|
379
|
+
if (!held)
|
|
380
|
+
return [];
|
|
381
|
+
const before = node(held, wroteAt(id, first));
|
|
382
|
+
const after = node(held, madeBy(id, fields));
|
|
383
|
+
return before && after && JSON.stringify(before) !== JSON.stringify(after) ? [{ before, after, fields }] : [];
|
|
384
|
+
});
|
|
385
|
+
const madeTheirs = kept();
|
|
386
|
+
out["diff"] = {
|
|
387
|
+
addedNodes: nodes(diff.addedNodes, (id) => madeBy(id)),
|
|
388
|
+
removedNodes: nodes(diff.removedNodes, (id) => wroteAt(id, first)),
|
|
389
|
+
changedNodes: [...changed, ...madeTheirs],
|
|
390
|
+
addedEdges: diff.addedEdges.filter(edge),
|
|
391
|
+
removedEdges: diff.removedEdges.filter(edge),
|
|
392
|
+
touched: [...diff.touched.filter(lens.shows), ...[...patched.keys()].filter((id) => lens.shows(id) && !diff.touched.includes(id))],
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
if (answer.ops)
|
|
396
|
+
out["ops"] = redact(answer.ops, lens);
|
|
397
|
+
if (answer.primitives) {
|
|
398
|
+
// An apply's primitives are its ops'; a preview's are not in the log yet, and are judged from the graph as it stands.
|
|
399
|
+
const ops = out["ops"];
|
|
400
|
+
out["primitives"] = ops
|
|
401
|
+
? ops.flatMap((op) => op.primitives)
|
|
402
|
+
: lens.timeline
|
|
403
|
+
? servePrimitives(answer.primitives, lens.timeline, { served: (node, writer) => lens.servedWith(node, writer), edgeClean: (edge) => !namesUnseen(edge, lens.sees) }, actingAs(principal).id)
|
|
404
|
+
: redact([{ id: "", seq: 0, batch: "", author: { kind: "system" }, intent: "", mutation: null, primitives: answer.primitives, inverse: [], reads: [], writes: [], at: "" }], lens)[0].primitives;
|
|
405
|
+
}
|
|
406
|
+
// Records the seat is served, and no id that names nothing: a hidden record and an absent one are left out alike (FR-55).
|
|
407
|
+
if (answer.reads)
|
|
408
|
+
out["reads"] = answer.reads.filter((id) => lens.shows(id) && has(id));
|
|
409
|
+
if (answer.writes)
|
|
410
|
+
out["writes"] = answer.writes.filter(lens.shows);
|
|
411
|
+
if (answer.intent !== undefined && namesUnseen(answer.intent, lens.sees))
|
|
412
|
+
out["intent"] = WITHHELD_INTENT;
|
|
413
|
+
for (const key of ["introduces", "resolves", "violationsAfter"]) {
|
|
414
|
+
const violations = answer[key];
|
|
415
|
+
if (violations)
|
|
416
|
+
out[key] = violations.filter((violation) => violationShown(lens, has, violation));
|
|
417
|
+
}
|
|
418
|
+
return out;
|
|
51
419
|
}
|
|
52
420
|
/** A log reading over a seat's redacted ops, for `checkUndo`. */
|
|
53
421
|
export function readingOf(ops, epochs) {
|
|
@@ -71,8 +439,8 @@ const bound = (target, prop) => {
|
|
|
71
439
|
* With no `sees`, it is the store, unchanged.
|
|
72
440
|
*/
|
|
73
441
|
export function seenBy(store, principal) {
|
|
74
|
-
// A module off keeps its kinds from every seat as a sight would (FR-12); the view reads which are off as it goes.
|
|
75
|
-
if (!store
|
|
442
|
+
// A module off keeps its kinds from every seat as a sight would (FR-12); the view reads which are off as it goes. The system is kept from nothing.
|
|
443
|
+
if (!hidesFrom(store, principal))
|
|
76
444
|
return store;
|
|
77
445
|
// Keyed by the seat the policy judges: an agent for Nick is not the same agent alone.
|
|
78
446
|
const judged = isSystem(principal) ? principal : actingAs(principal);
|
|
@@ -84,9 +452,51 @@ export function seenBy(store, principal) {
|
|
|
84
452
|
if (held)
|
|
85
453
|
return held;
|
|
86
454
|
const full = store.graph;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
455
|
+
/*
|
|
456
|
+
* WHAT THIS SEAT IS SERVED (FR-55): a record its sight reaches, with any
|
|
457
|
+
* field that names one it may not see cleared — or, when that field is
|
|
458
|
+
* one the record cannot do without, not the record at all.
|
|
459
|
+
*/
|
|
460
|
+
const lens = seatLens(store, principal);
|
|
461
|
+
/**
|
|
462
|
+
* HOW ONE READING JUDGES: a single record by the lens, which follows the
|
|
463
|
+
* store; a whole graph, a snapshot or a neighbourhood by the lens pinned
|
|
464
|
+
* for that one reading, each id and record judged once in it.
|
|
465
|
+
*/
|
|
466
|
+
const judging = (seat) => {
|
|
467
|
+
const served = (node) => (node === undefined ? undefined : seat.served(node));
|
|
468
|
+
const seenNode = (node) => served(node) !== undefined;
|
|
469
|
+
const seenId = (id) => seenNode(full.getNode(id));
|
|
470
|
+
const seenEdge = (edge) => seenId(edge.from) && seenId(edge.to) && !namesUnseen(edge, seat.sees);
|
|
471
|
+
const servedAll = (nodes) => nodes.flatMap((node) => served(node) ?? []);
|
|
472
|
+
return { served, seenNode, seenId, seenEdge, servedAll };
|
|
473
|
+
};
|
|
474
|
+
const { served, seenId } = judging(lens);
|
|
475
|
+
const pinned = () => judging(lens.pin?.() ?? lens);
|
|
476
|
+
/** A graph of another moment — an epoch's base, at `seq` — as this seat is served it: its records, as who wrote them then, and its links between them. */
|
|
477
|
+
const servedGraph = (snapshot, seq) => {
|
|
478
|
+
const writers = writersOf(store.log);
|
|
479
|
+
const seat = lens.pin?.() ?? lens;
|
|
480
|
+
const nodes = snapshot.nodes.flatMap((node) => seat.servedWith(node, (field) => writers.writerAt(node.id, field, seq)) ?? []);
|
|
481
|
+
const kept = new Set(nodes.map((node) => node.id));
|
|
482
|
+
return { nodes, edges: snapshot.edges.filter((edge) => kept.has(edge.from) && kept.has(edge.to) && !namesUnseen(edge, seat.sees)) };
|
|
483
|
+
};
|
|
484
|
+
/**
|
|
485
|
+
* AN EPOCH AS THIS SEAT IS SERVED IT: its base as the seat was served it
|
|
486
|
+
* then, and who made each record (`creators`, which a checkpoint carries
|
|
487
|
+
* so a seat's own records stay its own past a compaction) only for the
|
|
488
|
+
* records in that base — a record the seat is not served is not named in
|
|
489
|
+
* the map either, by its id or by its maker's (FR-55). The store keeps
|
|
490
|
+
* the whole map for its own judgement.
|
|
491
|
+
*/
|
|
492
|
+
const servedEpoch = (epoch) => {
|
|
493
|
+
const base = servedGraph(epoch.base, epoch.seq);
|
|
494
|
+
if (!epoch.creators)
|
|
495
|
+
return { ...epoch, base };
|
|
496
|
+
const kept = new Set(base.nodes.map((node) => node.id));
|
|
497
|
+
const creators = Object.fromEntries(Object.entries(epoch.creators).filter(([id, maker]) => kept.has(id) && !namesUnseen(maker, lens.sees)));
|
|
498
|
+
return { ...epoch, base, creators };
|
|
499
|
+
};
|
|
90
500
|
/*
|
|
91
501
|
* THE LOG, REDACTED RATHER THAN GAPPED (FR-16): every op in its place,
|
|
92
502
|
* and one that touched what this seat may not see withheld — so the log
|
|
@@ -98,38 +508,46 @@ export function seenBy(store, principal) {
|
|
|
98
508
|
get(target, prop) {
|
|
99
509
|
switch (prop) {
|
|
100
510
|
case "getNode":
|
|
101
|
-
return (id) =>
|
|
102
|
-
const node = target.getNode(id);
|
|
103
|
-
return seenNode(node) ? node : undefined;
|
|
104
|
-
};
|
|
511
|
+
return (id) => served(target.getNode(id));
|
|
105
512
|
case "has":
|
|
106
513
|
return (id) => seenId(id);
|
|
107
514
|
case "allNodes":
|
|
108
|
-
return () =>
|
|
515
|
+
return () => pinned().servedAll(target.allNodes());
|
|
109
516
|
case "nodesOfKind":
|
|
110
|
-
return (kind) => target.nodesOfKind(kind)
|
|
517
|
+
return (kind) => pinned().servedAll(target.nodesOfKind(kind));
|
|
111
518
|
case "allEdges":
|
|
112
|
-
return () => target.allEdges().filter(seenEdge);
|
|
519
|
+
return () => target.allEdges().filter(pinned().seenEdge);
|
|
113
520
|
case "edgesOfKind":
|
|
114
|
-
return (kind) => target.edgesOfKind(kind).filter(seenEdge);
|
|
521
|
+
return (kind) => target.edgesOfKind(kind).filter(pinned().seenEdge);
|
|
115
522
|
case "out":
|
|
116
|
-
return (id, kind) =>
|
|
523
|
+
return (id, kind) => {
|
|
524
|
+
const now = pinned();
|
|
525
|
+
return now.seenId(id) ? now.servedAll(target.out(id, kind)) : [];
|
|
526
|
+
};
|
|
117
527
|
case "in":
|
|
118
|
-
return (id, kind) =>
|
|
528
|
+
return (id, kind) => {
|
|
529
|
+
const now = pinned();
|
|
530
|
+
return now.seenId(id) ? now.servedAll(target.in(id, kind)) : [];
|
|
531
|
+
};
|
|
119
532
|
case "neighbors":
|
|
120
|
-
return (id) =>
|
|
533
|
+
return (id) => {
|
|
534
|
+
const now = pinned();
|
|
535
|
+
return now.seenId(id) ? now.servedAll(target.neighbors(id)) : [];
|
|
536
|
+
};
|
|
121
537
|
case "outEdges":
|
|
122
|
-
return (id, kind) => target.outEdges(id, kind).filter(seenEdge);
|
|
538
|
+
return (id, kind) => target.outEdges(id, kind).filter(pinned().seenEdge);
|
|
123
539
|
case "inEdges":
|
|
124
|
-
return (id, kind) => target.inEdges(id, kind).filter(seenEdge);
|
|
540
|
+
return (id, kind) => target.inEdges(id, kind).filter(pinned().seenEdge);
|
|
125
541
|
case "snapshot":
|
|
126
542
|
return () => {
|
|
127
543
|
const snapshot = target.snapshot();
|
|
128
|
-
|
|
544
|
+
const now = pinned();
|
|
545
|
+
return { ...snapshot, nodes: now.servedAll(snapshot.nodes), edges: snapshot.edges.filter(now.seenEdge) };
|
|
129
546
|
};
|
|
130
547
|
case "size": {
|
|
131
|
-
const
|
|
132
|
-
|
|
548
|
+
const now = pinned();
|
|
549
|
+
const nodes = target.allNodes().filter((node) => now.seenNode(node)).length;
|
|
550
|
+
return { nodes, edges: target.allEdges().filter(now.seenEdge).length };
|
|
133
551
|
}
|
|
134
552
|
default:
|
|
135
553
|
return bound(target, prop);
|
|
@@ -146,6 +564,14 @@ export function seenBy(store, principal) {
|
|
|
146
564
|
case "all":
|
|
147
565
|
case "toJSON":
|
|
148
566
|
return () => [...ops()];
|
|
567
|
+
// Each epoch's base as this seat is served it, so its log folds from there to what it is served (FR-55).
|
|
568
|
+
case "epochs":
|
|
569
|
+
return () => target.epochs().map((epoch) => servedEpoch(epoch));
|
|
570
|
+
case "lastEpoch":
|
|
571
|
+
return () => {
|
|
572
|
+
const epoch = target.lastEpoch();
|
|
573
|
+
return epoch && servedEpoch(epoch);
|
|
574
|
+
};
|
|
149
575
|
case "live":
|
|
150
576
|
return () => {
|
|
151
577
|
const all = ops();
|
|
@@ -178,11 +604,23 @@ export function seenBy(store, principal) {
|
|
|
178
604
|
case "canUndo":
|
|
179
605
|
return (batchIds) => checkUndo(reading(), typeof batchIds === "string" ? [batchIds] : batchIds);
|
|
180
606
|
case "violations":
|
|
181
|
-
return (...args) => target.violations(...args).filter((violation) => (
|
|
607
|
+
return (...args) => target.violations(...args).filter((violation) => violationShown(lens, (id) => full.has(id), violation));
|
|
608
|
+
// What no longer fits, about records this seat is served and naming none it may not see (FR-55).
|
|
609
|
+
case "findings":
|
|
610
|
+
return () => target.findings().filter((finding) => {
|
|
611
|
+
const ids = finding.id.includes("->") ? finding.id.split("->").map((end, at) => (at === 0 ? end.slice(end.indexOf(":") + 1) : end)) : [finding.id];
|
|
612
|
+
return ids.every((id) => !full.has(id) || seenId(id)) && !namesUnseen(finding, lens.sees);
|
|
613
|
+
});
|
|
182
614
|
case "seenBy":
|
|
183
615
|
return (other) => seenBy(target, other);
|
|
184
616
|
case "seenFor":
|
|
185
617
|
return principal;
|
|
618
|
+
// A checkpoint taken through the seat's view is the one it is served.
|
|
619
|
+
case "checkpoint":
|
|
620
|
+
return (...args) => {
|
|
621
|
+
const epoch = target.checkpoint(...args);
|
|
622
|
+
return epoch && servedEpoch(epoch);
|
|
623
|
+
};
|
|
186
624
|
default:
|
|
187
625
|
return bound(target, prop);
|
|
188
626
|
}
|