@openlfcp/shared-objects 0.2.0-beta.8 → 0.2.0

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.
@@ -361,12 +361,6 @@ export declare class SectionReplica {
361
361
  * never merged; the changes that depend on it wait.
362
362
  */
363
363
  receiveChanges(items: readonly (Uint8Array | SectionUnit)[]): SectionReceiveResult;
364
- /**
365
- * Everything an editor projects, in one synchronous read of one revision:
366
- * the title (and its concurrent values), each node, the visible order,
367
- * and the problems to show. `revision` is the modelRevision of a commit
368
- * receipt and the `base` of text.edit.
369
- */
370
364
  snapshot(): SectionSnapshot;
371
365
  /**
372
366
  * The Task of a task node (§4.2: stored under `/objects/<id>`, the node's
@@ -100,6 +100,8 @@ const S = (s) => new A.ImmutableString(s);
100
100
  const str = (v) => A.isImmutableString(v) ? v.toString() : typeof v === "string" ? v : undefined;
101
101
  const isListStyle = (s) => typeof s === "string" && LIST_STYLES.includes(s);
102
102
  const revisionOf = (doc) => [...A.getHeads(doc)].sort().join(",");
103
+ /** `changes` without those of `batch` (by hash), in their order. */
104
+ const withoutBatch = (changes, batch) => changes.filter((c) => !batch.has(A.decodeChange(c).hash));
103
105
  /** Rebuilds a section document from the changes it held before an aborted apply. */
104
106
  function restoreSection(changes, actor, revision) {
105
107
  let restored;
@@ -391,8 +393,29 @@ export class SectionReplica {
391
393
  * and the problems to show. `revision` is the modelRevision of a commit
392
394
  * receipt and the `base` of text.edit.
393
395
  */
396
+ /**
397
+ * The tree and snapshot derived from the document at one revision (its
398
+ * heads): both are frozen and depend on the document alone, so a read at
399
+ * the same revision returns them again. Any change to the document
400
+ * changes its heads, so every path that changes it (commit, apply,
401
+ * revert, receive, a rebuild) gives a new revision and a new derivation
402
+ * (soak #11: the status was derived again on every refresh).
403
+ */
404
+ #derived;
405
+ #derivedNow() {
406
+ const revision = revisionOf(this.#doc);
407
+ if (this.#derived?.revision !== revision)
408
+ this.#derived = { revision, tree: deriveTree(this.#doc) };
409
+ return this.#derived;
410
+ }
394
411
  snapshot() {
395
- const tree = deriveTree(this.#doc);
412
+ const derived = this.#derivedNow();
413
+ if (derived.snapshot !== undefined)
414
+ return derived.snapshot;
415
+ derived.snapshot = this.#snapshotOf(derived.tree);
416
+ return derived.snapshot;
417
+ }
418
+ #snapshotOf(tree) {
396
419
  const root = this.#doc;
397
420
  const section = (root.section ?? {});
398
421
  const nodes = (root.nodes ?? {});
@@ -454,7 +477,7 @@ export class SectionReplica {
454
477
  }
455
478
  /** §7, §9, §14.3: the effective tree, hidden nodes and structural facts. */
456
479
  tree() {
457
- return deriveTree(this.#doc);
480
+ return this.#derivedNow().tree;
458
481
  }
459
482
  /** The document heads, sorted, as one string. */
460
483
  revision() {
@@ -517,6 +540,9 @@ export class SectionReplica {
517
540
  if (A.getActorId(this.#doc) !== this.#actor)
518
541
  throw new Error("the document actor is not the §2 actor");
519
542
  const affected = new Set();
543
+ // The validation's own document, freed on every exit (#7: an Automerge
544
+ // document left to the garbage collector holds its whole history in
545
+ // wasm memory until the collector happens to run).
520
546
  let scratch = A.clone(this.#doc);
521
547
  // The validation of the document before the batch: nodes the batch
522
548
  // creates are valid by construction, so it is computed again only when
@@ -531,106 +557,159 @@ export class SectionReplica {
531
557
  const chunks = [[]];
532
558
  const used = { text: 0, nodes: 0 };
533
559
  let i = -1;
534
- for (const step of steps) {
535
- if (step.intent !== "text.continue")
536
- i = Math.min(i + 1, intents.length - 1);
537
- const cost = { text: 0, nodes: 0 };
538
- const write = this.#prepare(scratch, state, step, i, affected, cost);
539
- if (cost.text > AUTHORING_BUDGET.textOperations || cost.nodes > AUTHORING_BUDGET.createdNodes)
540
- throw new SectionIntentError("OVER_BUDGET", `one intent is over the §16.2 budgets (${cost.text} Text operations, ${cost.nodes} nodes)`, i);
541
- const current = chunks[chunks.length - 1];
542
- if (current.length > 0 &&
543
- (used.text + cost.text > AUTHORING_BUDGET.textOperations ||
544
- used.nodes + cost.nodes > AUTHORING_BUDGET.createdNodes)) {
545
- chunks.push([write]);
546
- used.text = cost.text;
547
- used.nodes = cost.nodes;
548
- }
549
- else {
550
- current.push(write);
551
- used.text += cost.text;
552
- used.nodes += cost.nodes;
560
+ try {
561
+ for (const step of steps) {
562
+ if (step.intent !== "text.continue")
563
+ i = Math.min(i + 1, intents.length - 1);
564
+ const cost = { text: 0, nodes: 0 };
565
+ const write = this.#prepare(scratch, state, step, i, affected, cost);
566
+ if (cost.text > AUTHORING_BUDGET.textOperations ||
567
+ cost.nodes > AUTHORING_BUDGET.createdNodes)
568
+ throw new SectionIntentError("OVER_BUDGET", `one intent is over the §16.2 budgets (${cost.text} Text operations, ${cost.nodes} nodes)`, i);
569
+ const current = chunks[chunks.length - 1];
570
+ if (current.length > 0 &&
571
+ (used.text + cost.text > AUTHORING_BUDGET.textOperations ||
572
+ used.nodes + cost.nodes > AUTHORING_BUDGET.createdNodes)) {
573
+ chunks.push([write]);
574
+ used.text = cost.text;
575
+ used.nodes = cost.nodes;
576
+ }
577
+ else {
578
+ current.push(write);
579
+ used.text += cost.text;
580
+ used.nodes += cost.nodes;
581
+ }
582
+ scratch = A.change(scratch, { time: 0 }, write);
583
+ if (step.intent === "section.create" || step.intent === "section.mark_ready")
584
+ state.v = undefined;
585
+ // Only creations keep every verified ancestor chain as it was.
586
+ if (!KEEPS_CHAINS.has(step.intent))
587
+ state.chainOk.clear();
553
588
  }
554
- scratch = A.change(scratch, { time: 0 }, write);
555
- if (step.intent === "section.create" || step.intent === "section.mark_ready")
556
- state.v = undefined;
557
- // Only creations keep every verified ancestor chain as it was.
558
- if (!KEEPS_CHAINS.has(step.intent))
559
- state.chainOk.clear();
589
+ }
590
+ finally {
591
+ A.free(scratch);
560
592
  }
561
593
  const message = intents.map((x) => x.intent).join(",");
562
594
  const before = A.getHeads(this.#doc);
563
- // On a clone: A.change outdates the handle it is given, and this
564
- // replica keeps its own until apply().
565
- let next = A.clone(this.#doc, { actor: this.#actor });
566
- const parts = [];
567
- const partChanges = [];
568
- for (const chunk of chunks) {
569
- if (chunk.length === 0)
570
- continue;
571
- const heads = A.getHeads(next).join();
572
- next = A.change(next, { message, time: 0 }, (d) => {
573
- for (const w of chunk)
574
- w(d);
575
- });
576
- if (A.getHeads(next).join() === heads)
577
- continue;
578
- const bytes = A.getLastLocalChange(next);
579
- let checked;
580
- try {
581
- checkChangeExpansion(bytes);
582
- checked = checkChange(bytes);
583
- }
584
- catch (e) {
585
- throw new SectionIntentError("OVER_BUDGET", `a change of the batch is larger than one change may be (SOP §11.1: ${e.message})`);
595
+ // The batch's changes are made on a fork with this replica's §2 actor;
596
+ // apply() gives #doc the same changes by applyChanges, so #doc keeps its
597
+ // one Automerge document. (Adopting the fork as #doc kept, through each
598
+ // object the new document shared with the old one, one old document
599
+ // alive per unchanged node: a section's memory grew with the number of
600
+ // Tasks times its history, #7.) The fork is freed on every exit.
601
+ let work = A.clone(this.#doc, { actor: this.#actor });
602
+ let handedOver = false;
603
+ try {
604
+ const parts = [];
605
+ const partChanges = [];
606
+ for (const chunk of chunks) {
607
+ if (chunk.length === 0)
608
+ continue;
609
+ const heads = A.getHeads(work).join();
610
+ work = A.change(work, { message, time: 0 }, (d) => {
611
+ for (const w of chunk)
612
+ w(d);
613
+ });
614
+ if (A.getHeads(work).join() === heads)
615
+ continue;
616
+ const bytes = A.getLastLocalChange(work);
617
+ let checked;
618
+ try {
619
+ checkChangeExpansion(bytes);
620
+ checked = checkChange(bytes);
621
+ }
622
+ catch (e) {
623
+ throw new SectionIntentError("OVER_BUDGET", `a change of the batch is larger than one change may be (SOP §11.1: ${e.message})`);
624
+ }
625
+ partChanges.push(checked);
626
+ parts.push(Object.freeze({
627
+ change: checked.bytes,
628
+ plaintext: frameProfilePayload(checked.bytes),
629
+ hash: checked.hash,
630
+ seq: checked.seq,
631
+ }));
586
632
  }
587
- partChanges.push(checked);
588
- parts.push(Object.freeze({
589
- change: checked.bytes,
590
- plaintext: frameProfilePayload(checked.bytes),
591
- hash: checked.hash,
592
- seq: checked.seq,
593
- }));
594
- }
595
- if (parts.length === 0 || A.getHeads(next).join() === before.join())
596
- return null;
597
- const last = parts[parts.length - 1];
598
- const change = Object.freeze({
599
- intents: Object.freeze(intents.map((x) => x.intent)),
600
- parts: Object.freeze(parts),
601
- ...last,
602
- affectedNodeIds: Object.freeze([...affected].sort()),
603
- modelRevision: revisionOf(next),
604
- });
605
- const base = revisionOf(this.#doc);
606
- const prior = this.#doc;
607
- const priorSeqs = this.#seqs === undefined ? undefined : new Map(this.#seqs);
608
- let applied = false;
609
- return Object.freeze({
610
- change,
611
- apply: () => {
612
- if (applied)
613
- return;
614
- if (revisionOf(this.#doc) !== base)
615
- throw new Error("the replica changed since the batch was staged; stage it again");
616
- this.#doc = next;
617
- if (this.#seqs !== undefined && last.seq > (this.#seqs.get(this.#actor) ?? 0))
618
- this.#seqs.set(this.#actor, last.seq);
619
- for (const c of partChanges)
620
- this.#refs?.add(c.hash, parsedOf(c));
621
- applied = true;
622
- },
623
- revert: () => {
624
- if (!applied)
633
+ if (parts.length === 0 || A.getHeads(work).join() === before.join())
634
+ return null;
635
+ const last = parts[parts.length - 1];
636
+ const change = Object.freeze({
637
+ intents: Object.freeze(intents.map((x) => x.intent)),
638
+ parts: Object.freeze(parts),
639
+ ...last,
640
+ affectedNodeIds: Object.freeze([...affected].sort()),
641
+ modelRevision: revisionOf(work),
642
+ });
643
+ const base = revisionOf(this.#doc);
644
+ const batch = new Set(partChanges.map((c) => c.hash));
645
+ const priorSeqs = this.#seqs === undefined ? undefined : new Map(this.#seqs);
646
+ const fork = work;
647
+ let forkFreed = false;
648
+ const freeFork = () => {
649
+ if (forkFreed)
625
650
  return;
626
- if (this.#doc !== next)
627
- throw new Error("the replica changed after the batch was applied; it cannot be reverted");
628
- this.#doc = prior;
629
- this.#seqs = priorSeqs === undefined ? undefined : new Map(priorSeqs);
630
- this.#refs = null;
631
- applied = false;
632
- },
633
- });
651
+ forkFreed = true;
652
+ A.free(fork);
653
+ };
654
+ let applied = false;
655
+ handedOver = true;
656
+ return Object.freeze({
657
+ change,
658
+ apply: () => {
659
+ if (applied)
660
+ return;
661
+ if (forkFreed)
662
+ throw new Error("the batch could not be applied before; stage it again");
663
+ if (revisionOf(this.#doc) !== base)
664
+ throw new Error("the replica changed since the batch was staged; stage it again");
665
+ try {
666
+ try {
667
+ this.#doc = A.applyChanges(this.#doc, partChanges.map((c) => c.bytes))[0];
668
+ if (revisionOf(this.#doc) !== change.modelRevision)
669
+ throw new Error("the batch did not apply as it was staged");
670
+ }
671
+ catch (e) {
672
+ if (isWasmTrap(e))
673
+ throw e;
674
+ // The document is not trusted after a failed apply: rebuilt from
675
+ // the changes the fork held before the batch, never from it.
676
+ this.#doc = this.#rebuilt(withoutBatch(A.getAllChanges(fork), batch), base);
677
+ throw e;
678
+ }
679
+ }
680
+ finally {
681
+ freeFork();
682
+ }
683
+ if (this.#seqs !== undefined && last.seq > (this.#seqs.get(this.#actor) ?? 0))
684
+ this.#seqs.set(this.#actor, last.seq);
685
+ for (const c of partChanges)
686
+ this.#refs?.add(c.hash, parsedOf(c));
687
+ applied = true;
688
+ },
689
+ revert: () => {
690
+ if (!applied)
691
+ return;
692
+ if (revisionOf(this.#doc) !== change.modelRevision)
693
+ throw new Error("the replica changed after the batch was applied; it cannot be reverted");
694
+ // The document as it was before the batch: its changes without the batch's.
695
+ this.#doc = this.#rebuilt(withoutBatch(A.getAllChanges(this.#doc), batch), base);
696
+ this.#seqs = priorSeqs === undefined ? undefined : new Map(priorSeqs);
697
+ this.#refs = null;
698
+ applied = false;
699
+ },
700
+ });
701
+ }
702
+ finally {
703
+ if (!handedOver)
704
+ A.free(work);
705
+ }
706
+ }
707
+ /** A document of exactly `changes`, with this replica's §2 actor, at `revision` (checked). */
708
+ #rebuilt(changes, revision) {
709
+ const doc = restoreSection(changes, this.#actor, revision);
710
+ if (A.getActorId(doc) !== this.#actor)
711
+ throw new Error("the section replica could not be restored: another actor");
712
+ return doc;
634
713
  }
635
714
  /**
636
715
  * Checks one intent against `doc`, the document with the batch so far,
@@ -182,19 +182,50 @@ export function deriveTree(doc, validation = validateSection(doc)) {
182
182
  validation,
183
183
  });
184
184
  }
185
+ const summarise = (bytes) => {
186
+ const c = A.decodeChange(bytes);
187
+ return {
188
+ hash: c.hash,
189
+ deps: c.deps,
190
+ actor: c.actor,
191
+ seq: c.seq,
192
+ ops: c.ops.map((op) => {
193
+ const o = op;
194
+ return { obj: o.obj, action: o.action, key: o.key, value: o.value };
195
+ }),
196
+ };
197
+ };
185
198
  /**
186
- * §9: the hidden nodes with content changes concurrent with a deletion of
187
- * the node or of an ancestor, from the change history. A change deletes
188
- * when the last lifecycle value it writes to an owner (a Task node's Task,
189
- * another node itself) is "deleted"; content is a node's creation, an
190
- * operation on its Text, a Task's title or status, or a move of the node
191
- * (a write to its `placement` register — node.move or node.resolve_placement,
192
- * §8). Two changes are concurrent when neither is in the other's dependencies.
193
- * Only a deletion the lifecycle register still holds counts: one a later write
194
- * to the same register depends on is superseded (deleting the node again clears
195
- * the attention a concurrent edit raised), while two concurrent deletions both
196
- * count (baseline.9).
199
+ * The decoded history of the documents read last, by their heads (a few,
200
+ * least recently used last out). A change is the same wherever it is, so a
201
+ * later revision of the same document decodes only the changes since a
202
+ * revision read before, whose heads it has: decoding the whole history on
203
+ * every new revision was linear in it, per pass (soak #11).
197
204
  */
205
+ const histories = new Map();
206
+ const HISTORIES = 16;
207
+ function historyOf(doc) {
208
+ const key = [...A.getHeads(doc)].sort().join(",");
209
+ const known = histories.get(key);
210
+ if (known !== undefined) {
211
+ histories.delete(key);
212
+ histories.set(key, known);
213
+ return known;
214
+ }
215
+ let out;
216
+ for (const [k, list] of [...histories].reverse()) {
217
+ const heads = k === "" ? [] : k.split(",");
218
+ if (A.getMissingDeps(doc, heads).length > 0)
219
+ continue;
220
+ out = [...list, ...A.getChangesSince(doc, heads).map(summarise)];
221
+ break;
222
+ }
223
+ out ??= A.getAllChanges(doc).map(summarise);
224
+ histories.set(key, out);
225
+ while (histories.size > HISTORIES)
226
+ histories.delete(histories.keys().next().value);
227
+ return out;
228
+ }
198
229
  function retainedEdits(doc, hidden, parentOf, sectionId) {
199
230
  const root = doc;
200
231
  const nodes = (root.nodes ?? {});
@@ -226,13 +257,15 @@ function retainedEdits(doc, hidden, parentOf, sectionId) {
226
257
  }
227
258
  }
228
259
  const deps = new Map();
260
+ // Each change's actor and sequence number, for its vector clock below.
261
+ const author = new Map();
229
262
  const deletes = [];
230
263
  // Every lifecycle write (any value), to tell whether a deletion is still held.
231
264
  const lifecycleWrites = [];
232
265
  const edits = [];
233
- for (const bytes of A.getAllChanges(doc)) {
234
- const change = A.decodeChange(bytes);
266
+ for (const change of historyOf(doc)) {
235
267
  deps.set(change.hash, change.deps);
268
+ author.set(change.hash, { actor: change.actor, seq: change.seq });
236
269
  const lastLifecycle = new Map();
237
270
  const touched = new Set();
238
271
  for (const op of change.ops) {
@@ -266,7 +299,51 @@ function retainedEdits(doc, hidden, parentOf, sectionId) {
266
299
  }
267
300
  if (deletes.length === 0 || edits.length === 0)
268
301
  return [];
269
- /** Every change `hash` depends on, transitively, memoized. */
302
+ /**
303
+ * Each change's vector clock: the highest sequence number of every actor
304
+ * in its causal history, itself included. When each change of an actor
305
+ * has that actor's previous change in its history (as Automerge writes
306
+ * them), `a` is in the history of `b` exactly when b's clock has a's actor
307
+ * at a's sequence or later: O(history × actors), where a set of ancestors
308
+ * per change was O(history²) (soak #11: seconds per pass after ~3,000
309
+ * changes in a section with deleted Tasks). A history where that does not
310
+ * hold (a change made without its actor's previous one), or one with more
311
+ * than 32 actors, falls back to the exact sets.
312
+ */
313
+ const clocks = new Map();
314
+ // A clock holds an entry per actor: with many actors (a hostile or unusual
315
+ // history), the exact sets are cheaper.
316
+ let regular = new Set([...author.values()].map((x) => x.actor)).size <= 32;
317
+ for (const start of regular ? deps.keys() : []) {
318
+ const stack = [start];
319
+ while (stack.length > 0) {
320
+ const h = stack[stack.length - 1];
321
+ if (clocks.has(h)) {
322
+ stack.pop();
323
+ continue;
324
+ }
325
+ const pending = (deps.get(h) ?? []).filter((d) => deps.has(d) && !clocks.has(d));
326
+ if (pending.length > 0) {
327
+ stack.push(...pending);
328
+ continue;
329
+ }
330
+ stack.pop();
331
+ const clock = new Map();
332
+ for (const d of deps.get(h) ?? [])
333
+ for (const [actor, seq] of clocks.get(d) ?? [])
334
+ if (seq > (clock.get(actor) ?? 0))
335
+ clock.set(actor, seq);
336
+ const me = author.get(h);
337
+ if (me !== undefined) {
338
+ if ((clock.get(me.actor) ?? 0) !== me.seq - 1)
339
+ regular = false;
340
+ if (me.seq > (clock.get(me.actor) ?? 0))
341
+ clock.set(me.actor, me.seq);
342
+ }
343
+ clocks.set(h, clock);
344
+ }
345
+ }
346
+ /** Every change `hash` depends on, transitively, memoized (the exact fallback). */
270
347
  const ancestry = new Map();
271
348
  const ancestorsOf = (hash) => {
272
349
  const known = ancestry.get(hash);
@@ -286,14 +363,23 @@ function retainedEdits(doc, hidden, parentOf, sectionId) {
286
363
  ancestry.set(hash, out);
287
364
  return out;
288
365
  };
289
- const concurrent = (a, b) => a !== b && !ancestorsOf(a).has(b) && !ancestorsOf(b).has(a);
366
+ /** Whether change `a` is in the causal history of change `b` (a ≠ b). */
367
+ const before = (a, b) => {
368
+ if (a === b)
369
+ return false;
370
+ if (!regular)
371
+ return ancestorsOf(b).has(a);
372
+ const me = author.get(a);
373
+ return me !== undefined && (clocks.get(b)?.get(me.actor) ?? 0) >= me.seq;
374
+ };
375
+ const concurrent = (a, b) => a !== b && !before(a, b) && !before(b, a);
290
376
  // §9 (baseline.9): a deletion counts only while the lifecycle register still
291
377
  // holds it. A later write to the same register — one that has the deletion in
292
378
  // its dependencies — supersedes it, so deleting a node again clears the
293
379
  // attention a concurrent edit raised. Two concurrent deletions supersede
294
380
  // neither, so both still count (SS86); the writes come from each change's
295
381
  // operations, which also keeps a deletion merged after a concurrent one.
296
- const heldDeletes = deletes.filter((d) => !lifecycleWrites.some((w) => w.node === d.node && w.hash !== d.hash && ancestorsOf(w.hash).has(d.hash)));
382
+ const heldDeletes = deletes.filter((d) => !lifecycleWrites.some((w) => w.node === d.node && w.hash !== d.hash && before(d.hash, w.hash)));
297
383
  const deletesOf = new Map();
298
384
  for (const d of heldDeletes)
299
385
  deletesOf.set(d.node, [...(deletesOf.get(d.node) ?? []), d.hash]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlfcp/shared-objects",
3
- "version": "0.2.0-beta.8",
3
+ "version": "0.2.0",
4
4
  "description": "SHARED-OBJECTS-PROFILE-01 (org.openlfcp.shared-objects.v1) implementation.",
5
5
  "keywords": [
6
6
  "openlfcp",
@@ -48,13 +48,13 @@
48
48
  ],
49
49
  "publishConfig": {
50
50
  "access": "public",
51
- "tag": "beta"
51
+ "tag": "latest"
52
52
  },
53
53
  "dependencies": {
54
54
  "@automerge/automerge": "3.5.0",
55
55
  "fflate": "0.8.3",
56
- "@openlfcp/core": "^0.2.0-beta.8",
57
- "@openlfcp/crypto": "^0.2.0-beta.8"
56
+ "@openlfcp/core": "^0.2.0",
57
+ "@openlfcp/crypto": "^0.2.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "fast-check": "4.10.2"