@symbo.ls/sdk 3.1.2 → 3.2.3

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 (65) hide show
  1. package/README.md +2 -2
  2. package/dist/cjs/config/environment.js +5 -21
  3. package/dist/cjs/index.js +6 -26
  4. package/dist/cjs/services/AIService.js +3 -3
  5. package/dist/cjs/services/CollabService.js +420 -0
  6. package/dist/cjs/services/CoreService.js +651 -107
  7. package/dist/cjs/services/SocketService.js +207 -59
  8. package/dist/cjs/services/index.js +5 -13
  9. package/dist/cjs/state/RootStateManager.js +86 -0
  10. package/dist/cjs/state/rootEventBus.js +65 -0
  11. package/dist/cjs/utils/CollabClient.js +157 -0
  12. package/dist/cjs/utils/TokenManager.js +62 -27
  13. package/dist/cjs/utils/jsonDiff.js +103 -0
  14. package/dist/cjs/utils/services.js +129 -88
  15. package/dist/cjs/utils/symstoryClient.js +5 -5
  16. package/dist/esm/config/environment.js +5 -21
  17. package/dist/esm/index.js +20459 -9286
  18. package/dist/esm/services/AIService.js +3 -3
  19. package/dist/esm/services/BasedService.js +5 -21
  20. package/dist/esm/services/CollabService.js +18028 -0
  21. package/dist/esm/services/CoreService.js +718 -155
  22. package/dist/esm/services/SocketService.js +323 -58
  23. package/dist/esm/services/SymstoryService.js +10 -26
  24. package/dist/esm/services/index.js +20305 -9158
  25. package/dist/esm/state/RootStateManager.js +102 -0
  26. package/dist/esm/state/rootEventBus.js +47 -0
  27. package/dist/esm/utils/CollabClient.js +17483 -0
  28. package/dist/esm/utils/TokenManager.js +62 -27
  29. package/dist/esm/utils/jsonDiff.js +6096 -0
  30. package/dist/esm/utils/services.js +129 -88
  31. package/dist/esm/utils/symstoryClient.js +10 -26
  32. package/dist/node/config/environment.js +5 -21
  33. package/dist/node/index.js +10 -34
  34. package/dist/node/services/AIService.js +3 -3
  35. package/dist/node/services/CollabService.js +401 -0
  36. package/dist/node/services/CoreService.js +651 -107
  37. package/dist/node/services/SocketService.js +197 -59
  38. package/dist/node/services/index.js +5 -13
  39. package/dist/node/state/RootStateManager.js +57 -0
  40. package/dist/node/state/rootEventBus.js +46 -0
  41. package/dist/node/utils/CollabClient.js +128 -0
  42. package/dist/node/utils/TokenManager.js +62 -27
  43. package/dist/node/utils/jsonDiff.js +74 -0
  44. package/dist/node/utils/services.js +129 -88
  45. package/dist/node/utils/symstoryClient.js +5 -5
  46. package/package.json +12 -6
  47. package/src/config/environment.js +5 -19
  48. package/src/index.js +9 -31
  49. package/src/services/AIService.js +3 -3
  50. package/src/services/BasedService.js +1 -0
  51. package/src/services/CollabService.js +491 -0
  52. package/src/services/CoreService.js +715 -110
  53. package/src/services/SocketService.js +227 -59
  54. package/src/services/index.js +6 -13
  55. package/src/state/RootStateManager.js +71 -0
  56. package/src/state/rootEventBus.js +48 -0
  57. package/src/utils/CollabClient.js +161 -0
  58. package/src/utils/TokenManager.js +68 -30
  59. package/src/utils/jsonDiff.js +109 -0
  60. package/src/utils/services.js +140 -88
  61. package/src/utils/symstoryClient.js +5 -5
  62. package/dist/cjs/services/SocketIOService.js +0 -307
  63. package/dist/esm/services/SocketIOService.js +0 -470
  64. package/dist/node/services/SocketIOService.js +0 -278
  65. package/src/services/SocketIOService.js +0 -334
