@cuboapp/crdt 1.0.12 → 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.12",
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,12 +1,14 @@
1
1
  import { keyBy, pick, uuid } from '@cuboapp/utils'
2
+ import { WsClientEvent } from '@cuboapp/ws'
2
3
  import { computed, reactive } from 'vue'
3
- import { applyUpdate, Doc } from 'yjs'
4
4
  import { applyAwarenessUpdate, Awareness, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'
5
+ import { applyUpdate, Doc } from 'yjs'
5
6
 
6
7
  import { CUBO_CRDT_EVENT } from '../constants'
7
8
  import { CuboCrdtServerDocumentIncomingAction } from '../server'
8
9
  import { CuboCrdtKey } from '../types'
9
10
 
11
+ import { AsyncSerialQueue } from './queue'
10
12
  import {
11
13
  CuboCrdtClientDocOrigin,
12
14
  CuboCrdtClientDocUpdateOptions,
@@ -17,7 +19,6 @@ import {
17
19
  CuboCrdtClientSubscribeEvent,
18
20
  CuboCrdtClientUseOptions
19
21
  } from './types'
20
- import { AsyncSerialQueue } from './queue'
21
22
 
22
23
  export * from './types'
23
24
 
@@ -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,12 +207,23 @@ 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)
156
224
 
225
+ this.store[storeKey]!.state.subscribed = false
226
+
157
227
  // очищаем стор
158
228
  if (clear !== false) {
159
229
  const item = this.store[storeKey]
@@ -279,18 +349,49 @@ export class CuboCrdtClient<M> {
279
349
  }
280
350
 
281
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
+
282
359
  this.ws.request({ method: CUBO_CRDT_EVENT.UPGRADE, data: { subscribe_id, filters } })
283
360
  }
284
361
 
285
- private async onDocumentCreate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
286
- let doc = this.store[opts.storeKey]?.docs.get(ctx.entity_id)
287
- if (doc) {
288
- 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) {
289
367
  return
368
+ }
369
+
370
+ const index = rows.findIndex((r: any) => r.id === entity_id)
371
+ if (index >= 0) {
372
+ rows[index] = json
290
373
  } else {
291
- doc = new Doc()
374
+ rows.push(json)
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
292
391
  }
293
392
 
393
+ const doc = new Doc()
394
+
294
395
  const update = new Uint8Array(ctx.data as any)
295
396
 
296
397
  if (opts?.onBeforeCreate) {
@@ -351,7 +452,8 @@ export class CuboCrdtClient<M> {
351
452
  awareness = new Awareness(doc)
352
453
 
353
454
  awareness.on('update', ({ added, updated, removed }, origin) => {
354
- console.log('[CRDT] awarness update', origin)
455
+ // console.log('[CRDT] awarness update', origin)
456
+
355
457
  if (origin === 'remote') {
356
458
  return
357
459
  }
@@ -396,17 +498,7 @@ export class CuboCrdtClient<M> {
396
498
  applyUpdate(doc, update, { react: false })
397
499
 
398
500
  // обновляем документ в реактивном хранилище
399
- const json: any = doc.getMap().toJSON()
400
- const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === ctx.entity_id)
401
- if (index !== undefined && index >= 0) {
402
- this.store[opts.storeKey]!.state.rows[index] = json
403
- } else {
404
- if (this.debug) {
405
- console.warn('[CRDT] onDocumentUpdate - row not exists: "' + ctx.entity_id + '"', { ctx, opts })
406
- }
407
-
408
- this.store[opts.storeKey]!.state.rows.push(json)
409
- }
501
+ this.applyRowJson(opts.storeKey, ctx.entity_id, doc)
410
502
 
411
503
  if (opts?.onAfterUpdate) {
412
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
  }
@@ -1,5 +1,6 @@
1
1
  import { cloneDeep } from '@cuboapp/utils'
2
2
  import { WsServerSocket } from '@cuboapp/ws'
3
+ import { applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'
3
4
 
4
5
  import { CUBO_CRDT_EVENT } from '../constants'
5
6
  import { CuboCrdtAction } from '../types'
@@ -16,7 +17,6 @@ import {
16
17
  CuboCrdtServerUnsubscribeDto,
17
18
  CuboCrdtSocketClient
18
19
  } from './types'
19
- import { applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'
20
20
 
21
21
  export * from './document'
22
22
  export * from './types'
@@ -99,7 +99,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
99
99
  console.log('[CRDT] incoming event', data)
100
100
  }
101
101
 
102
- data.forEach((row: any) => {
102
+ for (const row of data) {
103
103
  const update = new Uint8Array(row.data)
104
104
 
105
105
  switch (row.action) {
@@ -114,10 +114,9 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
114
114
  break
115
115
  case 'awareness':
116
116
  this.onAwarenessExternalUpdate(update, row)
117
-
118
117
  break
119
118
  }
120
- })
119
+ }
121
120
  })
122
121
 
123
122
  this.ws.registerHttpHandler('GET', '/stats', () => {
@@ -170,6 +169,10 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
170
169
  // удаляем мапу подписок по клиенту
171
170
  this.subscribesByClient.delete(client.id)
172
171
 
172
+ // снимаем запланированный флаш очереди (чтобы не слать в закрытый сокет)
173
+ clearTimeout(this.sendTimeouts.get(client.id))
174
+ this.sendTimeouts.delete(client.id)
175
+
173
176
  // удаляем очередь под клиента
174
177
  this.queue.delete(client.id)
175
178
 
@@ -177,14 +180,26 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
177
180
  this.clients.delete(client.id)
178
181
  }
179
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
+
180
191
  private sendQueueToClient(client: CuboCrdtSocketClient<A>) {
192
+ // снимаем запланированный флаш — мы отправляем прямо сейчас
193
+ clearTimeout(this.sendTimeouts.get(client.id))
194
+ this.sendTimeouts.delete(client.id)
195
+
181
196
  const queue = this.queue.get(client.id)
182
- if (!queue) {
197
+ if (!queue || queue.size === 0) {
183
198
  return
184
199
  }
185
200
 
186
- // const data = Array.from(queue)
187
- 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()]
188
203
 
189
204
  queue.clear()
190
205
 
@@ -194,8 +209,6 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
194
209
  data
195
210
  })
196
211
  )
197
-
198
- queue.clear()
199
212
  }
200
213
 
201
214
  public sendToClient(client_id: string, data: any) {
@@ -204,21 +217,26 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
204
217
  return
205
218
  }
206
219
 
207
- // сбрасываем таймаут если он есть
208
- clearTimeout(this.sendTimeouts.get(client_id))
220
+ const queue = this.queue.get(client_id)
221
+ if (!queue) {
222
+ return
223
+ }
209
224
 
210
225
  // добавляем в очередь
211
- this.queue.get(client_id).add(data)
226
+ queue.add(data)
212
227
 
213
- // если очередь >10 то сразу отправляем
214
- if (this.queue.size > 10) {
228
+ // если у ЭТОГО клиента в очереди накопилось много событий — отправляем сразу
229
+ if (queue.size >= this.batchMaxSize) {
215
230
  this.sendQueueToClient(client)
231
+ return
216
232
  }
217
- // если нет - ставим таймаут
218
- else {
233
+
234
+ // иначе — дебаунсим отправку, но НЕ сбрасываем уже запланированный флаш,
235
+ // иначе при непрерывном потоке апдейтов очередь никогда не отправится
236
+ if (!this.sendTimeouts.has(client_id)) {
219
237
  this.sendTimeouts.set(
220
238
  client_id,
221
- setTimeout(() => this.sendQueueToClient(client), 100)
239
+ setTimeout(() => this.sendQueueToClient(client), this.batchDebounce)
222
240
  )
223
241
  }
224
242
  }
@@ -288,18 +306,19 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
288
306
  }
289
307
 
290
308
  public getSutableSubscribes(entity: string, row: any) {
291
- return Array.from(this.subscribesByEntity.get(entity as E) || [])
292
- .filter((m) => {
293
- 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
+ }
294
314
 
295
- let state = subscribe && checkRowIsSutable(row, subscribe)
296
- if (this.options.checkRowIsSutable) {
297
- state = this.options.checkRowIsSutable(state, { entity: entity as any, subscribe, row })
298
- }
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
+ }
299
319
 
300
- return state
301
- })
302
- .map((m) => m[1])
320
+ return state
321
+ })
303
322
  }
