@cuboapp/crdt 1.0.13 → 1.0.14

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.
package/package.json CHANGED
@@ -1,30 +1,35 @@
1
1
  {
2
2
  "name": "@cuboapp/crdt",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "CRDT for CUBO",
5
5
  "main": "src/index.ts",
6
- "repository": "git@github.com:cuboapp/crdt.git",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git@cuboapp.gitlab.yandexcloud.net:cubo/crdt.git"
9
+ },
7
10
  "author": "CuboSoft",
8
11
  "license": "MIT",
9
12
  "type": "module",
10
13
  "publishConfig": {
11
14
  "access": "public"
12
15
  },
16
+ "files": [
17
+ "src"
18
+ ],
13
19
  "dependencies": {
14
- "@cuboapp/types": "^2.0.13",
15
- "@cuboapp/utils": "1.0.10",
16
- "@cuboapp/ws": "1.0.7",
17
- "ws": "^8.19.0",
20
+ "@cuboapp/types": "^2.0.14",
21
+ "@cuboapp/utils": "1.0.12",
22
+ "@cuboapp/ws": "1.0.9",
18
23
  "y-protocols": "^1.0.7",
19
- "yjs": "^13.6.30"
24
+ "yjs": "^13.6.31"
20
25
  },
21
26
  "peerDependencies": {
22
- "vue": "^3.5.30",
23
- "vue-router": "^5.0.2"
27
+ "vue": "^3.5.38",
28
+ "vue-router": "^5.1.0"
24
29
  },
25
30
  "devDependencies": {
26
- "@types/node": "^24.10.1",
31
+ "@types/node": "^26.0.0",
27
32
  "@types/ws": "^8.18.1",
28
- "typescript": "^5.9.3"
33
+ "typescript": "^6.0.3"
29
34
  }
30
35
  }
@@ -1,4 +1,5 @@
1
1
  import { keyBy, pick, uuid } from '@cuboapp/utils'
2
+ import { WsClientEvent } from '@cuboapp/ws'
2
3
  import { computed, reactive } from 'vue'
