@cuboapp/crdt 1.0.20 → 1.0.22

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.20",
3
+ "version": "1.0.22",
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
 
@@ -234,8 +234,8 @@ export class CuboCrdtClient<M> {
234
234
 
235
235
  this.store[storeKey] = {
236
236
  state,
237
- docs: new Map(),
238
- awarenesses: new Map()
237
+ docs: shallowReactive(new Map()),
238
+ awarenesses: shallowReactive(new Map())
239
239
  }
240
240
  }
241
241
 
@@ -450,47 +450,51 @@ export class CuboCrdtClient<M> {
450
450
  dto: K extends Extract<keyof M, string> ? Partial<M[K]> : object,
451
451
  opts?: CuboCrdtClientDocOrigin
452
452
  ) {
453
- const cardRow = this.store[`${entity}:${entity_id}`]?.state.rows?.[0]
454
- const listRow = this.store[entity]?.state?.rows.find((r: any) => r.id === entity_id)
455
- const cardDoc = this.store[`${entity}:${entity_id}`]?.docs.get(entity_id)
456
- const listDoc = this.store[entity]?.docs.get(entity_id)
457
-
458
- // обновляем документы
459
- for (const doc of [cardDoc, listDoc]) {
460
- if (doc) {
461
- const map = doc.getMap()
462
-
463
- const row = (cardRow || listRow) as any
464
- const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => row[key] !== value))
465
-
466
- if (Object.keys(toUpdate).length) {
467
- const origin: CuboCrdtClientDocOrigin = {
468
- store: opts?.store ?? true,
469
- expose: opts?.expose ?? 'other',
470
- keys: opts?.keys ?? Object.keys(toUpdate),
471
- react: true // отправляем на бэк
472
- }
453
+ const entityStores = [...new Set(
454
+ [...this.subscriptions.values()]
455
+ .filter((subscription) => subscription.entity === entity)
456
+ .map((subscription) => this.store[subscription.storeKey])
457
+ )]
458
+ const fallbackStores = [this.store[entity], this.store[`${entity}:${entity_id}`]]
459
+ const store = [...entityStores, ...fallbackStores]
460
+ .find((candidate) => candidate?.docs.has(entity_id))
461
+ const document = store?.docs.get(entity_id)
462
+
463
+ if (!store || !document) {
464
+ return
465
+ }
473
466
 
474
- doc.transact(() => {
475
- Object.entries(toUpdate).forEach(([key, value]) => {
476
- map.set(key, value)
477
- })
478
- }, origin)
479
- }
480
- }
467
+ const map = document.getMap()
468
+ const toUpdate = Object.fromEntries(
469
+ Object.entries(dto).filter(([key, value]) => map.get(key) !== value)
470
+ )
471
+
472
+ if (!Object.keys(toUpdate).length) {
473
+ return
481
474
  }
482
475
 
483
- // обновляем реактивку
484
- for (const row of [cardRow, listRow]) {
485
- if (row) {
486
- const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => row[key as keyof typeof row] !== value))
476
+ const origin: CuboCrdtClientDocOrigin = {
477
+ store: opts?.store ?? true,
478
+ expose: opts?.expose ?? 'other',
479
+ keys: opts?.keys ?? Object.keys(toUpdate),
480
+ react: true
481
+ }
487
482
 
488
- if (Object.keys(toUpdate).length) {
489
- Object.entries(toUpdate).forEach(([key, value]) => {
490
- row[key as keyof typeof row] = value
491
- })
492
- }
493
- }
483
+ // Одна сущность может присутствовать в нескольких подписках с разными
484
+ // storeKey. Мутацию отправляем только из одного документа: остальные
485
+ // подписки получат подтверждённый update через обычную CRDT-рассылку.
486
+ document.transact(() => {
487
+ Object.entries(toUpdate).forEach(([key, value]) => {
488
+ map.set(key, value)
489
+ })
490
+ }, origin)
491
+
492
+ const row = store.state.rows.find((item: any) => Number(item.id) === Number(entity_id))
493
+
494
+ if (row) {
495
+ Object.entries(toUpdate).forEach(([key, value]) => {
496
+ row[key] = value
497
+ })
494
498
  }
495
499
  }
496
500
 
@@ -551,6 +555,47 @@ export class CuboCrdtClient<M> {
551
555
  }
552
556
  }
553
557
 
