@openlfcp/shared-objects 0.1.5 → 0.2.0-beta.2

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.
Files changed (43) hide show
  1. package/README.md +15 -2
  2. package/dist/admission/actor.d.ts +16 -0
  3. package/dist/admission/actor.js +35 -0
  4. package/dist/{canonical.d.ts → admission/canonical.d.ts} +9 -0
  5. package/dist/{canonical.js → admission/canonical.js} +43 -3
  6. package/dist/{automerge-bytes.d.ts → admission/framing.d.ts} +38 -1
  7. package/dist/admission/framing.js +232 -0
  8. package/dist/admission/index.d.ts +26 -0
  9. package/dist/admission/index.js +26 -0
  10. package/dist/{chunk-limits.d.ts → admission/limits.d.ts} +11 -1
  11. package/dist/{chunk-limits.js → admission/limits.js} +25 -10
  12. package/dist/{references.d.ts → admission/references.d.ts} +1 -1
  13. package/dist/{references.js → admission/references.js} +9 -3
  14. package/dist/admission/sequence.d.ts +81 -0
  15. package/dist/admission/sequence.js +147 -0
  16. package/dist/data-profile.d.ts +1 -1
  17. package/dist/data-profile.js +3 -7
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.js +2 -2
  20. package/dist/replica.d.ts +32 -2
  21. package/dist/replica.js +154 -195
  22. package/dist/sections/admission.d.ts +35 -0
  23. package/dist/sections/admission.js +328 -0
  24. package/dist/sections/data-profile.d.ts +195 -0
  25. package/dist/sections/data-profile.js +383 -0
  26. package/dist/sections/dispatch.d.ts +41 -0
  27. package/dist/sections/dispatch.js +33 -0
  28. package/dist/sections/document.d.ts +54 -0
  29. package/dist/sections/document.js +101 -0
  30. package/dist/sections/index.d.ts +18 -0
  31. package/dist/sections/index.js +18 -0
  32. package/dist/sections/replica.d.ts +412 -0
  33. package/dist/sections/replica.js +1149 -0
  34. package/dist/sections/schema.d.ts +77 -0
  35. package/dist/sections/schema.js +548 -0
  36. package/dist/sections/tree.d.ts +83 -0
  37. package/dist/sections/tree.js +290 -0
  38. package/dist/sections/values.d.ts +30 -0
  39. package/dist/sections/values.js +36 -0
  40. package/dist/values.d.ts +3 -18
  41. package/dist/values.js +7 -95
  42. package/package.json +12 -4
  43. 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 "./automerge-bytes.js";
4
- import { checkSnapshotExpansion, MAX_DOCUMENT_DEPTH, SNAPSHOT_LIMITS_FLOOR, } from "./chunk-limits.js";
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,6 +411,7 @@ 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
  */
