@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.
@@ -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