@openlfcp/shared-objects 0.1.5 → 0.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +15 -2
  2. package/dist/admission/actor.d.ts +16 -0
  3. package/dist/admission/actor.js +35 -0
  4. package/dist/{canonical.d.ts → admission/canonical.d.ts} +2 -0
  5. package/dist/{canonical.js → admission/canonical.js} +9 -2
  6. package/dist/{automerge-bytes.d.ts → admission/framing.d.ts} +32 -1
  7. package/dist/admission/framing.js +231 -0
  8. package/dist/admission/index.d.ts +26 -0
  9. package/dist/admission/index.js +26 -0
  10. package/dist/{chunk-limits.d.ts → admission/limits.d.ts} +11 -1
  11. package/dist/{chunk-limits.js → admission/limits.js} +25 -10
  12. package/dist/{references.d.ts → admission/references.d.ts} +1 -1
  13. package/dist/{references.js → admission/references.js} +9 -3
  14. package/dist/admission/sequence.d.ts +81 -0
  15. package/dist/admission/sequence.js +133 -0
  16. package/dist/data-profile.d.ts +1 -1
  17. package/dist/data-profile.js +3 -7
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.js +2 -2
  20. package/dist/replica.d.ts +32 -2
  21. package/dist/replica.js +154 -195
  22. package/dist/sections/admission.d.ts +35 -0
  23. package/dist/sections/admission.js +328 -0
  24. package/dist/sections/data-profile.d.ts +195 -0
  25. package/dist/sections/data-profile.js +383 -0
  26. package/dist/sections/dispatch.d.ts +41 -0
  27. package/dist/sections/dispatch.js +33 -0
  28. package/dist/sections/document.d.ts +54 -0
  29. package/dist/sections/document.js +101 -0
  30. package/dist/sections/index.d.ts +18 -0
  31. package/dist/sections/index.js +18 -0
  32. package/dist/sections/replica.d.ts +412 -0
  33. package/dist/sections/replica.js +1149 -0
  34. package/dist/sections/schema.d.ts +77 -0
  35. package/dist/sections/schema.js +548 -0
  36. package/dist/sections/tree.d.ts +83 -0
  37. package/dist/sections/tree.js +290 -0
  38. package/dist/sections/values.d.ts +30 -0
  39. package/dist/sections/values.js +36 -0
  40. package/dist/values.d.ts +3 -18
  41. package/dist/values.js +7 -95
  42. package/package.json +12 -4
  43. package/dist/automerge-bytes.js +0 -111
