@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.
- package/dist/sections/replica.d.ts +0 -6
- package/dist/sections/replica.js +175 -96
- package/dist/sections/tree.js +102 -16
- package/package.json +4 -4
|
@@ -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
|
package/dist/sections/replica.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
//
|
|
564
|
-
//
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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,
|
package/dist/sections/tree.js
CHANGED
|
@@ -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
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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": "
|
|
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
|
|
57
|
-
"@openlfcp/crypto": "^0.2.0
|
|
56
|
+
"@openlfcp/core": "^0.2.0",
|
|
57
|
+
"@openlfcp/crypto": "^0.2.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"fast-check": "4.10.2"
|