@openlfcp/shared-objects 0.2.0-beta.6 → 0.2.0-beta.8

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.
@@ -0,0 +1,15 @@
1
+ import * as A from "@automerge/automerge";
2
+ type AMap = Record<string, unknown>;
3
+ /**
4
+ * The distinct concurrent values of `map[key]` in `doc` (SOP §45,
5
+ * mvp-0.2-baseline.12): none when absent, one when it is not conflicted or
6
+ * when every concurrent value is equal. Two values are equal when they have
7
+ * the same Automerge scalar type and the same value (strings code point by
8
+ * code point); an object (a map, a list or Text) equals no other value. In
9
+ * Automerge's order of the concurrent values, the first of each equal group.
10
+ */
11
+ export declare function distinctValues(doc: A.Doc<unknown>, map: AMap | undefined, key: string): unknown[];
12
+ /** Whether `map[key]` has two or more distinct concurrent values (a conflict, SOP §45). */
13
+ export declare function isConflicted(doc: A.Doc<unknown>, map: AMap, key: string): boolean;
14
+ export {};
15
+ //# sourceMappingURL=concurrent.d.ts.map
@@ -0,0 +1,55 @@
1
+ import * as A from "@automerge/automerge";
2
+ /** Automerge object types: an object value equals no other value (SOP §45). */
3
+ const OBJECTS = new Set(["map", "list", "text", "table"]);
4
+ /** A scalar's identity for equality: its Automerge type and its value. */
5
+ function scalarKey(type, value) {
6
+ if (value instanceof Uint8Array)
7
+ return `${type}:${Array.from(value, (b) => b.toString(16).padStart(2, "0")).join("")}`;
8
+ if (value instanceof Date)
9
+ return `${type}:${value.getTime()}`;
10
+ if (typeof value === "bigint")
11
+ return `${type}:${value}`;
12
+ return `${type}:${JSON.stringify(value)}`;
13
+ }
14
+ /**
15
+ * The distinct concurrent values of `map[key]` in `doc` (SOP §45,
16
+ * mvp-0.2-baseline.12): none when absent, one when it is not conflicted or
17
+ * when every concurrent value is equal. Two values are equal when they have
18
+ * the same Automerge scalar type and the same value (strings code point by
19
+ * code point); an object (a map, a list or Text) equals no other value. In
20
+ * Automerge's order of the concurrent values, the first of each equal group.
21
+ */
22
+ export function distinctValues(doc, map, key) {
23
+ if (map === undefined || !(key in map))
24
+ return [];
25
+ const conflicts = A.getConflicts(map, key);
26
+ if (conflicts === undefined)
27
+ return [map[key]];
28
+ const obj = A.getObjectId(map);
29
+ const all = obj === null ? [] : A.getBackend(doc).getAll(obj, key, A.getHeads(doc));
30
+ const typeOf = new Map();
31
+ for (const e of all) {
32
+ const type = e[0];
33
+ if (OBJECTS.has(type))
34
+ typeOf.set(e[1], { type, value: undefined });
35
+ else
36
+ typeOf.set(e[2], { type, value: e[1] });
37
+ }
38
+ const seen = new Set();
39
+ const out = [];
40
+ for (const [opId, value] of Object.entries(conflicts)) {
41
+ const t = typeOf.get(opId);
42
+ const k = t === undefined || OBJECTS.has(t.type) ? `object:${opId}` : scalarKey(t.type, t.value);
43
+ if (seen.has(k))
44
+ continue;
45
+ seen.add(k);
46
+ out.push(value);
47
+ }
48
+ return out;
49
+ }
50
+ /** Whether `map[key]` has two or more distinct concurrent values (a conflict, SOP §45). */
51
+ export function isConflicted(doc, map, key) {
52
+ return (A.getConflicts(map, key) !== undefined &&
53
+ distinctValues(doc, map, key).length > 1);
54
+ }
55
+ //# sourceMappingURL=concurrent.js.map
package/dist/replica.js CHANGED
@@ -4,6 +4,7 @@ import { checkChange, checkSaveHeader, parsedOf, unframeChange, unframeSnapshot,
4
4
  import { checkSnapshotExpansion, MAX_DOCUMENT_DEPTH, SNAPSHOT_LIMITS_FLOOR, } from "./admission/limits.js";
5
5
  import { referenceHistoryOf } from "./admission/references.js";
6
6
  import { admitBatch, admitChange } from "./admission/sequence.js";
7
+ import { distinctValues, isConflicted } from "./concurrent.js";
7
8
  /** The decoded actions that create an object (§11.2). */
8
9
  const MAKE_ACTIONS = new Set(["makeMap", "makeList", "makeText", "makeTable"]);
9
10
  import { ProfileInvalidError } from "./profile-invalid.js";
@@ -153,17 +154,13 @@ function canonical(value) {
153
154
  .map((k) => [k, canonical(value[k])]));
154
155
  return value;
155
156
  }
