@cuboapp/crdt 1.0.14 → 1.0.16

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.
@@ -3,8 +3,12 @@ import { WsServerSocket } from '@cuboapp/ws'
3
3
  import { applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'
4
4
 
5
5
  import { CUBO_CRDT_EVENT } from '../constants'
6
- import { CuboCrdtAction } from '../types'
7
- import { checkRowIsSutable } from '../utils'
6
+ import { CuboCrdtAction, CuboCrdtListSyncData, CuboCrdtMutation } from '../types'
7
+ import {
8
+ areCrdtListIdsEqual,
9
+ areCrdtListTotalsEqual,
10
+ checkRowIsSutable
11
+ } from '../utils'
8
12
 
9
13
  import { CuboCrdtClientDocOrigin } from '../client'
10
14
  import { CuboCrdtServerDocument } from './document'
@@ -14,6 +18,7 @@ import {
14
18
  CuboCrdtServerOptions,
15
19
  CuboCrdtServerSubscribe,
16
20
  CuboCrdtServerSubscribeDto,
21
+ CuboCrdtSubscribeRefreshState,
17
22
  CuboCrdtServerUnsubscribeDto,
18
23
  CuboCrdtSocketClient
19
24
  } from './types'
@@ -31,28 +36,40 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
31
36
  private subscribes = new Map<string, CuboCrdtServerSubscribe>()
32
37
  private subscribesByClient = new Map<string, Set<string>>()
33
38
  private subscribesByEntity = new Map<E, Set<string>>()
34
- private subscribesIniting = new Map<string, Promise<void>>()
35
- private subscribesUpgrading = new Map<string, Promise<void>>()
39
+ private subscribeRefreshStates = new Map<string, CuboCrdtSubscribeRefreshState>()
40
+ private subscribeGenerations = new Map<string, number>()
36
41
 
37
42
  private documents = new Map<string, CuboCrdtServerDocument>()
38
43
  private subscribesByDocument = new Map<string, Set<string>>()
44
+ // обратный индекс: подписка -> имена документов, к которым она привязана.
45
+ // Нужен, чтобы отписка/очистка не сканировала ВСЕ документы сервера (O(subs x docs)).
46
+ private documentsBySubscribe = new Map<string, Set<string>>()
39
47
 
40
48
  public async init() {
41
49
  this.entities.forEach((e) => this.subscribesByEntity.set(e, new Set()))
42
50
 
43
51
  this.ws.registerHandler(CUBO_CRDT_EVENT.SUBSCRIBE, async ({ client, message }) => {
44
- const { subscribe_id: id, entity, filters, awareness = false } = message.data as CuboCrdtServerSubscribeDto
52
+ const { subscribe_id: id, entity, filters, awareness = false, paginated } = message.data as CuboCrdtServerSubscribeDto
45
53
 
46
54
  if (this.debug) {
47
55
  console.log('[CRDT] subscribe', entity, filters)
48
56
  }
49
57
 
58
+ const existing = this.subscribes.get(id)
59
+ if (existing) {
60
+ this.cleanSubscribe(id)
61
+ }
62
+
50
63
  const subscribe = {
51
64
  id,
52
65
  client_id: client.id,
53
66
  entity,
54
- filters,
55
- awareness
67
+ filters: cloneDeep(filters || {}),
68
+ awareness,
69
+ paginated: this.resolvePaginated(filters, paginated),
70
+ row_ids: [],
71
+ totals: {},
72
+ revision: 0
56
73
  }
57
74
 
58
75
  this.subscribes.set(id, subscribe)
@@ -64,11 +81,13 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
64
81
  this.subscribesByEntity.get(entity as E)?.add(id)
65
82
 
66
83
  // инициализируем подписку
67
- this.initSubscribe(client, subscribe)
84
+ await this.initSubscribe(client, subscribe)
85
+
86
+ return { subscribe_id: id, revision: subscribe.revision }
68
87
  })
69
88
 
70
89
  this.ws.registerHandler(CUBO_CRDT_EVENT.UPGRADE, async ({ client, message }) => {
71
- const { subscribe_id: id, entity, filters } = message.data as CuboCrdtServerSubscribeDto
90
+ const { subscribe_id: id, entity, filters, paginated } = message.data as CuboCrdtServerSubscribeDto
72
91
 
73
92
  if (this.debug) {
74
93
  console.log('[CRDT] upgrade', entity, filters)
@@ -80,7 +99,9 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
80
99
  }
81
100
 
82
101
  // обновляем подписку
83
- this.upgradeSubscribe(client, subscribe, { filters })
102
+ await this.upgradeSubscribe(client, subscribe, { filters, paginated })
103
+
104
+ return { subscribe_id: id, revision: subscribe.revision }
84
105
  })
85
106
 
86
107
  this.ws.registerHandler(CUBO_CRDT_EVENT.UNSUBSCRIBE, async ({ message }) => {
@@ -145,9 +166,14 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
145
166
 
146
167
  public async destroy() {
147
168
  this.ws.deleteHandler(CUBO_CRDT_EVENT.SUBSCRIBE)
169
+ this.ws.deleteHandler(CUBO_CRDT_EVENT.UPGRADE)
148
170
  this.ws.deleteHandler(CUBO_CRDT_EVENT.UNSUBSCRIBE)
149
171
  this.ws.deleteHandler(CUBO_CRDT_EVENT.EVENT)
150
172
 
173
+ this.subscribeRefreshStates.forEach((state) => clearTimeout(state.timer))
174
+ this.subscribeRefreshStates.clear()
175
+ this.subscribeGenerations.clear()
176
+
151
177
  this.subscribes.forEach((sub) => this.ws.deleteHandler(sub.id))
152
178
  }
153
179
 
@@ -163,8 +189,9 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
163
189
  }
164
190
 
165
191
  public removeClient(client: WsServerSocket) {
166
- // удаляем все подписки по клиенту
167
- this.subscribesByClient.get(client.id)?.forEach((subscribe_id) => this.cleanSubscribe(subscribe_id))
192
+ // удаляем все подписки по клиенту (но только те, что всё ещё принадлежат ЭТОМУ сокету —
193
+ // подписка могла быть переподвязана к новому сокету при reconnect с тем же subscribe_id)
194
+ this.subscribesByClient.get(client.id)?.forEach((subscribe_id) => this.cleanSubscribe(subscribe_id, client.id))
168
195
 
169
196
  // удаляем мапу подписок по клиенту
170
197
  this.subscribesByClient.delete(client.id)
@@ -188,6 +215,21 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
188
215
  return this.options.batch?.debounce ?? 100
189
216
  }
190
217
 
218
+ private get paginationDebounce() {
219
+ return this.options.pagination?.debounce ?? 75
220
+ }
221
+
222
+ private resolvePaginated(filters?: Record<string, any>, explicit?: boolean) {
223
+ if (explicit !== undefined) {
224
+ return explicit
225
+ }
226
+
227
+ const page = Number(filters?.page)
228
+ const limit = Number(filters?.limit)
229
+
230
+ return Number.isFinite(page) && page > 0 && Number.isFinite(limit) && limit > 0
231
+ }
232
+
191
233
  private sendQueueToClient(client: CuboCrdtSocketClient<A>) {
192
234
  // снимаем запланированный флаш — мы отправляем прямо сейчас
193
235
  clearTimeout(this.sendTimeouts.get(client.id))
@@ -198,17 +240,17 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
198
240
  return
199
241
  }
200
242
 
201
- // дедупликация одинаковых событий внутри батча
202
- const data = [...new Map(Array.from(queue).map((item) => [JSON.stringify(item), item])).values()]
243
+ // Дедупликация побайтово идентичных событий внутри батча + сборка кадра за ОДИН проход
244
+ // сериализации: сериализуем каждый элемент один раз (он же служит ключом дедупа) и
245
+ // склеиваем готовые JSON-строки, а не сериализуем весь массив повторно.
246
+ const items = new Set<string>()
247
+ for (const item of queue) {
248
+ items.add(JSON.stringify(item))
249
+ }
203
250
 
204
251
  queue.clear()
205
252
 
206
- client.send(
207
- JSON.stringify({
208
- method: CUBO_CRDT_EVENT.EVENT,
209
- data
210
- })
211
- )
253
+ client.send(`{"method":${JSON.stringify(CUBO_CRDT_EVENT.EVENT)},"data":[${Array.from(items).join(',')}]}`)
212
254
  }
213
255
 
214
256
  public sendToClient(client_id: string, data: any) {
@@ -241,6 +283,29 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
241
283
  }
242
284
  }
243
285
 
286
+ // привязываем подписку к документу (в обоих индексах)
287
+ private linkSubscribeToDocument(subscribe_id: string, docName: string) {
288
+ let byDoc = this.subscribesByDocument.get(docName)
289
+ if (!byDoc) {
290
+ byDoc = new Set()
291
+ this.subscribesByDocument.set(docName, byDoc)
292
+ }
293
+ byDoc.add(subscribe_id)
294
+
295
+ let bySub = this.documentsBySubscribe.get(subscribe_id)
296
+ if (!bySub) {
297
+ bySub = new Set()
298
+ this.documentsBySubscribe.set(subscribe_id, bySub)
299
+ }
300
+ bySub.add(docName)
301
+ }
302
+
303
+ // отвязываем подписку от документа (в обоих индексах)
304
+ private unlinkSubscribeFromDocument(subscribe_id: string, docName: string) {
305
+ this.subscribesByDocument.get(docName)?.delete(subscribe_id)
306
+ this.documentsBySubscribe.get(subscribe_id)?.delete(docName)
307
+ }
308
+
244
309
  // если у документа нет подписок - сносим его
245
310
  private checkDocumentNeedRemove(name: string) {
246
311
  if (!this.subscribesByDocument.get(name)?.size) {
@@ -250,55 +315,67 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
250
315
 
251
316
  this.documents.get(name)?.destroy()
252
317
  this.documents.delete(name)
318
+ // не оставляем пустой Set в индексе (иначе — медленная утечка записей мапы);
319
+ // getOrCreateDocument пересоздаст его при повторном появлении документа
320
+ this.subscribesByDocument.delete(name)
253
321
  }
254
322
  }
255
323
 
256
- // очистка подписки (при отписке клиента - через emit-метод или options-хук)
257
- private cleanSubscribe(subscribe_id: string) {
324
+ // очистка подписки (при отписке клиента - через emit-метод или options-хук).
325
+ // ownerClientId (опционально): при отключении сокета передаётся id этого сокета —
326
+ // если подписка уже переподвязана к НОВОМУ клиенту (reconnect с тем же subscribe_id),
327
+ // старый close-хендлер не должен её сносить.
328
+ private cleanSubscribe(subscribe_id: string, ownerClientId?: string) {
258
329
  const subscribe = this.subscribes.get(subscribe_id)
259
- if (subscribe) {
330
+ if (!subscribe) {
331
+ return
332
+ }
333
+
334
+ // подписка была переподвязана к другому (более новому) клиенту — не трогаем
335
+ if (ownerClientId !== undefined && subscribe.client_id !== ownerClientId) {
260
336
  if (this.debug) {
261
- console.log('[CRDT] unsubscribe', subscribe.entity, subscribe.filters)
337
+ console.log('[CRDT] cleanSubscribe skipped (rebound to newer client)', subscribe_id)
262
338
  }
339
+ return
340
+ }
263
341
 
264
- if (subscribe.awareness) {
265
- //убираем аварнесс стейт при отписке
266
- this.subscribesByDocument.forEach((subs, docName) => {
267
- if (!subs.has(subscribe_id)) {
268
- return
269
- }
342
+ if (this.debug) {
343
+ console.log('[CRDT] unsubscribe', subscribe.entity, subscribe.filters)
344
+ }
270
345
 
271
- const doc = this.documents.get(docName)
272
- if (!doc?.awareness) {
273
- return
274
- }
346
+ // документы этой подписки берём из обратного индекса, а не сканируем все
347
+ const docNames = this.documentsBySubscribe.get(subscribe_id)
275
348
 
276
- const ids = Array.from(doc.awarenessBySubscribe.get(subscribe_id) || [])
277
- if (!ids.length) {
278
- return
349
+ if (docNames) {
350
+ for (const docName of docNames) {
351
+ if (subscribe.awareness) {
352
+ //убираем аварнесс стейт при отписке
353
+ const doc = this.documents.get(docName)
354
+ const ids = doc?.awareness ? Array.from(doc.awarenessBySubscribe.get(subscribe_id) || []) : []
355
+ if (doc?.awareness && ids.length) {
356
+ removeAwarenessStates(doc.awareness, ids, { subscribe_id, expose: 'all' })
357
+ doc.awarenessBySubscribe.delete(subscribe_id)
279
358
  }
359
+ }
280
360
 
281
- removeAwarenessStates(doc.awareness, ids, { subscribe_id, expose: 'all' })
282
-
283
- doc.awarenessBySubscribe.delete(subscribe_id)
284
- })
361
+ // отписываем документ от подписки (только по одной стороне индекса другую чистим ниже)
362
+ this.subscribesByDocument.get(docName)?.delete(subscribe_id)
363
+ this.checkDocumentNeedRemove(docName)
285
364
  }
365
+ }
286
366
 
287
- // удаляем подписку по сущности
288
- this.subscribesByEntity.get(subscribe.entity as E)?.delete(subscribe.id)
367
+ // удаляем обратный индекс
368
+ this.documentsBySubscribe.delete(subscribe_id)
289
369
 
290
- // отписываем все документы от подписки
291
- this.subscribesByDocument.forEach((subscribes, docName) => {
292
- subscribes.delete(subscribe_id)
370
+ const refreshState = this.subscribeRefreshStates.get(subscribe_id)
371
+ clearTimeout(refreshState?.timer)
372
+ this.subscribeRefreshStates.delete(subscribe_id)
373
+ this.subscribeGenerations.delete(subscribe_id)
293
374
 
294
- this.checkDocumentNeedRemove(docName)
295
- })
296
-
297
- // удаляем подписки
298
- this.subscribes.delete(subscribe_id)
299
- this.subscribesByClient.get(subscribe.client_id)?.delete(subscribe_id)
300
- this.subscribesByEntity.get(subscribe.entity as E)?.delete(subscribe_id)
301
- }
375
+ // удаляем подписки
376
+ this.subscribes.delete(subscribe_id)
377
+ this.subscribesByClient.get(subscribe.client_id)?.delete(subscribe_id)
378
+ this.subscribesByEntity.get(subscribe.entity as E)?.delete(subscribe_id)
302
379
  }
303
380
 
304
381
  public getDocument(entity: string, entity_id: number) {
@@ -321,60 +398,298 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
321
398
  })
322
399
  }
323
400
 
324
- public ensureDocument(entity: E, row: any, awareness?: boolean) {
325
- // todo: врапнуть всё это в Promise
326
- const entity_id = row.id
327
- const documentName = `${entity}:${entity_id}`
401
+ private getSubscribeRefreshState(subscribe_id: string) {
402
+ let state = this.subscribeRefreshStates.get(subscribe_id)
403
+ if (!state) {
404
+ state = { dirty: false, forceSync: false }
405
+ this.subscribeRefreshStates.set(subscribe_id, state)
406
+ }
407
+ return state
408
+ }
328
409
 
329
- // проверяем документ
330
- let document = this.documents.get(documentName)
331
- if (!document) {
332
- document = new CuboCrdtServerDocument({
333
- name: documentName,
334
- initialState: row,
335
- awareness: awareness,
336
- onUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
337
- // пушим обновление документа
338
- // console.log('push document update', document.name)
339
-
340
- this.pushDocumentAction('update', entity, document!, data, origin)
341
- },
342
- onAwarenessUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
343
- // пушим обновление документа
344
- // console.log('push document update', document.name)
410
+ private bumpSubscribeGeneration(subscribe_id: string) {
411
+ const generation = (this.subscribeGenerations.get(subscribe_id) || 0) + 1
412
+ this.subscribeGenerations.set(subscribe_id, generation)
413
+ return generation
414
+ }
345
415
 
346
- this.pushDocumentAction('awareness', entity, document!, data, origin)
347
- },
348
- onStore: (item: object, origin: CuboCrdtServerDocumentOrigin) => {
349
- const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
350
- const client = subscribe && this.clients.get(subscribe.client_id)
416
+ private runSubscribeRefresh(subscribe_id: string) {
417
+ const state = this.getSubscribeRefreshState(subscribe_id)
418
+ if (state.running) {
419
+ return state.running
420
+ }
351
421
 
352
- if (client) {
353
- return this.options?.storeRow?.(entity as any, entity_id, item as any, { document, client, origin })
422
+ state.running = (async () => {
423
+ while (state.dirty) {
424
+ state.dirty = false
425
+
426
+ const subscribe = this.subscribes.get(subscribe_id)
427
+ if (!subscribe) {
428
+ return
429
+ }
430
+
431
+ const client = this.clients.get(subscribe.client_id)
432
+ if (!client) {
433
+ return
434
+ }
435
+
436
+ const generation = this.subscribeGenerations.get(subscribe_id) || 0
437
+ const forceSync = state.forceSync
438
+ state.forceSync = false
439
+ const reconciled = await this.reconcileSubscribe(
440
+ client,
441
+ subscribe,
442
+ generation,
443
+ forceSync
444
+ )
445
+ if (!reconciled && forceSync) {
446
+ state.forceSync = true
447
+ }
448
+ }
449
+ })().finally(() => {
450
+ state.running = undefined
451
+ })
452
+
453
+ return state.running
454
+ }
455
+
456
+ private requestSubscribeRefresh(
457
+ subscribe_id: string,
458
+ immediate = false,
459
+ forceSync = immediate
460
+ ) {
461
+ const state = this.getSubscribeRefreshState(subscribe_id)
462
+ state.dirty = true
463
+ state.forceSync ||= forceSync
464
+ this.bumpSubscribeGeneration(subscribe_id)
465
+
466
+ if (immediate) {
467
+ clearTimeout(state.timer)
468
+ state.timer = undefined
469
+ return this.runSubscribeRefresh(subscribe_id)
470
+ }
471
+
472
+ if (!state.timer && !state.running) {
473
+ state.timer = setTimeout(() => {
474
+ state.timer = undefined
475
+ this.runSubscribeRefresh(subscribe_id).catch((e) => {
476
+ if (this.debug) {
477
+ console.error('[CRDT] refresh subscribe', subscribe_id, e)
354
478
  }
479
+ })
480
+ }, this.paginationDebounce)
481
+ }
482
+
483
+ return state.running ?? Promise.resolve()
484
+ }
485
+
486
+ private getSubscribeQueryKeys(filters?: Record<string, any>) {
487
+ const keys = new Set<string>()
488
+
489
+ Object.keys(filters || {}).forEach((key) => {
490
+ if (!['page', 'limit', 'sort', 'with'].includes(key)) {
491
+ keys.add(key)
492
+
493
+ const rangeKey = key.replace(/_(start|end)$/, '')
494
+ if (rangeKey !== key) {
495
+ keys.add(rangeKey)
355
496
  }
356
- })
357
497
 
358
- // создаём документ
359
- this.documents.set(documentName, document)
498
+ if (key.endsWith('_ids')) {
499
+ keys.add(key.slice(0, -1))
500
+ }
501
+ }
502
+ })
503
+
504
+ const appendSort = (sort: unknown) => {
505
+ if (Array.isArray(sort)) {
506
+ sort.forEach(appendSort)
507
+ return
508
+ }
509
+ if (typeof sort !== 'string') {
510
+ return
511
+ }
512
+
513
+ sort
514
+ .split(',')
515
+ .map((item) => item.trim().replace(/^-/, '').split(':')[0])
516
+ .filter(Boolean)
517
+ .forEach((key) => keys.add(key))
518
+ }
519
+
520
+ appendSort(filters?.sort)
521
+
522
+ return keys
523
+ }
524
+
525
+ private shouldRefreshSubscribe(entity: E, subscribe: CuboCrdtServerSubscribe, mutation: CuboCrdtMutation<M[E]>) {
526
+ if (!subscribe.paginated) {
527
+ return false
528
+ }
529
+
530
+ if (this.options.shouldRefreshSubscribe) {
531
+ return this.options.shouldRefreshSubscribe({ entity, subscribe, mutation })
532
+ }
533
+
534
+ if (mutation.action !== 'update' || !mutation.changedKeys?.length) {
535
+ return true
536
+ }
537
+
538
+ if (mutation.previousRow) {
539
+ const previousSuitable = this.checkRowIsSutable(
540
+ mutation.previousRow,
541
+ subscribe,
542
+ entity
543
+ )
544
+ const currentSuitable = this.checkRowIsSutable(
545
+ mutation.row,
546
+ subscribe,
547
+ entity
548
+ )
549
+
550
+ if (previousSuitable !== currentSuitable) {
551
+ return true
552
+ }
553
+ }
554
+
555
+ if (subscribe.filters?.search) {
556
+ return true
557
+ }
558
+
559
+ const queryKeys = this.getSubscribeQueryKeys(subscribe.filters)
560
+ return mutation.changedKeys.some((key) => queryKeys.has(key))
561
+ }
562
+
563
+ /**
564
+ * Уведомляет пагинированные подписки о сохранённой мутации.
565
+ * Пересчитываются только реально открытые страницы; несколько мутаций
566
+ * схлопываются debounce-ом в один запрос на подписку.
567
+ */
568
+ public notifyMutation(entity: E, mutation: CuboCrdtMutation<M[E]>) {
569
+ for (const subscribe_id of this.subscribesByEntity.get(entity) || []) {
570
+ const subscribe = this.subscribes.get(subscribe_id)
571
+ if (subscribe && this.shouldRefreshSubscribe(entity, subscribe, mutation)) {
572
+ this.requestSubscribeRefresh(subscribe_id)
573
+ }
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Принудительно инвалидирует все открытые пагинированные окна сущности.
579
+ * Используется для вычисляемых полей и зависимых сущностей, когда changedKeys
580
+ * исходной строки недостаточно для определения влияния на запрос.
581
+ */
582
+ public invalidate(entity: E) {
583
+ for (const subscribe_id of this.subscribesByEntity.get(entity) || []) {
584
+ const subscribe = this.subscribes.get(subscribe_id)
585
+ if (subscribe?.paginated) {
586
+ this.requestSubscribeRefresh(subscribe_id)
587
+ }
588
+ }
589
+ }
590
+
591
+ // создаёт (или возвращает существующий) серверный документ БЕЗ рассылки подписчикам.
592
+ // Используется как для рассылочного пути (ensureDocument), так и для точечной
593
+ // доставки на init подписки (pushSubscribeDocuments).
594
+ private getOrCreateDocument(entity: E, row: any, awareness?: boolean): CuboCrdtServerDocument {
595
+ const entity_id = row.id
596
+ const documentName = `${entity}:${entity_id}`
597
+
598
+ let document = this.documents.get(documentName)
599
+ if (document) {
600
+ return document
601
+ }
602
+
603
+ document = new CuboCrdtServerDocument({
604
+ name: documentName,
605
+ initialState: row,
606
+ awareness: awareness,
607
+ onUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
608
+ this.pushDocumentAction('update', entity, document!, data, origin)
609
+ },
610
+ onAwarenessUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
611
+ this.pushDocumentAction('awareness', entity, document!, data, origin)
612
+ },
613
+ onStore: (item: object, origin: CuboCrdtServerDocumentOrigin, store) => {
614
+ const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
615
+ const client = subscribe && this.clients.get(subscribe.client_id)
616
+
617
+ if (client) {
618
+ return this.options?.storeRow?.(entity as any, entity_id, item as any, {
619
+ document,
620
+ client,
621
+ origin,
622
+ previousRow: store.previousRow as any,
623
+ row: store.row as any
624
+ })
625
+ }
626
+ }
627
+ })
360
628
 
361
- // создаём пул подписок по документу
629
+ this.documents.set(documentName, document)
630
+ if (!this.subscribesByDocument.has(documentName)) {
362
631
  this.subscribesByDocument.set(documentName, new Set())
632
+ }
363
633
 
364
- // пушим создание документа
365
- this.pushDocumentAction('create', entity, document, document.stateAsUpdate)
634
+ return document
635
+ }
366
636
 
367
- // console.log('create document', documentName)
637
+ // Рассылочный путь (хук onAfterCreate/onAfterUpdate из бэка): создаёт документ и
638
+ // раскатывает 'create'/'upsert' по ВСЕМ подходящим подпискам сущности.
639
+ public ensureDocument(entity: E, row: any, awareness?: boolean) {
640
+ if (row?.id == null) {
641
+ if (this.debug) {
642
+ console.warn('[CRDT] ensureDocument: row without id', entity, row)
643
+ }
644
+ return
645
+ }
646
+
647
+ const documentName = `${entity}:${row.id}`
648
+ const existed = this.documents.has(documentName)
649
+ const document = this.getOrCreateDocument(entity, row, awareness)
650
+
651
+ if (!existed) {
652
+ this.pushDocumentAction('create', entity, document, document.stateAsUpdate)
368
653
  } else {
369
- // console.log('update document', documentName)
654
+ document.write(row, { expose: 'all', store: false })
655
+ document.setPersistedState(row)
656
+ }
370
657
 
371
- // пушим обновление документа
372
- this.pushDocumentAction('upsert', entity, document, document.stateAsUpdate)
658
+ if (!this.subscribesByDocument.get(documentName)?.size) {
659
+ this.checkDocumentNeedRemove(documentName)
373
660
  }
374
661
 
375
662
  return document
376
663
  }
377
664
 
665
+ // Точечная доставка документа ТОЛЬКО инициирующей подписке (init/upgrade/resubscribe).
666
+ // Раньше это шло через ensureDocument -> pushDocumentAction('upsert') с полным сканом
667
+ // всех подписок сущности — на каждый ряд из fetchRows. Теперь — один send на подписку.
668
+ private sendDocumentToSubscribe(subscribe: CuboCrdtServerSubscribe, document: CuboCrdtServerDocument, entity_id: number) {
669
+ this.linkSubscribeToDocument(subscribe.id, document.name)
670
+
671
+ this.sendToClient(subscribe.client_id, {
672
+ action: 'create',
673
+ entity: subscribe.entity,
674
+ entity_id,
675
+ data: Array.from(document.stateAsUpdate),
676
+ subscribe_id: subscribe.id
677
+ })
678
+
679
+ if (subscribe.awareness && document.awareness) {
680
+ const ids = Array.from(document.awareness.getStates().keys())
681
+ if (ids.length) {
682
+ this.sendToClient(subscribe.client_id, {
683
+ action: 'awareness',
684
+ entity: subscribe.entity,
685
+ entity_id,
686
+ data: Array.from(encodeAwarenessUpdate(document.awareness, ids)),
687
+ subscribe_id: subscribe.id
688
+ })
689
+ }
690
+ }
691
+ }
692
+
378
693
  public deleteDocument(entity: E, row: any, origin?: CuboCrdtClientDocOrigin) {
379
694
  if (this.debug) {
380
695
  console.log('[CRDT] delete document', entity, row.id, origin)
@@ -387,6 +702,15 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
387
702
  // сначала пушим удаление документа
388
703
  this.pushDocumentAction('delete', entity, document, null, origin)
389
704
 
705
+ // чистим обратный индекс: убираем документ из documentsBySubscribe у всех его подписок
706
+ const subs = this.subscribesByDocument.get(documentName)
707
+ if (subs) {
708
+ for (const subscribe_id of subs) {
709
+ this.documentsBySubscribe.get(subscribe_id)?.delete(documentName)
710
+ }
711
+ }
712
+ this.subscribesByDocument.delete(documentName)
713
+
390
714
  // удаляем документ
391
715
  this.documents.delete(documentName)
392
716
 
@@ -402,11 +726,19 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
402
726
  private onDocumentExternalUpdate(update: Uint8Array, action: CuboCrdtServerDocumentIncomingAction) {
403
727
  let document = this.getDocument(action.entity, action.entity_id)
404
728
  if (!document) {
729
+ // документ мог быть вытеснен (эвикция при отключении всех подписчиков), пока
730
+ // клиент ещё шлёт апдейты. Пересоздаём под ПРАВИЛЬНЫМ ключом ({ id }, а не число),
731
+ // без рассылки пустого 'create'. Раньше сюда передавался entity_id как row ->
732
+ // row.id === undefined -> общий документ-призрак `entity:undefined`.
733
+ if (action.entity_id == null) {
734
+ return
735
+ }
736
+
405
737
  if (this.debug) {
406
738
  console.warn('[CRDT] onDocumentExternalUpdate - document not found, creating:', action.entity, action.entity_id)
407
739
  }
408
740
 
409
- document = this.ensureDocument(action.entity as E, action.entity_id, true)
741
+ document = this.getOrCreateDocument(action.entity as E, { id: action.entity_id }, true)
410
742
  }
411
743
 
412
744
  document.applyUpdate(new Uint8Array(update), action.origin)
@@ -468,12 +800,25 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
468
800
  const sutableSubscribes = Array.from(this.subscribesByEntity.get(entity) || [])
469
801
  .map((subscribe_id) => {
470
802
  const subscribe = this.subscribes.get(subscribe_id)
803
+ if (!subscribe) {
804
+ return null
805
+ }
806
+
807
+ // аварнесс-события (курсоры/выделения) раскатываем ТОЛЬКО подпискам с awareness,
808
+ // а не всем спискам сущности, которым awareness не нужен
809
+ if (action === 'awareness' && !subscribe.awareness) {
810
+ return null
811
+ }
471
812
 
472
813
  const subscribeSutable = this.checkSubscribeIsSutable(origin, subscribe)
473
814
  const documentExistsInSubscribe = this.subscribesByDocument.get(document.name)?.has(subscribe.id)
474
- const rowSutable = this.checkRowIsSutable(row, subscribe, entity)
815
+ const rowSutable = subscribe.paginated
816
+ ? documentExistsInSubscribe
817
+ : this.checkRowIsSutable(row, subscribe, entity)
475
818
 
476
- const sutable = subscribeSutable && (rowSutable || documentExistsInSubscribe)
819
+ const sutable = subscribeSutable && (subscribe.paginated
820
+ ? documentExistsInSubscribe
821
+ : rowSutable || documentExistsInSubscribe)
477
822
 
478
823
  if (sutable) {
479
824
  if (this.debug && !rowSutable) {
@@ -534,8 +879,6 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
534
879
  .filter((i) => !!i) as { subscribe: CuboCrdtServerSubscribe; action: CuboCrdtAction; data: undefined | number[] }[]
535
880
 
536
881
  if (sutableSubscribes.length) {
537
- const documentSubscribes = this.subscribesByDocument.get(document.name)
538
-
539
882
  sutableSubscribes.forEach(({ subscribe, action, data }) => {
540
883
  this.sendToClient(subscribe.client_id, {
541
884
  action,
@@ -549,10 +892,10 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
549
892
  case 'create':
550
893
  case 'upsert':
551
894
  case 'update':
552
- documentSubscribes.add(subscribe.id)
895
+ this.linkSubscribeToDocument(subscribe.id, document.name)
553
896
  break
554
897
  case 'delete':
555
- documentSubscribes.delete(subscribe.id)
898
+ this.unlinkSubscribeFromDocument(subscribe.id, document.name)
556
899
  this.checkDocumentNeedRemove(document.name)
557
900
  break
558
901
  }
@@ -576,87 +919,123 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
576
919
  }
577
920
  }
578
921
 
579
- private async pushSubscribeDocuments(client: WsServerSocket, subscribe: CuboCrdtServerSubscribe) {
580
- // получаем список всех документов
581
- const rows = await this.options.fetchRows?.(client, subscribe)
582
-
583
- // пушим их в сокеты
584
- for (const row of rows || []) {
585
- // this.ensureDocument(subscribe.entity as E, row, { subscribes_ids: [subscribe.id] })
586
- this.ensureDocument(subscribe.entity as E, row, subscribe.awareness)
587
- }
588
- }
589
-
590
- // апгрейд подписки (фильтры поменялись например)
591
- private async upgradeSubscribe(
922
+ private async reconcileSubscribe(
592
923
  client: WsServerSocket,
593
924
  subscribe: CuboCrdtServerSubscribe,
594
- upgrade: Partial<Pick<CuboCrdtServerSubscribe, 'filters'>>
925
+ generation: number,
926
+ forceSync: boolean
595
927
  ) {
596
- if (this.subscribesUpgrading.has(subscribe.id)) {
597
- return this.subscribesUpgrading.get(subscribe.id)
928
+ const fetched = await this.options.fetchRows?.(client, subscribe)
929
+ const result = Array.isArray(fetched)
930
+ ? { rows: fetched, totals: subscribe.totals }
931
+ : { rows: fetched?.rows || [], totals: fetched?.totals || subscribe.totals }
932
+
933
+ if (
934
+ this.subscribes.get(subscribe.id) !== subscribe ||
935
+ this.subscribeGenerations.get(subscribe.id) !== generation
936
+ ) {
937
+ return false
598
938
  }
599
939
 
600
- this.subscribesUpgrading.set(
601
- subscribe.id,
602
- new Promise<void>(async (resolve, reject) => {
603
- try {
604
- if (this.debug) {
605
- console.log('[CRDT] upgrade subscribe ' + client.id, subscribe)
606
- }
940
+ const rowsById = new Map<number, any>()
941
+ const nextIds: number[] = []
607
942
 
608
- if (upgrade.filters) {
609
- subscribe.filters = cloneDeep(upgrade.filters || {})
610
- }
943
+ for (const row of result.rows) {
944
+ const id = Number((row as any)?.id)
945
+ if (!Number.isFinite(id) || rowsById.has(id)) {
946
+ continue
947
+ }
611
948
 
612
- await this.pushSubscribeDocuments(client, subscribe)
949
+ rowsById.set(id, row)
950
+ nextIds.push(id)
951
+ }
613
952
 
614
- resolve()
615
- } catch (e) {
616
- if (this.debug) {
617
- console.error('[CRDT] upgrade subscribe', e)
618
- }
953
+ const previousIds = new Set(subscribe.row_ids)
954
+ const nextIdsSet = new Set(nextIds)
619
955
 
620
- reject(e)
621
- } finally {
622
- this.subscribesUpgrading.delete(subscribe.id)
623
- }
624
- })
956
+ for (const entity_id of subscribe.row_ids) {
957
+ if (nextIdsSet.has(entity_id)) {
958
+ continue
959
+ }
960
+
961
+ const documentName = `${subscribe.entity}:${entity_id}`
962
+ this.unlinkSubscribeFromDocument(subscribe.id, documentName)
963
+ this.checkDocumentNeedRemove(documentName)
964
+ }
965
+
966
+ for (const entity_id of nextIds) {
967
+ const row = rowsById.get(entity_id)
968
+ if (!row) {
969
+ continue
970
+ }
971
+
972
+ if (previousIds.has(entity_id)) {
973
+ this.linkSubscribeToDocument(subscribe.id, `${subscribe.entity}:${entity_id}`)
974
+ continue
975
+ }
976
+
977
+ const document = this.getOrCreateDocument(subscribe.entity as E, row, subscribe.awareness)
978
+ this.sendDocumentToSubscribe(subscribe, document, entity_id)
979
+ }
980
+
981
+ const idsChanged = !areCrdtListIdsEqual(subscribe.row_ids, nextIds)
982
+ const totalsChanged = !areCrdtListTotalsEqual(
983
+ subscribe.totals,
984
+ result.totals
625
985
  )
626
986
 
627
- return this.subscribesUpgrading.get(subscribe.id)
628
- }
987
+ subscribe.row_ids = nextIds
988
+ subscribe.totals = result.totals
629
989
 
630
- // инциализация подписки
631
- private async initSubscribe(client: WsServerSocket, subscribe: CuboCrdtServerSubscribe) {
632
- if (this.subscribesIniting.has(subscribe.id)) {
633
- return this.subscribesIniting.get(subscribe.id)
990
+ if (!forceSync && !idsChanged && !totalsChanged) {
991
+ return true
634
992
  }
635
993
 
636
- this.subscribesIniting.set(
637
- subscribe.id,
638
- new Promise<void>(async (resolve, reject) => {
639
- try {
640
- if (this.debug) {
641
- console.log('[CRDT] init subscribe ' + client.id, subscribe)
642
- }
994
+ subscribe.revision += 1
995
+ const data: CuboCrdtListSyncData = {
996
+ ids: nextIds,
997
+ totals: subscribe.totals,
998
+ revision: subscribe.revision
999
+ }
643
1000
 
644
- await this.pushSubscribeDocuments(client, subscribe)
1001
+ this.sendToClient(subscribe.client_id, {
1002
+ action: 'sync',
1003
+ entity: subscribe.entity,
1004
+ subscribe_id: subscribe.id,
1005
+ data
1006
+ })
645
1007
 
646
- resolve()
647
- } catch (e) {
648
- if (this.debug) {
649
- console.error('[CRDT] init subscribe', e)
650
- }
1008
+ return true
1009
+ }
651
1010
 
652
- reject(e)
653
- } finally {
654
- this.subscribesIniting.delete(subscribe.id)
655
- }
656
- })
1011
+ // апгрейд подписки (фильтры поменялись например)
1012
+ private async upgradeSubscribe(
1013
+ client: WsServerSocket,
1014
+ subscribe: CuboCrdtServerSubscribe,
1015
+ upgrade: Partial<Pick<CuboCrdtServerSubscribe, 'filters' | 'paginated'>>
1016
+ ) {
1017
+ if (this.debug) {
1018
+ console.log('[CRDT] upgrade subscribe ' + client.id, subscribe)
1019
+ }
1020
+
1021
+ if (upgrade.filters !== undefined) {
1022
+ subscribe.filters = cloneDeep(upgrade.filters || {})
1023
+ }
1024
+ subscribe.paginated = this.resolvePaginated(
1025
+ subscribe.filters,
1026
+ upgrade.paginated
657
1027
  )
658
1028
 
659
- return this.subscribesIniting.get(subscribe.id)
1029
+ return this.requestSubscribeRefresh(subscribe.id, true)
1030
+ }
1031
+
1032
+ // инциализация подписки
1033
+ private async initSubscribe(client: WsServerSocket, subscribe: CuboCrdtServerSubscribe) {
1034
+ if (this.debug) {
1035
+ console.log('[CRDT] init subscribe ' + client.id, subscribe)
1036
+ }
1037
+
1038
+ return this.requestSubscribeRefresh(subscribe.id, true)
660
1039
  }
661
1040
 
662
1041
  private get debug() {