@symbo.ls/sdk 3.2.3 → 3.2.7

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 (183) hide show
  1. package/README.md +141 -0
  2. package/dist/cjs/config/environment.js +94 -10
  3. package/dist/cjs/index.js +152 -12
  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 +439 -116
  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 +60 -4
  23. package/dist/cjs/state/RootStateManager.js +2 -23
  24. package/dist/cjs/state/rootEventBus.js +9 -0
  25. package/dist/cjs/utils/CollabClient.js +78 -12
  26. package/dist/cjs/utils/TokenManager.js +16 -3
  27. package/dist/cjs/utils/changePreprocessor.js +199 -0
  28. package/dist/cjs/utils/jsonDiff.js +46 -4
  29. package/dist/cjs/utils/ordering.js +309 -0
  30. package/dist/cjs/utils/services.js +285 -128
  31. package/dist/cjs/utils/validation.js +0 -3
  32. package/dist/esm/config/environment.js +94 -10
  33. package/dist/esm/index.js +47862 -18248
  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 +24956 -16089
  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 +47373 -18027
  53. package/dist/esm/state/RootStateManager.js +11 -23
  54. package/dist/esm/state/rootEventBus.js +9 -0
  55. package/dist/esm/utils/CollabClient.js +17526 -16120
  56. package/dist/esm/utils/TokenManager.js +16 -3
  57. package/dist/esm/utils/changePreprocessor.js +542 -0
  58. package/dist/esm/utils/jsonDiff.js +958 -43
  59. package/dist/esm/utils/ordering.js +291 -0
  60. package/dist/esm/utils/services.js +285 -128
  61. package/dist/esm/utils/validation.js +116 -50
  62. package/dist/node/config/environment.js +94 -10
  63. package/dist/node/index.js +183 -16
  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 +439 -116
  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 +60 -4
  83. package/dist/node/state/RootStateManager.js +2 -23
  84. package/dist/node/state/rootEventBus.js +9 -0
  85. package/dist/node/utils/CollabClient.js +77 -11
  86. package/dist/node/utils/TokenManager.js +16 -3
  87. package/dist/node/utils/changePreprocessor.js +180 -0
  88. package/dist/node/utils/jsonDiff.js +46 -4
  89. package/dist/node/utils/ordering.js +290 -0
  90. package/dist/node/utils/services.js +285 -128
  91. package/dist/node/utils/validation.js +0 -3
  92. package/package.json +30 -18
  93. package/src/config/environment.js +95 -10
  94. package/src/index.js +190 -23
  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 +557 -148
  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 +79 -5
  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 +37 -32
  148. package/src/state/rootEventBus.js +19 -0
  149. package/src/utils/CollabClient.js +99 -12
  150. package/src/utils/TokenManager.js +20 -3
  151. package/src/utils/changePreprocessor.js +239 -0
  152. package/src/utils/jsonDiff.js +40 -5
  153. package/src/utils/ordering.js +271 -0
  154. package/src/utils/services.js +306 -139
  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 -2295
  159. package/dist/cjs/services/SocketService.js +0 -309
  160. package/dist/cjs/services/SymstoryService.js +0 -571
  161. package/dist/cjs/utils/basedQuerys.js +0 -181
  162. package/dist/cjs/utils/symstoryClient.js +0 -259
  163. package/dist/esm/services/AIService.js +0 -185
  164. package/dist/esm/services/BasedService.js +0 -5262
  165. package/dist/esm/services/CoreService.js +0 -2827
  166. package/dist/esm/services/SocketService.js +0 -456
  167. package/dist/esm/services/SymstoryService.js +0 -7025
  168. package/dist/esm/utils/basedQuerys.js +0 -163
  169. package/dist/esm/utils/symstoryClient.js +0 -354
  170. package/dist/node/services/AIService.js +0 -136
  171. package/dist/node/services/BasedService.js +0 -1156
  172. package/dist/node/services/CoreService.js +0 -2266
  173. package/dist/node/services/SocketService.js +0 -280
  174. package/dist/node/services/SymstoryService.js +0 -542
  175. package/dist/node/utils/basedQuerys.js +0 -162
  176. package/dist/node/utils/symstoryClient.js +0 -230
  177. package/src/services/AIService.js +0 -150
  178. package/src/services/BasedService.js +0 -1302
  179. package/src/services/CoreService.js +0 -2548
  180. package/src/services/SocketService.js +0 -336
  181. package/src/services/SymstoryService.js +0 -649
  182. package/src/utils/basedQuerys.js +0 -164
  183. package/src/utils/symstoryClient.js +0 -252
