@openlfcp/shared-objects 0.2.0-beta.6 → 0.2.0-beta.7
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/dist/concurrent.d.ts +15 -0
- package/dist/concurrent.js +55 -0
- package/dist/replica.js +12 -15
- package/dist/sections/replica.js +13 -14
- package/dist/sections/tree.js +7 -9
- package/package.json +3 -3
|
@@ -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
|
-
/**
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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) =>
|
|
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 =
|
|
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] =
|
|
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({
|
package/dist/sections/replica.js
CHANGED
|
@@ -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
|
-
/**
|
|
142
|
-
function values(map, key) {
|
|
143
|
-
|
|
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() ===
|
|
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);
|
package/dist/sections/tree.js
CHANGED
|
@@ -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
|
-
/**
|
|
7
|
-
function values(map, key) {
|
|
8
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.0-beta.7",
|
|
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.
|
|
57
|
-
"@openlfcp/crypto": "^0.2.0-beta.
|
|
56
|
+
"@openlfcp/core": "^0.2.0-beta.7",
|
|
57
|
+
"@openlfcp/crypto": "^0.2.0-beta.7"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"fast-check": "4.10.2"
|