@openlfcp/shared-objects 0.1.5 → 0.2.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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} +9 -0
- package/dist/{canonical.js → admission/canonical.js} +43 -3
- package/dist/{automerge-bytes.d.ts → admission/framing.d.ts} +41 -1
- package/dist/admission/framing.js +242 -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} +9 -3
- package/dist/admission/sequence.d.ts +81 -0
- package/dist/admission/sequence.js +147 -0
- package/dist/concurrent.d.ts +15 -0
- package/dist/concurrent.js +55 -0
- package/dist/data-profile.d.ts +1 -1
- package/dist/data-profile.js +3 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/replica.d.ts +32 -2
- package/dist/replica.js +165 -209
- 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 +406 -0
- package/dist/sections/replica.js +1227 -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 +401 -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,13 @@
|
|
|
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";
|
|
7
|
+
import { distinctValues, isConflicted } from "./concurrent.js";
|
|
5
8
|
/** The decoded actions that create an object (§11.2). */
|
|
6
9
|
const MAKE_ACTIONS = new Set(["makeMap", "makeList", "makeText", "makeTable"]);
|
|
7
10
|
import { ProfileInvalidError } from "./profile-invalid.js";
|
|
8
|
-
import { referenceHistoryOf } from "./references.js";
|
|
9
11
|
import { ProfileError, parseTask } from "./task.js";
|
|
10
12
|
import { firstPerField, isMap, objectProblems, pointerToken, validateRoot, } from "./validate.js";
|
|
11
13
|
import { deriveActorId, frameProfilePayload, PROFILE_ID } from "./values.js";
|
|
@@ -56,8 +58,6 @@ export function resolveFieldConflict(id, field, value) {
|
|
|
56
58
|
throw new LfcpError("UNSUPPORTED_VALUE", `only a date field can be cleared (§36)`);
|
|
57
59
|
return Object.freeze({ intent: "task.resolve_field_conflict", id, field, value });
|
|
58
60
|
}
|
|
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
61
|
/** §21: two concurrent objects under one Object ID: OBJECT_ID_COLLISION, a profile error of its own, not PROFILE_INVALID. */
|
|
62
62
|
export class ObjectIdCollisionError extends LfcpError {
|
|
63
63
|
objectId;
|
|
@@ -154,17 +154,13 @@ function canonical(value) {
|
|
|
154
154
|
.map((k) => [k, canonical(value[k])]));
|
|
155
155
|
return value;
|
|
156
156
|
}
|
|
157
|
-
/**
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return conflicts === undefined ? [map[key]] : Object.values(conflicts);
|
|
163
|
-
}
|
|
164
|
-
/** Top-level fields of `object` with more than one concurrent value. */
|
|
165
|
-
function conflictedFields(object) {
|
|
157
|
+
/**
|
|
158
|
+
* Top-level fields of `object` in conflict: two or more distinct concurrent
|
|
159
|
+
* values (§45; concurrent equal values agree).
|
|
160
|
+
*/
|
|
161
|
+
function conflictedFields(doc, object) {
|
|
166
162
|
return Object.keys(object)
|
|
167
|
-
.filter((f) =>
|
|
163
|
+
.filter((f) => isConflicted(doc, object, f))
|
|
168
164
|
.sort();
|
|
169
165
|
}
|
|
170
166
|
const problem = (diagnostic, pointer, message) => Object.freeze({ code: "PROFILE_INVALID", diagnostic, pointer, message });
|
|
@@ -221,6 +217,104 @@ function conflictValueProblems(object, key) {
|
|
|
221
217
|
}
|
|
222
218
|
return out;
|
|
223
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* §99: the Task under `id` of `objects` (the document's `objects` map) with
|
|
222
|
+
* conflict metadata, or undefined if there is no object or it is not a Task.
|
|
223
|
+
* Shared by the Shared Objects and the Shared Sections replicas.
|
|
224
|
+
*/
|
|
225
|
+
export function taskView(doc, objects, id) {
|
|
226
|
+
const stored = objects?.[id];
|
|
227
|
+
if (objects === undefined || !isMap(stored))
|
|
228
|
+
return undefined;
|
|
229
|
+
const object = stored;
|
|
230
|
+
if (plain(object.type) !== "task")
|
|
231
|
+
return undefined;
|
|
232
|
+
const collided = A.getConflicts(objects, id) !== undefined;
|
|
233
|
+
const problems = storedObjectProblems(doc, object, id);
|
|
234
|
+
const parsed = parseTask(plain(object), id);
|
|
235
|
+
const field = (f) => {
|
|
236
|
+
const values = distinctValues(doc, object, f).map(plain).sort(byJson);
|
|
237
|
+
return Object.freeze({
|
|
238
|
+
value: f in object ? plain(object[f]) : undefined,
|
|
239
|
+
values: Object.freeze(values),
|
|
240
|
+
conflicted: values.length > 1,
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
const keys = (f) => isMap(plain(object[f])) ? Object.keys(plain(object[f])).sort() : [];
|
|
244
|
+
return Object.freeze({
|
|
245
|
+
id,
|
|
246
|
+
status: collided ? "object_id_collision" : problems.length > 0 ? "profile_invalid" : "ready",
|
|
247
|
+
problems: Object.freeze(problems),
|
|
248
|
+
task: parsed.valid ? parsed.task : undefined,
|
|
249
|
+
fields: Object.freeze(Object.fromEntries(SCALAR_FIELDS.map((f) => [f, field(f)]))),
|
|
250
|
+
tags: Object.freeze(keys("tags")),
|
|
251
|
+
assignees: Object.freeze(keys("assignees")),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* §74.1 problems of one object as stored at `/objects/<id>` of `doc`: its
|
|
256
|
+
* logical values, every collaborative Text in it (§30) and every concurrent
|
|
257
|
+
* value of its scalar registers (§45), one diagnostic per field. Shared by
|
|
258
|
+
* every profile whose Tasks are SOP Tasks (SHARED-SECTIONS-PROFILE-01 §2).
|
|
259
|
+
*/
|
|
260
|
+
export function storedObjectProblems(doc, stored, id) {
|
|
261
|
+
const at = `/objects/${pointerToken(id)}`;
|
|
262
|
+
const all = [
|
|
263
|
+
...objectProblems(plain(stored), id).filter((p) => p.pointer.startsWith(at)),
|
|
264
|
+
...textProblems(doc, stored, id),
|
|
265
|
+
...conflictValueProblems(stored, id),
|
|
266
|
+
];
|
|
267
|
+
// §74.1: one diagnostic per field, the first in table order over every value.
|
|
268
|
+
return firstPerField(all.filter((p, i) => all.findIndex((q) => q.pointer === p.pointer && q.diagnostic === p.diagnostic) === i), at);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Checks one Task intent against `objects`, the stored objects map of
|
|
272
|
+
* `doc`, before anything is written: §21 no reuse of an Object ID on
|
|
273
|
+
* create, §74 the Task after the intent is valid, and no Text left in a
|
|
274
|
+
* field it writes. Throws ProfileError or ObjectIdCollisionError. Shared by
|
|
275
|
+
* every profile whose Tasks are SOP Tasks (SHARED-SECTIONS-PROFILE-01 §2).
|
|
276
|
+
*/
|
|
277
|
+
export function prepareTaskIntent(doc, objects, intent) {
|
|
278
|
+
if (intent.intent === "task.create") {
|
|
279
|
+
const task = intent.task;
|
|
280
|
+
const id = String(task.id);
|
|
281
|
+
// §21: a local create never reuses an Object ID this replica already holds.
|
|
282
|
+
if (id in objects)
|
|
283
|
+
throw new ObjectIdCollisionError(id);
|
|
284
|
+
const problems = objectProblems(task, id);
|
|
285
|
+
if (problems.length > 0)
|
|
286
|
+
throw new ProfileError(problems);
|
|
287
|
+
return Object.freeze({
|
|
288
|
+
id,
|
|
289
|
+
perform: (o) => {
|
|
290
|
+
o[id] = scalarize(task, 0); // §53: the whole Task in one change (the object map is depth 0)
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
const id = intent.id;
|
|
295
|
+
const stored = objects[id];
|
|
296
|
+
if (!isMap(stored))
|
|
297
|
+
throw new ProfileError(objectProblems(undefined, id));
|
|
298
|
+
if (A.getConflicts(objects, id) !== undefined)
|
|
299
|
+
throw new ObjectIdCollisionError(id);
|
|
300
|
+
const object = stored;
|
|
301
|
+
const writes = writesOf(intent);
|
|
302
|
+
const touched = new Set(writes.map((w) => (w.op === "put" || w.op === "delete" ? w.field : w.set)));
|
|
303
|
+
const next = candidate(plain(object), writes);
|
|
304
|
+
const problems = [
|
|
305
|
+
...(next.type === "task"
|
|
306
|
+
? objectProblems(next, id)
|
|
307
|
+
: [problem("INVALID_FIELD_TYPE", `/objects/${pointerToken(id)}/type`, "not a Task (§25)")]),
|
|
308
|
+
// Text in a field this intent writes is replaced by the write.
|
|
309
|
+
...textProblems(doc, object, id).filter((p) => !touched.has(unescapeToken(p.pointer.split("/")[3] ?? ""))),
|
|
310
|
+
];
|
|
311
|
+
if (problems.length > 0)
|
|
312
|
+
throw new ProfileError(problems);
|
|
313
|
+
return Object.freeze({
|
|
314
|
+
id,
|
|
315
|
+
perform: (o) => perform(o[id], writes),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
224
318
|
function writesOf(intent) {
|
|
225
319
|
const put = (field, value) => ({ op: "put", field, value });
|
|
226
320
|
const del = (field) => ({ op: "delete", field });
|
|
@@ -314,6 +408,7 @@ const actorHex = (opts) => toHex(deriveActorId(opts.resource, opts.principal));
|
|
|
314
408
|
* the change refused): it is rethrown as it is, never turned into
|
|
315
409
|
* INVALID_AUTOMERGE_BYTES, so a crash-loop breaker sees it.
|
|
316
410
|
*/
|
|
411
|
+
/** A wasm trap (RuntimeError, or a terminated module): fatal, not a refusable change. */
|
|
317
412
|
export function isWasmTrap(e) {
|
|
318
413
|
const trap = globalThis
|
|
319
414
|
.WebAssembly?.RuntimeError;
|
|
@@ -330,15 +425,16 @@ export function isWasmTrap(e) {
|
|
|
330
425
|
* is: the module is dead, and only a restart recovers. Exported for tests.
|
|
331
426
|
*/
|
|
332
427
|
export function applyChecked(doc, bytes) {
|
|
333
|
-
//
|
|
428
|
+
// The change's good state, captured before the apply: a change the engine
|
|
334
429
|
// aborts on (D1: a makeTable it cannot write back) stays in the document's
|
|
335
|
-
// graph or aborts the apply mid-way, so
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
// always gives its prior state.
|
|
430
|
+
// graph (it does not save loadably) or aborts the apply mid-way, so the
|
|
431
|
+
// document after the error is not a reliable restore source. Rebuilding
|
|
432
|
+
// from the changes it held before the apply always gives its prior state.
|
|
339
433
|
//
|
|
340
|
-
// Do NOT "simplify" this by applying to A.clone(doc)
|
|
341
|
-
// clone a
|
|
434
|
+
// Do NOT "simplify" this by applying to A.clone(doc) and returning the
|
|
435
|
+
// original on error: A.clone(doc) gives the clone a NEW actor id, which
|
|
436
|
+
// breaks local writes (§8 actor check) and changes merge/convergence.
|
|
437
|
+
// Apply to `doc` itself and restore from `good` only on the error path.
|
|
342
438
|
const before = [...A.getHeads(doc)].sort().join();
|
|
343
439
|
const actor = A.getActorId(doc);
|
|
344
440
|
const good = A.getAllChanges(doc);
|
|
@@ -372,7 +468,9 @@ function restoreFrom(good, actor, before) {
|
|
|
372
468
|
* heads it had. Exported for tests.
|
|
373
469
|
*/
|
|
374
470
|
export function applyBatchChecked(doc, batch) {
|
|
375
|
-
//
|
|
471
|
+
// The changes held before the batch, the restore source (see applyChecked):
|
|
472
|
+
// on an abort the caller re-applies the batch one change at a time to isolate
|
|
473
|
+
// the bad one.
|
|
376
474
|
const before = [...A.getHeads(doc)].sort().join();
|
|
377
475
|
const actor = A.getActorId(doc);
|
|
378
476
|
const good = A.getAllChanges(doc);
|
|
@@ -401,8 +499,6 @@ export class SharedObjectsReplica {
|
|
|
401
499
|
#actor;
|
|
402
500
|
#minSeq;
|
|
403
501
|
#doc;
|
|
404
|
-
/** §11.4: the document's changes as references read them; null to build again from the document. */
|
|
405
|
-
#refs = null;
|
|
406
502
|
/** Highest sequence seen per actor (hex). */
|
|
407
503
|
#seqs = new Map();
|
|
408
504
|
/** §11.2: known object depths (object ID → depth below the root), filled as objects appear. */
|
|
@@ -413,6 +509,8 @@ export class SharedObjectsReplica {
|
|
|
413
509
|
* at the view's heads, so the state before a change cannot be asked later.
|
|
414
510
|
*/
|
|
415
511
|
#conflicted = new Map();
|
|
512
|
+
/** §11.4: the document's changes as references read them; null to build again from the document. */
|
|
513
|
+
#refs = null;
|
|
416
514
|
constructor(doc, opts) {
|
|
417
515
|
this.resource = opts.resource;
|
|
418
516
|
this.#principal = opts.principal;
|
|
@@ -424,7 +522,7 @@ export class SharedObjectsReplica {
|
|
|
424
522
|
this.#noteSeq(meta.actor, meta.seq);
|
|
425
523
|
for (const [id, object] of Object.entries(this.#objects() ?? {}))
|
|
426
524
|
if (isMap(object))
|
|
427
|
-
this.#conflicted.set(id, conflictedFields(object));
|
|
525
|
+
this.#conflicted.set(id, conflictedFields(doc, object));
|
|
428
526
|
}
|
|
429
527
|
/**
|
|
430
528
|
* §11.2: the depths of the objects `change` creates (object ID → depth),
|
|
@@ -432,6 +530,15 @@ export class SharedObjectsReplica {
|
|
|
432
530
|
* same batch). Refuses an object deeper than MAX_DOCUMENT_DEPTH, or one
|
|
433
531
|
* written into an object the document does not have, before the engine.
|
|
434
532
|
*/
|
|
533
|
+
/** The document as the admission module reads it (§14.1). */
|
|
534
|
+
#sequences() {
|
|
535
|
+
this.#refs ??= referenceHistoryOf(this.#doc);
|
|
536
|
+
return {
|
|
537
|
+
hasChange: (hash) => A.hasHeads(this.#doc, [hash]),
|
|
538
|
+
latestSeq: (actor) => this.#seqs.get(actor) ?? 0,
|
|
539
|
+
references: this.#refs,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
435
542
|
#createdDepths(change, prior) {
|
|
436
543
|
const decoded = A.decodeChange(change.bytes);
|
|
437
544
|
const created = new Map();
|
|
@@ -639,9 +746,9 @@ export class SharedObjectsReplica {
|
|
|
639
746
|
for (const [id, object] of Object.entries(objects)) {
|
|
640
747
|
if (!isMap(object))
|
|
641
748
|
continue;
|
|
642
|
-
for (const field of conflictedFields(object)) {
|
|
749
|
+
for (const field of conflictedFields(this.#doc, object)) {
|
|
643
750
|
out[id] ??= {};
|
|
644
|
-
out[id][field] =
|
|
751
|
+
out[id][field] = distinctValues(this.#doc, object, field)
|
|
645
752
|
.map(plain)
|
|
646
753
|
.sort(byJson);
|
|
647
754
|
}
|
|
@@ -686,46 +793,9 @@ export class SharedObjectsReplica {
|
|
|
686
793
|
collisions: Object.freeze(this.collisions()),
|
|
687
794
|
});
|
|
688
795
|
}
|
|
689
|
-
#objectProblems(id, stored) {
|
|
690
|
-
const at = `/objects/${pointerToken(id)}`;
|
|
691
|
-
const all = [
|
|
692
|
-
...objectProblems(plain(stored), id).filter((p) => p.pointer.startsWith(at)),
|
|
693
|
-
...textProblems(this.#doc, stored, id),
|
|
694
|
-
...conflictValueProblems(stored, id),
|
|
695
|
-
];
|
|
696
|
-
// §74.1: one diagnostic per field, the first in table order over every value.
|
|
697
|
-
return firstPerField(all.filter((p, i) => all.findIndex((q) => q.pointer === p.pointer && q.diagnostic === p.diagnostic) === i), at);
|
|
698
|
-
}
|
|
699
796
|
/** §99: the Task under `id` with conflict metadata, or undefined if there is no object or it is not a Task. */
|
|
700
797
|
task(id) {
|
|
701
|
-
|
|
702
|
-
const stored = objects?.[id];
|
|
703
|
-
if (objects === undefined || !isMap(stored))
|
|
704
|
-
return undefined;
|
|
705
|
-
const object = stored;
|
|
706
|
-
if (plain(object.type) !== "task")
|
|
707
|
-
return undefined;
|
|
708
|
-
const collided = A.getConflicts(objects, id) !== undefined;
|
|
709
|
-
const problems = this.#objectProblems(id, object);
|
|
710
|
-
const parsed = parseTask(plain(object), id);
|
|
711
|
-
const field = (f) => {
|
|
712
|
-
const values = valuesOf(object, f).map(plain).sort(byJson);
|
|
713
|
-
return Object.freeze({
|
|
714
|
-
value: f in object ? plain(object[f]) : undefined,
|
|
715
|
-
values: Object.freeze(values),
|
|
716
|
-
conflicted: values.length > 1,
|
|
717
|
-
});
|
|
718
|
-
};
|
|
719
|
-
const keys = (f) => isMap(plain(object[f])) ? Object.keys(plain(object[f])).sort() : [];
|
|
720
|
-
return Object.freeze({
|
|
721
|
-
id,
|
|
722
|
-
status: collided ? "object_id_collision" : problems.length > 0 ? "profile_invalid" : "ready",
|
|
723
|
-
problems: Object.freeze(problems),
|
|
724
|
-
task: parsed.valid ? parsed.task : undefined,
|
|
725
|
-
fields: Object.freeze(Object.fromEntries(SCALAR_FIELDS.map((f) => [f, field(f)]))),
|
|
726
|
-
tags: Object.freeze(keys("tags")),
|
|
727
|
-
assignees: Object.freeze(keys("assignees")),
|
|
728
|
-
});
|
|
798
|
+
return taskView(this.#doc, this.#objects(), id);
|
|
729
799
|
}
|
|
730
800
|
/**
|
|
731
801
|
* Applies one intent as exactly one Automerge change (§10) and returns it,
|
|
@@ -739,39 +809,8 @@ export class SharedObjectsReplica {
|
|
|
739
809
|
const rootProblems = validateRoot(this.root()).problems.filter((p) => !p.pointer.startsWith("/objects/"));
|
|
740
810
|
if (objects === undefined || rootProblems.length > 0)
|
|
741
811
|
throw new ProfileError(rootProblems.length > 0 ? rootProblems : [problem("INVALID_ROOT", "/", "no root (§15)")]);
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
const id = String(task.id);
|
|
745
|
-
// §21: a local create never reuses an Object ID this replica already holds.
|
|
746
|
-
if (id in objects)
|
|
747
|
-
throw new ObjectIdCollisionError(id);
|
|
748
|
-
const problems = objectProblems(task, id);
|
|
749
|
-
if (problems.length > 0)
|
|
750
|
-
throw new ProfileError(problems);
|
|
751
|
-
return this.#commit(intent.intent, [id], (d) => {
|
|
752
|
-
d.objects[id] = scalarize(task, 0); // §53: the whole Task in one change (the object map is depth 0)
|
|
753
|
-
});
|
|
754
|
-
}
|
|
755
|
-
const id = intent.id;
|
|
756
|
-
const stored = objects[id];
|
|
757
|
-
if (!isMap(stored))
|
|
758
|
-
throw new ProfileError(objectProblems(undefined, id));
|
|
759
|
-
if (A.getConflicts(objects, id) !== undefined)
|
|
760
|
-
throw new ObjectIdCollisionError(id);
|
|
761
|
-
const object = stored;
|
|
762
|
-
const writes = writesOf(intent);
|
|
763
|
-
const touched = new Set(writes.map((w) => (w.op === "put" || w.op === "delete" ? w.field : w.set)));
|
|
764
|
-
const next = candidate(plain(object), writes);
|
|
765
|
-
const problems = [
|
|
766
|
-
...(next.type === "task"
|
|
767
|
-
? objectProblems(next, id)
|
|
768
|
-
: [problem("INVALID_FIELD_TYPE", `/objects/${pointerToken(id)}/type`, "not a Task (§25)")]),
|
|
769
|
-
// Text in a field this intent writes is replaced by the write.
|
|
770
|
-
...textProblems(this.#doc, object, id).filter((p) => !touched.has(unescapeToken(p.pointer.split("/")[3] ?? ""))),
|
|
771
|
-
];
|
|
772
|
-
if (problems.length > 0)
|
|
773
|
-
throw new ProfileError(problems);
|
|
774
|
-
return this.#commit(intent.intent, [id], (d) => perform(d.objects[id], writes));
|
|
812
|
+
const prepared = prepareTaskIntent(this.#doc, objects, intent);
|
|
813
|
+
return this.#commit(intent.intent, [prepared.id], (d) => prepared.perform(d.objects));
|
|
775
814
|
}
|
|
776
815
|
#commit(message, ids, fn) {
|
|
777
816
|
if (!this.writable)
|
|
@@ -804,8 +843,8 @@ export class SharedObjectsReplica {
|
|
|
804
843
|
if (checked.seq !== this.actorSeq + 1)
|
|
805
844
|
throw new LfcpError("SEQUENCE_REUSE", `actor sequence ${checked.seq} after ${this.actorSeq} (§9)`);
|
|
806
845
|
this.#doc = next;
|
|
807
|
-
this.#refs?.add(checked.hash, parsedOf(checked));
|
|
808
846
|
this.#noteSeq(checked.actor, checked.seq);
|
|
847
|
+
this.#refs?.add(checked.hash, parsedOf(checked));
|
|
809
848
|
for (const [id, d] of created)
|
|
810
849
|
this.#depths.set(id, d);
|
|
811
850
|
return Object.freeze({
|
|
@@ -850,85 +889,19 @@ export class SharedObjectsReplica {
|
|
|
850
889
|
seen.add(checked.hash);
|
|
851
890
|
all.push(checked);
|
|
852
891
|
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
const children = new Map();
|
|
857
|
-
all.forEach((c, i) => {
|
|
858
|
-
for (const d of c.deps) {
|
|
859
|
-
if (A.hasHeads(this.#doc, [d]))
|
|
860
|
-
continue;
|
|
861
|
-
if (index.has(d)) {
|
|
862
|
-
blocking[i] = blocking[i] + 1;
|
|
863
|
-
children.set(d, [...(children.get(d) ?? []), i]);
|
|
864
|
-
}
|
|
865
|
-
else
|
|
866
|
-
unreachable[i] = true;
|
|
867
|
-
}
|
|
868
|
-
});
|
|
869
|
-
const seqs = new Map();
|
|
870
|
-
const latest = (actor) => seqs.get(actor) ?? this.#seqs.get(actor) ?? 0;
|
|
871
|
-
const admitted = [];
|
|
892
|
+
// §11.1, §14.1 (the admission module both profiles share), then §11.2:
|
|
893
|
+
// the depths of the objects a change creates, given the document and
|
|
894
|
+
// the batch so far.
|
|
872
895
|
const batchDepths = new Map();
|
|
873
|
-
const
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
else if (c.seq <= latest(c.actor)) {
|
|
883
|
-
refused.push({
|
|
884
|
-
change: c,
|
|
885
|
-
error: new LfcpError("ACTOR_EQUIVOCATION", `actor ${c.actor} sequence ${c.seq} already has a different change (§26.2)`),
|
|
886
|
-
});
|
|
887
|
-
continue;
|
|
888
|
-
}
|
|
889
|
-
else if (c.otherActors.some((a) => latest(a) === 0)) {
|
|
890
|
-
refused.push({
|
|
891
|
-
change: c,
|
|
892
|
-
error: new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `the change names an actor unknown to this document (§11.1)`),
|
|
893
|
-
});
|
|
894
|
-
continue;
|
|
895
|
-
}
|
|
896
|
-
else if (c.seq !== latest(c.actor) + 1) {
|
|
897
|
-
refused.push({
|
|
898
|
-
change: c,
|
|
899
|
-
error: new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `actor ${c.actor} sequence ${c.seq} skips sequence ${latest(c.actor) + 1} (§14.1)`),
|
|
900
|
-
});
|
|
901
|
-
continue;
|
|
902
|
-
}
|
|
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
|
-
}
|
|
910
|
-
// §11.2: the depths of the objects it creates, given the document and the batch so far.
|
|
911
|
-
let created;
|
|
912
|
-
try {
|
|
913
|
-
created = this.#createdDepths(c, batchDepths);
|
|
914
|
-
}
|
|
915
|
-
catch (e) {
|
|
916
|
-
refused.push({ change: c, error: e });
|
|
917
|
-
continue;
|
|
918
|
-
}
|
|
919
|
-
for (const [id, d] of created)
|
|
920
|
-
batchDepths.set(id, d);
|
|
921
|
-
seqs.set(c.actor, c.seq);
|
|
922
|
-
this.#references().add(c.hash, parsedOf(c));
|
|
923
|
-
admitted.push(c);
|
|
924
|
-
}
|
|
925
|
-
for (const child of children.get(c.hash) ?? []) {
|
|
926
|
-
blocking[child] = blocking[child] - 1;
|
|
927
|
-
if (blocking[child] === 0 && !unreachable[child])
|
|
928
|
-
ready.push(child);
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
const waiting = all.filter((_, i) => !done[i]);
|
|
896
|
+
const decided = admitBatch(all, this.#sequences(), (c) => {
|
|
897
|
+
for (const [id, d] of this.#createdDepths(c, batchDepths))
|
|
898
|
+
batchDepths.set(id, d);
|
|
899
|
+
});
|
|
900
|
+
for (const r of decided.refused)
|
|
901
|
+
refused.push({ change: r.change, error: r.error });
|
|
902
|
+
const admitted = [...decided.admitted];
|
|
903
|
+
const duplicates = [...decided.duplicates];
|
|
904
|
+
const waiting = [...decided.waiting];
|
|
932
905
|
const before = A.getHeads(this.#doc);
|
|
933
906
|
let applied = admitted;
|
|
934
907
|
if (admitted.length > 0) {
|
|
@@ -942,7 +915,7 @@ export class SharedObjectsReplica {
|
|
|
942
915
|
}
|
|
943
916
|
else {
|
|
944
917
|
this.#doc = batch.restored;
|
|
945
|
-
// The index already holds the batch
|
|
918
|
+
// The index already holds the batch (admitBatch adds what it admits).
|
|
946
919
|
this.#refs = null;
|
|
947
920
|
applied = [];
|
|
948
921
|
for (const c of admitted) {
|
|
@@ -972,40 +945,23 @@ export class SharedObjectsReplica {
|
|
|
972
945
|
objects: Object.freeze(applied.length > 0 ? this.#objectChanges(before, "remote") : []),
|
|
973
946
|
});
|
|
974
947
|
}
|
|
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
|
-
}
|
|
980
948
|
#receive(change) {
|
|
981
|
-
|
|
949
|
+
// §11.1, §14.1 before Automerge sees it (the admission module both
|
|
950
|
+
// profiles share): Automerge 3.5.0 records a skipping change in its graph
|
|
951
|
+
// without its operations, so the document no longer saves loadably
|
|
952
|
+
// (automerge-rs 0.12 aborts), and aborts on an unknown actor. A taken
|
|
953
|
+
// sequence is an equivocation (or, for our own actor, a lost-state fork, §9).
|
|
954
|
+
const admission = admitChange(change, this.#sequences());
|
|
955
|
+
if (admission.kind === "duplicate")
|
|
982
956
|
return Object.freeze({ status: "duplicate", change });
|
|
983
|
-
|
|
984
|
-
if (missing.length > 0)
|
|
957
|
+
if (admission.kind === "missing")
|
|
985
958
|
return Object.freeze({
|
|
986
959
|
status: "missing_dependencies",
|
|
987
960
|
change,
|
|
988
|
-
missing:
|
|
961
|
+
missing: admission.missing,
|
|
989
962
|
});
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
if (change.seq <= (this.#seqs.get(change.actor) ?? 0))
|
|
993
|
-
throw new LfcpError("ACTOR_EQUIVOCATION", `actor ${change.actor} sequence ${change.seq} already has a different change (§26.2)`);
|
|
994
|
-
// §14.1: with every dependency present, the sequence follows the
|
|
995
|
-
// actor's latest change. Checked before Automerge sees it: Automerge
|
|
996
|
-
// 3.5.0 records a skipping change in its graph without its operations
|
|
997
|
-
// and the document no longer saves loadably (automerge-rs 0.12 aborts).
|
|
998
|
-
// §11.1: every other actor of the change is already an actor of the
|
|
999
|
-
// document (Automerge aborts on an unknown one).
|
|
1000
|
-
const unknown = change.otherActors.find((a) => !this.#seqs.has(a));
|
|
1001
|
-
if (unknown !== undefined)
|
|
1002
|
-
throw new ProfileInvalidError("INVALID_AUTOMERGE_BYTES", `the change names actor ${unknown}, unknown to this document (§11.1)`);
|
|
1003
|
-
if (change.seq !== (this.#seqs.get(change.actor) ?? 0) + 1)
|
|
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);
|
|
963
|
+
if (admission.kind === "held" || admission.kind === "invalid")
|
|
964
|
+
throw admission.error;
|
|
1009
965
|
// §11.2: no object deeper than MAX_DOCUMENT_DEPTH, before the engine.
|
|
1010
966
|
const created = this.#createdDepths(change, new Map());
|
|
1011
967
|
const before = A.getHeads(this.#doc);
|
|
@@ -1017,8 +973,8 @@ export class SharedObjectsReplica {
|
|
|
1017
973
|
}
|
|
1018
974
|
const next = applied.next;
|
|
1019
975
|
this.#doc = next;
|
|
1020
|
-
this.#refs?.add(change.hash, parsedOf(change));
|
|
1021
976
|
this.#noteSeq(change.actor, change.seq);
|
|
977
|
+
this.#refs?.add(change.hash, parsedOf(change));
|
|
1022
978
|
for (const [id, d] of created)
|
|
1023
979
|
this.#depths.set(id, d);
|
|
1024
980
|
return Object.freeze({
|
|
@@ -1049,7 +1005,7 @@ export class SharedObjectsReplica {
|
|
|
1049
1005
|
if (set.size === 0)
|
|
1050
1006
|
for (const f of Object.keys(now))
|
|
1051
1007
|
set.add(f);
|
|
1052
|
-
const nowConflicts = conflictedFields(now);
|
|
1008
|
+
const nowConflicts = conflictedFields(this.#doc, now);
|
|
1053
1009
|
const wasConflicts = this.#conflicted.get(id) ?? [];
|
|
1054
1010
|
this.#conflicted.set(id, nowConflicts);
|
|
1055
1011
|
out.push(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
|