@vuer-ai/vuer-rtc 0.7.0 → 0.8.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.
- package/CLAUDE.md +3 -2
- package/dist/client/EditBuffer.d.ts +4 -4
- package/dist/client/EditBuffer.d.ts.map +1 -1
- package/dist/client/EditBuffer.js +26 -25
- package/dist/client/EditBuffer.js.map +1 -1
- package/dist/client/actions.d.ts +3 -3
- package/dist/client/actions.d.ts.map +1 -1
- package/dist/client/actions.js +71 -70
- package/dist/client/actions.js.map +1 -1
- package/dist/client/coalesceGraphOps.d.ts +4 -4
- package/dist/client/coalesceGraphOps.js +4 -4
- package/dist/client/coalesceTextOperations.d.ts.map +1 -1
- package/dist/client/coalesceTextOperations.js +23 -20
- package/dist/client/coalesceTextOperations.js.map +1 -1
- package/dist/client/coalescence/lwwOperations.js +3 -3
- package/dist/client/coalescence/lwwOperations.js.map +1 -1
- package/dist/client/coalescence/numberOperations.js +2 -2
- package/dist/client/coalescence/numberOperations.js.map +1 -1
- package/dist/client/coalescence/registry.d.ts +3 -3
- package/dist/client/coalescence/registry.d.ts.map +1 -1
- package/dist/client/coalescence/registry.js +11 -11
- package/dist/client/coalescence/registry.js.map +1 -1
- package/dist/client/coalescence/textDeletes.d.ts +8 -7
- package/dist/client/coalescence/textDeletes.d.ts.map +1 -1
- package/dist/client/coalescence/textDeletes.js +11 -11
- package/dist/client/coalescence/textDeletes.js.map +1 -1
- package/dist/client/coalescence/textInserts.d.ts +8 -5
- package/dist/client/coalescence/textInserts.d.ts.map +1 -1
- package/dist/client/coalescence/textInserts.js +32 -12
- package/dist/client/coalescence/textInserts.js.map +1 -1
- package/dist/client/coalescence/utils.d.ts +3 -9
- package/dist/client/coalescence/utils.d.ts.map +1 -1
- package/dist/client/coalescence/utils.js +10 -8
- package/dist/client/coalescence/utils.js.map +1 -1
- package/dist/client/coalescence/vector3Operations.js +2 -2
- package/dist/client/coalescence/vector3Operations.js.map +1 -1
- package/dist/client/createGraph.d.ts +2 -2
- package/dist/client/createGraph.js +4 -4
- package/dist/client/createGraph.js.map +1 -1
- package/dist/client/createTextDocument.d.ts +1 -1
- package/dist/client/createTextDocument.js +3 -3
- package/dist/client/createTextDocument.js.map +1 -1
- package/dist/client/hooks.d.ts +3 -3
- package/dist/client/hooks.d.ts.map +1 -1
- package/dist/client/hooks.js +4 -4
- package/dist/client/hooks.js.map +1 -1
- package/dist/client/textActions.d.ts +2 -2
- package/dist/client/textActions.d.ts.map +1 -1
- package/dist/client/textActions.js +47 -47
- package/dist/client/textActions.js.map +1 -1
- package/dist/client/textTypes.d.ts +8 -8
- package/dist/client/textTypes.d.ts.map +1 -1
- package/dist/client/types.d.ts +4 -4
- package/dist/client/types.d.ts.map +1 -1
- package/dist/crdt/GraphTextCRDT.d.ts +2 -2
- package/dist/crdt/GraphTextCRDT.d.ts.map +1 -1
- package/dist/crdt/GraphTextCRDT.js +6 -6
- package/dist/crdt/GraphTextCRDT.js.map +1 -1
- package/dist/crdt/Rope.d.ts +13 -14
- package/dist/crdt/Rope.d.ts.map +1 -1
- package/dist/crdt/Rope.js +130 -59
- package/dist/crdt/Rope.js.map +1 -1
- package/dist/crdt/index.d.ts +1 -1
- package/dist/crdt/index.d.ts.map +1 -1
- package/dist/crdt/index.js +1 -1
- package/dist/crdt/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/operations/OperationTypes.d.ts +45 -48
- package/dist/operations/OperationTypes.d.ts.map +1 -1
- package/dist/operations/OperationValidator.js +11 -11
- package/dist/operations/OperationValidator.js.map +1 -1
- package/dist/operations/apply/node.js +3 -3
- package/dist/operations/apply/node.js.map +1 -1
- package/dist/operations/apply/text.d.ts.map +1 -1
- package/dist/operations/apply/text.js +35 -32
- package/dist/operations/apply/text.js.map +1 -1
- package/dist/operations/apply/types.d.ts +4 -4
- package/dist/operations/apply/types.d.ts.map +1 -1
- package/dist/operations/apply/types.js +8 -8
- package/dist/operations/apply/types.js.map +1 -1
- package/dist/operations/dispatcher.d.ts.map +1 -1
- package/dist/operations/dispatcher.js +52 -13
- package/dist/operations/dispatcher.js.map +1 -1
- package/dist/serdes.d.ts +1 -1
- package/dist/serdes.d.ts.map +1 -1
- package/dist/state/ConflictResolver.d.ts +9 -9
- package/dist/state/ConflictResolver.d.ts.map +1 -1
- package/dist/state/ConflictResolver.js +20 -20
- package/dist/state/ConflictResolver.js.map +1 -1
- package/dist/state/DType.d.ts +2 -2
- package/dist/state/DType.d.ts.map +1 -1
- package/dist/state/DType.js +14 -14
- package/dist/state/DType.js.map +1 -1
- package/dist/state/VectorClock.d.ts +6 -6
- package/dist/state/VectorClock.d.ts.map +1 -1
- package/dist/state/VectorClock.js +14 -14
- package/dist/state/VectorClock.js.map +1 -1
- package/dist/state/index.d.ts +1 -1
- package/dist/state/index.js +1 -1
- package/examples/01-basic-usage.ts +16 -16
- package/examples/02-concurrent-edits.ts +29 -29
- package/examples/03-scene-building.ts +28 -28
- package/examples/04-conflict-resolution.ts +56 -56
- package/examples/05-coalescence-usage.ts +23 -23
- package/examples/README.md +12 -12
- package/package.json +1 -1
- package/src/client/EditBuffer.ts +28 -27
- package/src/client/TEXT_DOCUMENT_API.md +9 -9
- package/src/client/actions.ts +74 -70
- package/src/client/coalesceGraphOps.ts +4 -4
- package/src/client/coalesceTextOperations.ts +26 -22
- package/src/client/coalescence/lwwOperations.ts +3 -3
- package/src/client/coalescence/numberOperations.ts +2 -2
- package/src/client/coalescence/registry.ts +13 -12
- package/src/client/coalescence/textDeletes.ts +22 -18
- package/src/client/coalescence/textInserts.ts +49 -25
- package/src/client/coalescence/utils.ts +14 -11
- package/src/client/coalescence/vector3Operations.ts +2 -2
- package/src/client/createGraph.ts +4 -4
- package/src/client/createTextDocument.ts +3 -3
- package/src/client/hooks.tsx +5 -5
- package/src/client/textActions.ts +47 -47
- package/src/client/textTypes.ts +8 -8
- package/src/client/types.ts +4 -4
- package/src/crdt/GraphTextCRDT.ts +6 -6
- package/src/crdt/Rope.ts +156 -71
- package/src/crdt/index.ts +2 -0
- package/src/index.ts +2 -0
- package/src/operations/OperationTypes.ts +47 -47
- package/src/operations/OperationValidator.ts +11 -11
- package/src/operations/apply/node.ts +3 -3
- package/src/operations/apply/text.ts +38 -32
- package/src/operations/apply/types.ts +11 -11
- package/src/operations/dispatcher.ts +57 -13
- package/src/serdes.ts +1 -1
- package/src/state/ConflictResolver.ts +23 -23
- package/src/state/DType.ts +16 -16
- package/src/state/VectorClock.ts +14 -14
- package/src/state/index.ts +1 -1
- package/tests/client/actions.test.ts +76 -76
- package/tests/client/coalesce-graph-operations.test.ts +84 -84
- package/tests/client/coalesce-text-operations.test.ts +91 -114
- package/tests/client/compaction.test.ts +18 -18
- package/tests/client/delete-coalescence-bug.test.ts +34 -34
- package/tests/client/edit-buffer.test.ts +27 -30
- package/tests/client/graph-coalescence-phase1.test.ts +66 -66
- package/tests/client/graph-coalescence.test.ts +50 -50
- package/tests/client/journal-benchmark.test.ts +5 -5
- package/tests/crdt/graph-text-crdt.test.ts +60 -64
- package/tests/crdt/rope.test.ts +9 -8
- package/tests/crdt/text-operations.test.ts +28 -28
- package/tests/fixtures/array-ops.jsonl +6 -6
- package/tests/fixtures/boolean-ops.jsonl +6 -6
- package/tests/fixtures/color-ops.jsonl +4 -4
- package/tests/fixtures/edit-buffer.jsonl +3 -3
- package/tests/fixtures/node-ops.jsonl +6 -6
- package/tests/fixtures/number-ops.jsonl +7 -7
- package/tests/fixtures/object-ops.jsonl +4 -4
- package/tests/fixtures/operations.jsonl +7 -7
- package/tests/fixtures/string-ops.jsonl +4 -4
- package/tests/fixtures/undo-redo.jsonl +3 -3
- package/tests/fixtures/vector-ops.jsonl +17 -17
- package/tests/operations/collections.test.ts +4 -4
- package/tests/operations/nodes.test.ts +5 -5
- package/tests/operations/operation-ordering.test.ts +406 -0
- package/tests/operations/primitives.test.ts +4 -4
- package/tests/operations/unified-schema.test.ts +27 -27
- package/tests/operations/vectors.test.ts +4 -4
- package/tests/sync/digest.test.ts +5 -5
|
@@ -4,14 +4,18 @@
|
|
|
4
4
|
* Merges consecutive text delete operations.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { Operation } from '../../operations/OperationTypes.js';
|
|
8
7
|
import { optimizeDeletions, parseItemId } from './utils.js';
|
|
9
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Text delete operation for graph coalescence.
|
|
11
|
+
* This is the wire format for text.delete operations (with key/path context).
|
|
12
|
+
* Uses compressed schema with tuple-based fields.
|
|
13
|
+
*/
|
|
10
14
|
export interface TextDeleteOp {
|
|
11
|
-
|
|
12
|
-
key: string;
|
|
13
|
-
path: string;
|
|
14
|
-
|
|
15
|
+
ot: 'text.delete'; // Graph operation type
|
|
16
|
+
key: string; // Node key
|
|
17
|
+
path: string; // Property path
|
|
18
|
+
rm: Array<[string, number]>; // [[id, length], ...]
|
|
15
19
|
// NOTE: TextDeleteOp does NOT have seq/ts fields (those are only on TextInsertOp)
|
|
16
20
|
// Time-based coalescence is not available for delete operations
|
|
17
21
|
}
|
|
@@ -25,13 +29,13 @@ export interface CoalesceOptions {
|
|
|
25
29
|
* Sort and optimize deletions array.
|
|
26
30
|
* Deletions must be sorted in ascending order for optimizeDeletions to work correctly.
|
|
27
31
|
*/
|
|
28
|
-
function sortAndOptimizeDeletions(deletions: Array<
|
|
32
|
+
function sortAndOptimizeDeletions(deletions: Array<[string, number]>): Array<[string, number]> {
|
|
29
33
|
if (deletions.length === 0) return deletions;
|
|
30
34
|
|
|
31
35
|
// Sort deletions by agent, then by sequence number (ascending)
|
|
32
36
|
const sorted = [...deletions].sort((a, b) => {
|
|
33
|
-
const aId = parseItemId(a
|
|
34
|
-
const bId = parseItemId(b
|
|
37
|
+
const aId = parseItemId(a[0]); // id is at [0]
|
|
38
|
+
const bId = parseItemId(b[0]); // id is at [0]
|
|
35
39
|
if (aId.agent !== bId.agent) {
|
|
36
40
|
return aId.agent.localeCompare(bId.agent);
|
|
37
41
|
}
|
|
@@ -44,11 +48,11 @@ function sortAndOptimizeDeletions(deletions: Array<{ id: string; length: number
|
|
|
44
48
|
/**
|
|
45
49
|
* Check if an operation is a text delete with CRDT metadata
|
|
46
50
|
*/
|
|
47
|
-
export function isTextDeleteOp(op:
|
|
51
|
+
export function isTextDeleteOp(op: any): op is TextDeleteOp {
|
|
48
52
|
return (
|
|
49
|
-
op.
|
|
50
|
-
Array.isArray(
|
|
51
|
-
|
|
53
|
+
op.ot === 'text.delete' &&
|
|
54
|
+
Array.isArray(op.rm) &&
|
|
55
|
+
op.rm.length > 0
|
|
52
56
|
);
|
|
53
57
|
}
|
|
54
58
|
|
|
@@ -77,7 +81,7 @@ export function coalesceTextDeletes(
|
|
|
77
81
|
for (const op of ops) {
|
|
78
82
|
if (pending === null) {
|
|
79
83
|
// Start new pending delete
|
|
80
|
-
pending = { ...op,
|
|
84
|
+
pending = { ...op, rm: [...op.rm] };
|
|
81
85
|
} else {
|
|
82
86
|
// Check merge conditions
|
|
83
87
|
const sameTarget = pending.key === op.key && pending.path === op.path;
|
|
@@ -87,16 +91,16 @@ export function coalesceTextDeletes(
|
|
|
87
91
|
if (sameTarget) {
|
|
88
92
|
// Merge operations - combine deletion lists
|
|
89
93
|
const merged: TextDeleteOp = {
|
|
90
|
-
|
|
94
|
+
ot: 'text.delete',
|
|
91
95
|
key: pending.key,
|
|
92
96
|
path: pending.path,
|
|
93
|
-
|
|
97
|
+
rm: [...pending.rm, ...op.rm],
|
|
94
98
|
};
|
|
95
99
|
pending = merged;
|
|
96
100
|
} else {
|
|
97
101
|
// Can't merge - flush pending and start new
|
|
98
102
|
result.push(pending);
|
|
99
|
-
pending = { ...op,
|
|
103
|
+
pending = { ...op, rm: [...op.rm] };
|
|
100
104
|
}
|
|
101
105
|
}
|
|
102
106
|
}
|
|
@@ -104,13 +108,13 @@ export function coalesceTextDeletes(
|
|
|
104
108
|
// Flush any remaining pending delete
|
|
105
109
|
if (pending !== null) {
|
|
106
110
|
// Sort and optimize deletions array before pushing
|
|
107
|
-
pending.
|
|
111
|
+
pending.rm = sortAndOptimizeDeletions(pending.rm);
|
|
108
112
|
result.push(pending);
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
// Also sort and optimize deletions in all previously pushed operations
|
|
112
116
|
for (const op of result) {
|
|
113
|
-
op.
|
|
117
|
+
op.rm = sortAndOptimizeDeletions(op.rm);
|
|
114
118
|
}
|
|
115
119
|
|
|
116
120
|
return result;
|
|
@@ -12,18 +12,38 @@
|
|
|
12
12
|
* 5. Compatible YATA structure (forms a chain)
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type { Operation } from '../../operations/OperationTypes.js';
|
|
16
15
|
import { parseItemId } from '../../crdt/Rope.js';
|
|
17
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Text insert operation for graph coalescence.
|
|
19
|
+
* This is the wire format for text.insert operations (with key/path context).
|
|
20
|
+
* Uses compressed schema with tuple-based fields.
|
|
21
|
+
*/
|
|
18
22
|
export interface TextInsertOp {
|
|
19
|
-
|
|
20
|
-
key: string;
|
|
21
|
-
path: string;
|
|
22
|
-
id: string;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
ot: 'text.insert'; // Graph operation type
|
|
24
|
+
key: string; // Node key
|
|
25
|
+
path: string; // Property path
|
|
26
|
+
id: string; // CRDT item ID
|
|
27
|
+
value: [string | null, string]; // [anchor, content]
|
|
28
|
+
seq: number; // Lamport timestamp
|
|
29
|
+
ts: number; // Wall-clock time
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Calculate the last character ID for an insert operation.
|
|
34
|
+
* If the operation has _lastCharId metadata, use it. Otherwise calculate from id + content length.
|
|
35
|
+
*/
|
|
36
|
+
function getLastCharId(op: TextInsertOp): string {
|
|
37
|
+
// Check if operation has _lastCharId metadata (from previous coalescence)
|
|
38
|
+
const lastCharId = (op as any)._lastCharId;
|
|
39
|
+
if (lastCharId) return lastCharId;
|
|
40
|
+
|
|
41
|
+
// Calculate from id + content length
|
|
42
|
+
const content = op.value[1];
|
|
43
|
+
if (content.length === 0) return op.id;
|
|
44
|
+
|
|
45
|
+
const parsed = parseItemId(op.id);
|
|
46
|
+
return `${parsed.agent}:${parsed.seq + content.length - 1}`;
|
|
27
47
|
}
|
|
28
48
|
|
|
29
49
|
export interface CoalesceOptions {
|
|
@@ -34,13 +54,14 @@ export interface CoalesceOptions {
|
|
|
34
54
|
/**
|
|
35
55
|
* Check if an operation is a text insert with CRDT metadata
|
|
36
56
|
*/
|
|
37
|
-
export function isTextInsertOp(op:
|
|
57
|
+
export function isTextInsertOp(op: any): op is TextInsertOp {
|
|
38
58
|
return (
|
|
39
|
-
op.
|
|
40
|
-
typeof
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
typeof
|
|
59
|
+
op.ot === 'text.insert' &&
|
|
60
|
+
typeof op.id === 'string' &&
|
|
61
|
+
Array.isArray(op.value) &&
|
|
62
|
+
op.value.length === 2 &&
|
|
63
|
+
typeof op.seq === 'number' &&
|
|
64
|
+
typeof op.ts === 'number'
|
|
44
65
|
);
|
|
45
66
|
}
|
|
46
67
|
|
|
@@ -79,9 +100,10 @@ export function coalesceTextInserts(
|
|
|
79
100
|
const sameAgent = prevId.agent === currId.agent;
|
|
80
101
|
const sameTarget = pending.key === op.key && pending.path === op.path;
|
|
81
102
|
|
|
82
|
-
// IDs must be sequential: next ID = prev ID + prev content length
|
|
83
|
-
// Example: prev="alice:5"
|
|
84
|
-
const
|
|
103
|
+
// IDs must be sequential: next ID = prev ID + prev value/content length
|
|
104
|
+
// Example: prev="alice:5" value="hel"(3 chars) → next="alice:8"
|
|
105
|
+
const pendingContent = pending.value[1]; // content is value[1]
|
|
106
|
+
const sequentialIds = currId.seq === prevId.seq + pendingContent.length;
|
|
85
107
|
|
|
86
108
|
// Time threshold: operations must be close in time (ts is in seconds)
|
|
87
109
|
const timeDiffMs = (op.ts - pending.ts) * 1000;
|
|
@@ -91,24 +113,26 @@ export function coalesceTextInserts(
|
|
|
91
113
|
// Current op's parent should be the last character ID in the merged content
|
|
92
114
|
// (not the first ID, which is what pending.id contains after merging)
|
|
93
115
|
// OR both should have the same parent (inserting at same position)
|
|
94
|
-
const prevLastId = (pending
|
|
116
|
+
const prevLastId = getLastCharId(pending);
|
|
117
|
+
const opAnchor = op.value[0]; // anchor is value[0] (can be null)
|
|
118
|
+
const pendingAnchor = pending.value[0]; // anchor is value[0] (can be null)
|
|
95
119
|
const formsChain =
|
|
96
|
-
|
|
97
|
-
(
|
|
120
|
+
opAnchor === prevLastId ||
|
|
121
|
+
(pendingAnchor === opAnchor && pendingAnchor !== null);
|
|
98
122
|
|
|
99
123
|
if (sameAgent && sameTarget && sequentialIds && withinThreshold && formsChain) {
|
|
100
124
|
// Merge operations
|
|
125
|
+
const opContent = op.value[1]; // content is value[1]
|
|
101
126
|
const merged: TextInsertOp = {
|
|
102
|
-
|
|
127
|
+
ot: 'text.insert',
|
|
103
128
|
key: pending.key,
|
|
104
129
|
path: pending.path,
|
|
105
130
|
id: pending.id, // Keep first ID (anchor point)
|
|
106
|
-
|
|
107
|
-
parentId: pending.parentId, // Keep first parentId
|
|
131
|
+
value: [pendingAnchor, pendingContent + opContent], // [anchor, merged content]
|
|
108
132
|
seq: Math.max(pending.seq, op.seq), // Use max Lamport clock for ordering
|
|
109
133
|
ts: pending.ts, // Keep first timestamp (when sequence started)
|
|
110
134
|
// Track the last character ID for chain validation in next merge
|
|
111
|
-
_lastCharId: op
|
|
135
|
+
_lastCharId: getLastCharId(op),
|
|
112
136
|
} as any;
|
|
113
137
|
pending = merged;
|
|
114
138
|
} else {
|
|
@@ -23,33 +23,36 @@ export function parseItemId(id: ItemId): { agent: string; seq: number } {
|
|
|
23
23
|
* Optimize a deletions array by merging consecutive deletions from the same agent.
|
|
24
24
|
*
|
|
25
25
|
* Example:
|
|
26
|
-
* Input: [
|
|
27
|
-
* Output: [
|
|
26
|
+
* Input: [["alice:1", 1], ["alice:2", 1], ["alice:3", 1]]
|
|
27
|
+
* Output: [["alice:1", 3]]
|
|
28
28
|
*
|
|
29
29
|
* This reduces the size of deletion arrays when consecutive items are deleted.
|
|
30
30
|
*/
|
|
31
31
|
export function optimizeDeletions(
|
|
32
|
-
deletions: Array<
|
|
33
|
-
): Array<
|
|
32
|
+
deletions: Array<[ItemId, number]>
|
|
33
|
+
): Array<[ItemId, number]> {
|
|
34
34
|
if (deletions.length === 0) return deletions;
|
|
35
35
|
|
|
36
|
-
const result: Array<
|
|
37
|
-
let current =
|
|
36
|
+
const result: Array<[ItemId, number]> = [];
|
|
37
|
+
let current: [ItemId, number] = [...deletions[0]]; // Clone the tuple
|
|
38
38
|
|
|
39
39
|
for (let i = 1; i < deletions.length; i++) {
|
|
40
40
|
const deletion = deletions[i];
|
|
41
|
-
const
|
|
42
|
-
const
|
|
41
|
+
const currentId = current[0]; // id is at [0]
|
|
42
|
+
const deletionId = deletion[0]; // id is at [0]
|
|
43
|
+
|
|
44
|
+
const currParsed = parseItemId(currentId);
|
|
45
|
+
const delParsed = parseItemId(deletionId);
|
|
43
46
|
|
|
44
47
|
// Can merge if: same agent AND current deletion ends where next starts
|
|
45
48
|
if (currParsed.agent === delParsed.agent &&
|
|
46
|
-
currParsed.seq + current
|
|
49
|
+
currParsed.seq + current[1] === delParsed.seq) {
|
|
47
50
|
// Merge: extend current deletion
|
|
48
|
-
current
|
|
51
|
+
current[1] += deletion[1]; // length is at [1]
|
|
49
52
|
} else {
|
|
50
53
|
// Cannot merge: push current and start new
|
|
51
54
|
result.push(current);
|
|
52
|
-
current =
|
|
55
|
+
current = [...deletion]; // Clone the tuple
|
|
53
56
|
}
|
|
54
57
|
}
|
|
55
58
|
|
|
@@ -16,7 +16,7 @@ export type { Vector3AddOp };
|
|
|
16
16
|
*/
|
|
17
17
|
export function isVector3AddOp(op: Operation): op is Vector3AddOp {
|
|
18
18
|
return (
|
|
19
|
-
op.
|
|
19
|
+
op.ot === 'vector3.add' &&
|
|
20
20
|
Array.isArray((op as any).value) &&
|
|
21
21
|
(op as any).value.length === 3
|
|
22
22
|
);
|
|
@@ -59,7 +59,7 @@ export function coalesceVector3Adds(
|
|
|
59
59
|
if (sameTarget) {
|
|
60
60
|
// Merge operations by summing vectors component-wise
|
|
61
61
|
pending = {
|
|
62
|
-
|
|
62
|
+
ot: 'vector3.add',
|
|
63
63
|
key: pending.key,
|
|
64
64
|
path: pending.path,
|
|
65
65
|
value: [
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
* Usage:
|
|
5
5
|
* ```typescript
|
|
6
6
|
* const store = createGraph({
|
|
7
|
-
*
|
|
7
|
+
* client: 'my-session',
|
|
8
8
|
* onSend: (msg) => websocket.send(msg),
|
|
9
9
|
* });
|
|
10
10
|
*
|
|
11
|
-
* store.edit({
|
|
11
|
+
* store.edit({ ot: 'vector3.add', key: 'cube', path: 'position', value: [1, 0, 0] });
|
|
12
12
|
* store.commit('Move cube');
|
|
13
13
|
* store.undo();
|
|
14
14
|
* ```
|
|
@@ -35,7 +35,7 @@ import type { VectorClock } from '../state/VectorClock.js';
|
|
|
35
35
|
* Create a graph store
|
|
36
36
|
*/
|
|
37
37
|
export function createGraph(options: CreateGraphOptions): GraphStore {
|
|
38
|
-
let state = createInitialState(options.
|
|
38
|
+
let state = createInitialState(options.client, options.initialSnapshot);
|
|
39
39
|
const listeners = new Set<() => void>();
|
|
40
40
|
let coalescingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
41
41
|
let coalescingEnabled = options.coalescingEnabled ?? false;
|
|
@@ -136,7 +136,7 @@ export function createGraph(options: CreateGraphOptions): GraphStore {
|
|
|
136
136
|
},
|
|
137
137
|
|
|
138
138
|
loadServerState: (snapshot: Snapshot, journal: CRDTMessage[]) => {
|
|
139
|
-
dispatch(() => initFromServer(options.
|
|
139
|
+
dispatch(() => initFromServer(options.client, snapshot, journal));
|
|
140
140
|
},
|
|
141
141
|
|
|
142
142
|
// Undo/redo
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Usage:
|
|
5
5
|
* ```typescript
|
|
6
6
|
* const store = createTextDocument({
|
|
7
|
-
*
|
|
7
|
+
* client: 'my-session',
|
|
8
8
|
* onSend: (msg) => websocket.send(msg),
|
|
9
9
|
* });
|
|
10
10
|
*
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
* Create a text document store
|
|
39
39
|
*/
|
|
40
40
|
export function createTextDocument(options: CreateTextDocumentOptions): TextDocumentStore {
|
|
41
|
-
let state = createInitialTextState(options.
|
|
41
|
+
let state = createInitialTextState(options.client, options.initialSnapshot);
|
|
42
42
|
const listeners = new Set<() => void>();
|
|
43
43
|
let coalescingTimer: ReturnType<typeof setTimeout> | null = null;
|
|
44
44
|
let coalescingEnabled = options.coalescingEnabled ?? false;
|
|
@@ -161,7 +161,7 @@ export function createTextDocument(options: CreateTextDocumentOptions): TextDocu
|
|
|
161
161
|
},
|
|
162
162
|
|
|
163
163
|
loadServerState: (snapshot: TextSnapshot, journal: TextMessage[]) => {
|
|
164
|
-
dispatch(() => initTextFromServer(options.
|
|
164
|
+
dispatch(() => initTextFromServer(options.client, snapshot, journal));
|
|
165
165
|
},
|
|
166
166
|
|
|
167
167
|
// Undo/redo
|
package/src/client/hooks.tsx
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* ```tsx
|
|
6
6
|
* function App() {
|
|
7
7
|
* return (
|
|
8
|
-
* <GraphProvider
|
|
8
|
+
* <GraphProvider client="my-session" onSend={sendToServer}>
|
|
9
9
|
* <Scene />
|
|
10
10
|
* </GraphProvider>
|
|
11
11
|
* );
|
|
@@ -42,21 +42,21 @@ const GraphContext = createContext<GraphStore | null>(null);
|
|
|
42
42
|
// ===========================================
|
|
43
43
|
|
|
44
44
|
export interface GraphProviderProps {
|
|
45
|
-
|
|
45
|
+
client: string;
|
|
46
46
|
initialSnapshot?: Snapshot;
|
|
47
47
|
onSend?: (msg: CRDTMessage) => void;
|
|
48
48
|
children: ReactNode;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export function GraphProvider({
|
|
52
|
-
|
|
52
|
+
client,
|
|
53
53
|
initialSnapshot,
|
|
54
54
|
onSend,
|
|
55
55
|
children,
|
|
56
56
|
}: GraphProviderProps): React.ReactElement {
|
|
57
57
|
const [store] = useState(() =>
|
|
58
58
|
createGraph({
|
|
59
|
-
|
|
59
|
+
client,
|
|
60
60
|
initialSnapshot,
|
|
61
61
|
onSend,
|
|
62
62
|
})
|
|
@@ -205,7 +205,7 @@ export function useDrag(nodeKey: string, path: string) {
|
|
|
205
205
|
const onDrag = useCallback(
|
|
206
206
|
(delta: [number, number, number]) => {
|
|
207
207
|
edit({
|
|
208
|
-
|
|
208
|
+
ot: 'vector3.add',
|
|
209
209
|
key: nodeKey,
|
|
210
210
|
path,
|
|
211
211
|
value: delta,
|
|
@@ -32,7 +32,7 @@ const clockManager = new VectorClockManager();
|
|
|
32
32
|
* Create initial text document state
|
|
33
33
|
*/
|
|
34
34
|
export function createInitialTextState(
|
|
35
|
-
|
|
35
|
+
client: string,
|
|
36
36
|
initialSnapshot?: TextSnapshot
|
|
37
37
|
): TextDocumentState {
|
|
38
38
|
if (initialSnapshot) {
|
|
@@ -41,26 +41,26 @@ export function createInitialTextState(
|
|
|
41
41
|
journal: [],
|
|
42
42
|
edits: { ops: [], baseText: null },
|
|
43
43
|
snapshot: initialSnapshot,
|
|
44
|
-
|
|
44
|
+
lt: initialSnapshot.lt,
|
|
45
45
|
vectorClock: { ...initialSnapshot.vectorClock },
|
|
46
|
-
|
|
46
|
+
client: client,
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
const rope = createRope(
|
|
50
|
+
const rope = createRope(client);
|
|
51
51
|
return {
|
|
52
52
|
rope,
|
|
53
53
|
journal: [],
|
|
54
54
|
edits: { ops: [], baseText: null },
|
|
55
55
|
snapshot: {
|
|
56
56
|
rope,
|
|
57
|
-
vectorClock: { [
|
|
58
|
-
|
|
57
|
+
vectorClock: { [client]: 0 },
|
|
58
|
+
lt: 0,
|
|
59
59
|
journalIndex: 0,
|
|
60
60
|
},
|
|
61
|
-
|
|
62
|
-
vectorClock: { [
|
|
63
|
-
|
|
61
|
+
lt: 0,
|
|
62
|
+
vectorClock: { [client]: 0 },
|
|
63
|
+
client: client,
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
|
|
@@ -82,9 +82,9 @@ export function onTextEdit(
|
|
|
82
82
|
|
|
83
83
|
// Clone rope and apply operation (rope operations mutate in place)
|
|
84
84
|
const rope = cloneRope(state.rope);
|
|
85
|
-
if (op.
|
|
85
|
+
if (op.ot === 'insert') {
|
|
86
86
|
ropeApply(rope, op);
|
|
87
|
-
} else if (op.
|
|
87
|
+
} else if (op.ot === 'delete') {
|
|
88
88
|
ropeApplyDelete(rope, op);
|
|
89
89
|
}
|
|
90
90
|
|
|
@@ -108,17 +108,17 @@ export function commitTextEdits(
|
|
|
108
108
|
? coalesceTextOperations(state.edits.ops, { thresholdMs: coalescingThresholdMs })
|
|
109
109
|
: state.edits.ops;
|
|
110
110
|
|
|
111
|
-
const msgId = `${state.
|
|
112
|
-
const vectorClock = clockManager.increment(state.vectorClock, state.
|
|
113
|
-
const lamportTime = state.
|
|
111
|
+
const msgId = `${state.client}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
112
|
+
const vectorClock = clockManager.increment(state.vectorClock, state.client);
|
|
113
|
+
const lamportTime = state.lt + 1;
|
|
114
114
|
|
|
115
115
|
const msg: TextMessage = {
|
|
116
116
|
msgId,
|
|
117
|
-
|
|
117
|
+
client: state.client,
|
|
118
118
|
operations,
|
|
119
119
|
vectorClock,
|
|
120
|
-
lamportTime,
|
|
121
|
-
|
|
120
|
+
lt: lamportTime,
|
|
121
|
+
ts: Date.now(),
|
|
122
122
|
description,
|
|
123
123
|
};
|
|
124
124
|
|
|
@@ -133,7 +133,7 @@ export function commitTextEdits(
|
|
|
133
133
|
journal: [...state.journal, entry],
|
|
134
134
|
edits: { ops: [], baseText: null },
|
|
135
135
|
vectorClock,
|
|
136
|
-
lamportTime,
|
|
136
|
+
lt: lamportTime,
|
|
137
137
|
},
|
|
138
138
|
msg,
|
|
139
139
|
};
|
|
@@ -153,9 +153,9 @@ export function cancelTextEdits(state: TextDocumentState): TextDocumentState {
|
|
|
153
153
|
for (const entry of state.journal) {
|
|
154
154
|
if (entry.deletedAt) continue; // Skip undone entries
|
|
155
155
|
for (const op of entry.msg.operations) {
|
|
156
|
-
if (op.
|
|
156
|
+
if (op.ot === 'insert') {
|
|
157
157
|
ropeApply(rope, op);
|
|
158
|
-
} else if (op.
|
|
158
|
+
} else if (op.ot === 'delete') {
|
|
159
159
|
ropeApplyDelete(rope, op);
|
|
160
160
|
}
|
|
161
161
|
}
|
|
@@ -196,9 +196,9 @@ export function onTextRemoteMessage(
|
|
|
196
196
|
// Clone rope and apply remote operations
|
|
197
197
|
const rope = cloneRope(state.rope);
|
|
198
198
|
for (const op of msg.operations) {
|
|
199
|
-
if (op.
|
|
199
|
+
if (op.ot === 'insert') {
|
|
200
200
|
ropeApply(rope, op);
|
|
201
|
-
} else if (op.
|
|
201
|
+
} else if (op.ot === 'delete') {
|
|
202
202
|
ropeApplyDelete(rope, op);
|
|
203
203
|
}
|
|
204
204
|
}
|
|
@@ -210,14 +210,14 @@ export function onTextRemoteMessage(
|
|
|
210
210
|
|
|
211
211
|
// Merge vector clocks
|
|
212
212
|
const vectorClock = clockManager.merge(state.vectorClock, msg.vectorClock);
|
|
213
|
-
const lamportTime = Math.max(state.
|
|
213
|
+
const lamportTime = Math.max(state.lt, msg.lt) + 1;
|
|
214
214
|
|
|
215
215
|
return {
|
|
216
216
|
...state,
|
|
217
217
|
rope,
|
|
218
218
|
journal: [...state.journal, entry],
|
|
219
219
|
vectorClock,
|
|
220
|
-
lamportTime,
|
|
220
|
+
lt: lamportTime,
|
|
221
221
|
};
|
|
222
222
|
}
|
|
223
223
|
|
|
@@ -225,11 +225,11 @@ export function onTextRemoteMessage(
|
|
|
225
225
|
* Initialize from server state
|
|
226
226
|
*/
|
|
227
227
|
export function initTextFromServer(
|
|
228
|
-
|
|
228
|
+
client: string,
|
|
229
229
|
snapshot: TextSnapshot,
|
|
230
230
|
journal: TextMessage[]
|
|
231
231
|
): TextDocumentState {
|
|
232
|
-
let state = createInitialTextState(
|
|
232
|
+
let state = createInitialTextState(client, snapshot);
|
|
233
233
|
|
|
234
234
|
// Apply journal entries
|
|
235
235
|
for (const msg of journal) {
|
|
@@ -249,7 +249,7 @@ export function undoText(
|
|
|
249
249
|
let lastIdx = -1;
|
|
250
250
|
for (let i = state.journal.length - 1; i >= 0; i--) {
|
|
251
251
|
if (
|
|
252
|
-
state.journal[i].msg.
|
|
252
|
+
state.journal[i].msg.client === state.client &&
|
|
253
253
|
!state.journal[i].deletedAt
|
|
254
254
|
) {
|
|
255
255
|
lastIdx = i;
|
|
@@ -271,24 +271,24 @@ export function undoText(
|
|
|
271
271
|
for (const entry of journal) {
|
|
272
272
|
if (entry.deletedAt) continue;
|
|
273
273
|
for (const op of entry.msg.operations) {
|
|
274
|
-
if (op.
|
|
274
|
+
if (op.ot === 'insert') {
|
|
275
275
|
ropeApply(rope, op);
|
|
276
|
-
} else if (op.
|
|
276
|
+
} else if (op.ot === 'delete') {
|
|
277
277
|
ropeApplyDelete(rope, op);
|
|
278
278
|
}
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
281
|
|
|
282
282
|
// Create undo message
|
|
283
|
-
const vectorClock = clockManager.increment(state.vectorClock, state.
|
|
284
|
-
const lamportTime = state.
|
|
283
|
+
const vectorClock = clockManager.increment(state.vectorClock, state.client);
|
|
284
|
+
const lamportTime = state.lt + 1;
|
|
285
285
|
const msg: TextMessage = {
|
|
286
|
-
msgId: `${state.
|
|
287
|
-
|
|
288
|
-
operations: [{
|
|
286
|
+
msgId: `${state.client}-undo-${Date.now()}`,
|
|
287
|
+
client: state.client,
|
|
288
|
+
operations: [{ ot: 'delete', rm: [] }], // Placeholder for undo marker
|
|
289
289
|
vectorClock,
|
|
290
|
-
lamportTime,
|
|
291
|
-
|
|
290
|
+
lt: lamportTime,
|
|
291
|
+
ts: Date.now(),
|
|
292
292
|
description: 'undo',
|
|
293
293
|
};
|
|
294
294
|
|
|
@@ -298,7 +298,7 @@ export function undoText(
|
|
|
298
298
|
rope,
|
|
299
299
|
journal,
|
|
300
300
|
vectorClock,
|
|
301
|
-
lamportTime,
|
|
301
|
+
lt: lamportTime,
|
|
302
302
|
},
|
|
303
303
|
msg,
|
|
304
304
|
};
|
|
@@ -314,7 +314,7 @@ export function redoText(
|
|
|
314
314
|
let lastIdx = -1;
|
|
315
315
|
for (let i = state.journal.length - 1; i >= 0; i--) {
|
|
316
316
|
if (
|
|
317
|
-
state.journal[i].msg.
|
|
317
|
+
state.journal[i].msg.client === state.client &&
|
|
318
318
|
state.journal[i].deletedAt
|
|
319
319
|
) {
|
|
320
320
|
lastIdx = i;
|
|
@@ -337,24 +337,24 @@ export function redoText(
|
|
|
337
337
|
for (const e of journal) {
|
|
338
338
|
if (e.deletedAt) continue;
|
|
339
339
|
for (const op of e.msg.operations) {
|
|
340
|
-
if (op.
|
|
340
|
+
if (op.ot === 'insert') {
|
|
341
341
|
ropeApply(rope, op);
|
|
342
|
-
} else if (op.
|
|
342
|
+
} else if (op.ot === 'delete') {
|
|
343
343
|
ropeApplyDelete(rope, op);
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
// Create redo message
|
|
349
|
-
const vectorClock = clockManager.increment(state.vectorClock, state.
|
|
350
|
-
const lamportTime = state.
|
|
349
|
+
const vectorClock = clockManager.increment(state.vectorClock, state.client);
|
|
350
|
+
const lamportTime = state.lt + 1;
|
|
351
351
|
const msg: TextMessage = {
|
|
352
|
-
msgId: `${state.
|
|
353
|
-
|
|
352
|
+
msgId: `${state.client}-redo-${Date.now()}`,
|
|
353
|
+
client: state.client,
|
|
354
354
|
operations: entry.msg.operations,
|
|
355
355
|
vectorClock,
|
|
356
|
-
lamportTime,
|
|
357
|
-
|
|
356
|
+
lt: lamportTime,
|
|
357
|
+
ts: Date.now(),
|
|
358
358
|
description: 'redo',
|
|
359
359
|
};
|
|
360
360
|
|
|
@@ -364,7 +364,7 @@ export function redoText(
|
|
|
364
364
|
rope,
|
|
365
365
|
journal,
|
|
366
366
|
vectorClock,
|
|
367
|
-
lamportTime,
|
|
367
|
+
lt: lamportTime,
|
|
368
368
|
},
|
|
369
369
|
msg,
|
|
370
370
|
};
|