@cuboapp/crdt 1.0.15 → 1.0.17
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 +2 -2
- package/src/client/index.ts +269 -57
- package/src/client/types/index.ts +19 -3
- package/src/client/types/store.ts +10 -1
- package/src/server/document/index.ts +18 -3
- package/src/server/index.ts +374 -82
- package/src/server/types/document.ts +10 -1
- package/src/server/types/index.ts +20 -1
- package/src/server/types/subscribe.ts +15 -2
- package/src/types/index.ts +24 -1
- package/src/utils/index.ts +24 -0
package/src/server/index.ts
CHANGED
|
@@ -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 {
|
|
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,8 +36,8 @@ 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
|
|
35
|
-
private
|
|
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,18 +49,27 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
44
49
|
this.entities.forEach((e) => this.subscribesByEntity.set(e, new Set()))
|
|
45
50
|
|
|
46
51
|
this.ws.registerHandler(CUBO_CRDT_EVENT.SUBSCRIBE, async ({ client, message }) => {
|
|
47
|
-
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
|
|
48
53
|
|
|
49
54
|
if (this.debug) {
|
|
50
55
|
console.log('[CRDT] subscribe', entity, filters)
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
const existing = this.subscribes.get(id)
|
|
59
|
+
if (existing) {
|
|
60
|
+
this.cleanSubscribe(id)
|
|
61
|
+
}
|
|
62
|
+
|
|
53
63
|
const subscribe = {
|
|
54
64
|
id,
|
|
55
65
|
client_id: client.id,
|
|
56
66
|
entity,
|
|
57
|
-
filters,
|
|
58
|
-
awareness
|
|
67
|
+
filters: cloneDeep(filters || {}),
|
|
68
|
+
awareness,
|
|
69
|
+
paginated: this.resolvePaginated(filters, paginated),
|
|
70
|
+
row_ids: [],
|
|
71
|
+
totals: {},
|
|
72
|
+
revision: 0
|
|
59
73
|
}
|
|
60
74
|
|
|
61
75
|
this.subscribes.set(id, subscribe)
|
|
@@ -67,11 +81,13 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
67
81
|
this.subscribesByEntity.get(entity as E)?.add(id)
|
|
68
82
|
|
|
69
83
|
// инициализируем подписку
|
|
70
|
-
this.initSubscribe(client, subscribe)
|
|
84
|
+
await this.initSubscribe(client, subscribe)
|
|
85
|
+
|
|
86
|
+
return { subscribe_id: id, revision: subscribe.revision }
|
|
71
87
|
})
|
|
72
88
|
|
|
73
89
|
this.ws.registerHandler(CUBO_CRDT_EVENT.UPGRADE, async ({ client, message }) => {
|
|
74
|
-
const { subscribe_id: id, entity, filters } = message.data as CuboCrdtServerSubscribeDto
|
|
90
|
+
const { subscribe_id: id, entity, filters, paginated } = message.data as CuboCrdtServerSubscribeDto
|
|
75
91
|
|
|
76
92
|
if (this.debug) {
|
|
77
93
|
console.log('[CRDT] upgrade', entity, filters)
|
|
@@ -83,7 +99,9 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
83
99
|
}
|
|
84
100
|
|
|
85
101
|
// обновляем подписку
|
|
86
|
-
this.upgradeSubscribe(client, subscribe, { filters })
|
|
102
|
+
await this.upgradeSubscribe(client, subscribe, { filters, paginated })
|
|
103
|
+
|
|
104
|
+
return { subscribe_id: id, revision: subscribe.revision }
|
|
87
105
|
})
|
|
88
106
|
|
|
89
107
|
this.ws.registerHandler(CUBO_CRDT_EVENT.UNSUBSCRIBE, async ({ message }) => {
|
|
@@ -148,9 +166,14 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
148
166
|
|
|
149
167
|
public async destroy() {
|
|
150
168
|
this.ws.deleteHandler(CUBO_CRDT_EVENT.SUBSCRIBE)
|
|
169
|
+
this.ws.deleteHandler(CUBO_CRDT_EVENT.UPGRADE)
|
|
151
170
|
this.ws.deleteHandler(CUBO_CRDT_EVENT.UNSUBSCRIBE)
|
|
152
171
|
this.ws.deleteHandler(CUBO_CRDT_EVENT.EVENT)
|
|
153
172
|
|
|
173
|
+
this.subscribeRefreshStates.forEach((state) => clearTimeout(state.timer))
|
|
174
|
+
this.subscribeRefreshStates.clear()
|
|
175
|
+
this.subscribeGenerations.clear()
|
|
176
|
+
|
|
154
177
|
this.subscribes.forEach((sub) => this.ws.deleteHandler(sub.id))
|
|
155
178
|
}
|
|
156
179
|
|
|
@@ -192,6 +215,21 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
192
215
|
return this.options.batch?.debounce ?? 100
|
|
193
216
|
}
|
|
194
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
|
+
|
|
195
233
|
private sendQueueToClient(client: CuboCrdtSocketClient<A>) {
|
|
196
234
|
// снимаем запланированный флаш — мы отправляем прямо сейчас
|
|
197
235
|
clearTimeout(this.sendTimeouts.get(client.id))
|
|
@@ -329,6 +367,11 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
329
367
|
// удаляем обратный индекс
|
|
330
368
|
this.documentsBySubscribe.delete(subscribe_id)
|
|
331
369
|
|
|
370
|
+
const refreshState = this.subscribeRefreshStates.get(subscribe_id)
|
|
371
|
+
clearTimeout(refreshState?.timer)
|
|
372
|
+
this.subscribeRefreshStates.delete(subscribe_id)
|
|
373
|
+
this.subscribeGenerations.delete(subscribe_id)
|
|
374
|
+
|
|
332
375
|
// удаляем подписки
|
|
333
376
|
this.subscribes.delete(subscribe_id)
|
|
334
377
|
this.subscribesByClient.get(subscribe.client_id)?.delete(subscribe_id)
|
|
@@ -355,6 +398,196 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
355
398
|
})
|
|
356
399
|
}
|
|
357
400
|
|
|
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
|
+
}
|
|
409
|
+
|
|
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
|
+
}
|
|
415
|
+
|
|
416
|
+
private runSubscribeRefresh(subscribe_id: string) {
|
|
417
|
+
const state = this.getSubscribeRefreshState(subscribe_id)
|
|
418
|
+
if (state.running) {
|
|
419
|
+
return state.running
|
|
420
|
+
}
|
|
421
|
+
|
|
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)
|
|
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)
|
|
496
|
+
}
|
|
497
|
+
|
|
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
|
+
|
|
358
591
|
// создаёт (или возвращает существующий) серверный документ БЕЗ рассылки подписчикам.
|
|
359
592
|
// Используется как для рассылочного пути (ensureDocument), так и для точечной
|
|
360
593
|
// доставки на init подписки (pushSubscribeDocuments).
|
|
@@ -364,6 +597,21 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
364
597
|
|
|
365
598
|
let document = this.documents.get(documentName)
|
|
366
599
|
if (document) {
|
|
600
|
+
// Освежаем существующий документ строкой из БД.
|
|
601
|
+
//
|
|
602
|
+
// Раньше документ возвращался как есть, а подписка отправляла клиенту его
|
|
603
|
+
// stateAsUpdate — то есть КЭШ документа, а не только что прочитанную строку.
|
|
604
|
+
// Документ, проспавший мутацию, оставался устаревшим навсегда: даже перезагрузка
|
|
605
|
+
// страницы отдавала старое значение, потому что reconcile брал его же.
|
|
606
|
+
//
|
|
607
|
+
// Стало заметно после того, как документы без подписчиков начали удаляться:
|
|
608
|
+
// документ может быть создан, остаться без подписок, пропустить мутации и
|
|
609
|
+
// «воскреснуть» на новой подписке уже неактуальным.
|
|
610
|
+
//
|
|
611
|
+
// store: false — это не правка от клиента, а синхронизация с БД, писать обратно нечего.
|
|
612
|
+
document.write(row, { expose: 'all', store: false })
|
|
613
|
+
document.setPersistedState(row)
|
|
614
|
+
|
|
367
615
|
return document
|
|
368
616
|
}
|
|
369
617
|
|
|
@@ -377,12 +625,18 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
377
625
|
onAwarenessUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
|
|
378
626
|
this.pushDocumentAction('awareness', entity, document!, data, origin)
|
|
379
627
|
},
|
|
380
|
-
onStore: (item: object, origin: CuboCrdtServerDocumentOrigin) => {
|
|
628
|
+
onStore: (item: object, origin: CuboCrdtServerDocumentOrigin, store) => {
|
|
381
629
|
const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
|
|
382
630
|
const client = subscribe && this.clients.get(subscribe.client_id)
|
|
383
631
|
|
|
384
632
|
if (client) {
|
|
385
|
-
return this.options?.storeRow?.(entity as any, entity_id, item as any, {
|
|
633
|
+
return this.options?.storeRow?.(entity as any, entity_id, item as any, {
|
|
634
|
+
document,
|
|
635
|
+
client,
|
|
636
|
+
origin,
|
|
637
|
+
previousRow: store.previousRow as any,
|
|
638
|
+
row: store.row as any
|
|
639
|
+
})
|
|
386
640
|
}
|
|
387
641
|
}
|
|
388
642
|
})
|
|
@@ -411,8 +665,14 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
411
665
|
|
|
412
666
|
if (!existed) {
|
|
413
667
|
this.pushDocumentAction('create', entity, document, document.stateAsUpdate)
|
|
414
|
-
}
|
|
415
|
-
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Для уже существующего документа освежать состояние здесь не нужно —
|
|
671
|
+
// это делает getOrCreateDocument, одинаково для всех путей (рассылка и подписка).
|
|
672
|
+
// write() сам пропускает неизменившиеся ключи, так что лишних update-эвентов нет.
|
|
673
|
+
|
|
674
|
+
if (!this.subscribesByDocument.get(documentName)?.size) {
|
|
675
|
+
this.checkDocumentNeedRemove(documentName)
|
|
416
676
|
}
|
|
417
677
|
|
|
418
678
|
return document
|
|
@@ -568,9 +828,13 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
568
828
|
|
|
569
829
|
const subscribeSutable = this.checkSubscribeIsSutable(origin, subscribe)
|
|
570
830
|
const documentExistsInSubscribe = this.subscribesByDocument.get(document.name)?.has(subscribe.id)
|
|
571
|
-
const rowSutable =
|
|
831
|
+
const rowSutable = subscribe.paginated
|
|
832
|
+
? documentExistsInSubscribe
|
|
833
|
+
: this.checkRowIsSutable(row, subscribe, entity)
|
|
572
834
|
|
|
573
|
-
const sutable = subscribeSutable && (
|
|
835
|
+
const sutable = subscribeSutable && (subscribe.paginated
|
|
836
|
+
? documentExistsInSubscribe
|
|
837
|
+
: rowSutable || documentExistsInSubscribe)
|
|
574
838
|
|
|
575
839
|
if (sutable) {
|
|
576
840
|
if (this.debug && !rowSutable) {
|
|
@@ -671,95 +935,123 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
671
935
|
}
|
|
672
936
|
}
|
|
673
937
|
|
|
674
|
-
private async
|
|
675
|
-
|
|
676
|
-
|
|
938
|
+
private async reconcileSubscribe(
|
|
939
|
+
client: WsServerSocket,
|
|
940
|
+
subscribe: CuboCrdtServerSubscribe,
|
|
941
|
+
generation: number,
|
|
942
|
+
forceSync: boolean
|
|
943
|
+
) {
|
|
944
|
+
const fetched = await this.options.fetchRows?.(client, subscribe)
|
|
945
|
+
const result = Array.isArray(fetched)
|
|
946
|
+
? { rows: fetched, totals: subscribe.totals }
|
|
947
|
+
: { rows: fetched?.rows || [], totals: fetched?.totals || subscribe.totals }
|
|
948
|
+
|
|
949
|
+
if (
|
|
950
|
+
this.subscribes.get(subscribe.id) !== subscribe ||
|
|
951
|
+
this.subscribeGenerations.get(subscribe.id) !== generation
|
|
952
|
+
) {
|
|
953
|
+
return false
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const rowsById = new Map<number, any>()
|
|
957
|
+
const nextIds: number[] = []
|
|
958
|
+
|
|
959
|
+
for (const row of result.rows) {
|
|
960
|
+
const id = Number((row as any)?.id)
|
|
961
|
+
if (!Number.isFinite(id) || rowsById.has(id)) {
|
|
962
|
+
continue
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
rowsById.set(id, row)
|
|
966
|
+
nextIds.push(id)
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const previousIds = new Set(subscribe.row_ids)
|
|
970
|
+
const nextIdsSet = new Set(nextIds)
|
|
971
|
+
|
|
972
|
+
for (const entity_id of subscribe.row_ids) {
|
|
973
|
+
if (nextIdsSet.has(entity_id)) {
|
|
974
|
+
continue
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const documentName = `${subscribe.entity}:${entity_id}`
|
|
978
|
+
this.unlinkSubscribeFromDocument(subscribe.id, documentName)
|
|
979
|
+
this.checkDocumentNeedRemove(documentName)
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
for (const entity_id of nextIds) {
|
|
983
|
+
const row = rowsById.get(entity_id)
|
|
984
|
+
if (!row) {
|
|
985
|
+
continue
|
|
986
|
+
}
|
|
677
987
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
// НА КАЖДУЮ строку из fetchRows (до 10k) — главный усилитель нагрузки при (ре)подписке.
|
|
681
|
-
// Строки уже отфильтрованы под фильтры подписки в fetchRows, поэтому повторная
|
|
682
|
-
// проверка/рассылка остальным не нужна: живые create/update придут через onAfterCreate.
|
|
683
|
-
for (const row of rows || []) {
|
|
684
|
-
if ((row as any)?.id == null) {
|
|
988
|
+
if (previousIds.has(entity_id)) {
|
|
989
|
+
this.linkSubscribeToDocument(subscribe.id, `${subscribe.entity}:${entity_id}`)
|
|
685
990
|
continue
|
|
686
991
|
}
|
|
687
992
|
|
|
688
993
|
const document = this.getOrCreateDocument(subscribe.entity as E, row, subscribe.awareness)
|
|
689
|
-
this.sendDocumentToSubscribe(subscribe, document,
|
|
994
|
+
this.sendDocumentToSubscribe(subscribe, document, entity_id)
|
|
690
995
|
}
|
|
996
|
+
|
|
997
|
+
const idsChanged = !areCrdtListIdsEqual(subscribe.row_ids, nextIds)
|
|
998
|
+
const totalsChanged = !areCrdtListTotalsEqual(
|
|
999
|
+
subscribe.totals,
|
|
1000
|
+
result.totals
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
subscribe.row_ids = nextIds
|
|
1004
|
+
subscribe.totals = result.totals
|
|
1005
|
+
|
|
1006
|
+
if (!forceSync && !idsChanged && !totalsChanged) {
|
|
1007
|
+
return true
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
subscribe.revision += 1
|
|
1011
|
+
const data: CuboCrdtListSyncData = {
|
|
1012
|
+
ids: nextIds,
|
|
1013
|
+
totals: subscribe.totals,
|
|
1014
|
+
revision: subscribe.revision
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
this.sendToClient(subscribe.client_id, {
|
|
1018
|
+
action: 'sync',
|
|
1019
|
+
entity: subscribe.entity,
|
|
1020
|
+
subscribe_id: subscribe.id,
|
|
1021
|
+
data
|
|
1022
|
+
})
|
|
1023
|
+
|
|
1024
|
+
return true
|
|
691
1025
|
}
|
|
692
1026
|
|
|
693
1027
|
// апгрейд подписки (фильтры поменялись например)
|
|
694
1028
|
private async upgradeSubscribe(
|
|
695
1029
|
client: WsServerSocket,
|
|
696
1030
|
subscribe: CuboCrdtServerSubscribe,
|
|
697
|
-
upgrade: Partial<Pick<CuboCrdtServerSubscribe, 'filters'>>
|
|
1031
|
+
upgrade: Partial<Pick<CuboCrdtServerSubscribe, 'filters' | 'paginated'>>
|
|
698
1032
|
) {
|
|
699
|
-
if (this.
|
|
700
|
-
|
|
1033
|
+
if (this.debug) {
|
|
1034
|
+
console.log('[CRDT] upgrade subscribe ' + client.id, subscribe)
|
|
701
1035
|
}
|
|
702
1036
|
|
|
703
|
-
|
|
704
|
-
subscribe.
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
if (upgrade.filters) {
|
|
712
|
-
subscribe.filters = cloneDeep(upgrade.filters || {})
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
await this.pushSubscribeDocuments(client, subscribe)
|
|
716
|
-
|
|
717
|
-
resolve()
|
|
718
|
-
} catch (e) {
|
|
719
|
-
if (this.debug) {
|
|
720
|
-
console.error('[CRDT] upgrade subscribe', e)
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
reject(e)
|
|
724
|
-
} finally {
|
|
725
|
-
this.subscribesUpgrading.delete(subscribe.id)
|
|
726
|
-
}
|
|
727
|
-
})
|
|
1037
|
+
if (upgrade.filters !== undefined) {
|
|
1038
|
+
subscribe.filters = cloneDeep(upgrade.filters || {})
|
|
1039
|
+
}
|
|
1040
|
+
subscribe.paginated = this.resolvePaginated(
|
|
1041
|
+
subscribe.filters,
|
|
1042
|
+
upgrade.paginated
|
|
728
1043
|
)
|
|
729
1044
|
|
|
730
|
-
return this.
|
|
1045
|
+
return this.requestSubscribeRefresh(subscribe.id, true)
|
|
731
1046
|
}
|
|
732
1047
|
|
|
733
1048
|
// инциализация подписки
|
|
734
1049
|
private async initSubscribe(client: WsServerSocket, subscribe: CuboCrdtServerSubscribe) {
|
|
735
|
-
if (this.
|
|
736
|
-
|
|
1050
|
+
if (this.debug) {
|
|
1051
|
+
console.log('[CRDT] init subscribe ' + client.id, subscribe)
|
|
737
1052
|
}
|
|
738
1053
|
|
|
739
|
-
this.
|
|
740
|
-
subscribe.id,
|
|
741
|
-
new Promise<void>(async (resolve, reject) => {
|
|
742
|
-
try {
|
|
743
|
-
if (this.debug) {
|
|
744
|
-
console.log('[CRDT] init subscribe ' + client.id, subscribe)
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
await this.pushSubscribeDocuments(client, subscribe)
|
|
748
|
-
|
|
749
|
-
resolve()
|
|
750
|
-
} catch (e) {
|
|
751
|
-
if (this.debug) {
|
|
752
|
-
console.error('[CRDT] init subscribe', e)
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
reject(e)
|
|
756
|
-
} finally {
|
|
757
|
-
this.subscribesIniting.delete(subscribe.id)
|
|
758
|
-
}
|
|
759
|
-
})
|
|
760
|
-
)
|
|
761
|
-
|
|
762
|
-
return this.subscribesIniting.get(subscribe.id)
|
|
1054
|
+
return this.requestSubscribeRefresh(subscribe.id, true)
|
|
763
1055
|
}
|
|
764
1056
|
|
|
765
1057
|
private get debug() {
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { CuboCrdtAction, CuboCrdtExposeStrategy } from '../../types'
|
|
2
2
|
|
|
3
|
+
export type CuboCrdtServerDocumentStoreContext = {
|
|
4
|
+
previousRow: object
|
|
5
|
+
row: object
|
|
6
|
+
}
|
|
7
|
+
|
|
3
8
|
export type CuboCrdtServerDocumentOptions = {
|
|
4
9
|
name: string
|
|
5
|
-
onStore?: (
|
|
10
|
+
onStore?: (
|
|
11
|
+
body: object,
|
|
12
|
+
origin: CuboCrdtServerDocumentOrigin,
|
|
13
|
+
context: CuboCrdtServerDocumentStoreContext
|
|
14
|
+
) => void | Promise<void>
|
|
6
15
|
// onCreate?: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => void
|
|
7
16
|
onUpdate?: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => void
|
|
8
17
|
onAwarenessUpdate?: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => void
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { WsServer, WsServerSocket } from '@cuboapp/ws'
|
|
2
|
+
import { CuboCrdtListTotals, CuboCrdtMutation } from '../../types'
|
|
2
3
|
import { CuboCrdtServerDocument } from '../document'
|
|
3
4
|
import { CuboCrdtServerDocumentOrigin } from './document'
|
|
4
5
|
import { CuboCrdtServerSubscribe } from './subscribe'
|
|
@@ -6,6 +7,11 @@ import { CuboCrdtServerSubscribe } from './subscribe'
|
|
|
6
7
|
export * from './document'
|
|
7
8
|
export * from './subscribe'
|
|
8
9
|
|
|
10
|
+
export type CuboCrdtServerFetchResult<T> = {
|
|
11
|
+
rows: T[]
|
|
12
|
+
totals?: CuboCrdtListTotals
|
|
13
|
+
}
|
|
14
|
+
|
|
9
15
|
export type CuboCrdtServerOptions<M, A, E extends Extract<keyof M, string>> = {
|
|
10
16
|
ws: WsServer
|
|
11
17
|
entities: E[]
|
|
@@ -19,8 +25,19 @@ export type CuboCrdtServerOptions<M, A, E extends Extract<keyof M, string>> = {
|
|
|
19
25
|
maxSize?: number
|
|
20
26
|
debounce?: number
|
|
21
27
|
}
|
|
22
|
-
|
|
28
|
+
pagination?: {
|
|
29
|
+
debounce?: number
|
|
30
|
+
}
|
|
31
|
+
fetchRows?: (
|
|
32
|
+
client: WsServerSocket,
|
|
33
|
+
subscribe: CuboCrdtServerSubscribe
|
|
34
|
+
) => Promise<M[E][] | CuboCrdtServerFetchResult<M[E]>>
|
|
23
35
|
checkRowIsSutable?: (baseState: boolean, ctx: { entity: E; subscribe: CuboCrdtServerSubscribe; row: any }) => boolean
|
|
36
|
+
shouldRefreshSubscribe?: (ctx: {
|
|
37
|
+
entity: E
|
|
38
|
+
subscribe: CuboCrdtServerSubscribe
|
|
39
|
+
mutation: CuboCrdtMutation<M[E]>
|
|
40
|
+
}) => boolean
|
|
24
41
|
storeRow?: <K extends E>(
|
|
25
42
|
entity: K,
|
|
26
43
|
entity_id: number,
|
|
@@ -29,6 +46,8 @@ export type CuboCrdtServerOptions<M, A, E extends Extract<keyof M, string>> = {
|
|
|
29
46
|
document: CuboCrdtServerDocument
|
|
30
47
|
client: CuboCrdtSocketClient<A>
|
|
31
48
|
origin: CuboCrdtServerDocumentOrigin
|
|
49
|
+
previousRow: M[K]
|
|
50
|
+
row: M[K]
|
|
32
51
|
}
|
|
33
52
|
) => Promise<void> | void
|
|
34
53
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { CuboCrdtListTotals } from '../../types'
|
|
2
|
+
|
|
1
3
|
export type CuboCrdtServerUnsubscribeDto = {
|
|
2
4
|
subscribe_id: string
|
|
3
5
|
}
|
|
@@ -7,10 +9,21 @@ export type CuboCrdtServerSubscribeDto = {
|
|
|
7
9
|
filters: {
|
|
8
10
|
[K in 'id' | string]: any
|
|
9
11
|
}
|
|
10
|
-
awareness
|
|
12
|
+
awareness?: boolean
|
|
13
|
+
paginated?: boolean
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
export type CuboCrdtServerSubscribe = {
|
|
14
17
|
id: string
|
|
15
18
|
client_id: string
|
|
16
|
-
|
|
19
|
+
row_ids: number[]
|
|
20
|
+
totals: CuboCrdtListTotals
|
|
21
|
+
revision: number
|
|
22
|
+
} & Pick<CuboCrdtServerSubscribeDto, 'entity' | 'filters' | 'awareness' | 'paginated'>
|
|
23
|
+
|
|
24
|
+
export type CuboCrdtSubscribeRefreshState = {
|
|
25
|
+
dirty: boolean
|
|
26
|
+
forceSync: boolean
|
|
27
|
+
timer?: ReturnType<typeof setTimeout>
|
|
28
|
+
running?: Promise<void>
|
|
29
|
+
}
|