@vuer-ai/vuer-rtc 0.0.1
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/client/EditBuffer.d.ts +43 -0
- package/dist/client/EditBuffer.d.ts.map +1 -0
- package/dist/client/EditBuffer.js +96 -0
- package/dist/client/EditBuffer.js.map +1 -0
- package/dist/client/actions.d.ts +66 -0
- package/dist/client/actions.d.ts.map +1 -0
- package/dist/client/actions.js +345 -0
- package/dist/client/actions.js.map +1 -0
- package/dist/client/createGraph.d.ts +30 -0
- package/dist/client/createGraph.d.ts.map +1 -0
- package/dist/client/createGraph.js +91 -0
- package/dist/client/createGraph.js.map +1 -0
- package/dist/client/hooks.d.ts +81 -0
- package/dist/client/hooks.d.ts.map +1 -0
- package/dist/client/hooks.js +161 -0
- package/dist/client/hooks.js.map +1 -0
- package/dist/client/index.d.ts +8 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +10 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/types.d.ts +74 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +11 -0
- package/dist/client/types.js.map +1 -0
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +7 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/operations/OperationTypes.d.ts +239 -0
- package/dist/operations/OperationTypes.d.ts.map +1 -0
- package/dist/operations/OperationTypes.js +10 -0
- package/dist/operations/OperationTypes.js.map +1 -0
- package/dist/operations/OperationValidator.d.ts +32 -0
- package/dist/operations/OperationValidator.d.ts.map +1 -0
- package/dist/operations/OperationValidator.js +208 -0
- package/dist/operations/OperationValidator.js.map +1 -0
- package/dist/operations/apply/array.d.ts +22 -0
- package/dist/operations/apply/array.d.ts.map +1 -0
- package/dist/operations/apply/array.js +64 -0
- package/dist/operations/apply/array.js.map +1 -0
- package/dist/operations/apply/boolean.d.ts +18 -0
- package/dist/operations/apply/boolean.d.ts.map +1 -0
- package/dist/operations/apply/boolean.js +34 -0
- package/dist/operations/apply/boolean.js.map +1 -0
- package/dist/operations/apply/color.d.ts +14 -0
- package/dist/operations/apply/color.d.ts.map +1 -0
- package/dist/operations/apply/color.js +46 -0
- package/dist/operations/apply/color.js.map +1 -0
- package/dist/operations/apply/index.d.ts +18 -0
- package/dist/operations/apply/index.d.ts.map +1 -0
- package/dist/operations/apply/index.js +26 -0
- package/dist/operations/apply/index.js.map +1 -0
- package/dist/operations/apply/node.d.ts +24 -0
- package/dist/operations/apply/node.d.ts.map +1 -0
- package/dist/operations/apply/node.js +77 -0
- package/dist/operations/apply/node.js.map +1 -0
- package/dist/operations/apply/number.d.ts +26 -0
- package/dist/operations/apply/number.d.ts.map +1 -0
- package/dist/operations/apply/number.js +54 -0
- package/dist/operations/apply/number.js.map +1 -0
- package/dist/operations/apply/object.d.ts +14 -0
- package/dist/operations/apply/object.d.ts.map +1 -0
- package/dist/operations/apply/object.js +47 -0
- package/dist/operations/apply/object.js.map +1 -0
- package/dist/operations/apply/quaternion.d.ts +15 -0
- package/dist/operations/apply/quaternion.d.ts.map +1 -0
- package/dist/operations/apply/quaternion.js +33 -0
- package/dist/operations/apply/quaternion.js.map +1 -0
- package/dist/operations/apply/string.d.ts +14 -0
- package/dist/operations/apply/string.d.ts.map +1 -0
- package/dist/operations/apply/string.js +26 -0
- package/dist/operations/apply/string.js.map +1 -0
- package/dist/operations/apply/types.d.ts +34 -0
- package/dist/operations/apply/types.d.ts.map +1 -0
- package/dist/operations/apply/types.js +32 -0
- package/dist/operations/apply/types.js.map +1 -0
- package/dist/operations/apply/vector3.d.ts +18 -0
- package/dist/operations/apply/vector3.d.ts.map +1 -0
- package/dist/operations/apply/vector3.js +44 -0
- package/dist/operations/apply/vector3.js.map +1 -0
- package/dist/operations/dispatcher.d.ts +35 -0
- package/dist/operations/dispatcher.d.ts.map +1 -0
- package/dist/operations/dispatcher.js +107 -0
- package/dist/operations/dispatcher.js.map +1 -0
- package/dist/operations/index.d.ts +10 -0
- package/dist/operations/index.d.ts.map +1 -0
- package/dist/operations/index.js +17 -0
- package/dist/operations/index.js.map +1 -0
- package/dist/state/ConflictResolver.d.ts +36 -0
- package/dist/state/ConflictResolver.d.ts.map +1 -0
- package/dist/state/ConflictResolver.js +167 -0
- package/dist/state/ConflictResolver.js.map +1 -0
- package/dist/state/DType.d.ts +160 -0
- package/dist/state/DType.d.ts.map +1 -0
- package/dist/state/DType.js +282 -0
- package/dist/state/DType.js.map +1 -0
- package/dist/state/Schema.d.ts +32 -0
- package/dist/state/Schema.d.ts.map +1 -0
- package/dist/state/Schema.js +175 -0
- package/dist/state/Schema.js.map +1 -0
- package/dist/state/VectorClock.d.ts +42 -0
- package/dist/state/VectorClock.d.ts.map +1 -0
- package/dist/state/VectorClock.js +84 -0
- package/dist/state/VectorClock.js.map +1 -0
- package/dist/state/index.d.ts +11 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +13 -0
- package/dist/state/index.js.map +1 -0
- package/docs/OPERATION_HINTS.md +222 -0
- package/docs/SCENE_GRAPH.md +373 -0
- package/docs/TYPE_BEHAVIORS.md +348 -0
- package/examples/01-basic-usage.ts +139 -0
- package/examples/02-concurrent-edits.ts +208 -0
- package/examples/03-scene-building.ts +258 -0
- package/examples/04-conflict-resolution.ts +339 -0
- package/examples/README.md +86 -0
- package/jest.config.js +19 -0
- package/package.json +57 -0
- package/src/client/EditBuffer.ts +105 -0
- package/src/client/actions.ts +397 -0
- package/src/client/createGraph.ts +132 -0
- package/src/client/hooks.tsx +249 -0
- package/src/client/index.ts +35 -0
- package/src/client/types.ts +94 -0
- package/src/hooks.ts +20 -0
- package/src/index.ts +14 -0
- package/src/operations/OperationTypes.ts +340 -0
- package/src/operations/OperationValidator.ts +260 -0
- package/src/operations/apply/array.ts +84 -0
- package/src/operations/apply/boolean.ts +48 -0
- package/src/operations/apply/color.ts +65 -0
- package/src/operations/apply/index.ts +37 -0
- package/src/operations/apply/node.ts +98 -0
- package/src/operations/apply/number.ts +76 -0
- package/src/operations/apply/object.ts +63 -0
- package/src/operations/apply/quaternion.ts +47 -0
- package/src/operations/apply/string.ts +36 -0
- package/src/operations/apply/types.ts +66 -0
- package/src/operations/apply/vector3.ts +60 -0
- package/src/operations/dispatcher.ts +127 -0
- package/src/operations/index.ts +80 -0
- package/src/state/ConflictResolver.ts +205 -0
- package/src/state/DType.ts +333 -0
- package/src/state/Schema.ts +236 -0
- package/src/state/VectorClock.ts +98 -0
- package/src/state/index.ts +14 -0
- package/tests/client/actions.test.ts +371 -0
- package/tests/client/edit-buffer.test.ts +117 -0
- package/tests/fixtures/array-ops.jsonl +6 -0
- package/tests/fixtures/boolean-ops.jsonl +6 -0
- package/tests/fixtures/color-ops.jsonl +4 -0
- package/tests/fixtures/edit-buffer.jsonl +3 -0
- package/tests/fixtures/node-ops.jsonl +6 -0
- package/tests/fixtures/number-ops.jsonl +7 -0
- package/tests/fixtures/object-ops.jsonl +4 -0
- package/tests/fixtures/operations.jsonl +7 -0
- package/tests/fixtures/string-ops.jsonl +4 -0
- package/tests/fixtures/undo-redo.jsonl +3 -0
- package/tests/fixtures/vector-ops.jsonl +9 -0
- package/tests/operations/collections.test.ts +193 -0
- package/tests/operations/nodes.test.ts +228 -0
- package/tests/operations/primitives.test.ts +222 -0
- package/tests/operations/vectors.test.ts +150 -0
- package/tsconfig.json +21 -0
- package/tsconfig.test.json +9 -0
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vuer-ai/vuer-rtc",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "CRDT-based real-time collaborative data structures",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"module": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./operations": {
|
|
15
|
+
"types": "./dist/operations/index.d.ts",
|
|
16
|
+
"import": "./dist/operations/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./state": {
|
|
19
|
+
"types": "./dist/state/index.d.ts",
|
|
20
|
+
"import": "./dist/state/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./client": {
|
|
23
|
+
"types": "./dist/client/index.d.ts",
|
|
24
|
+
"import": "./dist/client/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./hooks": {
|
|
27
|
+
"types": "./dist/hooks.d.ts",
|
|
28
|
+
"import": "./dist/hooks.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc",
|
|
33
|
+
"dev": "tsc --watch",
|
|
34
|
+
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
|
35
|
+
"test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"react": ">=18.0.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"react": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@jest/globals": "^30.2.0",
|
|
47
|
+
"@types/node": "^22.10.1",
|
|
48
|
+
"@types/react": "^18.2.0",
|
|
49
|
+
"jest": "^30.2.0",
|
|
50
|
+
"react": "^18.2.0",
|
|
51
|
+
"ts-jest": "^29.4.5",
|
|
52
|
+
"typescript": "^5.7.2"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"immer": "^11.0.1"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit Buffer
|
|
3
|
+
*
|
|
4
|
+
* Accumulates uncommitted operations during gestures (e.g., dragging).
|
|
5
|
+
* Merges additive operations to reduce message size.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Operation } from '../operations/OperationTypes.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if an operation type is additive (can be merged)
|
|
12
|
+
*/
|
|
13
|
+
export function isAdditiveOp(otype: string): boolean {
|
|
14
|
+
return otype === 'vector3.add' ||
|
|
15
|
+
otype === 'number.add' ||
|
|
16
|
+
otype === 'number.multiply' ||
|
|
17
|
+
otype === 'quaternion.multiply';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Merge two values for additive operations
|
|
22
|
+
*/
|
|
23
|
+
export function mergeValues(otype: string, a: unknown, b: unknown): unknown {
|
|
24
|
+
switch (otype) {
|
|
25
|
+
case 'vector3.add': {
|
|
26
|
+
const va = a as [number, number, number];
|
|
27
|
+
const vb = b as [number, number, number];
|
|
28
|
+
return [va[0] + vb[0], va[1] + vb[1], va[2] + vb[2]];
|
|
29
|
+
}
|
|
30
|
+
case 'number.add':
|
|
31
|
+
return (a as number) + (b as number);
|
|
32
|
+
case 'number.multiply':
|
|
33
|
+
return (a as number) * (b as number);
|
|
34
|
+
case 'quaternion.multiply': {
|
|
35
|
+
// Quaternion multiplication: q1 * q2
|
|
36
|
+
const q1 = a as [number, number, number, number];
|
|
37
|
+
const q2 = b as [number, number, number, number];
|
|
38
|
+
return [
|
|
39
|
+
q1[3] * q2[0] + q1[0] * q2[3] + q1[1] * q2[2] - q1[2] * q2[1],
|
|
40
|
+
q1[3] * q2[1] - q1[0] * q2[2] + q1[1] * q2[3] + q1[2] * q2[0],
|
|
41
|
+
q1[3] * q2[2] + q1[0] * q2[1] - q1[1] * q2[0] + q1[2] * q2[3],
|
|
42
|
+
q1[3] * q2[3] - q1[0] * q2[0] - q1[1] * q2[1] - q1[2] * q2[2],
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
default:
|
|
46
|
+
return b; // For LWW, later value wins
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Edit buffer class
|
|
52
|
+
*/
|
|
53
|
+
export class EditBufferImpl {
|
|
54
|
+
private opsMap: Map<string, Operation> = new Map();
|
|
55
|
+
private opOrder: string[] = [];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Add or merge an operation
|
|
59
|
+
*/
|
|
60
|
+
add(op: Operation): void {
|
|
61
|
+
const key = `${op.key}:${op.path}`;
|
|
62
|
+
const existing = this.opsMap.get(key);
|
|
63
|
+
|
|
64
|
+
if (existing && existing.otype === op.otype && isAdditiveOp(op.otype)) {
|
|
65
|
+
// Merge additive ops
|
|
66
|
+
const mergedValue = mergeValues(op.otype, (existing as any).value, (op as any).value);
|
|
67
|
+
this.opsMap.set(key, { ...existing, value: mergedValue } as Operation);
|
|
68
|
+
} else {
|
|
69
|
+
// New op or LWW replacement
|
|
70
|
+
if (!this.opsMap.has(key)) {
|
|
71
|
+
this.opOrder.push(key);
|
|
72
|
+
}
|
|
73
|
+
this.opsMap.set(key, op);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get ops in order (for committing)
|
|
79
|
+
*/
|
|
80
|
+
getOps(): Operation[] {
|
|
81
|
+
return this.opOrder.map(k => this.opsMap.get(k)!);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Clear buffer
|
|
86
|
+
*/
|
|
87
|
+
clear(): void {
|
|
88
|
+
this.opsMap.clear();
|
|
89
|
+
this.opOrder = [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check if empty
|
|
94
|
+
*/
|
|
95
|
+
isEmpty(): boolean {
|
|
96
|
+
return this.opsMap.size === 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get count
|
|
101
|
+
*/
|
|
102
|
+
size(): number {
|
|
103
|
+
return this.opsMap.size;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client State Actions
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that transform ClientState.
|
|
5
|
+
* Used by createGraph and React hooks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { produce } from 'immer';
|
|
9
|
+
import type { CRDTMessage, Operation, SceneGraph } from '../operations/OperationTypes.js';
|
|
10
|
+
import type { ClientState, JournalEntry, Snapshot } from './types.js';
|
|
11
|
+
import { applyMessage, applyOperation, createEmptyGraph } from '../operations/dispatcher.js';
|
|
12
|
+
import { VectorClockManager, type VectorClock } from '../state/VectorClock.js';
|
|
13
|
+
import { isAdditiveOp, mergeValues } from './EditBuffer.js';
|
|
14
|
+
|
|
15
|
+
const clockManager = new VectorClockManager();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generate a UUID
|
|
19
|
+
*/
|
|
20
|
+
export function generateUUID(): string {
|
|
21
|
+
return crypto.randomUUID();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create initial client state
|
|
26
|
+
*/
|
|
27
|
+
export function createInitialState(sessionId: string, initialSnapshot?: Snapshot): ClientState {
|
|
28
|
+
const emptyGraph = createEmptyGraph();
|
|
29
|
+
const snapshot = initialSnapshot ?? {
|
|
30
|
+
graph: emptyGraph,
|
|
31
|
+
vectorClock: {},
|
|
32
|
+
journalIndex: 0,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
graph: snapshot.graph,
|
|
37
|
+
journal: [],
|
|
38
|
+
edits: { ops: [], startGraph: null },
|
|
39
|
+
snapshot,
|
|
40
|
+
lamportTime: 0,
|
|
41
|
+
vectorClock: clockManager.create(sessionId),
|
|
42
|
+
sessionId,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Rebuild graph from snapshot + journal + edits
|
|
48
|
+
*/
|
|
49
|
+
export function rebuildGraph(
|
|
50
|
+
snapshot: Snapshot,
|
|
51
|
+
journal: JournalEntry[],
|
|
52
|
+
pendingOps: Operation[]
|
|
53
|
+
): SceneGraph {
|
|
54
|
+
let graph = snapshot.graph;
|
|
55
|
+
|
|
56
|
+
// Apply journal (skip deleted entries and meta ops)
|
|
57
|
+
for (const entry of journal) {
|
|
58
|
+
if (entry.deletedAt) continue;
|
|
59
|
+
|
|
60
|
+
const realOps = entry.msg.ops.filter(op => !op.otype.startsWith('meta.'));
|
|
61
|
+
if (realOps.length > 0) {
|
|
62
|
+
graph = applyMessage(graph, { ...entry.msg, ops: realOps });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Apply pending edits
|
|
67
|
+
if (pendingOps.length > 0) {
|
|
68
|
+
graph = produce(graph, draft => {
|
|
69
|
+
for (const op of pendingOps) {
|
|
70
|
+
applyOperation(draft, op, {
|
|
71
|
+
sessionId: 'local',
|
|
72
|
+
clock: {},
|
|
73
|
+
lamportTime: 0,
|
|
74
|
+
timestamp: Date.now(),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return graph;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Action: Edit (uncommitted)
|
|
85
|
+
*/
|
|
86
|
+
export function onEdit(state: ClientState, op: Operation): ClientState {
|
|
87
|
+
return produce(state, draft => {
|
|
88
|
+
// Save start graph for cancel (first edit only)
|
|
89
|
+
// Use state.graph (immutable original), not draft.graph (mutable proxy)
|
|
90
|
+
if (draft.edits.ops.length === 0) {
|
|
91
|
+
draft.edits.startGraph = state.graph;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Merge into edit buffer
|
|
95
|
+
const key = `${op.key}:${op.path}`;
|
|
96
|
+
const existingIdx = draft.edits.ops.findIndex(
|
|
97
|
+
o => `${o.key}:${o.path}` === key
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (existingIdx >= 0) {
|
|
101
|
+
const existing = draft.edits.ops[existingIdx];
|
|
102
|
+
if (existing.otype === op.otype && isAdditiveOp(op.otype)) {
|
|
103
|
+
// Merge additive ops
|
|
104
|
+
const mergedValue = mergeValues(op.otype, (existing as any).value, (op as any).value);
|
|
105
|
+
draft.edits.ops[existingIdx] = { ...existing, value: mergedValue } as Operation;
|
|
106
|
+
} else {
|
|
107
|
+
// Replace
|
|
108
|
+
draft.edits.ops[existingIdx] = op;
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
draft.edits.ops.push(op);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Apply to graph immediately (optimistic)
|
|
115
|
+
// Note: applyOperation mutates draft.graph in place
|
|
116
|
+
applyOperation(draft.graph, op, {
|
|
117
|
+
sessionId: draft.sessionId,
|
|
118
|
+
clock: draft.vectorClock,
|
|
119
|
+
lamportTime: draft.lamportTime,
|
|
120
|
+
timestamp: Date.now(),
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Action: Commit edits
|
|
127
|
+
*/
|
|
128
|
+
export function commitEdits(
|
|
129
|
+
state: ClientState,
|
|
130
|
+
_description?: string
|
|
131
|
+
): { state: ClientState; msg: CRDTMessage | null } {
|
|
132
|
+
if (state.edits.ops.length === 0) {
|
|
133
|
+
return { state, msg: null };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const newClock = clockManager.increment(state.vectorClock, state.sessionId);
|
|
137
|
+
const msg: CRDTMessage = {
|
|
138
|
+
id: generateUUID(),
|
|
139
|
+
sessionId: state.sessionId,
|
|
140
|
+
clock: newClock,
|
|
141
|
+
lamportTime: state.lamportTime + 1,
|
|
142
|
+
timestamp: Date.now(),
|
|
143
|
+
ops: [...state.edits.ops],
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const newState = produce(state, draft => {
|
|
147
|
+
// Add to journal (unacknowledged)
|
|
148
|
+
draft.journal.push({ msg, ack: false });
|
|
149
|
+
|
|
150
|
+
// Clear edit buffer
|
|
151
|
+
draft.edits = { ops: [], startGraph: null };
|
|
152
|
+
|
|
153
|
+
// Update clocks
|
|
154
|
+
draft.lamportTime = msg.lamportTime;
|
|
155
|
+
draft.vectorClock = newClock;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return { state: newState, msg };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Action: Cancel edits (revert to start graph)
|
|
163
|
+
*/
|
|
164
|
+
export function cancelEdits(state: ClientState): ClientState {
|
|
165
|
+
if (state.edits.ops.length === 0) {
|
|
166
|
+
return state;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return produce(state, draft => {
|
|
170
|
+
// Revert to start graph
|
|
171
|
+
if (draft.edits.startGraph) {
|
|
172
|
+
draft.graph = draft.edits.startGraph;
|
|
173
|
+
}
|
|
174
|
+
// Clear edit buffer
|
|
175
|
+
draft.edits = { ops: [], startGraph: null };
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Action: Server acknowledgement
|
|
181
|
+
*/
|
|
182
|
+
export function onServerAck(state: ClientState, msgId: string): ClientState {
|
|
183
|
+
return produce(state, draft => {
|
|
184
|
+
const entry = draft.journal.find(e => e.msg.id === msgId);
|
|
185
|
+
if (entry) {
|
|
186
|
+
entry.ack = true;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Action: Receive remote message
|
|
193
|
+
*/
|
|
194
|
+
export function onRemoteMessage(state: ClientState, msg: CRDTMessage): ClientState {
|
|
195
|
+
// Skip duplicates
|
|
196
|
+
if (state.journal.some(e => e.msg.id === msg.id)) {
|
|
197
|
+
return state;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return produce(state, draft => {
|
|
201
|
+
// Add to journal (already acked - came from server)
|
|
202
|
+
draft.journal.push({ msg, ack: true });
|
|
203
|
+
|
|
204
|
+
// Process meta ops (undo/redo)
|
|
205
|
+
for (const op of msg.ops) {
|
|
206
|
+
if (op.otype === 'meta.undo') {
|
|
207
|
+
const targetMsgId = (op as any).targetMsgId;
|
|
208
|
+
const target = draft.journal.find(e => e.msg.id === targetMsgId);
|
|
209
|
+
if (target) target.deletedAt = msg.timestamp;
|
|
210
|
+
} else if (op.otype === 'meta.redo') {
|
|
211
|
+
const targetMsgId = (op as any).targetMsgId;
|
|
212
|
+
const target = draft.journal.find(e => e.msg.id === targetMsgId);
|
|
213
|
+
if (target) delete target.deletedAt;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Merge clocks
|
|
218
|
+
draft.vectorClock = clockManager.merge(draft.vectorClock, msg.clock);
|
|
219
|
+
draft.lamportTime = Math.max(draft.lamportTime, msg.lamportTime);
|
|
220
|
+
|
|
221
|
+
// Rebuild graph
|
|
222
|
+
draft.graph = rebuildGraph(draft.snapshot, draft.journal, draft.edits.ops);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Action: Undo
|
|
228
|
+
*/
|
|
229
|
+
export function undo(state: ClientState): { state: ClientState; msg: CRDTMessage | null } {
|
|
230
|
+
let currentState = state;
|
|
231
|
+
let targetMsgId: string;
|
|
232
|
+
|
|
233
|
+
// If edit buffer not empty, commit first then mark as deleted
|
|
234
|
+
if (state.edits.ops.length > 0) {
|
|
235
|
+
const { state: committed, msg } = commitEdits(state);
|
|
236
|
+
if (!msg) return { state, msg: null };
|
|
237
|
+
currentState = committed;
|
|
238
|
+
targetMsgId = msg.id;
|
|
239
|
+
} else {
|
|
240
|
+
// Find last non-deleted message from this session
|
|
241
|
+
const lastActive = [...currentState.journal]
|
|
242
|
+
.reverse()
|
|
243
|
+
.find(e => !e.deletedAt && e.msg.sessionId === currentState.sessionId);
|
|
244
|
+
|
|
245
|
+
if (!lastActive) return { state: currentState, msg: null };
|
|
246
|
+
targetMsgId = lastActive.msg.id;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Create undo message
|
|
250
|
+
const newClock = clockManager.increment(currentState.vectorClock, currentState.sessionId);
|
|
251
|
+
const undoMsg: CRDTMessage = {
|
|
252
|
+
id: generateUUID(),
|
|
253
|
+
sessionId: currentState.sessionId,
|
|
254
|
+
clock: newClock,
|
|
255
|
+
lamportTime: currentState.lamportTime + 1,
|
|
256
|
+
timestamp: Date.now(),
|
|
257
|
+
ops: [{ otype: 'meta.undo', key: '_meta', path: '_meta', targetMsgId }],
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// Apply locally
|
|
261
|
+
const newState = produce(currentState, draft => {
|
|
262
|
+
// Add undo message to journal
|
|
263
|
+
draft.journal.push({ msg: undoMsg, ack: false });
|
|
264
|
+
|
|
265
|
+
// Mark target as deleted
|
|
266
|
+
const target = draft.journal.find(e => e.msg.id === targetMsgId);
|
|
267
|
+
if (target) target.deletedAt = undoMsg.timestamp;
|
|
268
|
+
|
|
269
|
+
// Update clocks
|
|
270
|
+
draft.lamportTime = undoMsg.lamportTime;
|
|
271
|
+
draft.vectorClock = newClock;
|
|
272
|
+
|
|
273
|
+
// Rebuild graph
|
|
274
|
+
draft.graph = rebuildGraph(draft.snapshot, draft.journal, draft.edits.ops);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return { state: newState, msg: undoMsg };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Action: Redo
|
|
282
|
+
*/
|
|
283
|
+
export function redo(state: ClientState): { state: ClientState; msg: CRDTMessage | null } {
|
|
284
|
+
// Find last deleted message from this session
|
|
285
|
+
const lastDeleted = [...state.journal]
|
|
286
|
+
.reverse()
|
|
287
|
+
.find(e => e.deletedAt && e.msg.sessionId === state.sessionId);
|
|
288
|
+
|
|
289
|
+
if (!lastDeleted) return { state, msg: null };
|
|
290
|
+
|
|
291
|
+
// Create redo message
|
|
292
|
+
const newClock = clockManager.increment(state.vectorClock, state.sessionId);
|
|
293
|
+
const redoMsg: CRDTMessage = {
|
|
294
|
+
id: generateUUID(),
|
|
295
|
+
sessionId: state.sessionId,
|
|
296
|
+
clock: newClock,
|
|
297
|
+
lamportTime: state.lamportTime + 1,
|
|
298
|
+
timestamp: Date.now(),
|
|
299
|
+
ops: [{ otype: 'meta.redo', key: '_meta', path: '_meta', targetMsgId: lastDeleted.msg.id }],
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// Apply locally
|
|
303
|
+
const newState = produce(state, draft => {
|
|
304
|
+
// Add redo message to journal
|
|
305
|
+
draft.journal.push({ msg: redoMsg, ack: false });
|
|
306
|
+
|
|
307
|
+
// Clear deletedAt on target
|
|
308
|
+
const target = draft.journal.find(e => e.msg.id === lastDeleted.msg.id);
|
|
309
|
+
if (target) delete target.deletedAt;
|
|
310
|
+
|
|
311
|
+
// Update clocks
|
|
312
|
+
draft.lamportTime = redoMsg.lamportTime;
|
|
313
|
+
draft.vectorClock = newClock;
|
|
314
|
+
|
|
315
|
+
// Rebuild graph
|
|
316
|
+
draft.graph = rebuildGraph(draft.snapshot, draft.journal, draft.edits.ops);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
return { state: newState, msg: redoMsg };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Action: Compact (create snapshot from acked entries)
|
|
324
|
+
*/
|
|
325
|
+
export function compact(state: ClientState): ClientState {
|
|
326
|
+
const lastAckedIdx = state.journal.findLastIndex(e => e.ack);
|
|
327
|
+
if (lastAckedIdx < 0) return state;
|
|
328
|
+
|
|
329
|
+
return produce(state, draft => {
|
|
330
|
+
// Build new snapshot (skip deleted entries and meta ops)
|
|
331
|
+
let snapshotGraph = draft.snapshot.graph;
|
|
332
|
+
for (let i = 0; i <= lastAckedIdx; i++) {
|
|
333
|
+
const entry = draft.journal[i];
|
|
334
|
+
if (entry.deletedAt) continue;
|
|
335
|
+
|
|
336
|
+
const realOps = entry.msg.ops.filter(op => !op.otype.startsWith('meta.'));
|
|
337
|
+
if (realOps.length > 0) {
|
|
338
|
+
snapshotGraph = applyMessage(snapshotGraph, { ...entry.msg, ops: realOps });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
draft.snapshot = {
|
|
343
|
+
graph: snapshotGraph,
|
|
344
|
+
vectorClock: draft.journal[lastAckedIdx].msg.clock,
|
|
345
|
+
journalIndex: draft.snapshot.journalIndex + lastAckedIdx + 1,
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Remove compacted entries
|
|
349
|
+
draft.journal = draft.journal.slice(lastAckedIdx + 1);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Initialize from server snapshot + journal
|
|
355
|
+
*/
|
|
356
|
+
export function initFromServer(
|
|
357
|
+
sessionId: string,
|
|
358
|
+
snapshot: Snapshot,
|
|
359
|
+
journal: CRDTMessage[]
|
|
360
|
+
): ClientState {
|
|
361
|
+
let graph = snapshot.graph;
|
|
362
|
+
const journalEntries: JournalEntry[] = [];
|
|
363
|
+
let maxLamport = 0;
|
|
364
|
+
let mergedClock: VectorClock = snapshot.vectorClock;
|
|
365
|
+
|
|
366
|
+
for (const msg of journal) {
|
|
367
|
+
// Process meta ops
|
|
368
|
+
for (const op of msg.ops) {
|
|
369
|
+
if (op.otype === 'meta.undo') {
|
|
370
|
+
const targetMsgId = (op as any).targetMsgId;
|
|
371
|
+
const target = journalEntries.find(e => e.msg.id === targetMsgId);
|
|
372
|
+
if (target) target.deletedAt = msg.timestamp;
|
|
373
|
+
} else if (op.otype === 'meta.redo') {
|
|
374
|
+
const targetMsgId = (op as any).targetMsgId;
|
|
375
|
+
const target = journalEntries.find(e => e.msg.id === targetMsgId);
|
|
376
|
+
if (target) delete target.deletedAt;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
journalEntries.push({ msg, ack: true });
|
|
381
|
+
maxLamport = Math.max(maxLamport, msg.lamportTime);
|
|
382
|
+
mergedClock = clockManager.merge(mergedClock, msg.clock);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Rebuild graph
|
|
386
|
+
graph = rebuildGraph(snapshot, journalEntries, []);
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
graph,
|
|
390
|
+
journal: journalEntries,
|
|
391
|
+
edits: { ops: [], startGraph: null },
|
|
392
|
+
snapshot,
|
|
393
|
+
lamportTime: maxLamport,
|
|
394
|
+
vectorClock: clockManager.increment(mergedClock, sessionId),
|
|
395
|
+
sessionId,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createGraph - Factory function for creating a graph store
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* ```typescript
|
|
6
|
+
* const store = createGraph({
|
|
7
|
+
* sessionId: 'my-session',
|
|
8
|
+
* onSend: (msg) => websocket.send(msg),
|
|
9
|
+
* });
|
|
10
|
+
*
|
|
11
|
+
* store.edit({ otype: 'vector3.add', key: 'cube', path: 'position', value: [1, 0, 0] });
|
|
12
|
+
* store.commit('Move cube');
|
|
13
|
+
* store.undo();
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { enableMapSet } from 'immer';
|
|
18
|
+
import type { CRDTMessage, Operation } from '../operations/OperationTypes.js';
|
|
19
|
+
import type { ClientState, CreateGraphOptions, GraphStore, Snapshot } from './types.js';
|
|
20
|
+
import {
|
|
21
|
+
createInitialState,
|
|
22
|
+
onEdit,
|
|
23
|
+
commitEdits,
|
|
24
|
+
cancelEdits,
|
|
25
|
+
onServerAck,
|
|
26
|
+
onRemoteMessage,
|
|
27
|
+
undo,
|
|
28
|
+
redo,
|
|
29
|
+
compact,
|
|
30
|
+
initFromServer,
|
|
31
|
+
} from './actions.js';
|
|
32
|
+
|
|
33
|
+
// Enable immer support for Map and Set
|
|
34
|
+
enableMapSet();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create a graph store
|
|
38
|
+
*/
|
|
39
|
+
export function createGraph(options: CreateGraphOptions): GraphStore {
|
|
40
|
+
let state = createInitialState(options.sessionId, options.initialSnapshot);
|
|
41
|
+
const listeners = new Set<() => void>();
|
|
42
|
+
|
|
43
|
+
function dispatch(fn: (s: ClientState) => ClientState): void {
|
|
44
|
+
state = fn(state);
|
|
45
|
+
options.onStateChange?.(state);
|
|
46
|
+
listeners.forEach(l => l());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
// State access
|
|
51
|
+
getState: () => state,
|
|
52
|
+
|
|
53
|
+
subscribe: (listener: () => void) => {
|
|
54
|
+
listeners.add(listener);
|
|
55
|
+
return () => listeners.delete(listener);
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// Edit operations
|
|
59
|
+
edit: (op: Operation) => {
|
|
60
|
+
dispatch(s => onEdit(s, op));
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
commit: (_description?: string) => {
|
|
64
|
+
const result = commitEdits(state, _description);
|
|
65
|
+
dispatch(() => result.state);
|
|
66
|
+
if (result.msg) {
|
|
67
|
+
options.onSend?.(result.msg);
|
|
68
|
+
}
|
|
69
|
+
return result.msg;
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
// Server communication
|
|
73
|
+
receive: (msg: CRDTMessage) => {
|
|
74
|
+
dispatch(s => onRemoteMessage(s, msg));
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
ack: (msgId: string) => {
|
|
78
|
+
dispatch(s => onServerAck(s, msgId));
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
// Undo/redo
|
|
82
|
+
undo: () => {
|
|
83
|
+
const result = undo(state);
|
|
84
|
+
dispatch(() => result.state);
|
|
85
|
+
if (result.msg) {
|
|
86
|
+
options.onSend?.(result.msg);
|
|
87
|
+
}
|
|
88
|
+
return { msg: result.msg };
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
redo: () => {
|
|
92
|
+
const result = redo(state);
|
|
93
|
+
dispatch(() => result.state);
|
|
94
|
+
if (result.msg) {
|
|
95
|
+
options.onSend?.(result.msg);
|
|
96
|
+
}
|
|
97
|
+
return { msg: result.msg };
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
// Maintenance
|
|
101
|
+
compact: () => {
|
|
102
|
+
dispatch(s => compact(s));
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Create a graph store initialized from server state
|
|
109
|
+
*/
|
|
110
|
+
export function createGraphFromServer(
|
|
111
|
+
options: CreateGraphOptions & {
|
|
112
|
+
snapshot: Snapshot;
|
|
113
|
+
journal: CRDTMessage[];
|
|
114
|
+
}
|
|
115
|
+
): GraphStore {
|
|
116
|
+
const store = createGraph(options);
|
|
117
|
+
|
|
118
|
+
// Replace state with server-initialized state
|
|
119
|
+
const serverState = initFromServer(
|
|
120
|
+
options.sessionId,
|
|
121
|
+
options.snapshot,
|
|
122
|
+
options.journal
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Hack: directly set state (since we don't expose setState)
|
|
126
|
+
(store as any)._state = serverState;
|
|
127
|
+
|
|
128
|
+
return store;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Re-export types
|
|
132
|
+
export type { ClientState, CreateGraphOptions, GraphStore, Snapshot, JournalEntry, EditBuffer } from './types.js';
|