558
+ private ensureDocumentAwareness(
559
+ ctx: CuboCrdtClientSubscribeEvent,
560
+ opts: CuboCrdtClientDocUpdateOptions,
561
+ doc: Doc,
562
+ entity_id: number
563
+ ) {
564
+ if (!opts.awareness) {
565
+ return
566
+ }
567
+
568
+ let awareness = this.store[opts.storeKey]?.awarenesses.get(entity_id)
569
+
570
+ if (awareness) {
571
+ return
572
+ }
573
+
574
+ awareness = new Awareness(doc)
575
+
576
+ awareness.on('update', ({ added, updated, removed }, origin) => {
577
+ if (origin === 'remote') {
578
+ return
579
+ }
580
+
581
+ const changed = added.concat(updated).concat(removed)
582
+ const update = encodeAwarenessUpdate(awareness!, changed)
583
+
584
+ this.ws.request({
585
+ method: CUBO_CRDT_EVENT.EVENT,
586
+ data: {
587
+ action: 'awareness',
588
+ entity: ctx.entity,
589
+ entity_id,
590
+ data: Array.from(update),
591
+ origin: { expose: 'other', subscribe_id: opts.subscribe_id }
592
+ }
593
+ })
594
+ })
595
+
596
+ this.store[opts.storeKey]?.awarenesses.set(entity_id, awareness)
597
+ }
598
+
554
599
  private async onDocumentCreate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
555
600
  const entity_id = ctx.entity_id
556
601
  if (entity_id === undefined) {
@@ -568,6 +613,7 @@ export class CuboCrdtClient<M> {
568
613
 
569
614
  applyUpdate(existing, new Uint8Array(ctx.data as any), { react: false })
570
615
  this.applyRowJson(opts.storeKey, entity_id, existing)
616
+ this.ensureDocumentAwareness(ctx, opts, existing, entity_id)
571
617
  return
572
618
  }
573
619
 
@@ -630,38 +676,7 @@ export class CuboCrdtClient<M> {
630
676
  await opts.onAfterCreate(doc, update, ctx, opts)
631
677
  }
632
678
 
633
- if (opts?.awareness) {
634
- let awareness = this.store[opts.storeKey]?.awarenesses.get(entity_id)
635
-
636
- if (!awareness) {
637
- awareness = new Awareness(doc)
638
-
639
- awareness.on('update', ({ added, updated, removed }, origin) => {
640
- // console.log('[CRDT] awarness update', origin)
641
-
642
- if (origin === 'remote') {
643
- return
644
- }
645
-
646
- const changed = added.concat(updated).concat(removed)
647
- const update = encodeAwarenessUpdate(awareness, changed)
648
-
649
- this.ws.request({
650
- method: CUBO_CRDT_EVENT.EVENT,
651
- data: {
652
- action: 'awareness',
653
- entity: ctx.entity,
654
- entity_id,
655
- data: Array.from(update),
656
- // 'other' — не шлём свой же курсор обратно исходной подписке
657
- origin: { expose: 'other', subscribe_id: opts.subscribe_id }
658
- }
659
- })
660
- })
661
-
662
- this.store[opts.storeKey]?.awarenesses.set(entity_id, awareness)
663
- }
664
- }
679
+ this.ensureDocumentAwareness(ctx, opts, doc, entity_id)
665
680
  }
666
681
 
667
682
  private async onDocumentUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
@@ -1,12 +1,23 @@
1
- import { cloneDeep, debounce, pick } from '@cuboapp/utils'
1
+ import { cloneDeep, pick } from '@cuboapp/utils'
2
2
  import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs'
3
3
  import { Awareness, encodeAwarenessUpdate } from 'y-protocols/awareness'
4
4
 
5
- import { CuboCrdtServerDocumentOptions, CuboCrdtServerDocumentOrigin } from '../types'
5
+ import {
6
+ CuboCrdtServerDocumentOptions,
7
+ CuboCrdtServerDocumentOrigin,
8
+ CuboCrdtServerDocumentPendingStore
9
+ } from '../types'
10
+ import { getCrdtStoreChangeKey } from '../utils'
11
+
12
+ const STORE_DEBOUNCE_MS = 300
6
13
 
7
14
  export class CuboCrdtServerDocument {
8
15
  private ydoc: Doc
9
16
  private persistedState: object = {}
17
+ private observedState: object = {}
18
+ private storeQueue: Promise<void> = Promise.resolve()
19
+ private pendingStore?: CuboCrdtServerDocumentPendingStore
20
+ private storeTimeout?: ReturnType<typeof setTimeout>
10
21
  public awareness: Awareness
11
22
  public awarenessBySubscribe = new Map<string, Set<number>>()
12
23
 
@@ -19,31 +30,11 @@ export class CuboCrdtServerDocument {
19
30
  })
20
31
 
21
32
  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
- })
33
+ this.enableAwareness()
44
34
  }
45
35
 
46
36
  this.persistedState = cloneDeep(opts.initialState ?? {})
37
+ this.observedState = cloneDeep(opts.initialState ?? {})
47
38
 
48
39
  // записываем исходное состояние
