@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.
Files changed (169) hide show
  1. package/dist/client/EditBuffer.d.ts +43 -0
  2. package/dist/client/EditBuffer.d.ts.map +1 -0
  3. package/dist/client/EditBuffer.js +96 -0
  4. package/dist/client/EditBuffer.js.map +1 -0
  5. package/dist/client/actions.d.ts +66 -0
  6. package/dist/client/actions.d.ts.map +1 -0
  7. package/dist/client/actions.js +345 -0
  8. package/dist/client/actions.js.map +1 -0
  9. package/dist/client/createGraph.d.ts +30 -0
  10. package/dist/client/createGraph.d.ts.map +1 -0
  11. package/dist/client/createGraph.js +91 -0
  12. package/dist/client/createGraph.js.map +1 -0
  13. package/dist/client/hooks.d.ts +81 -0
  14. package/dist/client/hooks.d.ts.map +1 -0
  15. package/dist/client/hooks.js +161 -0
  16. package/dist/client/hooks.js.map +1 -0
  17. package/dist/client/index.d.ts +8 -0
  18. package/dist/client/index.d.ts.map +1 -0
  19. package/dist/client/index.js +10 -0
  20. package/dist/client/index.js.map +1 -0
  21. package/dist/client/types.d.ts +74 -0
  22. package/dist/client/types.d.ts.map +1 -0
  23. package/dist/client/types.js +11 -0
  24. package/dist/client/types.js.map +1 -0
  25. package/dist/hooks.d.ts +8 -0
  26. package/dist/hooks.d.ts.map +1 -0
  27. package/dist/hooks.js +7 -0
  28. package/dist/hooks.js.map +1 -0
  29. package/dist/index.d.ts +9 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +12 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/operations/OperationTypes.d.ts +239 -0
  34. package/dist/operations/OperationTypes.d.ts.map +1 -0
  35. package/dist/operations/OperationTypes.js +10 -0
  36. package/dist/operations/OperationTypes.js.map +1 -0
  37. package/dist/operations/OperationValidator.d.ts +32 -0
  38. package/dist/operations/OperationValidator.d.ts.map +1 -0
  39. package/dist/operations/OperationValidator.js +208 -0
  40. package/dist/operations/OperationValidator.js.map +1 -0
  41. package/dist/operations/apply/array.d.ts +22 -0
  42. package/dist/operations/apply/array.d.ts.map +1 -0
  43. package/dist/operations/apply/array.js +64 -0
  44. package/dist/operations/apply/array.js.map +1 -0
  45. package/dist/operations/apply/boolean.d.ts +18 -0
  46. package/dist/operations/apply/boolean.d.ts.map +1 -0
  47. package/dist/operations/apply/boolean.js +34 -0
  48. package/dist/operations/apply/boolean.js.map +1 -0
  49. package/dist/operations/apply/color.d.ts +14 -0
  50. package/dist/operations/apply/color.d.ts.map +1 -0
  51. package/dist/operations/apply/color.js +46 -0
  52. package/dist/operations/apply/color.js.map +1 -0
  53. package/dist/operations/apply/index.d.ts +18 -0
  54. package/dist/operations/apply/index.d.ts.map +1 -0
  55. package/dist/operations/apply/index.js +26 -0
  56. package/dist/operations/apply/index.js.map +1 -0
  57. package/dist/operations/apply/node.d.ts +24 -0
  58. package/dist/operations/apply/node.d.ts.map +1 -0
  59. package/dist/operations/apply/node.js +77 -0
  60. package/dist/operations/apply/node.js.map +1 -0
  61. package/dist/operations/apply/number.d.ts +26 -0
  62. package/dist/operations/apply/number.d.ts.map +1 -0
  63. package/dist/operations/apply/number.js +54 -0
  64. package/dist/operations/apply/number.js.map +1 -0
  65. package/dist/operations/apply/object.d.ts +14 -0
  66. package/dist/operations/apply/object.d.ts.map +1 -0
  67. package/dist/operations/apply/object.js +47 -0
  68. package/dist/operations/apply/object.js.map +1 -0
  69. package/dist/operations/apply/quaternion.d.ts +15 -0
  70. package/dist/operations/apply/quaternion.d.ts.map +1 -0
  71. package/dist/operations/apply/quaternion.js +33 -0
  72. package/dist/operations/apply/quaternion.js.map +1 -0
  73. package/dist/operations/apply/string.d.ts +14 -0
  74. package/dist/operations/apply/string.d.ts.map +1 -0
  75. package/dist/operations/apply/string.js +26 -0
  76. package/dist/operations/apply/string.js.map +1 -0
  77. package/dist/operations/apply/types.d.ts +34 -0
  78. package/dist/operations/apply/types.d.ts.map +1 -0
  79. package/dist/operations/apply/types.js +32 -0
  80. package/dist/operations/apply/types.js.map +1 -0
  81. package/dist/operations/apply/vector3.d.ts +18 -0
  82. package/dist/operations/apply/vector3.d.ts.map +1 -0
  83. package/dist/operations/apply/vector3.js +44 -0
  84. package/dist/operations/apply/vector3.js.map +1 -0
  85. package/dist/operations/dispatcher.d.ts +35 -0
  86. package/dist/operations/dispatcher.d.ts.map +1 -0
  87. package/dist/operations/dispatcher.js +107 -0
  88. package/dist/operations/dispatcher.js.map +1 -0
  89. package/dist/operations/index.d.ts +10 -0
  90. package/dist/operations/index.d.ts.map +1 -0
  91. package/dist/operations/index.js +17 -0
  92. package/dist/operations/index.js.map +1 -0
  93. package/dist/state/ConflictResolver.d.ts +36 -0
  94. package/dist/state/ConflictResolver.d.ts.map +1 -0
  95. package/dist/state/ConflictResolver.js +167 -0
  96. package/dist/state/ConflictResolver.js.map +1 -0
  97. package/dist/state/DType.d.ts +160 -0
  98. package/dist/state/DType.d.ts.map +1 -0
  99. package/dist/state/DType.js +282 -0
  100. package/dist/state/DType.js.map +1 -0
  101. package/dist/state/Schema.d.ts +32 -0
  102. package/dist/state/Schema.d.ts.map +1 -0
  103. package/dist/state/Schema.js +175 -0
  104. package/dist/state/Schema.js.map +1 -0
  105. package/dist/state/VectorClock.d.ts +42 -0
  106. package/dist/state/VectorClock.d.ts.map +1 -0
  107. package/dist/state/VectorClock.js +84 -0
  108. package/dist/state/VectorClock.js.map +1 -0
  109. package/dist/state/index.d.ts +11 -0
  110. package/dist/state/index.d.ts.map +1 -0
  111. package/dist/state/index.js +13 -0
  112. package/dist/state/index.js.map +1 -0
  113. package/docs/OPERATION_HINTS.md +222 -0
  114. package/docs/SCENE_GRAPH.md +373 -0
  115. package/docs/TYPE_BEHAVIORS.md +348 -0
  116. package/examples/01-basic-usage.ts +139 -0
  117. package/examples/02-concurrent-edits.ts +208 -0
  118. package/examples/03-scene-building.ts +258 -0
  119. package/examples/04-conflict-resolution.ts +339 -0
  120. package/examples/README.md +86 -0
  121. package/jest.config.js +19 -0
  122. package/package.json +57 -0
  123. package/src/client/EditBuffer.ts +105 -0
  124. package/src/client/actions.ts +397 -0
  125. package/src/client/createGraph.ts +132 -0
  126. package/src/client/hooks.tsx +249 -0
  127. package/src/client/index.ts +35 -0
  128. package/src/client/types.ts +94 -0
  129. package/src/hooks.ts +20 -0
  130. package/src/index.ts +14 -0
  131. package/src/operations/OperationTypes.ts +340 -0
  132. package/src/operations/OperationValidator.ts +260 -0
  133. package/src/operations/apply/array.ts +84 -0
  134. package/src/operations/apply/boolean.ts +48 -0
  135. package/src/operations/apply/color.ts +65 -0
  136. package/src/operations/apply/index.ts +37 -0
  137. package/src/operations/apply/node.ts +98 -0
  138. package/src/operations/apply/number.ts +76 -0
  139. package/src/operations/apply/object.ts +63 -0
  140. package/src/operations/apply/quaternion.ts +47 -0
  141. package/src/operations/apply/string.ts +36 -0
  142. package/src/operations/apply/types.ts +66 -0
  143. package/src/operations/apply/vector3.ts +60 -0
  144. package/src/operations/dispatcher.ts +127 -0
  145. package/src/operations/index.ts +80 -0
  146. package/src/state/ConflictResolver.ts +205 -0
  147. package/src/state/DType.ts +333 -0
  148. package/src/state/Schema.ts +236 -0
  149. package/src/state/VectorClock.ts +98 -0
  150. package/src/state/index.ts +14 -0
  151. package/tests/client/actions.test.ts +371 -0
  152. package/tests/client/edit-buffer.test.ts +117 -0
  153. package/tests/fixtures/array-ops.jsonl +6 -0
  154. package/tests/fixtures/boolean-ops.jsonl +6 -0
  155. package/tests/fixtures/color-ops.jsonl +4 -0
  156. package/tests/fixtures/edit-buffer.jsonl +3 -0
  157. package/tests/fixtures/node-ops.jsonl +6 -0
  158. package/tests/fixtures/number-ops.jsonl +7 -0
  159. package/tests/fixtures/object-ops.jsonl +4 -0
  160. package/tests/fixtures/operations.jsonl +7 -0
  161. package/tests/fixtures/string-ops.jsonl +4 -0
  162. package/tests/fixtures/undo-redo.jsonl +3 -0
  163. package/tests/fixtures/vector-ops.jsonl +9 -0
  164. package/tests/operations/collections.test.ts +193 -0
  165. package/tests/operations/nodes.test.ts +228 -0
  166. package/tests/operations/primitives.test.ts +222 -0
  167. package/tests/operations/vectors.test.ts +150 -0
  168. package/tsconfig.json +21 -0
  169. 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';