@cuboapp/crdt 1.0.18 → 1.0.20

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.18",
3
+ "version": "1.0.20",
4
4
  "description": "CRDT for CUBO",
5
5
  "main": "src/index.ts",
6
6
  "repository": {
@@ -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
  }
@@ -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
  )
@@ -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 = {
@@ -64,7 +64,9 @@ export class CuboCrdtServerDocument {
64
64
  const body = pick(row, origin.keys ?? Object.keys(row))
65
65
 
66
66
  if (Object.keys(body).length) {
67
- this.debounceStore(body, origin)
67
+ this.debounceStore(body, origin).catch((error) => {
68
+ console.error('[CRDT] store document', this.name, error)
69
+ })
68
70
  }
69
71
  }
70
72
  })
@@ -102,7 +104,7 @@ export class CuboCrdtServerDocument {
102
104
  this.persistedState = cloneDeep(row)
103
105
  }
104
106
 
105
- async write(dto: object, origin?: CuboCrdtServerDocumentOrigin) {
107
+ write(dto: object, origin?: CuboCrdtServerDocumentOrigin) {
106
108
  const map = this.getMap()
107
109
 
108
110
  // Пропускаем скалярные ключи с тем же значением: Y.Map.set всегда создаёт новый Item и
@@ -138,5 +140,6 @@ export class CuboCrdtServerDocument {
138
140
  await this.opts?.onStore?.(body, origin, { previousRow, row })
139
141
  this.setPersistedState(row)
140
142
  }
143
+
141
144
  public debounceStore = debounce(this.store.bind(this), 300)
142
145
  }
@@ -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
  }
@@ -953,6 +961,33 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
953
961
  return false
954
962
  }
955
963
 
964
+ // Счётчику строки не нужны: документы не создаём и не связываем, храним только
965
+ // totals. Иначе подписка ради одного числа тянула бы весь набор в память сервера
966
+ // и рассылала бы клиенту каждое его изменение.
967
+ if (subscribe.counted) {
968
+ const totalsChanged = !areCrdtListTotalsEqual(subscribe.totals, result.totals)
969
+
970
+ subscribe.totals = result.totals
971
+
972
+ if (!forceSync && !totalsChanged) {
973
+ return true
974
+ }
975
+
976
+ subscribe.revision += 1
977
+
978
+ // Тот же sync, что и у пагинированной подписки, только со всегда пустым списком id:
979
+ // клиент уже умеет его принимать и обновлять totals/revision — отдельный тип
980
+ // сообщения пришлось бы поддерживать в обоих концах.
981
+ this.sendToClient(subscribe.client_id, {
982
+ action: 'sync',
983
+ entity: subscribe.entity,
984
+ subscribe_id: subscribe.id,
985
+ data: { ids: [], totals: subscribe.totals, revision: subscribe.revision }
986
+ })
987
+
988
+ return true
989
+ }
990
+
956
991
  const rowsById = new Map<number, any>()
957
992
  const nextIds: number[] = []
958
993
 
@@ -1028,7 +1063,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
1028
1063
  private async upgradeSubscribe(
1029
1064
  client: WsServerSocket,
1030
1065
  subscribe: CuboCrdtServerSubscribe,
1031
- upgrade: Partial<Pick<CuboCrdtServerSubscribe, 'filters' | 'paginated'>>
1066
+ upgrade: Partial<Pick<CuboCrdtServerSubscribe, 'filters' | 'paginated' | 'counted'>>
1032
1067
  ) {
1033
1068
  if (this.debug) {
1034
1069
  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