49
40
  this.write(opts.initialState ?? {})
@@ -58,7 +49,8 @@ export class CuboCrdtServerDocument {
58
49
  this.opts?.onUpdate?.(data, origin)
59
50
  // }
60
51
 
61
- // если нужно сохранять стейт документа - вызываем store по дебаунсу
52
+ // Состояние отслеживается синхронно с Y.Doc. Для клиентских изменений
53
+ // debounceStore сам обновит observedState после вычисления leaf-diff.
62
54
  if (origin?.store) {
63
55
  const row = this.getJson()
64
56
  const body = pick(row, origin.keys ?? Object.keys(row))
@@ -68,10 +60,41 @@ export class CuboCrdtServerDocument {
68
60
  console.error('[CRDT] store document', this.name, error)
69
61
  })
70
62
  }
63
+ } else {
64
+ this.observedState = cloneDeep(this.getJson())
71
65
  }
72
66
  })
73
67
  }
74
68
 
69
+ public enableAwareness() {
70
+ if (this.awareness) {
71
+ return
72
+ }
73
+
74
+ this.awareness = new Awareness(this.ydoc)
75
+ this.awareness.setLocalState(null)
76
+
77
+ this.awareness.on('update', ({ added, updated, removed }, origin: CuboCrdtServerDocumentOrigin) => {
78
+ const subId = origin?.subscribe_id
79
+
80
+ if (subId) {
81
+ let set = this.awarenessBySubscribe.get(subId)
82
+ if (!set) {
83
+ set = new Set()
84
+ this.awarenessBySubscribe.set(subId, set)
85
+ }
86
+
87
+ added.concat(updated).forEach((id) => set!.add(id))
88
+ removed.forEach((id) => set!.delete(id))
89
+ }
90
+
91
+ const changed = added.concat(updated).concat(removed)
92
+ const update = encodeAwarenessUpdate(this.awareness!, changed)
93
+
94
+ this.opts?.onAwarenessUpdate?.(update, origin)
95
+ })
96
+ }
97
+
75
98
  public get name() {
76
99
  return this.opts.name
77
100
  }
@@ -129,17 +152,117 @@ export class CuboCrdtServerDocument {
129
152
  }
130
153
 
131
154
  destroy() {
155
+ this.flushPendingStore()
132
156
  this.awareness?.destroy()
133
157
  this.ydoc.destroy()
134
158
  }
135
159
 
136
- public async store(body: object, origin: CuboCrdtServerDocumentOrigin) {
137
- const previousRow = this.getPersistedState()
138
- const row = this.getJson()
160
+ public store(body: object, origin: CuboCrdtServerDocumentOrigin, row = this.getJson()) {
161
+ const queuedBody = cloneDeep(body)
162
+ const queuedRow = cloneDeep(row)
163
+
164
+ // debounce ограничивает частоту вызовов, но не сериализует async store.
165
+ // Без очереди следующий PATCH мог завершиться раньше предыдущего и затем
166
+ // быть затёрт старым полным снимком составного поля (например case.extra).
167
+ const current = this.storeQueue
168
+ .catch(() => undefined)
169
+ .then(async () => {
170
+ const previousRow = this.getPersistedState()
171
+
172
+ await this.opts?.onStore?.(queuedBody, origin, {
173
+ previousRow,
174
+ row: queuedRow
175
+ })
176
+ this.setPersistedState(queuedRow)
177
+ })
178
+
179
+ this.storeQueue = current
180
+ return current
181
+ }
182
+
183
+ /**
184
+ * Схлопывает только соседние изменения одного origin и одного набора
185
+ * leaf-путей. Смена пользователя или поля немедленно фиксирует предыдущий
186
+ * пакет, сохраняя фактический порядок collaborative-обновлений.
187
+ */
188
+ public debounceStore(body: object, origin: CuboCrdtServerDocumentOrigin) {
189
+ const row = cloneDeep(this.getJson())
190
+ const bodyKeys = Object.keys(body)
191
+ const previousBody = pick(this.observedState, bodyKeys)
192
+ const changeKey = getCrdtStoreChangeKey(previousBody, body)
193
+ const originKey = `${origin.client_id || ''}:${origin.subscribe_id || ''}`
194
+
195
+ this.observedState = cloneDeep(row)
196
+
197
+ if (!changeKey) {
198
+ return Promise.resolve()
199
+ }
200
+
201
+ return new Promise<void>((resolve, reject) => {
202
+ const sameBatch = this.pendingStore?.originKey === originKey &&
203
+ this.pendingStore.changeKey === changeKey
204
+
205
+ if (this.pendingStore && !sameBatch) {
206
+ this.flushPendingStore()
207
+ }
208
+
209
+ if (!this.pendingStore) {
210
+ this.pendingStore = {
211
+ body: cloneDeep(body),
212
+ changeKey,
213
+ origin: cloneDeep(origin),
214
+ originKey,
215
+ row,
216
+ waiters: []
217
+ }
218
+ } else {
219
+ const keys = new Set([
220
+ ...(this.pendingStore.origin.keys || []),
221
+ ...(origin.keys || [])
222
+ ])
223
+
224
+ this.pendingStore.body = {
225
+ ...this.pendingStore.body,
226
+ ...cloneDeep(body)
227
+ }
228
+ this.pendingStore.origin = {
229
+ ...cloneDeep(origin),
230
+ keys: [...keys]
231
+ }
232
+ this.pendingStore.row = row
233
+ }
139
234
 
140
- await this.opts?.onStore?.(body, origin, { previousRow, row })
141
- this.setPersistedState(row)
235
+ this.pendingStore.waiters.push({ resolve, reject })
236
+ this.schedulePendingStore()
237
+ })
142
238
  }
143
239
 
144
- public debounceStore = debounce(this.store.bind(this), 300)
240
+ /** Перезапускает trailing debounce для текущего однородного пакета. */
241
+ private schedulePendingStore() {
242
+ if (this.storeTimeout) {
243
+ clearTimeout(this.storeTimeout)
244
+ }
245
+
246
+ this.storeTimeout = setTimeout(() => this.flushPendingStore(), STORE_DEBOUNCE_MS)
247
+ }
248
+
249
+ /** Передаёт накопленный пакет в общую последовательную очередь записи. */
250
+ private flushPendingStore() {
251
+ if (!this.pendingStore) {
252
+ return
253
+ }
254
+
255
+ if (this.storeTimeout) {
256
+ clearTimeout(this.storeTimeout)
257
+ this.storeTimeout = undefined
258
+ }
259
+
260
+ const pending = this.pendingStore
261
+ this.pendingStore = undefined
262
+
263
+ this.store(pending.body, pending.origin, pending.row).then(
264
+ () => pending.waiters.forEach(({ resolve }) => resolve()),
265
+ (error) => pending.waiters.forEach(({ reject }) => reject(error))
266
+ )
267
+ }
145
268
  }
