@cuboapp/crdt 1.0.13 → 1.0.15

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.15",
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
 
@@ -314,6 +413,10 @@ export class CuboCrdtClient<M> {
314
413
  entity_id: ctx.entity_id,
315
414
  data: Array.from(update),
316
415
  origin: {
416
+ // по умолчанию 'other' — исходную подписку исключаем (её yjs-документ уже
417
+ // применил изменение локально). Без этого апдейты от TipTap/y-prosemirror
418
+ // приходят без expose и сервер эхом шлёт их обратно самому автору.
419
+ expose: 'other',
317
420
  ...pick(origin || {}, ['store', 'keys', 'expose']),
318
421
  subscribe_id: opts?.subscribe_id
319
422
  }
@@ -369,7 +472,8 @@ export class CuboCrdtClient<M> {
369
472
  entity: ctx.entity,
370
473
  entity_id: ctx.entity_id,
371
474
  data: Array.from(update),
372
- origin: { subscribe_id: opts.subscribe_id }
475
+ // 'other' не шлём свой же курсор обратно исходной подписке
476
+ origin: { expose: 'other', subscribe_id: opts.subscribe_id }
373
477
  }
374
478
  })
375
479
  })
@@ -399,17 +503,7 @@ export class CuboCrdtClient<M> {
399
503
  applyUpdate(doc, update, { react: false })
400
504
 
401
505
  // обновляем документ в реактивном хранилище
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
- }
506
+ this.applyRowJson(opts.storeKey, ctx.entity_id, doc)
413
507
 
414
508
  if (opts?.onAfterUpdate) {
415
509
  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
  }