@@ -0,0 +1,491 @@
1
+ import { BaseService } from './BaseService.js'
2
+ import { CollabClient } from '../utils/CollabClient.js'
3
+ import { RootStateManager } from '../state/RootStateManager.js'
4
+ import { rootBus } from '../state/rootEventBus.js'
5
+
6
+ // (helper conversions reserved for future features)
7
+
8
+ export class CollabService extends BaseService {
9
+ constructor (config) {
10
+ super(config)
11
+ this._client = null
12
+ this._stateManager = null
13
+ this._connected = false
14
+ this._undoStack = []
15
+ this._redoStack = []
16
+ this._isUndoRedo = false
17
+ // Store operations made while offline so they can be flushed once the
18
+ // socket reconnects.
19
+ this._pendingOps = []
20
+ }
21
+
22
+ init ({ context }) {
23
+ // Defer state manager creation until a valid root state is present.
24
+ // The root state may not be set yet when the SDK is first initialised
25
+ // (e.g. inside initializeSDK()). We therefore create the manager lazily
26
+ // either when the SDK context is later updated or right before the first
27
+ // connection attempt.
28
+ if (context?.state) {
29
+ try {
30
+ this._stateManager = new RootStateManager(context.state)
31
+ } catch (err) {
32
+ this._setError(err)
33
+ throw err
34
+ }
35
+ }
36
+
37
+ this._setReady()
38
+ }
39
+
40
+ /**
41
+ * Overridden to re-initialise the state manager once the root state becomes
42
+ * available via a subsequent SDK `updateContext()` call.
43
+ */
44
+ updateContext (context = {}) {
45
+ // Preserve base behaviour
46
+ super.updateContext(context)
47
+
48
+ // Lazily (re)create state manager if a state tree is available
49
+ if (context.state) {
50
+ this._stateManager = new RootStateManager(context.state)
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Ensure that the state manager exists. This is called right before any
56
+ * operation that requires access to the root state (e.g. `connect()`).
57
+ * Throws an explicit error if the root state is still missing so that the
58
+ * caller can react accordingly.
59
+ */
60
+ _ensureStateManager () {
61
+ if (!this._stateManager) {
62
+ if (!this._context?.state) {
63
+ throw new Error('[CollabService] Cannot operate without root state')
64
+ }
65
+ this._stateManager = new RootStateManager(this._context.state)
66
+ }
67
+ }
68
+
69
+ /* ---------- Connection Management ---------- */
70
+ async connect (options = {}) {
71
+ // Make sure we have the state manager ready now that the context should
72
+ // contain the root state (after updateSDKContext()).
73
+ this._ensureStateManager()
74
+
75
+ const {
76
+ authToken: jwt,
77
+ projectId,
78
+ branch = 'main',
79
+ pro
80
+ } = {
81
+ ...this._context,
82
+ ...options
83
+ }
84
+ console.log(jwt, projectId, branch, pro)
85
+
86
+ if (!projectId) {
87
+ throw new Error('projectId is required for CollabService connection')
88
+ }
89
+
90
+ // Disconnect existing connection if any
91
+ if (this._client) {
92
+ await this.disconnect()
93
+ }
94
+
95
+ try {
96
+ this._client = new CollabClient({
97
+ jwt,
98
+ projectId,
99
+ branch,
100
+ live: Boolean(pro)
101
+ })
102
+
103
+ // Mark as connected once the socket establishes a connection. This prevents
104
+ // the SDK from being stuck waiting for an initial snapshot that may never
105
+ // arrive (e.g. for new/empty documents).
106
+ await new Promise(resolve => {
107
+ if (this._client.socket?.connected) {
108
+ resolve()
109
+ } else {
110
+ this._client.socket?.once('connect', resolve)
111
+ }
112
+ })
113
+
114
+ console.log('[CollabService] socket connected')
115
+
116
+ // Set up event listeners
117
+ this._client.socket?.on('ops', ({ changes }) => {
118
+ console.log(`ops event`)
119
+ console.log(changes)
120
+ this._stateManager.applyChanges(changes, { fromSocket: true })
121
+ })
122
+
123
+ this._client.socket?.on('commit', ({ version }) => {
124
+ this._stateManager.setVersion(version)
125
+ // Inform UI about automatic commit
126
+ rootBus.emit('checkpoint:done', { version, origin: 'auto' })
127
+ })
128
+
129
+ // 🔄 Presence / members / cursor updates
130
+ this._client.socket?.on('clients', this._handleClientsEvent.bind(this))
131
+ this._client.socket?.on('presence', this._handleClientsEvent.bind(this))
132
+ this._client.socket?.on('cursor', this._handleCursorEvent.bind(this))
133
+
134
+ // Flush any operations that were queued while we were offline.
135
+ if (this._pendingOps.length) {
136
+ console.log(
137
+ `[CollabService] Flushing ${this._pendingOps.length} offline operation batch(es)`
138
+ )
139
+ this._pendingOps.forEach(({ tuples }) => {
140
+ this.socket.emit('ops', { changes: tuples, ts: Date.now() })
141
+ })
142
+ this._pendingOps.length = 0
143
+ }
144
+
145
+ this._connected = true
146
+ console.log('[CollabService] Connected to project:', projectId)
147
+ } catch (err) {
148
+ console.error('[CollabService] Connection failed:', err)
149
+ throw err
150
+ }
151
+ }
152
+
153
+ disconnect () {
154
+ if (this._client?.socket) {
155
+ this._client.socket.disconnect()
156
+ }
157
+ this._client = null
158
+ this._connected = false
159
+ console.log('[CollabService] Disconnected')
160
+ }
161
+
162
+ isConnected () {
163
+ return this._connected && this._client?.socket?.connected
164
+ }
165
+
166
+ /* convenient shortcuts */
167
+ get ydoc () {
168
+ return this._client?.ydoc
169
+ }
170
+ get socket () {
171
+ return this._client?.socket
172
+ }
173
+
174
+ toggleLive (f) {
175
+ this._client?.toggleLive(f)
176
+ }
177
+ sendCursor (d) {
178
+ this._client?.sendCursor(d)
179
+ }
180
+ sendPresence (d) {
181
+ this._client?.sendPresence(d)
182
+ }
183
+
184
+ /* ---------- data helpers ---------- */
185
+ updateData (tuples, options = {}) {
186
+ // Always ensure we have a state manager so local changes are applied.
187
+ this._ensureStateManager()
188
+
189
+ const { isUndo = false, isRedo = false } = options
190
+
191
+ // Track operations for undo/redo (but not when performing undo/redo)
192
+ if (!isUndo && !isRedo && !this._isUndoRedo) {
193
+ this._trackForUndo(tuples, options)
194
+ }
195
+
196
+ // Apply changes to local state tree immediately.
197
+ this._stateManager.applyChanges(tuples, { ...options })
198
+
199
+ // If not connected yet, queue the operations for later synchronisation.
200
+ if (!this.isConnected()) {
201
+ console.warn('[CollabService] Not connected, queuing real-time update')
202
+ this._pendingOps.push({ tuples, options })
203
+ return
204
+ }
205
+
206
+ // When connected, send the operations to the backend.
207
+ if (this.socket?.connected) {
208
+ this.socket.emit('ops', { changes: tuples, ts: Date.now() })
209
+ }
210
+
211
+ return { success: true }
212
+ }
213
+
214
+ _trackForUndo (tuples, options) {
215
+ // Get current state before changes for undo
216
+ const undoOperations = tuples.map(tuple => {
217
+ const [action, path] = tuple
218
+ const currentValue = this._getValueAtPath(path)
219
+
220
+ if (action === 'delete') {
221
+ // For delete operations, store the current value to restore
222
+ return ['update', path, currentValue]
223
+ }
224
+ if (typeof currentValue !== 'undefined') {
225
+ return ['update', path, currentValue]
226
+ }
227
+ return ['delete', path]
228
+ })
229
+
230
+ this._undoStack.push({
231
+ operations: undoOperations,
232
+ originalOperations: tuples,
233
+ options,
234
+ timestamp: Date.now()
235
+ })
236
+
237
+ // Clear redo stack when new operation is performed
238
+ this._redoStack.length = 0
239
+
240
+ // Limit undo stack size (configurable)
241
+ const maxUndoSteps = this._options?.maxUndoSteps || 50
242
+ if (this._undoStack.length > maxUndoSteps) {
243
+ this._undoStack.shift()
244
+ }
245
+ }
246
+
247
+ _getValueAtPath (path) {
248
+ // Get value from root state at given path
249
+ const state = this._stateManager?.root
250
+ if (!state || !state.getByPath) {
251
+ return null
252
+ }
253
+
254
+ try {
255
+ return state.getByPath(path)
256
+ } catch (error) {
257
+ console.warn('[CollabService] Could not get value at path:', path, error)
258
+ return null
259
+ }
260
+ }
261
+
262
+ undo () {
263
+ if (!this._undoStack.length) {
264
+ throw new Error('Nothing to undo')
265
+ }
266
+
267
+ if (!this.isConnected()) {
268
+ console.warn('[CollabService] Not connected, cannot undo')
269
+ return
270
+ }
271
+
272
+ const undoItem = this._undoStack.pop()
273
+ const { operations, originalOperations, options } = undoItem
274
+
275
+ // Move to redo stack
276
+ this._redoStack.push({
277
+ operations: originalOperations,
278
+ originalOperations: operations,
279
+ options,
280
+ timestamp: Date.now()
281
+ })
282
+
283
+ // Apply undo operations
284
+ this._isUndoRedo = true
285
+ try {
286
+ this.updateData(operations, {
287
+ ...options,
288
+ isUndo: true,
289
+ message: `Undo: ${options.message || 'operation'}`
290
+ })
291
+ } finally {
292
+ this._isUndoRedo = false
293
+ }
294
+
295
+ return operations
296
+ }
297
+
298
+ redo () {
299
+ if (!this._redoStack.length) {
300
+ throw new Error('Nothing to redo')
301
+ }
302
+
303
+ if (!this.isConnected()) {
304
+ console.warn('[CollabService] Not connected, cannot redo')
305
+ return
306
+ }
307
+
308
+ const redoItem = this._redoStack.pop()
309
+ const { operations, originalOperations, options } = redoItem
310
+
311
+ // Move back to undo stack
312
+ this._undoStack.push({
313
+ operations: originalOperations,
314
+ originalOperations: operations,
315
+ options,
316
+ timestamp: Date.now()
317
+ })
318
+
319
+ // Apply redo operations
320
+ this._isUndoRedo = true
321
+ try {
322
+ this.updateData(operations, {
323
+ ...options,
324
+ isRedo: true,
325
+ message: `Redo: ${options.message || 'operation'}`
326
+ })
327
+ } finally {
328
+ this._isUndoRedo = false
329
+ }
330
+
331
+ return operations
332
+ }
333
+
334
+ /* ---------- Undo/Redo State ---------- */
335
+ canUndo () {
336
+ return this._undoStack.length > 0
337
+ }
338
+
339
+ canRedo () {
340
+ return this._redoStack.length > 0
341
+ }
342
+
343
+ getUndoStackSize () {
344
+ return this._undoStack.length
345
+ }
346
+
347
+ getRedoStackSize () {
348
+ return this._redoStack.length
349
+ }
350
+
351
+ clearUndoHistory () {
352
+ this._undoStack.length = 0
353
+ this._redoStack.length = 0
354
+ }
355
+
356
+ addItem (type, data, opts = {}) {
357
+ const { value, ...schema } = data
358
+ const tuples = [
359
+ ['update', [type, data.key], value],
360
+ ['update', ['schema', type, data.key], schema],
361
+ ...(opts.additionalChanges || [])
362
+ ]
363
+ return this.updateData(tuples, opts)
364
+ }
365
+
366
+ addMultipleItems (items, opts = {}) {
367
+ const tuples = []
368
+ items.forEach(([type, data]) => {
369
+ const { value, ...schema } = data
370
+ tuples.push(
371
+ ['update', [type, data.key], value],
372
+ ['update', ['schema', type, data.key], schema]
373
+ )
374
+ })
375
+
376
+ this.updateData([...tuples, ...(opts.additionalChanges || [])], {
377
+ message: `Created ${tuples.length} items`,
378
+ ...opts
379
+ })
380
+
381
+ return tuples
382
+ }
383
+
384
+ updateItem (type, data, opts = {}) {
385
+ const { value, ...schema } = data
386
+ const tuples = [
387
+ ['update', [type, data.key], value],
388
+ ['update', ['schema', type, data.key], schema]
389
+ ]
390
+ return this.updateData(tuples, opts)
391
+ }
392
+
393
+ deleteItem (type, key, opts = {}) {
394
+ const tuples = [
395
+ ['delete', [type, key]],
396
+ ['delete', ['schema', type, key]],
397
+ ...(opts.additionalChanges || [])
398
+ ]
399
+ return this.updateData(tuples, {
400
+ message: `Deleted ${key} from ${type}`,
401
+ ...opts
402
+ })
403
+ }
404
+
405
+ /* ---------- socket event helpers ---------- */
406
+ /**
407
+ * Handle "clients" or "presence" events coming from the collab socket.
408
+ * The backend sends the full `clients` object which we directly patch to
409
+ * the root state, mimicking the legacy SocketService behaviour so that
410
+ * existing UI components keep working unmodified.
411
+ */
412
+ _handleClientsEvent (data = {}) {
413
+ const root = this._stateManager?.root
414
+ if (root && typeof root.replace === 'function') {
415
+ root.replace(
416
+ { clients: data },
417
+ {
418
+ fromSocket: true,
419
+ preventUpdate: true
420
+ }
421
+ )
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Handle granular cursor updates coming from the socket.
427
+ * Expected payload: { userId, positions?, chosenPos?, ... }
428
+ * Only the provided fields are patched into the state tree.
429
+ */
430
+ _handleCursorEvent (payload = {}) {
431
+ const { userId, positions, chosenPos, ...rest } = payload || {}
432
+ if (!userId) {
433
+ return
434
+ }
435
+
436
+ const tuples = []
437
+
438
+ if (positions) {
439
+ tuples.push([
440
+ 'update',
441
+ ['canvas', 'clients', userId, 'positions'],
442
+ positions
443
+ ])
444
+ }
445
+
446
+ if (chosenPos) {
447
+ tuples.push([
448
+ 'update',
449
+ ['canvas', 'clients', userId, 'chosenPos'],
450
+ chosenPos
451
+ ])
452
+ }
453
+
454
+ // merge any additional cursor–related fields directly under the user node
455
+ if (Object.keys(rest).length) {
456
+ tuples.push(['update', ['canvas', 'clients', userId], rest])
457
+ }
458
+
459
+ if (tuples.length) {
460
+ this._stateManager.applyChanges(tuples, { fromSocket: true })
461
+ }
462
+ }
463
+
464
+ /* ---------- Manual checkpoint ---------- */
465
+ /**
466
+ * Manually request a checkpoint / commit of buffered operations on the server.
467
+ * Resolves with the new version number once the backend confirms via the
468
+ * regular "commit" event.
469
+ */
470
+ checkpoint () {
471
+ if (!this.isConnected()) {
472
+ console.warn('[CollabService] Not connected, cannot request checkpoint')
473
+ return Promise.reject(new Error('Not connected'))
474
+ }
475
+
476
+ return new Promise(resolve => {
477
+ const handler = ({ version }) => {
478
+ // Ensure we clean up the listener after the first commit event.
479
+ this.socket?.off('commit', handler)
480
+ rootBus.emit('checkpoint:done', { version, origin: 'manual' })
481
+ resolve(version)
482
+ }
483
+
484
+ // Listen for the next commit that the server will emit after checkpoint.
485
+ this.socket?.once('commit', handler)
486
+
487
+ // Trigger server-side checkpoint.
488
+ this.socket?.emit('checkpoint')
489
+ })
490
+ }
491
+ }