@openlfcp/shared-objects 0.1.3 → 0.1.5
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/README.md +2 -2
- package/dist/automerge-bytes.d.ts +3 -0
- package/dist/automerge-bytes.js +17 -1
- package/dist/canonical.d.ts +38 -0
- package/dist/canonical.js +613 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/references.d.ts +28 -0
- package/dist/references.js +225 -0
- package/dist/replica.d.ts +6 -0
- package/dist/replica.js +59 -28
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -20,9 +20,9 @@ reaches the replica (§95). The replica has no Markdown or editor concepts.
|
|
|
20
20
|
|
|
21
21
|
Part of the OpenLFCP TypeScript SDK, which implements the OpenLFCP MVP 0.1
|
|
22
22
|
subset of [LFCP-WIRE-01](https://github.com/openlfcp/spec/blob/main/wire/LFCP-WIRE-01.md)
|
|
23
|
-
at `mvp-0.1-baseline.
|
|
23
|
+
at `mvp-0.1-baseline.10`, not every deferred WIRE-01 feature. It does not
|
|
24
24
|
claim full LFCP-WIRE-01 conformance. This is the MVP 0.1 release
|
|
25
|
-
(`0.1.
|
|
25
|
+
(`0.1.4`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
|
|
26
26
|
|
|
27
27
|
## Automerge and WebAssembly
|
|
28
28
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ParsedChange } from "./canonical.js";
|
|
1
2
|
/** A checked Automerge change: its bytes and decoded header. */
|
|
2
3
|
export interface CheckedChange {
|
|
3
4
|
readonly bytes: Uint8Array;
|
|
@@ -20,6 +21,8 @@ export interface CheckedChange {
|
|
|
20
21
|
* a change without verifying its checksum, so the check is done here.
|
|
21
22
|
*/
|
|
22
23
|
export declare function checkChange(bytes: Uint8Array): CheckedChange;
|
|
24
|
+
/** The change as its canonical bytes give it (§11.3), reusing the walk of checkChange. */
|
|
25
|
+
export declare function parsedOf(change: CheckedChange): ParsedChange;
|
|
23
26
|
/**
|
|
24
27
|
* Checks that `bytes` starts as an Automerge full save (a document chunk,
|
|
25
28
|
* §13). Whether the whole image loads, checksums included, is decided by
|
package/dist/automerge-bytes.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as A from "@automerge/automerge";
|
|
2
2
|
import { toHex } from "@openlfcp/core";
|
|
3
|
+
import { checkCanonicalChange } from "./canonical.js";
|
|
3
4
|
import { checkChangeExpansion } from "./chunk-limits.js";
|
|
4
5
|
import { ProfileInvalidError } from "./profile-invalid.js";
|
|
5
6
|
import { frameProfilePayload, unframeProfilePayload } from "./values.js";
|
|
@@ -44,6 +45,8 @@ export function checkChange(bytes) {
|
|
|
44
45
|
reject(`chunk type ${type} is not an Automerge change (§11)`);
|
|
45
46
|
// §11.1: what the change expands to and its structure, before Automerge decodes it.
|
|
46
47
|
const expansion = checkChangeExpansion(bytes);
|
|
48
|
+
// §11.3: the canonical encoding, by the format's properties, still before the engine.
|
|
49
|
+
const parsed = checkCanonicalChange(bytes);
|
|
47
50
|
let decoded;
|
|
48
51
|
try {
|
|
49
52
|
decoded = A.decodeChange(bytes);
|
|
@@ -53,7 +56,7 @@ export function checkChange(bytes) {
|
|
|
53
56
|
}
|
|
54
57
|
if (toHex(bytes.subarray(4, 8)) !== decoded.hash.slice(0, 8))
|
|
55
58
|
reject("the Automerge change checksum does not match its hash (§11)");
|
|
56
|
-
|
|
59
|
+
const checked = Object.freeze({
|
|
57
60
|
bytes: Uint8Array.from(bytes),
|
|
58
61
|
hash: decoded.hash,
|
|
59
62
|
actor: decoded.actor,
|
|
@@ -61,6 +64,19 @@ export function checkChange(bytes) {
|
|
|
61
64
|
deps: Object.freeze([...decoded.deps]),
|
|
62
65
|
otherActors: expansion.otherActors,
|
|
63
66
|
});
|
|
67
|
+
PARSED.set(checked, parsed);
|
|
68
|
+
return checked;
|
|
69
|
+
}
|
|
70
|
+
/** The operations checkChange read from the bytes (§11.3), for the references of §11.4. */
|
|
71
|
+
const PARSED = new WeakMap();
|
|
72
|
+
/** The change as its canonical bytes give it (§11.3), reusing the walk of checkChange. */
|
|
73
|
+
export function parsedOf(change) {
|
|
74
|
+
let p = PARSED.get(change);
|
|
75
|
+
if (p === undefined) {
|
|
76
|
+
p = checkCanonicalChange(change.bytes);
|
|
77
|
+
PARSED.set(change, p);
|
|
78
|
+
}
|
|
79
|
+
return p;
|
|
64
80
|
}
|
|
65
81
|
/**
|
|
66
82
|
* Checks that `bytes` starts as an Automerge full save (a document chunk,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** An operation of a change, as the references of §11.4 read it. */
|
|
2
|
+
export interface ParsedOp {
|
|
3
|
+
/** "_root", or the creating operation's ID "counter@actorhex". */
|
|
4
|
+
readonly obj: string;
|
|
5
|
+
/** A property name, the head of a sequence, or an element ID. */
|
|
6
|
+
readonly key: {
|
|
7
|
+
readonly kind: "prop";
|
|
8
|
+
readonly name: string;
|
|
9
|
+
} | {
|
|
10
|
+
readonly kind: "head";
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: "elem";
|
|
13
|
+
readonly id: string;
|
|
14
|
+
};
|
|
15
|
+
readonly insert: boolean;
|
|
16
|
+
/** 0 makeMap, 1 set, 2 makeList, 3 del, 4 makeText, 5 inc, 6 makeTable, 7 mark. */
|
|
17
|
+
readonly action: number;
|
|
18
|
+
/** Predecessor IDs "counter@actorhex", in order. */
|
|
19
|
+
readonly pred: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
/** A change read from its bytes (§11.3), without the engine. */
|
|
22
|
+
export interface ParsedChange {
|
|
23
|
+
readonly actor: string;
|
|
24
|
+
readonly seq: number;
|
|
25
|
+
readonly startOp: number;
|
|
26
|
+
/** Hex dependency hashes, ascending. */
|
|
27
|
+
readonly deps: readonly string[];
|
|
28
|
+
/** Hex other actors, ascending: actor index i is otherActors[i - 1]. */
|
|
29
|
+
readonly otherActors: readonly string[];
|
|
30
|
+
readonly ops: readonly ParsedOp[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* §11.3: checks that `bytes` (one change chunk within the §11.1 limits) is
|
|
34
|
+
* the canonical encoding of its change, and returns the change's operations
|
|
35
|
+
* read from it. Throws PROFILE_INVALID / INVALID_AUTOMERGE_BYTES.
|
|
36
|
+
*/
|
|
37
|
+
export declare function checkCanonicalChange(bytes: Uint8Array): ParsedChange;
|
|
38
|
+
//# sourceMappingURL=canonical.d.ts.map
|
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
// The canonical change encoding (SHARED-OBJECTS-PROFILE-01 §11.3,
|
|
2
|
+
// SPEC-PATCH-10, ADR 0010), checked by the properties of the change format
|
|
3
|
+
// version 1, before the Automerge engine decodes the change. An engine does
|
|
4
|
+
// not keep the bytes of a change it applies: it writes the change again
|
|
5
|
+
// when it saves or hands it out, so a change with any other encoding of the
|
|
6
|
+
// same operations gets a different hash there, and the document's save
|
|
7
|
+
// then fails to load. The walk also gives the change's operations, read
|
|
8
|
+
// without the engine, for the operation references of §11.4.
|
|
9
|
+
//
|
|
10
|
+
// Each run-length column is decoded strictly (shortest LEB128, 64-bit
|
|
11
|
+
// range) and encoded again by the one rule of §11.3 rule 5; the two must be
|
|
12
|
+
// the same bytes. Everything else is checked as stated: the header, the
|
|
13
|
+
// columns and their presence, row counts, value metadata and bytes,
|
|
14
|
+
// actions, IDs and counters below 2^32, sorted predecessors.
|
|
15
|
+
import { ProfileInvalidError } from "./profile-invalid.js";
|
|
16
|
+
const refuse = (why) => {
|
|
17
|
+
throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `the change is not in its canonical encoding (§11.3): ${why}`);
|
|
18
|
+
};
|
|
19
|
+
const U64 = 1n << 64n;
|
|
20
|
+
const I64_MIN = -(1n << 63n);
|
|
21
|
+
const I64_MAX = (1n << 63n) - 1n;
|
|
22
|
+
const U32 = 2 ** 32;
|
|
23
|
+
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
24
|
+
function utf8(bytes, what) {
|
|
25
|
+
try {
|
|
26
|
+
return UTF8.decode(bytes);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return refuse(`${what} is not valid UTF-8`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
33
|
+
/** Byte-wise lexicographic order, a shorter prefix first. */
|
|
34
|
+
function compareBytes(a, b) {
|
|
35
|
+
const n = Math.min(a.length, b.length);
|
|
36
|
+
for (let i = 0; i < n; i++)
|
|
37
|
+
if (a[i] !== b[i])
|
|
38
|
+
return a[i] - b[i];
|
|
39
|
+
return a.length - b.length;
|
|
40
|
+
}
|
|
41
|
+
/** A strict reader: every LEB128 number in its shortest form and in range (rule 1). */
|
|
42
|
+
class Reader {
|
|
43
|
+
bytes;
|
|
44
|
+
pos = 0;
|
|
45
|
+
constructor(bytes) {
|
|
46
|
+
this.bytes = bytes;
|
|
47
|
+
}
|
|
48
|
+
get done() {
|
|
49
|
+
return this.pos >= this.bytes.length;
|
|
50
|
+
}
|
|
51
|
+
take(n) {
|
|
52
|
+
if (!(n <= this.bytes.length - this.pos))
|
|
53
|
+
refuse("a length runs past the end");
|
|
54
|
+
const out = this.bytes.subarray(this.pos, this.pos + n);
|
|
55
|
+
this.pos += n;
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
ubig() {
|
|
59
|
+
let value = 0n;
|
|
60
|
+
let shift = 0n;
|
|
61
|
+
const start = this.pos;
|
|
62
|
+
for (;;) {
|
|
63
|
+
if (this.pos >= this.bytes.length)
|
|
64
|
+
refuse("a LEB128 number runs past the end");
|
|
65
|
+
if (this.pos - start >= 10)
|
|
66
|
+
refuse("an unsigned number is 2^64 or more");
|
|
67
|
+
const b = this.bytes[this.pos++];
|
|
68
|
+
value |= BigInt(b & 0x7f) << shift;
|
|
69
|
+
shift += 7n;
|
|
70
|
+
if ((b & 0x80) === 0) {
|
|
71
|
+
if (b === 0 && this.pos - start > 1)
|
|
72
|
+
refuse("a LEB128 number is not in its shortest form");
|
|
73
|
+
if (value >= U64)
|
|
74
|
+
refuse("an unsigned number is 2^64 or more");
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
sbig() {
|
|
80
|
+
let value = 0n;
|
|
81
|
+
let shift = 0n;
|
|
82
|
+
const start = this.pos;
|
|
83
|
+
for (;;) {
|
|
84
|
+
if (this.pos >= this.bytes.length)
|
|
85
|
+
refuse("a LEB128 number runs past the end");
|
|
86
|
+
if (this.pos - start >= 10)
|
|
87
|
+
refuse("a signed number is outside the 64-bit range");
|
|
88
|
+
const b = this.bytes[this.pos++];
|
|
89
|
+
value |= BigInt(b & 0x7f) << shift;
|
|
90
|
+
shift += 7n;
|
|
91
|
+
if ((b & 0x80) === 0) {
|
|
92
|
+
if (b & 0x40)
|
|
93
|
+
value -= 1n << shift;
|
|
94
|
+
if (this.pos - start > 1) {
|
|
95
|
+
const prev = this.bytes[this.pos - 2];
|
|
96
|
+
if ((b === 0x00 && (prev & 0x40) === 0) || (b === 0x7f && (prev & 0x40) !== 0))
|
|
97
|
+
refuse("a LEB128 number is not in its shortest form");
|
|
98
|
+
}
|
|
99
|
+
if (value < I64_MIN || value > I64_MAX)
|
|
100
|
+
refuse("a signed number is outside the 64-bit range");
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** An unsigned count or length: a safe integer, else over every bound. */
|
|
106
|
+
unum() {
|
|
107
|
+
const v = this.ubig();
|
|
108
|
+
return v > BigInt(Number.MAX_SAFE_INTEGER) ? Number.POSITIVE_INFINITY : Number(v);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function uleb(v, out) {
|
|
112
|
+
let x = v;
|
|
113
|
+
do {
|
|
114
|
+
let b = Number(x & 0x7fn);
|
|
115
|
+
x >>= 7n;
|
|
116
|
+
if (x !== 0n)
|
|
117
|
+
b |= 0x80;
|
|
118
|
+
out.push(b);
|
|
119
|
+
} while (x !== 0n);
|
|
120
|
+
}
|
|
121
|
+
function sleb(v, out) {
|
|
122
|
+
let x = v;
|
|
123
|
+
for (;;) {
|
|
124
|
+
const b = Number(x & 0x7fn);
|
|
125
|
+
x >>= 7n;
|
|
126
|
+
const done = (x === 0n && (b & 0x40) === 0) || (x === -1n && (b & 0x40) !== 0);
|
|
127
|
+
out.push(done ? b : b | 0x80);
|
|
128
|
+
if (done)
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const TYPE_DELTA = 3;
|
|
133
|
+
const TYPE_BOOLEAN = 4;
|
|
134
|
+
const TYPE_STRING = 5;
|
|
135
|
+
const same = (a, b) => {
|
|
136
|
+
if (a === null || b === null)
|
|
137
|
+
return a === b;
|
|
138
|
+
if (typeof a === "bigint" || typeof b === "bigint")
|
|
139
|
+
return a === b;
|
|
140
|
+
return a.length === b.length && compareBytes(a, b) === 0;
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* No column of a change within the §11.1 limits has more rows (a group's
|
|
144
|
+
* entries are bounded by the group sum); the walk refuses more on its own,
|
|
145
|
+
* whether or not the limits ran first.
|
|
146
|
+
*/
|
|
147
|
+
const MAX_ROWS = 262_144;
|
|
148
|
+
const room = (have, add) => {
|
|
149
|
+
if (add > BigInt(MAX_ROWS - have))
|
|
150
|
+
refuse("a column has more rows than a change may");
|
|
151
|
+
};
|
|
152
|
+
/** Decodes a run-length column (types 0, 1, 2, 3, 5, 6) strictly; a delta column's rows are absolute. */
|
|
153
|
+
function decodeRle(type, data) {
|
|
154
|
+
const r = new Reader(data);
|
|
155
|
+
const rows = [];
|
|
156
|
+
let acc = 0n;
|
|
157
|
+
const one = () => {
|
|
158
|
+
if (type === TYPE_STRING)
|
|
159
|
+
return r.take(r.unum());
|
|
160
|
+
if (type === TYPE_DELTA)
|
|
161
|
+
return r.sbig();
|
|
162
|
+
return r.ubig();
|
|
163
|
+
};
|
|
164
|
+
const push = (v) => {
|
|
165
|
+
if (type === TYPE_DELTA) {
|
|
166
|
+
acc += v;
|
|
167
|
+
rows.push(acc);
|
|
168
|
+
}
|
|
169
|
+
else
|
|
170
|
+
rows.push(v);
|
|
171
|
+
};
|
|
172
|
+
while (!r.done) {
|
|
173
|
+
const n = r.sbig();
|
|
174
|
+
room(rows.length, n < 0n ? -n : n);
|
|
175
|
+
if (n > 0n) {
|
|
176
|
+
const v = one();
|
|
177
|
+
for (let i = 0n; i < n; i++)
|
|
178
|
+
push(v);
|
|
179
|
+
}
|
|
180
|
+
else if (n < 0n)
|
|
181
|
+
for (let i = 0n; i < -n; i++)
|
|
182
|
+
push(one());
|
|
183
|
+
else {
|
|
184
|
+
const nulls = r.ubig();
|
|
185
|
+
room(rows.length, nulls);
|
|
186
|
+
for (let i = 0n; i < nulls; i++)
|
|
187
|
+
rows.push(null);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return rows;
|
|
191
|
+
}
|
|
192
|
+
/** The one encoding of §11.3 rule 5 of `rows` (a delta column's rows absolute). */
|
|
193
|
+
function encodeRle(type, rows) {
|
|
194
|
+
let values = rows;
|
|
195
|
+
if (type === TYPE_DELTA) {
|
|
196
|
+
let prev = 0n;
|
|
197
|
+
values = rows.map((v) => {
|
|
198
|
+
if (v === null)
|
|
199
|
+
return null;
|
|
200
|
+
const d = v - prev;
|
|
201
|
+
prev = v;
|
|
202
|
+
return d;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const out = [];
|
|
206
|
+
const value = (v) => {
|
|
207
|
+
if (type === TYPE_STRING) {
|
|
208
|
+
const b = v;
|
|
209
|
+
uleb(BigInt(b.length), out);
|
|
210
|
+
for (const x of b)
|
|
211
|
+
out.push(x);
|
|
212
|
+
}
|
|
213
|
+
else if (type === TYPE_DELTA)
|
|
214
|
+
sleb(v, out);
|
|
215
|
+
else
|
|
216
|
+
uleb(v, out);
|
|
217
|
+
};
|
|
218
|
+
let i = 0;
|
|
219
|
+
while (i < values.length) {
|
|
220
|
+
const v = values[i];
|
|
221
|
+
if (v === null) {
|
|
222
|
+
let j = i;
|
|
223
|
+
while (j < values.length && values[j] === null)
|
|
224
|
+
j++;
|
|
225
|
+
sleb(0n, out);
|
|
226
|
+
uleb(BigInt(j - i), out);
|
|
227
|
+
i = j;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
let j = i + 1;
|
|
231
|
+
while (j < values.length && same(values[j], v))
|
|
232
|
+
j++;
|
|
233
|
+
if (j - i >= 2) {
|
|
234
|
+
sleb(BigInt(j - i), out);
|
|
235
|
+
value(v);
|
|
236
|
+
i = j;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
// A literal run: up to a null or the start of a repetition.
|
|
240
|
+
let k = i;
|
|
241
|
+
while (k < values.length &&
|
|
242
|
+
values[k] !== null &&
|
|
243
|
+
!(k + 1 < values.length && same(values[k + 1], values[k])))
|
|
244
|
+
k++;
|
|
245
|
+
sleb(BigInt(-(k - i)), out);
|
|
246
|
+
for (let m = i; m < k; m++)
|
|
247
|
+
value(values[m]);
|
|
248
|
+
i = k;
|
|
249
|
+
}
|
|
250
|
+
return Uint8Array.from(out);
|
|
251
|
+
}
|
|
252
|
+
function decodeBoolean(data) {
|
|
253
|
+
const r = new Reader(data);
|
|
254
|
+
const rows = [];
|
|
255
|
+
let bit = false;
|
|
256
|
+
while (!r.done) {
|
|
257
|
+
const n = r.unum();
|
|
258
|
+
room(rows.length, BigInt(Number.isFinite(n) ? n : Number.MAX_SAFE_INTEGER));
|
|
259
|
+
for (let i = 0; i < n; i++)
|
|
260
|
+
rows.push(bit);
|
|
261
|
+
bit = !bit;
|
|
262
|
+
}
|
|
263
|
+
return rows;
|
|
264
|
+
}
|
|
265
|
+
function encodeBoolean(rows) {
|
|
266
|
+
const out = [];
|
|
267
|
+
let bit = false;
|
|
268
|
+
let i = 0;
|
|
269
|
+
while (i < rows.length) {
|
|
270
|
+
let j = i;
|
|
271
|
+
while (j < rows.length && rows[j] === bit)
|
|
272
|
+
j++;
|
|
273
|
+
uleb(BigInt(j - i), out);
|
|
274
|
+
bit = !bit;
|
|
275
|
+
i = j;
|
|
276
|
+
}
|
|
277
|
+
return Uint8Array.from(out);
|
|
278
|
+
}
|
|
279
|
+
/** The columns of the change format (§11.3 rule 3), by specification. */
|
|
280
|
+
const OBJ_ACTOR = 0x01;
|
|
281
|
+
const OBJ_CTR = 0x02;
|
|
282
|
+
const KEY_ACTOR = 0x11;
|
|
283
|
+
const KEY_CTR = 0x13;
|
|
284
|
+
const KEY_STR = 0x15;
|
|
285
|
+
const INSERT = 0x34;
|
|
286
|
+
const ACTION = 0x42;
|
|
287
|
+
const VAL_META = 0x56;
|
|
288
|
+
const VAL_RAW = 0x57;
|
|
289
|
+
const PRED_GROUP = 0x70;
|
|
290
|
+
const PRED_ACTOR = 0x71;
|
|
291
|
+
const PRED_CTR = 0x73;
|
|
292
|
+
const EXPAND = 0x94;
|
|
293
|
+
const MARK_NAME = 0xa5;
|
|
294
|
+
const KNOWN = new Set([
|
|
295
|
+
OBJ_ACTOR,
|
|
296
|
+
OBJ_CTR,
|
|
297
|
+
KEY_ACTOR,
|
|
298
|
+
KEY_CTR,
|
|
299
|
+
KEY_STR,
|
|
300
|
+
INSERT,
|
|
301
|
+
ACTION,
|
|
302
|
+
VAL_META,
|
|
303
|
+
VAL_RAW,
|
|
304
|
+
PRED_GROUP,
|
|
305
|
+
PRED_ACTOR,
|
|
306
|
+
PRED_CTR,
|
|
307
|
+
EXPAND,
|
|
308
|
+
MARK_NAME,
|
|
309
|
+
]);
|
|
310
|
+
/** Decodes and checks one column; the rows must encode back to the same bytes (rule 5). */
|
|
311
|
+
function column(spec, data) {
|
|
312
|
+
const type = spec & 7;
|
|
313
|
+
if (type === TYPE_BOOLEAN) {
|
|
314
|
+
const rows = decodeBoolean(data);
|
|
315
|
+
if (compareBytes(encodeBoolean(rows), data) !== 0)
|
|
316
|
+
refuse(`column 0x${spec.toString(16)} is not in its one run encoding`);
|
|
317
|
+
return rows;
|
|
318
|
+
}
|
|
319
|
+
const rows = decodeRle(type, data);
|
|
320
|
+
if (compareBytes(encodeRle(type, rows), data) !== 0)
|
|
321
|
+
refuse(`column 0x${spec.toString(16)} is not in its one run encoding`);
|
|
322
|
+
return rows;
|
|
323
|
+
}
|
|
324
|
+
const num = (v) => (v === null ? null : Number(v));
|
|
325
|
+
/** Rule 6: a value's bytes for its metadata type. */
|
|
326
|
+
function checkValue(type, bytes) {
|
|
327
|
+
if (type >= 10)
|
|
328
|
+
refuse(`value type ${type} is reserved`);
|
|
329
|
+
if (type <= 2) {
|
|
330
|
+
if (bytes.length !== 0)
|
|
331
|
+
refuse("a null or boolean value has bytes");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (type === 3 || type === 4 || type === 8 || type === 9) {
|
|
335
|
+
const r = new Reader(bytes);
|
|
336
|
+
if (type === 3)
|
|
337
|
+
r.ubig();
|
|
338
|
+
else
|
|
339
|
+
r.sbig();
|
|
340
|
+
if (!r.done || bytes.length === 0)
|
|
341
|
+
refuse("a number value is not exactly one shortest LEB128");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (type === 5) {
|
|
345
|
+
if (bytes.length !== 8)
|
|
346
|
+
refuse("a float value is not 8 bytes");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (type === 6)
|
|
350
|
+
utf8(bytes, "a string value");
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* §11.3: checks that `bytes` (one change chunk within the §11.1 limits) is
|
|
354
|
+
* the canonical encoding of its change, and returns the change's operations
|
|
355
|
+
* read from it. Throws PROFILE_INVALID / INVALID_AUTOMERGE_BYTES.
|
|
356
|
+
*/
|
|
357
|
+
export function checkCanonicalChange(bytes) {
|
|
358
|
+
const r = new Reader(bytes);
|
|
359
|
+
r.take(9); // magic, checksum, chunk type: checked by the framing
|
|
360
|
+
const length = r.ubig();
|
|
361
|
+
if (length !== BigInt(bytes.length - r.pos))
|
|
362
|
+
refuse("the chunk length is wrong");
|
|
363
|
+
// Rule 2: the header.
|
|
364
|
+
const depCount = r.unum();
|
|
365
|
+
const deps = [];
|
|
366
|
+
for (let i = 0; i < depCount; i++)
|
|
367
|
+
deps.push(r.take(32));
|
|
368
|
+
for (let i = 1; i < deps.length; i++)
|
|
369
|
+
if (compareBytes(deps[i - 1], deps[i]) >= 0)
|
|
370
|
+
refuse("the dependencies are not strictly ascending");
|
|
371
|
+
const actor = r.take(r.unum());
|
|
372
|
+
const seq = r.ubig();
|
|
373
|
+
const startOp = r.ubig();
|
|
374
|
+
if (seq < 1n || startOp < 1n)
|
|
375
|
+
refuse("the sequence number or the start op is 0");
|
|
376
|
+
r.sbig(); // time
|
|
377
|
+
utf8(r.take(r.unum()), "the message");
|
|
378
|
+
const otherCount = r.unum();
|
|
379
|
+
const others = [];
|
|
380
|
+
for (let i = 0; i < otherCount; i++)
|
|
381
|
+
others.push(r.take(r.unum()));
|
|
382
|
+
for (let i = 0; i < others.length; i++) {
|
|
383
|
+
const o = others[i];
|
|
384
|
+
if (compareBytes(o, actor) === 0)
|
|
385
|
+
refuse("the change's actor is among its other actors");
|
|
386
|
+
if (i > 0 && compareBytes(others[i - 1], o) >= 0)
|
|
387
|
+
refuse("the other actors are not strictly ascending");
|
|
388
|
+
}
|
|
389
|
+
// Rule 3: the columns, in specification order, each once, never empty or deflated.
|
|
390
|
+
const columnCount = r.unum();
|
|
391
|
+
const metas = [];
|
|
392
|
+
for (let i = 0; i < columnCount; i++) {
|
|
393
|
+
const spec = r.unum();
|
|
394
|
+
const len = r.unum();
|
|
395
|
+
if (!KNOWN.has(spec))
|
|
396
|
+
refuse(`column 0x${spec.toString(16)} is not a change column`);
|
|
397
|
+
if (metas.length > 0 && metas.at(-1).spec >= spec)
|
|
398
|
+
refuse("the columns are not in ascending specification order");
|
|
399
|
+
if (len === 0)
|
|
400
|
+
refuse(`column 0x${spec.toString(16)} is empty`);
|
|
401
|
+
metas.push({ spec, length: len });
|
|
402
|
+
}
|
|
403
|
+
const data = new Map();
|
|
404
|
+
for (const m of metas)
|
|
405
|
+
data.set(m.spec, r.take(m.length));
|
|
406
|
+
// What follows is the change's extra bytes.
|
|
407
|
+
const rle = (spec) => {
|
|
408
|
+
const d = data.get(spec);
|
|
409
|
+
return d === undefined ? undefined : column(spec, d);
|
|
410
|
+
};
|
|
411
|
+
const bools = (spec) => {
|
|
412
|
+
const d = data.get(spec);
|
|
413
|
+
return d === undefined ? undefined : column(spec, d);
|
|
414
|
+
};
|
|
415
|
+
const action = rle(ACTION) ?? [];
|
|
416
|
+
const N = action.length;
|
|
417
|
+
const objActor = rle(OBJ_ACTOR);
|
|
418
|
+
const objCtr = rle(OBJ_CTR);
|
|
419
|
+
const keyActor = rle(KEY_ACTOR);
|
|
420
|
+
const keyCtr = rle(KEY_CTR);
|
|
421
|
+
const keyStr = rle(KEY_STR);
|
|
422
|
+
const insert = bools(INSERT);
|
|
423
|
+
const meta = rle(VAL_META);
|
|
424
|
+
const predCount = rle(PRED_GROUP);
|
|
425
|
+
const predActor = rle(PRED_ACTOR);
|
|
426
|
+
const predCtr = rle(PRED_CTR);
|
|
427
|
+
const expand = bools(EXPAND);
|
|
428
|
+
const markName = rle(MARK_NAME);
|
|
429
|
+
// Rule 4: row counts.
|
|
430
|
+
for (const [spec, rows] of [
|
|
431
|
+
[OBJ_ACTOR, objActor],
|
|
432
|
+
[OBJ_CTR, objCtr],
|
|
433
|
+
[KEY_ACTOR, keyActor],
|
|
434
|
+
[KEY_CTR, keyCtr],
|
|
435
|
+
[KEY_STR, keyStr],
|
|
436
|
+
[INSERT, insert],
|
|
437
|
+
[VAL_META, meta],
|
|
438
|
+
[PRED_GROUP, predCount],
|
|
439
|
+
[EXPAND, expand],
|
|
440
|
+
[MARK_NAME, markName],
|
|
441
|
+
])
|
|
442
|
+
if (rows !== undefined && rows.length !== N)
|
|
443
|
+
refuse(`column 0x${spec.toString(16)} has ${rows.length} rows, not ${N}`);
|
|
444
|
+
let predRows = 0;
|
|
445
|
+
for (const c of predCount ?? []) {
|
|
446
|
+
if (c === null)
|
|
447
|
+
refuse("a predecessor count is null");
|
|
448
|
+
predRows += Number(c);
|
|
449
|
+
}
|
|
450
|
+
for (const [spec, rows] of [
|
|
451
|
+
[PRED_ACTOR, predActor],
|
|
452
|
+
[PRED_CTR, predCtr],
|
|
453
|
+
])
|
|
454
|
+
if (rows !== undefined && rows.length !== predRows)
|
|
455
|
+
refuse(`column 0x${spec.toString(16)} has ${rows.length} rows, not ${predRows}`);
|
|
456
|
+
const actorOf = (index) => {
|
|
457
|
+
const i = num(index);
|
|
458
|
+
if (i === null)
|
|
459
|
+
return refuse("an actor index is null");
|
|
460
|
+
if (i === 0)
|
|
461
|
+
return hex(actor);
|
|
462
|
+
const o = others[i - 1];
|
|
463
|
+
if (o === undefined)
|
|
464
|
+
return refuse("an actor index is out of range");
|
|
465
|
+
return hex(o);
|
|
466
|
+
};
|
|
467
|
+
const named = new Set();
|
|
468
|
+
const counter = (v, what) => {
|
|
469
|
+
const c = num(v);
|
|
470
|
+
if (c === null || c < 1 || c >= U32)
|
|
471
|
+
return refuse(`${what} is not a counter from 1 below 2^32`);
|
|
472
|
+
return c;
|
|
473
|
+
};
|
|
474
|
+
// Values (rule 6) and the value bytes column (rule 4).
|
|
475
|
+
const raw = data.get(VAL_RAW) ?? new Uint8Array(0);
|
|
476
|
+
let rawAt = 0;
|
|
477
|
+
const valueTypes = [];
|
|
478
|
+
for (let i = 0; i < N; i++) {
|
|
479
|
+
const m = meta?.[i] ?? null;
|
|
480
|
+
if (m === null)
|
|
481
|
+
refuse("a value metadata row is null");
|
|
482
|
+
const mv = m;
|
|
483
|
+
const type = Number(mv & 15n);
|
|
484
|
+
const len = mv >> 4n;
|
|
485
|
+
if (len > BigInt(raw.length - rawAt))
|
|
486
|
+
refuse("the value bytes are shorter than their metadata");
|
|
487
|
+
checkValue(type, raw.subarray(rawAt, rawAt + Number(len)));
|
|
488
|
+
rawAt += Number(len);
|
|
489
|
+
valueTypes.push(type);
|
|
490
|
+
}
|
|
491
|
+
if (rawAt !== raw.length)
|
|
492
|
+
refuse("the value bytes are longer than their metadata");
|
|
493
|
+
if (startOp + BigInt(N) - 1n >= BigInt(U32))
|
|
494
|
+
refuse("the last operation counter is 2^32 or more");
|
|
495
|
+
const start = Number(startOp);
|
|
496
|
+
const ops = [];
|
|
497
|
+
let p = 0;
|
|
498
|
+
let anyObj = false;
|
|
499
|
+
let anyElem = false;
|
|
500
|
+
let anyKeyCtr = false;
|
|
501
|
+
let anyProp = false;
|
|
502
|
+
let anyPred = false;
|
|
503
|
+
let anyExpand = false;
|
|
504
|
+
let anyMark = false;
|
|
505
|
+
for (let i = 0; i < N; i++) {
|
|
506
|
+
// Rule 7: actions.
|
|
507
|
+
const a = num(action[i]);
|
|
508
|
+
if (a === null || a > 7)
|
|
509
|
+
refuse("an action is not 0 to 7");
|
|
510
|
+
const act = a;
|
|
511
|
+
const ins = insert?.[i] ?? false;
|
|
512
|
+
const exp = expand?.[i] ?? false;
|
|
513
|
+
const mark = markName?.[i] ?? null;
|
|
514
|
+
if ((act === 0 || act === 2 || act === 4 || act === 6 || act === 3) && valueTypes[i] !== 0)
|
|
515
|
+
refuse("an operation that makes an object or deletes has a value");
|
|
516
|
+
if (act === 5 && valueTypes[i] !== 3 && valueTypes[i] !== 4)
|
|
517
|
+
refuse("an increment's value is not an integer");
|
|
518
|
+
if (act === 7 && !ins)
|
|
519
|
+
refuse("a mark does not insert");
|
|
520
|
+
if (act !== 7 && (exp || mark !== null))
|
|
521
|
+
refuse("only a mark has a mark name or expands");
|
|
522
|
+
if (mark !== null) {
|
|
523
|
+
anyMark = true;
|
|
524
|
+
utf8(mark, "a mark name");
|
|
525
|
+
}
|
|
526
|
+
if (exp)
|
|
527
|
+
anyExpand = true;
|
|
528
|
+
// Rule 8: the object and the key.
|
|
529
|
+
const oa = objActor?.[i] ?? null;
|
|
530
|
+
const oc = objCtr?.[i] ?? null;
|
|
531
|
+
let obj;
|
|
532
|
+
if (oa === null && oc === null)
|
|
533
|
+
obj = "_root";
|
|
534
|
+
else if (oa !== null && oc !== null) {
|
|
535
|
+
obj = `${counter(oc, "an object counter")}@${actorOf(oa)}`;
|
|
536
|
+
named.add(num(oa));
|
|
537
|
+
anyObj = true;
|
|
538
|
+
}
|
|
539
|
+
else
|
|
540
|
+
return refuse("an object is half null");
|
|
541
|
+
const ka = keyActor?.[i] ?? null;
|
|
542
|
+
const kc = keyCtr?.[i] ?? null;
|
|
543
|
+
const ks = keyStr?.[i] ?? null;
|
|
544
|
+
let key;
|
|
545
|
+
if (ks !== null && ka === null && kc === null) {
|
|
546
|
+
key = { kind: "prop", name: utf8(ks, "a key") };
|
|
547
|
+
anyProp = true;
|
|
548
|
+
}
|
|
549
|
+
else if (ks === null && ka === null && kc !== null && num(kc) === 0) {
|
|
550
|
+
key = { kind: "head" };
|
|
551
|
+
anyKeyCtr = true;
|
|
552
|
+
}
|
|
553
|
+
else if (ks === null && ka !== null && kc !== null) {
|
|
554
|
+
key = { kind: "elem", id: `${counter(kc, "a key counter")}@${actorOf(ka)}` };
|
|
555
|
+
named.add(num(ka));
|
|
556
|
+
anyElem = true;
|
|
557
|
+
anyKeyCtr = true;
|
|
558
|
+
}
|
|
559
|
+
else
|
|
560
|
+
return refuse("a key is neither a property, the head nor an element");
|
|
561
|
+
// Rule 9: the predecessors, ascending by counter then actor.
|
|
562
|
+
const count = Number(predCount?.[i]);
|
|
563
|
+
const pred = [];
|
|
564
|
+
let last = null;
|
|
565
|
+
for (let k = 0; k < count; k++, p++) {
|
|
566
|
+
const pa = predActor?.[p] ?? null;
|
|
567
|
+
const ctr = counter(predCtr?.[p] ?? null, "a predecessor counter");
|
|
568
|
+
const id = actorOf(pa);
|
|
569
|
+
const idBytes = num(pa) === 0 ? actor : others[num(pa) - 1];
|
|
570
|
+
if (last !== null &&
|
|
571
|
+
(ctr < last.ctr || (ctr === last.ctr && compareBytes(last.actor, idBytes) >= 0)))
|
|
572
|
+
refuse("the predecessors are not strictly ascending");
|
|
573
|
+
last = { ctr, actor: idBytes };
|
|
574
|
+
named.add(num(pa));
|
|
575
|
+
pred.push(`${ctr}@${id}`);
|
|
576
|
+
anyPred = true;
|
|
577
|
+
}
|
|
578
|
+
ops.push(Object.freeze({ obj, key, insert: ins, action: act, pred: Object.freeze(pred) }));
|
|
579
|
+
}
|
|
580
|
+
// Rule 3: each column present exactly when the table says.
|
|
581
|
+
const presence = [
|
|
582
|
+
[OBJ_ACTOR, anyObj],
|
|
583
|
+
[OBJ_CTR, anyObj],
|
|
584
|
+
[KEY_ACTOR, anyElem],
|
|
585
|
+
[KEY_CTR, anyKeyCtr],
|
|
586
|
+
[KEY_STR, anyProp],
|
|
587
|
+
[INSERT, N > 0],
|
|
588
|
+
[ACTION, N > 0],
|
|
589
|
+
[VAL_META, N > 0],
|
|
590
|
+
[VAL_RAW, raw.length > 0],
|
|
591
|
+
[PRED_GROUP, N > 0],
|
|
592
|
+
[PRED_ACTOR, anyPred],
|
|
593
|
+
[PRED_CTR, anyPred],
|
|
594
|
+
[EXPAND, anyExpand],
|
|
595
|
+
[MARK_NAME, anyMark],
|
|
596
|
+
];
|
|
597
|
+
for (const [spec, want] of presence)
|
|
598
|
+
if (data.has(spec) !== want)
|
|
599
|
+
refuse(`column 0x${spec.toString(16)} is ${want ? "missing" : "present without a use"}`);
|
|
600
|
+
// Rule 2: the other actors are exactly those the operations name.
|
|
601
|
+
named.delete(0);
|
|
602
|
+
if (named.size !== others.length)
|
|
603
|
+
refuse("the other actors are not exactly those the operations name");
|
|
604
|
+
return Object.freeze({
|
|
605
|
+
actor: hex(actor),
|
|
606
|
+
seq: Number(seq),
|
|
607
|
+
startOp: start,
|
|
608
|
+
deps: Object.freeze(deps.map(hex)),
|
|
609
|
+
otherActors: Object.freeze(others.map(hex)),
|
|
610
|
+
ops: Object.freeze(ops),
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
//# sourceMappingURL=canonical.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export declare const PACKAGE = "@openlfcp/shared-objects";
|
|
|
7
7
|
export { type CheckedChange, checkChange, checkSaveHeader, frameChange, frameSnapshot, unframeChange, unframeSnapshot, } from "./automerge-bytes.js";
|
|
8
8
|
export { CHANGE_LIMITS, type ChunkExpansion, checkChangeExpansion, checkSnapshotExpansion, MAX_DOCUMENT_DEPTH, SNAPSHOT_LIMITS_FLOOR, type SnapshotLimits, } from "./chunk-limits.js";
|
|
9
9
|
export { type SharedObjectsApplyResult, type SharedObjectsBatchResult, type SharedObjectsCheckpoint, type SharedObjectsCodec, SharedObjectsDataProfile, type SharedObjectsDiagnostic, type SharedObjectsExcludeResult, type SharedObjectsSnapshotCodec, type SharedObjectsUnit, } from "./data-profile.js";
|
|
10
|
-
export { type BatchReceiveResult, type BuiltReplica, type LocalChange, type ObjectChange, ObjectIdCollisionError, type ObjectStatus, type ReceiveResult, type ReplicaIntent, type ReplicaOptions, type ReplicaValidation, type ResolveFieldConflict, resolveFieldConflict, SCALAR_FIELDS, type ScalarField, type ScalarView, SharedObjectsReplica, type TaskView, } from "./replica.js";
|
|
10
|
+
export { applyBatchChecked, applyChecked, type BatchReceiveResult, type BuiltReplica, isWasmTrap, type LocalChange, type ObjectChange, ObjectIdCollisionError, type ObjectStatus, type ReceiveResult, type ReplicaIntent, type ReplicaOptions, type ReplicaValidation, type ResolveFieldConflict, resolveFieldConflict, SCALAR_FIELDS, type ScalarField, type ScalarView, SharedObjectsReplica, type TaskView, } from "./replica.js";
|
|
11
11
|
export { addTag, assign, cancel, clearDue, clearScheduled, complete, createTask, deleteTask, type NewTask, type ParsedTask, ProfileError, parseTask, removeTag, reopen, restoreTask, setDue, setPriority, setScheduled, setStatus, setTitle, type Task, type TaskChange, type TaskIntent, type TaskPriority, type TaskStatus, taskToJson, unassign, } from "./task.js";
|
|
12
12
|
export { DIAGNOSTIC_ORDER, firstPerField, isMap, type Json, objectProblems, type ProfileDiagnostic, ProfileInvalidError, type ProfileProblem, pointerToken, type RootValidation, validateRoot, validateTransition, } from "./validate.js";
|
|
13
13
|
export { deriveActorId, FRAMING_VERSION, frameProfilePayload, isLocalDate, isNamespacedValue, isPrincipalRef, isReverseDomain, isUtcTimestamp, PROFILE_ID, type PrincipalRef, parsePrincipalRef, principalRef, unframeProfilePayload, } from "./values.js";
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ export const PACKAGE = "@openlfcp/shared-objects";
|
|
|
7
7
|
export { checkChange, checkSaveHeader, frameChange, frameSnapshot, unframeChange, unframeSnapshot, } from "./automerge-bytes.js";
|
|
8
8
|
export { CHANGE_LIMITS, checkChangeExpansion, checkSnapshotExpansion, MAX_DOCUMENT_DEPTH, SNAPSHOT_LIMITS_FLOOR, } from "./chunk-limits.js";
|
|
9
9
|
export { SharedObjectsDataProfile, } from "./data-profile.js";
|
|
10
|
-
export { ObjectIdCollisionError, resolveFieldConflict, SCALAR_FIELDS, SharedObjectsReplica, } from "./replica.js";
|
|
10
|
+
export { applyBatchChecked, applyChecked, isWasmTrap, ObjectIdCollisionError, resolveFieldConflict, SCALAR_FIELDS, SharedObjectsReplica, } from "./replica.js";
|
|
11
11
|
export { addTag, assign, cancel, clearDue, clearScheduled, complete, createTask, deleteTask, ProfileError, parseTask, removeTag, reopen, restoreTask, setDue, setPriority, setScheduled, setStatus, setTitle, taskToJson, unassign, } from "./task.js";
|
|
12
12
|
export { DIAGNOSTIC_ORDER, firstPerField, isMap, objectProblems, ProfileInvalidError, pointerToken, validateRoot, validateTransition, } from "./validate.js";
|
|
13
13
|
export { deriveActorId, FRAMING_VERSION, frameProfilePayload, isLocalDate, isNamespacedValue, isPrincipalRef, isReverseDomain, isUtcTimestamp, PROFILE_ID, parsePrincipalRef, principalRef, unframeProfilePayload, } from "./values.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as A from "@automerge/automerge";
|
|
2
|
+
import { type ParsedChange } from "./canonical.js";
|
|
3
|
+
/** A §11.4 rule a change breaks. */
|
|
4
|
+
export type ReferenceRule = "R1" | "R2" | "R3" | "R4" | "R5" | "R6" | "R7" | "R10";
|
|
5
|
+
/**
|
|
6
|
+
* The changes of a document as §11.4 reads them. A replica keeps it current
|
|
7
|
+
* as changes are admitted and committed, or builds it again from its
|
|
8
|
+
* document.
|
|
9
|
+
*/
|
|
10
|
+
export declare class ReferenceHistory {
|
|
11
|
+
#private;
|
|
12
|
+
get size(): number;
|
|
13
|
+
has(hash: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Adds a change the document now holds. Its dependencies are added
|
|
16
|
+
* first; one this index does not hold adds nothing to its history.
|
|
17
|
+
*/
|
|
18
|
+
add(hash: string, change: ParsedChange): void;
|
|
19
|
+
/** The §11.4 rule `change` breaks against its causal history, or null. */
|
|
20
|
+
check(change: ParsedChange): ReferenceRule | null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The index of every change a document holds, as the engine hands them
|
|
24
|
+
* out (in its encoding, so canonical), dependencies first. A change the
|
|
25
|
+
* walk cannot read is left out: references into it are then refused.
|
|
26
|
+
*/
|
|
27
|
+
export declare function referenceHistoryOf(doc: A.Doc<unknown>): ReferenceHistory;
|
|
28
|
+
//# sourceMappingURL=references.d.ts.map
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Operation references (SHARED-OBJECTS-PROFILE-01 §11.4, SPEC-PATCH-10,
|
|
2
|
+
// ADR 0010): a change's operations refer only to its own causal history H
|
|
3
|
+
// (its dependencies and all their ancestors) or to operations earlier in
|
|
4
|
+
// the change. An engine applies a change that refers elsewhere (a
|
|
5
|
+
// predecessor on another key, an element of another list, an object that is
|
|
6
|
+
// not one), but then cannot write it back out, and some such changes abort
|
|
7
|
+
// the apply itself, which in Automerge JS terminates its wasm module. The
|
|
8
|
+
// rules are decided against H only, never anything else the receiver holds,
|
|
9
|
+
// so every replica decides alike.
|
|
10
|
+
//
|
|
11
|
+
// H is held as a vector clock: an admitted change's actor chain is
|
|
12
|
+
// contiguous in its history (R1), so the latest sequence number of each
|
|
13
|
+
// actor in H says exactly which changes H holds. An operation is found by
|
|
14
|
+
// its actor's changes and their counter ranges.
|
|
15
|
+
import * as A from "@automerge/automerge";
|
|
16
|
+
import { checkCanonicalChange } from "./canonical.js";
|
|
17
|
+
const MAKE = new Set([0, 2, 4, 6]);
|
|
18
|
+
const MAP_LIKE = new Set([0, 6]);
|
|
19
|
+
function slotOf(op, id) {
|
|
20
|
+
if (op.insert)
|
|
21
|
+
return `e:${id}`;
|
|
22
|
+
if (op.key.kind === "prop")
|
|
23
|
+
return `p:${op.key.name}`;
|
|
24
|
+
if (op.key.kind === "elem")
|
|
25
|
+
return `e:${op.key.id}`;
|
|
26
|
+
return "e:_head";
|
|
27
|
+
}
|
|
28
|
+
const targetOf = (op, id) => ({
|
|
29
|
+
obj: op.obj,
|
|
30
|
+
slot: slotOf(op, id),
|
|
31
|
+
insert: op.insert,
|
|
32
|
+
del: op.action === 3,
|
|
33
|
+
make: MAKE.has(op.action) ? op.action : null,
|
|
34
|
+
});
|
|
35
|
+
function splitId(id) {
|
|
36
|
+
const at = id.indexOf("@");
|
|
37
|
+
return { ctr: Number(id.slice(0, at)), actor: id.slice(at + 1) };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The changes of a document as §11.4 reads them. A replica keeps it current
|
|
41
|
+
* as changes are admitted and committed, or builds it again from its
|
|
42
|
+
* document.
|
|
43
|
+
*/
|
|
44
|
+
export class ReferenceHistory {
|
|
45
|
+
#byHash = new Map();
|
|
46
|
+
/** Each actor's changes by sequence number (index seq - 1). */
|
|
47
|
+
#byActor = new Map();
|
|
48
|
+
get size() {
|
|
49
|
+
return this.#byHash.size;
|
|
50
|
+
}
|
|
51
|
+
has(hash) {
|
|
52
|
+
return this.#byHash.has(hash);
|
|
53
|
+
}
|
|
54
|
+
/** H of a change with these dependencies: the latest sequence of each actor. */
|
|
55
|
+
#history(deps) {
|
|
56
|
+
const clock = new Map();
|
|
57
|
+
for (const d of deps) {
|
|
58
|
+
const e = this.#byHash.get(d);
|
|
59
|
+
if (e === undefined)
|
|
60
|
+
continue;
|
|
61
|
+
for (const [a, s] of e.clock)
|
|
62
|
+
if ((clock.get(a) ?? 0) < s)
|
|
63
|
+
clock.set(a, s);
|
|
64
|
+
}
|
|
65
|
+
return clock;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Adds a change the document now holds. Its dependencies are added
|
|
69
|
+
* first; one this index does not hold adds nothing to its history.
|
|
70
|
+
*/
|
|
71
|
+
add(hash, change) {
|
|
72
|
+
if (this.#byHash.has(hash))
|
|
73
|
+
return;
|
|
74
|
+
const clock = this.#history(change.deps);
|
|
75
|
+
if ((clock.get(change.actor) ?? 0) < change.seq)
|
|
76
|
+
clock.set(change.actor, change.seq);
|
|
77
|
+
const entry = {
|
|
78
|
+
actor: change.actor,
|
|
79
|
+
seq: change.seq,
|
|
80
|
+
startOp: change.startOp,
|
|
81
|
+
ops: change.ops.map((op, i) => targetOf(op, `${change.startOp + i}@${change.actor}`)),
|
|
82
|
+
clock,
|
|
83
|
+
};
|
|
84
|
+
this.#byHash.set(hash, entry);
|
|
85
|
+
const list = this.#byActor.get(change.actor) ?? [];
|
|
86
|
+
list[change.seq - 1] = entry;
|
|
87
|
+
this.#byActor.set(change.actor, list);
|
|
88
|
+
}
|
|
89
|
+
/** The change of `actor` holding operation counter `ctr`, among its first `upToSeq` changes. */
|
|
90
|
+
#find(actor, ctr, upToSeq) {
|
|
91
|
+
const list = this.#byActor.get(actor);
|
|
92
|
+
if (list === undefined)
|
|
93
|
+
return null;
|
|
94
|
+
let lo = 0;
|
|
95
|
+
let hi = Math.min(upToSeq, list.length) - 1;
|
|
96
|
+
while (lo <= hi) {
|
|
97
|
+
const mid = (lo + hi) >> 1;
|
|
98
|
+
const e = list[mid];
|
|
99
|
+
if (e === undefined)
|
|
100
|
+
return null;
|
|
101
|
+
if (ctr < e.startOp)
|
|
102
|
+
hi = mid - 1;
|
|
103
|
+
else if (ctr >= e.startOp + e.ops.length)
|
|
104
|
+
lo = mid + 1;
|
|
105
|
+
else
|
|
106
|
+
return { entry: e, index: ctr - e.startOp };
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
/** The §11.4 rule `change` breaks against its causal history, or null. */
|
|
111
|
+
check(change) {
|
|
112
|
+
const H = this.#history(change.deps);
|
|
113
|
+
// R1: the actor's previous change is in H.
|
|
114
|
+
if (change.seq > 1 && (H.get(change.actor) ?? 0) < change.seq - 1)
|
|
115
|
+
return "R1";
|
|
116
|
+
// R2: the start op follows H's largest counter (each actor's latest change in H has its largest).
|
|
117
|
+
let top = 0;
|
|
118
|
+
for (const [a, s] of H) {
|
|
119
|
+
const e = this.#byActor.get(a)?.[s - 1];
|
|
120
|
+
if (e !== undefined && e.startOp + e.ops.length - 1 > top)
|
|
121
|
+
top = e.startOp + e.ops.length - 1;
|
|
122
|
+
}
|
|
123
|
+
if (change.startOp !== top + 1)
|
|
124
|
+
return "R2";
|
|
125
|
+
const own = [];
|
|
126
|
+
// An operation of H or earlier in this change; deletions are not targets.
|
|
127
|
+
const target = (id) => {
|
|
128
|
+
const { ctr, actor } = splitId(id);
|
|
129
|
+
let t;
|
|
130
|
+
if (actor === change.actor && ctr >= change.startOp && ctr < change.startOp + own.length)
|
|
131
|
+
t = own[ctr - change.startOp];
|
|
132
|
+
else {
|
|
133
|
+
const found = this.#find(actor, ctr, H.get(actor) ?? 0);
|
|
134
|
+
t = found === null ? undefined : found.entry.ops[found.index];
|
|
135
|
+
}
|
|
136
|
+
return t === undefined || t.del ? undefined : t;
|
|
137
|
+
};
|
|
138
|
+
for (const [i, op] of change.ops.entries()) {
|
|
139
|
+
const id = `${change.startOp + i}@${change.actor}`;
|
|
140
|
+
// R10: no operation makes a table. Automerge JS 3.5.0 cannot write a
|
|
141
|
+
// table back out and aborts applying one (differential fuzzing D1); a
|
|
142
|
+
// writer of this profile never makes one.
|
|
143
|
+
if (op.action === 6)
|
|
144
|
+
return "R10";
|
|
145
|
+
// R3: the root, or an object an operation made; its key form.
|
|
146
|
+
let sequence;
|
|
147
|
+
if (op.obj === "_root")
|
|
148
|
+
sequence = false;
|
|
149
|
+
else {
|
|
150
|
+
const made = target(op.obj)?.make ?? null;
|
|
151
|
+
if (made === null)
|
|
152
|
+
return "R3";
|
|
153
|
+
sequence = !MAP_LIKE.has(made);
|
|
154
|
+
}
|
|
155
|
+
if (!sequence && (op.insert || op.key.kind !== "prop"))
|
|
156
|
+
return op.insert ? "R4" : "R3";
|
|
157
|
+
if (sequence && op.key.kind === "prop")
|
|
158
|
+
return "R3";
|
|
159
|
+
const element = (e) => {
|
|
160
|
+
const t = target(e);
|
|
161
|
+
return t !== undefined && t.insert && t.obj === op.obj;
|
|
162
|
+
};
|
|
163
|
+
// R4: an insertion goes after the head or an element of the same object, without predecessors.
|
|
164
|
+
if (op.insert && (op.pred.length > 0 || (op.key.kind === "elem" && !element(op.key.id))))
|
|
165
|
+
return "R4";
|
|
166
|
+
// R5: any other sequence operation names an element of the same object.
|
|
167
|
+
if (sequence && !op.insert && (op.key.kind !== "elem" || !element(op.key.id)))
|
|
168
|
+
return "R5";
|
|
169
|
+
// R6: predecessors on the same object and key.
|
|
170
|
+
const slot = slotOf(op, id);
|
|
171
|
+
for (const p of op.pred) {
|
|
172
|
+
const t = target(p);
|
|
173
|
+
if (t === undefined || t.obj !== op.obj || t.slot !== slot)
|
|
174
|
+
return "R6";
|
|
175
|
+
}
|
|
176
|
+
// R7: a deletion has a predecessor.
|
|
177
|
+
if (op.action === 3 && op.pred.length === 0)
|
|
178
|
+
return "R7";
|
|
179
|
+
own.push(targetOf(op, id));
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The index of every change a document holds, as the engine hands them
|
|
186
|
+
* out (in its encoding, so canonical), dependencies first. A change the
|
|
187
|
+
* walk cannot read is left out: references into it are then refused.
|
|
188
|
+
*/
|
|
189
|
+
export function referenceHistoryOf(doc) {
|
|
190
|
+
const parsed = new Map();
|
|
191
|
+
for (const bytes of A.getAllChanges(doc)) {
|
|
192
|
+
try {
|
|
193
|
+
parsed.set(A.decodeChange(bytes).hash, checkCanonicalChange(bytes));
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// not readable: left out
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const refs = new ReferenceHistory();
|
|
200
|
+
const placed = new Set();
|
|
201
|
+
for (const start of parsed.keys()) {
|
|
202
|
+
// Iterative depth-first: dependencies first, without recursion.
|
|
203
|
+
const stack = [{ hash: start, next: 0 }];
|
|
204
|
+
while (stack.length > 0) {
|
|
205
|
+
const top = stack[stack.length - 1];
|
|
206
|
+
const change = parsed.get(top.hash);
|
|
207
|
+
if (change === undefined || placed.has(top.hash)) {
|
|
208
|
+
stack.pop();
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const dep = change.deps[top.next];
|
|
212
|
+
if (dep !== undefined) {
|
|
213
|
+
top.next++;
|
|
214
|
+
if (!placed.has(dep) && parsed.has(dep))
|
|
215
|
+
stack.push({ hash: dep, next: 0 });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
placed.add(top.hash);
|
|
219
|
+
refs.add(top.hash, change);
|
|
220
|
+
stack.pop();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return refs;
|
|
224
|
+
}
|
|
225
|
+
//# sourceMappingURL=references.js.map
|
package/dist/replica.d.ts
CHANGED
|
@@ -143,6 +143,12 @@ export interface BuiltReplica {
|
|
|
143
143
|
type Doc = A.Doc<Record<string, unknown>>;
|
|
144
144
|
/** §30: an object's maps and lists nest at most this many levels (the field's own map is 1). */
|
|
145
145
|
export declare const MAX_VALUE_DEPTH = 64;
|
|
146
|
+
/**
|
|
147
|
+
* Whether a thrown value is a wasm trap (the engine's module is dead, not
|
|
148
|
+
* the change refused): it is rethrown as it is, never turned into
|
|
149
|
+
* INVALID_AUTOMERGE_BYTES, so a crash-loop breaker sees it.
|
|
150
|
+
*/
|
|
151
|
+
export declare function isWasmTrap(e: unknown): boolean;
|
|
146
152
|
/**
|
|
147
153
|
* Applies one change that passed the receive checks. Automerge can throw
|
|
148
154
|
* after changing the document in place (a change that skips a sequence
|
package/dist/replica.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import * as A from "@automerge/automerge";
|
|
2
2
|
import { fromHex, LfcpError, toHex, } from "@openlfcp/core";
|
|
3
|
-
import { checkChange, checkSaveHeader, unframeChange, unframeSnapshot, } from "./automerge-bytes.js";
|
|
3
|
+
import { checkChange, checkSaveHeader, parsedOf, unframeChange, unframeSnapshot, } from "./automerge-bytes.js";
|
|
4
4
|
import { checkSnapshotExpansion, MAX_DOCUMENT_DEPTH, SNAPSHOT_LIMITS_FLOOR, } from "./chunk-limits.js";
|
|
5
5
|
/** The decoded actions that create an object (§11.2). */
|
|
6
6
|
const MAKE_ACTIONS = new Set(["makeMap", "makeList", "makeText", "makeTable"]);
|
|
7
7
|
import { ProfileInvalidError } from "./profile-invalid.js";
|
|
8
|
+
import { referenceHistoryOf } from "./references.js";
|
|
8
9
|
import { ProfileError, parseTask } from "./task.js";
|
|
9
10
|
import { firstPerField, isMap, objectProblems, pointerToken, validateRoot, } from "./validate.js";
|
|
10
11
|
import { deriveActorId, frameProfilePayload, PROFILE_ID } from "./values.js";
|
|
@@ -55,6 +56,8 @@ export function resolveFieldConflict(id, field, value) {
|
|
|
55
56
|
throw new LfcpError("UNSUPPORTED_VALUE", `only a date field can be cleared (§36)`);
|
|
56
57
|
return Object.freeze({ intent: "task.resolve_field_conflict", id, field, value });
|
|
57
58
|
}
|
|
59
|
+
/** §11.4: a refusal for a change whose operations refer outside its causal history. */
|
|
60
|
+
const referenceError = (rule) => new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `the change's operations refer outside its causal history (§11.4 ${rule})`);
|
|
58
61
|
/** §21: two concurrent objects under one Object ID: OBJECT_ID_COLLISION, a profile error of its own, not PROFILE_INVALID. */
|
|
59
62
|
export class ObjectIdCollisionError extends LfcpError {
|
|
60
63
|
objectId;
|
|
@@ -311,7 +314,7 @@ const actorHex = (opts) => toHex(deriveActorId(opts.resource, opts.principal));
|
|
|
311
314
|
* the change refused): it is rethrown as it is, never turned into
|
|
312
315
|
* INVALID_AUTOMERGE_BYTES, so a crash-loop breaker sees it.
|
|
313
316
|
*/
|
|
314
|
-
function isWasmTrap(e) {
|
|
317
|
+
export function isWasmTrap(e) {
|
|
315
318
|
const trap = globalThis
|
|
316
319
|
.WebAssembly?.RuntimeError;
|
|
317
320
|
if (trap !== undefined && e instanceof trap)
|
|
@@ -327,7 +330,18 @@ function isWasmTrap(e) {
|
|
|
327
330
|
* is: the module is dead, and only a restart recovers. Exported for tests.
|
|
328
331
|
*/
|
|
329
332
|
export function applyChecked(doc, bytes) {
|
|
333
|
+
// Capture the document's changes BEFORE the apply: a change the engine
|
|
334
|
+
// aborts on (D1: a makeTable it cannot write back) stays in the document's
|
|
335
|
+
// graph or aborts the apply mid-way, so A.getAllChanges(doc) AFTER the
|
|
336
|
+
// error still holds the bad change and rebuilding from it re-aborts
|
|
337
|
+
// ("could not be restored"). Rebuilding from the changes it held before
|
|
338
|
+
// always gives its prior state.
|
|
339
|
+
//
|
|
340
|
+
// Do NOT "simplify" this by applying to A.clone(doc): A.clone gives the
|
|
341
|
+
// clone a new actor id, which breaks local writes (§8) and convergence.
|
|
330
342
|
const before = [...A.getHeads(doc)].sort().join();
|
|
343
|
+
const actor = A.getActorId(doc);
|
|
344
|
+
const good = A.getAllChanges(doc);
|
|
331
345
|
try {
|
|
332
346
|
return { next: A.applyChanges(doc, [bytes])[0] };
|
|
333
347
|
}
|
|
@@ -335,18 +349,22 @@ export function applyChecked(doc, bytes) {
|
|
|
335
349
|
if (isWasmTrap(e))
|
|
336
350
|
throw e;
|
|
337
351
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
338
|
-
|
|
339
|
-
try {
|
|
340
|
-
[restored] = A.applyChanges(A.init({ actor: A.getActorId(doc) }), A.getAllChanges(doc));
|
|
341
|
-
}
|
|
342
|
-
catch (r) {
|
|
343
|
-
throw new Error(`the replica could not be restored after an Automerge error: ${String(r)}`);
|
|
344
|
-
}
|
|
345
|
-
if ([...A.getHeads(restored)].sort().join() !== before)
|
|
346
|
-
throw new Error("the replica could not be restored after an Automerge error: heads differ");
|
|
347
|
-
return { restored, error };
|
|
352
|
+
return { restored: restoreFrom(good, actor, before), error };
|
|
348
353
|
}
|
|
349
354
|
}
|
|
355
|
+
/** Rebuilds a document from the changes it held before an aborted apply (never from the aborted one). */
|
|
356
|
+
function restoreFrom(good, actor, before) {
|
|
357
|
+
let restored;
|
|
358
|
+
try {
|
|
359
|
+
[restored] = A.applyChanges(A.init({ actor }), [...good]);
|
|
360
|
+
}
|
|
361
|
+
catch (r) {
|
|
362
|
+
throw new Error(`the replica could not be restored after an Automerge error: ${String(r)}`);
|
|
363
|
+
}
|
|
364
|
+
if ([...A.getHeads(restored)].sort().join() !== before)
|
|
365
|
+
throw new Error("the replica could not be restored after an Automerge error: heads differ");
|
|
366
|
+
return restored;
|
|
367
|
+
}
|
|
350
368
|
/**
|
|
351
369
|
* applyChecked for a batch of admitted changes, in one engine call. On any
|
|
352
370
|
* engine error the handle is dropped and the document as it was before is
|
|
@@ -354,7 +372,10 @@ export function applyChecked(doc, bytes) {
|
|
|
354
372
|
* heads it had. Exported for tests.
|
|
355
373
|
*/
|
|
356
374
|
export function applyBatchChecked(doc, batch) {
|
|
357
|
-
|
|
375
|
+
// See applyChecked: capture the changes before the apply, restore from them.
|
|
376
|
+
const before = [...A.getHeads(doc)].sort().join();
|
|
377
|
+
const actor = A.getActorId(doc);
|
|
378
|
+
const good = A.getAllChanges(doc);
|
|
358
379
|
try {
|
|
359
380
|
return {
|
|
360
381
|
next: A.applyChanges(doc, batch.map((c) => c.bytes))[0],
|
|
@@ -364,23 +385,9 @@ export function applyBatchChecked(doc, batch) {
|
|
|
364
385
|
if (isWasmTrap(e))
|
|
365
386
|
throw e;
|
|
366
387
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
367
|
-
return { restored:
|
|
388
|
+
return { restored: restoreFrom(good, actor, before), error };
|
|
368
389
|
}
|
|
369
390
|
}
|
|
370
|
-
function restoreWithout(doc, batch, heads) {
|
|
371
|
-
const out = new Set(batch.map((c) => c.hash));
|
|
372
|
-
const kept = A.getAllChanges(doc).filter((c) => !out.has(A.decodeChange(c).hash));
|
|
373
|
-
let restored;
|
|
374
|
-
try {
|
|
375
|
-
[restored] = A.applyChanges(A.init({ actor: A.getActorId(doc) }), kept);
|
|
376
|
-
}
|
|
377
|
-
catch (r) {
|
|
378
|
-
throw new Error(`the replica could not be restored after an Automerge error: ${String(r)}`);
|
|
379
|
-
}
|
|
380
|
-
if ([...A.getHeads(restored)].sort().join() !== [...heads].sort().join())
|
|
381
|
-
throw new Error("the replica could not be restored after an Automerge error: heads differ");
|
|
382
|
-
return restored;
|
|
383
|
-
}
|
|
384
391
|
function loadFailure(e, what) {
|
|
385
392
|
if (e instanceof LfcpError)
|
|
386
393
|
throw e;
|
|
@@ -394,6 +401,8 @@ export class SharedObjectsReplica {
|
|
|
394
401
|
#actor;
|
|
395
402
|
#minSeq;
|
|
396
403
|
#doc;
|
|
404
|
+
/** §11.4: the document's changes as references read them; null to build again from the document. */
|
|
405
|
+
#refs = null;
|
|
397
406
|
/** Highest sequence seen per actor (hex). */
|
|
398
407
|
#seqs = new Map();
|
|
399
408
|
/** §11.2: known object depths (object ID → depth below the root), filled as objects appear. */
|
|
@@ -789,11 +798,13 @@ export class SharedObjectsReplica {
|
|
|
789
798
|
// this replica held is outdated by A.change: rebuild it without the change.
|
|
790
799
|
const hash = A.decodeChange(bytes).hash;
|
|
791
800
|
this.#doc = A.applyChanges(A.init({ actor: this.#actor }), A.getAllChanges(next).filter((c) => A.decodeChange(c).hash !== hash))[0];
|
|
801
|
+
this.#refs = null;
|
|
792
802
|
throw new LfcpError("CHANGE_TOO_LARGE", `the transaction "${message}" is larger than one change may be (§11.1: ${e.message}); split it into several (§12)`);
|
|
793
803
|
}
|
|
794
804
|
if (checked.seq !== this.actorSeq + 1)
|
|
795
805
|
throw new LfcpError("SEQUENCE_REUSE", `actor sequence ${checked.seq} after ${this.actorSeq} (§9)`);
|
|
796
806
|
this.#doc = next;
|
|
807
|
+
this.#refs?.add(checked.hash, parsedOf(checked));
|
|
797
808
|
this.#noteSeq(checked.actor, checked.seq);
|
|
798
809
|
for (const [id, d] of created)
|
|
799
810
|
this.#depths.set(id, d);
|
|
@@ -890,6 +901,12 @@ export class SharedObjectsReplica {
|
|
|
890
901
|
continue;
|
|
891
902
|
}
|
|
892
903
|
else {
|
|
904
|
+
// §11.4: the operations refer only to the change's causal history.
|
|
905
|
+
const rule = this.#references().check(parsedOf(c));
|
|
906
|
+
if (rule !== null) {
|
|
907
|
+
refused.push({ change: c, error: referenceError(rule) });
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
893
910
|
// §11.2: the depths of the objects it creates, given the document and the batch so far.
|
|
894
911
|
let created;
|
|
895
912
|
try {
|
|
@@ -902,6 +919,7 @@ export class SharedObjectsReplica {
|
|
|
902
919
|
for (const [id, d] of created)
|
|
903
920
|
batchDepths.set(id, d);
|
|
904
921
|
seqs.set(c.actor, c.seq);
|
|
922
|
+
this.#references().add(c.hash, parsedOf(c));
|
|
905
923
|
admitted.push(c);
|
|
906
924
|
}
|
|
907
925
|
for (const child of children.get(c.hash) ?? []) {
|
|
@@ -924,6 +942,8 @@ export class SharedObjectsReplica {
|
|
|
924
942
|
}
|
|
925
943
|
else {
|
|
926
944
|
this.#doc = batch.restored;
|
|
945
|
+
// The index already holds the batch: build it again from the restored document.
|
|
946
|
+
this.#refs = null;
|
|
927
947
|
applied = [];
|
|
928
948
|
for (const c of admitted) {
|
|
929
949
|
try {
|
|
@@ -952,6 +972,11 @@ export class SharedObjectsReplica {
|
|
|
952
972
|
objects: Object.freeze(applied.length > 0 ? this.#objectChanges(before, "remote") : []),
|
|
953
973
|
});
|
|
954
974
|
}
|
|
975
|
+
/** The reference index of the document (§11.4), built once and kept with it. */
|
|
976
|
+
#references() {
|
|
977
|
+
this.#refs ??= referenceHistoryOf(this.#doc);
|
|
978
|
+
return this.#refs;
|
|
979
|
+
}
|
|
955
980
|
#receive(change) {
|
|
956
981
|
if (A.hasHeads(this.#doc, [change.hash]))
|
|
957
982
|
return Object.freeze({ status: "duplicate", change });
|
|
@@ -977,16 +1002,22 @@ export class SharedObjectsReplica {
|
|
|
977
1002
|
throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `the change names actor ${unknown}, unknown to this document (§11.1)`);
|
|
978
1003
|
if (change.seq !== (this.#seqs.get(change.actor) ?? 0) + 1)
|
|
979
1004
|
throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `actor ${change.actor} sequence ${change.seq} skips sequence ${(this.#seqs.get(change.actor) ?? 0) + 1} (§14.1)`);
|
|
1005
|
+
// §11.4: the operations refer only to the change's causal history.
|
|
1006
|
+
const rule = this.#references().check(parsedOf(change));
|
|
1007
|
+
if (rule !== null)
|
|
1008
|
+
throw referenceError(rule);
|
|
980
1009
|
// §11.2: no object deeper than MAX_DOCUMENT_DEPTH, before the engine.
|
|
981
1010
|
const created = this.#createdDepths(change, new Map());
|
|
982
1011
|
const before = A.getHeads(this.#doc);
|
|
983
1012
|
const applied = applyChecked(this.#doc, change.bytes);
|
|
984
1013
|
if ("error" in applied) {
|
|
985
1014
|
this.#doc = applied.restored;
|
|
1015
|
+
this.#refs = null;
|
|
986
1016
|
throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `Automerge rejected the change (§11): ${applied.error.message}`);
|
|
987
1017
|
}
|
|
988
1018
|
const next = applied.next;
|
|
989
1019
|
this.#doc = next;
|
|
1020
|
+
this.#refs?.add(change.hash, parsedOf(change));
|
|
990
1021
|
this.#noteSeq(change.actor, change.seq);
|
|
991
1022
|
for (const [id, d] of created)
|
|
992
1023
|
this.#depths.set(id, d);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlfcp/shared-objects",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "SHARED-OBJECTS-PROFILE-01 (org.openlfcp.shared-objects.v1) implementation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openlfcp",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@automerge/automerge": "3.5.0",
|
|
47
47
|
"fflate": "0.8.3",
|
|
48
|
-
"@openlfcp/core": "^0.1.
|
|
49
|
-
"@openlfcp/crypto": "^0.1.
|
|
48
|
+
"@openlfcp/core": "^0.1.5",
|
|
49
|
+
"@openlfcp/crypto": "^0.1.5"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"fast-check": "4.10.2"
|