@cuboapp/crdt 1.0.22 → 1.0.24

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.22",
3
+ "version": "1.0.24",
4
4
  "description": "CRDT for CUBO",
5
5
  "main": "src/index.ts",
6
6
  "repository": {
@@ -1,19 +1,16 @@
1
1
  import { keyBy, pick, uuid } from '@cuboapp/utils'
2
2
  import { WsClientEvent } from '@cuboapp/ws'
3
- import { computed, reactive, shallowReactive } from 'vue'
3
+ import { computed, reactive } from 'vue'
4
4
  import { applyAwarenessUpdate, Awareness, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'
5
5
  import { applyUpdate, Doc } from 'yjs'
6
6
 
7
7
  import { CUBO_CRDT_EVENT } from '../constants'
8
8
  import { CuboCrdtServerDocumentIncomingAction } from '../server'
9
- import {
10
- CuboCrdtKey,
11
- CuboCrdtListSyncData,
12
- CuboCrdtSubscriptionStatus
13
- } from '../types'
9
+ import { CuboCrdtKey, CuboCrdtListSyncData, CuboCrdtSubscriptionStatus } from '../types'
14
10
 
15
11
  import { AsyncSerialQueue } from './queue'
16
12
  import {
13
+ CuboCrdtClientDoc,
17
14
  CuboCrdtClientDocOrigin,
18
15
  CuboCrdtClientDocUpdateOptions,
19
16
  CuboCrdtClientList,
@@ -28,6 +25,7 @@ export * from './types'
28
25
 
29
26
  export class CuboCrdtClient<M> {
30
27
  public store: CuboCrdtClientStore<M> = {}
28
+ public docs = new Map<string, CuboCrdtClientDoc>()
31
29
 
32
30
  private listeners: Map<string, Map<string, (ctx: CuboCrdtClientSubscribeEvent) => void | Promise<void>>> = new Map()
33
31
 
@@ -234,8 +232,7 @@ export class CuboCrdtClient<M> {
234
232
 
235
233
  this.store[storeKey] = {
236
234
  state,
237
- docs: shallowReactive(new Map()),
238
- awarenesses: shallowReactive(new Map())
235
+ awarenesses: new Map()
239
236
  }
240
237
  }
241
238
 
@@ -260,6 +257,7 @@ export class CuboCrdtClient<M> {
260
257
  }
261
258
 
262
259
  // подписываемся на фронте
260
+ // console.log('subscribe', opts)
263
261
  this.listeners.get(entity)?.set(subscribe_id, async (ctx) => {
264
262
  await this.onIncomingUpdate(ctx, {
265
263
  subscribe_id,
@@ -330,13 +328,11 @@ export class CuboCrdtClient<M> {
330
328
 
331
329
  // отписываемся на бэке
332
330
  if (this.ws.connected) {
333
- void this.ws
334
- .request({ method: CUBO_CRDT_EVENT.UNSUBSCRIBE, data: { subscribe_id } })
335
- .catch((e) => {
336
- if (this.debug) {
337
- console.warn('[CRDT] unsubscribe request failed (socket down)', subscribe_id, e)
338
- }
339
- })
331
+ void this.ws.request({ method: CUBO_CRDT_EVENT.UNSUBSCRIBE, data: { subscribe_id } }).catch((e) => {
332
+ if (this.debug) {
333
+ console.warn('[CRDT] unsubscribe request failed (socket down)', subscribe_id, e)
334
+ }
335
+ })
340
336
  }
341
337
 
342
338
  // отписываемся на фронте
@@ -358,8 +354,18 @@ export class CuboCrdtClient<M> {
358
354
  a.destroy()
359
355
  })
360
356
  item.awarenesses?.clear()
361
- item.docs?.forEach((d) => d.destroy())
362
- item.docs?.clear()
357
+
358
+ item.state.rows?.map((row) => {
359
+ const doc = this.docs.get(`${entity}:${(row as any).id}`)
360
+ if (doc) {
361
+ doc.subscribes.delete(subscribe_id)
362
+
363
+ if (doc.subscribes?.size === 0) {
364
+ doc.doc.destroy()
365
+ this.docs.delete(`${entity}:${(row as any).id}`)
366
+ }
367
+ }
368
+ })
363
369
  }
364
370
 
365
371
  this.store[storeKey] = undefined
@@ -383,7 +389,6 @@ export class CuboCrdtClient<M> {
383
389
  subscribe,
384
390
  upgrade,
385
391
  unsubscribe,
386
- docs: computed(() => this.store[storeKey]?.docs),
387
392
  awarenesses: opts?.awareness ? computed(() => this.store[storeKey]?.awarenesses) : undefined,
388
393
  subscribed: () => computed(() => this.store[storeKey]?.state.subscribed || false),
389
394
  loading: () => computed(() => this.store[storeKey]?.state.loading || false),
@@ -404,30 +409,18 @@ export class CuboCrdtClient<M> {
404
409
  ): CuboCrdtClientRow<T> {
405
410
  // console.log('store key', opts?.storeKey ?? `${entity}:${id}`)
406
411
 
407
- const {
408
- subscribe_id,
409
- storeKey,
410
- docs,
411
- awarenesses,
412
- subscribe,
413
- upgrade,
414
- unsubscribe,
415
- subscribed,
416
- loading,
417
- ready,
418
- status,
419
- error
420
- } = this.useList(entity, {
421
- ...opts,
422
- filters: opts?.filters ?? { id },
423
- storeKey: opts?.storeKey ?? `${entity}:${id}`
424
- })
412
+ const { subscribe_id, storeKey, awarenesses, subscribe, upgrade, unsubscribe, subscribed, loading, ready, status, error } =
413
+ this.useList(entity, {
414
+ ...opts,
415
+ filters: opts?.filters ?? { id },
416
+ storeKey: opts?.storeKey ?? `${entity}:${id}`
417
+ })
425
418
 
426
419
  return {
427
420
  subscribe_id,
428
421
  storeKey,
429
- doc: computed(() => docs.value?.get(id)),
430
- awareness: opts?.awareness ? computed(() => awarenesses?.value?.get(id)) : undefined,
422
+ doc: this.docs.get(`${entity}:${id}`)?.doc,
423
+ awareness: computed(() => awarenesses?.value?.get(id)),
431
424
  subscribe,
432
425
  upgrade,
433
426
  unsubscribe,
@@ -450,51 +443,47 @@ export class CuboCrdtClient<M> {
450
443
  dto: K extends Extract<keyof M, string> ? Partial<M[K]> : object,
451
444
  opts?: CuboCrdtClientDocOrigin
452
445
  ) {
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
- }
466
-
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
474
- }
446
+ const storeKey = `${entity}:${entity_id}`
447
+
448
+ const cardRow = this.store[storeKey]?.state.rows?.[0]
449
+ const listRow = this.store[entity]?.state?.rows.find((r: any) => r.id === entity_id)
450
+ const doc = this.docs.get(storeKey)
451
+
452
+ // обновляем документ
453
+ if (doc) {
454
+ const map = doc.doc.getMap()
455
+
456
+ // "или" - потому что они синхронизированы (без разницы какой брать)
457
+ const row = (cardRow || listRow) as any
458
+ const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => row[key] !== value))
459
+
460
+ if (Object.keys(toUpdate).length) {
461
+ const origin: CuboCrdtClientDocOrigin = {
462
+ store: opts?.store ?? true,
463
+ expose: opts?.expose ?? 'other',
464
+ keys: opts?.keys ?? Object.keys(toUpdate),
465
+ react: true // отправляем на бэк
466
+ }
475
467
 
476
- const origin: CuboCrdtClientDocOrigin = {
477
- store: opts?.store ?? true,
478
- expose: opts?.expose ?? 'other',
479
- keys: opts?.keys ?? Object.keys(toUpdate),
480
- react: true
468
+ doc.doc.transact(() => {
469
+ Object.entries(toUpdate).forEach(([key, value]) => {
470
+ map.set(key, value)
471
+ })
472
+ }, origin)
473
+ }
481
474
  }
482
475
 
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))
476
+ // обновляем реактивку
477
+ for (const row of [cardRow, listRow]) {
478
+ if (row) {
479
+ const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => row[key as keyof typeof row] !== value))
493
480
 
494
- if (row) {
495
- Object.entries(toUpdate).forEach(([key, value]) => {
496
- row[key] = value
497
- })
481
+ if (Object.keys(toUpdate).length) {
482
+ Object.entries(toUpdate).forEach(([key, value]) => {
483
+ row[key as keyof typeof row] = value
484
+ })
485
+ }
486
+ }
498
487
  }
499
488
  }
500
489
 
@@ -555,54 +544,13 @@ export class CuboCrdtClient<M> {
555
544
  }
556
545
  }
557
546
 
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
-
599
547
  private async onDocumentCreate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
600
548
  const entity_id = ctx.entity_id
601
549
  if (entity_id === undefined) {
602
550
  return
603
551
  }
604
552
 
605
- const existing = this.store[opts.storeKey]?.docs.get(entity_id)
553
+ const existing = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
606
554
  if (existing) {
607
555
  // документ уже есть локально (типичный кейс — повторный SUBSCRIBE после реконнекта).
608
556
  // вместо игнорирования вмёрживаем входящее состояние в существующий yjs-документ
@@ -611,13 +559,12 @@ export class CuboCrdtClient<M> {
611
559
  console.log('[CRDT] onDocumentCreate: doc already exists, merging state', { ctx, opts })
612
560
  }
613
561
 
614
- applyUpdate(existing, new Uint8Array(ctx.data as any), { react: false })
615
- this.applyRowJson(opts.storeKey, entity_id, existing)
616
- this.ensureDocumentAwareness(ctx, opts, existing, entity_id)
562
+ applyUpdate(existing.doc, new Uint8Array(ctx.data as any), { react: false })
563
+ this.applyRowJson(opts.storeKey, entity_id, existing.doc)
617
564
  return
618
565
  }
619
566
 
620
- const doc = new Doc()
567
+ const yjsDoc = new Doc()
621
568
 
622
569
  const update = new Uint8Array(ctx.data as any)
623
570
 
@@ -628,10 +575,10 @@ export class CuboCrdtClient<M> {
628
575
  }
629
576
  }
630
577
 
631
- applyUpdate(doc, update, { react: false })
578
+ applyUpdate(yjsDoc, update, { react: false })
632
579
 
633
580
  // подписываемся на обновления документа
634
- doc.on('update', (update, origin: CuboCrdtClientDocOrigin) => {
581
+ yjsDoc.on('update', (update, origin: CuboCrdtClientDocOrigin) => {
635
582
  // react = false, если это апдейт с бэка
636
583
  if (origin?.react !== false) {
637
584
  const data: CuboCrdtServerDocumentIncomingAction = {
@@ -657,10 +604,11 @@ export class CuboCrdtClient<M> {
657
604
  })
658
605
 
659
606
  // добавляем документ в хранилище документов
660
- this.store[opts.storeKey]?.docs.set(entity_id, doc)
607
+ const doc: CuboCrdtClientDoc = { doc: yjsDoc, subscribes: new Set() }
608
+ this.docs.set(`${ctx.entity}:${ctx.entity_id}`, doc)
661
609
 
662
610
  // добавляем документ в реактивное хранилище
663
- const json: any = doc.getMap().toJSON()
611
+ const json: any = yjsDoc.getMap().toJSON()
664
612
  const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === entity_id)
665
613
  if (index !== undefined && index >= 0) {
666
614
  if (this.debug) {
@@ -673,10 +621,41 @@ export class CuboCrdtClient<M> {
673
621
  }
674
622
 
675
623
  if (opts?.onAfterCreate) {
676
- await opts.onAfterCreate(doc, update, ctx, opts)
624
+ await opts.onAfterCreate(yjsDoc, update, ctx, opts)
677
625
  }
678
626
 
679
- this.ensureDocumentAwareness(ctx, opts, doc, entity_id)
627
+ if (opts?.awareness) {
628
+ let awareness = this.store[opts.storeKey]?.awarenesses.get(entity_id)
629
+
630
+ if (!awareness) {
631
+ awareness = new Awareness(yjsDoc)
632
+
633
+ awareness.on('update', ({ added, updated, removed }, origin) => {
634
+ // console.log('[CRDT] awarness update', origin)
635
+
636
+ if (origin === 'remote') {
637
+ return
638
+ }
639
+
640
+ const changed = added.concat(updated).concat(removed)
641
+ const update = encodeAwarenessUpdate(awareness, changed)
642
+
643
+ this.ws.request({
644
+ method: CUBO_CRDT_EVENT.EVENT,
645
+ data: {
646
+ action: 'awareness',
647
+ entity: ctx.entity,
648
+ entity_id,
649
+ data: Array.from(update),
650
+ // 'other' — не шлём свой же курсор обратно исходной подписке
651
+ origin: { expose: 'other', subscribe_id: opts.subscribe_id }
652
+ }
653
+ })
654
+ })
655
+
656
+ this.store[opts.storeKey]?.awarenesses.set(entity_id, awareness)
657
+ }
658
+ }
680
659
  }
681
660
 
682
661
  private async onDocumentUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
@@ -685,7 +664,7 @@ export class CuboCrdtClient<M> {
685
664
  return
686
665
  }
687
666
 
688
- const doc = this.store[opts.storeKey]?.docs.get(entity_id)
667
+ const doc = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
689
668
  if (!doc) {
690
669
  console.warn('[CRDT] onDocumentUpdate: doc not exists', { ctx, opts })
691
670
  return
@@ -694,20 +673,20 @@ export class CuboCrdtClient<M> {
694
673
  const update = new Uint8Array(ctx.data as any)
695
674
 
696
675
  if (opts?.onBeforeUpdate) {
697
- const result = await opts.onBeforeUpdate(doc, update, ctx, opts)
676
+ const result = await opts.onBeforeUpdate(doc.doc, update, ctx, opts)
698
677
  if (!result) {
699
678
  return
700
679
  }
701
680
  }
702
681
 
703
682
  // обновляем yjs-ный документ
704
- applyUpdate(doc, update, { react: false })
683
+ applyUpdate(doc.doc, update, { react: false })
705
684
 
706
685
  // обновляем документ в реактивном хранилище
707
- this.applyRowJson(opts.storeKey, entity_id, doc)
686
+ this.applyRowJson(opts.storeKey, entity_id, doc.doc)
708
687
 
709
688
  if (opts?.onAfterUpdate) {
710
- await opts.onAfterUpdate(doc, update, ctx, opts)
689
+ await opts.onAfterUpdate(doc.doc, update, ctx, opts)
711
690
  }
712
691
  }
713
692
 
@@ -717,23 +696,21 @@ export class CuboCrdtClient<M> {
717
696
  return
718
697
  }
719
698
 
720
- const doc = this.store[opts.storeKey]?.docs.get(entity_id)
699
+ const doc = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
721
700
  if (!doc) {
722
701
  console.warn('[CRDT] onDocumentDelete: doc not exists', { ctx, opts })
723
702
  return
724
703
  }
725
704
 
726
705
  if (opts?.onBeforeDelete) {
727
- const result = await opts.onBeforeDelete(doc, ctx, opts)
706
+ const result = await opts.onBeforeDelete(doc.doc, ctx, opts)
728
707
  if (!result) {
729
708
  return
730
709
  }
731
710
  }
732
711
 
733
- // console.log('onDocumentDelete', ctx, opts)
734
-
735
- doc.destroy()
736
- this.store[opts.storeKey]?.docs.delete(entity_id)
712
+ doc.doc.destroy()
713
+ this.docs.delete(`${ctx.entity}:${ctx.entity_id}`)
737
714
 
738
715
  const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === entity_id)
739
716
  if (index !== undefined && index >= 0) {
@@ -753,7 +730,7 @@ export class CuboCrdtClient<M> {
753
730
  }
754
731
 
755
732
  if (opts?.onAfterDelete) {
756
- await opts.onAfterDelete(doc, ctx, opts)
733
+ await opts.onAfterDelete(doc.doc, ctx, opts)
757
734
  }
758
735
  }
759
736
 
@@ -788,7 +765,9 @@ export class CuboCrdtClient<M> {
788
765
  const ids = data.ids.filter((id) => Number.isFinite(id))
789
766
  const idsSet = new Set(ids)
790
767
 
791
- for (const [entity_id, doc] of item.docs) {
768
+ for (const row of item.state.rows) {
769
+ const entity_id = (row as any).id
770
+
792
771
  if (idsSet.has(entity_id)) {
793
772
  continue
794
773
  }
@@ -801,14 +780,15 @@ export class CuboCrdtClient<M> {
801
780
  item.awarenesses.delete(entity_id)
802
781
  }
803
782
 
804
- doc.destroy()
805
- item.docs.delete(entity_id)
783
+ const doc = this.docs.get(`${ctx.entity}:${entity_id}`)
784
+ if (doc) {
785
+ doc.doc.destroy()
786
+ this.docs.delete(`${ctx.entity}:${entity_id}`)
787
+ }
806
788
  }
807
789
 
808
790
  const rowsById = new Map(item.state.rows.map((row: any) => [Number(row.id), row]))
809
- const orderedRows = ids
810
- .map((id) => rowsById.get(id))
811
- .filter((row) => row !== undefined)
791
+ const orderedRows = ids.map((id) => rowsById.get(id)).filter((row) => row !== undefined)
812
792
 
813
793
  item.state.rows.splice(0, item.state.rows.length, ...orderedRows)
814
794
  item.state.totals = data.totals || {}
@@ -1,8 +1,14 @@
1
+ import { Doc } from 'yjs'
1
2
  import { CuboCrdtClientUseOptions } from '..'
2
3
  import { CuboCrdtExposeStrategy } from '../../types'
3
4
 
4
5
  import { CuboCrdtClientBaseOptions } from './utils'
5
6
 
7
+ export type CuboCrdtClientDoc = {
8
+ doc: Doc
9
+ subscribes: Set<string>
10
+ }
11
+
6
12
  export type CuboCrdtClientDocOrigin = {
7
13
  // сохранять ли на бэке в дебаунсе
8
14
  store?: boolean
@@ -1,15 +1,10 @@
1
1
  import { type WsClient } from '@cuboapp/ws'
2
2
  import { ComputedRef } from 'vue'
3
3
  import { Awareness } from 'y-protocols/awareness'
4
- import { type Doc } from 'yjs'
5
4
 
6
- import {
7
- CuboCrdtAction,
8
- CuboCrdtListSyncData,
9
- CuboCrdtListTotals,
10
- CuboCrdtSubscriptionStatus
11
- } from '../../types'
5
+ import { CuboCrdtAction, CuboCrdtListSyncData, CuboCrdtListTotals, CuboCrdtSubscriptionStatus } from '../../types'
12
6
 
7
+ import { Doc } from 'yjs'
13
8
  import { CuboCrdtClientBaseOptions } from './utils'
14
9
 
15
10
  export * from './document'
@@ -45,7 +40,6 @@ export type CuboCrdtClientSubscribeEvent = {
45
40
  export type CuboCrdtClientList<T> = {
46
41
  subscribe_id: string
47
42
  storeKey: string
48
- docs: ComputedRef<Map<number, Doc> | undefined>
49
43
  awarenesses?: ComputedRef<Map<number, Awareness> | undefined>
50
44
  subscribe: (filters?: any) => void
51
45
  upgrade: (filters?: any) => void
@@ -64,7 +58,7 @@ export type CuboCrdtClientList<T> = {
64
58
  export type CuboCrdtClientRow<T> = {
65
59
  subscribe_id: string
66
60
  storeKey: string
67
- doc: ComputedRef<Doc | undefined>
61
+ doc: Doc
68
62
  awareness?: ComputedRef<Awareness | undefined>
69
63
  subscribe: () => void
70
64
  upgrade: (filters?: any) => void
@@ -1,12 +1,7 @@
1
1
  import { Reactive } from 'vue'
2
- import { Doc } from 'yjs'
3
2
  import { Awareness } from 'y-protocols/awareness'
4
3
 
5
- import {
6
- CuboCrdtKey,
7
- CuboCrdtListTotals,
8
- CuboCrdtSubscriptionStatus
9
- } from '../../types'
4
+ import { CuboCrdtKey, CuboCrdtListTotals, CuboCrdtSubscriptionStatus } from '../../types'
10
5
 
11
6
  export type CuboCrdtClientStoreItem<T> = {
12
7
  state: Reactive<{
@@ -18,7 +13,6 @@ export type CuboCrdtClientStoreItem<T> = {
18
13
  totals: CuboCrdtListTotals
19
14
  revision: number
20
15
  }>
21
- docs: Map<number, Doc>
22
16
  awarenesses: Map<number, Awareness>
23
17
  }
24
18
  export type CuboCrdtClientStore<M> = Partial<{
@@ -1,23 +1,12 @@
1
- import { cloneDeep, pick } from '@cuboapp/utils'
1
+ import { cloneDeep, debounce, 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 {
6
- CuboCrdtServerDocumentOptions,
7
- CuboCrdtServerDocumentOrigin,
8
- CuboCrdtServerDocumentPendingStore
9
- } from '../types'
10
- import { getCrdtStoreChangeKey } from '../utils'
11
-
12
- const STORE_DEBOUNCE_MS = 300
5
+ import { CuboCrdtServerDocumentOptions, CuboCrdtServerDocumentOrigin } from '../types'
13
6
 
14
7
  export class CuboCrdtServerDocument {
15
8
  private ydoc: Doc
16
9
  private persistedState: object = {}
17
- private observedState: object = {}
18
- private storeQueue: Promise<void> = Promise.resolve()
19
- private pendingStore?: CuboCrdtServerDocumentPendingStore
20
- private storeTimeout?: ReturnType<typeof setTimeout>
21
10
  public awareness: Awareness
22
11
  public awarenessBySubscribe = new Map<string, Set<number>>()
23
12
 
@@ -30,11 +19,31 @@ export class CuboCrdtServerDocument {
30
19
  })
31
20
 
32
21
  if (opts.awareness) {
33
- this.enableAwareness()
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
+ })
34
44
  }
35
45
 
36
46
  this.persistedState = cloneDeep(opts.initialState ?? {})
37
- this.observedState = cloneDeep(opts.initialState ?? {})
38
47
 
39
48
  // записываем исходное состояние
40
49
  this.write(opts.initialState ?? {})
@@ -49,8 +58,7 @@ export class CuboCrdtServerDocument {
49
58
  this.opts?.onUpdate?.(data, origin)
50
59
  // }
51
60
 
52
- // Состояние отслеживается синхронно с Y.Doc. Для клиентских изменений
53
- // debounceStore сам обновит observedState после вычисления leaf-diff.
61
+ // если нужно сохранять стейт документа - вызываем store по дебаунсу
54
62
  if (origin?.store) {
55
63
  const row = this.getJson()
56
64
  const body = pick(row, origin.keys ?? Object.keys(row))
@@ -60,41 +68,10 @@ export class CuboCrdtServerDocument {
60
68
  console.error('[CRDT] store document', this.name, error)
61
69
  })
62
70
  }
63
- } else {
64
- this.observedState = cloneDeep(this.getJson())
65
71
  }
66
72
  })
67
73
  }
68
74
 
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
-
98
75
  public get name() {
99
76
  return this.opts.name
100
77
  }
@@ -152,117 +129,17 @@ export class CuboCrdtServerDocument {
152
129
  }
153
130
 
154
131
  destroy() {
155
- this.flushPendingStore()
156
132
  this.awareness?.destroy()
157
133
  this.ydoc.destroy()
158
134
  }
159
135
 
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
- }
136
+ public async store(body: object, origin: CuboCrdtServerDocumentOrigin) {
137
+ const previousRow = this.getPersistedState()
138
+ const row = this.getJson()
234
139
 
235
- this.pendingStore.waiters.push({ resolve, reject })
236
- this.schedulePendingStore()
237
- })
140
+ await this.opts?.onStore?.(body, origin, { previousRow, row })
141
+ this.setPersistedState(row)
238
142
  }
239
143
 
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
- }
144
+ public debounceStore = debounce(this.store.bind(this), 300)
268
145
  }
@@ -4,11 +4,7 @@ import { applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } fr
4
4
 
5
5
  import { CUBO_CRDT_EVENT } from '../constants'
6
6
  import { CuboCrdtAction, CuboCrdtListSyncData, CuboCrdtMutation } from '../types'
7
- import {
8
- areCrdtListIdsEqual,
9
- areCrdtListTotalsEqual,
10
- checkRowIsSutable
11
- } from '../utils'
7
+ import { areCrdtListIdsEqual, areCrdtListTotalsEqual, checkRowIsSutable } from '../utils'
12
8
 
13
9
  import { CuboCrdtClientDocOrigin } from '../client'
14
10
  import { CuboCrdtServerDocument } from './document'
@@ -437,12 +433,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
437
433
  const generation = this.subscribeGenerations.get(subscribe_id) || 0
438
434
  const forceSync = state.forceSync
439
435
  state.forceSync = false
440
- const reconciled = await this.reconcileSubscribe(
441
- client,
442
- subscribe,
443
- generation,
444
- forceSync
445
- )
436
+ const reconciled = await this.reconcileSubscribe(client, subscribe, generation, forceSync)
446
437
  if (!reconciled && forceSync) {
447
438
  state.forceSync = true
448
439
  }
@@ -454,11 +445,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
454
445
  return state.running
455
446
  }
456
447
 
457
- private requestSubscribeRefresh(
458
- subscribe_id: string,
459
- immediate = false,
460
- forceSync = immediate
461
- ) {
448
+ private requestSubscribeRefresh(subscribe_id: string, immediate = false, forceSync = immediate) {
462
449
  const state = this.getSubscribeRefreshState(subscribe_id)
463
450
  state.dirty = true
464
451
  state.forceSync ||= forceSync
@@ -544,16 +531,8 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
544
531
  }
545
532
 
546
533
  if (mutation.previousRow) {
547
- const previousSuitable = this.checkRowIsSutable(
548
- mutation.previousRow,
549
- subscribe,
550
- entity
551
- )
552
- const currentSuitable = this.checkRowIsSutable(
553
- mutation.row,
554
- subscribe,
555
- entity
556
- )
534
+ const previousSuitable = this.checkRowIsSutable(mutation.previousRow, subscribe, entity)
535
+ const currentSuitable = this.checkRowIsSutable(mutation.row, subscribe, entity)
557
536
 
558
537
  if (previousSuitable !== currentSuitable) {
559
538
  return true
@@ -605,10 +584,6 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
605
584
 
606
585
  let document = this.documents.get(documentName)
607
586
  if (document) {
608
- if (awareness) {
609
- document.enableAwareness()
610
- }
611
-
612
587
  // Освежаем существующий документ строкой из БД.
613
588
  //
614
589
  // Раньше документ возвращался как есть, а подписка отправляла клиенту его
@@ -840,13 +815,9 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
840
815
 
841
816
  const subscribeSutable = this.checkSubscribeIsSutable(origin, subscribe)
842
817
  const documentExistsInSubscribe = this.subscribesByDocument.get(document.name)?.has(subscribe.id)
843
- const rowSutable = subscribe.paginated
844
- ? documentExistsInSubscribe
845
- : this.checkRowIsSutable(row, subscribe, entity)
818
+ const rowSutable = subscribe.paginated ? documentExistsInSubscribe : this.checkRowIsSutable(row, subscribe, entity)
846
819
 
847
- const sutable = subscribeSutable && (subscribe.paginated
848
- ? documentExistsInSubscribe
849
- : rowSutable || documentExistsInSubscribe)
820
+ const sutable = subscribeSutable && (subscribe.paginated ? documentExistsInSubscribe : rowSutable || documentExistsInSubscribe)
850
821
 
851
822
  if (sutable) {
852
823
  if (this.debug && !rowSutable) {
@@ -947,21 +918,13 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
947
918
  }
948
919
  }
949
920
 
950
- private async reconcileSubscribe(
951
- client: WsServerSocket,
952
- subscribe: CuboCrdtServerSubscribe,
953
- generation: number,
954
- forceSync: boolean
955
- ) {
921
+ private async reconcileSubscribe(client: WsServerSocket, subscribe: CuboCrdtServerSubscribe, generation: number, forceSync: boolean) {
956
922
  const fetched = await this.options.fetchRows?.(client, subscribe)
957
923
  const result = Array.isArray(fetched)
958
924
  ? { rows: fetched, totals: subscribe.totals }
959
925
  : { rows: fetched?.rows || [], totals: fetched?.totals || subscribe.totals }
960
926
 
961
- if (
962
- this.subscribes.get(subscribe.id) !== subscribe ||
963
- this.subscribeGenerations.get(subscribe.id) !== generation
964
- ) {
927
+ if (this.subscribes.get(subscribe.id) !== subscribe || this.subscribeGenerations.get(subscribe.id) !== generation) {
965
928
  return false
966
929
  }
967
930
 
@@ -1034,10 +997,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
1034
997
  }
1035
998
 
1036
999
  const idsChanged = !areCrdtListIdsEqual(subscribe.row_ids, nextIds)
1037
- const totalsChanged = !areCrdtListTotalsEqual(
1038
- subscribe.totals,
1039
- result.totals
1040
- )
1000
+ const totalsChanged = !areCrdtListTotalsEqual(subscribe.totals, result.totals)
1041
1001
 
1042
1002
  subscribe.row_ids = nextIds
1043
1003
  subscribe.totals = result.totals
@@ -1076,10 +1036,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
1076
1036
  if (upgrade.filters !== undefined) {
1077
1037
  subscribe.filters = cloneDeep(upgrade.filters || {})
1078
1038
  }
1079
- subscribe.paginated = this.resolvePaginated(
1080
- subscribe.filters,
1081
- upgrade.paginated
1082
- )
1039
+ subscribe.paginated = this.resolvePaginated(subscribe.filters, upgrade.paginated)
1083
1040
 
1084
1041
  return this.requestSubscribeRefresh(subscribe.id, true)
1085
1042
  }
@@ -5,20 +5,6 @@ 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
-
22
8
  export type CuboCrdtServerDocumentOptions = {
23
9
  name: string
24
10
  onStore?: (
@@ -1,41 +0,0 @@
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
- }