@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 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.9`, not every deferred WIRE-01 feature. It does not
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.3`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
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
@@ -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
- return Object.freeze({
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
- let restored;
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
- const heads = A.getHeads(doc);
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: restoreWithout(doc, batch, heads), error };
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",
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.3",
49
- "@openlfcp/crypto": "^0.1.3"
48
+ "@openlfcp/core": "^0.1.5",
49
+ "@openlfcp/crypto": "^0.1.5"
50
50
  },
51
51
  "devDependencies": {
52
52
  "fast-check": "4.10.2"