@openlfcp/shared-objects 0.1.0-rc.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/LICENSE +201 -0
- package/README.md +91 -0
- package/dist/automerge-bytes.d.ts +37 -0
- package/dist/automerge-bytes.js +95 -0
- package/dist/chunk-limits.d.ts +58 -0
- package/dist/chunk-limits.js +457 -0
- package/dist/data-profile.d.ts +159 -0
- package/dist/data-profile.js +311 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/profile-invalid.d.ts +12 -0
- package/dist/profile-invalid.js +15 -0
- package/dist/replica.d.ts +277 -0
- package/dist/replica.js +1039 -0
- package/dist/task.d.ts +159 -0
- package/dist/task.js +192 -0
- package/dist/validate.d.ts +70 -0
- package/dist/validate.js +196 -0
- package/dist/values.d.ts +52 -0
- package/dist/values.js +163 -0
- package/dist/wasm.d.ts +33 -0
- package/dist/wasm.js +58 -0
- package/package.json +59 -0
package/dist/replica.js
ADDED
|
@@ -0,0 +1,1039 @@
|
|
|
1
|
+
import * as A from "@automerge/automerge";
|
|
2
|
+
import { fromHex, LfcpError, toHex, } from "@openlfcp/core";
|
|
3
|
+
import { checkChange, checkSaveHeader, unframeChange, unframeSnapshot, } from "./automerge-bytes.js";
|
|
4
|
+
import { checkSnapshotExpansion, MAX_DOCUMENT_DEPTH, SNAPSHOT_LIMITS_FLOOR, } from "./chunk-limits.js";
|
|
5
|
+
/** The decoded actions that create an object (§11.2). */
|
|
6
|
+
const MAKE_ACTIONS = new Set(["makeMap", "makeList", "makeText", "makeTable"]);
|
|
7
|
+
import { ProfileInvalidError } from "./profile-invalid.js";
|
|
8
|
+
import { ProfileError, parseTask } from "./task.js";
|
|
9
|
+
import { firstPerField, isMap, objectProblems, pointerToken, validateRoot, } from "./validate.js";
|
|
10
|
+
import { deriveActorId, frameProfilePayload, PROFILE_ID } from "./values.js";
|
|
11
|
+
/**
|
|
12
|
+
* The Automerge binding of SHARED-OBJECTS-PROFILE-01 (LFCP-031): one
|
|
13
|
+
* Resource's replica of the org.openlfcp.shared-objects.v1 document.
|
|
14
|
+
*
|
|
15
|
+
* - The actor is always the §8 actor of (Resource, Principal), set
|
|
16
|
+
* explicitly; §9 actor state safety is enforced with `minSeq`.
|
|
17
|
+
* - One semantic intent is exactly one Automerge change (§10, §12), with
|
|
18
|
+
* time 0 (informational, and a wall clock would leak edit times) and the
|
|
19
|
+
* intent name as message. An intent that changes nothing makes no change.
|
|
20
|
+
* - Writes touch single properties only, so unknown fields, extension
|
|
21
|
+
* namespaces and object types survive (§70-§72). Objects are never
|
|
22
|
+
* removed: deletion is the lifecycle tombstone (§54-§56).
|
|
23
|
+
* - Scalar conflicts stay visible (§44-§47) and are resolved by a new
|
|
24
|
+
* causal write (§69); tags and assignees are add-wins maps (§39-§43).
|
|
25
|
+
*
|
|
26
|
+
* Receiving is pure profile work: LFCP verification, decryption and
|
|
27
|
+
* authorization come first (§95, LFCP-033).
|
|
28
|
+
*/
|
|
29
|
+
// §30 (G-SC3): every profile string is an Automerge scalar string
|
|
30
|
+
// (ImmutableString), never collaborative Text. Automerge 3 JS stores a plain
|
|
31
|
+
// JS string as Text, so writes wrap every string, and a known field found as
|
|
32
|
+
// Text is PROFILE_INVALID / INVALID_FIELD_TYPE.
|
|
33
|
+
// §58 (G-SC4): Automerge drops an assignment of the value already
|
|
34
|
+
// present, so an intent that writes deletes the property first when the value
|
|
35
|
+
// is unchanged: the intent is then a real concurrent write (add-wins, conflicts)
|
|
36
|
+
// exactly as in the reference corpus generator.
|
|
37
|
+
/** The conflict-preserving scalar registers of a Task (§44). */
|
|
38
|
+
export const SCALAR_FIELDS = [
|
|
39
|
+
"lifecycle",
|
|
40
|
+
"title",
|
|
41
|
+
"status",
|
|
42
|
+
"due",
|
|
43
|
+
"scheduled",
|
|
44
|
+
"completion_date",
|
|
45
|
+
"priority",
|
|
46
|
+
];
|
|
47
|
+
const DATE_FIELDS = new Set(["due", "scheduled", "completion_date"]);
|
|
48
|
+
/** RFC 6901: a reference token back to its key. */
|
|
49
|
+
const unescapeToken = (token) => token.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
50
|
+
/** task.resolve_field_conflict (§69). */
|
|
51
|
+
export function resolveFieldConflict(id, field, value) {
|
|
52
|
+
if (!SCALAR_FIELDS.includes(field))
|
|
53
|
+
throw new LfcpError("UNSUPPORTED_VALUE", `${field} is not a scalar register (§44)`);
|
|
54
|
+
if (value === null && !DATE_FIELDS.has(field))
|
|
55
|
+
throw new LfcpError("UNSUPPORTED_VALUE", `only a date field can be cleared (§36)`);
|
|
56
|
+
return Object.freeze({ intent: "task.resolve_field_conflict", id, field, value });
|
|
57
|
+
}
|
|
58
|
+
/** §21: two concurrent objects under one Object ID: OBJECT_ID_COLLISION, a profile error of its own, not PROFILE_INVALID. */
|
|
59
|
+
export class ObjectIdCollisionError extends LfcpError {
|
|
60
|
+
objectId;
|
|
61
|
+
constructor(objectId) {
|
|
62
|
+
super("OBJECT_ID_COLLISION", `${objectId}: concurrent objects share this Object ID (§21)`);
|
|
63
|
+
this.name = "ObjectIdCollisionError";
|
|
64
|
+
this.objectId = objectId;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const scalarString = (s) => new A.ImmutableString(s);
|
|
68
|
+
/**
|
|
69
|
+
* A logical value as Automerge input: every string becomes a scalar string
|
|
70
|
+
* (G-SC3). `depth` is the depth of `value` if it is a map or list (an
|
|
71
|
+
* object's field value is depth 1, §30); a writer never nests deeper than
|
|
72
|
+
* MAX_VALUE_DEPTH, and checking here keeps Automerge from ever building a
|
|
73
|
+
* deep value (§11.2: deep nesting can trap its wasm module).
|
|
74
|
+
*/
|
|
75
|
+
function scalarize(value, depth = 1) {
|
|
76
|
+
if (typeof value === "string")
|
|
77
|
+
return scalarString(value);
|
|
78
|
+
const nested = Array.isArray(value) || isMap(value);
|
|
79
|
+
if (nested && depth > MAX_VALUE_DEPTH)
|
|
80
|
+
throw new ProfileError([
|
|
81
|
+
problem("INVALID_FIELD_TYPE", "", `a value nests maps or lists deeper than ${MAX_VALUE_DEPTH} levels (§30)`),
|
|
82
|
+
]);
|
|
83
|
+
if (Array.isArray(value))
|
|
84
|
+
return value.map((v) => scalarize(v, depth + 1));
|
|
85
|
+
if (isMap(value))
|
|
86
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, scalarize(v, depth + 1)]));
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
/** §30: an object's maps and lists nest at most this many levels (the field's own map is 1). */
|
|
90
|
+
export const MAX_VALUE_DEPTH = 64;
|
|
91
|
+
/** Maps and lists plain() reads, the first included: the root, `objects` and an object map plus MAX_VALUE_DEPTH. */
|
|
92
|
+
const MAX_READ_DEPTH = MAX_VALUE_DEPTH + 3;
|
|
93
|
+
/**
|
|
94
|
+
* An Automerge value as logical JSON: scalar strings and Text read as
|
|
95
|
+
* strings. Reads at most `budget` nested maps and lists; a deeper one reads
|
|
96
|
+
* as null, so a crafted document cannot exhaust the stack (§30: it is
|
|
97
|
+
* INVALID_FIELD_TYPE, reported by textProblems).
|
|
98
|
+
*/
|
|
99
|
+
function plain(value, budget = MAX_READ_DEPTH) {
|
|
100
|
+
if (A.isImmutableString(value))
|
|
101
|
+
return value.toString();
|
|
102
|
+
if (value instanceof A.Counter)
|
|
103
|
+
return value.value;
|
|
104
|
+
const nested = value !== null &&
|
|
105
|
+
typeof value === "object" &&
|
|
106
|
+
!(value instanceof Uint8Array) &&
|
|
107
|
+
!(value instanceof Date);
|
|
108
|
+
if (nested && budget <= 0)
|
|
109
|
+
return null;
|
|
110
|
+
if (Array.isArray(value))
|
|
111
|
+
return value.map((v) => plain(v, budget - 1));
|
|
112
|
+
if (nested)
|
|
113
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plain(v, budget - 1)]));
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Whether any concurrent value of `map[key]` is collaborative Text (G-SC3).
|
|
118
|
+
*
|
|
119
|
+
* Not from the JS values: Automerge 3.5.0 reads a Text property as a plain
|
|
120
|
+
* JS string, and getConflicts returns a concurrent scalar ImmutableString as
|
|
121
|
+
* a plain JS string too (verified empirically), so `typeof` cannot tell them
|
|
122
|
+
* apart once a field is conflicted. The backend's getAll lists every
|
|
123
|
+
* concurrent value with its datatype: ["str", value, opId] for a scalar
|
|
124
|
+
* string and ["text", objId] for a Text object, conflicted or not.
|
|
125
|
+
*/
|
|
126
|
+
function hasTextValue(doc, map, key) {
|
|
127
|
+
const obj = A.getObjectId(map);
|
|
128
|
+
if (obj === null)
|
|
129
|
+
return false;
|
|
130
|
+
return A.getBackend(doc)
|
|
131
|
+
.getAll(obj, key, A.getHeads(doc))
|
|
132
|
+
.some((v) => v[0] === "text");
|
|
133
|
+
}
|
|
134
|
+
const byJson = (a, b) => {
|
|
135
|
+
const [x, y] = [JSON.stringify(a), JSON.stringify(b)];
|
|
136
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* The same JSON with object keys in a canonical order (UTF-16 code unit
|
|
140
|
+
* order, at every level; arrays keep their order). Automerge's property
|
|
141
|
+
* order depends on history: after a G-SC4 delete+put the writer and a
|
|
142
|
+
* receiver list the same keys in different orders, so only a canonical form
|
|
143
|
+
* makes JSON of the logical state comparable across replicas.
|
|
144
|
+
*/
|
|
145
|
+
function canonical(value) {
|
|
146
|
+
if (Array.isArray(value))
|
|
147
|
+
return value.map(canonical);
|
|
148
|
+
if (value !== null && typeof value === "object")
|
|
149
|
+
return Object.fromEntries(Object.keys(value)
|
|
150
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
151
|
+
.map((k) => [k, canonical(value[k])]));
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
/** Every concurrent value of `map[key]`: none if absent, one if not conflicted. */
|
|
155
|
+
function valuesOf(map, key) {
|
|
156
|
+
if (!(key in map))
|
|
157
|
+
return [];
|
|
158
|
+
const conflicts = A.getConflicts(map, key);
|
|
159
|
+
return conflicts === undefined ? [map[key]] : Object.values(conflicts);
|
|
160
|
+
}
|
|
161
|
+
/** Top-level fields of `object` with more than one concurrent value. */
|
|
162
|
+
function conflictedFields(object) {
|
|
163
|
+
return Object.keys(object)
|
|
164
|
+
.filter((f) => A.getConflicts(object, f) !== undefined)
|
|
165
|
+
.sort();
|
|
166
|
+
}
|
|
167
|
+
const problem = (diagnostic, pointer, message) => Object.freeze({ code: "PROFILE_INVALID", diagnostic, pointer, message });
|
|
168
|
+
/**
|
|
169
|
+
* §30, §74.1 (SO-STRINGS): every collaborative Text value anywhere in one
|
|
170
|
+
* stored object (known and unknown fields, `extensions`, nested maps,
|
|
171
|
+
* lists by index, every concurrent value) is INVALID_FIELD_TYPE at its own
|
|
172
|
+
* JSON Pointer. Read from the backend's getAll datatypes (see hasTextValue):
|
|
173
|
+
* "text" is Text, "str" a scalar string.
|
|
174
|
+
*/
|
|
175
|
+
function textProblems(doc, object, key) {
|
|
176
|
+
const obj = A.getObjectId(object);
|
|
177
|
+
if (obj === null)
|
|
178
|
+
return [];
|
|
179
|
+
const backend = A.getBackend(doc);
|
|
180
|
+
const heads = A.getHeads(doc);
|
|
181
|
+
const out = [];
|
|
182
|
+
// `depth` is the depth of the maps and lists found under this one: the
|
|
183
|
+
// object's field values are at depth 1 (§30).
|
|
184
|
+
const scan = (id, kind, at, depth) => {
|
|
185
|
+
const props = kind === "list"
|
|
186
|
+
? Array.from({ length: backend.length(id, heads) }, (_, i) => i)
|
|
187
|
+
: backend.keys(id, heads);
|
|
188
|
+
for (const prop of props) {
|
|
189
|
+
const here = `${at}/${pointerToken(String(prop))}`;
|
|
190
|
+
const values = backend.getAll(id, prop, heads);
|
|
191
|
+
const nested = values.filter((v) => v[0] === "map" || v[0] === "list");
|
|
192
|
+
if (values.some((v) => v[0] === "text"))
|
|
193
|
+
out.push(problem("INVALID_FIELD_TYPE", here, "collaborative Text, not a scalar string (§30)"));
|
|
194
|
+
else if (nested.length > 0 && depth > MAX_VALUE_DEPTH)
|
|
195
|
+
out.push(problem("INVALID_FIELD_TYPE", here, `nested deeper than ${MAX_VALUE_DEPTH} levels (§30)`));
|
|
196
|
+
if (depth > MAX_VALUE_DEPTH)
|
|
197
|
+
continue; // nothing below it is examined
|
|
198
|
+
for (const v of nested)
|
|
199
|
+
scan(v[1], v[0], here, depth + 1);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
scan(obj, "map", `/objects/${pointerToken(key)}`, 1);
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
/** Problems of the non-visible concurrent values of a Task's scalar registers (§45). */
|
|
206
|
+
function conflictValueProblems(object, key) {
|
|
207
|
+
const visible = plain(object);
|
|
208
|
+
const out = [];
|
|
209
|
+
for (const field of SCALAR_FIELDS) {
|
|
210
|
+
const conflicts = A.getConflicts(object, field);
|
|
211
|
+
if (conflicts === undefined)
|
|
212
|
+
continue;
|
|
213
|
+
for (const v of Object.values(conflicts)) {
|
|
214
|
+
const at = `/objects/${pointerToken(key)}/${field}`;
|
|
215
|
+
out.push(...objectProblems({ ...visible, [field]: plain(v) }, key).filter((p) => p.pointer === at &&
|
|
216
|
+
!out.some((q) => q.pointer === p.pointer && q.diagnostic === p.diagnostic)));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
function writesOf(intent) {
|
|
222
|
+
const put = (field, value) => ({ op: "put", field, value });
|
|
223
|
+
const del = (field) => ({ op: "delete", field });
|
|
224
|
+
switch (intent.intent) {
|
|
225
|
+
case "task.set_title":
|
|
226
|
+
return [put("title", intent.title)];
|
|
227
|
+
case "task.set_status":
|
|
228
|
+
return [put("status", intent.status)];
|
|
229
|
+
case "task.complete": // §63
|
|
230
|
+
return intent.completionDate === undefined
|
|
231
|
+
? [put("status", "done")]
|
|
232
|
+
: [put("status", "done"), put("completion_date", intent.completionDate)];
|
|
233
|
+
case "task.reopen": // §64
|
|
234
|
+
return [put("status", "todo"), del("completion_date")];
|
|
235
|
+
case "task.cancel": // §65
|
|
236
|
+
return [put("status", "cancelled"), del("completion_date")];
|
|
237
|
+
case "task.set_due":
|
|
238
|
+
return [put("due", intent.date)];
|
|
239
|
+
case "task.set_scheduled":
|
|
240
|
+
return [put("scheduled", intent.date)];
|
|
241
|
+
case "task.clear_due": // §36, §66: delete the property
|
|
242
|
+
return [del("due")];
|
|
243
|
+
case "task.clear_scheduled":
|
|
244
|
+
return [del("scheduled")];
|
|
245
|
+
case "task.set_priority":
|
|
246
|
+
return [put("priority", intent.priority)];
|
|
247
|
+
case "task.add_tag": // §67
|
|
248
|
+
return [{ op: "add", set: "tags", key: intent.tag }];
|
|
249
|
+
case "task.remove_tag":
|
|
250
|
+
return [{ op: "remove", set: "tags", key: intent.tag }];
|
|
251
|
+
case "task.add_assignee": // §68
|
|
252
|
+
return [{ op: "add", set: "assignees", key: intent.assignee }];
|
|
253
|
+
case "task.remove_assignee":
|
|
254
|
+
return [{ op: "remove", set: "assignees", key: intent.assignee }];
|
|
255
|
+
case "task.delete": // §54
|
|
256
|
+
return [put("lifecycle", "deleted")];
|
|
257
|
+
case "task.restore": // §55
|
|
258
|
+
return [put("lifecycle", "active")];
|
|
259
|
+
case "task.resolve_field_conflict": // §69
|
|
260
|
+
return [intent.value === null ? del(intent.field) : put(intent.field, intent.value)];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** The logical object after `writes`, for validation before any change is made. */
|
|
264
|
+
function candidate(object, writes) {
|
|
265
|
+
const next = { ...object };
|
|
266
|
+
for (const w of writes) {
|
|
267
|
+
if (w.op === "put")
|
|
268
|
+
next[w.field] = w.value;
|
|
269
|
+
else if (w.op === "delete")
|
|
270
|
+
delete next[w.field];
|
|
271
|
+
else {
|
|
272
|
+
const set = { ...(isMap(next[w.set]) ? next[w.set] : {}) };
|
|
273
|
+
if (w.op === "add")
|
|
274
|
+
set[w.key] = true;
|
|
275
|
+
else
|
|
276
|
+
delete set[w.key];
|
|
277
|
+
next[w.set] = set;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return next;
|
|
281
|
+
}
|
|
282
|
+
/** Applies `writes` to the Automerge object map, inside a change. */
|
|
283
|
+
function perform(object, writes) {
|
|
284
|
+
for (const w of writes) {
|
|
285
|
+
if (w.op === "delete") {
|
|
286
|
+
if (w.field in object)
|
|
287
|
+
delete object[w.field];
|
|
288
|
+
}
|
|
289
|
+
else if (w.op === "put") {
|
|
290
|
+
// §58 (G-SC4): an unchanged value is deleted first so the intent writes.
|
|
291
|
+
if (w.field in object && JSON.stringify(plain(object[w.field])) === JSON.stringify(w.value))
|
|
292
|
+
delete object[w.field];
|
|
293
|
+
object[w.field] = scalarize(w.value);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
const set = object[w.set];
|
|
297
|
+
if (w.op === "add") {
|
|
298
|
+
// §58 (G-SC4): a re-add is a fresh write, so it wins over a concurrent remove (§41, §43).
|
|
299
|
+
if (w.key in set)
|
|
300
|
+
delete set[w.key];
|
|
301
|
+
set[w.key] = true;
|
|
302
|
+
}
|
|
303
|
+
else if (w.key in set)
|
|
304
|
+
delete set[w.key];
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const actorHex = (opts) => toHex(deriveActorId(opts.resource, opts.principal));
|
|
309
|
+
/**
|
|
310
|
+
* Whether a thrown value is a wasm trap (the engine's module is dead, not
|
|
311
|
+
* the change refused): it is rethrown as it is, never turned into
|
|
312
|
+
* INVALID_AUTOMERGE_BYTES, so a crash-loop breaker sees it.
|
|
313
|
+
*/
|
|
314
|
+
function isWasmTrap(e) {
|
|
315
|
+
const trap = globalThis
|
|
316
|
+
.WebAssembly?.RuntimeError;
|
|
317
|
+
if (trap !== undefined && e instanceof trap)
|
|
318
|
+
return true;
|
|
319
|
+
return e instanceof Error && /\b(module|instance)\b.*\bterminated\b/i.test(e.message);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Applies one change that passed the receive checks. Automerge can throw
|
|
323
|
+
* after changing the document in place (a change that skips a sequence
|
|
324
|
+
* number stays in its graph without its operations), so on any engine error
|
|
325
|
+
* the handle is dropped: the document is rebuilt from the changes it held,
|
|
326
|
+
* which must give back the heads it had. A wasm trap is rethrown as it
|
|
327
|
+
* is: the module is dead, and only a restart recovers. Exported for tests.
|
|
328
|
+
*/
|
|
329
|
+
export function applyChecked(doc, bytes) {
|
|
330
|
+
const before = [...A.getHeads(doc)].sort().join();
|
|
331
|
+
try {
|
|
332
|
+
return { next: A.applyChanges(doc, [bytes])[0] };
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
if (isWasmTrap(e))
|
|
336
|
+
throw e;
|
|
337
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
338
|
+
let restored;
|
|
339
|
+
try {
|
|
340
|
+
[restored] = A.applyChanges(A.init({ actor: A.getActorId(doc) }), A.getAllChanges(doc));
|
|
341
|
+
}
|
|
342
|
+
catch (r) {
|
|
343
|
+
throw new Error(`the replica could not be restored after an Automerge error: ${String(r)}`);
|
|
344
|
+
}
|
|
345
|
+
if ([...A.getHeads(restored)].sort().join() !== before)
|
|
346
|
+
throw new Error("the replica could not be restored after an Automerge error: heads differ");
|
|
347
|
+
return { restored, error };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* applyChecked for a batch of admitted changes, in one engine call. On any
|
|
352
|
+
* engine error the handle is dropped and the document as it was before is
|
|
353
|
+
* rebuilt from the changes it held minus the batch, checked against the
|
|
354
|
+
* heads it had. Exported for tests.
|
|
355
|
+
*/
|
|
356
|
+
export function applyBatchChecked(doc, batch) {
|
|
357
|
+
const heads = A.getHeads(doc);
|
|
358
|
+
try {
|
|
359
|
+
return {
|
|
360
|
+
next: A.applyChanges(doc, batch.map((c) => c.bytes))[0],
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
catch (e) {
|
|
364
|
+
if (isWasmTrap(e))
|
|
365
|
+
throw e;
|
|
366
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
367
|
+
return { restored: restoreWithout(doc, batch, heads), error };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function restoreWithout(doc, batch, heads) {
|
|
371
|
+
const out = new Set(batch.map((c) => c.hash));
|
|
372
|
+
const kept = A.getAllChanges(doc).filter((c) => !out.has(A.decodeChange(c).hash));
|
|
373
|
+
let restored;
|
|
374
|
+
try {
|
|
375
|
+
[restored] = A.applyChanges(A.init({ actor: A.getActorId(doc) }), kept);
|
|
376
|
+
}
|
|
377
|
+
catch (r) {
|
|
378
|
+
throw new Error(`the replica could not be restored after an Automerge error: ${String(r)}`);
|
|
379
|
+
}
|
|
380
|
+
if ([...A.getHeads(restored)].sort().join() !== [...heads].sort().join())
|
|
381
|
+
throw new Error("the replica could not be restored after an Automerge error: heads differ");
|
|
382
|
+
return restored;
|
|
383
|
+
}
|
|
384
|
+
function loadFailure(e, what) {
|
|
385
|
+
if (e instanceof LfcpError)
|
|
386
|
+
throw e;
|
|
387
|
+
throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `${what}: ${e.message}`);
|
|
388
|
+
}
|
|
389
|
+
export class SharedObjectsReplica {
|
|
390
|
+
resource;
|
|
391
|
+
/** The §8 Automerge actor ID. */
|
|
392
|
+
actorId;
|
|
393
|
+
#principal;
|
|
394
|
+
#actor;
|
|
395
|
+
#minSeq;
|
|
396
|
+
#doc;
|
|
397
|
+
/** Highest sequence seen per actor (hex). */
|
|
398
|
+
#seqs = new Map();
|
|
399
|
+
/** §11.2: known object depths (object ID → depth below the root), filled as objects appear. */
|
|
400
|
+
#depths = new Map();
|
|
401
|
+
/**
|
|
402
|
+
* Conflicted fields per object, kept current on every change. Automerge
|
|
403
|
+
* 3.5.0 getConflicts on an A.view reports the current conflicts, not those
|
|
404
|
+
* at the view's heads, so the state before a change cannot be asked later.
|
|
405
|
+
*/
|
|
406
|
+
#conflicted = new Map();
|
|
407
|
+
constructor(doc, opts) {
|
|
408
|
+
this.resource = opts.resource;
|
|
409
|
+
this.#principal = opts.principal;
|
|
410
|
+
this.#actor = actorHex(opts);
|
|
411
|
+
this.actorId = fromHex(this.#actor);
|
|
412
|
+
this.#minSeq = opts.minSeq ?? 0;
|
|
413
|
+
this.#doc = doc;
|
|
414
|
+
for (const meta of A.getChangesMetaSince(doc, []))
|
|
415
|
+
this.#noteSeq(meta.actor, meta.seq);
|
|
416
|
+
for (const [id, object] of Object.entries(this.#objects() ?? {}))
|
|
417
|
+
if (isMap(object))
|
|
418
|
+
this.#conflicted.set(id, conflictedFields(object));
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* §11.2: the depths of the objects `change` creates (object ID → depth),
|
|
422
|
+
* in operation order, given the objects of the document and `prior` (the
|
|
423
|
+
* same batch). Refuses an object deeper than MAX_DOCUMENT_DEPTH, or one
|
|
424
|
+
* written into an object the document does not have, before the engine.
|
|
425
|
+
*/
|
|
426
|
+
#createdDepths(change, prior) {
|
|
427
|
+
const decoded = A.decodeChange(change.bytes);
|
|
428
|
+
const created = new Map();
|
|
429
|
+
decoded.ops.forEach((op, i) => {
|
|
430
|
+
if (!MAKE_ACTIONS.has(op.action))
|
|
431
|
+
return;
|
|
432
|
+
const parent = op.obj === "_root"
|
|
433
|
+
? 0
|
|
434
|
+
: (created.get(op.obj) ?? prior.get(op.obj) ?? this.#depthOf(op.obj));
|
|
435
|
+
if (parent === undefined)
|
|
436
|
+
throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `the change writes into object ${op.obj}, which this document does not have (§11.2)`);
|
|
437
|
+
if (parent + 1 > MAX_DOCUMENT_DEPTH)
|
|
438
|
+
throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `the change creates an object deeper than ${MAX_DOCUMENT_DEPTH} levels (§11.2)`);
|
|
439
|
+
created.set(`${decoded.startOp + i}@${decoded.actor}`, parent + 1);
|
|
440
|
+
});
|
|
441
|
+
return created;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* The depth of an object of the document: cached, else its path (objects
|
|
445
|
+
* never move, so the path of a live object has one element per level), else
|
|
446
|
+
* (a deleted object) every object's depth from the history, once.
|
|
447
|
+
*/
|
|
448
|
+
#depthOf(obj) {
|
|
449
|
+
const known = this.#depths.get(obj);
|
|
450
|
+
if (known !== undefined)
|
|
451
|
+
return known;
|
|
452
|
+
let path;
|
|
453
|
+
try {
|
|
454
|
+
path = A.getBackend(this.#doc).objInfo(obj).path;
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return undefined; // not an object of this document
|
|
458
|
+
}
|
|
459
|
+
if (path !== undefined) {
|
|
460
|
+
this.#depths.set(obj, path.length);
|
|
461
|
+
return path.length;
|
|
462
|
+
}
|
|
463
|
+
for (const bytes of A.getAllChanges(this.#doc)) {
|
|
464
|
+
const c = A.decodeChange(bytes);
|
|
465
|
+
c.ops.forEach((op, i) => {
|
|
466
|
+
if (!MAKE_ACTIONS.has(op.action))
|
|
467
|
+
return;
|
|
468
|
+
const parent = op.obj === "_root" ? 0 : this.#depths.get(op.obj);
|
|
469
|
+
if (parent !== undefined)
|
|
470
|
+
this.#depths.set(`${c.startOp + i}@${c.actor}`, parent + 1);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
return this.#depths.get(obj);
|
|
474
|
+
}
|
|
475
|
+
#noteSeq(actor, seq) {
|
|
476
|
+
if (seq > (this.#seqs.get(actor) ?? 0))
|
|
477
|
+
this.#seqs.set(actor, seq);
|
|
478
|
+
}
|
|
479
|
+
/** §16: a new Resource document. The initial change is the Resource's first Data Unit. */
|
|
480
|
+
static create(opts) {
|
|
481
|
+
const replica = SharedObjectsReplica.empty(opts);
|
|
482
|
+
const change = replica.#commit("profile.init", [], (d) => {
|
|
483
|
+
d.profile = scalarString(PROFILE_ID);
|
|
484
|
+
d.objects = {};
|
|
485
|
+
d.extensions = {};
|
|
486
|
+
});
|
|
487
|
+
if (change === null)
|
|
488
|
+
throw new Error("profile.init made no change");
|
|
489
|
+
return { replica, change };
|
|
490
|
+
}
|
|
491
|
+
/** A replica with no state yet, that receives the Resource's changes. */
|
|
492
|
+
static empty(opts) {
|
|
493
|
+
return new SharedObjectsReplica(A.init({ actor: actorHex(opts) }), opts);
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Loads an Automerge full save: a received Snapshot image (§13), checked
|
|
497
|
+
* against `limits` (§13.1, at least the floor) before Automerge loads it,
|
|
498
|
+
* or this device's own persisted state ("local-state", not limited).
|
|
499
|
+
* Throws PROFILE_INVALID / INVALID_AUTOMERGE_BYTES.
|
|
500
|
+
*/
|
|
501
|
+
static fromSave(save, opts, limits = SNAPSHOT_LIMITS_FLOOR) {
|
|
502
|
+
checkSaveHeader(save);
|
|
503
|
+
if (limits !== "local-state")
|
|
504
|
+
checkSnapshotExpansion(save, limits);
|
|
505
|
+
let doc;
|
|
506
|
+
try {
|
|
507
|
+
doc = A.load(save, { actor: actorHex(opts) });
|
|
508
|
+
}
|
|
509
|
+
catch (e) {
|
|
510
|
+
return loadFailure(e, "invalid Automerge save (§13)");
|
|
511
|
+
}
|
|
512
|
+
return new SharedObjectsReplica(doc, opts);
|
|
513
|
+
}
|
|
514
|
+
/** §13: loads a Snapshot plaintext [1, save]; later Data Units are received after it. */
|
|
515
|
+
static fromSnapshot(plaintext, opts) {
|
|
516
|
+
return SharedObjectsReplica.fromSave(unframeSnapshot(plaintext), opts);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* The replica of exactly an accepted change set: its state depends only
|
|
520
|
+
* on the set, not on the order. Changes whose dependencies are not in the
|
|
521
|
+
* set are returned as unapplied.
|
|
522
|
+
*/
|
|
523
|
+
// §14.1 (G-EP7): replica state is a deterministic function of the set
|
|
524
|
+
// of accepted changes, so a unit quarantined after it was merged can be taken
|
|
525
|
+
// out by rebuilding (rebuildWithout). LFCP-033 drives it.
|
|
526
|
+
static fromChanges(changes, opts) {
|
|
527
|
+
const replica = SharedObjectsReplica.empty(opts);
|
|
528
|
+
const r = replica.receiveChanges([...changes].map(checkChange));
|
|
529
|
+
const refused = r.refused[0];
|
|
530
|
+
if (refused !== undefined)
|
|
531
|
+
throw refused.error;
|
|
532
|
+
return { replica, unapplied: r.waiting };
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* §14.1 (G-EP7): this replica rebuilt from its own changes minus
|
|
536
|
+
* `exclude` (change hashes). Changes that depend on an excluded change are
|
|
537
|
+
* unapplied too. An excluded change of this actor is not lost state (§9):
|
|
538
|
+
* the rebuilt replica writes on from its own sequence, reusing the
|
|
539
|
+
* sequence numbers of the removed changes. A replica that was already
|
|
540
|
+
* behind its persisted sequence stays refused.
|
|
541
|
+
*/
|
|
542
|
+
rebuildWithout(exclude) {
|
|
543
|
+
const out = new Set(exclude);
|
|
544
|
+
const kept = A.getAllChanges(this.#doc).filter((c) => !out.has(A.decodeChange(c).hash));
|
|
545
|
+
return SharedObjectsReplica.fromChanges(kept, {
|
|
546
|
+
resource: this.resource,
|
|
547
|
+
principal: this.#principal,
|
|
548
|
+
minSeq: this.writable ? 0 : this.#minSeq,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* This replica merged with an Automerge full save (a loaded Snapshot,
|
|
553
|
+
* §13): the save's document plus every change of this replica, so local
|
|
554
|
+
* work the Snapshot does not hold is kept. The §9 sequence carries over.
|
|
555
|
+
* Throws PROFILE_INVALID / INVALID_AUTOMERGE_BYTES when the save does not load.
|
|
556
|
+
*/
|
|
557
|
+
mergeSave(save) {
|
|
558
|
+
const merged = SharedObjectsReplica.fromSave(save, {
|
|
559
|
+
resource: this.resource,
|
|
560
|
+
principal: this.#principal,
|
|
561
|
+
minSeq: Math.max(this.#minSeq, this.actorSeq),
|
|
562
|
+
});
|
|
563
|
+
const r = merged.receiveChanges(this.changes().map(checkChange));
|
|
564
|
+
const refused = r.refused[0];
|
|
565
|
+
if (refused !== undefined)
|
|
566
|
+
throw refused.error;
|
|
567
|
+
return { replica: merged, unapplied: r.waiting };
|
|
568
|
+
}
|
|
569
|
+
/** An empty replica of the same Resource and actor that keeps the §9 sequence (nothing reused). */
|
|
570
|
+
emptied() {
|
|
571
|
+
return SharedObjectsReplica.empty({
|
|
572
|
+
resource: this.resource,
|
|
573
|
+
principal: this.#principal,
|
|
574
|
+
minSeq: Math.max(this.#minSeq, this.actorSeq),
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
/** The highest change sequence of this replica's own actor in its state. */
|
|
578
|
+
get actorSeq() {
|
|
579
|
+
return this.#seqs.get(this.#actor) ?? 0;
|
|
580
|
+
}
|
|
581
|
+
/** §9: false when the state is behind the persisted sequence, so local writes are refused. */
|
|
582
|
+
get writable() {
|
|
583
|
+
return this.actorSeq >= this.#minSeq;
|
|
584
|
+
}
|
|
585
|
+
/** Hex hashes of the current heads, sorted. */
|
|
586
|
+
heads() {
|
|
587
|
+
return [...A.getHeads(this.#doc)].sort();
|
|
588
|
+
}
|
|
589
|
+
hasChange(hash) {
|
|
590
|
+
return A.hasHeads(this.#doc, [hash]);
|
|
591
|
+
}
|
|
592
|
+
/** Every change of the state, dependencies first. */
|
|
593
|
+
changes() {
|
|
594
|
+
return A.getAllChanges(this.#doc);
|
|
595
|
+
}
|
|
596
|
+
/** The Automerge full save of the state. */
|
|
597
|
+
save() {
|
|
598
|
+
return A.save(this.#doc);
|
|
599
|
+
}
|
|
600
|
+
/** §13: the Snapshot plaintext [1, save]. */
|
|
601
|
+
snapshot() {
|
|
602
|
+
return frameProfilePayload(this.save());
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* The logical root: scalar strings (and Text) read as strings, object keys
|
|
606
|
+
* in canonical order, so JSON.stringify(root()) is comparable across
|
|
607
|
+
* replicas holding the same state.
|
|
608
|
+
*/
|
|
609
|
+
root() {
|
|
610
|
+
return canonical(plain(this.#doc));
|
|
611
|
+
}
|
|
612
|
+
/** The logical object stored under `id` (its visible value), if any. */
|
|
613
|
+
getObject(id) {
|
|
614
|
+
const objects = this.#objects();
|
|
615
|
+
return objects !== undefined && id in objects ? plain(objects[id]) : undefined;
|
|
616
|
+
}
|
|
617
|
+
objectIds() {
|
|
618
|
+
return Object.keys(this.#objects() ?? {}).sort();
|
|
619
|
+
}
|
|
620
|
+
#objects() {
|
|
621
|
+
const objects = this.#doc.objects;
|
|
622
|
+
return objects !== null && typeof objects === "object" && !A.isImmutableString(objects)
|
|
623
|
+
? objects
|
|
624
|
+
: undefined;
|
|
625
|
+
}
|
|
626
|
+
/** §45: every conflicted top-level field of every object, with its concurrent values sorted. */
|
|
627
|
+
conflicts() {
|
|
628
|
+
const out = {};
|
|
629
|
+
const objects = this.#objects() ?? {};
|
|
630
|
+
for (const [id, object] of Object.entries(objects)) {
|
|
631
|
+
if (!isMap(object))
|
|
632
|
+
continue;
|
|
633
|
+
for (const field of conflictedFields(object)) {
|
|
634
|
+
out[id] ??= {};
|
|
635
|
+
out[id][field] = valuesOf(object, field)
|
|
636
|
+
.map(plain)
|
|
637
|
+
.sort(byJson);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return out;
|
|
641
|
+
}
|
|
642
|
+
/** §21: Object IDs under which concurrent objects were created. */
|
|
643
|
+
collisions() {
|
|
644
|
+
const objects = this.#objects();
|
|
645
|
+
if (objects === undefined)
|
|
646
|
+
return [];
|
|
647
|
+
return Object.keys(objects)
|
|
648
|
+
.filter((id) => A.getConflicts(objects, id) !== undefined)
|
|
649
|
+
.sort();
|
|
650
|
+
}
|
|
651
|
+
/** §73-§77 validation of the visible state, every concurrent scalar value and G-SC3, plus §21 collisions. */
|
|
652
|
+
validate() {
|
|
653
|
+
const base = validateRoot(this.root());
|
|
654
|
+
const extraRoot = [];
|
|
655
|
+
// §30 (G-SC3): the root profile value is a scalar string too.
|
|
656
|
+
if ("profile" in this.#doc && hasTextValue(this.#doc, this.#doc, "profile"))
|
|
657
|
+
extraRoot.push(problem("INVALID_ROOT", "/profile", "profile is collaborative Text, not a scalar string (G-SC3)"));
|
|
658
|
+
const perObject = new Map(base.objects);
|
|
659
|
+
const objects = this.#objects() ?? {};
|
|
660
|
+
for (const [id, problems] of base.objects) {
|
|
661
|
+
const stored = objects[id];
|
|
662
|
+
if (!isMap(stored))
|
|
663
|
+
continue;
|
|
664
|
+
const more = [
|
|
665
|
+
...textProblems(this.#doc, stored, id),
|
|
666
|
+
...conflictValueProblems(stored, id),
|
|
667
|
+
].filter((p) => !problems.some((q) => q.pointer === p.pointer && q.diagnostic === p.diagnostic));
|
|
668
|
+
// §74.1: one diagnostic per field, the first in table order over every value.
|
|
669
|
+
perObject.set(id, Object.freeze(firstPerField([...problems, ...more], `/objects/${pointerToken(id)}`)));
|
|
670
|
+
}
|
|
671
|
+
const rootProblems = base.problems.filter((p) => !p.pointer.startsWith("/objects/"));
|
|
672
|
+
const all = [...rootProblems, ...extraRoot, ...[...perObject.values()].flat()];
|
|
673
|
+
return Object.freeze({
|
|
674
|
+
valid: all.length === 0,
|
|
675
|
+
problems: Object.freeze(all),
|
|
676
|
+
objects: perObject,
|
|
677
|
+
collisions: Object.freeze(this.collisions()),
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
#objectProblems(id, stored) {
|
|
681
|
+
const at = `/objects/${pointerToken(id)}`;
|
|
682
|
+
const all = [
|
|
683
|
+
...objectProblems(plain(stored), id).filter((p) => p.pointer.startsWith(at)),
|
|
684
|
+
...textProblems(this.#doc, stored, id),
|
|
685
|
+
...conflictValueProblems(stored, id),
|
|
686
|
+
];
|
|
687
|
+
// §74.1: one diagnostic per field, the first in table order over every value.
|
|
688
|
+
return firstPerField(all.filter((p, i) => all.findIndex((q) => q.pointer === p.pointer && q.diagnostic === p.diagnostic) === i), at);
|
|
689
|
+
}
|
|
690
|
+
/** §99: the Task under `id` with conflict metadata, or undefined if there is no object or it is not a Task. */
|
|
691
|
+
task(id) {
|
|
692
|
+
const objects = this.#objects();
|
|
693
|
+
const stored = objects?.[id];
|
|
694
|
+
if (objects === undefined || !isMap(stored))
|
|
695
|
+
return undefined;
|
|
696
|
+
const object = stored;
|
|
697
|
+
if (plain(object.type) !== "task")
|
|
698
|
+
return undefined;
|
|
699
|
+
const collided = A.getConflicts(objects, id) !== undefined;
|
|
700
|
+
const problems = this.#objectProblems(id, object);
|
|
701
|
+
const parsed = parseTask(plain(object), id);
|
|
702
|
+
const field = (f) => {
|
|
703
|
+
const values = valuesOf(object, f).map(plain).sort(byJson);
|
|
704
|
+
return Object.freeze({
|
|
705
|
+
value: f in object ? plain(object[f]) : undefined,
|
|
706
|
+
values: Object.freeze(values),
|
|
707
|
+
conflicted: values.length > 1,
|
|
708
|
+
});
|
|
709
|
+
};
|
|
710
|
+
const keys = (f) => isMap(plain(object[f])) ? Object.keys(plain(object[f])).sort() : [];
|
|
711
|
+
return Object.freeze({
|
|
712
|
+
id,
|
|
713
|
+
status: collided ? "object_id_collision" : problems.length > 0 ? "profile_invalid" : "ready",
|
|
714
|
+
problems: Object.freeze(problems),
|
|
715
|
+
task: parsed.valid ? parsed.task : undefined,
|
|
716
|
+
fields: Object.freeze(Object.fromEntries(SCALAR_FIELDS.map((f) => [f, field(f)]))),
|
|
717
|
+
tags: Object.freeze(keys("tags")),
|
|
718
|
+
assignees: Object.freeze(keys("assignees")),
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Applies one intent as exactly one Automerge change (§10) and returns it,
|
|
723
|
+
* or null when the intent changes nothing (removing an absent tag,
|
|
724
|
+
* clearing an absent date). The resulting object must be profile-valid;
|
|
725
|
+
* an object already invalid or collided is not written (§76, §21) unless
|
|
726
|
+
* the write repairs it.
|
|
727
|
+
*/
|
|
728
|
+
apply(intent) {
|
|
729
|
+
const objects = this.#objects();
|
|
730
|
+
const rootProblems = validateRoot(this.root()).problems.filter((p) => !p.pointer.startsWith("/objects/"));
|
|
731
|
+
if (objects === undefined || rootProblems.length > 0)
|
|
732
|
+
throw new ProfileError(rootProblems.length > 0 ? rootProblems : [problem("INVALID_ROOT", "/", "no root (§15)")]);
|
|
733
|
+
if (intent.intent === "task.create") {
|
|
734
|
+
const task = intent.task;
|
|
735
|
+
const id = String(task.id);
|
|
736
|
+
// §21: a local create never reuses an Object ID this replica already holds.
|
|
737
|
+
if (id in objects)
|
|
738
|
+
throw new ObjectIdCollisionError(id);
|
|
739
|
+
const problems = objectProblems(task, id);
|
|
740
|
+
if (problems.length > 0)
|
|
741
|
+
throw new ProfileError(problems);
|
|
742
|
+
return this.#commit(intent.intent, [id], (d) => {
|
|
743
|
+
d.objects[id] = scalarize(task, 0); // §53: the whole Task in one change (the object map is depth 0)
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
const id = intent.id;
|
|
747
|
+
const stored = objects[id];
|
|
748
|
+
if (!isMap(stored))
|
|
749
|
+
throw new ProfileError(objectProblems(undefined, id));
|
|
750
|
+
if (A.getConflicts(objects, id) !== undefined)
|
|
751
|
+
throw new ObjectIdCollisionError(id);
|
|
752
|
+
const object = stored;
|
|
753
|
+
const writes = writesOf(intent);
|
|
754
|
+
const touched = new Set(writes.map((w) => (w.op === "put" || w.op === "delete" ? w.field : w.set)));
|
|
755
|
+
const next = candidate(plain(object), writes);
|
|
756
|
+
const problems = [
|
|
757
|
+
...(next.type === "task"
|
|
758
|
+
? objectProblems(next, id)
|
|
759
|
+
: [problem("INVALID_FIELD_TYPE", `/objects/${pointerToken(id)}/type`, "not a Task (§25)")]),
|
|
760
|
+
// Text in a field this intent writes is replaced by the write.
|
|
761
|
+
...textProblems(this.#doc, object, id).filter((p) => !touched.has(unescapeToken(p.pointer.split("/")[3] ?? ""))),
|
|
762
|
+
];
|
|
763
|
+
if (problems.length > 0)
|
|
764
|
+
throw new ProfileError(problems);
|
|
765
|
+
return this.#commit(intent.intent, [id], (d) => perform(d.objects[id], writes));
|
|
766
|
+
}
|
|
767
|
+
#commit(message, ids, fn) {
|
|
768
|
+
if (!this.writable)
|
|
769
|
+
throw new LfcpError("SEQUENCE_REUSE", `the replica is at actor sequence ${this.actorSeq}, behind ${this.#minSeq} already used (§9)`);
|
|
770
|
+
if (A.getActorId(this.#doc) !== this.#actor)
|
|
771
|
+
throw new Error("the document actor is not the §8 actor");
|
|
772
|
+
const before = A.getHeads(this.#doc);
|
|
773
|
+
const next = A.change(this.#doc, { message, time: 0 }, fn);
|
|
774
|
+
if (A.getHeads(next).join() === before.join()) {
|
|
775
|
+
this.#doc = next;
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
const bytes = A.getLastLocalChange(next);
|
|
779
|
+
if (bytes === undefined)
|
|
780
|
+
throw new Error("Automerge made no local change");
|
|
781
|
+
let checked;
|
|
782
|
+
let created;
|
|
783
|
+
try {
|
|
784
|
+
checked = checkChange(bytes);
|
|
785
|
+
created = this.#createdDepths(checked, new Map());
|
|
786
|
+
}
|
|
787
|
+
catch (e) {
|
|
788
|
+
// §11.1: a writer never emits a change over the limits. The handle
|
|
789
|
+
// this replica held is outdated by A.change: rebuild it without the change.
|
|
790
|
+
const hash = A.decodeChange(bytes).hash;
|
|
791
|
+
this.#doc = A.applyChanges(A.init({ actor: this.#actor }), A.getAllChanges(next).filter((c) => A.decodeChange(c).hash !== hash))[0];
|
|
792
|
+
throw new LfcpError("CHANGE_TOO_LARGE", `the transaction "${message}" is larger than one change may be (§11.1: ${e.message}); split it into several (§12)`);
|
|
793
|
+
}
|
|
794
|
+
if (checked.seq !== this.actorSeq + 1)
|
|
795
|
+
throw new LfcpError("SEQUENCE_REUSE", `actor sequence ${checked.seq} after ${this.actorSeq} (§9)`);
|
|
796
|
+
this.#doc = next;
|
|
797
|
+
this.#noteSeq(checked.actor, checked.seq);
|
|
798
|
+
for (const [id, d] of created)
|
|
799
|
+
this.#depths.set(id, d);
|
|
800
|
+
return Object.freeze({
|
|
801
|
+
intent: message,
|
|
802
|
+
change: checked.bytes,
|
|
803
|
+
plaintext: frameProfilePayload(checked.bytes),
|
|
804
|
+
hash: checked.hash,
|
|
805
|
+
seq: checked.seq,
|
|
806
|
+
objects: this.#objectChanges(before, "local", ids),
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
/** §11: receives a Data Unit plaintext [1, change]. Throws PROFILE_INVALID / INVALID_AUTOMERGE_BYTES for invalid bytes. */
|
|
810
|
+
receive(plaintext) {
|
|
811
|
+
return this.#receive(unframeChange(plaintext));
|
|
812
|
+
}
|
|
813
|
+
/** Receives one bare Automerge change (persisted or reference bytes). */
|
|
814
|
+
receiveChange(bytes) {
|
|
815
|
+
return this.#receive(checkChange(bytes));
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Receives many changes at once: store replay, catch-up, a Snapshot's
|
|
819
|
+
* remainder. Same rules as receiveChange (§14.1: no change with a
|
|
820
|
+
* missing dependency reaches Automerge; a taken sequence is
|
|
821
|
+
* ACTOR_EQUIVOCATION; a skipped one INVALID_AUTOMERGE_BYTES), but the
|
|
822
|
+
* admitted changes go to Automerge in ONE call: one change per call is
|
|
823
|
+
* quadratic (10 000 changes: ~12 s against ~70 ms as one batch).
|
|
824
|
+
*
|
|
825
|
+
* The changes may come in any order, duplicated: each is admitted once
|
|
826
|
+
* its dependencies inside the batch are (Kahn's order). A refused change
|
|
827
|
+
* does not stop the others; changes that depend on it wait. If Automerge
|
|
828
|
+
* fails anyway, the document is restored and the admitted changes are
|
|
829
|
+
* received one at a time, which isolates the failing one.
|
|
830
|
+
*/
|
|
831
|
+
receiveChanges(changes) {
|
|
832
|
+
const refused = [];
|
|
833
|
+
const all = [];
|
|
834
|
+
const seen = new Set();
|
|
835
|
+
for (const c of changes) {
|
|
836
|
+
const checked = c instanceof Uint8Array ? checkChange(c) : c;
|
|
837
|
+
if (seen.has(checked.hash))
|
|
838
|
+
continue;
|
|
839
|
+
seen.add(checked.hash);
|
|
840
|
+
all.push(checked);
|
|
841
|
+
}
|
|
842
|
+
const index = new Map(all.map((c, i) => [c.hash, i]));
|
|
843
|
+
const blocking = all.map(() => 0);
|
|
844
|
+
const unreachable = all.map(() => false);
|
|
845
|
+
const children = new Map();
|
|
846
|
+
all.forEach((c, i) => {
|
|
847
|
+
for (const d of c.deps) {
|
|
848
|
+
if (A.hasHeads(this.#doc, [d]))
|
|
849
|
+
continue;
|
|
850
|
+
if (index.has(d)) {
|
|
851
|
+
blocking[i] = blocking[i] + 1;
|
|
852
|
+
children.set(d, [...(children.get(d) ?? []), i]);
|
|
853
|
+
}
|
|
854
|
+
else
|
|
855
|
+
unreachable[i] = true;
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
const seqs = new Map();
|
|
859
|
+
const latest = (actor) => seqs.get(actor) ?? this.#seqs.get(actor) ?? 0;
|
|
860
|
+
const admitted = [];
|
|
861
|
+
const batchDepths = new Map();
|
|
862
|
+
const duplicates = [];
|
|
863
|
+
const done = all.map(() => false);
|
|
864
|
+
const ready = all.flatMap((_, i) => (blocking[i] === 0 && !unreachable[i] ? [i] : []));
|
|
865
|
+
for (let k = 0; k < ready.length; k++) {
|
|
866
|
+
const i = ready[k];
|
|
867
|
+
const c = all[i];
|
|
868
|
+
done[i] = true;
|
|
869
|
+
if (A.hasHeads(this.#doc, [c.hash]))
|
|
870
|
+
duplicates.push(c);
|
|
871
|
+
else if (c.seq <= latest(c.actor)) {
|
|
872
|
+
refused.push({
|
|
873
|
+
change: c,
|
|
874
|
+
error: new LfcpError("ACTOR_EQUIVOCATION", `actor ${c.actor} sequence ${c.seq} already has a different change (§26.2)`),
|
|
875
|
+
});
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
else if (c.otherActors.some((a) => latest(a) === 0)) {
|
|
879
|
+
refused.push({
|
|
880
|
+
change: c,
|
|
881
|
+
error: new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `the change names an actor unknown to this document (§11.1)`),
|
|
882
|
+
});
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
else if (c.seq !== latest(c.actor) + 1) {
|
|
886
|
+
refused.push({
|
|
887
|
+
change: c,
|
|
888
|
+
error: new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `actor ${c.actor} sequence ${c.seq} skips sequence ${latest(c.actor) + 1} (§14.1)`),
|
|
889
|
+
});
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
// §11.2: the depths of the objects it creates, given the document and the batch so far.
|
|
894
|
+
let created;
|
|
895
|
+
try {
|
|
896
|
+
created = this.#createdDepths(c, batchDepths);
|
|
897
|
+
}
|
|
898
|
+
catch (e) {
|
|
899
|
+
refused.push({ change: c, error: e });
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
for (const [id, d] of created)
|
|
903
|
+
batchDepths.set(id, d);
|
|
904
|
+
seqs.set(c.actor, c.seq);
|
|
905
|
+
admitted.push(c);
|
|
906
|
+
}
|
|
907
|
+
for (const child of children.get(c.hash) ?? []) {
|
|
908
|
+
blocking[child] = blocking[child] - 1;
|
|
909
|
+
if (blocking[child] === 0 && !unreachable[child])
|
|
910
|
+
ready.push(child);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
const waiting = all.filter((_, i) => !done[i]);
|
|
914
|
+
const before = A.getHeads(this.#doc);
|
|
915
|
+
let applied = admitted;
|
|
916
|
+
if (admitted.length > 0) {
|
|
917
|
+
const batch = applyBatchChecked(this.#doc, admitted);
|
|
918
|
+
if ("next" in batch) {
|
|
919
|
+
this.#doc = batch.next;
|
|
920
|
+
for (const c of admitted)
|
|
921
|
+
this.#noteSeq(c.actor, c.seq);
|
|
922
|
+
for (const [id, d] of batchDepths)
|
|
923
|
+
this.#depths.set(id, d);
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
this.#doc = batch.restored;
|
|
927
|
+
applied = [];
|
|
928
|
+
for (const c of admitted) {
|
|
929
|
+
try {
|
|
930
|
+
const r = this.#receive(c);
|
|
931
|
+
if (r.status === "applied")
|
|
932
|
+
applied.push(c);
|
|
933
|
+
else if (r.status === "missing_dependencies")
|
|
934
|
+
waiting.push(c);
|
|
935
|
+
}
|
|
936
|
+
catch (e) {
|
|
937
|
+
refused.push({
|
|
938
|
+
change: c,
|
|
939
|
+
error: e instanceof LfcpError
|
|
940
|
+
? e
|
|
941
|
+
: new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", String(e)),
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
return Object.freeze({
|
|
948
|
+
applied: Object.freeze(applied),
|
|
949
|
+
duplicates: Object.freeze(duplicates),
|
|
950
|
+
waiting: Object.freeze(waiting),
|
|
951
|
+
refused: Object.freeze(refused),
|
|
952
|
+
objects: Object.freeze(applied.length > 0 ? this.#objectChanges(before, "remote") : []),
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
#receive(change) {
|
|
956
|
+
if (A.hasHeads(this.#doc, [change.hash]))
|
|
957
|
+
return Object.freeze({ status: "duplicate", change });
|
|
958
|
+
const missing = change.deps.filter((d) => !A.hasHeads(this.#doc, [d]));
|
|
959
|
+
if (missing.length > 0)
|
|
960
|
+
return Object.freeze({
|
|
961
|
+
status: "missing_dependencies",
|
|
962
|
+
change,
|
|
963
|
+
missing: Object.freeze(missing),
|
|
964
|
+
});
|
|
965
|
+
// Same actor and sequence, different change: an equivocation (or, for our
|
|
966
|
+
// own actor, a lost-state fork, §9). Checked before Automerge sees it.
|
|
967
|
+
if (change.seq <= (this.#seqs.get(change.actor) ?? 0))
|
|
968
|
+
throw new LfcpError("ACTOR_EQUIVOCATION", `actor ${change.actor} sequence ${change.seq} already has a different change (§26.2)`);
|
|
969
|
+
// §14.1: with every dependency present, the sequence follows the
|
|
970
|
+
// actor's latest change. Checked before Automerge sees it: Automerge
|
|
971
|
+
// 3.5.0 records a skipping change in its graph without its operations
|
|
972
|
+
// and the document no longer saves loadably (automerge-rs 0.12 aborts).
|
|
973
|
+
// §11.1: every other actor of the change is already an actor of the
|
|
974
|
+
// document (Automerge aborts on an unknown one).
|
|
975
|
+
const unknown = change.otherActors.find((a) => !this.#seqs.has(a));
|
|
976
|
+
if (unknown !== undefined)
|
|
977
|
+
throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `the change names actor ${unknown}, unknown to this document (§11.1)`);
|
|
978
|
+
if (change.seq !== (this.#seqs.get(change.actor) ?? 0) + 1)
|
|
979
|
+
throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `actor ${change.actor} sequence ${change.seq} skips sequence ${(this.#seqs.get(change.actor) ?? 0) + 1} (§14.1)`);
|
|
980
|
+
// §11.2: no object deeper than MAX_DOCUMENT_DEPTH, before the engine.
|
|
981
|
+
const created = this.#createdDepths(change, new Map());
|
|
982
|
+
const before = A.getHeads(this.#doc);
|
|
983
|
+
const applied = applyChecked(this.#doc, change.bytes);
|
|
984
|
+
if ("error" in applied) {
|
|
985
|
+
this.#doc = applied.restored;
|
|
986
|
+
throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `Automerge rejected the change (§11): ${applied.error.message}`);
|
|
987
|
+
}
|
|
988
|
+
const next = applied.next;
|
|
989
|
+
this.#doc = next;
|
|
990
|
+
this.#noteSeq(change.actor, change.seq);
|
|
991
|
+
for (const [id, d] of created)
|
|
992
|
+
this.#depths.set(id, d);
|
|
993
|
+
return Object.freeze({
|
|
994
|
+
status: "applied",
|
|
995
|
+
change,
|
|
996
|
+
objects: this.#objectChanges(before, "remote"),
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
/** §100 notifications for the objects changed since `before`. */
|
|
1000
|
+
#objectChanges(before, origin, hint = []) {
|
|
1001
|
+
const after = A.getHeads(this.#doc);
|
|
1002
|
+
const fields = new Map(hint.map((id) => [id, new Set()]));
|
|
1003
|
+
for (const patch of A.diff(this.#doc, before, after)) {
|
|
1004
|
+
const [top, id, field] = patch.path;
|
|
1005
|
+
if (top !== "objects" || typeof id !== "string")
|
|
1006
|
+
continue;
|
|
1007
|
+
const set = fields.get(id) ?? new Set();
|
|
1008
|
+
if (typeof field === "string")
|
|
1009
|
+
set.add(field);
|
|
1010
|
+
fields.set(id, set);
|
|
1011
|
+
}
|
|
1012
|
+
const objects = this.#objects() ?? {};
|
|
1013
|
+
const out = [];
|
|
1014
|
+
for (const [id, set] of [...fields].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
1015
|
+
const now = objects[id];
|
|
1016
|
+
if (!isMap(now))
|
|
1017
|
+
continue;
|
|
1018
|
+
if (set.size === 0)
|
|
1019
|
+
for (const f of Object.keys(now))
|
|
1020
|
+
set.add(f);
|
|
1021
|
+
const nowConflicts = conflictedFields(now);
|
|
1022
|
+
const wasConflicts = this.#conflicted.get(id) ?? [];
|
|
1023
|
+
this.#conflicted.set(id, nowConflicts);
|
|
1024
|
+
out.push(Object.freeze({
|
|
1025
|
+
resource: this.resource,
|
|
1026
|
+
objectId: id,
|
|
1027
|
+
objectType: typeof plain(now.type) === "string"
|
|
1028
|
+
? plain(now.type)
|
|
1029
|
+
: undefined,
|
|
1030
|
+
fields: Object.freeze([...set].sort()),
|
|
1031
|
+
conflictsAppeared: Object.freeze(nowConflicts.filter((f) => !wasConflicts.includes(f))),
|
|
1032
|
+
conflictsDisappeared: Object.freeze(wasConflicts.filter((f) => !nowConflicts.includes(f))),
|
|
1033
|
+
origin,
|
|
1034
|
+
}));
|
|
1035
|
+
}
|
|
1036
|
+
return out;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
//# sourceMappingURL=replica.js.map
|