@@ -94,8 +94,18 @@ export class CuboCrdtServerDocument {
94
94
  async write(dto: object, origin?: CuboCrdtServerDocumentOrigin) {
95
95
  const map = this.getMap()
96
96
 
97
+ // Пропускаем скалярные ключи с тем же значением: Y.Map.set всегда создаёт новый Item и
98
+ // тумбстонит старый (безостановочный рост документа + лишний update-эвент), даже если
99
+ // значение не изменилось. Сравнение по ссылке — дёшево и не трогает объекты (для них
100
+ // set выполняется как и раньше, без лишней сериализации на горячем пути).
101
+ const entries = Object.entries(dto).filter(([key, value]) => map.get(key) !== value)
102
+
103
+ if (!entries.length) {
104
+ return
105
+ }
106
+
97
107
  this.ydoc.transact(() => {
98
- Object.entries(dto).forEach(([key, value]) => {
108
+ entries.forEach(([key, value]) => {
99
109
  map.set(key, value)
100
110
  })
101
111
  }, origin)
@@ -36,6 +36,9 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
36
36
 
37
37
  private documents = new Map<string, CuboCrdtServerDocument>()
38
38
  private subscribesByDocument = new Map<string, Set<string>>()
39
+ // обратный индекс: подписка -> имена документов, к которым она привязана.
40
+ // Нужен, чтобы отписка/очистка не сканировала ВСЕ документы сервера (O(subs x docs)).
41
+ private documentsBySubscribe = new Map<string, Set<string>>()
39
42
 
40
43
  public async init() {
41
44
  this.entities.forEach((e) => this.subscribesByEntity.set(e, new Set()))
@@ -163,12 +166,17 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
163
166
  }
164
167
 
165
168
  public removeClient(client: WsServerSocket) {
166
- // удаляем все подписки по клиенту
167
- this.subscribesByClient.get(client.id)?.forEach((subscribe_id) => this.cleanSubscribe(subscribe_id))
169
+ // удаляем все подписки по клиенту (но только те, что всё ещё принадлежат ЭТОМУ сокету —
170
+ // подписка могла быть переподвязана к новому сокету при reconnect с тем же subscribe_id)
171
+ this.subscribesByClient.get(client.id)?.forEach((subscribe_id) => this.cleanSubscribe(subscribe_id, client.id))
168
172
 
169
173
  // удаляем мапу подписок по клиенту
170
174
  this.subscribesByClient.delete(client.id)
171
175
 
176
+ // снимаем запланированный флаш очереди (чтобы не слать в закрытый сокет)
177
+ clearTimeout(this.sendTimeouts.get(client.id))
178
+ this.sendTimeouts.delete(client.id)
179
+
172
180
  // удаляем очередь под клиента
173
181
  this.queue.delete(client.id)
174
182
 
@@ -176,25 +184,35 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
176
184
  this.clients.delete(client.id)
177
185
  }
178
186
 
187
+ private get batchMaxSize() {
188
+ return this.options.batch?.maxSize ?? 50
189
+ }
190
+
191
+ private get batchDebounce() {
192
+ return this.options.batch?.debounce ?? 100
193
+ }
194
+
179
195
  private sendQueueToClient(client: CuboCrdtSocketClient<A>) {
196
+ // снимаем запланированный флаш — мы отправляем прямо сейчас
197
+ clearTimeout(this.sendTimeouts.get(client.id))
198
+ this.sendTimeouts.delete(client.id)
199
+
180
200
  const queue = this.queue.get(client.id)
181
- if (!queue) {
201
+ if (!queue || queue.size === 0) {
182
202
  return
183
203
  }
184
204
 
185
- // const data = Array.from(queue)
186
- const data = [...new Map((Array.from(queue) || []).map((item) => [JSON.stringify(item), item])).values()]
205
+ // Дедупликация побайтово идентичных событий внутри батча + сборка кадра за ОДИН проход
206
+ // сериализации: сериализуем каждый элемент один раз (он же служит ключом дедупа) и
207
+ // склеиваем готовые JSON-строки, а не сериализуем весь массив повторно.
208
+ const items = new Set<string>()
209
+ for (const item of queue) {
210
+ items.add(JSON.stringify(item))
211
+ }
187
212
 
188
213
  queue.clear()
189
214
 
190
- client.send(
191
- JSON.stringify({
192
- method: CUBO_CRDT_EVENT.EVENT,
193
- data
194
- })
195
- )
196
-
197
- queue.clear()
215
+ client.send(`{"method":${JSON.stringify(CUBO_CRDT_EVENT.EVENT)},"data":[${Array.from(items).join(',')}]}`)
198
216
  }
199
217
 
200
218
  public sendToClient(client_id: string, data: any) {
@@ -203,25 +221,53 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
203
221
  return
204
222
  }
205
223
 
206
- // сбрасываем таймаут если он есть
207
- clearTimeout(this.sendTimeouts.get(client_id))
224
+ const queue = this.queue.get(client_id)
225
+ if (!queue) {
226
+ return
227
+ }
208
228
 
209
229
  // добавляем в очередь
210
- this.queue.get(client_id).add(data)
230
+ queue.add(data)
211
231
 
212
- // если очередь >10 то сразу отправляем
213
- if (this.queue.size > 10) {
232
+ // если у ЭТОГО клиента в очереди накопилось много событий — отправляем сразу
233
+ if (queue.size >= this.batchMaxSize) {
214
234
  this.sendQueueToClient(client)
235
+ return
215
236
  }
216
- // если нет - ставим таймаут
217
- else {
237
+
238
+ // иначе — дебаунсим отправку, но НЕ сбрасываем уже запланированный флаш,
239
+ // иначе при непрерывном потоке апдейтов очередь никогда не отправится
240
+ if (!this.sendTimeouts.has(client_id)) {
218
241
  this.sendTimeouts.set(
219
242
  client_id,
220
- setTimeout(() => this.sendQueueToClient(client), 100)
243
+ setTimeout(() => this.sendQueueToClient(client), this.batchDebounce)
221
244
  )
222
245
  }
223
246
  }
224
247
 
248
+ // привязываем подписку к документу (в обоих индексах)
249
+ private linkSubscribeToDocument(subscribe_id: string, docName: string) {
250
+ let byDoc = this.subscribesByDocument.get(docName)
251
+ if (!byDoc) {
252
+ byDoc = new Set()
253
+ this.subscribesByDocument.set(docName, byDoc)
254
+ }
255
+ byDoc.add(subscribe_id)
256
+
257
+ let bySub = this.documentsBySubscribe.get(subscribe_id)
258
+ if (!bySub) {
259
+ bySub = new Set()
260
+ this.documentsBySubscribe.set(subscribe_id, bySub)
261
+ }
262
+ bySub.add(docName)
263
+ }
264
+
265
+ // отвязываем подписку от документа (в обоих индексах)
266
+ private unlinkSubscribeFromDocument(subscribe_id: string, docName: string) {
267
+ this.subscribesByDocument.get(docName)?.delete(subscribe_id)
268
+ this.documentsBySubscribe.get(subscribe_id)?.delete(docName)
269
+ }
270
+
225
271
  // если у документа нет подписок - сносим его
226
272
  private checkDocumentNeedRemove(name: string) {
227
273
  if (!this.subscribesByDocument.get(name)?.size) {
@@ -231,55 +277,62 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
231
277
 
232
278
  this.documents.get(name)?.destroy()
233
279
  this.documents.delete(name)
280
+ // не оставляем пустой Set в индексе (иначе — медленная утечка записей мапы);
281
+ // getOrCreateDocument пересоздаст его при повторном появлении документа
282
+ this.subscribesByDocument.delete(name)
234
283
  }
235
284
  }
236
285
 
237
- // очистка подписки (при отписке клиента - через emit-метод или options-хук)
238
- private cleanSubscribe(subscribe_id: string) {
286
+ // очистка подписки (при отписке клиента - через emit-метод или options-хук).
287
+ // ownerClientId (опционально): при отключении сокета передаётся id этого сокета —
288
+ // если подписка уже переподвязана к НОВОМУ клиенту (reconnect с тем же subscribe_id),
289
+ // старый close-хендлер не должен её сносить.
290
+ private cleanSubscribe(subscribe_id: string, ownerClientId?: string) {
239
291
  const subscribe = this.subscribes.get(subscribe_id)
240
- if (subscribe) {
292
+ if (!subscribe) {
293
+ return
294
+ }
295
+
296
+ // подписка была переподвязана к другому (более новому) клиенту — не трогаем
297
+ if (ownerClientId !== undefined && subscribe.client_id !== ownerClientId) {
241
298
  if (this.debug) {
242
- console.log('[CRDT] unsubscribe', subscribe.entity, subscribe.filters)
299
+ console.log('[CRDT] cleanSubscribe skipped (rebound to newer client)', subscribe_id)
243
300
  }
301
+ return
302
+ }
244
303
 
245
- if (subscribe.awareness) {
246
- //убираем аварнесс стейт при отписке
247
- this.subscribesByDocument.forEach((subs, docName) => {
248
- if (!subs.has(subscribe_id)) {
249
- return
250
- }
304
+ if (this.debug) {
305
+ console.log('[CRDT] unsubscribe', subscribe.entity, subscribe.filters)
306
+ }
251
307
 
252
- const doc = this.documents.get(docName)
253
- if (!doc?.awareness) {
254
- return
255
- }
308
+ // документы этой подписки берём из обратного индекса, а не сканируем все
309
+ const docNames = this.documentsBySubscribe.get(subscribe_id)
256
310
 
257
- const ids = Array.from(doc.awarenessBySubscribe.get(subscribe_id) || [])
258
- if (!ids.length) {
259
- return
311
+ if (docNames) {
312
+ for (const docName of docNames) {
313
+ if (subscribe.awareness) {
314
+ //убираем аварнесс стейт при отписке
315
+ const doc = this.documents.get(docName)
316
+ const ids = doc?.awareness ? Array.from(doc.awarenessBySubscribe.get(subscribe_id) || []) : []
317
+ if (doc?.awareness && ids.length) {
318
+ removeAwarenessStates(doc.awareness, ids, { subscribe_id, expose: 'all' })
319
+ doc.awarenessBySubscribe.delete(subscribe_id)
260
320
  }
321
+ }
261
322
 
262
- removeAwarenessStates(doc.awareness, ids, { subscribe_id, expose: 'all' })
263
-
264
- doc.awarenessBySubscribe.delete(subscribe_id)
265
- })
323
+ // отписываем документ от подписки (только по одной стороне индекса другую чистим ниже)
324
+ this.subscribesByDocument.get(docName)?.delete(subscribe_id)
325
+ this.checkDocumentNeedRemove(docName)
266
326
  }
327
+ }
267
328
 
268
- // удаляем подписку по сущности
269
- this.subscribesByEntity.get(subscribe.entity as E)?.delete(subscribe.id)
270
-
271
- // отписываем все документы от подписки
272
- this.subscribesByDocument.forEach((subscribes, docName) => {
273
- subscribes.delete(subscribe_id)
274
-
275
- this.checkDocumentNeedRemove(docName)
276
- })
329
+ // удаляем обратный индекс
330
+ this.documentsBySubscribe.delete(subscribe_id)
277
331
 
278
- // удаляем подписки
279
- this.subscribes.delete(subscribe_id)
280
- this.subscribesByClient.get(subscribe.client_id)?.delete(subscribe_id)
281
- this.subscribesByEntity.get(subscribe.entity as E)?.delete(subscribe_id)
282
- }
332
+ // удаляем подписки
333
+ this.subscribes.delete(subscribe_id)
334
+ this.subscribesByClient.get(subscribe.client_id)?.delete(subscribe_id)
335
+ this.subscribesByEntity.get(subscribe.entity as E)?.delete(subscribe_id)
283
336
  }
284
337
 
285
338
  public getDocument(entity: string, entity_id: number) {
@@ -287,74 +340,112 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
287
340
  }
288
341
 
289
342
  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])
343
+ return Array.from(this.subscribesByEntity.get(entity as E) || []).filter((subscribe_id) => {
344
+ const subscribe = this.subscribes.get(subscribe_id)
345
+ if (!subscribe) {
346
+ return false
347
+ }
293
348
 
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
- }
349
+ let state = checkRowIsSutable(row, subscribe.filters || {})
350
+ if (this.options.checkRowIsSutable) {
351
+ state = this.options.checkRowIsSutable(state, { entity: entity as any, subscribe, row })
352
+ }
298
353
 
299
- return state
300
- })
301
- .map((m) => m[1])
354
+ return state
355
+ })
302
356
  }
303
357
 
304
- public ensureDocument(entity: E, row: any, awareness?: boolean) {
305
- // todo: врапнуть всё это в Promise
358
+ // создаёт (или возвращает существующий) серверный документ БЕЗ рассылки подписчикам.
359
+ // Используется как для рассылочного пути (ensureDocument), так и для точечной
360
+ // доставки на init подписки (pushSubscribeDocuments).
361
+ private getOrCreateDocument(entity: E, row: any, awareness?: boolean): CuboCrdtServerDocument {
306
362
  const entity_id = row.id
307
363
  const documentName = `${entity}:${entity_id}`
308
364
 
309
- // проверяем документ
310
365
  let document = this.documents.get(documentName)
311
- if (!document) {
312
- document = new CuboCrdtServerDocument({
313
- name: documentName,
314
- initialState: row,
315
- awareness: awareness,
316
- onUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
317
- // пушим обновление документа
318
- // console.log('push document update', document.name)
319
-
320
- this.pushDocumentAction('update', entity, document!, data, origin)
321
- },
322
- onAwarenessUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
323
- // пушим обновление документа
324
- // console.log('push document update', document.name)
325
-
326
- this.pushDocumentAction('awareness', entity, document!, data, origin)
327
- },
328
- onStore: (item: object, origin: CuboCrdtServerDocumentOrigin) => {
329
- const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
330
- const client = subscribe && this.clients.get(subscribe.client_id)
366
+ if (document) {
367
+ return document
368
+ }
331
369
 
332
- if (client) {
333
- return this.options?.storeRow?.(entity as any, entity_id, item as any, { document, client, origin })
334
- }
370
+ document = new CuboCrdtServerDocument({
371
+ name: documentName,
372
+ initialState: row,
373
+ awareness: awareness,
374
+ onUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
375
+ this.pushDocumentAction('update', entity, document!, data, origin)
376
+ },
377
+ onAwarenessUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
378
+ this.pushDocumentAction('awareness', entity, document!, data, origin)
379
+ },
380
+ onStore: (item: object, origin: CuboCrdtServerDocumentOrigin) => {
381
+ const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
382
+ const client = subscribe && this.clients.get(subscribe.client_id)
383
+
384
+ if (client) {
385
+ return this.options?.storeRow?.(entity as any, entity_id, item as any, { document, client, origin })
335
386
  }
336
- })
337
-
338
- // создаём документ
339
- this.documents.set(documentName, document)
387
+ }
388
+ })
340
389
 
341
- // создаём пул подписок по документу
390
+ this.documents.set(documentName, document)
391
+ if (!this.subscribesByDocument.has(documentName)) {
342
392
  this.subscribesByDocument.set(documentName, new Set())
393
+ }
343
394
 
344
- // пушим создание документа
345
- this.pushDocumentAction('create', entity, document, document.stateAsUpdate)
395
+ return document
396
+ }
346
397
 
347
- // console.log('create document', documentName)
348
- } else {
349
- // console.log('update document', documentName)
398
+ // Рассылочный путь (хук onAfterCreate/onAfterUpdate из бэка): создаёт документ и
399
+ // раскатывает 'create'/'upsert' по ВСЕМ подходящим подпискам сущности.
400
+ public ensureDocument(entity: E, row: any, awareness?: boolean) {
401
+ if (row?.id == null) {
402
+ if (this.debug) {
403
+ console.warn('[CRDT] ensureDocument: row without id', entity, row)
404
+ }
405
+ return
406
+ }
407
+
408
+ const documentName = `${entity}:${row.id}`
409
+ const existed = this.documents.has(documentName)
410
+ const document = this.getOrCreateDocument(entity, row, awareness)
350
411
 
351
- // пушим обновление документа
412
+ if (!existed) {
413
+ this.pushDocumentAction('create', entity, document, document.stateAsUpdate)
414
+ } else {
352
415
  this.pushDocumentAction('upsert', entity, document, document.stateAsUpdate)
353
416
  }
354
417
 
355
418
  return document
356
419
  }
357
420
 
421
+ // Точечная доставка документа ТОЛЬКО инициирующей подписке (init/upgrade/resubscribe).
422
+ // Раньше это шло через ensureDocument -> pushDocumentAction('upsert') с полным сканом
423
+ // всех подписок сущности — на каждый ряд из fetchRows. Теперь — один send на подписку.
424
+ private sendDocumentToSubscribe(subscribe: CuboCrdtServerSubscribe, document: CuboCrdtServerDocument, entity_id: number) {
425
+ this.linkSubscribeToDocument(subscribe.id, document.name)
426
+
427
+ this.sendToClient(subscribe.client_id, {
428
+ action: 'create',
429
+ entity: subscribe.entity,
430
+ entity_id,
431
+ data: Array.from(document.stateAsUpdate),
432
+ subscribe_id: subscribe.id
433
+ })
434
+
435
+ if (subscribe.awareness && document.awareness) {
436
+ const ids = Array.from(document.awareness.getStates().keys())
437
+ if (ids.length) {
438
+ this.sendToClient(subscribe.client_id, {
439
+ action: 'awareness',
440
+ entity: subscribe.entity,
441
+ entity_id,
442
+ data: Array.from(encodeAwarenessUpdate(document.awareness, ids)),
443
+ subscribe_id: subscribe.id
444
+ })
445
+ }
446
+ }
447
+ }
448
+
358
449
  public deleteDocument(entity: E, row: any, origin?: CuboCrdtClientDocOrigin) {
359
450
  if (this.debug) {
360
451
  console.log('[CRDT] delete document', entity, row.id, origin)
@@ -367,6 +458,15 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
367
458
  // сначала пушим удаление документа
368
459
  this.pushDocumentAction('delete', entity, document, null, origin)
369
460
 
461
+ // чистим обратный индекс: убираем документ из documentsBySubscribe у всех его подписок
462
+ const subs = this.subscribesByDocument.get(documentName)
463
+ if (subs) {
464
+ for (const subscribe_id of subs) {
465
+ this.documentsBySubscribe.get(subscribe_id)?.delete(documentName)
466
+ }
467
+ }
468
+ this.subscribesByDocument.delete(documentName)
469
+
370
470
  // удаляем документ
371
471
  this.documents.delete(documentName)
372
472
 
@@ -382,11 +482,19 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
382
482
  private onDocumentExternalUpdate(update: Uint8Array, action: CuboCrdtServerDocumentIncomingAction) {
383
483
  let document = this.getDocument(action.entity, action.entity_id)
384
484
  if (!document) {
485
+ // документ мог быть вытеснен (эвикция при отключении всех подписчиков), пока
486
+ // клиент ещё шлёт апдейты. Пересоздаём под ПРАВИЛЬНЫМ ключом ({ id }, а не число),
487
+ // без рассылки пустого 'create'. Раньше сюда передавался entity_id как row ->
488
+ // row.id === undefined -> общий документ-призрак `entity:undefined`.
489
+ if (action.entity_id == null) {
490
+ return
491
+ }
492
+
385
493
  if (this.debug) {
386
494
  console.warn('[CRDT] onDocumentExternalUpdate - document not found, creating:', action.entity, action.entity_id)
387
495
  }
388
496
 
389
- document = this.ensureDocument(action.entity as E, action.entity_id, true)
497
+ document = this.getOrCreateDocument(action.entity as E, { id: action.entity_id }, true)
390
498
  }
391
499
 
392
500
  document.applyUpdate(new Uint8Array(update), action.origin)
@@ -445,9 +553,18 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
445
553
  const row = document.getJson()
446
554
 
447
555
  // берём все подписки по сущности
448
- const sutableSubscribes = Array.from(this.subscribesByEntity.get(entity))
556
+ const sutableSubscribes = Array.from(this.subscribesByEntity.get(entity) || [])
449
557
  .map((subscribe_id) => {
450
558
  const subscribe = this.subscribes.get(subscribe_id)
559
+ if (!subscribe) {
560
+ return null
561
+ }
562
+
563
+ // аварнесс-события (курсоры/выделения) раскатываем ТОЛЬКО подпискам с awareness,
564
+ // а не всем спискам сущности, которым awareness не нужен
565
+ if (action === 'awareness' && !subscribe.awareness) {
566
+ return null
567
+ }
451
568
 
452
569
  const subscribeSutable = this.checkSubscribeIsSutable(origin, subscribe)
453
570
  const documentExistsInSubscribe = this.subscribesByDocument.get(document.name)?.has(subscribe.id)
@@ -514,8 +631,6 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
514
631
  .filter((i) => !!i) as { subscribe: CuboCrdtServerSubscribe; action: CuboCrdtAction; data: undefined | number[] }[]
515
632
 
516
633
  if (sutableSubscribes.length) {
517
- const documentSubscribes = this.subscribesByDocument.get(document.name)
518
-
519
634
  sutableSubscribes.forEach(({ subscribe, action, data }) => {
520
635
  this.sendToClient(subscribe.client_id, {
521
636
  action,
@@ -529,10 +644,10 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
529
644
  case 'create':
530
645
  case 'upsert':
531
646
  case 'update':
532
- documentSubscribes.add(subscribe.id)
647
+ this.linkSubscribeToDocument(subscribe.id, document.name)
533
648
  break
534
649
  case 'delete':
535
- documentSubscribes.delete(subscribe.id)
650
+ this.unlinkSubscribeFromDocument(subscribe.id, document.name)
536
651
  this.checkDocumentNeedRemove(document.name)
537
652
  break
538
653
  }
@@ -557,13 +672,21 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
557
672
  }
558
673
 
559
674
  private async pushSubscribeDocuments(client: WsServerSocket, subscribe: CuboCrdtServerSubscribe) {
560
- // получаем список всех документов
675
+ // получаем строки под фильтры ЭТОЙ подписки
561
676
  const rows = await this.options.fetchRows?.(client, subscribe)
562
677
 
563
- // пушим их в сокеты
678
+ // Доставляем документы ТОЧЕЧНО инициирующей подписке. Раньше это шло через
679
+ // ensureDocument -> pushDocumentAction('upsert') с полным сканом всех подписок сущности
680
+ // НА КАЖДУЮ строку из fetchRows (до 10k) — главный усилитель нагрузки при (ре)подписке.
681
+ // Строки уже отфильтрованы под фильтры подписки в fetchRows, поэтому повторная
682
+ // проверка/рассылка остальным не нужна: живые create/update придут через onAfterCreate.
564
683
  for (const row of rows || []) {
565
- // this.ensureDocument(subscribe.entity as E, row, { subscribes_ids: [subscribe.id] })
566
- this.ensureDocument(subscribe.entity as E, row, subscribe.awareness)
684
+ if ((row as any)?.id == null) {
685
+ continue
686
+ }
687
+
688
+ const document = this.getOrCreateDocument(subscribe.entity as E, row, subscribe.awareness)
689
+ this.sendDocumentToSubscribe(subscribe, document, (row as any).id)
567
690
  }
568
691
  }
569
692
 
@@ -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
- }