@openlfcp/shared-objects 0.2.0-beta.9 → 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 +23 -2
- 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
|
@@ -393,8 +393,29 @@ export class SectionReplica {
|
|
|
393
393
|
* and the problems to show. `revision` is the modelRevision of a commit
|
|
394
394
|
* receipt and the `base` of text.edit.
|
|
395
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
|
+
}
|
|
396
411
|
snapshot() {
|
|
397
|
-
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) {
|
|
398
419
|
const root = this.#doc;
|
|
399
420
|
const section = (root.section ?? {});
|
|
400
421
|
const nodes = (root.nodes ?? {});
|
|
@@ -456,7 +477,7 @@ export class SectionReplica {
|
|
|
456
477
|
}
|
|
457
478
|
/** §7, §9, §14.3: the effective tree, hidden nodes and structural facts. */
|
|
458
479
|
tree() {
|
|
459
|
-
return
|
|
480
|
+
return this.#derivedNow().tree;
|
|
460
481
|
}
|
|
461
482
|
/** The document heads, sorted, as one string. */
|
|
462
483
|
revision() {
|
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"
|