@@ -605,6 +605,10 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
605
605
 
606
606
  let document = this.documents.get(documentName)
607
607
  if (document) {
608
+ if (awareness) {
609
+ document.enableAwareness()
610
+ }
611
+
608
612
  // Освежаем существующий документ строкой из БД.
609
613
  //
610
614
  // Раньше документ возвращался как есть, а подписка отправляла клиенту его
@@ -5,6 +5,20 @@ export type CuboCrdtServerDocumentStoreContext = {
5
5
  row: object
6
6
  }
7
7
 
8
+ export type CuboCrdtServerDocumentStoreWaiter = {
9
+ resolve: () => void
10
+ reject: (reason?: unknown) => void
11
+ }
12
+
13
+ export type CuboCrdtServerDocumentPendingStore = {
14
+ body: object
15
+ changeKey: string
16
+ origin: CuboCrdtServerDocumentOrigin
17
+ originKey: string
18
+ row: object
19
+ waiters: CuboCrdtServerDocumentStoreWaiter[]
20
+ }
21
+
8
22
  export type CuboCrdtServerDocumentOptions = {
9
23
  name: string
10
24
  onStore?: (
@@ -0,0 +1,41 @@
1
+ const isObject = (value: unknown): value is Record<string, unknown> => {
2
+ return value !== null && typeof value === 'object'
3
+ }
4
+
5
+ const collectChangedPaths = (
6
+ previous: unknown,
7
+ current: unknown,
8
+ path: string,
9
+ result: string[]
10
+ ) => {
11
+ if (Object.is(previous, current)) {
12
+ return
13
+ }
14
+
15
+ if (!isObject(previous) || !isObject(current)) {
16
+ result.push(path || '$')
17
+ return
18
+ }
19
+
20
+ const keys = new Set([
21
+ ...Object.keys(previous),
22
+ ...Object.keys(current)
23
+ ])
24
+
25
+ for (const key of [...keys].sort()) {
26
+ const nextPath = Array.isArray(previous) || Array.isArray(current)
27
+ ? `${path}[${key}]`
28
+ : path ? `${path}.${key}` : key
29
+
30
+ collectChangedPaths(previous[key], current[key], nextPath, result)
31
+ }
32
+ }
33
+
34
+ /** Возвращает стабильный ключ фактически изменённых leaf-путей объекта. */
35
+ export const getCrdtStoreChangeKey = (previous: object, current: object) => {
36
+ const paths: string[] = []
37
+
38
+ collectChangedPaths(previous, current, '', paths)
39
+
40
+ return paths.join('|')
41
+ }