@symbo.ls/sdk 3.1.2 → 3.2.6

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 (187) hide show
  1. package/README.md +143 -2
  2. package/dist/cjs/config/environment.js +98 -30
  3. package/dist/cjs/index.js +144 -24
  4. package/dist/cjs/services/AdminService.js +351 -0
  5. package/dist/cjs/services/AuthService.js +738 -305
  6. package/dist/cjs/services/BaseService.js +158 -6
  7. package/dist/cjs/services/BranchService.js +484 -0
  8. package/dist/cjs/services/CollabService.js +743 -0
  9. package/dist/cjs/services/DnsService.js +340 -0
  10. package/dist/cjs/services/FeatureFlagService.js +175 -0
  11. package/dist/cjs/services/FileService.js +201 -0
  12. package/dist/cjs/services/IntegrationService.js +538 -0
  13. package/dist/cjs/services/MetricsService.js +62 -0
  14. package/dist/cjs/services/PaymentService.js +271 -0
  15. package/dist/cjs/services/PlanService.js +426 -0
  16. package/dist/cjs/services/ProjectService.js +1207 -0
  17. package/dist/cjs/services/PullRequestService.js +503 -0
  18. package/dist/cjs/services/ScreenshotService.js +304 -0
  19. package/dist/cjs/services/SubscriptionService.js +396 -0
  20. package/dist/cjs/services/TrackingService.js +661 -0
  21. package/dist/cjs/services/WaitlistService.js +148 -0
  22. package/dist/cjs/services/index.js +64 -16
  23. package/dist/cjs/state/RootStateManager.js +65 -0
  24. package/dist/cjs/state/rootEventBus.js +74 -0
  25. package/dist/cjs/utils/CollabClient.js +223 -0
  26. package/dist/cjs/utils/TokenManager.js +78 -30
  27. package/dist/cjs/utils/changePreprocessor.js +199 -0
  28. package/dist/cjs/utils/jsonDiff.js +145 -0
  29. package/dist/cjs/utils/ordering.js +309 -0
  30. package/dist/cjs/utils/services.js +301 -103
  31. package/dist/cjs/utils/validation.js +0 -3
  32. package/dist/esm/config/environment.js +98 -30
  33. package/dist/esm/index.js +49505 -8718
  34. package/dist/esm/services/AdminService.js +1132 -0
  35. package/dist/esm/services/AuthService.js +1493 -386
  36. package/dist/esm/services/BaseService.js +757 -6
  37. package/dist/esm/services/BranchService.js +1265 -0
  38. package/dist/esm/services/CollabService.js +26895 -0
  39. package/dist/esm/services/DnsService.js +1121 -0
  40. package/dist/esm/services/FeatureFlagService.js +956 -0
  41. package/dist/esm/services/FileService.js +982 -0
  42. package/dist/esm/services/IntegrationService.js +1319 -0
  43. package/dist/esm/services/MetricsService.js +843 -0
  44. package/dist/esm/services/PaymentService.js +1052 -0
  45. package/dist/esm/services/PlanService.js +1207 -0
  46. package/dist/esm/services/ProjectService.js +2526 -0
  47. package/dist/esm/services/PullRequestService.js +1284 -0
  48. package/dist/esm/services/ScreenshotService.js +1085 -0
  49. package/dist/esm/services/SubscriptionService.js +1177 -0
  50. package/dist/esm/services/TrackingService.js +18454 -0
  51. package/dist/esm/services/WaitlistService.js +929 -0
  52. package/dist/esm/services/index.js +49062 -8569
  53. package/dist/esm/state/RootStateManager.js +90 -0
  54. package/dist/esm/state/rootEventBus.js +56 -0
  55. package/dist/esm/utils/CollabClient.js +18889 -0
  56. package/dist/esm/utils/TokenManager.js +78 -30
  57. package/dist/esm/utils/changePreprocessor.js +542 -0
  58. package/dist/esm/utils/jsonDiff.js +7011 -0
  59. package/dist/esm/utils/ordering.js +291 -0
  60. package/dist/esm/utils/services.js +301 -103
  61. package/dist/esm/utils/validation.js +116 -50
  62. package/dist/node/config/environment.js +98 -30
  63. package/dist/node/index.js +175 -32
  64. package/dist/node/services/AdminService.js +332 -0
  65. package/dist/node/services/AuthService.js +742 -310
  66. package/dist/node/services/BaseService.js +148 -6
  67. package/dist/node/services/BranchService.js +465 -0
  68. package/dist/node/services/CollabService.js +724 -0
  69. package/dist/node/services/DnsService.js +321 -0
  70. package/dist/node/services/FeatureFlagService.js +156 -0
  71. package/dist/node/services/FileService.js +182 -0
  72. package/dist/node/services/IntegrationService.js +519 -0
  73. package/dist/node/services/MetricsService.js +43 -0
  74. package/dist/node/services/PaymentService.js +252 -0
  75. package/dist/node/services/PlanService.js +407 -0
  76. package/dist/node/services/ProjectService.js +1188 -0
  77. package/dist/node/services/PullRequestService.js +484 -0
  78. package/dist/node/services/ScreenshotService.js +285 -0
  79. package/dist/node/services/SubscriptionService.js +377 -0
  80. package/dist/node/services/TrackingService.js +632 -0
  81. package/dist/node/services/WaitlistService.js +129 -0
  82. package/dist/node/services/index.js +64 -16
  83. package/dist/node/state/RootStateManager.js +36 -0
  84. package/dist/node/state/rootEventBus.js +55 -0
  85. package/dist/node/utils/CollabClient.js +194 -0
  86. package/dist/node/utils/TokenManager.js +78 -30
  87. package/dist/node/utils/changePreprocessor.js +180 -0
  88. package/dist/node/utils/jsonDiff.js +116 -0
  89. package/dist/node/utils/ordering.js +290 -0
  90. package/dist/node/utils/services.js +301 -103
  91. package/dist/node/utils/validation.js +0 -3
  92. package/package.json +39 -21
  93. package/src/config/environment.js +99 -28
  94. package/src/index.js +181 -36
  95. package/src/services/AdminService.js +374 -0
  96. package/src/services/AuthService.js +874 -328
  97. package/src/services/BaseService.js +166 -6
  98. package/src/services/BranchService.js +536 -0
  99. package/src/services/CollabService.js +900 -0
  100. package/src/services/DnsService.js +366 -0
  101. package/src/services/FeatureFlagService.js +174 -0
  102. package/src/services/FileService.js +213 -0
  103. package/src/services/IntegrationService.js +548 -0
  104. package/src/services/MetricsService.js +40 -0
  105. package/src/services/PaymentService.js +287 -0
  106. package/src/services/PlanService.js +468 -0
  107. package/src/services/ProjectService.js +1366 -0
  108. package/src/services/PullRequestService.js +537 -0
  109. package/src/services/ScreenshotService.js +258 -0
  110. package/src/services/SubscriptionService.js +425 -0
  111. package/src/services/TrackingService.js +853 -0
  112. package/src/services/WaitlistService.js +130 -0
  113. package/src/services/index.js +80 -13
  114. package/src/services/tests/BranchService/createBranch.test.js +153 -0
  115. package/src/services/tests/BranchService/deleteBranch.test.js +173 -0
  116. package/src/services/tests/BranchService/getBranchChanges.test.js +146 -0
  117. package/src/services/tests/BranchService/listBranches.test.js +87 -0
  118. package/src/services/tests/BranchService/mergeBranch.test.js +210 -0
  119. package/src/services/tests/BranchService/publishVersion.test.js +183 -0
  120. package/src/services/tests/BranchService/renameBranch.test.js +240 -0
  121. package/src/services/tests/BranchService/resetBranch.test.js +152 -0
  122. package/src/services/tests/FeatureFlagService/adminFeatureFlags.test.js +67 -0
  123. package/src/services/tests/FeatureFlagService/getFeatureFlags.test.js +75 -0
  124. package/src/services/tests/FileService/createFileFormData.test.js +74 -0
  125. package/src/services/tests/FileService/getFileUrl.test.js +69 -0
  126. package/src/services/tests/FileService/updateProjectIcon.test.js +109 -0
  127. package/src/services/tests/FileService/uploadDocument.test.js +36 -0
  128. package/src/services/tests/FileService/uploadFile.test.js +78 -0
  129. package/src/services/tests/FileService/uploadFileWithValidation.test.js +114 -0
  130. package/src/services/tests/FileService/uploadImage.test.js +36 -0
  131. package/src/services/tests/FileService/uploadMultipleFiles.test.js +111 -0
  132. package/src/services/tests/FileService/validateFile.test.js +63 -0
  133. package/src/services/tests/PlanService/createPlan.test.js +104 -0
  134. package/src/services/tests/PlanService/createPlanWithValidation.test.js +523 -0
  135. package/src/services/tests/PlanService/deletePlan.test.js +92 -0
  136. package/src/services/tests/PlanService/getActivePlans.test.js +123 -0
  137. package/src/services/tests/PlanService/getAdminPlans.test.js +84 -0
  138. package/src/services/tests/PlanService/getPlan.test.js +50 -0
  139. package/src/services/tests/PlanService/getPlanByKey.test.js +109 -0
  140. package/src/services/tests/PlanService/getPlanWithValidation.test.js +85 -0
  141. package/src/services/tests/PlanService/getPlans.test.js +53 -0
  142. package/src/services/tests/PlanService/getPlansByPriceRange.test.js +109 -0
  143. package/src/services/tests/PlanService/getPlansWithValidation.test.js +48 -0
  144. package/src/services/tests/PlanService/initializePlans.test.js +75 -0
  145. package/src/services/tests/PlanService/updatePlan.test.js +111 -0
  146. package/src/services/tests/PlanService/updatePlanWithValidation.test.js +556 -0
  147. package/src/state/RootStateManager.js +76 -0
  148. package/src/state/rootEventBus.js +67 -0
  149. package/src/utils/CollabClient.js +248 -0
  150. package/src/utils/TokenManager.js +88 -33
  151. package/src/utils/changePreprocessor.js +239 -0
  152. package/src/utils/jsonDiff.js +144 -0
  153. package/src/utils/ordering.js +271 -0
  154. package/src/utils/services.js +326 -107
  155. package/src/utils/validation.js +0 -3
  156. package/dist/cjs/services/AIService.js +0 -155
  157. package/dist/cjs/services/BasedService.js +0 -1185
  158. package/dist/cjs/services/CoreService.js +0 -1751
  159. package/dist/cjs/services/SocketIOService.js +0 -307
  160. package/dist/cjs/services/SocketService.js +0 -161
  161. package/dist/cjs/services/SymstoryService.js +0 -571
  162. package/dist/cjs/utils/basedQuerys.js +0 -181
  163. package/dist/cjs/utils/symstoryClient.js +0 -259
  164. package/dist/esm/services/AIService.js +0 -185
  165. package/dist/esm/services/BasedService.js +0 -5278
  166. package/dist/esm/services/CoreService.js +0 -2264
  167. package/dist/esm/services/SocketIOService.js +0 -470
  168. package/dist/esm/services/SocketService.js +0 -191
  169. package/dist/esm/services/SymstoryService.js +0 -7041
  170. package/dist/esm/utils/basedQuerys.js +0 -163
  171. package/dist/esm/utils/symstoryClient.js +0 -370
  172. package/dist/node/services/AIService.js +0 -136
  173. package/dist/node/services/BasedService.js +0 -1156
  174. package/dist/node/services/CoreService.js +0 -1722
  175. package/dist/node/services/SocketIOService.js +0 -278
  176. package/dist/node/services/SocketService.js +0 -142
  177. package/dist/node/services/SymstoryService.js +0 -542
  178. package/dist/node/utils/basedQuerys.js +0 -162
  179. package/dist/node/utils/symstoryClient.js +0 -230
  180. package/src/services/AIService.js +0 -150
  181. package/src/services/BasedService.js +0 -1301
  182. package/src/services/CoreService.js +0 -1943
  183. package/src/services/SocketIOService.js +0 -334
  184. package/src/services/SocketService.js +0 -168
  185. package/src/services/SymstoryService.js +0 -649
  186. package/src/utils/basedQuerys.js +0 -164
  187. package/src/utils/symstoryClient.js +0 -252
