@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.
@@ -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
@@ -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 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) {
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 deriveTree(this.#doc);
480
+ return this.#derivedNow().tree;
460
481
  }
461
482
  /** The document heads, sorted, as one string. */
462
483
  revision() {
@@ -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.9",
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.9",
57
- "@openlfcp/crypto": "^0.2.0-beta.9"
56
+ "@openlfcp/core": "^0.2.0",
57
+ "@openlfcp/crypto": "^0.2.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "fast-check": "4.10.2"