@openlfcp/shared-objects 0.1.4 → 0.2.0-beta.1
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 +15 -2
- package/dist/admission/actor.d.ts +16 -0
- package/dist/admission/actor.js +35 -0
- package/dist/{canonical.d.ts → admission/canonical.d.ts} +2 -0
- package/dist/{canonical.js → admission/canonical.js} +9 -2
- package/dist/{automerge-bytes.d.ts → admission/framing.d.ts} +32 -1
- package/dist/admission/framing.js +231 -0
- package/dist/admission/index.d.ts +26 -0
- package/dist/admission/index.js +26 -0
- package/dist/{chunk-limits.d.ts → admission/limits.d.ts} +11 -1
- package/dist/{chunk-limits.js → admission/limits.js} +25 -10
- package/dist/{references.d.ts → admission/references.d.ts} +1 -1
- package/dist/{references.js → admission/references.js} +11 -0
- package/dist/admission/sequence.d.ts +81 -0
- package/dist/admission/sequence.js +133 -0
- package/dist/data-profile.d.ts +1 -1
- package/dist/data-profile.js +3 -7
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/replica.d.ts +38 -2
- package/dist/replica.js +177 -214
- package/dist/sections/admission.d.ts +35 -0
- package/dist/sections/admission.js +328 -0
- package/dist/sections/data-profile.d.ts +195 -0
- package/dist/sections/data-profile.js +383 -0
- package/dist/sections/dispatch.d.ts +41 -0
- package/dist/sections/dispatch.js +33 -0
- package/dist/sections/document.d.ts +54 -0
- package/dist/sections/document.js +101 -0
- package/dist/sections/index.d.ts +18 -0
- package/dist/sections/index.js +18 -0
- package/dist/sections/replica.d.ts +412 -0
- package/dist/sections/replica.js +1149 -0
- package/dist/sections/schema.d.ts +77 -0
- package/dist/sections/schema.js +548 -0
- package/dist/sections/tree.d.ts +83 -0
- package/dist/sections/tree.js +290 -0
- package/dist/sections/values.d.ts +30 -0
- package/dist/sections/values.js +36 -0
- package/dist/values.d.ts +3 -18
- package/dist/values.js +7 -95
- package/package.json +12 -4
- package/dist/automerge-bytes.js +0 -111
package/dist/replica.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import * as A from "@automerge/automerge";
|
|
2
2
|
import { fromHex, LfcpError, toHex, } from "@openlfcp/core";
|
|
3
|
-
import { checkChange, checkSaveHeader, parsedOf, unframeChange, unframeSnapshot, } from "./
|
|
4
|
-
import { checkSnapshotExpansion, MAX_DOCUMENT_DEPTH, SNAPSHOT_LIMITS_FLOOR, } from "./
|
|
3
|
+
import { checkChange, checkSaveHeader, parsedOf, unframeChange, unframeSnapshot, } from "./admission/framing.js";
|
|
4
|
+
import { checkSnapshotExpansion, MAX_DOCUMENT_DEPTH, SNAPSHOT_LIMITS_FLOOR, } from "./admission/limits.js";
|
|
5
|
+
import { referenceHistoryOf } from "./admission/references.js";
|
|
6
|
+
import { admitBatch, admitChange } from "./admission/sequence.js";
|
|
5
7
|
/** The decoded actions that create an object (§11.2). */
|
|
6
8
|
const MAKE_ACTIONS = new Set(["makeMap", "makeList", "makeText", "makeTable"]);
|
|
7
9
|
import { ProfileInvalidError } from "./profile-invalid.js";
|
|
8
|
-
import { referenceHistoryOf } from "./references.js";
|
|
9
10
|
import { ProfileError, parseTask } from "./task.js";
|
|
10
11
|
import { firstPerField, isMap, objectProblems, pointerToken, validateRoot, } from "./validate.js";
|
|
11
12
|
import { deriveActorId, frameProfilePayload, PROFILE_ID } from "./values.js";
|
|
@@ -56,8 +57,6 @@ export function resolveFieldConflict(id, field, value) {
|
|
|
56
57
|
throw new LfcpError("UNSUPPORTED_VALUE", `only a date field can be cleared (§36)`);
|
|
57
58
|
return Object.freeze({ intent: "task.resolve_field_conflict", id, field, value });
|
|
58
59
|
}
|
|
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})`);
|
|
61
60
|
/** §21: two concurrent objects under one Object ID: OBJECT_ID_COLLISION, a profile error of its own, not PROFILE_INVALID. */
|
|
62
61
|
export class ObjectIdCollisionError extends LfcpError {
|
|
63
62
|
objectId;
|
|
@@ -221,6 +220,104 @@ function conflictValueProblems(object, key) {
|
|
|
221
220
|
}
|
|
222
221
|
return out;
|
|
223
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* §99: the Task under `id` of `objects` (the document's `objects` map) with
|
|
225
|
+
* conflict metadata, or undefined if there is no object or it is not a Task.
|
|
226
|
+
* Shared by the Shared Objects and the Shared Sections replicas.
|
|
227
|
+
*/
|
|
228
|
+
export function taskView(doc, objects, id) {
|
|
229
|
+
const stored = objects?.[id];
|
|
230
|
+
if (objects === undefined || !isMap(stored))
|
|
231
|
+
return undefined;
|
|
232
|
+
const object = stored;
|
|
233
|
+
if (plain(object.type) !== "task")
|
|
234
|
+
return undefined;
|
|
235
|
+
const collided = A.getConflicts(objects, id) !== undefined;
|
|
236
|
+
const problems = storedObjectProblems(doc, object, id);
|
|
237
|
+
const parsed = parseTask(plain(object), id);
|
|
238
|
+
const field = (f) => {
|
|
239
|
+
const values = valuesOf(object, f).map(plain).sort(byJson);
|
|
240
|
+
return Object.freeze({
|
|
241
|
+
value: f in object ? plain(object[f]) : undefined,
|
|
242
|
+
values: Object.freeze(values),
|
|
243
|
+
conflicted: values.length > 1,
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
const keys = (f) => isMap(plain(object[f])) ? Object.keys(plain(object[f])).sort() : [];
|
|
247
|
+
return Object.freeze({
|
|
248
|
+
id,
|
|
249
|
+
status: collided ? "object_id_collision" : problems.length > 0 ? "profile_invalid" : "ready",
|
|
250
|
+
problems: Object.freeze(problems),
|
|
251
|
+
task: parsed.valid ? parsed.task : undefined,
|
|
252
|
+
fields: Object.freeze(Object.fromEntries(SCALAR_FIELDS.map((f) => [f, field(f)]))),
|
|
253
|
+
tags: Object.freeze(keys("tags")),
|
|
254
|
+
assignees: Object.freeze(keys("assignees")),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* §74.1 problems of one object as stored at `/objects/<id>` of `doc`: its
|
|
259
|
+
* logical values, every collaborative Text in it (§30) and every concurrent
|
|
260
|
+
* value of its scalar registers (§45), one diagnostic per field. Shared by
|
|
261
|
+
* every profile whose Tasks are SOP Tasks (SHARED-SECTIONS-PROFILE-01 §2).
|
|
262
|
+
*/
|
|
263
|
+
export function storedObjectProblems(doc, stored, id) {
|
|
264
|
+
const at = `/objects/${pointerToken(id)}`;
|
|
265
|
+
const all = [
|
|
266
|
+
...objectProblems(plain(stored), id).filter((p) => p.pointer.startsWith(at)),
|
|
267
|
+
...textProblems(doc, stored, id),
|
|
268
|
+
...conflictValueProblems(stored, id),
|
|
269
|
+
];
|
|
270
|
+
// §74.1: one diagnostic per field, the first in table order over every value.
|
|
271
|
+
return firstPerField(all.filter((p, i) => all.findIndex((q) => q.pointer === p.pointer && q.diagnostic === p.diagnostic) === i), at);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Checks one Task intent against `objects`, the stored objects map of
|
|
275
|
+
* `doc`, before anything is written: §21 no reuse of an Object ID on
|
|
276
|
+
* create, §74 the Task after the intent is valid, and no Text left in a
|
|
277
|
+
* field it writes. Throws ProfileError or ObjectIdCollisionError. Shared by
|
|
278
|
+
* every profile whose Tasks are SOP Tasks (SHARED-SECTIONS-PROFILE-01 §2).
|
|
279
|
+
*/
|
|
280
|
+
export function prepareTaskIntent(doc, objects, intent) {
|
|
281
|
+
if (intent.intent === "task.create") {
|
|
282
|
+
const task = intent.task;
|
|
283
|
+
const id = String(task.id);
|
|
284
|
+
// §21: a local create never reuses an Object ID this replica already holds.
|
|
285
|
+
if (id in objects)
|
|
286
|
+
throw new ObjectIdCollisionError(id);
|
|
287
|
+
const problems = objectProblems(task, id);
|
|
288
|
+
if (problems.length > 0)
|
|
289
|
+
throw new ProfileError(problems);
|
|
290
|
+
return Object.freeze({
|
|
291
|
+
id,
|
|
292
|
+
perform: (o) => {
|
|
293
|
+
o[id] = scalarize(task, 0); // §53: the whole Task in one change (the object map is depth 0)
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
const id = intent.id;
|
|
298
|
+
const stored = objects[id];
|
|
299
|
+
if (!isMap(stored))
|
|
300
|
+
throw new ProfileError(objectProblems(undefined, id));
|
|
301
|
+
if (A.getConflicts(objects, id) !== undefined)
|
|
302
|
+
throw new ObjectIdCollisionError(id);
|
|
303
|
+
const object = stored;
|
|
304
|
+
const writes = writesOf(intent);
|
|
305
|
+
const touched = new Set(writes.map((w) => (w.op === "put" || w.op === "delete" ? w.field : w.set)));
|
|
306
|
+
const next = candidate(plain(object), writes);
|
|
307
|
+
const problems = [
|
|
308
|
+
...(next.type === "task"
|
|
309
|
+
? objectProblems(next, id)
|
|
310
|
+
: [problem("INVALID_FIELD_TYPE", `/objects/${pointerToken(id)}/type`, "not a Task (§25)")]),
|
|
311
|
+
// Text in a field this intent writes is replaced by the write.
|
|
312
|
+
...textProblems(doc, object, id).filter((p) => !touched.has(unescapeToken(p.pointer.split("/")[3] ?? ""))),
|
|
313
|
+
];
|
|
314
|
+
if (problems.length > 0)
|
|
315
|
+
throw new ProfileError(problems);
|
|
316
|
+
return Object.freeze({
|
|
317
|
+
id,
|
|
318
|
+
perform: (o) => perform(o[id], writes),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
224
321
|
function writesOf(intent) {
|
|
225
322
|
const put = (field, value) => ({ op: "put", field, value });
|
|
226
323
|
const del = (field) => ({ op: "delete", field });
|
|
@@ -314,7 +411,8 @@ const actorHex = (opts) => toHex(deriveActorId(opts.resource, opts.principal));
|
|
|
314
411
|
* the change refused): it is rethrown as it is, never turned into
|
|
315
412
|
* INVALID_AUTOMERGE_BYTES, so a crash-loop breaker sees it.
|
|
316
413
|
*/
|
|
317
|
-
|
|
414
|
+
/** A wasm trap (RuntimeError, or a terminated module): fatal, not a refusable change. */
|
|
415
|
+
export function isWasmTrap(e) {
|
|
318
416
|
const trap = globalThis
|
|
319
417
|
.WebAssembly?.RuntimeError;
|
|
320
418
|
if (trap !== undefined && e instanceof trap)
|
|
@@ -330,7 +428,19 @@ function isWasmTrap(e) {
|
|
|
330
428
|
* is: the module is dead, and only a restart recovers. Exported for tests.
|
|
331
429
|
*/
|
|
332
430
|
export function applyChecked(doc, bytes) {
|
|
431
|
+
// The change's good state, captured before the apply: a change the engine
|
|
432
|
+
// aborts on (D1: a makeTable it cannot write back) stays in the document's
|
|
433
|
+
// graph (it does not save loadably) or aborts the apply mid-way, so the
|
|
434
|
+
// document after the error is not a reliable restore source. Rebuilding
|
|
435
|
+
// from the changes it held before the apply always gives its prior state.
|
|
436
|
+
//
|
|
437
|
+
// Do NOT "simplify" this by applying to A.clone(doc) and returning the
|
|
438
|
+
// original on error: A.clone(doc) gives the clone a NEW actor id, which
|
|
439
|
+
// breaks local writes (§8 actor check) and changes merge/convergence.
|
|
440
|
+
// Apply to `doc` itself and restore from `good` only on the error path.
|
|
333
441
|
const before = [...A.getHeads(doc)].sort().join();
|
|
442
|
+
const actor = A.getActorId(doc);
|
|
443
|
+
const good = A.getAllChanges(doc);
|
|
334
444
|
try {
|
|
335
445
|
return { next: A.applyChanges(doc, [bytes])[0] };
|
|
336
446
|
}
|
|
@@ -338,18 +448,22 @@ export function applyChecked(doc, bytes) {
|
|
|
338
448
|
if (isWasmTrap(e))
|
|
339
449
|
throw e;
|
|
340
450
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
341
|
-
|
|
342
|
-
try {
|
|
343
|
-
[restored] = A.applyChanges(A.init({ actor: A.getActorId(doc) }), A.getAllChanges(doc));
|
|
344
|
-
}
|
|
345
|
-
catch (r) {
|
|
346
|
-
throw new Error(`the replica could not be restored after an Automerge error: ${String(r)}`);
|
|
347
|
-
}
|
|
348
|
-
if ([...A.getHeads(restored)].sort().join() !== before)
|
|
349
|
-
throw new Error("the replica could not be restored after an Automerge error: heads differ");
|
|
350
|
-
return { restored, error };
|
|
451
|
+
return { restored: restoreFrom(good, actor, before), error };
|
|
351
452
|
}
|
|
352
453
|
}
|
|
454
|
+
/** Rebuilds a document from the changes it held before an aborted apply (never from the aborted one). */
|
|
455
|
+
function restoreFrom(good, actor, before) {
|
|
456
|
+
let restored;
|
|
457
|
+
try {
|
|
458
|
+
[restored] = A.applyChanges(A.init({ actor }), [...good]);
|
|
459
|
+
}
|
|
460
|
+
catch (r) {
|
|
461
|
+
throw new Error(`the replica could not be restored after an Automerge error: ${String(r)}`);
|
|
462
|
+
}
|
|
463
|
+
if ([...A.getHeads(restored)].sort().join() !== before)
|
|
464
|
+
throw new Error("the replica could not be restored after an Automerge error: heads differ");
|
|
465
|
+
return restored;
|
|
466
|
+
}
|
|
353
467
|
/**
|
|
354
468
|
* applyChecked for a batch of admitted changes, in one engine call. On any
|
|
355
469
|
* engine error the handle is dropped and the document as it was before is
|
|
@@ -357,7 +471,12 @@ export function applyChecked(doc, bytes) {
|
|
|
357
471
|
* heads it had. Exported for tests.
|
|
358
472
|
*/
|
|
359
473
|
export function applyBatchChecked(doc, batch) {
|
|
360
|
-
|
|
474
|
+
// The changes held before the batch, the restore source (see applyChecked):
|
|
475
|
+
// on an abort the caller re-applies the batch one change at a time to isolate
|
|
476
|
+
// the bad one.
|
|
477
|
+
const before = [...A.getHeads(doc)].sort().join();
|
|
478
|
+
const actor = A.getActorId(doc);
|
|
479
|
+
const good = A.getAllChanges(doc);
|
|
361
480
|
try {
|
|
362
481
|
return {
|
|
363
482
|
next: A.applyChanges(doc, batch.map((c) => c.bytes))[0],
|
|
@@ -367,23 +486,9 @@ export function applyBatchChecked(doc, batch) {
|
|
|
367
486
|
if (isWasmTrap(e))
|
|
368
487
|
throw e;
|
|
369
488
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
370
|
-
return { restored:
|
|
489
|
+
return { restored: restoreFrom(good, actor, before), error };
|
|
371
490
|
}
|
|
372
491
|
}
|
|
373
|
-
function restoreWithout(doc, batch, heads) {
|
|
374
|
-
const out = new Set(batch.map((c) => c.hash));
|
|
375
|
-
const kept = A.getAllChanges(doc).filter((c) => !out.has(A.decodeChange(c).hash));
|
|
376
|
-
let restored;
|
|
377
|
-
try {
|
|
378
|
-
[restored] = A.applyChanges(A.init({ actor: A.getActorId(doc) }), kept);
|
|
379
|
-
}
|
|
380
|
-
catch (r) {
|
|
381
|
-
throw new Error(`the replica could not be restored after an Automerge error: ${String(r)}`);
|
|
382
|
-
}
|
|
383
|
-
if ([...A.getHeads(restored)].sort().join() !== [...heads].sort().join())
|
|
384
|
-
throw new Error("the replica could not be restored after an Automerge error: heads differ");
|
|
385
|
-
return restored;
|
|
386
|
-
}
|
|
387
492
|
function loadFailure(e, what) {
|
|
388
493
|
if (e instanceof LfcpError)
|
|
389
494
|
throw e;
|
|
@@ -397,8 +502,6 @@ export class SharedObjectsReplica {
|
|
|
397
502
|
#actor;
|
|
398
503
|
#minSeq;
|
|
399
504
|
#doc;
|
|
400
|
-
/** §11.4: the document's changes as references read them; null to build again from the document. */
|
|
401
|
-
#refs = null;
|
|
402
505
|
/** Highest sequence seen per actor (hex). */
|
|
403
506
|
#seqs = new Map();
|
|
404
507
|
/** §11.2: known object depths (object ID → depth below the root), filled as objects appear. */
|
|
@@ -409,6 +512,8 @@ export class SharedObjectsReplica {
|
|
|
409
512
|
* at the view's heads, so the state before a change cannot be asked later.
|
|
410
513
|
*/
|
|
411
514
|
#conflicted = new Map();
|
|
515
|
+
/** §11.4: the document's changes as references read them; null to build again from the document. */
|
|
516
|
+
#refs = null;
|
|
412
517
|
constructor(doc, opts) {
|
|
413
518
|
this.resource = opts.resource;
|
|
414
519
|
this.#principal = opts.principal;
|
|
@@ -428,6 +533,15 @@ export class SharedObjectsReplica {
|
|
|
428
533
|
* same batch). Refuses an object deeper than MAX_DOCUMENT_DEPTH, or one
|
|
429
534
|
* written into an object the document does not have, before the engine.
|
|
430
535
|
*/
|
|
536
|
+
/** The document as the admission module reads it (§14.1). */
|
|
537
|
+
#sequences() {
|
|
538
|
+
this.#refs ??= referenceHistoryOf(this.#doc);
|
|
539
|
+
return {
|
|
540
|
+
hasChange: (hash) => A.hasHeads(this.#doc, [hash]),
|
|
541
|
+
latestSeq: (actor) => this.#seqs.get(actor) ?? 0,
|
|
542
|
+
references: this.#refs,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
431
545
|
#createdDepths(change, prior) {
|
|
432
546
|
const decoded = A.decodeChange(change.bytes);
|
|
433
547
|
const created = new Map();
|
|
@@ -682,46 +796,9 @@ export class SharedObjectsReplica {
|
|
|
682
796
|
collisions: Object.freeze(this.collisions()),
|
|
683
797
|
});
|
|
684
798
|
}
|
|
685
|
-
#objectProblems(id, stored) {
|
|
686
|
-
const at = `/objects/${pointerToken(id)}`;
|
|
687
|
-
const all = [
|
|
688
|
-
...objectProblems(plain(stored), id).filter((p) => p.pointer.startsWith(at)),
|
|
689
|
-
...textProblems(this.#doc, stored, id),
|
|
690
|
-
...conflictValueProblems(stored, id),
|
|
691
|
-
];
|
|
692
|
-
// §74.1: one diagnostic per field, the first in table order over every value.
|
|
693
|
-
return firstPerField(all.filter((p, i) => all.findIndex((q) => q.pointer === p.pointer && q.diagnostic === p.diagnostic) === i), at);
|
|
694
|
-
}
|
|
695
799
|
/** §99: the Task under `id` with conflict metadata, or undefined if there is no object or it is not a Task. */
|
|
696
800
|
task(id) {
|
|
697
|
-
|
|
698
|
-
const stored = objects?.[id];
|
|
699
|
-
if (objects === undefined || !isMap(stored))
|
|
700
|
-
return undefined;
|
|
701
|
-
const object = stored;
|
|
702
|
-
if (plain(object.type) !== "task")
|
|
703
|
-
return undefined;
|
|
704
|
-
const collided = A.getConflicts(objects, id) !== undefined;
|
|
705
|
-
const problems = this.#objectProblems(id, object);
|
|
706
|
-
const parsed = parseTask(plain(object), id);
|
|
707
|
-
const field = (f) => {
|
|
708
|
-
const values = valuesOf(object, f).map(plain).sort(byJson);
|
|
709
|
-
return Object.freeze({
|
|
710
|
-
value: f in object ? plain(object[f]) : undefined,
|
|
711
|
-
values: Object.freeze(values),
|
|
712
|
-
conflicted: values.length > 1,
|
|
713
|
-
});
|
|
714
|
-
};
|
|
715
|
-
const keys = (f) => isMap(plain(object[f])) ? Object.keys(plain(object[f])).sort() : [];
|
|
716
|
-
return Object.freeze({
|
|
717
|
-
id,
|
|
718
|
-
status: collided ? "object_id_collision" : problems.length > 0 ? "profile_invalid" : "ready",
|
|
719
|
-
problems: Object.freeze(problems),
|
|
720
|
-
task: parsed.valid ? parsed.task : undefined,
|
|
721
|
-
fields: Object.freeze(Object.fromEntries(SCALAR_FIELDS.map((f) => [f, field(f)]))),
|
|
722
|
-
tags: Object.freeze(keys("tags")),
|
|
723
|
-
assignees: Object.freeze(keys("assignees")),
|
|
724
|
-
});
|
|
801
|
+
return taskView(this.#doc, this.#objects(), id);
|
|
725
802
|
}
|
|
726
803
|
/**
|
|
727
804
|
* Applies one intent as exactly one Automerge change (§10) and returns it,
|
|
@@ -735,39 +812,8 @@ export class SharedObjectsReplica {
|
|
|
735
812
|
const rootProblems = validateRoot(this.root()).problems.filter((p) => !p.pointer.startsWith("/objects/"));
|
|
736
813
|
if (objects === undefined || rootProblems.length > 0)
|
|
737
814
|
throw new ProfileError(rootProblems.length > 0 ? rootProblems : [problem("INVALID_ROOT", "/", "no root (§15)")]);
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
const id = String(task.id);
|
|
741
|
-
// §21: a local create never reuses an Object ID this replica already holds.
|
|
742
|
-
if (id in objects)
|
|
743
|
-
throw new ObjectIdCollisionError(id);
|
|
744
|
-
const problems = objectProblems(task, id);
|
|
745
|
-
if (problems.length > 0)
|
|
746
|
-
throw new ProfileError(problems);
|
|
747
|
-
return this.#commit(intent.intent, [id], (d) => {
|
|
748
|
-
d.objects[id] = scalarize(task, 0); // §53: the whole Task in one change (the object map is depth 0)
|
|
749
|
-
});
|
|
750
|
-
}
|
|
751
|
-
const id = intent.id;
|
|
752
|
-
const stored = objects[id];
|
|
753
|
-
if (!isMap(stored))
|
|
754
|
-
throw new ProfileError(objectProblems(undefined, id));
|
|
755
|
-
if (A.getConflicts(objects, id) !== undefined)
|
|
756
|
-
throw new ObjectIdCollisionError(id);
|
|
757
|
-
const object = stored;
|
|
758
|
-
const writes = writesOf(intent);
|
|
759
|
-
const touched = new Set(writes.map((w) => (w.op === "put" || w.op === "delete" ? w.field : w.set)));
|
|
760
|
-
const next = candidate(plain(object), writes);
|
|
761
|
-
const problems = [
|
|
762
|
-
...(next.type === "task"
|
|
763
|
-
? objectProblems(next, id)
|
|
764
|
-
: [problem("INVALID_FIELD_TYPE", `/objects/${pointerToken(id)}/type`, "not a Task (§25)")]),
|
|
765
|
-
// Text in a field this intent writes is replaced by the write.
|
|
766
|
-
...textProblems(this.#doc, object, id).filter((p) => !touched.has(unescapeToken(p.pointer.split("/")[3] ?? ""))),
|
|
767
|
-
];
|
|
768
|
-
if (problems.length > 0)
|
|
769
|
-
throw new ProfileError(problems);
|
|
770
|
-
return this.#commit(intent.intent, [id], (d) => perform(d.objects[id], writes));
|
|
815
|
+
const prepared = prepareTaskIntent(this.#doc, objects, intent);
|
|
816
|
+
return this.#commit(intent.intent, [prepared.id], (d) => prepared.perform(d.objects));
|
|
771
817
|
}
|
|
772
818
|
#commit(message, ids, fn) {
|
|
773
819
|
if (!this.writable)
|
|
@@ -800,8 +846,8 @@ export class SharedObjectsReplica {
|
|
|
800
846
|
if (checked.seq !== this.actorSeq + 1)
|
|
801
847
|
throw new LfcpError("SEQUENCE_REUSE", `actor sequence ${checked.seq} after ${this.actorSeq} (§9)`);
|
|
802
848
|
this.#doc = next;
|
|
803
|
-
this.#refs?.add(checked.hash, parsedOf(checked));
|
|
804
849
|
this.#noteSeq(checked.actor, checked.seq);
|
|
850
|
+
this.#refs?.add(checked.hash, parsedOf(checked));
|
|
805
851
|
for (const [id, d] of created)
|
|
806
852
|
this.#depths.set(id, d);
|
|
807
853
|
return Object.freeze({
|
|
@@ -846,85 +892,19 @@ export class SharedObjectsReplica {
|
|
|
846
892
|
seen.add(checked.hash);
|
|
847
893
|
all.push(checked);
|
|
848
894
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
const children = new Map();
|
|
853
|
-
all.forEach((c, i) => {
|
|
854
|
-
for (const d of c.deps) {
|
|
855
|
-
if (A.hasHeads(this.#doc, [d]))
|
|
856
|
-
continue;
|
|
857
|
-
if (index.has(d)) {
|
|
858
|
-
blocking[i] = blocking[i] + 1;
|
|
859
|
-
children.set(d, [...(children.get(d) ?? []), i]);
|
|
860
|
-
}
|
|
861
|
-
else
|
|
862
|
-
unreachable[i] = true;
|
|
863
|
-
}
|
|
864
|
-
});
|
|
865
|
-
const seqs = new Map();
|
|
866
|
-
const latest = (actor) => seqs.get(actor) ?? this.#seqs.get(actor) ?? 0;
|
|
867
|
-
const admitted = [];
|
|
895
|
+
// §11.1, §14.1 (the admission module both profiles share), then §11.2:
|
|
896
|
+
// the depths of the objects a change creates, given the document and
|
|
897
|
+
// the batch so far.
|
|
868
898
|
const batchDepths = new Map();
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
else if (c.seq <= latest(c.actor)) {
|
|
879
|
-
refused.push({
|
|
880
|
-
change: c,
|
|
881
|
-
error: new LfcpError("ACTOR_EQUIVOCATION", `actor ${c.actor} sequence ${c.seq} already has a different change (§26.2)`),
|
|
882
|
-
});
|
|
883
|
-
continue;
|
|
884
|
-
}
|
|
885
|
-
else if (c.otherActors.some((a) => latest(a) === 0)) {
|
|
886
|
-
refused.push({
|
|
887
|
-
change: c,
|
|
888
|
-
error: new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `the change names an actor unknown to this document (§11.1)`),
|
|
889
|
-
});
|
|
890
|
-
continue;
|
|
891
|
-
}
|
|
892
|
-
else if (c.seq !== latest(c.actor) + 1) {
|
|
893
|
-
refused.push({
|
|
894
|
-
change: c,
|
|
895
|
-
error: new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `actor ${c.actor} sequence ${c.seq} skips sequence ${latest(c.actor) + 1} (§14.1)`),
|
|
896
|
-
});
|
|
897
|
-
continue;
|
|
898
|
-
}
|
|
899
|
-
else {
|
|
900
|
-
// §11.4: the operations refer only to the change's causal history.
|
|
901
|
-
const rule = this.#references().check(parsedOf(c));
|
|
902
|
-
if (rule !== null) {
|
|
903
|
-
refused.push({ change: c, error: referenceError(rule) });
|
|
904
|
-
continue;
|
|
905
|
-
}
|
|
906
|
-
// §11.2: the depths of the objects it creates, given the document and the batch so far.
|
|
907
|
-
let created;
|
|
908
|
-
try {
|
|
909
|
-
created = this.#createdDepths(c, batchDepths);
|
|
910
|
-
}
|
|
911
|
-
catch (e) {
|
|
912
|
-
refused.push({ change: c, error: e });
|
|
913
|
-
continue;
|
|
914
|
-
}
|
|
915
|
-
for (const [id, d] of created)
|
|
916
|
-
batchDepths.set(id, d);
|
|
917
|
-
seqs.set(c.actor, c.seq);
|
|
918
|
-
this.#references().add(c.hash, parsedOf(c));
|
|
919
|
-
admitted.push(c);
|
|
920
|
-
}
|
|
921
|
-
for (const child of children.get(c.hash) ?? []) {
|
|
922
|
-
blocking[child] = blocking[child] - 1;
|
|
923
|
-
if (blocking[child] === 0 && !unreachable[child])
|
|
924
|
-
ready.push(child);
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
const waiting = all.filter((_, i) => !done[i]);
|
|
899
|
+
const decided = admitBatch(all, this.#sequences(), (c) => {
|
|
900
|
+
for (const [id, d] of this.#createdDepths(c, batchDepths))
|
|
901
|
+
batchDepths.set(id, d);
|
|
902
|
+
});
|
|
903
|
+
for (const r of decided.refused)
|
|
904
|
+
refused.push({ change: r.change, error: r.error });
|
|
905
|
+
const admitted = [...decided.admitted];
|
|
906
|
+
const duplicates = [...decided.duplicates];
|
|
907
|
+
const waiting = [...decided.waiting];
|
|
928
908
|
const before = A.getHeads(this.#doc);
|
|
929
909
|
let applied = admitted;
|
|
930
910
|
if (admitted.length > 0) {
|
|
@@ -938,7 +918,7 @@ export class SharedObjectsReplica {
|
|
|
938
918
|
}
|
|
939
919
|
else {
|
|
940
920
|
this.#doc = batch.restored;
|
|
941
|
-
// The index already holds the batch
|
|
921
|
+
// The index already holds the batch (admitBatch adds what it admits).
|
|
942
922
|
this.#refs = null;
|
|
943
923
|
applied = [];
|
|
944
924
|
for (const c of admitted) {
|
|
@@ -968,40 +948,23 @@ export class SharedObjectsReplica {
|
|
|
968
948
|
objects: Object.freeze(applied.length > 0 ? this.#objectChanges(before, "remote") : []),
|
|
969
949
|
});
|
|
970
950
|
}
|
|
971
|
-
/** The reference index of the document (§11.4), built once and kept with it. */
|
|
972
|
-
#references() {
|
|
973
|
-
this.#refs ??= referenceHistoryOf(this.#doc);
|
|
974
|
-
return this.#refs;
|
|
975
|
-
}
|
|
976
951
|
#receive(change) {
|
|
977
|
-
|
|
952
|
+
// §11.1, §14.1 before Automerge sees it (the admission module both
|
|
953
|
+
// profiles share): Automerge 3.5.0 records a skipping change in its graph
|
|
954
|
+
// without its operations, so the document no longer saves loadably
|
|
955
|
+
// (automerge-rs 0.12 aborts), and aborts on an unknown actor. A taken
|
|
956
|
+
// sequence is an equivocation (or, for our own actor, a lost-state fork, §9).
|
|
957
|
+
const admission = admitChange(change, this.#sequences());
|
|
958
|
+
if (admission.kind === "duplicate")
|
|
978
959
|
return Object.freeze({ status: "duplicate", change });
|
|
979
|
-
|
|
980
|
-
if (missing.length > 0)
|
|
960
|
+
if (admission.kind === "missing")
|
|
981
961
|
return Object.freeze({
|
|
982
962
|
status: "missing_dependencies",
|
|
983
963
|
change,
|
|
984
|
-
missing:
|
|
964
|
+
missing: admission.missing,
|
|
985
965
|
});
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
if (change.seq <= (this.#seqs.get(change.actor) ?? 0))
|
|
989
|
-
throw new LfcpError("ACTOR_EQUIVOCATION", `actor ${change.actor} sequence ${change.seq} already has a different change (§26.2)`);
|
|
990
|
-
// §14.1: with every dependency present, the sequence follows the
|
|
991
|
-
// actor's latest change. Checked before Automerge sees it: Automerge
|
|
992
|
-
// 3.5.0 records a skipping change in its graph without its operations
|
|
993
|
-
// and the document no longer saves loadably (automerge-rs 0.12 aborts).
|
|
994
|
-
// §11.1: every other actor of the change is already an actor of the
|
|
995
|
-
// document (Automerge aborts on an unknown one).
|
|
996
|
-
const unknown = change.otherActors.find((a) => !this.#seqs.has(a));
|
|
997
|
-
if (unknown !== undefined)
|
|
998
|
-
throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `the change names actor ${unknown}, unknown to this document (§11.1)`);
|
|
999
|
-
if (change.seq !== (this.#seqs.get(change.actor) ?? 0) + 1)
|
|
1000
|
-
throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `actor ${change.actor} sequence ${change.seq} skips sequence ${(this.#seqs.get(change.actor) ?? 0) + 1} (§14.1)`);
|
|
1001
|
-
// §11.4: the operations refer only to the change's causal history.
|
|
1002
|
-
const rule = this.#references().check(parsedOf(change));
|
|
1003
|
-
if (rule !== null)
|
|
1004
|
-
throw referenceError(rule);
|
|
966
|
+
if (admission.kind === "held" || admission.kind === "invalid")
|
|
967
|
+
throw admission.error;
|
|
1005
968
|
// §11.2: no object deeper than MAX_DOCUMENT_DEPTH, before the engine.
|
|
1006
969
|
const created = this.#createdDepths(change, new Map());
|
|
1007
970
|
const before = A.getHeads(this.#doc);
|
|
@@ -1013,8 +976,8 @@ export class SharedObjectsReplica {
|
|
|
1013
976
|
}
|
|
1014
977
|
const next = applied.next;
|
|
1015
978
|
this.#doc = next;
|
|
1016
|
-
this.#refs?.add(change.hash, parsedOf(change));
|
|
1017
979
|
this.#noteSeq(change.actor, change.seq);
|
|
980
|
+
this.#refs?.add(change.hash, parsedOf(change));
|
|
1018
981
|
for (const [id, d] of created)
|
|
1019
982
|
this.#depths.set(id, d);
|
|
1020
983
|
return Object.freeze({
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as A from "@automerge/automerge";
|
|
2
|
+
import { LfcpError, type ResourceId } from "@openlfcp/core";
|
|
3
|
+
import { type CheckedChange } from "../admission/framing.js";
|
|
4
|
+
/**
|
|
5
|
+
* The structural admission of SHARED-SECTIONS-PROFILE-01 §14.1 (LFCP-02-017):
|
|
6
|
+
* rules A1–A5 and the readiness rule of §12.1, decided from the change's own
|
|
7
|
+
* operations and the objects they write into, in time linear in the change.
|
|
8
|
+
* It runs after the inherited SOP checks (framing, limits, actor, sequence)
|
|
9
|
+
* and before the engine applies anything; a refused change is never merged.
|
|
10
|
+
*
|
|
11
|
+
* Objects of the document are identified by their path from the root
|
|
12
|
+
* (Automerge objInfo); objects created by changes admitted earlier in the
|
|
13
|
+
* same batch, or earlier in the change, by the operation that creates them.
|
|
14
|
+
*/
|
|
15
|
+
/** §14.1: the admission diagnostics, in their order of precedence. */
|
|
16
|
+
export declare const ADMISSION_ORDER: readonly ["INVALID_AUTOMERGE_BYTES", "CHANGE_ACTOR_MISMATCH", "CONTAINER_REPLACED", "CHILDREN_LIST_MUTATED", "PLACEMENT_NOT_ATOMIC", "IMMUTABLE_FIELD_MUTATED", "INVALID_FIELD_TYPE"];
|
|
17
|
+
export type AdmissionDiagnostic = (typeof ADMISSION_ORDER)[number];
|
|
18
|
+
/** A change refused at admission: PROFILE_INVALID with its §14.1 diagnostic. */
|
|
19
|
+
export declare class SectionAdmissionError extends LfcpError {
|
|
20
|
+
readonly diagnostic: AdmissionDiagnostic;
|
|
21
|
+
constructor(diagnostic: AdmissionDiagnostic, message: string);
|
|
22
|
+
}
|
|
23
|
+
export declare class SectionAdmission {
|
|
24
|
+
#private;
|
|
25
|
+
constructor(doc: A.Doc<unknown>, resource: ResourceId);
|
|
26
|
+
/** The document with the admitted changes applied. */
|
|
27
|
+
get document(): A.Doc<unknown>;
|
|
28
|
+
/**
|
|
29
|
+
* Admits `change` against its causal history, or throws
|
|
30
|
+
* SectionAdmissionError. An admitted change is applied to the working
|
|
31
|
+
* document at once, so the changes after it in the batch see it.
|
|
32
|
+
*/
|
|
33
|
+
accept(change: CheckedChange): void;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=admission.d.ts.map
|