@openlfcp/shared-objects 0.1.5 → 0.2.0-beta.10
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 +15 -2
- package/dist/admission/actor.d.ts +16 -0
- package/dist/admission/actor.js +35 -0
- package/dist/{canonical.d.ts → admission/canonical.d.ts} +9 -0
- package/dist/{canonical.js → admission/canonical.js} +43 -3
- package/dist/{automerge-bytes.d.ts → admission/framing.d.ts} +41 -1
- package/dist/admission/framing.js +242 -0
- package/dist/admission/index.d.ts +26 -0
- package/dist/admission/index.js +26 -0
- package/dist/{chunk-limits.d.ts → admission/limits.d.ts} +11 -1
- package/dist/{chunk-limits.js → admission/limits.js} +25 -10
- package/dist/{references.d.ts → admission/references.d.ts} +1 -1
- package/dist/{references.js → admission/references.js} +9 -3
- package/dist/admission/sequence.d.ts +81 -0
- package/dist/admission/sequence.js +147 -0
- package/dist/concurrent.d.ts +15 -0
- package/dist/concurrent.js +55 -0
- package/dist/data-profile.d.ts +1 -1
- package/dist/data-profile.js +3 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/replica.d.ts +32 -2
- package/dist/replica.js +165 -209
- package/dist/sections/admission.d.ts +35 -0
- package/dist/sections/admission.js +328 -0
- package/dist/sections/data-profile.d.ts +195 -0
- package/dist/sections/data-profile.js +383 -0
- package/dist/sections/dispatch.d.ts +41 -0
- package/dist/sections/dispatch.js +33 -0
- package/dist/sections/document.d.ts +54 -0
- package/dist/sections/document.js +101 -0
- package/dist/sections/index.d.ts +18 -0
- package/dist/sections/index.js +18 -0
- package/dist/sections/replica.d.ts +406 -0
- package/dist/sections/replica.js +1227 -0
- package/dist/sections/schema.d.ts +77 -0
- package/dist/sections/schema.js +548 -0
- package/dist/sections/tree.d.ts +83 -0
- package/dist/sections/tree.js +401 -0
- package/dist/sections/values.d.ts +30 -0
- package/dist/sections/values.js +36 -0
- package/dist/values.d.ts +3 -18
- package/dist/values.js +7 -95
- package/package.json +12 -4
- package/dist/automerge-bytes.js +0 -111
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as A from "@automerge/automerge";
|
|
2
|
+
import { type SectionValidation } from "./schema.js";
|
|
3
|
+
/**
|
|
4
|
+
* The effective tree of a section and its structural facts
|
|
5
|
+
* (SHARED-SECTIONS-PROFILE-01 §7, §9, §14.3; LFCP-02-014), derived from the
|
|
6
|
+
* fully merged state, never from arrival order:
|
|
7
|
+
*
|
|
8
|
+
* - a node with more than one concurrent placement is PLACEMENT_CONFLICT;
|
|
9
|
+
* its lifecycle (a Task node's: its Task's) with concurrent different
|
|
10
|
+
* values is LIFECYCLE_CONFLICT (equal values agree);
|
|
11
|
+
* - every member of a cycle of the selected parent graph is PARENT_CYCLE;
|
|
12
|
+
* - every node under a conflicted, cyclic, invalid or collided node is
|
|
13
|
+
* BLOCKED_PARENT, to a fixed point;
|
|
14
|
+
* - a deleted node, and every node under one, is hidden; a node whose
|
|
15
|
+
* lifecycle is in conflict is not: its branch is blocked (§7.6);
|
|
16
|
+
* - the tree scans each children list in its merged order and emits a node
|
|
17
|
+
* only when it is eligible, visible and selects exactly that entry.
|
|
18
|
+
*
|
|
19
|
+
* No winner is chosen and nothing is rewritten: blocked and hidden content
|
|
20
|
+
* stays in the document, and recovery lists it. Traversals are iterative,
|
|
21
|
+
* so deep or adversarial graphs do not depend on the call stack (§16.4).
|
|
22
|
+
*/
|
|
23
|
+
export type StructuralFact = "PLACEMENT_CONFLICT" | "PARENT_CYCLE" | "BLOCKED_PARENT" | "LIFECYCLE_CONFLICT";
|
|
24
|
+
export interface TreeEntry {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
/** The section ID or the parent node's ID. */
|
|
27
|
+
readonly parent: string;
|
|
28
|
+
/** 0 under the section. */
|
|
29
|
+
readonly depth: number;
|
|
30
|
+
readonly kind: string;
|
|
31
|
+
}
|
|
32
|
+
export interface SectionTree {
|
|
33
|
+
/**
|
|
34
|
+
* PROFILE_INVALID: section-level problems, nothing projected; IMPORTING:
|
|
35
|
+
* no `ready` (§12.1), nothing projected; STRUCTURAL_ATTENTION: a
|
|
36
|
+
* recovery fact, an invalid node or a collision; VALID otherwise.
|
|
37
|
+
*/
|
|
38
|
+
readonly classification: "PROFILE_INVALID" | "IMPORTING" | "STRUCTURAL_ATTENTION" | "VALID";
|
|
39
|
+
/** The visible nodes in projection order (preorder). */
|
|
40
|
+
readonly tree: readonly TreeEntry[];
|
|
41
|
+
/** Nodes hidden by their own or an ancestor's deletion, sorted. */
|
|
42
|
+
readonly hidden: readonly string[];
|
|
43
|
+
/** Blocked nodes with their fact, sorted by node ID for presentation only. */
|
|
44
|
+
readonly recovery: readonly {
|
|
45
|
+
readonly id: string;
|
|
46
|
+
readonly code: StructuralFact;
|
|
47
|
+
}[];
|
|
48
|
+
/** Invalid nodes with their §14.2 diagnostic, sorted. */
|
|
49
|
+
readonly invalid: readonly {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly diagnostic: string;
|
|
52
|
+
}[];
|
|
53
|
+
/** Colliding IDs (§14.2), sorted. */
|
|
54
|
+
readonly collisions: readonly string[];
|
|
55
|
+
/**
|
|
56
|
+
* §9, §14.3 EDIT_UNDER_DELETED_ANCESTOR: hidden nodes whose content (the
|
|
57
|
+
* node created, its Text edited, its Task's title or status set) changed
|
|
58
|
+
* concurrently with the deletion of the node or an ancestor; sorted. The
|
|
59
|
+
* edit is retained, not visibly applied: it needs the user's attention.
|
|
60
|
+
*/
|
|
61
|
+
readonly retainedConcurrentEdits: readonly string[];
|
|
62
|
+
/**
|
|
63
|
+
* SOP §44–§47: the Task scalar fields with concurrent values (title,
|
|
64
|
+
* status, lifecycle, due, priority), each with its values sorted. A
|
|
65
|
+
* provisional value may show; the conflict stays visible until a user
|
|
66
|
+
* resolves it.
|
|
67
|
+
*/
|
|
68
|
+
readonly scalarConflicts: readonly {
|
|
69
|
+
readonly id: string;
|
|
70
|
+
readonly field: string;
|
|
71
|
+
readonly values: readonly string[];
|
|
72
|
+
}[];
|
|
73
|
+
/** Each blocked node's candidate placements and their parents (§7 recovery information). */
|
|
74
|
+
readonly candidates: ReadonlyMap<string, readonly {
|
|
75
|
+
readonly placement: string;
|
|
76
|
+
readonly parent: string;
|
|
77
|
+
}[]>;
|
|
78
|
+
/** The schema validation the tree was derived from. */
|
|
79
|
+
readonly validation: SectionValidation;
|
|
80
|
+
}
|
|
81
|
+
/** §7: the effective tree and structural facts of `doc`. Reads only. */
|
|
82
|
+
export declare function deriveTree(doc: A.Doc<unknown>, validation?: SectionValidation): SectionTree;
|
|
83
|
+
//# sourceMappingURL=tree.d.ts.map
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import * as A from "@automerge/automerge";
|
|
2
|
+
import { distinctValues } from "../concurrent.js";
|
|
3
|
+
import { validateSection } from "./schema.js";
|
|
4
|
+
/** The Task scalar fields whose concurrent values tree() reports. */
|
|
5
|
+
const CONFLICT_FIELDS = ["title", "status", "lifecycle", "due", "priority"];
|
|
6
|
+
const str = (v) => A.isImmutableString(v) ? v.toString() : typeof v === "string" ? v : undefined;
|
|
7
|
+
/** The distinct concurrent values of `map[key]` in `doc`, as strings (SOP §45: equal values agree). */
|
|
8
|
+
function values(doc, map, key) {
|
|
9
|
+
return distinctValues(doc, map, key).map(str);
|
|
10
|
+
}
|
|
11
|
+
const byId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
12
|
+
/** §7: the effective tree and structural facts of `doc`. Reads only. */
|
|
13
|
+
export function deriveTree(doc, validation = validateSection(doc)) {
|
|
14
|
+
const root = doc;
|
|
15
|
+
const nodes = (root.nodes ?? {});
|
|
16
|
+
const placements = (root.placements ?? {});
|
|
17
|
+
const objects = (root.objects ?? {});
|
|
18
|
+
const sectionId = validation.sectionId;
|
|
19
|
+
const invalid = validation.nodes;
|
|
20
|
+
const collided = new Set(validation.collided);
|
|
21
|
+
const blocked = new Map();
|
|
22
|
+
const parents = new Map();
|
|
23
|
+
const candidates = new Map();
|
|
24
|
+
const keys = Object.keys(nodes).sort();
|
|
25
|
+
const node = (id) => nodes[id];
|
|
26
|
+
const owner = (id) => (str(node(id)?.kind) === "task" ? objects[id] : node(id));
|
|
27
|
+
// One pass over the nodes: selected parent, deletion, conflicts.
|
|
28
|
+
const deleted = new Set();
|
|
29
|
+
for (const id of keys) {
|
|
30
|
+
// §7.6: a lifecycle conflict has no winner. It blocks the branch (below)
|
|
31
|
+
// instead of the provisional value hiding it: the node is not deleted,
|
|
32
|
+
// and edits under it are not under a deleted ancestor.
|
|
33
|
+
const lifecycles = new Set(values(doc, owner(id), "lifecycle"));
|
|
34
|
+
if (lifecycles.size === 1 && lifecycles.has("deleted"))
|
|
35
|
+
deleted.add(id);
|
|
36
|
+
if (collided.has(id))
|
|
37
|
+
continue;
|
|
38
|
+
const n = node(id);
|
|
39
|
+
const selected = values(doc, n, "placement");
|
|
40
|
+
const parentOf = (p) => p === undefined ? undefined : str(placements[p]?.parent_id);
|
|
41
|
+
// The default value selects the parent used for hiding and for the walk below.
|
|
42
|
+
const parent = parentOf(str(n.placement));
|
|
43
|
+
if (parent !== undefined)
|
|
44
|
+
parents.set(id, parent);
|
|
45
|
+
if (selected.length > 1) {
|
|
46
|
+
blocked.set(id, "PLACEMENT_CONFLICT");
|
|
47
|
+
candidates.set(id, selected.map((p) => ({ placement: p ?? "", parent: parentOf(p) ?? "" })));
|
|
48
|
+
}
|
|
49
|
+
if (lifecycles.size > 1)
|
|
50
|
+
blocked.set(id, "LIFECYCLE_CONFLICT");
|
|
51
|
+
}
|
|
52
|
+
for (const id of invalid.keys())
|
|
53
|
+
blocked.delete(id);
|
|
54
|
+
// Every member of a cycle of the selected parent graph (§7.4), whatever
|
|
55
|
+
// the start: each node has one parent, so one coloured walk per start
|
|
56
|
+
// that stops at any node already done finds them all in linear time.
|
|
57
|
+
const done = new Set();
|
|
58
|
+
for (const start of keys) {
|
|
59
|
+
const path = [];
|
|
60
|
+
const onPath = new Map();
|
|
61
|
+
let n = start;
|
|
62
|
+
while (n !== undefined &&
|
|
63
|
+
n !== sectionId &&
|
|
64
|
+
node(n) !== undefined &&
|
|
65
|
+
!done.has(n) &&
|
|
66
|
+
!blocked.has(n) &&
|
|
67
|
+
!collided.has(n) &&
|
|
68
|
+
!invalid.has(n)) {
|
|
69
|
+
const at = onPath.get(n);
|
|
70
|
+
if (at !== undefined) {
|
|
71
|
+
for (const c of path.slice(at))
|
|
72
|
+
blocked.set(c, "PARENT_CYCLE");
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
onPath.set(n, path.length);
|
|
76
|
+
path.push(n);
|
|
77
|
+
n = parents.get(n);
|
|
78
|
+
}
|
|
79
|
+
for (const c of path)
|
|
80
|
+
done.add(c);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolves `fact(id)` = own(id) || fact(parent) along the parent chain,
|
|
84
|
+
* iteratively and memoized; a walk that comes back to a node of its own
|
|
85
|
+
* path stops there, with what the loop holds.
|
|
86
|
+
*/
|
|
87
|
+
const chained = (own) => {
|
|
88
|
+
const memo = new Map();
|
|
89
|
+
return (id) => {
|
|
90
|
+
const path = [];
|
|
91
|
+
const onPath = new Set();
|
|
92
|
+
let tail = false;
|
|
93
|
+
let n = id;
|
|
94
|
+
while (n !== undefined && n !== sectionId && node(n) !== undefined) {
|
|
95
|
+
const known = memo.get(n);
|
|
96
|
+
if (known !== undefined) {
|
|
97
|
+
tail = known;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
if (onPath.has(n)) {
|
|
101
|
+
// A loop: every node of it sees all of it.
|
|
102
|
+
tail = path.slice(path.indexOf(n)).some(own);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
onPath.add(n);
|
|
106
|
+
path.push(n);
|
|
107
|
+
n = parents.get(n);
|
|
108
|
+
}
|
|
109
|
+
for (let k = path.length - 1; k >= 0; k--) {
|
|
110
|
+
tail = tail || own(path[k]);
|
|
111
|
+
memo.set(path[k], tail);
|
|
112
|
+
}
|
|
113
|
+
return memo.get(id) ?? tail;
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
// §7.5: blocked structure propagates to every descendant.
|
|
117
|
+
const out = (id) => id !== undefined && (blocked.has(id) || invalid.has(id) || collided.has(id));
|
|
118
|
+
const isOut = new Set(keys.filter((id) => out(id)));
|
|
119
|
+
const blockedAbove = chained((id) => isOut.has(id));
|
|
120
|
+
for (const id of keys) {
|
|
121
|
+
const parent = parents.get(id);
|
|
122
|
+
if (!isOut.has(id) && parent !== undefined && blockedAbove(parent))
|
|
123
|
+
blocked.set(id, "BLOCKED_PARENT");
|
|
124
|
+
}
|
|
125
|
+
// §9: a deleted node or a descendant of one is hidden, not removed.
|
|
126
|
+
const isHidden = chained((id) => deleted.has(id));
|
|
127
|
+
const hidden = new Set(keys.filter((id) => isHidden(id)));
|
|
128
|
+
const retained = hidden.size === 0 ? [] : retainedEdits(doc, hidden, (id) => parents.get(id), sectionId);
|
|
129
|
+
const tree = [];
|
|
130
|
+
const projectable = validation.state === "ready" && sectionId !== undefined;
|
|
131
|
+
if (projectable) {
|
|
132
|
+
const laneOf = (p) => ((p === sectionId ? root.section.children : node(p)?.children) ?? []);
|
|
133
|
+
// An explicit stack of (parent, depth, next index) instead of recursion.
|
|
134
|
+
const stack = [
|
|
135
|
+
{ parent: sectionId, depth: 0, lane: laneOf(sectionId), i: 0 },
|
|
136
|
+
];
|
|
137
|
+
const emitted = new Set();
|
|
138
|
+
while (stack.length > 0) {
|
|
139
|
+
const top = stack[stack.length - 1];
|
|
140
|
+
if (top.i >= top.lane.length) {
|
|
141
|
+
stack.pop();
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const slot = str(top.lane[top.i++]);
|
|
145
|
+
if (slot === undefined || collided.has(slot))
|
|
146
|
+
continue;
|
|
147
|
+
const id = str(placements[slot]?.node_id);
|
|
148
|
+
if (id === undefined || node(id) === undefined || out(id) || hidden.has(id))
|
|
149
|
+
continue;
|
|
150
|
+
if (str(node(id)?.placement) !== slot || emitted.has(id))
|
|
151
|
+
continue;
|
|
152
|
+
emitted.add(id);
|
|
153
|
+
tree.push(Object.freeze({
|
|
154
|
+
id,
|
|
155
|
+
parent: top.parent,
|
|
156
|
+
depth: top.depth,
|
|
157
|
+
kind: str(node(id)?.kind) ?? "",
|
|
158
|
+
}));
|
|
159
|
+
stack.push({ parent: id, depth: top.depth + 1, lane: laneOf(id), i: 0 });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const classification = validation.state === "invalid"
|
|
163
|
+
? "PROFILE_INVALID"
|
|
164
|
+
: validation.state === "importing"
|
|
165
|
+
? "IMPORTING"
|
|
166
|
+
: blocked.size > 0 || invalid.size > 0 || validation.collisions.length > 0
|
|
167
|
+
? "STRUCTURAL_ATTENTION"
|
|
168
|
+
: "VALID";
|
|
169
|
+
return Object.freeze({
|
|
170
|
+
classification,
|
|
171
|
+
tree: Object.freeze(tree),
|
|
172
|
+
hidden: Object.freeze([...hidden].sort()),
|
|
173
|
+
recovery: Object.freeze([...blocked].map(([id, code]) => ({ id, code })).sort(byId)),
|
|
174
|
+
invalid: Object.freeze([...invalid].map(([id, p]) => ({ id, diagnostic: p.diagnostic })).sort(byId)),
|
|
175
|
+
collisions: validation.collisions,
|
|
176
|
+
retainedConcurrentEdits: Object.freeze(retained),
|
|
177
|
+
scalarConflicts: Object.freeze(Object.keys(objects).flatMap((id) => CONFLICT_FIELDS.flatMap((field) => {
|
|
178
|
+
const vs = values(doc, objects[id], field);
|
|
179
|
+
return vs.length > 1 ? [{ id, field, values: vs.map((v) => v ?? "").sort() }] : [];
|
|
180
|
+
}))),
|
|
181
|
+
candidates,
|
|
182
|
+
validation,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const summarise = (bytes) => {
|
|
186
|
+
const c = A.decodeChange(bytes);
|
|
187
|
+
return {
|
|
188
|
+
hash: c.hash,
|
|
189
|
+
deps: c.deps,
|
|
190
|
+
actor: c.actor,
|
|
191
|
+
seq: c.seq,
|
|
192
|
+
ops: c.ops.map((op) => {
|
|
193
|
+
const o = op;
|
|
194
|
+
return { obj: o.obj, action: o.action, key: o.key, value: o.value };
|
|
195
|
+
}),
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
/**
|
|
199
|
+
* The decoded history of the documents read last, by their heads (a few,
|
|
200
|
+
* least recently used last out). A change is the same wherever it is, so a
|
|
201
|
+
* later revision of the same document decodes only the changes since a
|
|
202
|
+
* revision read before, whose heads it has: decoding the whole history on
|
|
203
|
+
* every new revision was linear in it, per pass (soak #11).
|
|
204
|
+
*/
|
|
205
|
+
const histories = new Map();
|
|
206
|
+
const HISTORIES = 16;
|
|
207
|
+
function historyOf(doc) {
|
|
208
|
+
const key = [...A.getHeads(doc)].sort().join(",");
|
|
209
|
+
const known = histories.get(key);
|
|
210
|
+
if (known !== undefined) {
|
|
211
|
+
histories.delete(key);
|
|
212
|
+
histories.set(key, known);
|
|
213
|
+
return known;
|
|
214
|
+
}
|
|
215
|
+
let out;
|
|
216
|
+
for (const [k, list] of [...histories].reverse()) {
|
|
217
|
+
const heads = k === "" ? [] : k.split(",");
|
|
218
|
+
if (A.getMissingDeps(doc, heads).length > 0)
|
|
219
|
+
continue;
|
|
220
|
+
out = [...list, ...A.getChangesSince(doc, heads).map(summarise)];
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
out ??= A.getAllChanges(doc).map(summarise);
|
|
224
|
+
histories.set(key, out);
|
|
225
|
+
while (histories.size > HISTORIES)
|
|
226
|
+
histories.delete(histories.keys().next().value);
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
function retainedEdits(doc, hidden, parentOf, sectionId) {
|
|
230
|
+
const root = doc;
|
|
231
|
+
const nodes = (root.nodes ?? {});
|
|
232
|
+
const objects = (root.objects ?? {});
|
|
233
|
+
const nodesObj = A.getObjectId(root, "nodes");
|
|
234
|
+
// Object IDs to the node whose content or lifecycle they hold.
|
|
235
|
+
const textOf = new Map();
|
|
236
|
+
const ownerOf = new Map();
|
|
237
|
+
const taskOf = new Map();
|
|
238
|
+
// The object ID of each node's own map, for a move written to its `placement`.
|
|
239
|
+
const nodeMapOf = new Map();
|
|
240
|
+
for (const id of Object.keys(nodes)) {
|
|
241
|
+
const n = nodes[id];
|
|
242
|
+
const nodeMap = A.getObjectId(n);
|
|
243
|
+
if (nodeMap !== null)
|
|
244
|
+
nodeMapOf.set(nodeMap, id);
|
|
245
|
+
const text = A.getObjectId(n, "text");
|
|
246
|
+
if (text !== null)
|
|
247
|
+
textOf.set(text, id);
|
|
248
|
+
const isTask = str(n.kind) === "task";
|
|
249
|
+
const task = isTask ? objects[id] : undefined;
|
|
250
|
+
const owner = isTask ? (task === undefined ? null : A.getObjectId(task)) : A.getObjectId(n);
|
|
251
|
+
if (owner !== null)
|
|
252
|
+
ownerOf.set(owner, id);
|
|
253
|
+
if (task !== undefined) {
|
|
254
|
+
const t = A.getObjectId(task);
|
|
255
|
+
if (t !== null)
|
|
256
|
+
taskOf.set(t, id);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const deps = new Map();
|
|
260
|
+
// Each change's actor and sequence number, for its vector clock below.
|
|
261
|
+
const author = new Map();
|
|
262
|
+
const deletes = [];
|
|
263
|
+
// Every lifecycle write (any value), to tell whether a deletion is still held.
|
|
264
|
+
const lifecycleWrites = [];
|
|
265
|
+
const edits = [];
|
|
266
|
+
for (const change of historyOf(doc)) {
|
|
267
|
+
deps.set(change.hash, change.deps);
|
|
268
|
+
author.set(change.hash, { actor: change.actor, seq: change.seq });
|
|
269
|
+
const lastLifecycle = new Map();
|
|
270
|
+
const touched = new Set();
|
|
271
|
+
for (const op of change.ops) {
|
|
272
|
+
const key = op.key;
|
|
273
|
+
const value = op.value;
|
|
274
|
+
if (op.obj === nodesObj && op.action === "makeMap" && key !== undefined && hidden.has(key))
|
|
275
|
+
touched.add(key);
|
|
276
|
+
const text = textOf.get(op.obj);
|
|
277
|
+
if (text !== undefined && hidden.has(text))
|
|
278
|
+
touched.add(text);
|
|
279
|
+
const task = taskOf.get(op.obj);
|
|
280
|
+
if (task !== undefined && hidden.has(task) && (key === "title" || key === "status"))
|
|
281
|
+
touched.add(task);
|
|
282
|
+
// §8, §9: a move writes the node's `placement` register (node.move or
|
|
283
|
+
// node.resolve_placement). A creation also writes it and is already
|
|
284
|
+
// counted above; what this adds is a move of an existing node.
|
|
285
|
+
const moved = nodeMapOf.get(op.obj);
|
|
286
|
+
if (moved !== undefined && key === "placement" && hidden.has(moved))
|
|
287
|
+
touched.add(moved);
|
|
288
|
+
const owner = ownerOf.get(op.obj);
|
|
289
|
+
if (owner !== undefined && key === "lifecycle")
|
|
290
|
+
lastLifecycle.set(owner, value);
|
|
291
|
+
}
|
|
292
|
+
for (const [owner, value] of lastLifecycle) {
|
|
293
|
+
lifecycleWrites.push({ hash: change.hash, node: owner });
|
|
294
|
+
if (value === "deleted")
|
|
295
|
+
deletes.push({ hash: change.hash, node: owner });
|
|
296
|
+
}
|
|
297
|
+
for (const node of touched)
|
|
298
|
+
edits.push({ hash: change.hash, node });
|
|
299
|
+
}
|
|
300
|
+
if (deletes.length === 0 || edits.length === 0)
|
|
301
|
+
return [];
|
|
302
|
+
/**
|
|
303
|
+
* Each change's vector clock: the highest sequence number of every actor
|
|
304
|
+
* in its causal history, itself included. When each change of an actor
|
|
305
|
+
* has that actor's previous change in its history (as Automerge writes
|
|
306
|
+
* them), `a` is in the history of `b` exactly when b's clock has a's actor
|
|
307
|
+
* at a's sequence or later: O(history × actors), where a set of ancestors
|
|
308
|
+
* per change was O(history²) (soak #11: seconds per pass after ~3,000
|
|
309
|
+
* changes in a section with deleted Tasks). A history where that does not
|
|
310
|
+
* hold (a change made without its actor's previous one), or one with more
|
|
311
|
+
* than 32 actors, falls back to the exact sets.
|
|
312
|
+
*/
|
|
313
|
+
const clocks = new Map();
|
|
314
|
+
// A clock holds an entry per actor: with many actors (a hostile or unusual
|
|
315
|
+
// history), the exact sets are cheaper.
|
|
316
|
+
let regular = new Set([...author.values()].map((x) => x.actor)).size <= 32;
|
|
317
|
+
for (const start of regular ? deps.keys() : []) {
|
|
318
|
+
const stack = [start];
|
|
319
|
+
while (stack.length > 0) {
|
|
320
|
+
const h = stack[stack.length - 1];
|
|
321
|
+
if (clocks.has(h)) {
|
|
322
|
+
stack.pop();
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const pending = (deps.get(h) ?? []).filter((d) => deps.has(d) && !clocks.has(d));
|
|
326
|
+
if (pending.length > 0) {
|
|
327
|
+
stack.push(...pending);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
stack.pop();
|
|
331
|
+
const clock = new Map();
|
|
332
|
+
for (const d of deps.get(h) ?? [])
|
|
333
|
+
for (const [actor, seq] of clocks.get(d) ?? [])
|
|
334
|
+
if (seq > (clock.get(actor) ?? 0))
|
|
335
|
+
clock.set(actor, seq);
|
|
336
|
+
const me = author.get(h);
|
|
337
|
+
if (me !== undefined) {
|
|
338
|
+
if ((clock.get(me.actor) ?? 0) !== me.seq - 1)
|
|
339
|
+
regular = false;
|
|
340
|
+
if (me.seq > (clock.get(me.actor) ?? 0))
|
|
341
|
+
clock.set(me.actor, me.seq);
|
|
342
|
+
}
|
|
343
|
+
clocks.set(h, clock);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/** Every change `hash` depends on, transitively, memoized (the exact fallback). */
|
|
347
|
+
const ancestry = new Map();
|
|
348
|
+
const ancestorsOf = (hash) => {
|
|
349
|
+
const known = ancestry.get(hash);
|
|
350
|
+
if (known !== undefined)
|
|
351
|
+
return known;
|
|
352
|
+
const out = new Set();
|
|
353
|
+
const todo = [...(deps.get(hash) ?? [])];
|
|
354
|
+
while (todo.length > 0) {
|
|
355
|
+
const h = todo.pop();
|
|
356
|
+
if (out.has(h))
|
|
357
|
+
continue;
|
|
358
|
+
out.add(h);
|
|
359
|
+
for (const d of deps.get(h) ?? [])
|
|
360
|
+
if (!out.has(d))
|
|
361
|
+
todo.push(d);
|
|
362
|
+
}
|
|
363
|
+
ancestry.set(hash, out);
|
|
364
|
+
return out;
|
|
365
|
+
};
|
|
366
|
+
/** Whether change `a` is in the causal history of change `b` (a ≠ b). */
|
|
367
|
+
const before = (a, b) => {
|
|
368
|
+
if (a === b)
|
|
369
|
+
return false;
|
|
370
|
+
if (!regular)
|
|
371
|
+
return ancestorsOf(b).has(a);
|
|
372
|
+
const me = author.get(a);
|
|
373
|
+
return me !== undefined && (clocks.get(b)?.get(me.actor) ?? 0) >= me.seq;
|
|
374
|
+
};
|
|
375
|
+
const concurrent = (a, b) => a !== b && !before(a, b) && !before(b, a);
|
|
376
|
+
// §9 (baseline.9): a deletion counts only while the lifecycle register still
|
|
377
|
+
// holds it. A later write to the same register — one that has the deletion in
|
|
378
|
+
// its dependencies — supersedes it, so deleting a node again clears the
|
|
379
|
+
// attention a concurrent edit raised. Two concurrent deletions supersede
|
|
380
|
+
// neither, so both still count (SS86); the writes come from each change's
|
|
381
|
+
// operations, which also keeps a deletion merged after a concurrent one.
|
|
382
|
+
const heldDeletes = deletes.filter((d) => !lifecycleWrites.some((w) => w.node === d.node && w.hash !== d.hash && before(d.hash, w.hash)));
|
|
383
|
+
const deletesOf = new Map();
|
|
384
|
+
for (const d of heldDeletes)
|
|
385
|
+
deletesOf.set(d.node, [...(deletesOf.get(d.node) ?? []), d.hash]);
|
|
386
|
+
const attention = new Set();
|
|
387
|
+
for (const edit of edits) {
|
|
388
|
+
if (attention.has(edit.node))
|
|
389
|
+
continue;
|
|
390
|
+
const seen = new Set();
|
|
391
|
+
for (let p = edit.node; p !== undefined && p !== sectionId && nodes[p] !== undefined && !seen.has(p); p = parentOf(p)) {
|
|
392
|
+
seen.add(p);
|
|
393
|
+
if ((deletesOf.get(p) ?? []).some((d) => concurrent(d, edit.hash))) {
|
|
394
|
+
attention.add(edit.node);
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return [...attention].sort();
|
|
400
|
+
}
|
|
401
|
+
//# sourceMappingURL=tree.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PrincipalId, ResourceId } from "@openlfcp/core";
|
|
2
|
+
/**
|
|
3
|
+
* Identifiers and value domains of SHARED-SECTIONS-PROFILE-01 (Working
|
|
4
|
+
* Draft 0.3): the profile identifier, the actor domain of its §2 binding,
|
|
5
|
+
* and the enumerations of §4.
|
|
6
|
+
*/
|
|
7
|
+
/** The profile identifier, as a Resource's Genesis names it. */
|
|
8
|
+
export declare const SECTIONS_PROFILE_ID = "org.openlfcp.shared-sections.v1";
|
|
9
|
+
/** §2: the actor domain this profile gives the inherited SOP §8 binding. */
|
|
10
|
+
export declare const SECTIONS_ACTOR_DOMAIN = "OPENLFCP-SHARED-SECTIONS-ACTOR-v1";
|
|
11
|
+
/**
|
|
12
|
+
* §2: the Automerge actor ID of `principal` editing the section `resource`:
|
|
13
|
+
* SHA-256(ASCII("OPENLFCP-SHARED-SECTIONS-ACTOR-v1") || resource_id || principal_id),
|
|
14
|
+
* from the raw 32-byte IDs. It differs from the Shared Objects actor of the
|
|
15
|
+
* same pair, so a change of one profile never passes as the other's.
|
|
16
|
+
*/
|
|
17
|
+
export declare function deriveSectionActorId(resource: ResourceId, principal: PrincipalId): Uint8Array;
|
|
18
|
+
/** §4.2: the kinds of a node. */
|
|
19
|
+
export declare const NODE_KINDS: readonly ["task", "paragraph", "item", "raw"];
|
|
20
|
+
export type NodeKind = (typeof NODE_KINDS)[number];
|
|
21
|
+
/** §4.2: the kinds that hold collaborative Text. */
|
|
22
|
+
export declare const TEXT_KINDS: ReadonlySet<NodeKind>;
|
|
23
|
+
/** §4.2: the kinds that can parent content; paragraph and raw nodes cannot. */
|
|
24
|
+
export declare const PARENT_KINDS: ReadonlySet<NodeKind>;
|
|
25
|
+
/** §4.2: list_style, on task and item nodes only. */
|
|
26
|
+
export declare const LIST_STYLES: readonly ["bullet", "ordered"];
|
|
27
|
+
export type ListStyle = (typeof LIST_STYLES)[number];
|
|
28
|
+
/** §3: the root containers, each a map. */
|
|
29
|
+
export declare const ROOT_MAPS: readonly ["section", "objects", "nodes", "placements", "extensions"];
|
|
30
|
+
//# sourceMappingURL=values.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { deriveDomainActorId } from "../admission/actor.js";
|
|
2
|
+
/**
|
|
3
|
+
* Identifiers and value domains of SHARED-SECTIONS-PROFILE-01 (Working
|
|
4
|
+
* Draft 0.3): the profile identifier, the actor domain of its §2 binding,
|
|
5
|
+
* and the enumerations of §4.
|
|
6
|
+
*/
|
|
7
|
+
/** The profile identifier, as a Resource's Genesis names it. */
|
|
8
|
+
export const SECTIONS_PROFILE_ID = "org.openlfcp.shared-sections.v1";
|
|
9
|
+
/** §2: the actor domain this profile gives the inherited SOP §8 binding. */
|
|
10
|
+
export const SECTIONS_ACTOR_DOMAIN = "OPENLFCP-SHARED-SECTIONS-ACTOR-v1";
|
|
11
|
+
/**
|
|
12
|
+
* §2: the Automerge actor ID of `principal` editing the section `resource`:
|
|
13
|
+
* SHA-256(ASCII("OPENLFCP-SHARED-SECTIONS-ACTOR-v1") || resource_id || principal_id),
|
|
14
|
+
* from the raw 32-byte IDs. It differs from the Shared Objects actor of the
|
|
15
|
+
* same pair, so a change of one profile never passes as the other's.
|
|
16
|
+
*/
|
|
17
|
+
export function deriveSectionActorId(resource, principal) {
|
|
18
|
+
return deriveDomainActorId(SECTIONS_ACTOR_DOMAIN, resource, principal);
|
|
19
|
+
}
|
|
20
|
+
/** §4.2: the kinds of a node. */
|
|
21
|
+
export const NODE_KINDS = Object.freeze(["task", "paragraph", "item", "raw"]);
|
|
22
|
+
/** §4.2: the kinds that hold collaborative Text. */
|
|
23
|
+
export const TEXT_KINDS = new Set(["paragraph", "item", "raw"]);
|
|
24
|
+
/** §4.2: the kinds that can parent content; paragraph and raw nodes cannot. */
|
|
25
|
+
export const PARENT_KINDS = new Set(["task", "item"]);
|
|
26
|
+
/** §4.2: list_style, on task and item nodes only. */
|
|
27
|
+
export const LIST_STYLES = Object.freeze(["bullet", "ordered"]);
|
|
28
|
+
/** §3: the root containers, each a map. */
|
|
29
|
+
export const ROOT_MAPS = Object.freeze([
|
|
30
|
+
"section",
|
|
31
|
+
"objects",
|
|
32
|
+
"nodes",
|
|
33
|
+
"placements",
|
|
34
|
+
"extensions",
|
|
35
|
+
]);
|
|
36
|
+
//# sourceMappingURL=values.js.map
|
package/dist/values.d.ts
CHANGED
|
@@ -25,28 +25,13 @@ export declare function isUtcTimestamp(text: unknown): text is string;
|
|
|
25
25
|
export declare const isReverseDomain: (text: unknown) => text is string;
|
|
26
26
|
/** §33 ABNF namespaced-value: x/<reverse-domain>/<value>, value non-empty without "/". */
|
|
27
27
|
export declare const isNamespacedValue: (text: unknown) => text is string;
|
|
28
|
+
/** §8: the actor domain of this profile. */
|
|
29
|
+
export declare const ACTOR_DOMAIN = "OPENLFCP-SHARED-OBJECTS-ACTOR-v1";
|
|
28
30
|
/**
|
|
29
31
|
* §8: the Automerge actor ID of `principal` editing `resource`:
|
|
30
32
|
* SHA-256(ASCII("OPENLFCP-SHARED-OBJECTS-ACTOR-v1") || resource_id || principal_id),
|
|
31
33
|
* from the raw 32-byte IDs.
|
|
32
34
|
*/
|
|
33
35
|
export declare function deriveActorId(resource: ResourceId, principal: PrincipalId): Uint8Array;
|
|
34
|
-
|
|
35
|
-
export declare const FRAMING_VERSION = 1;
|
|
36
|
-
/**
|
|
37
|
-
* Deterministic CBOR of [1, bstr] (§11 shared-objects-change, §13
|
|
38
|
-
* shared-objects-snapshot): the profile plaintext of a Data Unit (one
|
|
39
|
-
* Automerge change) or a Snapshot (one Automerge full save). The bytes are
|
|
40
|
-
* framed as given; checking that they are a valid Automerge change or save
|
|
41
|
-
* is the Automerge binding (LFCP-031).
|
|
42
|
-
*/
|
|
43
|
-
export declare function frameProfilePayload(bytes: Uint8Array): Uint8Array;
|
|
44
|
-
/**
|
|
45
|
-
* The inner bytes of a framed profile plaintext. §11: a receiver MUST
|
|
46
|
-
* reject plaintext that is not valid CBOR, not a two-element array, or
|
|
47
|
-
* uses an unsupported framing version; this also requires the
|
|
48
|
-
* deterministic encoding and no trailing bytes. Throws PROFILE_INVALID /
|
|
49
|
-
* INVALID_AUTOMERGE_BYTES (§74.1).
|
|
50
|
-
*/
|
|
51
|
-
export declare function unframeProfilePayload(framed: Uint8Array): Uint8Array;
|
|
36
|
+
export { FRAMING_VERSION, frameProfilePayload, unframeProfilePayload, } from "./admission/framing.js";
|
|
52
37
|
//# sourceMappingURL=values.d.ts.map
|