304
323
 
305
324
  public ensureDocument(entity: E, row: any, awareness?: boolean) {
@@ -381,15 +400,16 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
381
400
  }
382
401
 
383
402
  private onDocumentExternalUpdate(update: Uint8Array, action: CuboCrdtServerDocumentIncomingAction) {
384
- const document = this.getDocument(action.entity, action.entity_id)
385
-
386
- // console.log('onDocumentExternalUpdate', action)
387
-
403
+ let document = this.getDocument(action.entity, action.entity_id)
388
404
  if (!document) {
389
- console.warn('[CRDT] onDocumentExternalUpdate - document not found:', action.entity, action.entity_id)
390
- } else {
391
- document.applyUpdate(new Uint8Array(update), action.origin)
405
+ if (this.debug) {
406
+ console.warn('[CRDT] onDocumentExternalUpdate - document not found, creating:', action.entity, action.entity_id)
407
+ }
408
+
409
+ document = this.ensureDocument(action.entity as E, action.entity_id, true)
392
410
  }
411
+
412
+ document.applyUpdate(new Uint8Array(update), action.origin)
393
413
  }
394
414
 
395
415
  private onAwarenessExternalUpdate(update: Uint8Array, action: CuboCrdtServerDocumentIncomingAction) {
@@ -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,21 +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
- "baseUrl": "./",
13
- "incremental": true,
14
- "skipLibCheck": true,
15
- "paths": {
16
- "@/*": ["src/*"]
17
- }
18
- },
19
- "include": ["src/**/*"],
20
- "exclude": ["node_modules", "dist"]
21
- }