@cuboapp/crdt 1.0.19 → 1.0.21
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 +1 -1
- package/src/client/index.ts +78 -39
- package/src/client/types/index.ts +5 -0
- package/src/server/document/index.ts +30 -22
- package/src/server/index.ts +44 -5
- package/src/server/types/subscribe.ts +9 -1
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { keyBy, pick, uuid } from '@cuboapp/utils'
|
|
2
2
|
import { WsClientEvent } from '@cuboapp/ws'
|
|
3
|
-
import { computed, reactive } from 'vue'
|
|
3
|
+
import { computed, reactive, shallowReactive } from 'vue'
|
|
4
4
|
import { applyAwarenessUpdate, Awareness, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'
|
|
5
5
|
import { applyUpdate, Doc } from 'yjs'
|
|
6
6
|
|
|
@@ -40,6 +40,7 @@ export class CuboCrdtClient<M> {
|
|
|
40
40
|
filters?: Record<string, any>
|
|
41
41
|
awareness?: boolean
|
|
42
42
|
paginated?: boolean
|
|
43
|
+
counted?: boolean
|
|
43
44
|
}
|
|
44
45
|
>()
|
|
45
46
|
private onReconnect = () => this.resubscribeAll()
|
|
@@ -145,7 +146,8 @@ export class CuboCrdtClient<M> {
|
|
|
145
146
|
entity: sub.entity,
|
|
146
147
|
filters: sub.filters,
|
|
147
148
|
awareness: sub.awareness,
|
|
148
|
-
paginated: sub.paginated
|
|
149
|
+
paginated: sub.paginated,
|
|
150
|
+
counted: sub.counted
|
|
149
151
|
}
|
|
150
152
|
},
|
|
151
153
|
{ wait: true, timeout: 10_000 }
|
|
@@ -176,6 +178,29 @@ export class CuboCrdtClient<M> {
|
|
|
176
178
|
return Number.isFinite(page) && page > 0 && Number.isFinite(limit) && limit > 0
|
|
177
179
|
}
|
|
178
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Подписка на КОЛИЧЕСТВО строк под фильтрами.
|
|
183
|
+
*
|
|
184
|
+
* Возвращает реактивное число и живёт как обычная подписка: сервер пересчитывает
|
|
185
|
+
* count на любую мутацию сущности и присылает только totals — строки не грузятся,
|
|
186
|
+
* CRDT-документы не создаются.
|
|
187
|
+
*
|
|
188
|
+
* Зачем: счётчику (оранжевая точка инбокса, число задач в «Неразобранном») нужно одно
|
|
189
|
+
* число, а обычная подписка ради него держала бы весь набор строк — память на сервере,
|
|
190
|
+
* трафик на каждое их изменение и документы, которые никто не читает.
|
|
191
|
+
*
|
|
192
|
+
* const { count, unsubscribe } = crdt.useCount('events', { filters: { recipient: 5 } })
|
|
193
|
+
*/
|
|
194
|
+
public useCount<K extends Extract<keyof M, string>>(entity: K | string, opts?: CuboCrdtClientUseOptions) {
|
|
195
|
+
const list = this.useList(entity, { ...(opts || {}), counted: true })
|
|
196
|
+
const totals = list.totals()
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
...list,
|
|
200
|
+
count: computed(() => Number(totals.value?.count || 0))
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
179
204
|
public useComputedList<K extends Extract<keyof M, string>>(entity: K) {
|
|
180
205
|
return computed(() => (this.store[entity]?.state.rows || []) as M[K][])
|
|
181
206
|
}
|
|
@@ -209,8 +234,8 @@ export class CuboCrdtClient<M> {
|
|
|
209
234
|
|
|
210
235
|
this.store[storeKey] = {
|
|
211
236
|
state,
|
|
212
|
-
docs: new Map(),
|
|
213
|
-
awarenesses: new Map()
|
|
237
|
+
docs: shallowReactive(new Map()),
|
|
238
|
+
awarenesses: shallowReactive(new Map())
|
|
214
239
|
}
|
|
215
240
|
}
|
|
216
241
|
|
|
@@ -228,6 +253,7 @@ export class CuboCrdtClient<M> {
|
|
|
228
253
|
|
|
229
254
|
filters = filters !== undefined ? filters : opts?.filters
|
|
230
255
|
const paginated = this.resolvePaginated(filters, opts?.paginated)
|
|
256
|
+
const counted = !!opts?.counted
|
|
231
257
|
|
|
232
258
|
if (this.debug) {
|
|
233
259
|
console.log('[CRDT] subscribe', { entity, opts, filters })
|
|
@@ -256,7 +282,8 @@ export class CuboCrdtClient<M> {
|
|
|
256
282
|
storeKey,
|
|
257
283
|
filters,
|
|
258
284
|
awareness: opts?.awareness,
|
|
259
|
-
paginated
|
|
285
|
+
paginated,
|
|
286
|
+
counted
|
|
260
287
|
})
|
|
261
288
|
|
|
262
289
|
// подписываемся на бэке (если сокет ещё не подключён — переподпишемся по событию CONNECTED)
|
|
@@ -270,7 +297,8 @@ export class CuboCrdtClient<M> {
|
|
|
270
297
|
entity,
|
|
271
298
|
filters,
|
|
272
299
|
awareness: opts?.awareness,
|
|
273
|
-
paginated
|
|
300
|
+
paginated,
|
|
301
|
+
counted
|
|
274
302
|
}
|
|
275
303
|
},
|
|
276
304
|
{ wait: true, timeout: 10_000 }
|
|
@@ -489,7 +517,7 @@ export class CuboCrdtClient<M> {
|
|
|
489
517
|
.request(
|
|
490
518
|
{
|
|
491
519
|
method: CUBO_CRDT_EVENT.UPGRADE,
|
|
492
|
-
data: { subscribe_id, filters, paginated: sub?.paginated }
|
|
520
|
+
data: { subscribe_id, filters, paginated: sub?.paginated, counted: sub?.counted }
|
|
493
521
|
},
|
|
494
522
|
{ wait: true, timeout: 10_000 }
|
|
495
523
|
)
|
|
@@ -523,6 +551,47 @@ export class CuboCrdtClient<M> {
|
|
|
523
551
|
}
|
|
524
552
|
}
|
|
525
553
|
|
|
554
|
+
private ensureDocumentAwareness(
|
|
555
|
+
ctx: CuboCrdtClientSubscribeEvent,
|
|
556
|
+
opts: CuboCrdtClientDocUpdateOptions,
|
|
557
|
+
doc: Doc,
|
|
558
|
+
entity_id: number
|
|
559
|
+
) {
|
|
560
|
+
if (!opts.awareness) {
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
let awareness = this.store[opts.storeKey]?.awarenesses.get(entity_id)
|
|
565
|
+
|
|
566
|
+
if (awareness) {
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
awareness = new Awareness(doc)
|
|
571
|
+
|
|
572
|
+
awareness.on('update', ({ added, updated, removed }, origin) => {
|
|
573
|
+
if (origin === 'remote') {
|
|
574
|
+
return
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const changed = added.concat(updated).concat(removed)
|
|
578
|
+
const update = encodeAwarenessUpdate(awareness!, changed)
|
|
579
|
+
|
|
580
|
+
this.ws.request({
|
|
581
|
+
method: CUBO_CRDT_EVENT.EVENT,
|
|
582
|
+
data: {
|
|
583
|
+
action: 'awareness',
|
|
584
|
+
entity: ctx.entity,
|
|
585
|
+
entity_id,
|
|
586
|
+
data: Array.from(update),
|
|
587
|
+
origin: { expose: 'other', subscribe_id: opts.subscribe_id }
|
|
588
|
+
}
|
|
589
|
+
})
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
this.store[opts.storeKey]?.awarenesses.set(entity_id, awareness)
|
|
593
|
+
}
|
|
594
|
+
|
|
526
595
|
private async onDocumentCreate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
|
|
527
596
|
const entity_id = ctx.entity_id
|
|
528
597
|
if (entity_id === undefined) {
|
|
@@ -540,6 +609,7 @@ export class CuboCrdtClient<M> {
|
|
|
540
609
|
|
|
541
610
|
applyUpdate(existing, new Uint8Array(ctx.data as any), { react: false })
|
|
542
611
|
this.applyRowJson(opts.storeKey, entity_id, existing)
|
|
612
|
+
this.ensureDocumentAwareness(ctx, opts, existing, entity_id)
|
|
543
613
|
return
|
|
544
614
|
}
|
|
545
615
|
|
|
@@ -602,38 +672,7 @@ export class CuboCrdtClient<M> {
|
|
|
602
672
|
await opts.onAfterCreate(doc, update, ctx, opts)
|
|
603
673
|
}
|
|
604
674
|
|
|
605
|
-
|
|
606
|
-
let awareness = this.store[opts.storeKey]?.awarenesses.get(entity_id)
|
|
607
|
-
|
|
608
|
-
if (!awareness) {
|
|
609
|
-
awareness = new Awareness(doc)
|
|
610
|
-
|
|
611
|
-
awareness.on('update', ({ added, updated, removed }, origin) => {
|
|
612
|
-
// console.log('[CRDT] awarness update', origin)
|
|
613
|
-
|
|
614
|
-
if (origin === 'remote') {
|
|
615
|
-
return
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
const changed = added.concat(updated).concat(removed)
|
|
619
|
-
const update = encodeAwarenessUpdate(awareness, changed)
|
|
620
|
-
|
|
621
|
-
this.ws.request({
|
|
622
|
-
method: CUBO_CRDT_EVENT.EVENT,
|
|
623
|
-
data: {
|
|
624
|
-
action: 'awareness',
|
|
625
|
-
entity: ctx.entity,
|
|
626
|
-
entity_id,
|
|
627
|
-
data: Array.from(update),
|
|
628
|
-
// 'other' — не шлём свой же курсор обратно исходной подписке
|
|
629
|
-
origin: { expose: 'other', subscribe_id: opts.subscribe_id }
|
|
630
|
-
}
|
|
631
|
-
})
|
|
632
|
-
})
|
|
633
|
-
|
|
634
|
-
this.store[opts.storeKey]?.awarenesses.set(entity_id, awareness)
|
|
635
|
-
}
|
|
636
|
-
}
|
|
675
|
+
this.ensureDocumentAwareness(ctx, opts, doc, entity_id)
|
|
637
676
|
}
|
|
638
677
|
|
|
639
678
|
private async onDocumentUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
|
|
@@ -27,6 +27,11 @@ export type CuboCrdtClientUseOptions = CuboCrdtClientBaseOptions & {
|
|
|
27
27
|
filters?: Record<string, any>
|
|
28
28
|
awareness?: boolean
|
|
29
29
|
paginated?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* Подписка только на КОЛИЧЕСТВО строк под фильтрами: строки не грузятся и документы
|
|
32
|
+
* не создаются, приезжает лишь totals. Для счётчиков и индикаторов — см. useCount.
|
|
33
|
+
*/
|
|
34
|
+
counted?: boolean
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export type CuboCrdtClientSubscribeEvent = {
|
|
@@ -19,28 +19,7 @@ export class CuboCrdtServerDocument {
|
|
|
19
19
|
})
|
|
20
20
|
|
|
21
21
|
if (opts.awareness) {
|
|
22
|
-
this.
|
|
23
|
-
this.awareness.setLocalState(null)
|
|
24
|
-
|
|
25
|
-
this.awareness.on('update', ({ added, updated, removed }, origin: CuboCrdtServerDocumentOrigin) => {
|
|
26
|
-
const subId = origin?.subscribe_id
|
|
27
|
-
|
|
28
|
-
if (subId) {
|
|
29
|
-
let set = this.awarenessBySubscribe.get(subId)
|
|
30
|
-
if (!set) {
|
|
31
|
-
set = new Set()
|
|
32
|
-
this.awarenessBySubscribe.set(subId, set)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
added.concat(updated).forEach((id) => set!.add(id))
|
|
36
|
-
removed.forEach((id) => set!.delete(id))
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const changed = added.concat(updated).concat(removed)
|
|
40
|
-
const update = encodeAwarenessUpdate(this.awareness!, changed)
|
|
41
|
-
|
|
42
|
-
this.opts?.onAwarenessUpdate?.(update, origin)
|
|
43
|
-
})
|
|
22
|
+
this.enableAwareness()
|
|
44
23
|
}
|
|
45
24
|
|
|
46
25
|
this.persistedState = cloneDeep(opts.initialState ?? {})
|
|
@@ -72,6 +51,35 @@ export class CuboCrdtServerDocument {
|
|
|
72
51
|
})
|
|
73
52
|
}
|
|
74
53
|
|
|
54
|
+
public enableAwareness() {
|
|
55
|
+
if (this.awareness) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
this.awareness = new Awareness(this.ydoc)
|
|
60
|
+
this.awareness.setLocalState(null)
|
|
61
|
+
|
|
62
|
+
this.awareness.on('update', ({ added, updated, removed }, origin: CuboCrdtServerDocumentOrigin) => {
|
|
63
|
+
const subId = origin?.subscribe_id
|
|
64
|
+
|
|
65
|
+
if (subId) {
|
|
66
|
+
let set = this.awarenessBySubscribe.get(subId)
|
|
67
|
+
if (!set) {
|
|
68
|
+
set = new Set()
|
|
69
|
+
this.awarenessBySubscribe.set(subId, set)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
added.concat(updated).forEach((id) => set!.add(id))
|
|
73
|
+
removed.forEach((id) => set!.delete(id))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const changed = added.concat(updated).concat(removed)
|
|
77
|
+
const update = encodeAwarenessUpdate(this.awareness!, changed)
|
|
78
|
+
|
|
79
|
+
this.opts?.onAwarenessUpdate?.(update, origin)
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
75
83
|
public get name() {
|
|
76
84
|
return this.opts.name
|
|
77
85
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -49,7 +49,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
49
49
|
this.entities.forEach((e) => this.subscribesByEntity.set(e, new Set()))
|
|
50
50
|
|
|
51
51
|
this.ws.registerHandler(CUBO_CRDT_EVENT.SUBSCRIBE, async ({ client, message }) => {
|
|
52
|
-
const { subscribe_id: id, entity, filters, awareness = false, paginated } = message.data as CuboCrdtServerSubscribeDto
|
|
52
|
+
const { subscribe_id: id, entity, filters, awareness = false, paginated, counted } = message.data as CuboCrdtServerSubscribeDto
|
|
53
53
|
|
|
54
54
|
if (this.debug) {
|
|
55
55
|
console.log('[CRDT] subscribe', entity, filters)
|
|
@@ -67,6 +67,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
67
67
|
filters: cloneDeep(filters || {}),
|
|
68
68
|
awareness,
|
|
69
69
|
paginated: this.resolvePaginated(filters, paginated),
|
|
70
|
+
counted: !!counted,
|
|
70
71
|
row_ids: [],
|
|
71
72
|
totals: {},
|
|
72
73
|
revision: 0
|
|
@@ -87,7 +88,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
87
88
|
})
|
|
88
89
|
|
|
89
90
|
this.ws.registerHandler(CUBO_CRDT_EVENT.UPGRADE, async ({ client, message }) => {
|
|
90
|
-
const { subscribe_id: id, entity, filters, paginated } = message.data as CuboCrdtServerSubscribeDto
|
|
91
|
+
const { subscribe_id: id, entity, filters, paginated, counted } = message.data as CuboCrdtServerSubscribeDto
|
|
91
92
|
|
|
92
93
|
if (this.debug) {
|
|
93
94
|
console.log('[CRDT] upgrade', entity, filters)
|
|
@@ -99,7 +100,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
// обновляем подписку
|
|
102
|
-
await this.upgradeSubscribe(client, subscribe, { filters, paginated })
|
|
103
|
+
await this.upgradeSubscribe(client, subscribe, { filters, paginated, counted })
|
|
103
104
|
|
|
104
105
|
return { subscribe_id: id, revision: subscribe.revision }
|
|
105
106
|
})
|
|
@@ -523,6 +524,13 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
523
524
|
}
|
|
524
525
|
|
|
525
526
|
private shouldRefreshSubscribe(entity: E, subscribe: CuboCrdtServerSubscribe, mutation: CuboCrdtMutation<M[E]>) {
|
|
527
|
+
// Счётчик пересчитываем на любую мутацию сущности: количество — свойство ВЫБОРКИ,
|
|
528
|
+
// а не строки, и по одному событию понять, изменилось ли оно, нельзя. Строк мы при
|
|
529
|
+
// этом не держим, поэтому пересчёт стоит один запрос count, а не выгрузку набора.
|
|
530
|
+
if (subscribe.counted) {
|
|
531
|
+
return true
|
|
532
|
+
}
|
|
533
|
+
|
|
526
534
|
if (!subscribe.paginated) {
|
|
527
535
|
return false
|
|
528
536
|
}
|
|
@@ -582,7 +590,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
582
590
|
public invalidate(entity: E) {
|
|
583
591
|
for (const subscribe_id of this.subscribesByEntity.get(entity) || []) {
|
|
584
592
|
const subscribe = this.subscribes.get(subscribe_id)
|
|
585
|
-
if (subscribe?.paginated) {
|
|
593
|
+
if (subscribe?.paginated || subscribe?.counted) {
|
|
586
594
|
this.requestSubscribeRefresh(subscribe_id)
|
|
587
595
|
}
|
|
588
596
|
}
|
|
@@ -597,6 +605,10 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
597
605
|
|
|
598
606
|
let document = this.documents.get(documentName)
|
|
599
607
|
if (document) {
|
|
608
|
+
if (awareness) {
|
|
609
|
+
document.enableAwareness()
|
|
610
|
+
}
|
|
611
|
+
|
|
600
612
|
// Освежаем существующий документ строкой из БД.
|
|
601
613
|
//
|
|
602
614
|
// Раньше документ возвращался как есть, а подписка отправляла клиенту его
|
|
@@ -953,6 +965,33 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
953
965
|
return false
|
|
954
966
|
}
|
|
955
967
|
|
|
968
|
+
// Счётчику строки не нужны: документы не создаём и не связываем, храним только
|
|
969
|
+
// totals. Иначе подписка ради одного числа тянула бы весь набор в память сервера
|
|
970
|
+
// и рассылала бы клиенту каждое его изменение.
|
|
971
|
+
if (subscribe.counted) {
|
|
972
|
+
const totalsChanged = !areCrdtListTotalsEqual(subscribe.totals, result.totals)
|
|
973
|
+
|
|
974
|
+
subscribe.totals = result.totals
|
|
975
|
+
|
|
976
|
+
if (!forceSync && !totalsChanged) {
|
|
977
|
+
return true
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
subscribe.revision += 1
|
|
981
|
+
|
|
982
|
+
// Тот же sync, что и у пагинированной подписки, только со всегда пустым списком id:
|
|
983
|
+
// клиент уже умеет его принимать и обновлять totals/revision — отдельный тип
|
|
984
|
+
// сообщения пришлось бы поддерживать в обоих концах.
|
|
985
|
+
this.sendToClient(subscribe.client_id, {
|
|
986
|
+
action: 'sync',
|
|
987
|
+
entity: subscribe.entity,
|
|
988
|
+
subscribe_id: subscribe.id,
|
|
989
|
+
data: { ids: [], totals: subscribe.totals, revision: subscribe.revision }
|
|
990
|
+
})
|
|
991
|
+
|
|
992
|
+
return true
|
|
993
|
+
}
|
|
994
|
+
|
|
956
995
|
const rowsById = new Map<number, any>()
|
|
957
996
|
const nextIds: number[] = []
|
|
958
997
|
|
|
@@ -1028,7 +1067,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
1028
1067
|
private async upgradeSubscribe(
|
|
1029
1068
|
client: WsServerSocket,
|
|
1030
1069
|
subscribe: CuboCrdtServerSubscribe,
|
|
1031
|
-
upgrade: Partial<Pick<CuboCrdtServerSubscribe, 'filters' | 'paginated'>>
|
|
1070
|
+
upgrade: Partial<Pick<CuboCrdtServerSubscribe, 'filters' | 'paginated' | 'counted'>>
|
|
1032
1071
|
) {
|
|
1033
1072
|
if (this.debug) {
|
|
1034
1073
|
console.log('[CRDT] upgrade subscribe ' + client.id, subscribe)
|
|
@@ -11,6 +11,14 @@ export type CuboCrdtServerSubscribeDto = {
|
|
|
11
11
|
}
|
|
12
12
|
awareness?: boolean
|
|
13
13
|
paginated?: boolean
|
|
14
|
+
/**
|
|
15
|
+
* Подписка только на КОЛИЧЕСТВО строк под фильтрами.
|
|
16
|
+
*
|
|
17
|
+
* Документы при этом не создаются вовсе: клиенту нужно одно число (счётчик,
|
|
18
|
+
* индикатор непрочитанного), а держать ради него весь набор строк — держать
|
|
19
|
+
* лишнюю память на сервере и лишний трафик на каждое их изменение.
|
|
20
|
+
*/
|
|
21
|
+
counted?: boolean
|
|
14
22
|
}
|
|
15
23
|
|
|
16
24
|
export type CuboCrdtServerSubscribe = {
|
|
@@ -19,7 +27,7 @@ export type CuboCrdtServerSubscribe = {
|
|
|
19
27
|
row_ids: number[]
|
|
20
28
|
totals: CuboCrdtListTotals
|
|
21
29
|
revision: number
|
|
22
|
-
} & Pick<CuboCrdtServerSubscribeDto, 'entity' | 'filters' | 'awareness' | 'paginated'>
|
|
30
|
+
} & Pick<CuboCrdtServerSubscribeDto, 'entity' | 'filters' | 'awareness' | 'paginated' | 'counted'>
|
|
23
31
|
|
|
24
32
|
export type CuboCrdtSubscribeRefreshState = {
|
|
25
33
|
dirty: boolean
|