@@ -2,24 +2,72 @@ import { BaseService } from './BaseService.js'
2
2
  import { CollabClient } from '../utils/CollabClient.js'
3
3
  import { RootStateManager } from '../state/RootStateManager.js'
4
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
+ }
5
40
 
6
- // (helper conversions reserved for future features)
41
+ return clone
42
+ }
7
43
 
8
44
  export class CollabService extends BaseService {
9
- constructor (config) {
45
+ constructor(config) {
10
46
  super(config)
11
47
  this._client = null
12
48
  this._stateManager = null
13
49
  this._connected = false
50
+ this._connecting = false
51
+ this._connectPromise = null
52
+ this._connectionMeta = null
53
+ this._pendingConnectReject = null
14
54
  this._undoStack = []
15
55
  this._redoStack = []
16
56
  this._isUndoRedo = false
17
57
  // Store operations made while offline so they can be flushed once the
18
58
  // socket reconnects.
19
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)
20
64
  }
21
65
 
22
- init ({ context }) {
66
+ init({ context }) {
67
+ super.init({ context })
68
+ // console.log('CollabService init')
69
+ // console.log(context)
70
+
23
71
  // Defer state manager creation until a valid root state is present.
24
72
  // The root state may not be set yet when the SDK is first initialised
25
73
  // (e.g. inside initializeSDK()). We therefore create the manager lazily
@@ -41,7 +89,7 @@ export class CollabService extends BaseService {
41
89
  * Overridden to re-initialise the state manager once the root state becomes
42
90
  * available via a subsequent SDK `updateContext()` call.
43
91
  */
44
- updateContext (context = {}) {
92
+ updateContext(context = {}) {
45
93
  // Preserve base behaviour
46
94
  super.updateContext(context)
47
95
 
@@ -57,42 +105,113 @@ export class CollabService extends BaseService {
57
105
  * Throws an explicit error if the root state is still missing so that the
58
106
  * caller can react accordingly.
59
107
  */
60
- _ensureStateManager () {
108
+ _ensureStateManager() {
61
109
  if (!this._stateManager) {
62
110
  if (!this._context?.state) {
63
111
  throw new Error('[CollabService] Cannot operate without root state')
64
112
  }
65
113
  this._stateManager = new RootStateManager(this._context.state)
66
114
  }
67
- }
68
115
 
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()
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
74
120
 
75
- const {
76
- authToken: jwt,
77
- projectId,
78
- branch = 'main',
79
- pro
80
- } = {
81
- ...this._context,
82
- ...options
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
+ }
83
147
  }
84
- console.log(jwt, projectId, branch, pro)
148
+ }
85
149
 
86
- if (!projectId) {
87
- throw new Error('projectId is required for CollabService connection')
150
+ /* ---------- Connection Management ---------- */
151
+ async connect(options = {}) {
152
+ if (this._connectPromise) {
153
+ return this._connectPromise
88
154
  }
89
155
 
90
- // Disconnect existing connection if any
91
- if (this._client) {
92
- await this.disconnect()
93
- }
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
+ }
94
214
 
95
- try {
96
215
  this._client = new CollabClient({
97
216
  jwt,
98
217
  projectId,
@@ -103,86 +222,202 @@ export class CollabService extends BaseService {
103
222
  // Mark as connected once the socket establishes a connection. This prevents
104
223
  // the SDK from being stuck waiting for an initial snapshot that may never
105
224
  // 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
- })
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
+ }
113
290
 
114
- console.log('[CollabService] socket connected')
291
+ this._attachSocketLifecycleListeners()
292
+ if (socket?.connected) {
293
+ this._onSocketConnect()
294
+ }
115
295
 
116
296
  // Set up event listeners
117
- this._client.socket?.on('ops', ({ changes }) => {
297
+ socket?.on('ops', ({ changes }) => {
118
298
  console.log(`ops event`)
119
- console.log(changes)
120
299
  this._stateManager.applyChanges(changes, { fromSocket: true })
121
300
  })
122
301
 
123
- this._client.socket?.on('commit', ({ version }) => {
124
- this._stateManager.setVersion(version)
302
+ socket?.on('commit', ({ version }) => {
303
+ if (version) {
304
+ this._stateManager.setVersion(version)
305
+ }
306
+
125
307
  // Inform UI about automatic commit
126
308
  rootBus.emit('checkpoint:done', { version, origin: 'auto' })
127
309
  })
128
310
 
129
311
  // 🔄 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))
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))
133
317
 
134
318
  // Flush any operations that were queued while we were offline.
135
319
  if (this._pendingOps.length) {
136
320
  console.log(
137
321
  `[CollabService] Flushing ${this._pendingOps.length} offline operation batch(es)`
138
322
  )
139
- this._pendingOps.forEach(({ tuples }) => {
140
- this.socket.emit('ops', { changes: tuples, ts: Date.now() })
141
- })
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
+ )
142
339
  this._pendingOps.length = 0
143
340
  }
144
341
 
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
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
150
358
  }
151
359
  }
152
360
 
153
- disconnect () {
361
+ disconnect() {
154
362
  if (this._client?.socket) {
155
- this._client.socket.disconnect()
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
+ }
156
375
  }
157
376
  this._client = null
158
377
  this._connected = false
378
+ this._connecting = false
379
+ this._connectionMeta = null
380
+ this._pendingConnectReject = null
159
381
  console.log('[CollabService] Disconnected')
160
382
  }
161
383
 
162
- isConnected () {
163
- return this._connected && this._client?.socket?.connected
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
+ }
164
399
  }
165
400
 
166
401
  /* convenient shortcuts */
167
- get ydoc () {
402
+ get ydoc() {
168
403
  return this._client?.ydoc
169
404
  }
170
- get socket () {
405
+ get socket() {
171
406
  return this._client?.socket
172
407
  }
173
408
 
174
- toggleLive (f) {
409
+ toggleLive(f) {
175
410
  this._client?.toggleLive(f)
176
411
  }
177
- sendCursor (d) {
412
+ sendCursor(d) {
178
413
  this._client?.sendCursor(d)
179
414
  }
180
- sendPresence (d) {
415
+ sendPresence(d) {
181
416
  this._client?.sendPresence(d)
182
417
  }
183
418
 
184
419
  /* ---------- data helpers ---------- */
185
- updateData (tuples, options = {}) {
420
+ updateData(tuples, options = {}) {
186
421
  // Always ensure we have a state manager so local changes are applied.
187
422
  this._ensureStateManager()
188
423
 
@@ -193,27 +428,73 @@ export class CollabService extends BaseService {
193
428
  this._trackForUndo(tuples, options)
194
429
  }
195
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
+
196
444
  // Apply changes to local state tree immediately.
197
445
  this._stateManager.applyChanges(tuples, { ...options })
198
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
+
199
456
  // If not connected yet, queue the operations for later synchronisation.
200
457
  if (!this.isConnected()) {
201
458
  console.warn('[CollabService] Not connected, queuing real-time update')
202
- this._pendingOps.push({ tuples, options })
459
+ this._pendingOps.push({
460
+ changes: stringifiedTuples,
461
+ granularChanges: stringifiedGranularTuples,
462
+ orders,
463
+ options
464
+ })
203
465
  return
204
466
  }
205
467
 
206
468
  // When connected, send the operations to the backend.
207
469
  if (this.socket?.connected) {
208
- this.socket.emit('ops', { changes: tuples, ts: Date.now() })
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)
209
490
  }
210
491
 
211
492
  return { success: true }
212
493
  }
213
494
 
214
- _trackForUndo (tuples, options) {
495
+ _trackForUndo(tuples, options) {
215
496
  // Get current state before changes for undo
216
- const undoOperations = tuples.map(tuple => {
497
+ const undoOperations = tuples.map((tuple) => {
217
498
  const [action, path] = tuple
218
499
  const currentValue = this._getValueAtPath(path)
219
500
 
@@ -244,7 +525,7 @@ export class CollabService extends BaseService {
244
525
  }
245
526
  }
246
527
 
247
- _getValueAtPath (path) {
528
+ _getValueAtPath(path) {
248
529
  // Get value from root state at given path
249
530
  const state = this._stateManager?.root
250
531
  if (!state || !state.getByPath) {
@@ -259,8 +540,14 @@ export class CollabService extends BaseService {
259
540
  }
260
541
  }
261
542
 
262
- undo () {
543
+ undo() {
263
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
+ })
264
551
  throw new Error('Nothing to undo')
265
552
  }
266
553
 
@@ -295,8 +582,14 @@ export class CollabService extends BaseService {
295
582
  return operations
296
583
  }
297
584
 
298
- redo () {
585
+ redo() {
299
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
+ })
300
593
  throw new Error('Nothing to redo')
301
594
  }
302
595
 
@@ -332,74 +625,133 @@ export class CollabService extends BaseService {
332
625
  }
333
626
 
334
627
  /* ---------- Undo/Redo State ---------- */
335
- canUndo () {
628
+ canUndo() {
336
629
  return this._undoStack.length > 0
337
630
  }
338
631
 
339
- canRedo () {
632
+ canRedo() {
340
633
  return this._redoStack.length > 0
341
634
  }
342
635
 
343
- getUndoStackSize () {
636
+ getUndoStackSize() {
344
637
  return this._undoStack.length
345
638
  }
346
639
 
347
- getRedoStackSize () {
640
+ getRedoStackSize() {
348
641
  return this._redoStack.length
349
642
  }
350
643
 
351
- clearUndoHistory () {
644
+ clearUndoHistory() {
352
645
  this._undoStack.length = 0
353
646
  this._redoStack.length = 0
354
647
  }
355
648
 
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
- }
649
+ addItem(type, data, opts = {}) {
650
+ try {
651
+ validateParams.type(type)
652
+ validateParams.data(data, type)
365
653
 
366
- addMultipleItems (items, opts = {}) {
367
- const tuples = []
368
- items.forEach(([type, data]) => {
369
654
  const { value, ...schema } = data
370
- tuples.push(
655
+
656
+ // Base tuple for the actual value update
657
+ const tuples = [
371
658
  ['update', [type, data.key], value],
372
- ['update', ['schema', type, data.key], schema]
373
- )
374
- })
659
+ ['update', ['schema', type, data.key], schema || {}]
660
+ ]
375
661
 
376
- this.updateData([...tuples, ...(opts.additionalChanges || [])], {
377
- message: `Created ${tuples.length} items`,
378
- ...opts
379
- })
662
+ // Prevent components:changed event emission when updateData is invoked via addItem
663
+ const updatedOpts = { ...opts, skipComponentsChangedEvent: true }
380
664
 
381
- return tuples
665
+ return this.updateData(tuples, updatedOpts)
666
+ } catch (error) {
667
+ throw new Error(`Failed to add item: ${error.message}`, { cause: error })
668
+ }
382
669
  }
383
670
 
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)
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
+ }
391
702
  }
392
703
 
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
- })
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
+ }
403
755
  }
404
756
 
405
757
  /* ---------- socket event helpers ---------- */
@@ -409,71 +761,128 @@ export class CollabService extends BaseService {
409
761
  * the root state, mimicking the legacy SocketService behaviour so that
410
762
  * existing UI components keep working unmodified.
411
763
  */
412
- _handleClientsEvent (data = {}) {
764
+ _handleClientsEvent(data = {}) {
413
765
  const root = this._stateManager?.root
414
766
  if (root && typeof root.replace === 'function') {
415
- root.replace(
416
- { clients: data },
417
- {
418
- fromSocket: true,
419
- preventUpdate: true
420
- }
421
- )
767
+ root.clients = data
422
768
  }
769
+ rootBus.emit('clients:updated', data)
423
770
  }
424
771
 
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
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)
434
799
  }
435
800
 
436
- const tuples = []
801
+ // Notify UI via rootBus and toast/notification helper if available
802
+ const root = this._stateManager?.root
803
+ const el = root?.__element
437
804
 
438
- if (positions) {
439
- tuples.push([
440
- 'update',
441
- ['canvas', 'clients', userId, 'positions'],
442
- positions
443
- ])
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
+ })
444
811
  }
445
812
 
446
- if (chosenPos) {
447
- tuples.push([
448
- 'update',
449
- ['canvas', 'clients', userId, 'chosenPos'],
450
- chosenPos
451
- ])
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
+ })
452
830
  }
453
831
 
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])
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
457
840
  }
458
841
 
459
- if (tuples.length) {
460
- this._stateManager.applyChanges(tuples, { fromSocket: true })
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
461
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)
462
872
  }
463
873
 
464
- /* ---------- Manual checkpoint ---------- */
465
874
  /**
466
875
  * Manually request a checkpoint / commit of buffered operations on the server.
467
876
  * Resolves with the new version number once the backend confirms via the
468
877
  * regular "commit" event.
469
878
  */
470
- checkpoint () {
879
+ checkpoint() {
471
880
  if (!this.isConnected()) {
472
881
  console.warn('[CollabService] Not connected, cannot request checkpoint')
473
882
  return Promise.reject(new Error('Not connected'))
474
883
  }
475
884
 
476
- return new Promise(resolve => {
885
+ return new Promise((resolve) => {
477
886
  const handler = ({ version }) => {
478
887
  // Ensure we clean up the listener after the first commit event.
479
888
  this.socket?.off('commit', handler)