@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cuboapp/crdt",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
4
4
  "description": "CRDT for CUBO",
5
5
  "main": "src/index.ts",
6
6
  "repository": {
@@ -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
- if (opts?.awareness) {
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.awareness = new Awareness(this.ydoc)
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
  }
@@ -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