@@ -0,0 +1,900 @@
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
+ import { validateParams } from '../utils/validation.js'
6
+ import { deepStringifyFunctions } from '@domql/utils'
7
+ import { preprocessChanges } from '../utils/changePreprocessor.js'
8
+
9
+ // Helper: clone a value while converting all functions to strings. This is
10
+ // tailored for collab payloads (tuples / granularChanges) and is more robust
11
+ // for nested array shapes than the generic DOMQL helper.
12
+ const FUNCTION_META_KEYS = ['node', '__ref', '__element', 'parent', 'parse']
13
+
14
+ function stringifyFunctionsForTransport(value, seen = new WeakMap()) {
15
+ if (value === null || typeof value !== 'object') {
16
+ return typeof value === 'function' ? value.toString() : value
17
+ }
18
+
19
+ if (seen.has(value)) {
20
+ return seen.get(value)
21
+ }
22
+
23
+ const clone = Array.isArray(value) ? [] : {}
24
+ seen.set(value, clone)
25
+
26
+ if (Array.isArray(value)) {
27
+ for (let i = 0; i < value.length; i++) {
28
+ clone[i] = stringifyFunctionsForTransport(value[i], seen)
29
+ }
30
+ return clone
31
+ }
32
+
33
+ const keys = Object.keys(value)
34
+ for (let i = 0; i < keys.length; i++) {
35
+ const key = keys[i]
36
+ if (!FUNCTION_META_KEYS.includes(key)) {
37
+ clone[key] = stringifyFunctionsForTransport(value[key], seen)
38
+ }
39
+ }
40
+
41
+ return clone
42
+ }
43
+
44
+ export class CollabService extends BaseService {
45
+ constructor(config) {
46
+ super(config)
47
+ this._client = null
48
+ this._stateManager = null
49
+ this._connected = false
50
+ this._connecting = false
51
+ this._connectPromise = null
52
+ this._connectionMeta = null
53
+ this._pendingConnectReject = null
54
+ this._undoStack = []
55
+ this._redoStack = []
56
+ this._isUndoRedo = false
57
+ // Store operations made while offline so they can be flushed once the
58
+ // socket reconnects.
59
+ this._pendingOps = []
60
+
61
+ this._onSocketConnect = this._onSocketConnect.bind(this)
62
+ this._onSocketDisconnect = this._onSocketDisconnect.bind(this)
63
+ this._onSocketError = this._onSocketError.bind(this)
64
+ }
65
+
66
+ init({ context }) {
67
+ super.init({ context })
68
+ // console.log('CollabService init')
69
+ // console.log(context)
70
+
71
+ // Defer state manager creation until a valid root state is present.
72
+ // The root state may not be set yet when the SDK is first initialised
73
+ // (e.g. inside initializeSDK()). We therefore create the manager lazily
74
+ // either when the SDK context is later updated or right before the first
75
+ // connection attempt.
76
+ if (context?.state) {
77
+ try {
78
+ this._stateManager = new RootStateManager(context.state)
79
+ } catch (err) {
80
+ this._setError(err)
81
+ throw err
82
+ }
83
+ }
84
+
85
+ this._setReady()
86
+ }
87
+
88
+ /**
89
+ * Overridden to re-initialise the state manager once the root state becomes
90
+ * available via a subsequent SDK `updateContext()` call.
91
+ */
92
+ updateContext(context = {}) {
93
+ // Preserve base behaviour
94
+ super.updateContext(context)
95
+
96
+ // Lazily (re)create state manager if a state tree is available
97
+ if (context.state) {
98
+ this._stateManager = new RootStateManager(context.state)
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Ensure that the state manager exists. This is called right before any
104
+ * operation that requires access to the root state (e.g. `connect()`).
105
+ * Throws an explicit error if the root state is still missing so that the
106
+ * caller can react accordingly.
107
+ */
108
+ _ensureStateManager() {
109
+ if (!this._stateManager) {
110
+ if (!this._context?.state) {
111
+ throw new Error('[CollabService] Cannot operate without root state')
112
+ }
113
+ this._stateManager = new RootStateManager(this._context.state)
114
+ }
115
+
116
+ // 🌐 Ensure we always have a usable `__element` stub so that calls like
117
+ // `el.call('openNotification', …)` or `el.call('deepStringifyFunctions', …)` do not
118
+ // crash in headless / Node.js environments (e.g. integration tests).
119
+ const root = this._stateManager?.root
120
+
121
+ if (root && !root.__element) {
122
+ // Minimal no-op implementation of the DOMQL element API used here
123
+ root.__element = {
124
+ /**
125
+ * Very small subset of the DOMQL `call` API that we rely on inside the
126
+ * CollabService for browser notifications and data helpers.
127
+ * In a Node.js test context we simply log or return fallbacks.
128
+ */
129
+ call: (method, ...args) => {
130
+ switch (method) {
131
+ case 'openNotification': {
132
+ const [payload = {}] = args
133
+ const { type = 'info', title = '', message = '' } = payload
134
+ const logger = type === 'error' ? console.error : console.log
135
+ logger(`[Notification] ${title}${message ? ` – ${message}` : ''}`)
136
+ return
137
+ }
138
+ case 'deepStringifyFunctions': {
139
+ // Pass-through to the shared utility from `smbls`
140
+ return deepStringifyFunctions(...args)
141
+ }
142
+ default:
143
+ return {}
144
+ }
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ /* ---------- Connection Management ---------- */
151
+ async connect(options = {}) {
152
+ if (this._connectPromise) {
153
+ return this._connectPromise
154
+ }
155
+
156
+ this._connectPromise = (async () => {
157
+ this._connecting = true
158
+ this._connected = false
159
+
160
+ // Make sure we have the state manager ready now that the context should
161
+ // contain the root state (after updateSDKContext()).
162
+ this._ensureStateManager()
163
+
164
+ const mergedOptions = {
165
+ ...this._context,
166
+ ...options
167
+ }
168
+
169
+ let { authToken: jwt } = mergedOptions
170
+ const { projectId, branch = 'main', pro } = mergedOptions
171
+
172
+ if (!jwt && this._tokenManager) {
173
+ try {
174
+ jwt = await this._tokenManager.ensureValidToken()
175
+ } catch (error) {
176
+ console.warn(
177
+ '[CollabService] Failed to obtain auth token from token manager',
178
+ error
179
+ )
180
+ }
181
+
182
+ if (!jwt && typeof this._tokenManager.getAccessToken === 'function') {
183
+ jwt = this._tokenManager.getAccessToken()
184
+ }
185
+ }
186
+
187
+ if (!jwt) {
188
+ throw new Error('[CollabService] Cannot connect without auth token')
189
+ }
190
+
191
+ this._context = {
192
+ ...this._context,
193
+ authToken: jwt,
194
+ projectId,
195
+ branch,
196
+ pro
197
+ }
198
+
199
+ if (!projectId) {
200
+ const state = this._stateManager?.root
201
+ const el = state.__element
202
+ el.call('openNotification', {
203
+ type: 'error',
204
+ title: 'projectId is required',
205
+ message: 'projectId is required for CollabService connection'
206
+ })
207
+ throw new Error('projectId is required for CollabService connection')
208
+ }
209
+
210
+ // Disconnect existing connection if any
211
+ if (this._client) {
212
+ await this.disconnect()
213
+ }
214
+
215
+ this._client = new CollabClient({
216
+ jwt,
217
+ projectId,
218
+ branch,
219
+ live: Boolean(pro)
220
+ })
221
+
222
+ // Mark as connected once the socket establishes a connection. This prevents
223
+ // the SDK from being stuck waiting for an initial snapshot that may never
224
+ // arrive (e.g. for new/empty documents).
225
+ const { socket } = this._client
226
+
227
+ try {
228
+ await new Promise((resolve, reject) => {
229
+ if (!socket) {
230
+ reject(new Error('[CollabService] Socket instance missing'))
231
+ return
232
+ }
233
+
234
+ if (socket.connected) {
235
+ resolve()
236
+ return
237
+ }
238
+
239
+ /* eslint-disable no-use-before-define */
240
+ const cleanup = () => {
241
+ socket.off('connect', handleConnect)
242
+ socket.off('connect_error', handleError)
243
+ socket.off('error', handleError)
244
+ socket.off('disconnect', handleDisconnect)
245
+ if (this._pendingConnectReject === handleError) {
246
+ this._pendingConnectReject = null
247
+ }
248
+ }
249
+
250
+ const handleConnect = () => {
251
+ cleanup()
252
+ resolve()
253
+ }
254
+
255
+ const handleError = (error) => {
256
+ cleanup()
257
+ reject(
258
+ error instanceof Error
259
+ ? error
260
+ : new Error(String(error || 'Unknown connection error'))
261
+ )
262
+ }
263
+
264
+ const handleDisconnect = (reason) => {
265
+ handleError(
266
+ reason instanceof Error
267
+ ? reason
268
+ : new Error(
269
+ `[CollabService] Socket disconnected before connect: ${
270
+ reason || 'unknown'
271
+ }`
272
+ )
273
+ )
274
+ }
275
+
276
+ this._pendingConnectReject = handleError
277
+
278
+ socket.once('connect', handleConnect)
279
+ socket.once('connect_error', handleError)
280
+ socket.once('error', handleError)
281
+ socket.once('disconnect', handleDisconnect)
282
+ /* eslint-enable no-use-before-define */
283
+ })
284
+ } catch (error) {
285
+ socket?.disconnect()
286
+ this._client = null
287
+ this._connectionMeta = null
288
+ throw error
289
+ }
290
+
291
+ this._attachSocketLifecycleListeners()
292
+ if (socket?.connected) {
293
+ this._onSocketConnect()
294
+ }
295
+
296
+ // Set up event listeners
297
+ socket?.on('ops', ({ changes }) => {
298
+ console.log(`ops event`)
299
+ this._stateManager.applyChanges(changes, { fromSocket: true })
300
+ })
301
+
302
+ socket?.on('commit', ({ version }) => {
303
+ if (version) {
304
+ this._stateManager.setVersion(version)
305
+ }
306
+
307
+ // Inform UI about automatic commit
308
+ rootBus.emit('checkpoint:done', { version, origin: 'auto' })
309
+ })
310
+
311
+ // 🔄 Presence / members / cursor updates
312
+ socket?.on('clients', this._handleClientsEvent.bind(this))
313
+
314
+ // 🗜️ Bundle events – emitted by the dependency bundler service
315
+ socket?.on('bundle:done', this._handleBundleDoneEvent.bind(this))
316
+ socket?.on('bundle:error', this._handleBundleErrorEvent.bind(this))
317
+
318
+ // Flush any operations that were queued while we were offline.
319
+ if (this._pendingOps.length) {
320
+ console.log(
321
+ `[CollabService] Flushing ${this._pendingOps.length} offline operation batch(es)`
322
+ )
323
+ this._pendingOps.forEach(
324
+ ({ changes, granularChanges, orders, options: opOptions }) => {
325
+ const { message } = opOptions || {}
326
+ const ts = Date.now()
327
+ const payload = {
328
+ changes,
329
+ granularChanges,
330
+ orders,
331
+ ts
332
+ }
333
+ if (message) {
334
+ payload.message = message
335
+ }
336
+ this.socket.emit('ops', payload)
337
+ }
338
+ )
339
+ this._pendingOps.length = 0
340
+ }
341
+
342
+ await this._client.ready
343
+
344
+ this._connectionMeta = {
345
+ projectId,
346
+ branch,
347
+ live: Boolean(pro)
348
+ }
349
+
350
+ return this.getConnectionInfo()
351
+ })()
352
+
353
+ try {
354
+ return await this._connectPromise
355
+ } finally {
356
+ this._connecting = false
357
+ this._connectPromise = null
358
+ }
359
+ }
360
+
361
+ disconnect() {
362
+ if (this._client?.socket) {
363
+ if (this._pendingConnectReject) {
364
+ this._pendingConnectReject(
365
+ new Error('[CollabService] Connection attempt aborted')
366
+ )
367
+ this._pendingConnectReject = null
368
+ }
369
+ this._detachSocketLifecycleListeners()
370
+ if (typeof this._client.dispose === 'function') {
371
+ this._client.dispose()
372
+ } else {
373
+ this._client.socket.disconnect()
374
+ }
375
+ }
376
+ this._client = null
377
+ this._connected = false
378
+ this._connecting = false
379
+ this._connectionMeta = null
380
+ this._pendingConnectReject = null
381
+ console.log('[CollabService] Disconnected')
382
+ }
383
+
384
+ isConnected() {
385
+ return Boolean(this._connected && this._client?.socket?.connected)
386
+ }
387
+
388
+ getConnectionInfo() {
389
+ return {
390
+ connected: this.isConnected(),
391
+ connecting: this._connecting,
392
+ projectId: this._connectionMeta?.projectId ?? null,
393
+ branch: this._connectionMeta?.branch ?? null,
394
+ live: this._connectionMeta?.live ?? null,
395
+ pendingOps: this._pendingOps.length,
396
+ undoStackSize: this.getUndoStackSize(),
397
+ redoStackSize: this.getRedoStackSize()
398
+ }
399
+ }
400
+
401
+ /* convenient shortcuts */
402
+ get ydoc() {
403
+ return this._client?.ydoc
404
+ }
405
+ get socket() {
406
+ return this._client?.socket
407
+ }
408
+
409
+ toggleLive(f) {
410
+ this._client?.toggleLive(f)
411
+ }
412
+ sendCursor(d) {
413
+ this._client?.sendCursor(d)
414
+ }
415
+ sendPresence(d) {
416
+ this._client?.sendPresence(d)
417
+ }
418
+
419
+ /* ---------- data helpers ---------- */
420
+ updateData(tuples, options = {}) {
421
+ // Always ensure we have a state manager so local changes are applied.
422
+ this._ensureStateManager()
423
+
424
+ const { isUndo = false, isRedo = false } = options
425
+
426
+ // Track operations for undo/redo (but not when performing undo/redo)
427
+ if (!isUndo && !isRedo && !this._isUndoRedo) {
428
+ this._trackForUndo(tuples, options)
429
+ }
430
+
431
+ // Preprocess into granular changes and derive orders
432
+ const root = this._stateManager?.root
433
+ const { granularChanges: processedTuples, orders } = preprocessChanges(
434
+ root,
435
+ tuples,
436
+ options
437
+ )
438
+
439
+ // Include any additional changes passed via opts
440
+ if (options.append && options.append.length) {
441
+ processedTuples.push(...options.append)
442
+ }
443
+
444
+ // Apply changes to local state tree immediately.
445
+ this._stateManager.applyChanges(tuples, { ...options })
446
+
447
+ // Use a dedicated helper that correctly handles nested array structures
448
+ // such as granular tuples while also avoiding DOM / state metadata keys.
449
+ const stringifiedGranularTuples =
450
+ stringifyFunctionsForTransport(processedTuples)
451
+
452
+ const stringifiedTuples = stringifyFunctionsForTransport(tuples)
453
+
454
+ const { message } = options
455
+
456
+ // If not connected yet, queue the operations for later synchronisation.
457
+ if (!this.isConnected()) {
458
+ console.warn('[CollabService] Not connected, queuing real-time update')
459
+ this._pendingOps.push({
460
+ changes: stringifiedTuples,
461
+ granularChanges: stringifiedGranularTuples,
462
+ orders,
463
+ options
464
+ })
465
+ return
466
+ }
467
+
468
+ // When connected, send the operations to the backend.
469
+ if (this.socket?.connected) {
470
+ const ts = Date.now()
471
+ // console.log('[CollabService] Sending operations to the backend', {
472
+ // changes: stringifiedTuples,
473
+ // granularChanges: stringifiedGranularTuples,
474
+ // orders,
475
+ // ts,
476
+ // message
477
+ // })
478
+ const payload = {
479
+ changes: stringifiedTuples,
480
+ granularChanges: stringifiedGranularTuples,
481
+ orders,
482
+ ts
483
+ }
484
+
485
+ if (message) {
486
+ payload.message = message
487
+ }
488
+
489
+ this.socket.emit('ops', payload)
490
+ }
491
+
492
+ return { success: true }
493
+ }
494
+
495
+ _trackForUndo(tuples, options) {
496
+ // Get current state before changes for undo
497
+ const undoOperations = tuples.map((tuple) => {
498
+ const [action, path] = tuple
499
+ const currentValue = this._getValueAtPath(path)
500
+
501
+ if (action === 'delete') {
502
+ // For delete operations, store the current value to restore
503
+ return ['update', path, currentValue]
504
+ }
505
+ if (typeof currentValue !== 'undefined') {
506
+ return ['update', path, currentValue]
507
+ }
508
+ return ['delete', path]
509
+ })
510
+
511
+ this._undoStack.push({
512
+ operations: undoOperations,
513
+ originalOperations: tuples,
514
+ options,
515
+ timestamp: Date.now()
516
+ })
517
+
518
+ // Clear redo stack when new operation is performed
519
+ this._redoStack.length = 0
520
+
521
+ // Limit undo stack size (configurable)
522
+ const maxUndoSteps = this._options?.maxUndoSteps || 50
523
+ if (this._undoStack.length > maxUndoSteps) {
524
+ this._undoStack.shift()
525
+ }
526
+ }
527
+
528
+ _getValueAtPath(path) {
529
+ // Get value from root state at given path
530
+ const state = this._stateManager?.root
531
+ if (!state || !state.getByPath) {
532
+ return null
533
+ }
534
+
535
+ try {
536
+ return state.getByPath(path)
537
+ } catch (error) {
538
+ console.warn('[CollabService] Could not get value at path:', path, error)
539
+ return null
540
+ }
541
+ }
542
+
543
+ undo() {
544
+ if (!this._undoStack.length) {
545
+ const state = this._stateManager?.root
546
+ const el = state.__element
547
+ el.call('openNotification', {
548
+ type: 'error',
549
+ title: 'Nothing to undo'
550
+ })
551
+ throw new Error('Nothing to undo')
552
+ }
553
+
554
+ if (!this.isConnected()) {
555
+ console.warn('[CollabService] Not connected, cannot undo')
556
+ return
557
+ }
558
+
559
+ const undoItem = this._undoStack.pop()
560
+ const { operations, originalOperations, options } = undoItem
561
+
562
+ // Move to redo stack
563
+ this._redoStack.push({
564
+ operations: originalOperations,
565
+ originalOperations: operations,
566
+ options,
567
+ timestamp: Date.now()
568
+ })
569
+
570
+ // Apply undo operations
571
+ this._isUndoRedo = true
572
+ try {
573
+ this.updateData(operations, {
574
+ ...options,
575
+ isUndo: true,
576
+ message: `Undo: ${options.message || 'operation'}`
577
+ })
578
+ } finally {
579
+ this._isUndoRedo = false
580
+ }
581
+
582
+ return operations
583
+ }
584
+
585
+ redo() {
586
+ if (!this._redoStack.length) {
587
+ const state = this._stateManager?.root
588
+ const el = state.__element
589
+ el.call('openNotification', {
590
+ type: 'error',
591
+ title: 'Nothing to redo'
592
+ })
593
+ throw new Error('Nothing to redo')
594
+ }
595
+
596
+ if (!this.isConnected()) {
597
+ console.warn('[CollabService] Not connected, cannot redo')
598
+ return
599
+ }
600
+
601
+ const redoItem = this._redoStack.pop()
602
+ const { operations, originalOperations, options } = redoItem
603
+
604
+ // Move back to undo stack
605
+ this._undoStack.push({
606
+ operations: originalOperations,
607
+ originalOperations: operations,
608
+ options,
609
+ timestamp: Date.now()
610
+ })
611
+
612
+ // Apply redo operations
613
+ this._isUndoRedo = true
614
+ try {
615
+ this.updateData(operations, {
616
+ ...options,
617
+ isRedo: true,
618
+ message: `Redo: ${options.message || 'operation'}`
619
+ })
620
+ } finally {
621
+ this._isUndoRedo = false
622
+ }
623
+
624
+ return operations
625
+ }
626
+
627
+ /* ---------- Undo/Redo State ---------- */
628
+ canUndo() {
629
+ return this._undoStack.length > 0
630
+ }
631
+
632
+ canRedo() {
633
+ return this._redoStack.length > 0
634
+ }
635
+
636
+ getUndoStackSize() {
637
+ return this._undoStack.length
638
+ }
639
+
640
+ getRedoStackSize() {
641
+ return this._redoStack.length
642
+ }
643
+
644
+ clearUndoHistory() {
645
+ this._undoStack.length = 0
646
+ this._redoStack.length = 0
647
+ }
648
+
649
+ addItem(type, data, opts = {}) {
650
+ try {
651
+ validateParams.type(type)
652
+ validateParams.data(data, type)
653
+
654
+ const { value, ...schema } = data
655
+
656
+ // Base tuple for the actual value update
657
+ const tuples = [
658
+ ['update', [type, data.key], value],
659
+ ['update', ['schema', type, data.key], schema || {}]
660
+ ]
661
+
662
+ // Prevent components:changed event emission when updateData is invoked via addItem
663
+ const updatedOpts = { ...opts, skipComponentsChangedEvent: true }
664
+
665
+ return this.updateData(tuples, updatedOpts)
666
+ } catch (error) {
667
+ throw new Error(`Failed to add item: ${error.message}`, { cause: error })
668
+ }
669
+ }
670
+
671
+ addMultipleItems(items, opts = {}) {
672
+ const tuples = []
673
+
674
+ try {
675
+ items.forEach(([type, data]) => {
676
+ validateParams.type(type)
677
+ validateParams.data(data, type)
678
+
679
+ const { value, ...schema } = data
680
+
681
+ tuples.push(
682
+ ['update', [type, data.key], value],
683
+ ['update', ['schema', type, data.key], schema]
684
+ )
685
+ })
686
+
687
+ this.updateData([...tuples, ...(opts.append || [])], {
688
+ message: `Created ${tuples.length} items`,
689
+ ...opts
690
+ })
691
+ return tuples
692
+ } catch (error) {
693
+ const state = this._stateManager?.root
694
+ const el = state.__element
695
+ el.call('openNotification', {
696
+ type: 'error',
697
+ title: 'Failed to add item',
698
+ message: error.message
699
+ })
700
+ throw new Error(`Failed to add item: ${error.message}`, { cause: error })
701
+ }
702
+ }
703
+
704
+ updateItem(type, data, opts = {}) {
705
+ try {
706
+ validateParams.type(type)
707
+ validateParams.data(data, type)
708
+
709
+ const { value, ...schema } = data
710
+ const tuples = [
711
+ ['update', [type, data.key], value],
712
+ ['update', ['schema', type, data.key], schema]
713
+ ]
714
+ return this.updateData(tuples, opts)
715
+ } catch (error) {
716
+ const state = this._stateManager?.root
717
+ const el = state.__element
718
+ el.call('openNotification', {
719
+ type: 'error',
720
+ title: 'Failed to update item',
721
+ message: error.message
722
+ })
723
+ throw new Error(`Failed to update item: ${error.message}`, {
724
+ cause: error
725
+ })
726
+ }
727
+ }
728
+
729
+ deleteItem(type, key, opts = {}) {
730
+ try {
731
+ validateParams.type(type)
732
+ validateParams.key(key, type)
733
+
734
+ const tuples = [
735
+ ['delete', [type, key]],
736
+ ['delete', ['schema', type, key]],
737
+ ...(opts.append || [])
738
+ ]
739
+ return this.updateData(tuples, {
740
+ message: `Deleted ${key} from ${type}`,
741
+ ...opts
742
+ })
743
+ } catch (error) {
744
+ const state = this._stateManager?.root
745
+ const el = state.__element
746
+ el.call('openNotification', {
747
+ type: 'error',
748
+ title: 'Failed to delete item',
749
+ message: error.message
750
+ })
751
+ throw new Error(`Failed to delete item: ${error.message}`, {
752
+ cause: error
753
+ })
754
+ }
755
+ }
756
+
757
+ /* ---------- socket event helpers ---------- */
758
+ /**
759
+ * Handle "clients" or "presence" events coming from the collab socket.
760
+ * The backend sends the full `clients` object which we directly patch to
761
+ * the root state, mimicking the legacy SocketService behaviour so that
762
+ * existing UI components keep working unmodified.
763
+ */
764
+ _handleClientsEvent(data = {}) {
765
+ const root = this._stateManager?.root
766
+ if (root && typeof root.replace === 'function') {
767
+ root.clients = data
768
+ }
769
+ rootBus.emit('clients:updated', data)
770
+ }
771
+
772
+ /* ---------- Dependency bundling events ---------- */
773
+ _handleBundleDoneEvent({
774
+ project,
775
+ ticket,
776
+ dependencies = {},
777
+ schema = {}
778
+ } = {}) {
779
+ console.info('[CollabService] Bundle done', { project, ticket })
780
+
781
+ // Update local state with latest dependency information
782
+ try {
783
+ this._ensureStateManager()
784
+
785
+ const { dependencies: schemaDependencies = {} } = schema || {}
786
+
787
+ const tuples = [
788
+ ['update', ['dependencies'], dependencies],
789
+ ['update', ['schema', 'dependencies'], schemaDependencies]
790
+ ]
791
+
792
+ this._stateManager.applyChanges(tuples, {
793
+ fromSocket: true,
794
+ preventFetchDeps: true,
795
+ preventUpdate: ['Iframe']
796
+ })
797
+ } catch (err) {
798
+ console.error('[CollabService] Failed to update deps after bundle', err)
799
+ }
800
+
801
+ // Notify UI via rootBus and toast/notification helper if available
802
+ const root = this._stateManager?.root
803
+ const el = root?.__element
804
+
805
+ if (el?.call) {
806
+ el.call('openNotification', {
807
+ type: 'success',
808
+ title: 'Dependencies ready',
809
+ message: `Project ${project} dependencies have been bundled successfully.`
810
+ })
811
+ }
812
+
813
+ rootBus.emit('bundle:done', { project, ticket })
814
+ }
815
+
816
+ _handleBundleErrorEvent({ project, ticket, error } = {}) {
817
+ console.error('[CollabService] Bundle error', { project, ticket, error })
818
+
819
+ const root = this._stateManager?.root
820
+ const el = root?.__element
821
+
822
+ if (el?.call) {
823
+ el.call('openNotification', {
824
+ type: 'error',
825
+ title: 'Dependency bundle failed',
826
+ message:
827
+ error ||
828
+ `An error occurred while bundling dependencies for project ${project}.`
829
+ })
830
+ }
831
+
832
+ rootBus.emit('bundle:error', { project, ticket, error })
833
+ }
834
+
835
+ /* ---------- Manual checkpoint ---------- */
836
+ _attachSocketLifecycleListeners() {
837
+ const socket = this._client?.socket
838
+ if (!socket) {
839
+ return
840
+ }
841
+
842
+ socket.on('connect', this._onSocketConnect)
843
+ socket.on('disconnect', this._onSocketDisconnect)
844
+ socket.on('connect_error', this._onSocketError)
845
+ }
846
+
847
+ _detachSocketLifecycleListeners() {
848
+ const socket = this._client?.socket
849
+ if (!socket) {
850
+ return
851
+ }
852
+
853
+ socket.off('connect', this._onSocketConnect)
854
+ socket.off('disconnect', this._onSocketDisconnect)
855
+ socket.off('connect_error', this._onSocketError)
856
+ }
857
+
858
+ _onSocketConnect() {
859
+ this._connected = true
860
+ }
861
+
862
+ _onSocketDisconnect(reason) {
863
+ this._connected = false
864
+
865
+ if (reason && reason !== 'io client disconnect') {
866
+ console.warn('[CollabService] Socket disconnected', reason)
867
+ }
868
+ }
869
+
870
+ _onSocketError(error) {
871
+ console.warn('[CollabService] Socket connection error', error)
872
+ }
873
+
874
+ /**
875
+ * Manually request a checkpoint / commit of buffered operations on the server.
876
+ * Resolves with the new version number once the backend confirms via the
877
+ * regular "commit" event.
878
+ */
879
+ checkpoint() {
880
+ if (!this.isConnected()) {
881
+ console.warn('[CollabService] Not connected, cannot request checkpoint')
882
+ return Promise.reject(new Error('Not connected'))
883
+ }
884
+
885
+ return new Promise((resolve) => {
886
+ const handler = ({ version }) => {
887
+ // Ensure we clean up the listener after the first commit event.
888
+ this.socket?.off('commit', handler)
889
+ rootBus.emit('checkpoint:done', { version, origin: 'manual' })
890
+ resolve(version)
891
+ }
892
+
893
+ // Listen for the next commit that the server will emit after checkpoint.
894
+ this.socket?.once('commit', handler)
895
+
896
+ // Trigger server-side checkpoint.
897
+ this.socket?.emit('checkpoint')
898
+ })
899
+ }
900
+ }