@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
@@ -1,4 +1,11 @@
1
- import { BaseService } from './BaseService'
1
+ import { connect, send, disconnect } from '@symbo.ls/socket/client.js'
2
+ import { BaseService } from './BaseService.js'
3
+
4
+ import * as utils from '@domql/utils'
5
+ import { router } from '@symbo.ls/router'
6
+ import environment from '../config/environment.js'
7
+
8
+ const { deepStringify, deepDestringify, isString } = utils.default || utils
2
9
 
3
10
  export class SocketService extends BaseService {
4
11
  constructor (config) {
@@ -8,14 +15,31 @@ export class SocketService extends BaseService {
8
15
  this._maxReconnectAttempts = config?.maxReconnectAttempts || 5
9
16
  this._reconnectDelay = config?.reconnectDelay || 1000
10
17
  this._handlers = new Map()
18
+ this._sessionId = Math.random()
19
+
20
+ this._ignoreSync = [
21
+ 'userId',
22
+ 'username',
23
+ 'usersName',
24
+ 'email',
25
+ 'projects',
26
+ 'feedbacks',
27
+ 'userRoles',
28
+ 'loading',
29
+ 'appKey',
30
+ 'projectName',
31
+ 'followingUser',
32
+ 'activeProject',
33
+ 'user',
34
+ 'sessionId',
35
+ 'clients'
36
+ ]
11
37
  }
12
38
 
13
- async init ({ context, options }) {
39
+ init () {
14
40
  try {
15
- this._context = context
16
- this._options = options
17
- const { authToken } = context
18
- const { socketUrl } = context.socket || {}
41
+ const { _context, _options } = this
42
+ const socketUrl = environment.socketUrl || _options.socketUrl
19
43
 
20
44
  if (!socketUrl) {
21
45
  throw new Error('Socket URL is required')
@@ -24,12 +48,11 @@ export class SocketService extends BaseService {
24
48
  this._info = {
25
49
  config: {
26
50
  url: socketUrl,
27
- hasToken: Boolean(authToken),
51
+ hasToken: Boolean(_context.authToken),
28
52
  status: 'initializing'
29
53
  }
30
54
  }
31
55
 
32
- await this.connect()
33
56
  this._setReady()
34
57
  } catch (error) {
35
58
  this._setError(error)
@@ -39,93 +62,233 @@ export class SocketService extends BaseService {
39
62
 
40
63
  connect () {
41
64
  try {
42
- const { socketUrl, authToken } = this._context.socket || {}
43
-
44
- this._socket = new window.WebSocket(socketUrl)
65
+ // Check if already connected or connecting
66
+ if (
67
+ this._socket &&
68
+ ['connected', 'connecting'].includes(this._info?.config?.status)
69
+ ) {
70
+ console.warn(
71
+ 'Socket connection already exists:',
72
+ this._info?.config?.status
73
+ )
74
+ return true
75
+ }
45
76
 
46
- this._socket.onopen = () => {
47
- this._reconnectAttempts = 0
48
- this._updateStatus('connected')
77
+ const { _context } = this
49
78
 
50
- // Authenticate if token available
51
- if (authToken) {
52
- this.send('auth', { token: authToken })
53
- }
79
+ if (!_context.appKey) {
80
+ throw new Error('App key is required')
54
81
  }
55
82
 
56
- this._socket.onclose = () => {
57
- this._updateStatus('disconnected')
58
- this._handleReconnect()
83
+ // Update status to connecting before attempting connection
84
+ this._updateStatus('connecting')
85
+
86
+ const config = {
87
+ source: 'platform',
88
+ userId: _context.user?.id,
89
+ socketUrl: this._info.config.url,
90
+ location: window.location.host,
91
+ // onConnect: () => {
92
+ // console.log('waz')
93
+ // },
94
+ onChange: this._handleMessage.bind(this),
95
+ sessionId: this._sessionId,
96
+ usersName: _context.user?.name,
97
+ route: window.location.pathname,
98
+ onDisconnect: this._handleDisconnect.bind(this)
59
99
  }
60
100
 
61
- this._socket.onerror = error => {
62
- this._updateStatus('error')
63
- this._setError(error)
101
+ // If a previous socket exists but wasn't properly cleaned up, destroy it
102
+ if (this._socket) {
103
+ this.destroy()
64
104
  }
65
105
 
66
- this._socket.onmessage = event => {
67
- this._handleMessage(event.data)
106
+ this._socket = connect(_context.appKey, config)
107
+ this._updateStatus('connected')
108
+
109
+ if (environment.isDevelopment) {
110
+ console.log('Socket connection established:', {
111
+ appKey: _context.appKey,
112
+ userId: _context.user?.id,
113
+ sessionId: this._sessionId,
114
+ url: this._info.config.url
115
+ })
68
116
  }
117
+
118
+ return true
69
119
  } catch (error) {
120
+ this._updateStatus('failed')
121
+ console.error('Socket connection failed:', error)
70
122
  throw new Error(`Socket connection failed: ${error.message}`)
71
123
  }
72
124
  }
73
125
 
74
- // Send message to socket server
75
- send (type, data) {
126
+ send (type, data, opts = {}) {
76
127
  this._requireReady()
77
128
 
78
- if (!this._socket || this._socket.readyState !== window.WebSocket.OPEN) {
129
+ if (!this._socket) {
79
130
  throw new Error('Socket is not connected')
80
131
  }
81
132
 
82
- this._socket.send(JSON.stringify({ type, data }))
83
- }
84
-
85
- // Subscribe to socket events
86
- subscribe (type, handler) {
87
- if (!this._handlers.has(type)) {
88
- this._handlers.set(type, new Set())
133
+ const payload = {
134
+ sessionId: this._sessionId,
135
+ userId: this._context.user?.id,
136
+ usersName: this._context.user?.name,
137
+ ...data
89
138
  }
90
139
 
91
- this._handlers.get(type).add(handler)
140
+ send.call(
141
+ this._socket,
142
+ type,
143
+ opts.useDeepStringify ? deepStringify(payload) : payload
144
+ )
145
+ }
146
+
147
+ _handleMessage (event, data) {
148
+ try {
149
+ const d = isString(data) ? deepDestringify(JSON.parse(data)) : data
150
+ if (this._sessionId === d.sessionId) {
151
+ return
152
+ }
92
153
 
93
- return () => {
94
- const handlers = this._handlers.get(type)
154
+ const handlers = this._handlers.get(event)
95
155
  if (handlers) {
96
- handlers.delete(handler)
97
- if (handlers.size === 0) {
98
- this._handlers.delete(type)
99
- }
156
+ handlers.forEach(handler => handler(d))
100
157
  }
158
+
159
+ // Handle specific events
160
+ switch (event) {
161
+ case 'change':
162
+ this._handleChangeEvent(d)
163
+ break
164
+ case 'clients':
165
+ this._handleClientsEvent(d)
166
+ break
167
+ case 'route':
168
+ this._handleRouteEvent(d)
169
+ break
170
+ default:
171
+ break
172
+ }
173
+ } catch (error) {
174
+ this._setError(new Error(`Failed to handle message: ${error.message}`))
101
175
  }
102
176
  }
103
177
 
104
- subscribeChannel (type, handler) {
105
- return this.subscribe(type, handler)
178
+ _handleChangeEvent (data) {
179
+ const { type, changes, version } = data
180
+ if (version) {
181
+ this._context.state.version = version
182
+ }
183
+ if (changes) {
184
+ window.requestAnimationFrame(async () => {
185
+ await this._context.state.setPathCollection(changes, {
186
+ preventReplace: type === 'canvas',
187
+ preventUpdate: true,
188
+ fromSocket: true,
189
+ userId: data.userId,
190
+ changes
191
+ })
192
+ })
193
+ }
194
+
195
+ // monaco updates
196
+ // if (data.canvas) {
197
+ // const { clients } = data.canvas
198
+ // const [firstClientKey] = Object.keys(clients)
199
+ // const monaco =
200
+ // clients && clients[firstClientKey] && clients[firstClientKey].monaco
201
+ // if (monaco) {
202
+ // const Canvas =
203
+ // this._context.element && this._context.element.getCanvas()
204
+ // if (Canvas) {
205
+ // Canvas.Chosen.EditorPanels.update({}, { forceMonacoUpdate: true })
206
+ // }
207
+ // }
208
+ // return
209
+ // }
106
210
  }
107
211
 
108
- // Private methods
109
- _handleMessage (rawData) {
110
- try {
111
- const { type, data } = JSON.parse(rawData)
112
- const handlers = this._handlers.get(type)
212
+ _handleClientsEvent (data) {
213
+ const { root } = this._context.state
113
214
 
114
- if (handlers) {
115
- handlers.forEach(handler => handler(data))
215
+ root.replace(
216
+ { clients: data },
217
+ {
218
+ fromSocket: true,
219
+ preventUpdate: true
116
220
  }
117
- } catch (error) {
118
- this._setError(new Error(`Failed to handle message: ${error.message}`))
221
+ )
222
+ }
223
+
224
+ _handleRouteEvent (data) {
225
+ const { element } = this._context
226
+ const { state } = this._context
227
+
228
+ if (data.userId && data.type === 'routeChanged') {
229
+ const isModalOpen = this.getWindow('modal')
230
+ const isFollowing = state.followingUser === data.userId
231
+ const isRouteSyncEnabled =
232
+ element.getUserSettings('presentMode') && data.userId === state.userId
233
+
234
+ if ((isFollowing || isRouteSyncEnabled) && !isModalOpen) {
235
+ router(
236
+ data.route,
237
+ element.__ref.root,
238
+ {},
239
+ {
240
+ fromSocket: true,
241
+ updateStateOptions: {
242
+ fromSocket: true,
243
+ preventStateUpdateListener: 1 // !isModalRoute(data.route, element)
244
+ }
245
+ }
246
+ )
247
+ }
248
+ } else if (data.reload) {
249
+ window.location.reload()
250
+ } else if (data.route && data.type === 'routeForced') {
251
+ router(
252
+ data.route,
253
+ element.__ref.root,
254
+ {},
255
+ {
256
+ fromSocket: true,
257
+ updateStateOptions: {
258
+ fromSocket: true
259
+ }
260
+ }
261
+ )
262
+ } else if (data.componentKey) {
263
+ if (!element.getData('components')[data.componentKey]) {
264
+ return
265
+ }
266
+ element.activateSelected(data.componentKey)
119
267
  }
120
268
  }
121
269
 
270
+ _handleDisconnect () {
271
+ this._updateStatus('disconnected')
272
+ this._handleReconnect()
273
+ }
274
+
122
275
  _handleReconnect () {
123
276
  if (this._reconnectAttempts < this._maxReconnectAttempts) {
124
277
  this._reconnectAttempts++
125
278
  this._updateStatus('reconnecting')
126
279
 
127
280
  setTimeout(() => {
128
- this.connect()
281
+ try {
282
+ const connected = this.connect()
283
+ if (connected) {
284
+ this._reconnectAttempts = 0
285
+ } else {
286
+ this._handleReconnect()
287
+ }
288
+ } catch (error) {
289
+ console.error('Reconnection failed:', error)
290
+ this._handleReconnect()
291
+ }
129
292
  }, this._reconnectDelay * this._reconnectAttempts)
130
293
  } else {
131
294
  this._updateStatus('failed')
@@ -143,19 +306,24 @@ export class SocketService extends BaseService {
143
306
  }
144
307
  }
145
308
 
146
- // Cleanup
147
309
  destroy () {
148
310
  if (this._socket) {
149
- this._socket.close()
311
+ disconnect.call(this._socket)
150
312
  this._socket = null
151
313
  }
152
314
  this._handlers.clear()
153
315
  this._setReady(false)
154
316
  }
155
317
 
318
+ reconnect () {
319
+ this.destroy()
320
+ this.connect()
321
+ }
322
+
156
323
  _checkRequiredContext () {
157
- const { socket } = this._context
158
- return Boolean(socket?.socketUrl && this._socket)
324
+ return Boolean(
325
+ this._context?.appKey && this._context?.authToken && this._socket
326
+ )
159
327
  }
160
328
 
161
329
  isReady () {
@@ -1,23 +1,16 @@
1
- import { SymstoryService } from './SymstoryService.js'
1
+
2
2
  import { AuthService } from './AuthService.js'
3
- import { AIService } from './AIService.js'
4
- import { SocketService } from './SocketIOService.js'
5
3
  import { CoreService } from './CoreService.js'
4
+ import { CollabService } from './CollabService.js'
6
5
 
7
6
  const createService = (ServiceClass, config) => new ServiceClass(config)
8
7
 
9
8
  // Export service creators
10
- export const createSymstoryService = config =>
11
- createService(SymstoryService, config)
12
-
13
9
  export const createAuthService = config => createService(AuthService, config)
14
10
 
15
- export const createAIService = config => createService(AIService, config)
16
-
17
- export const createSocketService = config =>
18
- createService(SocketService, config)
19
-
20
11
  export const createCoreService = config => createService(CoreService, config)
21
12
 
22
- // Export service classes for direct usage
23
- export { SymstoryService, AuthService, AIService, SocketService, CoreService }
13
+ export const createCollabService = config =>
14
+ createService(CollabService, config)
15
+
16
+ export { AuthService, CoreService, CollabService }
@@ -0,0 +1,71 @@
1
+ import * as utils from '@domql/utils'
2
+ import { rootBus } from './rootEventBus.js'
3
+
4
+ const { isFunction } = utils.default || utils
5
+
6
+ export class RootStateManager {
7
+ constructor (rootState) {
8
+ this._rootState = rootState
9
+ }
10
+
11
+ /**
12
+ * Apply change tuples to the root state using the built-in setPathCollection
13
+ * of Symbo.ls APP state tree.
14
+ *
15
+ * @param {Array} changes – eg. ['update', ['foo'], 'bar']
16
+ * @param {Object} opts – forwarded to setPathCollection
17
+ */
18
+ applyChanges (changes = [], opts = {}) {
19
+ if (!this._rootState || !isFunction(this._rootState.setPathCollection)) {
20
+ return
21
+ }
22
+
23
+ const result = this._rootState.setPathCollection(changes, {
24
+ preventUpdate: true,
25
+ ...opts
26
+ })
27
+
28
+ // Identify library component mutations and notify UI once per batch
29
+ try {
30
+ const changedKeys = new Set()
31
+ changes.forEach(tuple => {
32
+ const [, path = []] = tuple
33
+ if (!Array.isArray(path) || !path.length) {return}
34
+
35
+ // Direct component value: ['components', key]
36
+ if (path[0] === 'components' && typeof path[1] === 'string') {
37
+ changedKeys.add(path[1])
38
+ }
39
+
40
+ // Component schema: ['schema', 'components', key, ...]
41
+ if (
42
+ path[0] === 'schema' &&
43
+ path[1] === 'components' &&
44
+ typeof path[2] === 'string'
45
+ ) {
46
+ changedKeys.add(path[2])
47
+ }
48
+ })
49
+
50
+ if (changedKeys.size) {
51
+ console.log('emit components:changed', [...changedKeys])
52
+ rootBus.emit('components:changed', [...changedKeys])
53
+ }
54
+ } catch (err) {
55
+ // Do not interrupt the main apply flow if notification fails
56
+ console.error('[RootStateManager] emit components:changed failed', err)
57
+ }
58
+
59
+ return result
60
+ }
61
+
62
+ setVersion (v) {
63
+ if (this._rootState) {
64
+ this._rootState.version = v
65
+ }
66
+ }
67
+
68
+ get root () {
69
+ return this._rootState
70
+ }
71
+ }
@@ -0,0 +1,48 @@
1
+ // Ensure we always reuse a single instance even if this file is evaluated
2
+ // multiple times from different bundle copies or with differing specifiers.
3
+ const getGlobalBus = () => {
4
+ if (globalThis.__SMBLS_ROOT_BUS__) {
5
+ return globalThis.__SMBLS_ROOT_BUS__
6
+ }
7
+
8
+ const events = {}
9
+
10
+ const bus = {
11
+ on (event, handler) {
12
+ (events[event] ||= []).push(handler)
13
+ },
14
+
15
+ off (event, handler) {
16
+ const list = events[event]
17
+ if (!list) {return}
18
+ const idx = list.indexOf(handler)
19
+ if (idx !== -1) {list.splice(idx, 1)}
20
+ },
21
+
22
+ emit (event, payload) {
23
+ const list = events[event]
24
+ if (!list || !list.length) {return}
25
+ // copy to avoid mutation during iteration
26
+ list.slice().forEach(fn => {
27
+ try {
28
+ fn(payload)
29
+ } catch (err) {
30
+ console.error('[rootBus] handler error for', event, err)
31
+ }
32
+ })
33
+ }
34
+ }
35
+
36
+ // expose for debugging if needed
37
+ Object.defineProperty(bus, '_listeners', {
38
+ value: events,
39
+ enumerable: false
40
+ })
41
+
42
+ globalThis.__SMBLS_ROOT_BUS__ = bus
43
+ return bus
44
+ }
45
+
46
+ export const rootBus = getGlobalBus()
47
+
48
+ export default rootBus
@@ -0,0 +1,161 @@
1
+ import { io } from 'socket.io-client'
2
+ import * as Y from 'yjs'
3
+ import { IndexeddbPersistence } from 'y-indexeddb'
4
+ import Dexie from 'dexie'
5
+ import { nanoid } from 'nanoid'
6
+ import environment from '../config/environment.js'
7
+ // import gzip from 'gzip-js' // reserved for future compression features
8
+
9
+ // diff / patch helpers
10
+ import { diffJson, applyOpsToJson } from './jsonDiff.js'
11
+
12
+ /* eslint-disable no-use-before-define, no-new, no-promise-executor-return */
13
+
14
+ export class CollabClient {
15
+ /* public fields */
16
+ socket = null
17
+ ydoc = null
18
+ branch = 'main'
19
+ live = false
20
+ projectId = null
21
+ jwt = null
22
+
23
+ /* private state */
24
+ _buffer = []
25
+ _flushTimer = null
26
+ _clientId = nanoid()
27
+ _outboxStore = null // Dexie table
28
+ _readyResolve
29
+ ready = new Promise(res => (this._readyResolve = res))
30
+
31
+ constructor ({ jwt, projectId, branch = 'main', live = false }) {
32
+ Object.assign(this, { jwt, projectId, branch, live })
33
+
34
+ /* 1️⃣ create Yjs doc + offline persistence */
35
+ this.ydoc = new Y.Doc()
36
+ new IndexeddbPersistence(`${projectId}:${branch}`, this.ydoc)
37
+
38
+ /* 2️⃣ init Dexie for outbox */
39
+ this._outboxStore = createDexieOutbox(`${projectId}:${branch}`)
40
+
41
+ /* 3️⃣ WebSocket transport */
42
+ this.socket = io(environment.socketUrl, {
43
+ path: '/collab-socket',
44
+ transports: ['websocket'],
45
+ auth: { token: jwt, projectId, branch, live },
46
+ reconnectionAttempts: Infinity,
47
+ reconnectionDelayMax: 4000
48
+ })
49
+
50
+ /* socket events */
51
+ this.socket
52
+ .on('snapshot', this._onSnapshot)
53
+ .on('ops', this._onOps)
54
+ .on('commit', this._onCommit)
55
+ .on('liveMode', flag => { this.live = flag })
56
+ .on('connect', this._onConnect)
57
+ .on('error', e => console.warn('[collab] socket error', e))
58
+
59
+ /* Track last known JSON representation so we can compute granular diffs. */
60
+ this._prevJson = this.ydoc.getMap('root').toJSON()
61
+
62
+ /* 4️⃣ hook Yjs change listener */
63
+ this.ydoc.on('afterTransaction', tr => {
64
+ // Ignore changes that originated from remote patches.
65
+ if (tr.origin === 'remote') {return}
66
+
67
+ const currentJson = this.ydoc.getMap('root').toJSON()
68
+
69
+ // Compute minimal diff between previous and current state.
70
+ const ops = diffJson(this._prevJson, currentJson)
71
+
72
+ // Cache new snapshot for next diff calculation.
73
+ this._prevJson = currentJson
74
+
75
+ if (!ops.length) {return}
76
+ this._queueOps(ops)
77
+ })
78
+ }
79
+
80
+ /* ---------- public helpers ---------- */
81
+ toggleLive (flag) { this.socket.emit('toggleLive', Boolean(flag)) }
82
+ sendCursor (data) { this.socket.emit('cursor', data) }
83
+ sendPresence (d) { this.socket.emit('presence', d) }
84
+
85
+ /* ---------- private handlers ---------- */
86
+ _onSnapshot = ({ data /* Uint8Array */ }) => {
87
+ // first paint; trust server compressed payload (≤256 kB)
88
+ Y.applyUpdate(this.ydoc, Uint8Array.from(data))
89
+
90
+ // Store current state as baseline for future diffs.
91
+ this._prevJson = this.ydoc.getMap('root').toJSON()
92
+ if (typeof this._readyResolve === 'function') {
93
+ this._readyResolve()
94
+ this._readyResolve = null
95
+ }
96
+ }
97
+
98
+ _onOps = ({ changes }) => {
99
+ // Apply remote ops
100
+ applyOpsToJson(changes, this.ydoc)
101
+
102
+ // Refresh baseline snapshot so we don't generate redundant diffs for the
103
+ // just-applied remote changes.
104
+ this._prevJson = this.ydoc.getMap('root').toJSON()
105
+ }
106
+
107
+ _onCommit = async ({ version }) => {
108
+ await this._outboxStore.clear()
109
+ console.info('[collab] committed', version)
110
+ }
111
+
112
+ _onConnect = async () => {
113
+ // Mark client as ready if we haven't received a snapshot yet
114
+ if (typeof this._readyResolve === 'function') {
115
+ this._readyResolve()
116
+ // Prevent multiple resolutions
117
+ this._readyResolve = null
118
+ }
119
+
120
+ // flush locally stored ops
121
+ const queued = await this._outboxStore.toArray()
122
+ if (queued.length) {
123
+ this.socket.emit('ops', {
124
+ changes: queued.flatMap(e => e.ops),
125
+ ts: Date.now(),
126
+ clientId: this._clientId
127
+ })
128
+ await this._outboxStore.clear()
129
+ }
130
+ }
131
+
132
+ /* ---------- buffering & debounce ---------- */
133
+ _queueOps (ops) {
134
+ this._buffer.push(...ops)
135
+ this._outboxStore.put({ id: nanoid(), ops })
136
+
137
+ if (this.live && this.socket.connected) {
138
+ this._flushNow()
139
+ } else {
140
+ clearTimeout(this._flushTimer)
141
+ this._flushTimer = setTimeout(() => this._flushNow(), 40)
142
+ }
143
+ }
144
+
145
+ _flushNow () {
146
+ if (!this._buffer.length || !this.socket.connected) {return}
147
+ this.socket.emit('ops', {
148
+ changes: this._buffer,
149
+ ts: Date.now(),
150
+ clientId: this._clientId
151
+ })
152
+ this._buffer.length = 0
153
+ }
154
+ }
155
+
156
+ /* ---------- Dexie helper ---------- */
157
+ function createDexieOutbox (name) {
158
+ const db = new Dexie(`collab-${name}`)
159
+ db.version(1).stores({ outbox: 'id, ops' })
160
+ return db.table('outbox')
161
+ }