414
+ /** A wasm trap (RuntimeError, or a terminated module): fatal, not a refusable change. */
317
415
  export function isWasmTrap(e) {
318
416
  const trap = globalThis
319
417
  .WebAssembly?.RuntimeError;
@@ -330,15 +428,16 @@ export 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) {
333
- // Capture the document's changes BEFORE the apply: a change the engine
431
+ // The change's good state, captured before the apply: a change the engine
334
432
  // 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.
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.
339
436
  //
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.
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.
342
441
  const before = [...A.getHeads(doc)].sort().join();
343
442
  const actor = A.getActorId(doc);
344
443
  const good = A.getAllChanges(doc);
@@ -372,7 +471,9 @@ function restoreFrom(good, actor, before) {
372
471
  * heads it had. Exported for tests.
373
472
  */
374
473
  export function applyBatchChecked(doc, batch) {
375
- // See applyChecked: capture the changes before the apply, restore from them.
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.
376
477
  const before = [...A.getHeads(doc)].sort().join();
377
478
  const actor = A.getActorId(doc);
378
479
  const good = A.getAllChanges(doc);
@@ -401,8 +502,6 @@ export class SharedObjectsReplica {
401
502
  #actor;
402
503
  #minSeq;
403
504
  #doc;
404
- /** §11.4: the document's changes as references read them; null to build again from the document. */
405
- #refs = null;
406
505
  /** Highest sequence seen per actor (hex). */
407
506
  #seqs = new Map();
408
507
  /** §11.2: known object depths (object ID → depth below the root), filled as objects appear. */
@@ -413,6 +512,8 @@ export class SharedObjectsReplica {
413
512
  * at the view's heads, so the state before a change cannot be asked later.
414
513
  */
415
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;
416
517
  constructor(doc, opts) {
417
518
  this.resource = opts.resource;
418
519
  this.#principal = opts.principal;
@@ -432,6 +533,15 @@ export class SharedObjectsReplica {
432
533
  * same batch). Refuses an object deeper than MAX_DOCUMENT_DEPTH, or one
433
534
  * written into an object the document does not have, before the engine.
434
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
+ }
435
545
  #createdDepths(change, prior) {
436
546
  const decoded = A.decodeChange(change.bytes);
437
547
  const created = new Map();
@@ -686,46 +796,9 @@ export class SharedObjectsReplica {
686
796
  collisions: Object.freeze(this.collisions()),
687
797
  });
688
798
  }
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
799
  /** §99: the Task under `id` with conflict metadata, or undefined if there is no object or it is not a Task. */
700
800
  task(id) {
701
- const objects = this.#objects();
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
- });
801
+ return taskView(this.#doc, this.#objects(), id);
729
802
  }
730
803
  /**
731
804
  * Applies one intent as exactly one Automerge change (§10) and returns it,
@@ -739,39 +812,8 @@ export class SharedObjectsReplica {
739
812
  const rootProblems = validateRoot(this.root()).problems.filter((p) => !p.pointer.startsWith("/objects/"));
740
813
  if (objects === undefined || rootProblems.length > 0)
741
814
  throw new ProfileError(rootProblems.length > 0 ? rootProblems : [problem("INVALID_ROOT", "/", "no root (§15)")]);
742
- if (intent.intent === "task.create") {
743
- const task = intent.task;
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));
815
+ const prepared = prepareTaskIntent(this.#doc, objects, intent);
816
+ return this.#commit(intent.intent, [prepared.id], (d) => prepared.perform(d.objects));
775
817
  }
776
818
  #commit(message, ids, fn) {
777
819
  if (!this.writable)
@@ -804,8 +846,8 @@ export class SharedObjectsReplica {
804
846
  if (checked.seq !== this.actorSeq + 1)
805
847
  throw new LfcpError("SEQUENCE_REUSE", `actor sequence ${checked.seq} after ${this.actorSeq} (§9)`);
806
848
  this.#doc = next;
807
- this.#refs?.add(checked.hash, parsedOf(checked));
808
849
  this.#noteSeq(checked.actor, checked.seq);
850
+ this.#refs?.add(checked.hash, parsedOf(checked));
809
851
  for (const [id, d] of created)
810
852
  this.#depths.set(id, d);
811
853
  return Object.freeze({
@@ -850,85 +892,19 @@ export class SharedObjectsReplica {
850
892
  seen.add(checked.hash);
851
893
  all.push(checked);
852
894
  }
853
- const index = new Map(all.map((c, i) => [c.hash, i]));
854
- const blocking = all.map(() => 0);
855
- const unreachable = all.map(() => false);
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 = [];
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.
872
898
  const batchDepths = new Map();
873
- const duplicates = [];
874
- const done = all.map(() => false);
875
- const ready = all.flatMap((_, i) => (blocking[i] === 0 && !unreachable[i] ? [i] : []));
876
- for (let k = 0; k < ready.length; k++) {
877
- const i = ready[k];
878
- const c = all[i];
879
- done[i] = true;
880
- if (A.hasHeads(this.#doc, [c.hash]))
881
- duplicates.push(c);
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]);
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];
932
908
  const before = A.getHeads(this.#doc);
933
909
  let applied = admitted;
934
910
  if (admitted.length > 0) {
@@ -942,7 +918,7 @@ export class SharedObjectsReplica {
942
918
  }
943
919
  else {
944
920
  this.#doc = batch.restored;
945
- // The index already holds the batch: build it again from the restored document.
921
+ // The index already holds the batch (admitBatch adds what it admits).
946
922
  this.#refs = null;
947
923
  applied = [];
948
924
  for (const c of admitted) {
@@ -972,40 +948,23 @@ export class SharedObjectsReplica {
972
948
  objects: Object.freeze(applied.length > 0 ? this.#objectChanges(before, "remote") : []),
973
949
  });
974
950
  }
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
951
  #receive(change) {
981
- if (A.hasHeads(this.#doc, [change.hash]))
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")
982
959
  return Object.freeze({ status: "duplicate", change });
983
- const missing = change.deps.filter((d) => !A.hasHeads(this.#doc, [d]));
984
- if (missing.length > 0)
960
+ if (admission.kind === "missing")
985
961
  return Object.freeze({
986
962
  status: "missing_dependencies",
987
963
  change,
988
- missing: Object.freeze(missing),
964
+ missing: admission.missing,
989
965
  });
990
- // Same actor and sequence, different change: an equivocation (or, for our
991
- // own actor, a lost-state fork, §9). Checked before Automerge sees it.
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);
966
+ if (admission.kind === "held" || admission.kind === "invalid")
967
+ throw admission.error;
1009
968
  // §11.2: no object deeper than MAX_DOCUMENT_DEPTH, before the engine.
1010
969
  const created = this.#createdDepths(change, new Map());
1011
970
  const before = A.getHeads(this.#doc);
@@ -1017,8 +976,8 @@ export class SharedObjectsReplica {
1017
976
  }
1018
977
  const next = applied.next;
1019
978
  this.#doc = next;
1020
- this.#refs?.add(change.hash, parsedOf(change));
1021
979
  this.#noteSeq(change.actor, change.seq);
980
+ this.#refs?.add(change.hash, parsedOf(change));
1022
981
  for (const [id, d] of created)
1023
982
  this.#depths.set(id, d);
1024
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