@openlfcp/shared-objects 0.2.0-beta.1 → 0.2.0-beta.10
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/admission/canonical.d.ts +7 -0
- package/dist/admission/canonical.js +34 -1
- package/dist/admission/framing.d.ts +17 -8
- package/dist/admission/framing.js +23 -12
- package/dist/admission/sequence.js +14 -0
- package/dist/concurrent.d.ts +15 -0
- package/dist/concurrent.js +55 -0
- package/dist/replica.js +12 -15
- package/dist/sections/replica.d.ts +0 -6
- package/dist/sections/replica.js +189 -111
- package/dist/sections/tree.js +132 -21
- package/package.json +3 -3
|
@@ -30,6 +30,13 @@ export interface ParsedChange {
|
|
|
30
30
|
/** Hex other actors, ascending: actor index i is otherActors[i - 1]. */
|
|
31
31
|
readonly otherActors: readonly string[];
|
|
32
32
|
readonly ops: readonly ParsedOp[];
|
|
33
|
+
/**
|
|
34
|
+
* §14.1: the extra bytes after the columns (which rule 4 leaves free) begin
|
|
35
|
+
* with an Automerge author — an unsigned LEB128 1, a length L, then at least
|
|
36
|
+
* L bytes. automerge 0.12 reads it and asserts the change's sequence number
|
|
37
|
+
* is 1, so a later change carrying one is refused (finding D5).
|
|
38
|
+
*/
|
|
39
|
+
readonly beginsWithAuthor: boolean;
|
|
33
40
|
}
|
|
34
41
|
/**
|
|
35
42
|
* §11.3: checks that `bytes` (one change chunk within the §11.1 limits) is
|
|
@@ -349,6 +349,36 @@ function checkValue(type, bytes) {
|
|
|
349
349
|
if (type === 6)
|
|
350
350
|
utf8(bytes, "a string value");
|
|
351
351
|
}
|
|
352
|
+
/**
|
|
353
|
+
* §14.1: whether the extra bytes after a change's columns begin with an
|
|
354
|
+
* Automerge author (finding D5). Rule 4 leaves these bytes free, but
|
|
355
|
+
* automerge 0.12 reads an author there — an unsigned LEB128 1, a length L,
|
|
356
|
+
* then L bytes — and asserts the change's sequence number is 1. The LEB128 is
|
|
357
|
+
* read as automerge reads it: up to ten bytes, under 2^64, in any encoding
|
|
358
|
+
* (not the shortest-form `Reader` the rest of the walk uses).
|
|
359
|
+
*/
|
|
360
|
+
function beginsWithAuthor(extra) {
|
|
361
|
+
let pos = 0;
|
|
362
|
+
const leb = () => {
|
|
363
|
+
let value = 0n;
|
|
364
|
+
let shift = 0n;
|
|
365
|
+
for (;;) {
|
|
366
|
+
if (pos >= extra.length)
|
|
367
|
+
return null;
|
|
368
|
+
const b = extra[pos++];
|
|
369
|
+
if (shift === 63n && b !== 0 && b !== 1)
|
|
370
|
+
return null;
|
|
371
|
+
value |= BigInt(b & 0x7f) << shift;
|
|
372
|
+
if ((b & 0x80) === 0)
|
|
373
|
+
return value;
|
|
374
|
+
shift += 7n;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
if (leb() !== 1n)
|
|
378
|
+
return false;
|
|
379
|
+
const length = leb();
|
|
380
|
+
return length !== null && BigInt(extra.length - pos) >= length;
|
|
381
|
+
}
|
|
352
382
|
/**
|
|
353
383
|
* §11.3: checks that `bytes` (one change chunk within the §11.1 limits) is
|
|
354
384
|
* the canonical encoding of its change, and returns the change's operations
|
|
@@ -403,7 +433,9 @@ export function checkCanonicalChange(bytes) {
|
|
|
403
433
|
const data = new Map();
|
|
404
434
|
for (const m of metas)
|
|
405
435
|
data.set(m.spec, r.take(m.length));
|
|
406
|
-
// What follows is the change's extra bytes.
|
|
436
|
+
// What follows is the change's extra bytes (§11.3 rule 4), free except that
|
|
437
|
+
// automerge reads an author from their start (§14.1, finding D5).
|
|
438
|
+
const authored = beginsWithAuthor(bytes.subarray(r.pos));
|
|
407
439
|
const rle = (spec) => {
|
|
408
440
|
const d = data.get(spec);
|
|
409
441
|
return d === undefined ? undefined : column(spec, d);
|
|
@@ -615,6 +647,7 @@ export function checkCanonicalChange(bytes) {
|
|
|
615
647
|
deps: Object.freeze(deps.map(hex)),
|
|
616
648
|
otherActors: Object.freeze(others.map(hex)),
|
|
617
649
|
ops: Object.freeze(ops),
|
|
650
|
+
beginsWithAuthor: authored,
|
|
618
651
|
});
|
|
619
652
|
}
|
|
620
653
|
//# sourceMappingURL=canonical.js.map
|
|
@@ -12,6 +12,12 @@ export interface CheckedChange {
|
|
|
12
12
|
readonly deps: readonly string[];
|
|
13
13
|
/** Hex IDs of the other actors the change refers to (§11.1: each must be known to the document). */
|
|
14
14
|
readonly otherActors: readonly string[];
|
|
15
|
+
/**
|
|
16
|
+
* §14.1: the change's extra bytes begin with an Automerge author, allowed
|
|
17
|
+
* only in an actor's first change (finding D5). See
|
|
18
|
+
* {@link ParsedChange.beginsWithAuthor}.
|
|
19
|
+
*/
|
|
20
|
+
readonly beginsWithAuthor: boolean;
|
|
15
21
|
}
|
|
16
22
|
/**
|
|
17
23
|
* Checks that `bytes` is exactly one valid Automerge change (§11, §12) and
|
|
@@ -23,15 +29,18 @@ export interface CheckedChange {
|
|
|
23
29
|
*/
|
|
24
30
|
export declare function checkChange(bytes: Uint8Array): CheckedChange;
|
|
25
31
|
/**
|
|
26
|
-
* The change hash of bytes checkChange refused, read without decoding
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
32
|
+
* The change hash of bytes checkChange refused, read without decoding them:
|
|
33
|
+
* the SHA-256 of the chunk from its type byte on (the Automerge change hash).
|
|
34
|
+
* §14.1 names a refused change by this hash whenever the bytes are one change
|
|
35
|
+
* chunk of type 1 whose length field covers them exactly and whose checksum
|
|
36
|
+
* matches — the inner header is never read, so a change above a §11.1 limit
|
|
37
|
+
* (named without being expanded) and one whose header holds a length running
|
|
38
|
+
* past its end (finding C3) are both named. Undefined when the bytes are not
|
|
39
|
+
* such a chunk: no magic bytes, another chunk type (a compressed chunk
|
|
40
|
+
* included), a length that does not cover them exactly, or a wrong checksum
|
|
41
|
+
* (whose name would be its intact copy's, finding C1).
|
|
33
42
|
*/
|
|
34
|
-
export declare function refusedChangeHash(bytes: Uint8Array
|
|
43
|
+
export declare function refusedChangeHash(bytes: Uint8Array): string | undefined;
|
|
35
44
|
/** The change as its canonical bytes give it (§11.3), reusing the walk of checkChange. */
|
|
36
45
|
export declare function parsedOf(change: CheckedChange): ParsedChange;
|
|
37
46
|
/** The decoded change, reusing the decode of checkChange when there was one. */
|
|
@@ -3,7 +3,7 @@ import { LfcpError, toHex } from "@openlfcp/core";
|
|
|
3
3
|
import { sha256 } from "@openlfcp/crypto";
|
|
4
4
|
import { ProfileInvalidError } from "../profile-invalid.js";
|
|
5
5
|
import { checkCanonicalChange } from "./canonical.js";
|
|
6
|
-
import { checkChangeExpansion
|
|
6
|
+
import { checkChangeExpansion } from "./limits.js";
|
|
7
7
|
/**
|
|
8
8
|
* Automerge byte checks of the SHARED-OBJECTS-PROFILE-01 payloads (LFCP-031):
|
|
9
9
|
* a Data Unit plaintext carries exactly one Automerge change (§11, §12), a
|
|
@@ -63,23 +63,25 @@ export function checkChange(bytes) {
|
|
|
63
63
|
seq: decoded.seq,
|
|
64
64
|
deps: Object.freeze([...decoded.deps]),
|
|
65
65
|
otherActors: expansion.otherActors,
|
|
66
|
+
beginsWithAuthor: parsed.beginsWithAuthor,
|
|
66
67
|
});
|
|
67
68
|
DECODED.set(checked, decoded);
|
|
68
69
|
PARSED.set(checked, parsed);
|
|
69
70
|
return checked;
|
|
70
71
|
}
|
|
71
72
|
/**
|
|
72
|
-
* The change hash of bytes checkChange refused, read without decoding
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
73
|
+
* The change hash of bytes checkChange refused, read without decoding them:
|
|
74
|
+
* the SHA-256 of the chunk from its type byte on (the Automerge change hash).
|
|
75
|
+
* §14.1 names a refused change by this hash whenever the bytes are one change
|
|
76
|
+
* chunk of type 1 whose length field covers them exactly and whose checksum
|
|
77
|
+
* matches — the inner header is never read, so a change above a §11.1 limit
|
|
78
|
+
* (named without being expanded) and one whose header holds a length running
|
|
79
|
+
* past its end (finding C3) are both named. Undefined when the bytes are not
|
|
80
|
+
* such a chunk: no magic bytes, another chunk type (a compressed chunk
|
|
81
|
+
* included), a length that does not cover them exactly, or a wrong checksum
|
|
82
|
+
* (whose name would be its intact copy's, finding C1).
|
|
79
83
|
*/
|
|
80
|
-
export function refusedChangeHash(bytes
|
|
81
|
-
if (error instanceof MalformedChunkError)
|
|
82
|
-
return undefined;
|
|
84
|
+
export function refusedChangeHash(bytes) {
|
|
83
85
|
if (bytes.length < HEADER || MAGIC.some((b, i) => bytes[i] !== b))
|
|
84
86
|
return undefined;
|
|
85
87
|
if (bytes[8] !== CHUNK_CHANGE)
|
|
@@ -99,7 +101,16 @@ export function refusedChangeHash(bytes, error) {
|
|
|
99
101
|
}
|
|
100
102
|
if (pos + length !== bytes.length)
|
|
101
103
|
return undefined;
|
|
102
|
-
|
|
104
|
+
// §14.1 (baseline.7, finding C1): name the bytes only when their checksum
|
|
105
|
+
// matches. The name is the SHA-256 of the chunk from its type byte on, and
|
|
106
|
+
// the checksum (bytes 4 to 8) is the first four bytes of that same hash; a
|
|
107
|
+
// chunk with a wrong checksum would be named by its intact copy's hash, so
|
|
108
|
+
// it is left unnamed and the intact copy is checked as any change is.
|
|
109
|
+
const hash = sha256(bytes.subarray(8));
|
|
110
|
+
for (let i = 0; i < 4; i++)
|
|
111
|
+
if (bytes[4 + i] !== hash[i])
|
|
112
|
+
return undefined;
|
|
113
|
+
return toHex(hash);
|
|
103
114
|
}
|
|
104
115
|
/** The operations checkChange read from the bytes (§11.3), for the references of §11.4. */
|
|
105
116
|
const PARSED = new WeakMap();
|
|
@@ -69,6 +69,14 @@ export function admitBatch(changes, doc, accept = () => undefined) {
|
|
|
69
69
|
});
|
|
70
70
|
continue;
|
|
71
71
|
}
|
|
72
|
+
else if (c.beginsWithAuthor && c.seq !== 1) {
|
|
73
|
+
refused.push({
|
|
74
|
+
change: c,
|
|
75
|
+
error: new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `the change's extra bytes begin with an author but its sequence number is ${c.seq}, not 1 (§14.1)`),
|
|
76
|
+
held: false,
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
72
80
|
else {
|
|
73
81
|
const references = referenceRefusal(c, doc);
|
|
74
82
|
if (references !== null) {
|
|
@@ -124,6 +132,12 @@ export function admitChange(change, doc) {
|
|
|
124
132
|
kind: "invalid",
|
|
125
133
|
error: new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `actor ${change.actor} sequence ${change.seq} skips sequence ${latest + 1} (§14.1)`),
|
|
126
134
|
};
|
|
135
|
+
// §14.1: an author belongs only in an actor's first change (finding D5).
|
|
136
|
+
if (change.beginsWithAuthor && change.seq !== 1)
|
|
137
|
+
return {
|
|
138
|
+
kind: "invalid",
|
|
139
|
+
error: new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `the change's extra bytes begin with an author but its sequence number is ${change.seq}, not 1 (§14.1)`),
|
|
140
|
+
};
|
|
127
141
|
// §11.4: checked only; the caller adds the change once the engine applied it.
|
|
128
142
|
const references = referenceRefusal(change, doc);
|
|
129
143
|
if (references !== null)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as A from "@automerge/automerge";
|
|
2
|
+
type AMap = Record<string, unknown>;
|
|
3
|
+
/**
|
|
4
|
+
* The distinct concurrent values of `map[key]` in `doc` (SOP §45,
|
|
5
|
+
* mvp-0.2-baseline.12): none when absent, one when it is not conflicted or
|
|
6
|
+
* when every concurrent value is equal. Two values are equal when they have
|
|
7
|
+
* the same Automerge scalar type and the same value (strings code point by
|
|
8
|
+
* code point); an object (a map, a list or Text) equals no other value. In
|
|
9
|
+
* Automerge's order of the concurrent values, the first of each equal group.
|
|
10
|
+
*/
|
|
11
|
+
export declare function distinctValues(doc: A.Doc<unknown>, map: AMap | undefined, key: string): unknown[];
|
|
12
|
+
/** Whether `map[key]` has two or more distinct concurrent values (a conflict, SOP §45). */
|
|
13
|
+
export declare function isConflicted(doc: A.Doc<unknown>, map: AMap, key: string): boolean;
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=concurrent.d.ts.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as A from "@automerge/automerge";
|
|
2
|
+
/** Automerge object types: an object value equals no other value (SOP §45). */
|
|
3
|
+
const OBJECTS = new Set(["map", "list", "text", "table"]);
|
|
4
|
+
/** A scalar's identity for equality: its Automerge type and its value. */
|
|
5
|
+
function scalarKey(type, value) {
|
|
6
|
+
if (value instanceof Uint8Array)
|
|
7
|
+
return `${type}:${Array.from(value, (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
8
|
+
if (value instanceof Date)
|
|
9
|
+
return `${type}:${value.getTime()}`;
|
|
10
|
+
if (typeof value === "bigint")
|
|
11
|
+
return `${type}:${value}`;
|
|
12
|
+
return `${type}:${JSON.stringify(value)}`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The distinct concurrent values of `map[key]` in `doc` (SOP §45,
|
|
16
|
+
* mvp-0.2-baseline.12): none when absent, one when it is not conflicted or
|
|
17
|
+
* when every concurrent value is equal. Two values are equal when they have
|
|
18
|
+
* the same Automerge scalar type and the same value (strings code point by
|
|
19
|
+
* code point); an object (a map, a list or Text) equals no other value. In
|
|
20
|
+
* Automerge's order of the concurrent values, the first of each equal group.
|
|
21
|
+
*/
|
|
22
|
+
export function distinctValues(doc, map, key) {
|
|
23
|
+
if (map === undefined || !(key in map))
|
|
24
|
+
return [];
|
|
25
|
+
const conflicts = A.getConflicts(map, key);
|
|
26
|
+
if (conflicts === undefined)
|
|
27
|
+
return [map[key]];
|
|
28
|
+
const obj = A.getObjectId(map);
|
|
29
|
+
const all = obj === null ? [] : A.getBackend(doc).getAll(obj, key, A.getHeads(doc));
|
|
30
|
+
const typeOf = new Map();
|
|
31
|
+
for (const e of all) {
|
|
32
|
+
const type = e[0];
|
|
33
|
+
if (OBJECTS.has(type))
|
|
34
|
+
typeOf.set(e[1], { type, value: undefined });
|
|
35
|
+
else
|
|
36
|
+
typeOf.set(e[2], { type, value: e[1] });
|
|
37
|
+
}
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const [opId, value] of Object.entries(conflicts)) {
|
|
41
|
+
const t = typeOf.get(opId);
|
|
42
|
+
const k = t === undefined || OBJECTS.has(t.type) ? `object:${opId}` : scalarKey(t.type, t.value);
|
|
43
|
+
if (seen.has(k))
|
|
44
|
+
continue;
|
|
45
|
+
seen.add(k);
|
|
46
|
+
out.push(value);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
/** Whether `map[key]` has two or more distinct concurrent values (a conflict, SOP §45). */
|
|
51
|
+
export function isConflicted(doc, map, key) {
|
|
52
|
+
return (A.getConflicts(map, key) !== undefined &&
|
|
53
|
+
distinctValues(doc, map, key).length > 1);
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=concurrent.js.map
|
package/dist/replica.js
CHANGED
|
@@ -4,6 +4,7 @@ import { checkChange, checkSaveHeader, parsedOf, unframeChange, unframeSnapshot,
|
|
|
4
4
|
import { checkSnapshotExpansion, MAX_DOCUMENT_DEPTH, SNAPSHOT_LIMITS_FLOOR, } from "./admission/limits.js";
|
|
5
5
|
import { referenceHistoryOf } from "./admission/references.js";
|
|
6
6
|
import { admitBatch, admitChange } from "./admission/sequence.js";
|
|
7
|
+
import { distinctValues, isConflicted } from "./concurrent.js";
|
|
7
8
|
/** The decoded actions that create an object (§11.2). */
|
|
8
9
|
const MAKE_ACTIONS = new Set(["makeMap", "makeList", "makeText", "makeTable"]);
|
|
9
10
|
import { ProfileInvalidError } from "./profile-invalid.js";
|
|
@@ -153,17 +154,13 @@ function canonical(value) {
|
|
|
153
154
|
.map((k) => [k, canonical(value[k])]));
|
|
154
155
|
return value;
|
|
155
156
|
}
|
|
156
|
-
/**
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return conflicts === undefined ? [map[key]] : Object.values(conflicts);
|
|
162
|
-
}
|
|
163
|
-
/** Top-level fields of `object` with more than one concurrent value. */
|
|
164
|
-
function conflictedFields(object) {
|
|
157
|
+
/**
|
|
158
|
+
* Top-level fields of `object` in conflict: two or more distinct concurrent
|
|
159
|
+
* values (§45; concurrent equal values agree).
|
|
160
|
+
*/
|
|
161
|
+
function conflictedFields(doc, object) {
|
|
165
162
|
return Object.keys(object)
|
|
166
|
-
.filter((f) =>
|
|
163
|
+
.filter((f) => isConflicted(doc, object, f))
|
|
167
164
|
.sort();
|
|
168
165
|
}
|
|
169
166
|
const problem = (diagnostic, pointer, message) => Object.freeze({ code: "PROFILE_INVALID", diagnostic, pointer, message });
|
|
@@ -236,7 +233,7 @@ export function taskView(doc, objects, id) {
|
|
|
236
233
|
const problems = storedObjectProblems(doc, object, id);
|
|
237
234
|
const parsed = parseTask(plain(object), id);
|
|
238
235
|
const field = (f) => {
|
|
239
|
-
const values =
|
|
236
|
+
const values = distinctValues(doc, object, f).map(plain).sort(byJson);
|
|
240
237
|
return Object.freeze({
|
|
241
238
|
value: f in object ? plain(object[f]) : undefined,
|
|
242
239
|
values: Object.freeze(values),
|
|
@@ -525,7 +522,7 @@ export class SharedObjectsReplica {
|
|
|
525
522
|
this.#noteSeq(meta.actor, meta.seq);
|
|
526
523
|
for (const [id, object] of Object.entries(this.#objects() ?? {}))
|
|
527
524
|
if (isMap(object))
|
|
528
|
-
this.#conflicted.set(id, conflictedFields(object));
|
|
525
|
+
this.#conflicted.set(id, conflictedFields(doc, object));
|
|
529
526
|
}
|
|
530
527
|
/**
|
|
531
528
|
* §11.2: the depths of the objects `change` creates (object ID → depth),
|
|
@@ -749,9 +746,9 @@ export class SharedObjectsReplica {
|
|
|
749
746
|
for (const [id, object] of Object.entries(objects)) {
|
|
750
747
|
if (!isMap(object))
|
|
751
748
|
continue;
|
|
752
|
-
for (const field of conflictedFields(object)) {
|
|
749
|
+
for (const field of conflictedFields(this.#doc, object)) {
|
|
753
750
|
out[id] ??= {};
|
|
754
|
-
out[id][field] =
|
|
751
|
+
out[id][field] = distinctValues(this.#doc, object, field)
|
|
755
752
|
.map(plain)
|
|
756
753
|
.sort(byJson);
|
|
757
754
|
}
|
|
@@ -1008,7 +1005,7 @@ export class SharedObjectsReplica {
|
|
|
1008
1005
|
if (set.size === 0)
|
|
1009
1006
|
for (const f of Object.keys(now))
|
|
1010
1007
|
set.add(f);
|
|
1011
|
-
const nowConflicts = conflictedFields(now);
|
|
1008
|
+
const nowConflicts = conflictedFields(this.#doc, now);
|
|
1012
1009
|
const wasConflicts = this.#conflicted.get(id) ?? [];
|
|
1013
1010
|
this.#conflicted.set(id, nowConflicts);
|
|
1014
1011
|
out.push(Object.freeze({
|
|
@@ -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
|
@@ -4,6 +4,7 @@ import { checkChange, checkSaveHeader, parsedOf, refusedChangeHash, } from "../a
|
|
|
4
4
|
import { checkChangeExpansion, checkSnapshotExpansion, SNAPSHOT_LIMITS_FLOOR, } from "../admission/limits.js";
|
|
5
5
|
import { referenceHistoryOf } from "../admission/references.js";
|
|
6
6
|
import { admitBatch } from "../admission/sequence.js";
|
|
7
|
+
import { distinctValues } from "../concurrent.js";
|
|
7
8
|
import { ProfileInvalidError } from "../profile-invalid.js";
|
|
8
9
|
import { isWasmTrap, prepareTaskIntent, taskView, } from "../replica.js";
|
|
9
10
|
import { frameProfilePayload, isUtcTimestamp, principalRef } from "../values.js";
|
|
@@ -99,6 +100,8 @@ const S = (s) => new A.ImmutableString(s);
|
|
|
99
100
|
const str = (v) => A.isImmutableString(v) ? v.toString() : typeof v === "string" ? v : undefined;
|
|
100
101
|
const isListStyle = (s) => typeof s === "string" && LIST_STYLES.includes(s);
|
|
101
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));
|
|
102
105
|
/** Rebuilds a section document from the changes it held before an aborted apply. */
|
|
103
106
|
function restoreSection(changes, actor, revision) {
|
|
104
107
|
let restored;
|
|
@@ -138,12 +141,9 @@ function applyIsolating(doc, changes) {
|
|
|
138
141
|
}
|
|
139
142
|
return { doc: work, applied, poisoned };
|
|
140
143
|
}
|
|
141
|
-
/**
|
|
142
|
-
function values(map, key) {
|
|
143
|
-
|
|
144
|
-
return [];
|
|
145
|
-
const c = A.getConflicts(map, key);
|
|
146
|
-
return c === undefined ? [map[key]] : Object.values(c);
|
|
144
|
+
/** The distinct concurrent values of `map[key]` in `doc` (SOP §45: equal values agree). */
|
|
145
|
+
function values(doc, map, key) {
|
|
146
|
+
return distinctValues(doc, map, key);
|
|
147
147
|
}
|
|
148
148
|
/** PROFILE_INVALID for a Snapshot, with the diagnostic of what refused it. */
|
|
149
149
|
function snapshotRefused(diagnostic, message) {
|
|
@@ -292,7 +292,7 @@ export class SectionReplica {
|
|
|
292
292
|
}
|
|
293
293
|
catch (e) {
|
|
294
294
|
// Never decoded: a change refused by the checks is named from its bytes only.
|
|
295
|
-
refused.push(refusal(index, refusedChangeHash(unit.bytes
|
|
295
|
+
refused.push(refusal(index, refusedChangeHash(unit.bytes), e, false));
|
|
296
296
|
return;
|
|
297
297
|
}
|
|
298
298
|
if (unit.signer !== undefined &&
|
|
@@ -393,15 +393,36 @@ 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 ?? {});
|
|
401
422
|
const objects = (root.objects ?? {});
|
|
402
423
|
const placements = (root.placements ?? {});
|
|
403
424
|
const hidden = new Set(tree.hidden);
|
|
404
|
-
const titles = values(section, "title").map(str);
|
|
425
|
+
const titles = values(this.#doc, section, "title").map(str);
|
|
405
426
|
const out = {};
|
|
406
427
|
for (const id of Object.keys(nodes).sort()) {
|
|
407
428
|
if (tree.collisions.includes(id))
|
|
@@ -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() {
|
|
@@ -519,6 +540,9 @@ export class SectionReplica {
|
|
|
519
540
|
if (A.getActorId(this.#doc) !== this.#actor)
|
|
520
541
|
throw new Error("the document actor is not the §2 actor");
|
|
521
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).
|
|
522
546
|
let scratch = A.clone(this.#doc);
|
|
523
547
|
// The validation of the document before the batch: nodes the batch
|
|
524
548
|
// creates are valid by construction, so it is computed again only when
|
|
@@ -533,106 +557,159 @@ export class SectionReplica {
|
|
|
533
557
|
const chunks = [[]];
|
|
534
558
|
const used = { text: 0, nodes: 0 };
|
|
535
559
|
let i = -1;
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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();
|
|
555
588
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
// Only creations keep every verified ancestor chain as it was.
|
|
560
|
-
if (!KEEPS_CHAINS.has(step.intent))
|
|
561
|
-
state.chainOk.clear();
|
|
589
|
+
}
|
|
590
|
+
finally {
|
|
591
|
+
A.free(scratch);
|
|
562
592
|
}
|
|
563
593
|
const message = intents.map((x) => x.intent).join(",");
|
|
564
594
|
const before = A.getHeads(this.#doc);
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
+
}));
|
|
588
632
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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)
|
|
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)
|
|
627
650
|
return;
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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;
|
|
636
713
|
}
|
|
637
714
|
/**
|
|
638
715
|
* Checks one intent against `doc`, the document with the batch so far,
|
|
@@ -844,7 +921,8 @@ export class SectionReplica {
|
|
|
844
921
|
refuse("NODE_IN_CONFLICT", `${second} is invalid or collides (§14.2)`, second);
|
|
845
922
|
const otherKind = str(other.kind);
|
|
846
923
|
const sameStyle = kind === "paragraph" ||
|
|
847
|
-
values(node, "list_style").join() ===
|
|
924
|
+
values(doc, node, "list_style").join() ===
|
|
925
|
+
values(doc, other, "list_style").join();
|
|
848
926
|
if (otherKind !== kind || (kind !== "paragraph" && kind !== "item") || !sameStyle)
|
|
849
927
|
refuse("INVALID_INTENT", "only two paragraphs, or two items of one list style, join (§10)", id);
|
|
850
928
|
const kids = (n) => (n.children ?? []).length;
|
|
@@ -871,8 +949,8 @@ export class SectionReplica {
|
|
|
871
949
|
// split: a deletion of the suffix and a new node after this one.
|
|
872
950
|
if (intent.intent.slice(0, intent.intent.indexOf(".")) !== kind)
|
|
873
951
|
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)
|
|
952
|
+
if (values(doc, node, "placement").length !== 1 ||
|
|
953
|
+
new Set(values(doc, node, "lifecycle")).size !== 1)
|
|
876
954
|
refuse("NODE_IN_CONFLICT", `${id} has unresolved structure (§10)`, id);
|
|
877
955
|
const cut = rebase(doc, path, intent.base, [{ index: intent.at, deleteCount: 0, insert: "" }], refuse, id)[0];
|
|
878
956
|
const suffix = current.slice(cut.from);
|
|
@@ -939,7 +1017,7 @@ export class SectionReplica {
|
|
|
939
1017
|
n.list_style = S(style);
|
|
940
1018
|
};
|
|
941
1019
|
}
|
|
942
|
-
if (intent.intent === "node.move" && values(node, "placement").length !== 1)
|
|
1020
|
+
if (intent.intent === "node.move" && values(doc, node, "placement").length !== 1)
|
|
943
1021
|
refuse("NODE_IN_CONFLICT", `${id} has concurrent placements: use node.resolve_placement (§7, §8)`, id);
|
|
944
1022
|
const placementId = intent.placementId ?? generateObjectId();
|
|
945
1023
|
if (!isObjectId(placementId))
|
|
@@ -1086,12 +1164,12 @@ chainOk) {
|
|
|
1086
1164
|
return { why: "is invalid (§14.2)" };
|
|
1087
1165
|
const kind = str(node.kind);
|
|
1088
1166
|
const owner = kind === "task" ? objects[id] : node;
|
|
1089
|
-
const life = values(owner, "lifecycle").map(str);
|
|
1167
|
+
const life = values(doc, owner, "lifecycle").map(str);
|
|
1090
1168
|
if (life.length !== 1)
|
|
1091
1169
|
return { why: "has a lifecycle conflict (§9)" };
|
|
1092
1170
|
if (life[0] !== "active")
|
|
1093
1171
|
return { why: "is deleted (§9)" };
|
|
1094
|
-
const selected = values(node, "placement").map(str);
|
|
1172
|
+
const selected = values(doc, node, "placement").map(str);
|
|
1095
1173
|
if (selected.length !== 1)
|
|
1096
1174
|
return { why: "has a placement conflict (§7)" };
|
|
1097
1175
|
const placement = placements[selected[0]];
|
|
@@ -1131,7 +1209,7 @@ chainOk) {
|
|
|
1131
1209
|
const e = eligible(after);
|
|
1132
1210
|
if ("why" in e || e.parent !== parent)
|
|
1133
1211
|
refuse("INVALID_PREDECESSOR", `${after} is not a visible child of ${parent} (§6)`, after);
|
|
1134
|
-
const slot = str(values(nodes[after], "placement")[0]);
|
|
1212
|
+
const slot = str(values(doc, nodes[after], "placement")[0]);
|
|
1135
1213
|
const at = lane.findIndex((x) => str(x) === slot);
|
|
1136
1214
|
if (at < 0)
|
|
1137
1215
|
refuse("INVALID_PREDECESSOR", `${after}'s placement is not in the parent's list (§4.3)`, after);
|
package/dist/sections/tree.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import * as A from "@automerge/automerge";
|
|
2
|
+
import { distinctValues } from "../concurrent.js";
|
|
2
3
|
import { validateSection } from "./schema.js";
|
|
3
4
|
/** The Task scalar fields whose concurrent values tree() reports. */
|
|
4
5
|
const CONFLICT_FIELDS = ["title", "status", "lifecycle", "due", "priority"];
|
|
5
6
|
const str = (v) => A.isImmutableString(v) ? v.toString() : typeof v === "string" ? v : undefined;
|
|
6
|
-
/**
|
|
7
|
-
function values(map, key) {
|
|
8
|
-
|
|
9
|
-
return [];
|
|
10
|
-
const c = A.getConflicts(map, key);
|
|
11
|
-
return (c === undefined ? [map[key]] : Object.values(c)).map(str);
|
|
7
|
+
/** The distinct concurrent values of `map[key]` in `doc`, as strings (SOP §45: equal values agree). */
|
|
8
|
+
function values(doc, map, key) {
|
|
9
|
+
return distinctValues(doc, map, key).map(str);
|
|
12
10
|
}
|
|
13
11
|
const byId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
14
12
|
/** §7: the effective tree and structural facts of `doc`. Reads only. */
|
|
@@ -32,13 +30,13 @@ export function deriveTree(doc, validation = validateSection(doc)) {
|
|
|
32
30
|
// §7.6: a lifecycle conflict has no winner. It blocks the branch (below)
|
|
33
31
|
// instead of the provisional value hiding it: the node is not deleted,
|
|
34
32
|
// and edits under it are not under a deleted ancestor.
|
|
35
|
-
const lifecycles = new Set(values(owner(id), "lifecycle"));
|
|
33
|
+
const lifecycles = new Set(values(doc, owner(id), "lifecycle"));
|
|
36
34
|
if (lifecycles.size === 1 && lifecycles.has("deleted"))
|
|
37
35
|
deleted.add(id);
|
|
38
36
|
if (collided.has(id))
|
|
39
37
|
continue;
|
|
40
38
|
const n = node(id);
|
|
41
|
-
const selected = values(n, "placement");
|
|
39
|
+
const selected = values(doc, n, "placement");
|
|
42
40
|
const parentOf = (p) => p === undefined ? undefined : str(placements[p]?.parent_id);
|
|
43
41
|
// The default value selects the parent used for hiding and for the walk below.
|
|
44
42
|
const parent = parentOf(str(n.placement));
|
|
@@ -177,21 +175,57 @@ export function deriveTree(doc, validation = validateSection(doc)) {
|
|
|
177
175
|
collisions: validation.collisions,
|
|
178
176
|
retainedConcurrentEdits: Object.freeze(retained),
|
|
179
177
|
scalarConflicts: Object.freeze(Object.keys(objects).flatMap((id) => CONFLICT_FIELDS.flatMap((field) => {
|
|
180
|
-
const vs = values(objects[id], field);
|
|
178
|
+
const vs = values(doc, objects[id], field);
|
|
181
179
|
return vs.length > 1 ? [{ id, field, values: vs.map((v) => v ?? "").sort() }] : [];
|
|
182
180
|
}))),
|
|
183
181
|
candidates,
|
|
184
182
|
validation,
|
|
185
183
|
});
|
|
186
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
|
+
};
|
|
187
198
|
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* concurrent when neither is in the other's dependencies.
|
|
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).
|
|
194
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
|
+
}
|
|
195
229
|
function retainedEdits(doc, hidden, parentOf, sectionId) {
|
|
196
230
|
const root = doc;
|
|
197
231
|
const nodes = (root.nodes ?? {});
|
|
@@ -201,8 +235,13 @@ function retainedEdits(doc, hidden, parentOf, sectionId) {
|
|
|
201
235
|
const textOf = new Map();
|
|
202
236
|
const ownerOf = new Map();
|
|
203
237
|
const taskOf = new Map();
|
|
238
|
+
// The object ID of each node's own map, for a move written to its `placement`.
|
|
239
|
+
const nodeMapOf = new Map();
|
|
204
240
|
for (const id of Object.keys(nodes)) {
|
|
205
241
|
const n = nodes[id];
|
|
242
|
+
const nodeMap = A.getObjectId(n);
|
|
243
|
+
if (nodeMap !== null)
|
|
244
|
+
nodeMapOf.set(nodeMap, id);
|
|
206
245
|
const text = A.getObjectId(n, "text");
|
|
207
246
|
if (text !== null)
|
|
208
247
|
textOf.set(text, id);
|
|
@@ -218,11 +257,15 @@ function retainedEdits(doc, hidden, parentOf, sectionId) {
|
|
|
218
257
|
}
|
|
219
258
|
}
|
|
220
259
|
const deps = new Map();
|
|
260
|
+
// Each change's actor and sequence number, for its vector clock below.
|
|
261
|
+
const author = new Map();
|
|
221
262
|
const deletes = [];
|
|
263
|
+
// Every lifecycle write (any value), to tell whether a deletion is still held.
|
|
264
|
+
const lifecycleWrites = [];
|
|
222
265
|
const edits = [];
|
|
223
|
-
for (const
|
|
224
|
-
const change = A.decodeChange(bytes);
|
|
266
|
+
for (const change of historyOf(doc)) {
|
|
225
267
|
deps.set(change.hash, change.deps);
|
|
268
|
+
author.set(change.hash, { actor: change.actor, seq: change.seq });
|
|
226
269
|
const lastLifecycle = new Map();
|
|
227
270
|
const touched = new Set();
|
|
228
271
|
for (const op of change.ops) {
|
|
@@ -236,19 +279,71 @@ function retainedEdits(doc, hidden, parentOf, sectionId) {
|
|
|
236
279
|
const task = taskOf.get(op.obj);
|
|
237
280
|
if (task !== undefined && hidden.has(task) && (key === "title" || key === "status"))
|
|
238
281
|
touched.add(task);
|
|
282
|
+
// §8, §9: a move writes the node's `placement` register (node.move or
|
|
283
|
+
// node.resolve_placement). A creation also writes it and is already
|
|
284
|
+
// counted above; what this adds is a move of an existing node.
|
|
285
|
+
const moved = nodeMapOf.get(op.obj);
|
|
286
|
+
if (moved !== undefined && key === "placement" && hidden.has(moved))
|
|
287
|
+
touched.add(moved);
|
|
239
288
|
const owner = ownerOf.get(op.obj);
|
|
240
289
|
if (owner !== undefined && key === "lifecycle")
|
|
241
290
|
lastLifecycle.set(owner, value);
|
|
242
291
|
}
|
|
243
|
-
for (const [owner, value] of lastLifecycle)
|
|
292
|
+
for (const [owner, value] of lastLifecycle) {
|
|
293
|
+
lifecycleWrites.push({ hash: change.hash, node: owner });
|
|
244
294
|
if (value === "deleted")
|
|
245
295
|
deletes.push({ hash: change.hash, node: owner });
|
|
296
|
+
}
|
|
246
297
|
for (const node of touched)
|
|
247
298
|
edits.push({ hash: change.hash, node });
|
|
248
299
|
}
|
|
249
300
|
if (deletes.length === 0 || edits.length === 0)
|
|
250
301
|
return [];
|
|
251
|
-
/**
|
|
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). */
|
|
252
347
|
const ancestry = new Map();
|
|
253
348
|
const ancestorsOf = (hash) => {
|
|
254
349
|
const known = ancestry.get(hash);
|
|
@@ -268,9 +363,25 @@ function retainedEdits(doc, hidden, parentOf, sectionId) {
|
|
|
268
363
|
ancestry.set(hash, out);
|
|
269
364
|
return out;
|
|
270
365
|
};
|
|
271
|
-
|
|
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);
|
|
376
|
+
// §9 (baseline.9): a deletion counts only while the lifecycle register still
|
|
377
|
+
// holds it. A later write to the same register — one that has the deletion in
|
|
378
|
+
// its dependencies — supersedes it, so deleting a node again clears the
|
|
379
|
+
// attention a concurrent edit raised. Two concurrent deletions supersede
|
|
380
|
+
// neither, so both still count (SS86); the writes come from each change's
|
|
381
|
+
// operations, which also keeps a deletion merged after a concurrent one.
|
|
382
|
+
const heldDeletes = deletes.filter((d) => !lifecycleWrites.some((w) => w.node === d.node && w.hash !== d.hash && before(d.hash, w.hash)));
|
|
272
383
|
const deletesOf = new Map();
|
|
273
|
-
for (const d of
|
|
384
|
+
for (const d of heldDeletes)
|
|
274
385
|
deletesOf.set(d.node, [...(deletesOf.get(d.node) ?? []), d.hash]);
|
|
275
386
|
const attention = new Set();
|
|
276
387
|
for (const edit of edits) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlfcp/shared-objects",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.10",
|
|
4
4
|
"description": "SHARED-OBJECTS-PROFILE-01 (org.openlfcp.shared-objects.v1) implementation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openlfcp",
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@automerge/automerge": "3.5.0",
|
|
55
55
|
"fflate": "0.8.3",
|
|
56
|
-
"@openlfcp/
|
|
57
|
-
"@openlfcp/
|
|
56
|
+
"@openlfcp/crypto": "^0.2.0-beta.10",
|
|
57
|
+
"@openlfcp/core": "^0.2.0-beta.10"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"fast-check": "4.10.2"
|