@openlfcp/shared-objects 0.1.4 → 0.2.0-beta.1
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} +2 -0
- package/dist/{canonical.js → admission/canonical.js} +9 -2
- package/dist/{automerge-bytes.d.ts → admission/framing.d.ts} +32 -1
- package/dist/admission/framing.js +231 -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} +11 -0
- package/dist/admission/sequence.d.ts +81 -0
- package/dist/admission/sequence.js +133 -0
- package/dist/data-profile.d.ts +1 -1
- package/dist/data-profile.js +3 -7
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/replica.d.ts +38 -2
- package/dist/replica.js +177 -214
- 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 +412 -0
- package/dist/sections/replica.js +1149 -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 +290 -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,290 @@
|
|
|
1
|
+
import * as A from "@automerge/automerge";
|
|
2
|
+
import { validateSection } from "./schema.js";
|
|
3
|
+
/** The Task scalar fields whose concurrent values tree() reports. */
|
|
4
|
+
const CONFLICT_FIELDS = ["title", "status", "lifecycle", "due", "priority"];
|
|
5
|
+
const str = (v) => A.isImmutableString(v) ? v.toString() : typeof v === "string" ? v : undefined;
|
|
6
|
+
/** Every concurrent value of `map[key]`, as strings. */
|
|
7
|
+
function values(map, key) {
|
|
8
|
+
if (map === undefined || !(key in map))
|
|
9
|
+
return [];
|
|
10
|
+
const c = A.getConflicts(map, key);
|
|
11
|
+
return (c === undefined ? [map[key]] : Object.values(c)).map(str);
|
|
12
|
+
}
|
|
13
|
+
const byId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
14
|
+
/** §7: the effective tree and structural facts of `doc`. Reads only. */
|
|
15
|
+
export function deriveTree(doc, validation = validateSection(doc)) {
|
|
16
|
+
const root = doc;
|
|
17
|
+
const nodes = (root.nodes ?? {});
|
|
18
|
+
const placements = (root.placements ?? {});
|
|
19
|
+
const objects = (root.objects ?? {});
|
|
20
|
+
const sectionId = validation.sectionId;
|
|
21
|
+
const invalid = validation.nodes;
|
|
22
|
+
const collided = new Set(validation.collided);
|
|
23
|
+
const blocked = new Map();
|
|
24
|
+
const parents = new Map();
|
|
25
|
+
const candidates = new Map();
|
|
26
|
+
const keys = Object.keys(nodes).sort();
|
|
27
|
+
const node = (id) => nodes[id];
|
|
28
|
+
const owner = (id) => (str(node(id)?.kind) === "task" ? objects[id] : node(id));
|
|
29
|
+
// One pass over the nodes: selected parent, deletion, conflicts.
|
|
30
|
+
const deleted = new Set();
|
|
31
|
+
for (const id of keys) {
|
|
32
|
+
// §7.6: a lifecycle conflict has no winner. It blocks the branch (below)
|
|
33
|
+
// instead of the provisional value hiding it: the node is not deleted,
|
|
34
|
+
// and edits under it are not under a deleted ancestor.
|
|
35
|
+
const lifecycles = new Set(values(owner(id), "lifecycle"));
|
|
36
|
+
if (lifecycles.size === 1 && lifecycles.has("deleted"))
|
|
37
|
+
deleted.add(id);
|
|
38
|
+
if (collided.has(id))
|
|
39
|
+
continue;
|
|
40
|
+
const n = node(id);
|
|
41
|
+
const selected = values(n, "placement");
|
|
42
|
+
const parentOf = (p) => p === undefined ? undefined : str(placements[p]?.parent_id);
|
|
43
|
+
// The default value selects the parent used for hiding and for the walk below.
|
|
44
|
+
const parent = parentOf(str(n.placement));
|
|
45
|
+
if (parent !== undefined)
|
|
46
|
+
parents.set(id, parent);
|
|
47
|
+
if (selected.length > 1) {
|
|
48
|
+
blocked.set(id, "PLACEMENT_CONFLICT");
|
|
49
|
+
candidates.set(id, selected.map((p) => ({ placement: p ?? "", parent: parentOf(p) ?? "" })));
|
|
50
|
+
}
|
|
51
|
+
if (lifecycles.size > 1)
|
|
52
|
+
blocked.set(id, "LIFECYCLE_CONFLICT");
|
|
53
|
+
}
|
|
54
|
+
for (const id of invalid.keys())
|
|
55
|
+
blocked.delete(id);
|
|
56
|
+
// Every member of a cycle of the selected parent graph (§7.4), whatever
|
|
57
|
+
// the start: each node has one parent, so one coloured walk per start
|
|
58
|
+
// that stops at any node already done finds them all in linear time.
|
|
59
|
+
const done = new Set();
|
|
60
|
+
for (const start of keys) {
|
|
61
|
+
const path = [];
|
|
62
|
+
const onPath = new Map();
|
|
63
|
+
let n = start;
|
|
64
|
+
while (n !== undefined &&
|
|
65
|
+
n !== sectionId &&
|
|
66
|
+
node(n) !== undefined &&
|
|
67
|
+
!done.has(n) &&
|
|
68
|
+
!blocked.has(n) &&
|
|
69
|
+
!collided.has(n) &&
|
|
70
|
+
!invalid.has(n)) {
|
|
71
|
+
const at = onPath.get(n);
|
|
72
|
+
if (at !== undefined) {
|
|
73
|
+
for (const c of path.slice(at))
|
|
74
|
+
blocked.set(c, "PARENT_CYCLE");
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
onPath.set(n, path.length);
|
|
78
|
+
path.push(n);
|
|
79
|
+
n = parents.get(n);
|
|
80
|
+
}
|
|
81
|
+
for (const c of path)
|
|
82
|
+
done.add(c);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolves `fact(id)` = own(id) || fact(parent) along the parent chain,
|
|
86
|
+
* iteratively and memoized; a walk that comes back to a node of its own
|
|
87
|
+
* path stops there, with what the loop holds.
|
|
88
|
+
*/
|
|
89
|
+
const chained = (own) => {
|
|
90
|
+
const memo = new Map();
|
|
91
|
+
return (id) => {
|
|
92
|
+
const path = [];
|
|
93
|
+
const onPath = new Set();
|
|
94
|
+
let tail = false;
|
|
95
|
+
let n = id;
|
|
96
|
+
while (n !== undefined && n !== sectionId && node(n) !== undefined) {
|
|
97
|
+
const known = memo.get(n);
|
|
98
|
+
if (known !== undefined) {
|
|
99
|
+
tail = known;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
if (onPath.has(n)) {
|
|
103
|
+
// A loop: every node of it sees all of it.
|
|
104
|
+
tail = path.slice(path.indexOf(n)).some(own);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
onPath.add(n);
|
|
108
|
+
path.push(n);
|
|
109
|
+
n = parents.get(n);
|
|
110
|
+
}
|
|
111
|
+
for (let k = path.length - 1; k >= 0; k--) {
|
|
112
|
+
tail = tail || own(path[k]);
|
|
113
|
+
memo.set(path[k], tail);
|
|
114
|
+
}
|
|
115
|
+
return memo.get(id) ?? tail;
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
// §7.5: blocked structure propagates to every descendant.
|
|
119
|
+
const out = (id) => id !== undefined && (blocked.has(id) || invalid.has(id) || collided.has(id));
|
|
120
|
+
const isOut = new Set(keys.filter((id) => out(id)));
|
|
121
|
+
const blockedAbove = chained((id) => isOut.has(id));
|
|
122
|
+
for (const id of keys) {
|
|
123
|
+
const parent = parents.get(id);
|
|
124
|
+
if (!isOut.has(id) && parent !== undefined && blockedAbove(parent))
|
|
125
|
+
blocked.set(id, "BLOCKED_PARENT");
|
|
126
|
+
}
|
|
127
|
+
// §9: a deleted node or a descendant of one is hidden, not removed.
|
|
128
|
+
const isHidden = chained((id) => deleted.has(id));
|
|
129
|
+
const hidden = new Set(keys.filter((id) => isHidden(id)));
|
|
130
|
+
const retained = hidden.size === 0 ? [] : retainedEdits(doc, hidden, (id) => parents.get(id), sectionId);
|
|
131
|
+
const tree = [];
|
|
132
|
+
const projectable = validation.state === "ready" && sectionId !== undefined;
|
|
133
|
+
if (projectable) {
|
|
134
|
+
const laneOf = (p) => ((p === sectionId ? root.section.children : node(p)?.children) ?? []);
|
|
135
|
+
// An explicit stack of (parent, depth, next index) instead of recursion.
|
|
136
|
+
const stack = [
|
|
137
|
+
{ parent: sectionId, depth: 0, lane: laneOf(sectionId), i: 0 },
|
|
138
|
+
];
|
|
139
|
+
const emitted = new Set();
|
|
140
|
+
while (stack.length > 0) {
|
|
141
|
+
const top = stack[stack.length - 1];
|
|
142
|
+
if (top.i >= top.lane.length) {
|
|
143
|
+
stack.pop();
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const slot = str(top.lane[top.i++]);
|
|
147
|
+
if (slot === undefined || collided.has(slot))
|
|
148
|
+
continue;
|
|
149
|
+
const id = str(placements[slot]?.node_id);
|
|
150
|
+
if (id === undefined || node(id) === undefined || out(id) || hidden.has(id))
|
|
151
|
+
continue;
|
|
152
|
+
if (str(node(id)?.placement) !== slot || emitted.has(id))
|
|
153
|
+
continue;
|
|
154
|
+
emitted.add(id);
|
|
155
|
+
tree.push(Object.freeze({
|
|
156
|
+
id,
|
|
157
|
+
parent: top.parent,
|
|
158
|
+
depth: top.depth,
|
|
159
|
+
kind: str(node(id)?.kind) ?? "",
|
|
160
|
+
}));
|
|
161
|
+
stack.push({ parent: id, depth: top.depth + 1, lane: laneOf(id), i: 0 });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const classification = validation.state === "invalid"
|
|
165
|
+
? "PROFILE_INVALID"
|
|
166
|
+
: validation.state === "importing"
|
|
167
|
+
? "IMPORTING"
|
|
168
|
+
: blocked.size > 0 || invalid.size > 0 || validation.collisions.length > 0
|
|
169
|
+
? "STRUCTURAL_ATTENTION"
|
|
170
|
+
: "VALID";
|
|
171
|
+
return Object.freeze({
|
|
172
|
+
classification,
|
|
173
|
+
tree: Object.freeze(tree),
|
|
174
|
+
hidden: Object.freeze([...hidden].sort()),
|
|
175
|
+
recovery: Object.freeze([...blocked].map(([id, code]) => ({ id, code })).sort(byId)),
|
|
176
|
+
invalid: Object.freeze([...invalid].map(([id, p]) => ({ id, diagnostic: p.diagnostic })).sort(byId)),
|
|
177
|
+
collisions: validation.collisions,
|
|
178
|
+
retainedConcurrentEdits: Object.freeze(retained),
|
|
179
|
+
scalarConflicts: Object.freeze(Object.keys(objects).flatMap((id) => CONFLICT_FIELDS.flatMap((field) => {
|
|
180
|
+
const vs = values(objects[id], field);
|
|
181
|
+
return vs.length > 1 ? [{ id, field, values: vs.map((v) => v ?? "").sort() }] : [];
|
|
182
|
+
}))),
|
|
183
|
+
candidates,
|
|
184
|
+
validation,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* §9: the hidden nodes with content changes concurrent with a deletion of
|
|
189
|
+
* the node or of an ancestor, from the change history. A change deletes
|
|
190
|
+
* when the last lifecycle value it writes to an owner (a Task node's Task,
|
|
191
|
+
* another node itself) is "deleted"; content is a node's creation, an
|
|
192
|
+
* operation on its Text, or a Task's title or status. Two changes are
|
|
193
|
+
* concurrent when neither is in the other's dependencies.
|
|
194
|
+
*/
|
|
195
|
+
function retainedEdits(doc, hidden, parentOf, sectionId) {
|
|
196
|
+
const root = doc;
|
|
197
|
+
const nodes = (root.nodes ?? {});
|
|
198
|
+
const objects = (root.objects ?? {});
|
|
199
|
+
const nodesObj = A.getObjectId(root, "nodes");
|
|
200
|
+
// Object IDs to the node whose content or lifecycle they hold.
|
|
201
|
+
const textOf = new Map();
|
|
202
|
+
const ownerOf = new Map();
|
|
203
|
+
const taskOf = new Map();
|
|
204
|
+
for (const id of Object.keys(nodes)) {
|
|
205
|
+
const n = nodes[id];
|
|
206
|
+
const text = A.getObjectId(n, "text");
|
|
207
|
+
if (text !== null)
|
|
208
|
+
textOf.set(text, id);
|
|
209
|
+
const isTask = str(n.kind) === "task";
|
|
210
|
+
const task = isTask ? objects[id] : undefined;
|
|
211
|
+
const owner = isTask ? (task === undefined ? null : A.getObjectId(task)) : A.getObjectId(n);
|
|
212
|
+
if (owner !== null)
|
|
213
|
+
ownerOf.set(owner, id);
|
|
214
|
+
if (task !== undefined) {
|
|
215
|
+
const t = A.getObjectId(task);
|
|
216
|
+
if (t !== null)
|
|
217
|
+
taskOf.set(t, id);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const deps = new Map();
|
|
221
|
+
const deletes = [];
|
|
222
|
+
const edits = [];
|
|
223
|
+
for (const bytes of A.getAllChanges(doc)) {
|
|
224
|
+
const change = A.decodeChange(bytes);
|
|
225
|
+
deps.set(change.hash, change.deps);
|
|
226
|
+
const lastLifecycle = new Map();
|
|
227
|
+
const touched = new Set();
|
|
228
|
+
for (const op of change.ops) {
|
|
229
|
+
const key = op.key;
|
|
230
|
+
const value = op.value;
|
|
231
|
+
if (op.obj === nodesObj && op.action === "makeMap" && key !== undefined && hidden.has(key))
|
|
232
|
+
touched.add(key);
|
|
233
|
+
const text = textOf.get(op.obj);
|
|
234
|
+
if (text !== undefined && hidden.has(text))
|
|
235
|
+
touched.add(text);
|
|
236
|
+
const task = taskOf.get(op.obj);
|
|
237
|
+
if (task !== undefined && hidden.has(task) && (key === "title" || key === "status"))
|
|
238
|
+
touched.add(task);
|
|
239
|
+
const owner = ownerOf.get(op.obj);
|
|
240
|
+
if (owner !== undefined && key === "lifecycle")
|
|
241
|
+
lastLifecycle.set(owner, value);
|
|
242
|
+
}
|
|
243
|
+
for (const [owner, value] of lastLifecycle)
|
|
244
|
+
if (value === "deleted")
|
|
245
|
+
deletes.push({ hash: change.hash, node: owner });
|
|
246
|
+
for (const node of touched)
|
|
247
|
+
edits.push({ hash: change.hash, node });
|
|
248
|
+
}
|
|
249
|
+
if (deletes.length === 0 || edits.length === 0)
|
|
250
|
+
return [];
|
|
251
|
+
/** Every change `hash` depends on, transitively, memoized. */
|
|
252
|
+
const ancestry = new Map();
|
|
253
|
+
const ancestorsOf = (hash) => {
|
|
254
|
+
const known = ancestry.get(hash);
|
|
255
|
+
if (known !== undefined)
|
|
256
|
+
return known;
|
|
257
|
+
const out = new Set();
|
|
258
|
+
const todo = [...(deps.get(hash) ?? [])];
|
|
259
|
+
while (todo.length > 0) {
|
|
260
|
+
const h = todo.pop();
|
|
261
|
+
if (out.has(h))
|
|
262
|
+
continue;
|
|
263
|
+
out.add(h);
|
|
264
|
+
for (const d of deps.get(h) ?? [])
|
|
265
|
+
if (!out.has(d))
|
|
266
|
+
todo.push(d);
|
|
267
|
+
}
|
|
268
|
+
ancestry.set(hash, out);
|
|
269
|
+
return out;
|
|
270
|
+
};
|
|
271
|
+
const concurrent = (a, b) => a !== b && !ancestorsOf(a).has(b) && !ancestorsOf(b).has(a);
|
|
272
|
+
const deletesOf = new Map();
|
|
273
|
+
for (const d of deletes)
|
|
274
|
+
deletesOf.set(d.node, [...(deletesOf.get(d.node) ?? []), d.hash]);
|
|
275
|
+
const attention = new Set();
|
|
276
|
+
for (const edit of edits) {
|
|
277
|
+
if (attention.has(edit.node))
|
|
278
|
+
continue;
|
|
279
|
+
const seen = new Set();
|
|
280
|
+
for (let p = edit.node; p !== undefined && p !== sectionId && nodes[p] !== undefined && !seen.has(p); p = parentOf(p)) {
|
|
281
|
+
seen.add(p);
|
|
282
|
+
if ((deletesOf.get(p) ?? []).some((d) => concurrent(d, edit.hash))) {
|
|
283
|
+
attention.add(edit.node);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return [...attention].sort();
|
|
289
|
+
}
|
|
290
|
+
//# 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
|
package/dist/values.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { fromBase64url, ID32_LENGTH, LfcpError, principalId, toBase64url, } from "@openlfcp/core";
|
|
2
|
-
import {
|
|
3
|
-
import { ProfileInvalidError } from "./profile-invalid.js";
|
|
2
|
+
import { deriveDomainActorId } from "./admission/actor.js";
|
|
4
3
|
/**
|
|
5
4
|
* Value-level rules of SHARED-OBJECTS-PROFILE-01: Principal references,
|
|
6
5
|
* Local Dates, timestamps, reverse-domain names, namespaced values, the
|
|
@@ -60,104 +59,17 @@ const NAMESPACED = new RegExp(`^x/${LABEL}(?:\\.${LABEL})+/[^/]+$`, "u");
|
|
|
60
59
|
export const isReverseDomain = (text) => typeof text === "string" && REVERSE_DOMAIN.test(text);
|
|
61
60
|
/** §33 ABNF namespaced-value: x/<reverse-domain>/<value>, value non-empty without "/". */
|
|
62
61
|
export const isNamespacedValue = (text) => typeof text === "string" && NAMESPACED.test(text);
|
|
63
|
-
|
|
62
|
+
/** §8: the actor domain of this profile. */
|
|
63
|
+
export const ACTOR_DOMAIN = "OPENLFCP-SHARED-OBJECTS-ACTOR-v1";
|
|
64
64
|
/**
|
|
65
65
|
* §8: the Automerge actor ID of `principal` editing `resource`:
|
|
66
66
|
* SHA-256(ASCII("OPENLFCP-SHARED-OBJECTS-ACTOR-v1") || resource_id || principal_id),
|
|
67
67
|
* from the raw 32-byte IDs.
|
|
68
68
|
*/
|
|
69
69
|
export function deriveActorId(resource, principal) {
|
|
70
|
-
|
|
71
|
-
["the Resource ID", resource],
|
|
72
|
-
["the Principal ID", principal],
|
|
73
|
-
]) {
|
|
74
|
-
if (!(id instanceof Uint8Array) || id.length !== ID32_LENGTH)
|
|
75
|
-
throw new LfcpError("INVALID_LENGTH", `${what} must be the raw 32-byte ID`);
|
|
76
|
-
}
|
|
77
|
-
const input = new Uint8Array(ACTOR_DOMAIN.length + 2 * ID32_LENGTH);
|
|
78
|
-
input.set(ACTOR_DOMAIN, 0);
|
|
79
|
-
input.set(resource, ACTOR_DOMAIN.length);
|
|
80
|
-
input.set(principal, ACTOR_DOMAIN.length + ID32_LENGTH);
|
|
81
|
-
return sha256(input);
|
|
82
|
-
}
|
|
83
|
-
/** §11, §13: the only framing version. */
|
|
84
|
-
export const FRAMING_VERSION = 1;
|
|
85
|
-
/** The deterministic CBOR head of a byte string of `length` bytes (major type 2, shortest form). */
|
|
86
|
-
function bstrHead(length) {
|
|
87
|
-
if (length < 24)
|
|
88
|
-
return [0x40 | length];
|
|
89
|
-
if (length < 0x100)
|
|
90
|
-
return [0x58, length];
|
|
91
|
-
if (length < 0x10000)
|
|
92
|
-
return [0x59, length >> 8, length & 0xff];
|
|
93
|
-
if (length <= 0xffffffff)
|
|
94
|
-
return [
|
|
95
|
-
0x5a,
|
|
96
|
-
(length >>> 24) & 0xff,
|
|
97
|
-
(length >>> 16) & 0xff,
|
|
98
|
-
(length >>> 8) & 0xff,
|
|
99
|
-
length & 0xff,
|
|
100
|
-
];
|
|
101
|
-
throw new LfcpError("OUT_OF_RANGE", "the framed bytes are too long");
|
|
102
|
-
}
|
|
103
|
-
/**
|
|
104
|
-
* Deterministic CBOR of [1, bstr] (§11 shared-objects-change, §13
|
|
105
|
-
* shared-objects-snapshot): the profile plaintext of a Data Unit (one
|
|
106
|
-
* Automerge change) or a Snapshot (one Automerge full save). The bytes are
|
|
107
|
-
* framed as given; checking that they are a valid Automerge change or save
|
|
108
|
-
* is the Automerge binding (LFCP-031).
|
|
109
|
-
*/
|
|
110
|
-
export function frameProfilePayload(bytes) {
|
|
111
|
-
if (!(bytes instanceof Uint8Array))
|
|
112
|
-
throw new LfcpError("INVALID_LENGTH", "framing takes bytes");
|
|
113
|
-
const head = bstrHead(bytes.length);
|
|
114
|
-
const out = new Uint8Array(2 + head.length + bytes.length);
|
|
115
|
-
out.set([0x82, FRAMING_VERSION, ...head], 0);
|
|
116
|
-
out.set(bytes, 2 + head.length);
|
|
117
|
-
return out;
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* The inner bytes of a framed profile plaintext. §11: a receiver MUST
|
|
121
|
-
* reject plaintext that is not valid CBOR, not a two-element array, or
|
|
122
|
-
* uses an unsupported framing version; this also requires the
|
|
123
|
-
* deterministic encoding and no trailing bytes. Throws PROFILE_INVALID /
|
|
124
|
-
* INVALID_AUTOMERGE_BYTES (§74.1).
|
|
125
|
-
*/
|
|
126
|
-
export function unframeProfilePayload(framed) {
|
|
127
|
-
const fail = (why) => {
|
|
128
|
-
throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `invalid profile framing: ${why}`);
|
|
129
|
-
};
|
|
130
|
-
if (!(framed instanceof Uint8Array) || framed.length < 3)
|
|
131
|
-
return fail("too short");
|
|
132
|
-
if (framed[0] !== 0x82)
|
|
133
|
-
return fail("not a two-element array");
|
|
134
|
-
if (framed[1] !== FRAMING_VERSION)
|
|
135
|
-
return fail("unsupported framing version");
|
|
136
|
-
const first = framed[2];
|
|
137
|
-
if (first >> 5 !== 2)
|
|
138
|
-
return fail("the second element is not a byte string");
|
|
139
|
-
const info = first & 0x1f;
|
|
140
|
-
let length;
|
|
141
|
-
let offset;
|
|
142
|
-
const at = (i) => framed[i];
|
|
143
|
-
if (info < 24)
|
|
144
|
-
[length, offset] = [info, 3];
|
|
145
|
-
else if (info === 24)
|
|
146
|
-
[length, offset] = [at(3), 4];
|
|
147
|
-
else if (info === 25)
|
|
148
|
-
[length, offset] = [(at(3) << 8) | at(4), 5];
|
|
149
|
-
else if (info === 26)
|
|
150
|
-
[length, offset] = [((at(3) << 24) >>> 0) + (at(4) << 16) + (at(5) << 8) + at(6), 7];
|
|
151
|
-
else
|
|
152
|
-
return fail("unsupported byte string length");
|
|
153
|
-
if (offset > framed.length)
|
|
154
|
-
return fail("truncated");
|
|
155
|
-
const bytes = framed.subarray(offset);
|
|
156
|
-
if (bytes.length !== length)
|
|
157
|
-
return fail(bytes.length < length ? "truncated" : "trailing bytes");
|
|
158
|
-
const canonical = bstrHead(length);
|
|
159
|
-
if (canonical.length !== offset - 2)
|
|
160
|
-
return fail("non-shortest byte string length");
|
|
161
|
-
return Uint8Array.from(bytes);
|
|
70
|
+
return deriveDomainActorId(ACTOR_DOMAIN, resource, principal);
|
|
162
71
|
}
|
|
72
|
+
// §11, §13: the profile framing [1, bytes] is part of the admission module
|
|
73
|
+
// both profiles share (./admission/framing.ts); re-exported here unchanged.
|
|
74
|
+
export { FRAMING_VERSION, frameProfilePayload, unframeProfilePayload, } from "./admission/framing.js";
|
|
163
75
|
//# sourceMappingURL=values.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlfcp/shared-objects",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.0-beta.1",
|
|
4
4
|
"description": "SHARED-OBJECTS-PROFILE-01 (org.openlfcp.shared-objects.v1) implementation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openlfcp",
|
|
@@ -31,6 +31,14 @@
|
|
|
31
31
|
".": {
|
|
32
32
|
"types": "./dist/index.d.ts",
|
|
33
33
|
"import": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./admission": {
|
|
36
|
+
"types": "./dist/admission/index.d.ts",
|
|
37
|
+
"import": "./dist/admission/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./sections": {
|
|
40
|
+
"types": "./dist/sections/index.d.ts",
|
|
41
|
+
"import": "./dist/sections/index.js"
|
|
34
42
|
}
|
|
35
43
|
},
|
|
36
44
|
"files": [
|
|
@@ -40,13 +48,13 @@
|
|
|
40
48
|
],
|
|
41
49
|
"publishConfig": {
|
|
42
50
|
"access": "public",
|
|
43
|
-
"tag": "
|
|
51
|
+
"tag": "beta"
|
|
44
52
|
},
|
|
45
53
|
"dependencies": {
|
|
46
54
|
"@automerge/automerge": "3.5.0",
|
|
47
55
|
"fflate": "0.8.3",
|
|
48
|
-
"@openlfcp/
|
|
49
|
-
"@openlfcp/
|
|
56
|
+
"@openlfcp/core": "^0.2.0-beta.1",
|
|
57
|
+
"@openlfcp/crypto": "^0.2.0-beta.1"
|
|
50
58
|
},
|
|
51
59
|
"devDependencies": {
|
|
52
60
|
"fast-check": "4.10.2"
|