@@ -0,0 +1,1149 @@
1
+ import * as A from "@automerge/automerge";
2
+ import { generateObjectId, isObjectId, LfcpError, toHex, } from "@openlfcp/core";
3
+ import { checkChange, checkSaveHeader, parsedOf, refusedChangeHash, } from "../admission/framing.js";
4
+ import { checkChangeExpansion, checkSnapshotExpansion, SNAPSHOT_LIMITS_FLOOR, } from "../admission/limits.js";
5
+ import { referenceHistoryOf } from "../admission/references.js";
6
+ import { admitBatch } from "../admission/sequence.js";
7
+ import { ProfileInvalidError } from "../profile-invalid.js";
8
+ import { isWasmTrap, prepareTaskIntent, taskView, } from "../replica.js";
9
+ import { frameProfilePayload, isUtcTimestamp, principalRef } from "../values.js";
10
+ import { SectionAdmission } from "./admission.js";
11
+ import { validateSection } from "./schema.js";
12
+ import { deriveTree } from "./tree.js";
13
+ import { deriveSectionActorId, LIST_STYLES, PARENT_KINDS, SECTIONS_PROFILE_ID, } from "./values.js";
14
+ /** A batch refused before commit, with the intent it concerns (SDK-SECTIONS-INTEGRATION-01 §3.6). */
15
+ export class SectionIntentError extends Error {
16
+ code;
17
+ /** The index of the refused intent in its batch. */
18
+ intentIndex;
19
+ /** The node the refusal concerns, when there is one. */
20
+ nodeId;
21
+ constructor(code, message, intentIndex, nodeId) {
22
+ super(message);
23
+ this.name = "SectionIntentError";
24
+ this.code = code;
25
+ this.intentIndex = intentIndex;
26
+ this.nodeId = nodeId;
27
+ }
28
+ }
29
+ /**
30
+ * §12, §12.3: splits what one change cannot carry. A Text edit or a new
31
+ * node's text over the Text budget continues in runs at its cursor; a
32
+ * batch that creates the section writes `ready` in its last change
33
+ * (§12.1), whatever number of changes it becomes.
34
+ */
35
+ function expand(intents) {
36
+ const LIMIT = AUTHORING_BUDGET.textOperations;
37
+ const runs = (id, deleteCount, insert) => {
38
+ const out = [];
39
+ for (let left = deleteCount; left > 0; left -= LIMIT)
40
+ out.push({ intent: "text.continue", id, deleteCount: Math.min(left, LIMIT), insert: "" });
41
+ for (let at = 0; at < insert.length; at += LIMIT)
42
+ out.push({
43
+ intent: "text.continue",
44
+ id,
45
+ deleteCount: 0,
46
+ insert: insert.slice(at, at + LIMIT).join(""),
47
+ });
48
+ return out;
49
+ };
50
+ const out = [];
51
+ let ready = false;
52
+ for (const intent of intents) {
53
+ if (intent.intent === "section.create" && intent.ready !== false) {
54
+ out.push({ ...intent, ready: false });
55
+ ready = true;
56
+ }
57
+ else if (intent.intent === "text.edit" && intent.edits.length === 1) {
58
+ const e = intent.edits[0];
59
+ const insert = Array.from(e.insert);
60
+ if (e.deleteCount + insert.length <= LIMIT)
61
+ out.push(intent);
62
+ else {
63
+ const d0 = Math.min(e.deleteCount, LIMIT);
64
+ const i0 = insert.slice(0, LIMIT - d0);
65
+ out.push({ ...intent, edits: [{ index: e.index, deleteCount: d0, insert: i0.join("") }] });
66
+ out.push(...runs(intent.id, e.deleteCount - d0, insert.slice(i0.length)));
67
+ }
68
+ }
69
+ else if ((intent.intent === "paragraph.create" ||
70
+ intent.intent === "item.create" ||
71
+ intent.intent === "raw.create") &&
72
+ typeof intent.text === "string" &&
73
+ Array.from(intent.text).length > LIMIT) {
74
+ const text = Array.from(intent.text);
75
+ out.push({ ...intent, text: text.slice(0, LIMIT).join("") });
76
+ out.push(...runs(intent.id, 0, text.slice(LIMIT)));
77
+ }
78
+ else
79
+ out.push(intent);
80
+ }
81
+ if (ready)
82
+ out.push({ intent: "section.mark_ready" });
83
+ return out;
84
+ }
85
+ /** Intents after which every verified ancestor chain still holds. */
86
+ const KEEPS_CHAINS = new Set([
87
+ "section.create",
88
+ "section.set_title",
89
+ "section.mark_ready",
90
+ "task.create_in_section",
91
+ "paragraph.create",
92
+ "item.create",
93
+ "raw.create",
94
+ "node.set_list_style",
95
+ ]);
96
+ /** §16.2: what one change may carry. */
97
+ export const AUTHORING_BUDGET = Object.freeze({ textOperations: 8192, createdNodes: 256 });
98
+ const S = (s) => new A.ImmutableString(s);
99
+ const str = (v) => A.isImmutableString(v) ? v.toString() : typeof v === "string" ? v : undefined;
100
+ const isListStyle = (s) => typeof s === "string" && LIST_STYLES.includes(s);
101
+ const revisionOf = (doc) => [...A.getHeads(doc)].sort().join(",");
102
+ /** Rebuilds a section document from the changes it held before an aborted apply. */
103
+ function restoreSection(changes, actor, revision) {
104
+ let restored;
105
+ try {
106
+ [restored] = A.applyChanges(A.init({ actor }), [...changes]);
107
+ }
108
+ catch (r) {
109
+ throw new Error(`the section replica could not be restored after an Automerge error: ${String(r)}`);
110
+ }
111
+ if (revisionOf(restored) !== revision)
112
+ throw new Error("the section replica could not be restored after an Automerge error: heads differ");
113
+ return restored;
114
+ }
115
+ /**
116
+ * Applies admitted changes one at a time to `doc`, so a change the engine
117
+ * aborts on (D1) is isolated. On an abort the working document is rebuilt
118
+ * from the changes applied so far, never from the aborted one. A wasm trap
119
+ * is rethrown (isWasmTrap): the module is dead, and only a restart recovers.
120
+ */
121
+ function applyIsolating(doc, changes) {
122
+ const actor = A.getActorId(doc);
123
+ let work = doc;
124
+ const applied = [];
125
+ const poisoned = [];
126
+ for (const c of changes) {
127
+ const before = { revision: revisionOf(work), changes: A.getAllChanges(work) };
128
+ try {
129
+ work = A.applyChanges(work, [c.bytes])[0];
130
+ applied.push(c);
131
+ }
132
+ catch (e) {
133
+ if (isWasmTrap(e))
134
+ throw e;
135
+ work = restoreSection(before.changes, actor, before.revision);
136
+ poisoned.push(c);
137
+ }
138
+ }
139
+ return { doc: work, applied, poisoned };
140
+ }
141
+ /** Every concurrent value of `map[key]`. */
142
+ function values(map, key) {
143
+ if (!(key in map))
144
+ return [];
145
+ const c = A.getConflicts(map, key);
146
+ return c === undefined ? [map[key]] : Object.values(c);
147
+ }
148
+ /** PROFILE_INVALID for a Snapshot, with the diagnostic of what refused it. */
149
+ function snapshotRefused(diagnostic, message) {
150
+ return Object.assign(new LfcpError("PROFILE_INVALID", message), { diagnostic });
151
+ }
152
+ export class SectionReplica {
153
+ resource;
154
+ /** The §2 Automerge actor ID. */
155
+ actorId;
156
+ #principal;
157
+ #actor;
158
+ #minSeq;
159
+ #doc;
160
+ /** SOP §11.4: the document's changes as references read them; null to build again from the document. */
161
+ #refs = null;
162
+ constructor(doc, opts) {
163
+ this.resource = opts.resource;
164
+ this.#minSeq = opts.minSeq ?? 0;
165
+ this.#principal = opts.principal;
166
+ this.actorId = deriveSectionActorId(opts.resource, opts.principal);
167
+ this.#actor = toHex(this.actorId);
168
+ this.#doc = A.getActorId(doc) === this.#actor ? doc : A.clone(doc, { actor: this.#actor });
169
+ }
170
+ /** This actor's latest change sequence in the document (0 when it has none). */
171
+ get actorSeq() {
172
+ return this.#sequences().get(this.#actor) ?? 0;
173
+ }
174
+ /** SOP §9: whether this replica may write (it is not behind its actor's used sequence). */
175
+ get writable() {
176
+ return this.actorSeq >= this.#minSeq;
177
+ }
178
+ /**
179
+ * §12.1: "ready" once the section's `ready` is written, "importing"
180
+ * before, undefined while the document has no section. Reads one
181
+ * register; validate() tells whether the section is valid.
182
+ */
183
+ sectionState() {
184
+ const section = this.#doc.section;
185
+ if (section === null || typeof section !== "object" || A.isImmutableString(section))
186
+ return undefined;
187
+ return section.ready === true ? "ready" : "importing";
188
+ }
189
+ /** Whether the document holds the change with this hex hash. */
190
+ hasChange(hash) {
191
+ return A.hasHeads(this.#doc, [hash]);
192
+ }
193
+ /**
194
+ * SOP §14.1 (G-EP7): the replica rebuilt from its changes minus `exclude`
195
+ * (change hashes); changes that depend on an excluded one are unapplied
196
+ * too. An excluded change of this actor is not lost state (§9): the
197
+ * rebuilt replica writes on from its own sequence.
198
+ */
199
+ rebuildWithout(exclude) {
200
+ const out = new Set(exclude);
201
+ const kept = A.getAllChanges(this.#doc).filter((c) => !out.has(A.decodeChange(c).hash));
202
+ return SectionReplica.fromChanges(kept, {
203
+ resource: this.resource,
204
+ principal: this.#principal,
205
+ minSeq: this.writable ? 0 : this.#minSeq,
206
+ });
207
+ }
208
+ /**
209
+ * This replica merged with a received Snapshot's full save (SOP §13,
210
+ * §14): every change the save holds is admitted as if received one by
211
+ * one (the SOP checks and A1–A5, §14.1), on an empty replica of this
212
+ * actor, so a Snapshot that holds a change admission refuses is
213
+ * rejected; then this replica's own changes follow, so local work the
214
+ * Snapshot lacks is kept. The §9 sequence carries over. Throws
215
+ * PROFILE_INVALID (with the refused change's diagnostic, or
216
+ * INVALID_AUTOMERGE_BYTES) and keeps this replica unchanged.
217
+ */
218
+ mergeSave(save, limits = SNAPSHOT_LIMITS_FLOOR) {
219
+ const opts = {
220
+ resource: this.resource,
221
+ principal: this.#principal,
222
+ minSeq: Math.max(this.#minSeq, this.actorSeq),
223
+ };
224
+ const image = SectionReplica.fromSave(save, opts, limits);
225
+ const merged = SectionReplica.empty(opts);
226
+ const r = merged.receiveChanges(image.changes());
227
+ const refused = r.refused.find((x) => !x.held) ?? r.refused[0];
228
+ if (refused !== undefined)
229
+ throw snapshotRefused(refused.diagnostic, `the Snapshot holds a change admission refuses: ${refused.message}`);
230
+ if (r.waiting.length > 0)
231
+ throw snapshotRefused("INVALID_AUTOMERGE_BYTES", "the Snapshot holds a change whose dependency it lacks");
232
+ const own = merged.receiveChanges(this.changes());
233
+ const lost = own.refused[0];
234
+ if (lost !== undefined)
235
+ throw snapshotRefused(lost.diagnostic, `local state does not merge with the Snapshot: ${lost.message}`);
236
+ return merged;
237
+ }
238
+ /** An empty replica of the same actor that keeps the §9 sequence (SNAP-EP reset). */
239
+ emptied() {
240
+ return SectionReplica.empty({
241
+ resource: this.resource,
242
+ principal: this.#principal,
243
+ minSeq: Math.max(this.#minSeq, this.actorSeq),
244
+ });
245
+ }
246
+ /** An empty document, for section.create or for receiving. */
247
+ static empty(opts) {
248
+ const actor = toHex(deriveSectionActorId(opts.resource, opts.principal));
249
+ return new SectionReplica(A.init({ actor }), opts);
250
+ }
251
+ /** Loads a full save; SOP §13.1 limits unless it is this device's own state. */
252
+ static fromSave(save, opts, limits = SNAPSHOT_LIMITS_FLOOR) {
253
+ checkSaveHeader(save);
254
+ if (limits !== "local-state")
255
+ checkSnapshotExpansion(save, limits);
256
+ try {
257
+ return new SectionReplica(A.load(save), opts);
258
+ }
259
+ catch (e) {
260
+ if (e instanceof LfcpError)
261
+ throw e;
262
+ throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `invalid Automerge save: ${String(e)}`);
263
+ }
264
+ }
265
+ /** The replica of an admitted change set; changes missing a dependency are returned unapplied. */
266
+ static fromChanges(changes, opts) {
267
+ const checked = [...changes].map((b) => {
268
+ checkChangeExpansion(b);
269
+ return checkChange(b);
270
+ });
271
+ const [doc] = A.applyChanges(A.init(), checked.map((c) => c.bytes));
272
+ const unapplied = checked.filter((c) => !A.hasHeads(doc, [c.hash])).map((c) => c.hash);
273
+ return { replica: new SectionReplica(doc, opts), unapplied };
274
+ }
275
+ /**
276
+ * Receives changes others wrote, as LFCP accepted them (§14.1): each
277
+ * passes the inherited SOP checks (framing, §11.1 expansion, the signer's
278
+ * actor when `signer` is given, the sequence check, holding a taken
279
+ * actor sequence) and the section rules A1–A5 and §12.1, against its
280
+ * causal history, before the engine applies any. A refused change is
281
+ * never merged; the changes that depend on it wait.
282
+ */
283
+ receiveChanges(items) {
284
+ const refused = [];
285
+ const checked = [];
286
+ const indexOf = new Map();
287
+ items.forEach((item, index) => {
288
+ const unit = item instanceof Uint8Array ? { bytes: item } : item;
289
+ let c;
290
+ try {
291
+ c = checkChange(unit.bytes);
292
+ }
293
+ catch (e) {
294
+ // Never decoded: a change refused by the checks is named from its bytes only.
295
+ refused.push(refusal(index, refusedChangeHash(unit.bytes, e), e, false));
296
+ return;
297
+ }
298
+ if (unit.signer !== undefined &&
299
+ c.actor !== toHex(deriveSectionActorId(this.resource, unit.signer))) {
300
+ refused.push({
301
+ index,
302
+ hash: c.hash,
303
+ diagnostic: "CHANGE_ACTOR_MISMATCH",
304
+ held: false,
305
+ message: `the change's actor ${c.actor} is not the §2 actor of the unit's signer`,
306
+ });
307
+ return;
308
+ }
309
+ if (indexOf.has(c.hash))
310
+ return;
311
+ indexOf.set(c.hash, index);
312
+ checked.push(c);
313
+ });
314
+ const seqs = this.#sequences();
315
+ this.#refs ??= referenceHistoryOf(this.#doc);
316
+ const references = this.#refs;
317
+ // The document's good state, captured before admission applies anything: a
318
+ // change the engine aborts on (D1: a makeTable it cannot write back)
319
+ // corrupts the working document, so on an abort this.#doc is rebuilt from
320
+ // the changes it held here, never from the aborted one.
321
+ const safe = {
322
+ revision: this.revision(),
323
+ actor: A.getActorId(this.#doc),
324
+ changes: A.getAllChanges(this.#doc),
325
+ };
326
+ const admission = new SectionAdmission(this.#doc, this.resource);
327
+ const decided = admitBatch(checked, {
328
+ hasChange: (hash) => A.hasHeads(this.#doc, [hash]),
329
+ latestSeq: (actor) => seqs.get(actor) ?? 0,
330
+ // SOP §11.4; admitBatch adds what it admits, as the working copy holds it.
331
+ references,
332
+ }, (c) => admission.accept(c));
333
+ for (const r of decided.refused)
334
+ refused.push(refusal(indexOf.get(r.change.hash) ?? -1, r.change.hash, r.error, r.held));
335
+ if (decided.admitted.length > 0) {
336
+ let applied = decided.admitted;
337
+ try {
338
+ // The admission applied each admitted change, at its turn, to its working copy.
339
+ this.#doc = admission.document;
340
+ }
341
+ catch (e) {
342
+ if (isWasmTrap(e))
343
+ throw e;
344
+ // The engine aborted applying an admitted change without a wasm trap
345
+ // (D1). The working document is corrupt; rebuild this.#doc from the
346
+ // changes it held before, then re-apply the admitted changes one at a
347
+ // time, refusing the one the engine rejects (§74.1).
348
+ this.#doc = restoreSection(safe.changes, safe.actor, safe.revision);
349
+ const isolated = applyIsolating(this.#doc, decided.admitted);
350
+ this.#doc = isolated.doc;
351
+ applied = isolated.applied;
352
+ for (const c of isolated.poisoned)
353
+ refused.push({
354
+ index: indexOf.get(c.hash) ?? -1,
355
+ hash: c.hash,
356
+ diagnostic: "INVALID_AUTOMERGE_BYTES",
357
+ held: false,
358
+ message: `the engine could not apply the change (${String(e)})`,
359
+ });
360
+ // The admitted changes were added to the reference index and the
361
+ // sequences; a refused one is not in the document, so rebuild both.
362
+ this.#refs = null;
363
+ this.#seqs = undefined;
364
+ }
365
+ for (const c of applied)
366
+ if (c.seq > (seqs.get(c.actor) ?? 0))
367
+ seqs.set(c.actor, c.seq);
368
+ }
369
+ const hashes = (list) => Object.freeze(list.map((c) => c.hash));
370
+ return Object.freeze({
371
+ admitted: hashes(decided.admitted),
372
+ duplicates: hashes(decided.duplicates),
373
+ waiting: hashes(decided.waiting),
374
+ refused: Object.freeze(refused.sort((a, b) => a.index - b.index)),
375
+ });
376
+ }
377
+ /** Each actor's latest sequence number in the document, read once and kept current. */
378
+ #seqs;
379
+ #sequences() {
380
+ if (this.#seqs === undefined) {
381
+ this.#seqs = new Map();
382
+ for (const bytes of A.getAllChanges(this.#doc)) {
383
+ const c = A.decodeChange(bytes);
384
+ if (c.seq > (this.#seqs.get(c.actor) ?? 0))
385
+ this.#seqs.set(c.actor, c.seq);
386
+ }
387
+ }
388
+ return this.#seqs;
389
+ }
390
+ /**
391
+ * Everything an editor projects, in one synchronous read of one revision:
392
+ * the title (and its concurrent values), each node, the visible order,
393
+ * and the problems to show. `revision` is the modelRevision of a commit
394
+ * receipt and the `base` of text.edit.
395
+ */
396
+ snapshot() {
397
+ const tree = deriveTree(this.#doc);
398
+ const root = this.#doc;
399
+ const section = (root.section ?? {});
400
+ const nodes = (root.nodes ?? {});
401
+ const objects = (root.objects ?? {});
402
+ const placements = (root.placements ?? {});
403
+ const hidden = new Set(tree.hidden);
404
+ const titles = values(section, "title").map(str);
405
+ const out = {};
406
+ for (const id of Object.keys(nodes).sort()) {
407
+ if (tree.collisions.includes(id))
408
+ continue;
409
+ const n = nodes[id];
410
+ const kind = str(n.kind) ?? "";
411
+ const owner = (kind === "task" ? objects[id] : n);
412
+ const placement = placements[str(n.placement) ?? ""];
413
+ const text = n.text;
414
+ out[id] = Object.freeze({
415
+ kind,
416
+ parent: str(placement?.parent_id),
417
+ ...(kind !== "task" && text !== undefined ? { text: String(text) } : {}),
418
+ ...(n.list_style !== undefined ? { listStyle: str(n.list_style) } : {}),
419
+ ...(kind === "task" ? { taskId: id } : {}),
420
+ deleted: str(owner?.lifecycle) === "deleted",
421
+ hidden: hidden.has(id),
422
+ });
423
+ }
424
+ return Object.freeze({
425
+ revision: revisionOf(this.#doc),
426
+ classification: tree.classification,
427
+ title: Object.freeze({
428
+ value: str(section.title),
429
+ conflicts: Object.freeze(titles.length > 1 ? titles.map((t) => t ?? "").sort() : []),
430
+ }),
431
+ nodes: Object.freeze(out),
432
+ order: tree.tree,
433
+ problems: Object.freeze({
434
+ recovery: tree.recovery,
435
+ invalid: tree.invalid,
436
+ collisions: tree.collisions,
437
+ retainedConcurrentEdits: tree.retainedConcurrentEdits,
438
+ scalarConflicts: tree.scalarConflicts,
439
+ }),
440
+ });
441
+ }
442
+ /**
443
+ * The Task of a task node (§4.2: stored under `/objects/<id>`, the node's
444
+ * ID) with its conflict metadata, in the form SharedObjectsReplica.task
445
+ * gives (SOP §99); undefined if there is no object or it is not a Task.
446
+ */
447
+ task(id) {
448
+ const objects = this.#doc.objects;
449
+ return taskView(this.#doc, objects !== null && typeof objects === "object" && !A.isImmutableString(objects)
450
+ ? objects
451
+ : undefined, id);
452
+ }
453
+ /** §3, §4, §14.2. */
454
+ validate() {
455
+ return validateSection(this.#doc);
456
+ }
457
+ /** §7, §9, §14.3: the effective tree, hidden nodes and structural facts. */
458
+ tree() {
459
+ return deriveTree(this.#doc);
460
+ }
461
+ /** The document heads, sorted, as one string. */
462
+ revision() {
463
+ return revisionOf(this.#doc);
464
+ }
465
+ /**
466
+ * The Shared Objects (by ID) that changes since `revision`, an earlier
467
+ * revision() of this document, wrote: a Task's fields, conflicts
468
+ * included, live there and not in its node. From A.diff, without reading
469
+ * the document; undefined when this document does not hold `revision`
470
+ * (a rebuild, or another document).
471
+ */
472
+ objectsTouchedSince(revision) {
473
+ const heads = revision === "" ? [] : revision.split(",");
474
+ if (!A.hasHeads(this.#doc, heads))
475
+ return undefined;
476
+ const out = new Set();
477
+ for (const p of A.diff(this.#doc, heads, A.getHeads(this.#doc))) {
478
+ const [container, key] = p.path;
479
+ if (container === "objects" && typeof key === "string")
480
+ out.add(key);
481
+ }
482
+ return [...out].sort();
483
+ }
484
+ save() {
485
+ return A.save(this.#doc);
486
+ }
487
+ /** Every change of the document, in causal order. */
488
+ changes() {
489
+ return A.getAllChanges(this.#doc);
490
+ }
491
+ /** The current state as JSON: scalar strings and Text read as strings. */
492
+ toJSON() {
493
+ return plain(this.#doc);
494
+ }
495
+ /**
496
+ * Validates `intents` as one batch and commits them as one change.
497
+ * Returns null when the batch writes nothing. Throws SectionIntentError,
498
+ * or ProfileError / ObjectIdCollisionError for a Task the SOP refuses.
499
+ */
500
+ commit(intents) {
501
+ const staged = this.stage(intents);
502
+ if (staged === null)
503
+ return null;
504
+ staged.apply();
505
+ return staged.change;
506
+ }
507
+ /**
508
+ * Validates `intents` as one batch and prepares their change without
509
+ * changing this replica: `apply()` adopts it, once the caller has stored
510
+ * it durably (its Data Unit, outbound entry and checkpoint). A batch that
511
+ * is never applied leaves the replica as it was. Null when the batch
512
+ * writes nothing.
513
+ */
514
+ stage(intents) {
515
+ if (intents.length === 0)
516
+ return null;
517
+ if (!this.writable)
518
+ throw new LfcpError("SEQUENCE_REUSE", `the replica is at actor sequence ${this.actorSeq}, behind ${this.#minSeq} already used (SOP §9)`);
519
+ if (A.getActorId(this.#doc) !== this.#actor)
520
+ throw new Error("the document actor is not the §2 actor");
521
+ const affected = new Set();
522
+ let scratch = A.clone(this.#doc);
523
+ // The validation of the document before the batch: nodes the batch
524
+ // creates are valid by construction, so it is computed again only when
525
+ // the batch changes the section itself (create, ready).
526
+ const state = { chainOk: new Set(), cursor: new Map() };
527
+ // structure.resolve is its moves, each one checked after the ones before it.
528
+ const steps = expand(intents).flatMap((intent) => intent.intent === "structure.resolve"
529
+ ? intent.moves.map((m) => ({ intent: "node.resolve_placement", ...m }))
530
+ : [intent]);
531
+ // §12, §16.2: each change carries at most the budgets; a batch over them
532
+ // is several changes, one after the other.
533
+ const chunks = [[]];
534
+ const used = { text: 0, nodes: 0 };
535
+ let i = -1;
536
+ for (const step of steps) {
537
+ if (step.intent !== "text.continue")
538
+ i = Math.min(i + 1, intents.length - 1);
539
+ const cost = { text: 0, nodes: 0 };
540
+ const write = this.#prepare(scratch, state, step, i, affected, cost);
541
+ if (cost.text > AUTHORING_BUDGET.textOperations || cost.nodes > AUTHORING_BUDGET.createdNodes)
542
+ throw new SectionIntentError("OVER_BUDGET", `one intent is over the §16.2 budgets (${cost.text} Text operations, ${cost.nodes} nodes)`, i);
543
+ const current = chunks[chunks.length - 1];
544
+ if (current.length > 0 &&
545
+ (used.text + cost.text > AUTHORING_BUDGET.textOperations ||
546
+ used.nodes + cost.nodes > AUTHORING_BUDGET.createdNodes)) {
547
+ chunks.push([write]);
548
+ used.text = cost.text;
549
+ used.nodes = cost.nodes;
550
+ }
551
+ else {
552
+ current.push(write);
553
+ used.text += cost.text;
554
+ used.nodes += cost.nodes;
555
+ }
556
+ scratch = A.change(scratch, { time: 0 }, write);
557
+ if (step.intent === "section.create" || step.intent === "section.mark_ready")
558
+ state.v = undefined;
559
+ // Only creations keep every verified ancestor chain as it was.
560
+ if (!KEEPS_CHAINS.has(step.intent))
561
+ state.chainOk.clear();
562
+ }
563
+ const message = intents.map((x) => x.intent).join(",");
564
+ const before = A.getHeads(this.#doc);
565
+ // On a clone: A.change outdates the handle it is given, and this
566
+ // replica keeps its own until apply().
567
+ let next = A.clone(this.#doc, { actor: this.#actor });
568
+ const parts = [];
569
+ const partChanges = [];
570
+ for (const chunk of chunks) {
571
+ if (chunk.length === 0)
572
+ continue;
573
+ const heads = A.getHeads(next).join();
574
+ next = A.change(next, { message, time: 0 }, (d) => {
575
+ for (const w of chunk)
576
+ w(d);
577
+ });
578
+ if (A.getHeads(next).join() === heads)
579
+ continue;
580
+ const bytes = A.getLastLocalChange(next);
581
+ let checked;
582
+ try {
583
+ checkChangeExpansion(bytes);
584
+ checked = checkChange(bytes);
585
+ }
586
+ catch (e) {
587
+ throw new SectionIntentError("OVER_BUDGET", `a change of the batch is larger than one change may be (SOP §11.1: ${e.message})`);
588
+ }
589
+ partChanges.push(checked);
590
+ parts.push(Object.freeze({
591
+ change: checked.bytes,
592
+ plaintext: frameProfilePayload(checked.bytes),
593
+ hash: checked.hash,
594
+ seq: checked.seq,
595
+ }));
596
+ }
597
+ if (parts.length === 0 || A.getHeads(next).join() === before.join())
598
+ return null;
599
+ const last = parts[parts.length - 1];
600
+ const change = Object.freeze({
601
+ intents: Object.freeze(intents.map((x) => x.intent)),
602
+ parts: Object.freeze(parts),
603
+ ...last,
604
+ affectedNodeIds: Object.freeze([...affected].sort()),
605
+ modelRevision: revisionOf(next),
606
+ });
607
+ const base = revisionOf(this.#doc);
608
+ const prior = this.#doc;
609
+ const priorSeqs = this.#seqs === undefined ? undefined : new Map(this.#seqs);
610
+ let applied = false;
611
+ return Object.freeze({
612
+ change,
613
+ apply: () => {
614
+ if (applied)
615
+ return;
616
+ if (revisionOf(this.#doc) !== base)
617
+ throw new Error("the replica changed since the batch was staged; stage it again");
618
+ this.#doc = next;
619
+ if (this.#seqs !== undefined && last.seq > (this.#seqs.get(this.#actor) ?? 0))
620
+ this.#seqs.set(this.#actor, last.seq);
621
+ for (const c of partChanges)
622
+ this.#refs?.add(c.hash, parsedOf(c));
623
+ applied = true;
624
+ },
625
+ revert: () => {
626
+ if (!applied)
627
+ return;
628
+ if (this.#doc !== next)
629
+ throw new Error("the replica changed after the batch was applied; it cannot be reverted");
630
+ this.#doc = prior;
631
+ this.#seqs = priorSeqs === undefined ? undefined : new Map(priorSeqs);
632
+ this.#refs = null;
633
+ applied = false;
634
+ },
635
+ });
636
+ }
637
+ /**
638
+ * Checks one intent against `doc`, the document with the batch so far,
639
+ * and `state.v`, its validation before the batch; returns its write.
640
+ */
641
+ #prepare(doc, state, intent, i, affected, budget) {
642
+ const refuse = (code, message, node) => {
643
+ throw new SectionIntentError(code, message, i, node);
644
+ };
645
+ if (intent.intent === "section.create") {
646
+ if (Object.keys(doc).length > 0)
647
+ refuse("SECTION_EXISTS", "the document already has a root (§4.1)");
648
+ if (!isObjectId(intent.sectionId))
649
+ refuse("INVALID_INTENT", "the SectionId is not a canonical UUIDv7 (§3)");
650
+ checkString(intent.title, "the title", refuse);
651
+ checkCreatedAt(intent.createdAt, refuse);
652
+ affected.add(intent.sectionId);
653
+ const createdBy = principalRef(intent.createdBy);
654
+ return (d) => {
655
+ d.profile = S(SECTIONS_PROFILE_ID);
656
+ d.section = {
657
+ id: S(intent.sectionId),
658
+ title: S(intent.title),
659
+ created_by: S(createdBy),
660
+ ...(intent.createdAt === undefined ? {} : { created_at: S(intent.createdAt) }),
661
+ children: [],
662
+ extensions: {},
663
+ ...(intent.ready === false ? {} : { ready: true }),
664
+ };
665
+ d.objects = {};
666
+ d.nodes = {};
667
+ d.placements = {};
668
+ d.extensions = {};
669
+ };
670
+ }
671
+ // Every other intent writes into a valid section.
672
+ state.v ??= validateSection(doc);
673
+ const v = state.v;
674
+ if (v.state === "invalid" || v.sectionId === undefined)
675
+ refuse("SECTION_INVALID", `the document is not a valid section: ${v.problems[0]?.message ?? "no root"}`);
676
+ const section = doc.section;
677
+ if (v.state === "importing" && str(section.created_by) !== principalRef(this.#principal))
678
+ refuse("SECTION_IMPORTING", "the section is being imported by its creator (§12.1)");
679
+ const sectionId = v.sectionId;
680
+ if (intent.intent === "section.mark_ready") {
681
+ if (v.state === "ready")
682
+ refuse("SECTION_EXISTS", "the section is ready already; ready is written once (§12.1)");
683
+ if (str(section.created_by) !== principalRef(this.#principal))
684
+ refuse("SECTION_IMPORTING", "only the section's creator writes ready (§12.1)");
685
+ affected.add(sectionId);
686
+ return (d) => {
687
+ d.section.ready = true;
688
+ };
689
+ }
690
+ if (intent.intent === "section.set_title") {
691
+ checkString(intent.title, "the title", refuse);
692
+ affected.add(sectionId);
693
+ return (d) => {
694
+ const s = d.section;
695
+ // §58 (G-SC4): an unchanged value is deleted first so the intent writes.
696
+ if (str(s.title) === intent.title)
697
+ delete s.title;
698
+ s.title = S(intent.title);
699
+ };
700
+ }
701
+ if (intent.intent === "task.create_in_section" ||
702
+ intent.intent === "paragraph.create" ||
703
+ intent.intent === "item.create" ||
704
+ intent.intent === "raw.create") {
705
+ const isTask = intent.intent === "task.create_in_section";
706
+ const id = isTask ? String(intent.task.id) : intent.id;
707
+ const kind = isTask ? "task" : intent.intent.slice(0, intent.intent.indexOf("."));
708
+ if (!isObjectId(id))
709
+ refuse("INVALID_INTENT", `the node ID ${id} is not a canonical UUIDv7 (§3)`, id);
710
+ if (idInUse(doc, sectionId, id))
711
+ refuse("ID_IN_USE", `the ID ${id} is already used in the Resource (§3)`, id);
712
+ const placementId = intent.placementId ?? generateObjectId();
713
+ if (!isObjectId(placementId))
714
+ refuse("INVALID_INTENT", "the PlacementId is not a canonical UUIDv7 (§3)", id);
715
+ if (placementId === id || idInUse(doc, sectionId, placementId))
716
+ refuse("ID_IN_USE", `the PlacementId ${placementId} is already used (§3)`, id);
717
+ const listStyle = intent.listStyle;
718
+ if (listStyle !== undefined &&
719
+ (!isListStyle(listStyle) || kind === "paragraph" || kind === "raw"))
720
+ refuse("INVALID_INTENT", "list_style is bullet or ordered, on task and item nodes only (§4.2)", id);
721
+ const index = insertionIndex(doc, v, sectionId, intent.parent, intent.after, refuse, undefined, state.chainOk);
722
+ let createdBy;
723
+ let node;
724
+ let taskWrite;
725
+ if (isTask) {
726
+ const prepared = prepareTaskIntent(doc, doc.objects, {
727
+ intent: "task.create",
728
+ task: intent.task,
729
+ });
730
+ taskWrite = prepared.perform;
731
+ createdBy = String(intent.task.created_by);
732
+ node = {
733
+ id: S(id),
734
+ kind: S("task"),
735
+ created_by: S(createdBy),
736
+ ...(intent.task.created_at === undefined
737
+ ? {}
738
+ : { created_at: S(intent.task.created_at) }),
739
+ lifecycle: S("active"),
740
+ placement: S(placementId),
741
+ children: [],
742
+ extensions: {},
743
+ task_id: S(id),
744
+ list_style: S(listStyle ?? "bullet"),
745
+ };
746
+ }
747
+ else {
748
+ checkString(intent.text, "the text", refuse);
749
+ checkCreatedAt(intent.createdAt, refuse);
750
+ createdBy = principalRef(intent.createdBy);
751
+ budget.text += Array.from(intent.text).length;
752
+ node = {
753
+ id: S(id),
754
+ kind: S(kind),
755
+ created_by: S(createdBy),
756
+ ...(intent.createdAt === undefined ? {} : { created_at: S(intent.createdAt) }),
757
+ lifecycle: S("active"),
758
+ placement: S(placementId),
759
+ children: [],
760
+ extensions: {},
761
+ text: intent.text,
762
+ ...(kind === "item" ? { list_style: S(listStyle ?? "bullet") } : {}),
763
+ };
764
+ }
765
+ budget.nodes += 1;
766
+ affected.add(id);
767
+ // A long text continues in later runs (§12.3) from the end of this one.
768
+ if (!isTask)
769
+ state.cursor.set(id, intent.text.length);
770
+ if (intent.parent === sectionId || state.chainOk.has(intent.parent))
771
+ state.chainOk.add(id);
772
+ const parent = intent.parent;
773
+ return (d) => {
774
+ taskWrite?.(d.objects);
775
+ d.nodes[id] = node;
776
+ d.placements[placementId] = {
777
+ id: S(placementId),
778
+ node_id: S(id),
779
+ parent_id: S(parent),
780
+ created_by: S(createdBy),
781
+ };
782
+ const lane = (parent === sectionId
783
+ ? d.section.children
784
+ : d.nodes[parent].children);
785
+ lane.splice(index, 0, S(placementId));
786
+ };
787
+ }
788
+ if (intent.intent === "text.continue") {
789
+ // §12.3: the next run of a long edit, where the previous one ended.
790
+ const id = intent.id;
791
+ const at = state.cursor.get(id);
792
+ const text = String(doc.nodes[id]?.text ?? "");
793
+ if (at === undefined)
794
+ refuse("INVALID_INTENT", `no Text edit of ${id} to continue (§12.3)`, id);
795
+ const removed = Array.from(text.slice(at)).slice(0, intent.deleteCount).join("");
796
+ budget.text += intent.deleteCount + Array.from(intent.insert).length;
797
+ state.cursor.set(id, at + intent.insert.length);
798
+ affected.add(id);
799
+ const path = ["nodes", id, "text"];
800
+ return (d) => A.splice(d, path, at, removed.length, intent.insert);
801
+ }
802
+ if (intent.intent === "text.edit" ||
803
+ intent.intent === "paragraph.split" ||
804
+ intent.intent === "item.split" ||
805
+ intent.intent === "node.join") {
806
+ const id = intent.id;
807
+ const node = doc.nodes[id];
808
+ if (node === undefined)
809
+ refuse("UNKNOWN_NODE", `${id} names no node`, id);
810
+ if (v.nodes.has(id) || collidedSet(v).has(id))
811
+ refuse("NODE_IN_CONFLICT", `${id} is invalid or collides (§14.2)`, id);
812
+ const kind = str(node.kind);
813
+ if (kind === "task")
814
+ refuse("INVALID_INTENT", "a task node has no Text (§4.2)", id);
815
+ const current = String(node.text ?? "");
816
+ const path = ["nodes", id, "text"];
817
+ if (intent.intent === "text.edit") {
818
+ const ranges = rebase(doc, path, intent.base, intent.edits, refuse, id);
819
+ const lastRange = ranges[ranges.length - 1];
820
+ if (lastRange !== undefined) {
821
+ // A later run of this edit (§12.3) continues after what it inserts.
822
+ const shift = ranges
823
+ .slice(0, -1)
824
+ .reduce((n, r) => n + r.insert.length - (r.to - r.from), 0);
825
+ state.cursor.set(id, lastRange.from + shift + lastRange.insert.length);
826
+ }
827
+ for (const e of intent.edits) {
828
+ checkString(e.insert, "inserted text", refuse);
829
+ budget.text += Array.from(e.insert).length + e.deleteCount;
830
+ }
831
+ affected.add(id);
832
+ return (d) => {
833
+ // From the end, so earlier positions stay valid.
834
+ for (const r of [...ranges].reverse())
835
+ A.splice(d, path, r.from, r.to - r.from, r.insert);
836
+ };
837
+ }
838
+ if (intent.intent === "node.join") {
839
+ const second = intent.second;
840
+ const other = doc.nodes[second];
841
+ if (other === undefined)
842
+ refuse("UNKNOWN_NODE", `${second} names no node`, second);
843
+ if (v.nodes.has(second) || collidedSet(v).has(second))
844
+ refuse("NODE_IN_CONFLICT", `${second} is invalid or collides (§14.2)`, second);
845
+ const otherKind = str(other.kind);
846
+ const sameStyle = kind === "paragraph" ||
847
+ values(node, "list_style").join() === values(other, "list_style").join();
848
+ if (otherKind !== kind || (kind !== "paragraph" && kind !== "item") || !sameStyle)
849
+ refuse("INVALID_INTENT", "only two paragraphs, or two items of one list style, join (§10)", id);
850
+ const kids = (n) => (n.children ?? []).length;
851
+ if (kids(node) > 0 || kids(other) > 0)
852
+ refuse("INVALID_INTENT", "a node with children does not join (§10)", id);
853
+ if (!adjacent(doc, v, id, second))
854
+ refuse("INVALID_INTENT", `${second} is not the visible sibling right after ${id} (§10)`, second);
855
+ const separator = intent.separator ?? "\n";
856
+ checkString(separator, "the separator", refuse);
857
+ const appended = separator + String(other.text ?? "");
858
+ const at = current.length;
859
+ budget.text += Array.from(appended).length;
860
+ affected.add(id);
861
+ affected.add(second);
862
+ return (d) => {
863
+ A.splice(d, path, at, 0, appended);
864
+ // §9, §10: the second node is deleted with a fresh write; its Text stays.
865
+ const o = d.nodes[second];
866
+ if (str(o.lifecycle) === "deleted")
867
+ o.lifecycle = S("active");
868
+ o.lifecycle = S("deleted");
869
+ };
870
+ }
871
+ // split: a deletion of the suffix and a new node after this one.
872
+ if (intent.intent.slice(0, intent.intent.indexOf(".")) !== kind)
873
+ refuse("INVALID_INTENT", `${intent.intent} on a ${kind} node (§10)`, id);
874
+ if (values(node, "placement").length !== 1 ||
875
+ new Set(values(node, "lifecycle")).size !== 1)
876
+ refuse("NODE_IN_CONFLICT", `${id} has unresolved structure (§10)`, id);
877
+ const cut = rebase(doc, path, intent.base, [{ index: intent.at, deleteCount: 0, insert: "" }], refuse, id)[0];
878
+ const suffix = current.slice(cut.from);
879
+ const placement = doc.placements[str(node.placement)];
880
+ const parent = str(placement.parent_id);
881
+ const create = this.#prepare(doc, state, {
882
+ intent: kind === "item" ? "item.create" : "paragraph.create",
883
+ id: intent.newId,
884
+ parent,
885
+ after: id,
886
+ text: suffix,
887
+ createdBy: intent.createdBy,
888
+ ...(kind === "item"
889
+ ? { listStyle: (str(node.list_style) ?? "bullet") }
890
+ : {}),
891
+ ...(intent.placementId === undefined ? {} : { placementId: intent.placementId }),
892
+ }, i, affected, budget);
893
+ budget.text += Array.from(suffix).length;
894
+ affected.add(id);
895
+ return (d) => {
896
+ A.splice(d, path, cut.from, current.length - cut.from, "");
897
+ create(d);
898
+ };
899
+ }
900
+ if (intent.intent === "node.delete" || intent.intent === "node.restore") {
901
+ const id = intent.id;
902
+ const node = doc.nodes[id];
903
+ if (node === undefined)
904
+ refuse("UNKNOWN_NODE", `${id} names no node`, id);
905
+ if (v.nodes.has(id) || collidedSet(v).has(id))
906
+ refuse("NODE_IN_CONFLICT", `${id} is invalid or collides (§14.2)`, id);
907
+ const isTask = str(node.kind) === "task";
908
+ const value = intent.intent === "node.delete" ? "deleted" : "active";
909
+ affected.add(id);
910
+ return (d) => {
911
+ const owner = (isTask ? d.objects[id] : d.nodes[id]);
912
+ // §9: a same-value assignment would be dropped by the binding, so the
913
+ // other valid value is written first, in the same change.
914
+ if (str(owner.lifecycle) === value)
915
+ owner.lifecycle = S(value === "active" ? "deleted" : "active");
916
+ owner.lifecycle = S(value);
917
+ };
918
+ }
919
+ if (intent.intent === "node.move" ||
920
+ intent.intent === "node.resolve_placement" ||
921
+ intent.intent === "node.set_list_style") {
922
+ const id = intent.id;
923
+ const node = doc.nodes[id];
924
+ if (node === undefined)
925
+ refuse("UNKNOWN_NODE", `${id} names no node`, id);
926
+ if (v.nodes.has(id) || collidedSet(v).has(id))
927
+ refuse("NODE_IN_CONFLICT", `${id} is invalid or collides (§14.2)`, id);
928
+ const kind = str(node.kind);
929
+ affected.add(id);
930
+ if (intent.intent === "node.set_list_style") {
931
+ if (!isListStyle(intent.listStyle) || (kind !== "task" && kind !== "item"))
932
+ refuse("INVALID_INTENT", "list_style is bullet or ordered, on task and item nodes only (§4.2)", id);
933
+ const style = intent.listStyle;
934
+ return (d) => {
935
+ const n = d.nodes[id];
936
+ // §58 (G-SC4): an unchanged value is deleted first so the intent writes.
937
+ if (str(n.list_style) === style)
938
+ delete n.list_style;
939
+ n.list_style = S(style);
940
+ };
941
+ }
942
+ if (intent.intent === "node.move" && values(node, "placement").length !== 1)
943
+ refuse("NODE_IN_CONFLICT", `${id} has concurrent placements: use node.resolve_placement (§7, §8)`, id);
944
+ const placementId = intent.placementId ?? generateObjectId();
945
+ if (!isObjectId(placementId))
946
+ refuse("INVALID_INTENT", "the PlacementId is not a canonical UUIDv7 (§3)", id);
947
+ if (idInUse(doc, sectionId, placementId))
948
+ refuse("ID_IN_USE", `the PlacementId ${placementId} is already used (§3)`, id);
949
+ const index = insertionIndex(doc, v, sectionId, intent.parent, intent.after, refuse, id);
950
+ const createdBy = principalRef(this.#principal);
951
+ const parent = intent.parent;
952
+ return (d) => {
953
+ d.placements[placementId] = {
954
+ id: S(placementId),
955
+ node_id: S(id),
956
+ parent_id: S(parent),
957
+ created_by: S(createdBy),
958
+ };
959
+ const lane = (parent === sectionId
960
+ ? d.section.children
961
+ : d.nodes[parent].children);
962
+ lane.splice(index, 0, S(placementId));
963
+ // §5: the node's register selects the new slot; the old one stays as an anchor.
964
+ d.nodes[id].placement = S(placementId);
965
+ };
966
+ }
967
+ // A SOP Task intent on a Task of this section (§2).
968
+ const prepared = prepareTaskIntent(doc, doc.objects, intent);
969
+ affected.add(prepared.id);
970
+ return (d) => prepared.perform(d.objects);
971
+ }
972
+ }
973
+ /** §3: a lone surrogate code point is not valid Unicode. */
974
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
975
+ /**
976
+ * §10: the Text edits at `base` as UTF-16 ranges of the current Text.
977
+ * Each position is the current place of the character it stood before at
978
+ * `base` (an Automerge cursor taken at the base heads); a deleted range
979
+ * must still hold exactly the text it held then.
980
+ */
981
+ function rebase(doc, path, base, edits, refuse, node) {
982
+ const heads = base === "" ? [] : base.split(",");
983
+ let view;
984
+ try {
985
+ if (!heads.every((h) => /^[0-9a-f]{64}$/.test(h)) || !A.hasHeads(doc, heads))
986
+ throw new Error();
987
+ view = A.view(doc, heads);
988
+ }
989
+ catch {
990
+ return refuse("STALE_BASE", `the base revision is not in this document (§10)`, node);
991
+ }
992
+ const before = String(view.nodes?.[path[1]]?.text ?? "");
993
+ const scalars = Array.from(before);
994
+ const utf16 = (scalar) => scalars.slice(0, scalar).join("").length;
995
+ const now = String(doc.nodes[path[1]].text ?? "");
996
+ let last = 0;
997
+ return edits.map((e) => {
998
+ if (!Number.isInteger(e.index) ||
999
+ !Number.isInteger(e.deleteCount) ||
1000
+ e.index < last ||
1001
+ e.deleteCount < 0 ||
1002
+ e.index + e.deleteCount > scalars.length)
1003
+ refuse("INVALID_INTENT", "Text edits are sorted, do not overlap and stay within the base Text (§10)", node);
1004
+ last = e.index + e.deleteCount;
1005
+ const position = (scalar) => {
1006
+ try {
1007
+ return A.getCursorPosition(doc, path, A.getCursor(view, path, utf16(scalar)));
1008
+ }
1009
+ catch {
1010
+ return refuse("STALE_BASE", "a Text position cannot be rebased (§10)", node);
1011
+ }
1012
+ };
1013
+ const from = position(e.index);
1014
+ const to = e.deleteCount === 0 ? from : position(e.index + e.deleteCount);
1015
+ const removed = scalars.slice(e.index, e.index + e.deleteCount).join("");
1016
+ if (now.slice(from, to) !== removed)
1017
+ refuse("STALE_BASE", "the deleted Text changed since the base revision (§10)", node);
1018
+ return { from, to, insert: e.insert };
1019
+ });
1020
+ }
1021
+ /** §10: whether `second` is the visible sibling right after `first`, both unblocked. */
1022
+ function adjacent(doc, v, first, second) {
1023
+ const entries = deriveTree(doc, v).tree;
1024
+ const i = entries.findIndex((e) => e.id === first);
1025
+ if (i < 0)
1026
+ return false;
1027
+ const { parent, depth } = entries[i];
1028
+ for (let k = i + 1; k < entries.length; k++) {
1029
+ const e = entries[k];
1030
+ if (e.depth < depth)
1031
+ return false;
1032
+ if (e.parent === parent)
1033
+ return e.id === second;
1034
+ }
1035
+ return false;
1036
+ }
1037
+ function refusal(index, hash, e, held) {
1038
+ const diagnostic = e.diagnostic ??
1039
+ (held ? "ACTOR_EQUIVOCATION" : (e.code ?? "INVALID_AUTOMERGE_BYTES"));
1040
+ return { index, hash, diagnostic, held, message: e instanceof Error ? e.message : String(e) };
1041
+ }
1042
+ function checkString(text, what, refuse) {
1043
+ if (typeof text !== "string" || LONE_SURROGATE.test(text))
1044
+ refuse("INVALID_INTENT", `${what} must be a string of valid Unicode (§3)`);
1045
+ }
1046
+ function checkCreatedAt(at, refuse) {
1047
+ if (at !== undefined && !isUtcTimestamp(at))
1048
+ refuse("INVALID_INTENT", "created_at is not an RFC 3339 UTC timestamp (§3)");
1049
+ }
1050
+ const collidedSets = new WeakMap();
1051
+ /** The collided nodes of a validation as a set, built once per validation. */
1052
+ function collidedSet(v) {
1053
+ let set = collidedSets.get(v);
1054
+ if (set === undefined) {
1055
+ set = new Set(v.collided);
1056
+ collidedSets.set(v, set);
1057
+ }
1058
+ return set;
1059
+ }
1060
+ /** §3: whether `id` already names the section, a node, a placement or an object. */
1061
+ function idInUse(doc, sectionId, id) {
1062
+ return (id === sectionId ||
1063
+ id in doc.nodes ||
1064
+ id in doc.placements ||
1065
+ id in doc.objects);
1066
+ }
1067
+ /**
1068
+ * §6: the physical index in the parent's children list at which a new
1069
+ * placement goes. The parent is the section, or a valid, active,
1070
+ * unconflicted task or item node whose ancestors are too; `after` is a
1071
+ * visible child of it, and the new entry goes right after its selected
1072
+ * placement, keeping every historical slot.
1073
+ */
1074
+ function insertionIndex(doc, v, sectionId, parent, after, refuse, moving,
1075
+ /** Nodes whose ancestor chain this batch already verified; unused for a move. */
1076
+ chainOk) {
1077
+ const nodes = doc.nodes;
1078
+ const placements = doc.placements;
1079
+ const objects = doc.objects;
1080
+ /** The selected parent of an eligible node, or why it is not eligible. */
1081
+ const eligible = (id) => {
1082
+ const node = nodes[id];
1083
+ if (node === undefined)
1084
+ return { why: "names no node" };
1085
+ if (v.nodes.has(id) || collidedSet(v).has(id))
1086
+ return { why: "is invalid (§14.2)" };
1087
+ const kind = str(node.kind);
1088
+ const owner = kind === "task" ? objects[id] : node;
1089
+ const life = values(owner, "lifecycle").map(str);
1090
+ if (life.length !== 1)
1091
+ return { why: "has a lifecycle conflict (§9)" };
1092
+ if (life[0] !== "active")
1093
+ return { why: "is deleted (§9)" };
1094
+ const selected = values(node, "placement").map(str);
1095
+ if (selected.length !== 1)
1096
+ return { why: "has a placement conflict (§7)" };
1097
+ const placement = placements[selected[0]];
1098
+ if (placement === undefined)
1099
+ return { why: "has no placement" };
1100
+ return { parent: str(placement.parent_id) };
1101
+ };
1102
+ if (parent !== sectionId) {
1103
+ const kind = str(nodes[parent]?.kind);
1104
+ if (kind === undefined || !PARENT_KINDS.has(kind))
1105
+ refuse("INVALID_PARENT", `the parent ${parent} is not the section, a task or an item (§4.2)`, parent);
1106
+ // The parent and every ancestor are eligible, without a cycle (§6, §7).
1107
+ const seen = new Set();
1108
+ let p = parent;
1109
+ while (p !== sectionId) {
1110
+ if (moving === undefined && chainOk?.has(p))
1111
+ break;
1112
+ if (p === moving)
1113
+ refuse("INVALID_PARENT", `${moving} cannot move under itself or its descendant (§6)`, moving);
1114
+ if (seen.has(p))
1115
+ refuse("INVALID_PARENT", `the parent ${parent} is in a cycle (§7)`, parent);
1116
+ seen.add(p);
1117
+ const e = eligible(p);
1118
+ if ("why" in e)
1119
+ refuse("INVALID_PARENT", `the parent's ancestor ${p} ${e.why}`, parent);
1120
+ p = e.parent;
1121
+ }
1122
+ if (moving === undefined)
1123
+ for (const n of seen)
1124
+ chainOk?.add(n);
1125
+ }
1126
+ const lane = (parent === sectionId ? doc.section.children : nodes[parent].children);
1127
+ if (after === null)
1128
+ return 0;
1129
+ if (after === moving)
1130
+ refuse("INVALID_PREDECESSOR", `${after} cannot follow itself (§6)`, after);
1131
+ const e = eligible(after);
1132
+ if ("why" in e || e.parent !== parent)
1133
+ refuse("INVALID_PREDECESSOR", `${after} is not a visible child of ${parent} (§6)`, after);
1134
+ const slot = str(values(nodes[after], "placement")[0]);
1135
+ const at = lane.findIndex((x) => str(x) === slot);
1136
+ if (at < 0)
1137
+ refuse("INVALID_PREDECESSOR", `${after}'s placement is not in the parent's list (§4.3)`, after);
1138
+ return at + 1;
1139
+ }
1140
+ function plain(value) {
1141
+ if (A.isImmutableString(value))
1142
+ return value.toString();
1143
+ if (Array.isArray(value))
1144
+ return value.map(plain);
1145
+ if (value !== null && typeof value === "object" && !(value instanceof Uint8Array))
1146
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plain(v)]));
1147
+ return value;
1148
+ }
1149
+ //# sourceMappingURL=replica.js.map