@cocreate/crdt 1.32.0 → 2.0.0

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/src/runs.js ADDED
@@ -0,0 +1,46 @@
1
+ // Run identity reuses the first operation ID. Browser/frame identity comes from
2
+ // Socket Client; no separate actor ID or generated run ID is introduced.
3
+ export function assignRun(object, change) {
4
+ const key = JSON.stringify([change.clientId, change.frameId]);
5
+ object.runs ||= new Map();
6
+ const previous = object.runs.get(key);
7
+ const insertion = !change.history && change.value.length > 0;
8
+ if (!insertion) { object.runs.delete(key); return change; }
9
+ const continuing = change.length === 0 && change.runBreak !== true && previous && change.start === previous.nextPosition && change.left === previous.last;
10
+ const runId = continuing ? previous.id : change.id;
11
+ const runIndex = continuing ? previous.index + 1 : 0;
12
+ const runLeft = continuing ? previous.left : change.left;
13
+ const runRight = continuing ? previous.right : change.right;
14
+ if (change.runEnd === true) object.runs.delete(key);
15
+ else object.runs.set(key, {
16
+ id: runId, index: runIndex, left: runLeft, right: runRight,
17
+ last: `${change.id}:${change.value.length - 1}`,
18
+ nextPosition: change.start + change.value.length
19
+ });
20
+ return { ...change, runVersion: 1, runId, runIndex, runLeft, runRight };
21
+ }
22
+
23
+ export function clearRun(object, clientId, frameId) {
24
+ object?.runs?.delete(JSON.stringify([clientId, frameId]));
25
+ }
26
+
27
+ // A foreign edit may invalidate sequential positioning even when the last local
28
+ // character still exists. Detect this in CRDT rather than requiring DOM hooks.
29
+ export function reconcileRuns(object, change, previous, current) {
30
+ if (!object.runs?.size) return;
31
+ const boundaryRuns = new Set();
32
+ if (change.value.length) {
33
+ for (const id of [change.left, change.right]) {
34
+ const run = previous.nodes.get(id)?.runId;
35
+ if (run) boundaryRuns.add(run);
36
+ }
37
+ }
38
+ for (const id of [ ...(change.targets || []), ...(change.restores || []).map(item => item.target) ]) {
39
+ const run = previous.nodes.get(id)?.runId;
40
+ if (run) boundaryRuns.add(run);
41
+ }
42
+ const positions = new Map(current.visible.map((node, index) => [node.id, index + 1]));
43
+ for (const [key, run] of object.runs) {
44
+ if (positions.get(run.last) !== run.nextPosition || boundaryRuns.has(run.id)) object.runs.delete(key);
45
+ }
46
+ }
@@ -0,0 +1,53 @@
1
+ // Selection offsets are UTF-16 source offsets, just like text operations.
2
+ // Both neighbors survive deletion as tombstones. Affinity chooses a side when
3
+ // concurrent text is inserted into the same gap.
4
+ // Replay creates a new sequence for each text change. Share one source-offset
5
+ // index across all participants resolving against that immutable sequence.
6
+ const selectionOffsets = new WeakMap();
7
+ export function anchorSelection(state, selection) {
8
+ if (selection === null) return null;
9
+ if (!selection || typeof selection !== 'object' ||
10
+ !('anchor' in selection) || !('head' in selection))
11
+ throw new RangeError('Invalid CRDT selection.');
12
+ const endpoint = value => {
13
+ if (typeof value === 'object' && value !== null) {
14
+ if (!['left', 'right'].includes(value.affinity) ||
15
+ !('left' in value) || !('right' in value) ||
16
+ [value.left, value.right].some(id => id !== null && typeof id !== 'string'))
17
+ throw new RangeError('Invalid CRDT selection reference.');
18
+ return { left: value.left, right: value.right, affinity: value.affinity };
19
+ }
20
+ if (!Number.isInteger(value) || value < 0 || value > state.visible.length)
21
+ throw new RangeError('Invalid CRDT selection offset.');
22
+ return { left: state.visible[value - 1]?.id || null,
23
+ right: state.visible[value]?.id || null, affinity: 'left' };
24
+ };
25
+ return { anchor: endpoint(selection.anchor), head: endpoint(selection.head) };
26
+ }
27
+
28
+ export function resolveSelection(state, selection) {
29
+ if (selection === null) return null;
30
+ const endpoint = point => {
31
+ // An insertion may arrive after the selection that references it.
32
+ if ((point.left && !state.nodes.has(point.left)) ||
33
+ (point.right && !state.nodes.has(point.right))) return undefined;
34
+ const id = point.affinity === 'right' ? point.right : point.left;
35
+ if (id === null) return point.affinity === 'right' ? state.visible.length : 0;
36
+ let offsets = selectionOffsets.get(state);
37
+ if (!offsets) {
38
+ offsets = new Map();
39
+ let offset = 0;
40
+ for (const node of state.order) {
41
+ offsets.set(node.id, offset);
42
+ if (!node.deletedBy.size) offset++;
43
+ }
44
+ selectionOffsets.set(state, offsets);
45
+ }
46
+ const offset = offsets.get(id);
47
+ if (offset === undefined) return undefined;
48
+ return offset + (point.affinity === 'left' && !state.nodes.get(id).deletedBy.size ? 1 : 0);
49
+ };
50
+ const anchor = endpoint(selection.anchor), head = endpoint(selection.head);
51
+ if (anchor === undefined || head === undefined) return undefined;
52
+ return { anchor, head, start: Math.min(anchor, head), end: Math.max(anchor, head) };
53
+ }
@@ -0,0 +1,160 @@
1
+ // Stable identities belong to source characters, not transient numeric positions.
2
+ // Indexes use UTF-16 code units to match String.slice and existing editor APIs.
3
+ const tokenId = (operation, index) => `${operation}:${index}`;
4
+
5
+ function compareBranches(a, b, parent) {
6
+ const continuing = node => !!node.runId && node.runId === parent?.runId && node.clientId === parent?.clientId;
7
+ if (continuing(a) !== continuing(b)) return continuing(a) ? -1 : 1;
8
+ if (a.runId && b.runId) {
9
+ // Code-unit comparison is locale-independent across clients.
10
+ if (a.clientId !== b.clientId) return a.clientId < b.clientId ? -1 : 1;
11
+ if (a.runId !== b.runId) return a.runId < b.runId ? -1 : 1;
12
+ }
13
+ return a.operation > b.operation ? -1 : a.operation < b.operation ? 1 : b.index - a.index;
14
+ }
15
+
16
+ function orderBranches(list, parentId, nodes) {
17
+ // Observed placement takes precedence over concurrent client priority. If a
18
+ // user inserts before existing content, retain that explicit right boundary.
19
+ const edges = new Map(list.map(node => [node.id, new Set()]));
20
+ const incoming = new Map(list.map(node => [node.id, 0]));
21
+ for (const node of list) {
22
+ let right = nodes.get(node.right);
23
+ const visited = new Set();
24
+ while (right && right.after !== parentId && !visited.has(right.id)) {
25
+ visited.add(right.id);
26
+ right = nodes.get(right.after);
27
+ }
28
+ if (right && right.id !== node.id && incoming.has(right.id) && !edges.get(node.id).has(right.id)) {
29
+ edges.get(node.id).add(right.id);
30
+ incoming.set(right.id, incoming.get(right.id) + 1);
31
+ }
32
+ }
33
+ const compare = (a, b) => compareBranches(a, b, nodes.get(parentId));
34
+ const ready = list.filter(node => incoming.get(node.id) === 0).sort(compare);
35
+ const byId = new Map(list.map(node => [node.id, node]));
36
+ const result = [];
37
+ while (ready.length) {
38
+ const node = ready.shift();
39
+ result.push(node);
40
+ for (const id of edges.get(node.id)) {
41
+ incoming.set(id, incoming.get(id) - 1);
42
+ if (incoming.get(id) === 0) { ready.push(byId.get(id)); ready.sort(compare); }
43
+ }
44
+ }
45
+ if (result.length !== list.length) throw new Error('Conflicting CRDT insertion boundaries.');
46
+ return result;
47
+ }
48
+
49
+ function visibleState(nodes) {
50
+ const children = new Map();
51
+ for (const node of nodes.values()) {
52
+ let list = children.get(node.after);
53
+ if (!list) children.set(node.after, list = []);
54
+ list.push(node);
55
+ }
56
+ for (const [parent, list] of children) children.set(parent, orderBranches(list, parent, nodes));
57
+ const stack = [...(children.get(null) || [])].reverse();
58
+ const visible = [], order = [], visited = new Set();
59
+ while (stack.length) {
60
+ const node = stack.pop();
61
+ if (visited.has(node.id)) continue;
62
+ visited.add(node.id);
63
+ order.push(node);
64
+ if (!node.deletedBy.size) visible.push(node);
65
+ const descendants = children.get(node.id) || [];
66
+ for (let i = descendants.length - 1; i >= 0; i--) stack.push(descendants[i]);
67
+ }
68
+ return { nodes, visible, order, text: visible.map(node => node.value).join('') };
69
+ }
70
+
71
+ export function prepareChange(state, change) {
72
+ const start = change.start, length = change.length || 0;
73
+ if (!Number.isInteger(start) || !Number.isInteger(length) || start < 0 || length < 0 ||
74
+ start + length > state.visible.length) throw new RangeError('Invalid CRDT text range.');
75
+ const targets = state.visible.slice(start, start + length);
76
+ return {
77
+ ...change, format: 2,
78
+ left: state.visible[start - 1]?.id || null,
79
+ right: state.visible[start + length]?.id || null,
80
+ targets: targets.map(node => node.id),
81
+ restores: [],
82
+ removedValue: targets.map(node => node.value).join(''),
83
+ previousCharacter: state.visible[start - 1]?.value || '',
84
+ nextCharacter: state.visible[start + length]?.value || ''
85
+ };
86
+ }
87
+
88
+ export function replaySequence(log) {
89
+ const snapshot = log.filter(change => change.type === 'snapshot').sort((a, b) => b.id.localeCompare(a.id))[0];
90
+ const nodes = new Map();
91
+ const seen = new Set(snapshot?.seenIds || []);
92
+ const applied = new Set();
93
+ const operations = new Map();
94
+ if (snapshot?.sequence) {
95
+ for (const node of snapshot.sequence) nodes.set(node.id, { ...node, deletedBy: new Set(node.deletedBy) });
96
+ } else if (snapshot) {
97
+ let after = null;
98
+ for (let index = 0; index < snapshot.value.length; index++) {
99
+ const id = tokenId(snapshot.id, index);
100
+ nodes.set(id, { id, operation: snapshot.id, index, after, value: snapshot.value[index], deletedBy: new Set() });
101
+ after = id;
102
+ }
103
+ }
104
+ const deletions = [], restorations = [];
105
+ const sorted = log.filter(change => change.type !== 'snapshot').slice().sort((a, b) => a.id.localeCompare(b.id));
106
+ for (const raw of sorted) {
107
+ if (seen.has(raw.id) || applied.has(raw.id)) continue;
108
+ applied.add(raw.id);
109
+ // Historical positional operations can only be interpreted in their old
110
+ // deterministic replay order. New operations always carry author identities.
111
+ let change = raw;
112
+ if (raw.format !== 2) {
113
+ const current = visibleState(nodes);
114
+ change = prepareChange(current, { ...raw,
115
+ start: Math.min(raw.start, current.visible.length),
116
+ length: Math.min(raw.length || 0, Math.max(0, current.visible.length - raw.start)) });
117
+ }
118
+ operations.set(change.id, change);
119
+ let after = change.left;
120
+ for (let index = 0; index < change.value.length; index++) {
121
+ const id = tokenId(change.id, index);
122
+ nodes.set(id, { id, operation: change.id, index, after, value: change.value[index],
123
+ clientId: change.clientId, runId: change.runId, right: change.right, deletedBy: new Set() });
124
+ after = id;
125
+ }
126
+ for (const id of change.targets || []) {
127
+ deletions.push([id, change.id]);
128
+ // Apply immediately too, for positional legacy entries later in this replay.
129
+ nodes.get(id)?.deletedBy.add(change.id);
130
+ }
131
+ for (const restore of change.restores || []) {
132
+ restorations.push(restore);
133
+ nodes.get(restore.target)?.deletedBy.delete(restore.deletion);
134
+ }
135
+ }
136
+ // Preserve deletes/restores delivered before the insertion they reference.
137
+ for (const [id, deletion] of deletions) nodes.get(id)?.deletedBy.add(deletion);
138
+ for (const { target, deletion } of restorations) nodes.get(target)?.deletedBy.delete(deletion);
139
+ return { ...visibleState(nodes), seenIds: new Set([...seen, ...applied]), operations };
140
+ }
141
+
142
+ export function inverseChange(state, change, id, data) {
143
+ const inserted = Array.from({ length: change.value.length }, (_, index) => tokenId(change.id, index));
144
+ const targets = [...inserted, ...(change.restores || []).map(restore => restore.target)];
145
+ const indexes = targets.map(target => state.visible.findIndex(node => node.id === target)).filter(index => index >= 0);
146
+ return {
147
+ ...data, id, frameId: undefined, history: true, format: 2,
148
+ start: indexes.length ? Math.min(...indexes) : change.start,
149
+ length: indexes.length, value: '', left: change.left, right: change.right,
150
+ targets, restores: (change.targets || []).map(target => ({ target, deletion: change.id })),
151
+ removedValue: indexes.map(index => state.visible[index].value).join('')
152
+ };
153
+ }
154
+
155
+ export function textDiff(before, after) {
156
+ let start = 0, oldEnd = before.length, newEnd = after.length;
157
+ while (start < oldEnd && start < newEnd && before[start] === after[start]) start++;
158
+ while (oldEnd > start && newEnd > start && before[oldEnd - 1] === after[newEnd - 1]) { oldEnd--; newEnd--; }
159
+ return { start, length: oldEnd - start, value: after.slice(start, newEnd) };
160
+ }