3
4
  import { applyAwarenessUpdate, Awareness, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'
4
5
  import { applyUpdate, Doc } from 'yjs'
@@ -25,6 +26,12 @@ export class CuboCrdtClient<M> {
25
26
  public store: CuboCrdtClientStore<M> = {}
26
27
 
27
28
  private listeners: Map<string, Map<string, (ctx: CuboCrdtClientSubscribeEvent) => void | Promise<void>>> = new Map()
29
+
30
+ // реестр активных подписок — нужен, чтобы переподписаться после реконнекта
31
+ private subscriptions = new Map<string, { entity: string; filters?: Record<string, any>; awareness?: boolean }>()
32
+ private connectedOnce = false
33
+ private onReconnect = () => this.resubscribeAll()
34
+
28
35
  private eventQueue = new AsyncSerialQueue({
29
36
  errorMode: 'continue',
30
37
  onError: (e) => console.error('[CRDT] inbound queue task failed', e),
@@ -36,6 +43,13 @@ export class CuboCrdtClient<M> {
36
43
  }
37
44
 
38
45
  async start() {
46
+ // если сокет уже подключён к моменту старта — считаем текущий коннект «первым»,
47
+ // чтобы первое же событие CONNECTED (после обрыва) сработало как переподписка
48
+ this.connectedOnce = this.ws.connected
49
+
50
+ // переподписываемся на все документы при (ре)коннекте сокета
51
+ this.ws.ee.on(WsClientEvent.CONNECTED, this.onReconnect)
52
+
39
53
  this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, ({ message }) => {
40
54
  this.eventQueue
41
55
  .enqueue(async () => {
@@ -68,9 +82,45 @@ export class CuboCrdtClient<M> {
68
82
  }
69
83
 
70
84
  async stop() {
85
+ this.ws.ee.off(WsClientEvent.CONNECTED, this.onReconnect)
86
+
71
87
  this.ws.deleteHandler(CUBO_CRDT_EVENT.EVENT)
72
88
 
73
89
  this.listeners.clear()
90
+ this.subscriptions.clear()
91
+ this.connectedOnce = false
92
+ }
93
+
94
+ /**
95
+ * Переподписка на все активные подписки после реконнекта.
96
+ *
97
+ * При обрыве сокета бэкенд удаляет клиента вместе со всеми его подписками
98
+ * (removeClient -> cleanSubscribe), а после реконнекта сокету выдаётся новый id.
99
+ * Локальные слушатели (this.listeners) и yjs-документы при этом сохраняются,
100
+ * поэтому достаточно заново отправить SUBSCRIBE по реестру — заново
101
+ * навешивать слушатели не нужно.
102
+ */
103
+ private resubscribeAll() {
104
+ // первый коннект — useList() уже сам отправил SUBSCRIBE, дублировать не нужно
105
+ if (!this.connectedOnce) {
106
+ this.connectedOnce = true
107
+ return
108
+ }
109
+
110
+ if (this.debug) {
111
+ console.log('[CRDT] reconnected, resubscribing', { count: this.subscriptions.size })
112
+ }
113
+
114
+ for (const [subscribe_id, sub] of this.subscriptions) {
115
+ try {
116
+ this.ws.request({
117
+ method: CUBO_CRDT_EVENT.SUBSCRIBE,
118
+ data: { subscribe_id, entity: sub.entity, filters: sub.filters, awareness: sub.awareness }
119
+ })
120
+ } catch (e) {
121
+ console.error('[CRDT] resubscribe failed', subscribe_id, e)
122
+ }
123
+ }
74
124
  }
75
125
 
76
126
  public useComputedList<K extends Extract<keyof M, string>>(entity: K) {
@@ -138,8 +188,17 @@ export class CuboCrdtClient<M> {
138
188
  })
139
189
  })
140
190
 
141
- // подписываемся на бэке
142
- this.ws.request({ method: CUBO_CRDT_EVENT.SUBSCRIBE, data: { subscribe_id, entity, filters, awareness: opts?.awareness } })
191
+ // регистрируем подписку, чтобы переподписаться после реконнекта
192
+ this.subscriptions.set(subscribe_id, { entity: `${entity}`, filters, awareness: opts?.awareness })
193
+
194
+ // подписываемся на бэке (если сокет ещё не подключён — переподпишемся по событию CONNECTED)
195
+ try {
196
+ this.ws.request({ method: CUBO_CRDT_EVENT.SUBSCRIBE, data: { subscribe_id, entity, filters, awareness: opts?.awareness } })
197
+ } catch (e) {
198
+ if (this.debug) {
199
+ console.warn('[CRDT] subscribe deferred until reconnect', subscribe_id, e)
200
+ }
201
+ }
143
202
  }
144
203
 
145
204
  // отписка
@@ -148,8 +207,17 @@ export class CuboCrdtClient<M> {
148
207
  console.log('[CRDT] unsubscribe', { entity, opts, filters })
149
208
  }
150
209
 
210
+ // снимаем подписку из реестра переподписки
211
+ this.subscriptions.delete(subscribe_id)
212
+
151
213
  // отписываемся на бэке
152
- this.ws.request({ method: CUBO_CRDT_EVENT.UNSUBSCRIBE, data: { subscribe_id } })
214
+ try {
215
+ this.ws.request({ method: CUBO_CRDT_EVENT.UNSUBSCRIBE, data: { subscribe_id } })
216
+ } catch (e) {
217
+ if (this.debug) {
218
+ console.warn('[CRDT] unsubscribe request failed (socket down)', subscribe_id, e)
219
+ }
220
+ }
153
221
 
154
222
  // отписываемся на фронте
155
223
  this.listeners.get(entity)?.delete(subscribe_id)
@@ -281,17 +349,48 @@ export class CuboCrdtClient<M> {
281
349
  }
282
350
 
283
351
  public upgrade(subscribe_id: string, filters?: Record<string, any>) {
352
+ // держим реестр в актуальном состоянии, чтобы переподписка после реконнекта
353
+ // использовала уже обновлённые фильтры
354
+ const sub = this.subscriptions.get(subscribe_id)
355
+ if (sub) {
356
+ sub.filters = filters
357
+ }
358
+
284
359
  this.ws.request({ method: CUBO_CRDT_EVENT.UPGRADE, data: { subscribe_id, filters } })
285
360
  }
286
361
 
287
- private async onDocumentCreate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
288
- let doc = this.store[opts.storeKey]?.docs.get(ctx.entity_id)
289
- if (doc) {
290
- console.warn('[CRDT] onDocumentCreate: doc already exists', { ctx, opts })
362
+ // апсертит строку в реактивное хранилище из текущего состояния yjs-документа
363
+ private applyRowJson(storeKey: string, entity_id: number, doc: Doc) {
364
+ const json: any = doc.getMap().toJSON()
365
+ const rows = this.store[storeKey]?.state.rows
366
+ if (!rows) {
291
367
  return
368
+ }
369
+
370
+ const index = rows.findIndex((r: any) => r.id === entity_id)
371
+ if (index >= 0) {
372
+ rows[index] = json
292
373
  } else {
293
- doc = new Doc()
374
+ rows.push(json)
294
375
  }
376
+ }
377
+
378
+ private async onDocumentCreate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
379
+ const existing = this.store[opts.storeKey]?.docs.get(ctx.entity_id)
380
+ if (existing) {
381
+ // документ уже есть локально (типичный кейс — повторный SUBSCRIBE после реконнекта).
382
+ // вместо игнорирования вмёрживаем входящее состояние в существующий yjs-документ
383
+ // (CRDT-merge), сохраняя локальные слушатели и awareness.
384
+ if (this.debug) {
385
+ console.log('[CRDT] onDocumentCreate: doc already exists, merging state', { ctx, opts })
386
+ }
387
+
388
+ applyUpdate(existing, new Uint8Array(ctx.data as any), { react: false })
389
+ this.applyRowJson(opts.storeKey, ctx.entity_id, existing)
390
+ return
391
+ }
392
+
393
+ const doc = new Doc()
295
394
 
296
395
  const update = new Uint8Array(ctx.data as any)
297
396
 
@@ -399,17 +498,7 @@ export class CuboCrdtClient<M> {
399
498
  applyUpdate(doc, update, { react: false })
400
499
 
401
500
  // обновляем документ в реактивном хранилище
402
- const json: any = doc.getMap().toJSON()
403
- const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === ctx.entity_id)
404
- if (index !== undefined && index >= 0) {
405
- this.store[opts.storeKey]!.state.rows[index] = json
406
- } else {
407
- if (this.debug) {
408
- console.warn('[CRDT] onDocumentUpdate - row not exists: "' + ctx.entity_id + '"', { ctx, opts })
409
- }
410
-
411
- this.store[opts.storeKey]!.state.rows.push(json)
412
- }
501
+ this.applyRowJson(opts.storeKey, ctx.entity_id, doc)
413
502
 
414
503
  if (opts?.onAfterUpdate) {
415
504
  await opts.onAfterUpdate(doc, update, ctx, opts)
@@ -60,7 +60,7 @@ export class CuboCrdtServerDocument {
60
60
  const row = this.getJson()
61
61
  const body = pick(row, origin.keys ?? Object.keys(row))
62
62
 
63
- if (Object.keys(body)) {
63
+ if (Object.keys(body).length) {
64
64
  this.debounceStore(body, origin)
65
65
  }
66
66
  }
@@ -169,6 +169,10 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
169
169
  // удаляем мапу подписок по клиенту
170
170
  this.subscribesByClient.delete(client.id)
171
171
 
172
+ // снимаем запланированный флаш очереди (чтобы не слать в закрытый сокет)
173
+ clearTimeout(this.sendTimeouts.get(client.id))
174
+ this.sendTimeouts.delete(client.id)
175
+
172
176
  // удаляем очередь под клиента
173
177
  this.queue.delete(client.id)
174
178
 
@@ -176,14 +180,26 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
176
180
  this.clients.delete(client.id)
177
181
  }
178
182
 
183
+ private get batchMaxSize() {
184
+ return this.options.batch?.maxSize ?? 50
185
+ }
186
+
187
+ private get batchDebounce() {
188
+ return this.options.batch?.debounce ?? 100
189
+ }
190
+
179
191
  private sendQueueToClient(client: CuboCrdtSocketClient<A>) {
192
+ // снимаем запланированный флаш — мы отправляем прямо сейчас
193
+ clearTimeout(this.sendTimeouts.get(client.id))
194
+ this.sendTimeouts.delete(client.id)
195
+
180
196
  const queue = this.queue.get(client.id)
181
- if (!queue) {
197
+ if (!queue || queue.size === 0) {
182
198
  return
183
199
  }
184
200
 
185
- // const data = Array.from(queue)
186
- const data = [...new Map((Array.from(queue) || []).map((item) => [JSON.stringify(item), item])).values()]
201
+ // дедупликация одинаковых событий внутри батча
202
+ const data = [...new Map(Array.from(queue).map((item) => [JSON.stringify(item), item])).values()]
187
203
 
188
204
  queue.clear()
189
205
 
@@ -193,8 +209,6 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
193
209
  data
194
210
  })
195
211
  )
196
-
197
- queue.clear()
198
212
  }
199
213
 
200
214
  public sendToClient(client_id: string, data: any) {
@@ -203,21 +217,26 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
203
217
  return
204
218
  }
205
219
 
206
- // сбрасываем таймаут если он есть
207
- clearTimeout(this.sendTimeouts.get(client_id))
220
+ const queue = this.queue.get(client_id)
221
+ if (!queue) {
222
+ return
223
+ }
208
224
 
209
225
  // добавляем в очередь
210
- this.queue.get(client_id).add(data)
226
+ queue.add(data)
211
227
 
212
- // если очередь >10 то сразу отправляем
213
- if (this.queue.size > 10) {
228
+ // если у ЭТОГО клиента в очереди накопилось много событий — отправляем сразу
229
+ if (queue.size >= this.batchMaxSize) {
214
230
  this.sendQueueToClient(client)
231
+ return
215
232
  }
216
- // если нет - ставим таймаут
217
- else {
233
+
234
+ // иначе — дебаунсим отправку, но НЕ сбрасываем уже запланированный флаш,
235
+ // иначе при непрерывном потоке апдейтов очередь никогда не отправится
236
+ if (!this.sendTimeouts.has(client_id)) {
218
237
  this.sendTimeouts.set(
219
238
  client_id,
220
- setTimeout(() => this.sendQueueToClient(client), 100)
239
+ setTimeout(() => this.sendQueueToClient(client), this.batchDebounce)
221
240
  )
222
241
  }
223
242
  }
@@ -287,18 +306,19 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
287
306
  }
288
307
 
289
308
  public getSutableSubscribes(entity: string, row: any) {
290
- return Array.from(this.subscribesByEntity.get(entity as E) || [])
291
- .filter((m) => {
292
- const subscribe = this.subscribes.get(m[1])
309
+ return Array.from(this.subscribesByEntity.get(entity as E) || []).filter((subscribe_id) => {
310
+ const subscribe = this.subscribes.get(subscribe_id)
311
+ if (!subscribe) {
312
+ return false
313
+ }
293
314
 
294
- let state = subscribe && checkRowIsSutable(row, subscribe)
295
- if (this.options.checkRowIsSutable) {
296
- state = this.options.checkRowIsSutable(state, { entity: entity as any, subscribe, row })
297
- }
315
+ let state = checkRowIsSutable(row, subscribe.filters || {})
316
+ if (this.options.checkRowIsSutable) {
317
+ state = this.options.checkRowIsSutable(state, { entity: entity as any, subscribe, row })
318
+ }
298
319
 
299
- return state
300
- })
301
- .map((m) => m[1])
320
+ return state
321
+ })
302
322
  }
303
323
 
304
324
  public ensureDocument(entity: E, row: any, awareness?: boolean) {
@@ -445,7 +465,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
445
465
  const row = document.getJson()
446
466
 
447
467
  // берём все подписки по сущности
448
- const sutableSubscribes = Array.from(this.subscribesByEntity.get(entity))
468
+ const sutableSubscribes = Array.from(this.subscribesByEntity.get(entity) || [])
449
469
  .map((subscribe_id) => {
450
470
  const subscribe = this.subscribes.get(subscribe_id)
451
471
 
@@ -10,6 +10,15 @@ export type CuboCrdtServerOptions<M, A, E extends Extract<keyof M, string>> = {
10
10
  ws: WsServer
11
11
  entities: E[]
12
12
  debug?: boolean
13
+ /**
14
+ * Параметры батчинга исходящих событий (защита от перегрузки бэка).
15
+ * - maxSize: при достижении такого размера очереди клиента батч отправляется сразу
16
+ * - debounce: задержка перед отправкой неполного батча (мс)
17
+ */
18
+ batch?: {
19
+ maxSize?: number
20
+ debounce?: number
21
+ }
13
22
  fetchRows?: (client: WsServerSocket, subscribe: CuboCrdtServerSubscribe) => Promise<M[E][]>
14
23
  checkRowIsSutable?: (baseState: boolean, ctx: { entity: E; subscribe: CuboCrdtServerSubscribe; row: any }) => boolean
15
24
  storeRow?: <K extends E>(
package/.prettierrc DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "trailingComma": "none",
3
- "tabWidth": 2,
4
- "semi": false,
5
- "singleQuote": true,
6
- "eslintIntegration": true,
7
- "printWidth": 140
8
- }
@@ -1,4 +0,0 @@
1
- {
2
- "extends": "./tsconfig.json",
3
- "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4
- }
package/tsconfig.json DELETED
@@ -1,22 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "module": "commonjs",
4
- "declaration": true,
5
- "removeComments": true,
6
- "emitDecoratorMetadata": true,
7
- "experimentalDecorators": true,
8
- "allowSyntheticDefaultImports": true,
9
- "target": "ES2021",
10
- "sourceMap": true,
11
- "outDir": "./dist",
12
- "strict": false,
13
- "rootDir": "./",
14
- "incremental": true,
15
- "skipLibCheck": true,
16
- "paths": {
17
- "@/*": ["./src/*"]
18
- }
19
- },
20
- "include": ["src/**/*"],
21
- "exclude": ["node_modules", "dist"]
22
- }