156
- /** Every concurrent value of `map[key]`: none if absent, one if not conflicted. */
157
- function valuesOf(map, key) {
158
- if (!(key in map))
159
- return [];
160
- const conflicts = A.getConflicts(map, key);
161
- return conflicts === undefined ? [map[key]] : Object.values(conflicts);
162
- }
163
- /** Top-level fields of `object` with more than one concurrent value. */
164
- 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) {
165
162
  return Object.keys(object)
166
- .filter((f) => A.getConflicts(object, f) !== undefined)
163
+ .filter((f) => isConflicted(doc, object, f))
167
164
  .sort();
168
165
  }
169
166
  const problem = (diagnostic, pointer, message) => Object.freeze({ code: "PROFILE_INVALID", diagnostic, pointer, message });
@@ -236,7 +233,7 @@ export function taskView(doc, objects, id) {
236
233
  const problems = storedObjectProblems(doc, object, id);
237
234
  const parsed = parseTask(plain(object), id);
238
235
  const field = (f) => {
239
- const values = valuesOf(object, f).map(plain).sort(byJson);
236
+ const values = distinctValues(doc, object, f).map(plain).sort(byJson);
240
237
  return Object.freeze({
241
238
  value: f in object ? plain(object[f]) : undefined,
242
239
  values: Object.freeze(values),
@@ -525,7 +522,7 @@ export class SharedObjectsReplica {
525
522
  this.#noteSeq(meta.actor, meta.seq);
526
523
  for (const [id, object] of Object.entries(this.#objects() ?? {}))
527
524
  if (isMap(object))
528
- this.#conflicted.set(id, conflictedFields(object));
525
+ this.#conflicted.set(id, conflictedFields(doc, object));
529
526
  }
530
527
  /**
531
528
  * §11.2: the depths of the objects `change` creates (object ID → depth),
@@ -749,9 +746,9 @@ export class SharedObjectsReplica {
749
746
  for (const [id, object] of Object.entries(objects)) {
750
747
  if (!isMap(object))
751
748
  continue;
752
- for (const field of conflictedFields(object)) {
749
+ for (const field of conflictedFields(this.#doc, object)) {
753
750
  out[id] ??= {};
754
- out[id][field] = valuesOf(object, field)
751
+ out[id][field] = distinctValues(this.#doc, object, field)
755
752
  .map(plain)
756
753
  .sort(byJson);
757
754
  }
@@ -1008,7 +1005,7 @@ export class SharedObjectsReplica {
1008
1005
  if (set.size === 0)
1009
1006
  for (const f of Object.keys(now))
1010
1007
  set.add(f);
1011
- const nowConflicts = conflictedFields(now);
1008
+ const nowConflicts = conflictedFields(this.#doc, now);
1012
1009
  const wasConflicts = this.#conflicted.get(id) ?? [];
1013
1010
  this.#conflicted.set(id, nowConflicts);
1014
1011
  out.push(Object.freeze({
@@ -4,6 +4,7 @@ import { checkChange, checkSaveHeader, parsedOf, refusedChangeHash, } from "../a
4
4
  import { checkChangeExpansion, checkSnapshotExpansion, SNAPSHOT_LIMITS_FLOOR, } from "../admission/limits.js";
5
5
  import { referenceHistoryOf } from "../admission/references.js";
6
6
  import { admitBatch } from "../admission/sequence.js";
7
+ import { distinctValues } from "../concurrent.js";
7
8
  import { ProfileInvalidError } from "../profile-invalid.js";
8
9
  import { isWasmTrap, prepareTaskIntent, taskView, } from "../replica.js";
9
10
  import { frameProfilePayload, isUtcTimestamp, principalRef } from "../values.js";
@@ -138,12 +139,9 @@ function applyIsolating(doc, changes) {
138
139
  }
139
140
  return { doc: work, applied, poisoned };
140
141
  }
141
- /** Every concurrent value of `map[key]`. */
142
- function values(map, key) {
143
- if (!(key in map))
144
- return [];
145
- const c = A.getConflicts(map, key);
146
- return c === undefined ? [map[key]] : Object.values(c);
142
+ /** The distinct concurrent values of `map[key]` in `doc` (SOP §45: equal values agree). */
143
+ function values(doc, map, key) {
144
+ return distinctValues(doc, map, key);
147
145
  }
148
146
  /** PROFILE_INVALID for a Snapshot, with the diagnostic of what refused it. */
149
147
  function snapshotRefused(diagnostic, message) {
@@ -401,7 +399,7 @@ export class SectionReplica {
401
399
  const objects = (root.objects ?? {});
402
400
  const placements = (root.placements ?? {});
403
401
  const hidden = new Set(tree.hidden);
404
- const titles = values(section, "title").map(str);
402
+ const titles = values(this.#doc, section, "title").map(str);
405
403
  const out = {};
406
404
  for (const id of Object.keys(nodes).sort()) {
407
405
  if (tree.collisions.includes(id))
@@ -844,7 +842,8 @@ export class SectionReplica {
844
842
  refuse("NODE_IN_CONFLICT", `${second} is invalid or collides (§14.2)`, second);
845
843
  const otherKind = str(other.kind);
846
844
  const sameStyle = kind === "paragraph" ||
847
- values(node, "list_style").join() === values(other, "list_style").join();
845
+ values(doc, node, "list_style").join() ===
846
+ values(doc, other, "list_style").join();
848
847
  if (otherKind !== kind || (kind !== "paragraph" && kind !== "item") || !sameStyle)
849
848
  refuse("INVALID_INTENT", "only two paragraphs, or two items of one list style, join (§10)", id);
850
849
  const kids = (n) => (n.children ?? []).length;
@@ -871,8 +870,8 @@ export class SectionReplica {
871
870
  // split: a deletion of the suffix and a new node after this one.
872
871
  if (intent.intent.slice(0, intent.intent.indexOf(".")) !== kind)
873
872
  refuse("INVALID_INTENT", `${intent.intent} on a ${kind} node (§10)`, id);
874
- if (values(node, "placement").length !== 1 ||
875
- new Set(values(node, "lifecycle")).size !== 1)
873
+ if (values(doc, node, "placement").length !== 1 ||
874
+ new Set(values(doc, node, "lifecycle")).size !== 1)
876
875
  refuse("NODE_IN_CONFLICT", `${id} has unresolved structure (§10)`, id);
877
876
  const cut = rebase(doc, path, intent.base, [{ index: intent.at, deleteCount: 0, insert: "" }], refuse, id)[0];
878
877
  const suffix = current.slice(cut.from);
@@ -939,7 +938,7 @@ export class SectionReplica {
939
938
  n.list_style = S(style);
940
939
  };
941
940
  }
942
- if (intent.intent === "node.move" && values(node, "placement").length !== 1)
941
+ if (intent.intent === "node.move" && values(doc, node, "placement").length !== 1)
943
942
  refuse("NODE_IN_CONFLICT", `${id} has concurrent placements: use node.resolve_placement (§7, §8)`, id);
944
943
  const placementId = intent.placementId ?? generateObjectId();
945
944
  if (!isObjectId(placementId))
@@ -1086,12 +1085,12 @@ chainOk) {
1086
1085
  return { why: "is invalid (§14.2)" };
1087
1086
  const kind = str(node.kind);
1088
1087
  const owner = kind === "task" ? objects[id] : node;
1089
- const life = values(owner, "lifecycle").map(str);
1088
+ const life = values(doc, owner, "lifecycle").map(str);
1090
1089
  if (life.length !== 1)
1091
1090
  return { why: "has a lifecycle conflict (§9)" };
1092
1091
  if (life[0] !== "active")
1093
1092
  return { why: "is deleted (§9)" };
1094
- const selected = values(node, "placement").map(str);
1093
+ const selected = values(doc, node, "placement").map(str);
1095
1094
  if (selected.length !== 1)
1096
1095
  return { why: "has a placement conflict (§7)" };
1097
1096
  const placement = placements[selected[0]];
@@ -1131,7 +1130,7 @@ chainOk) {
1131
1130
  const e = eligible(after);
1132
1131
  if ("why" in e || e.parent !== parent)
1133
1132
  refuse("INVALID_PREDECESSOR", `${after} is not a visible child of ${parent} (§6)`, after);
1134
- const slot = str(values(nodes[after], "placement")[0]);
1133
+ const slot = str(values(doc, nodes[after], "placement")[0]);
1135
1134
  const at = lane.findIndex((x) => str(x) === slot);
1136
1135
  if (at < 0)
1137
1136
  refuse("INVALID_PREDECESSOR", `${after}'s placement is not in the parent's list (§4.3)`, after);
@@ -1,14 +1,12 @@
1
1
  import * as A from "@automerge/automerge";
2
+ import { distinctValues } from "../concurrent.js";
2
3
  import { validateSection } from "./schema.js";
3
4
  /** The Task scalar fields whose concurrent values tree() reports. */
4
5
  const CONFLICT_FIELDS = ["title", "status", "lifecycle", "due", "priority"];
5
6
  const str = (v) => A.isImmutableString(v) ? v.toString() : typeof v === "string" ? v : undefined;
6
- /** Every concurrent value of `map[key]`, as strings. */
7
- function values(map, key) {
8
- if (map === undefined || !(key in map))
9
- return [];
10
- const c = A.getConflicts(map, key);
11
- return (c === undefined ? [map[key]] : Object.values(c)).map(str);
7
+ /** The distinct concurrent values of `map[key]` in `doc`, as strings (SOP §45: equal values agree). */
8
+ function values(doc, map, key) {
9
+ return distinctValues(doc, map, key).map(str);
12
10
  }
13
11
  const byId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
14
12
  /** §7: the effective tree and structural facts of `doc`. Reads only. */
@@ -32,13 +30,13 @@ export function deriveTree(doc, validation = validateSection(doc)) {
32
30
  // §7.6: a lifecycle conflict has no winner. It blocks the branch (below)
33
31
  // instead of the provisional value hiding it: the node is not deleted,
34
32
  // and edits under it are not under a deleted ancestor.
35
- const lifecycles = new Set(values(owner(id), "lifecycle"));
33
+ const lifecycles = new Set(values(doc, owner(id), "lifecycle"));
36
34
  if (lifecycles.size === 1 && lifecycles.has("deleted"))
37
35
  deleted.add(id);
38
36
  if (collided.has(id))
39
37
  continue;
40
38
  const n = node(id);
41
- const selected = values(n, "placement");
39
+ const selected = values(doc, n, "placement");
42
40
  const parentOf = (p) => p === undefined ? undefined : str(placements[p]?.parent_id);
43
41
  // The default value selects the parent used for hiding and for the walk below.
44
42
  const parent = parentOf(str(n.placement));
@@ -177,7 +175,7 @@ export function deriveTree(doc, validation = validateSection(doc)) {
177
175
  collisions: validation.collisions,
178
176
  retainedConcurrentEdits: Object.freeze(retained),
179
177
  scalarConflicts: Object.freeze(Object.keys(objects).flatMap((id) => CONFLICT_FIELDS.flatMap((field) => {
180
- const vs = values(objects[id], field);
178
+ const vs = values(doc, objects[id], field);
181
179
  return vs.length > 1 ? [{ id, field, values: vs.map((v) => v ?? "").sort() }] : [];
182
180
  }))),
183
181
  candidates,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlfcp/shared-objects",
3
- "version": "0.2.0-beta.6",
3
+ "version": "0.2.0-beta.8",
4
4
  "description": "SHARED-OBJECTS-PROFILE-01 (org.openlfcp.shared-objects.v1) implementation.",
5
5
  "keywords": [
6
6
  "openlfcp",
@@ -53,8 +53,8 @@
53
53
  "dependencies": {
54
54
  "@automerge/automerge": "3.5.0",
55
55
  "fflate": "0.8.3",
56
- "@openlfcp/core": "^0.2.0-beta.6",
57
- "@openlfcp/crypto": "^0.2.0-beta.6"
56
+ "@openlfcp/core": "^0.2.0-beta.8",
57
+ "@openlfcp/crypto": "^0.2.0-beta.8"
58
58
  },
59
59
  "devDependencies": {
60
60
  "fast-check": "4.10.2"