@cuboapp/crdt 1.0.23 → 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.23",
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
- import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs'
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,92 +443,48 @@ export class CuboCrdtClient<M> {
450
443
  dto: K extends Extract<keyof M, string> ? Partial<M[K]> : object,
451
444
  opts?: CuboCrdtClientDocOrigin
452
445
  ) {
453
- // Если строка открыта через useRow, mutating API работает только с её
454
- // каноническим документом. Списки могут содержать тот же id, но не должны
455
- // становиться источником записи для карточки.
456
- const rowStore = this.store[`${entity}:${entity_id}`]
457
- const store = rowStore?.state.status === 'ready' && rowStore.docs.has(entity_id)
458
- ? rowStore
459
- : [...this.subscriptions.values()]
460
- .filter((subscription) => subscription.entity === entity)
461
- .map((subscription) => this.store[subscription.storeKey])
462
- .find((candidate) => candidate?.docs.has(entity_id) && candidate.state.status === 'ready')
463
- const document = store?.docs.get(entity_id)
464
-
465
- if (!store || !document) {
466
- return false
467
- }
468
-
469
- const map = document.getMap()
470
- const toUpdate = Object.fromEntries(
471
- Object.entries(dto).filter(([key, value]) => map.get(key) !== value)
472
- )
473
-
474
- if (!Object.keys(toUpdate).length) {
475
- // Состояние уже совпадает (например, update успел прийти из другой
476
- // вкладки). Это успешный no-op, а не ошибка изменения.
477
- return true
478
- }
479
-
480
- const origin: CuboCrdtClientDocOrigin = {
481
- store: opts?.store ?? true,
482
- expose: opts?.expose ?? 'other',
483
- keys: opts?.keys ?? Object.keys(toUpdate),
484
- // update() сам отправляет ровно один пакет ниже. Общий listener остаётся
485
- // для изменений, которые создаются напрямую сторонними Yjs-binding'ами.
486
- react: false
487
- }
488
-
489
- const stateVector = encodeStateVector(document)
490
-
491
- // Одна сущность может присутствовать в нескольких подписках с разными
492
- // storeKey. Мутацию отправляем только из одного документа: остальные
493
- // подписки получат подтверждённый update через обычную CRDT-рассылку.
494
- document.transact(() => {
495
- Object.entries(toUpdate).forEach(([key, value]) => {
496
- map.set(key, value)
497
- })
498
- }, origin)
499
-
500
- const update = encodeStateAsUpdate(document, stateVector)
501
- const receipt = this.ws
502
- .request(
503
- {
504
- method: CUBO_CRDT_EVENT.EVENT,
505
- data: {
506
- action: 'update',
507
- entity,
508
- entity_id,
509
- data: Array.from(update),
510
- origin: {
511
- ...pick(origin, ['store', 'keys', 'expose']),
512
- subscribe_id: [...this.subscriptions.entries()]
513
- .find(([, subscription]) => this.store[subscription.storeKey] === store)?.[0]
514
- }
515
- }
516
- },
517
- { wait: true, timeout: 10_000 }
518
- )
519
- .then(() => true)
520
- .catch((error) => {
521
- console.error('[CRDT] update request failed', error)
522
- return false
523
- })
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
+ }
524
467
 
525
- // Этот документ является каноническим локальным состоянием строки. Обновляем
526
- // его реактивную строку сразу, а другие подписки получат тот же Yjs-update
527
- // через серверную рассылку.
528
- this.applyRowJson(`${entity}:${entity_id}`, entity_id, document)
468
+ doc.doc.transact(() => {
469
+ Object.entries(toUpdate).forEach(([key, value]) => {
470
+ map.set(key, value)
471
+ })
472
+ }, origin)
473
+ }
474
+ }
529
475
 
530
- 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))
531
480
 
532
- if (row) {
533
- Object.entries(toUpdate).forEach(([key, value]) => {
534
- row[key] = value
535
- })
481
+ if (Object.keys(toUpdate).length) {
482
+ Object.entries(toUpdate).forEach(([key, value]) => {
483
+ row[key as keyof typeof row] = value
484
+ })
485
+ }
486
+ }
536
487
  }
537
-
538
- return receipt
539
488
  }
540
489
 
541
490
  public upgrade(subscribe_id: string, filters?: Record<string, any>) {
@@ -595,54 +544,13 @@ export class CuboCrdtClient<M> {
595
544
  }
596
545
  }
597
546
 
598
- private ensureDocumentAwareness(
599
- ctx: CuboCrdtClientSubscribeEvent,
600
- opts: CuboCrdtClientDocUpdateOptions,
601
- doc: Doc,
602
- entity_id: number
603
- ) {
604
- if (!opts.awareness) {
605
- return
606
- }
607
-
608
- let awareness = this.store[opts.storeKey]?.awarenesses.get(entity_id)
609
-
610
- if (awareness) {
611
- return
612
- }
613
-
614
- awareness = new Awareness(doc)
615
-
616
- awareness.on('update', ({ added, updated, removed }, origin) => {
617
- if (origin === 'remote') {
618
- return
619
- }
620
-
621
- const changed = added.concat(updated).concat(removed)
622
- const update = encodeAwarenessUpdate(awareness!, changed)
623
-
624
- this.ws.request({
625
- method: CUBO_CRDT_EVENT.EVENT,
626
- data: {
627
- action: 'awareness',
628
- entity: ctx.entity,
629
- entity_id,
630
- data: Array.from(update),
631
- origin: { expose: 'other', subscribe_id: opts.subscribe_id }
632
- }
633
- })
634
- })
635
-
636
- this.store[opts.storeKey]?.awarenesses.set(entity_id, awareness)
637
- }
638
-
639
547
  private async onDocumentCreate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
640
548
  const entity_id = ctx.entity_id
641
549
  if (entity_id === undefined) {
642
550
  return
643
551
  }
644
552
 
645
- const existing = this.store[opts.storeKey]?.docs.get(entity_id)
553
+ const existing = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
646
554
  if (existing) {
647
555
  // документ уже есть локально (типичный кейс — повторный SUBSCRIBE после реконнекта).
648
556
  // вместо игнорирования вмёрживаем входящее состояние в существующий yjs-документ
@@ -651,13 +559,12 @@ export class CuboCrdtClient<M> {
651
559
  console.log('[CRDT] onDocumentCreate: doc already exists, merging state', { ctx, opts })
652
560
  }
653
561
 
654
- applyUpdate(existing, new Uint8Array(ctx.data as any), { react: false })
655
- this.applyRowJson(opts.storeKey, entity_id, existing)
656
- 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)
657
564
  return
658
565
  }
659
566
 
660
- const doc = new Doc()
567
+ const yjsDoc = new Doc()
661
568
 
662
569
  const update = new Uint8Array(ctx.data as any)
663
570
 
@@ -668,10 +575,10 @@ export class CuboCrdtClient<M> {
668
575
  }
669
576
  }
670
577
 
671
- applyUpdate(doc, update, { react: false })
578
+ applyUpdate(yjsDoc, update, { react: false })
672
579
 
673
580
  // подписываемся на обновления документа
674
- doc.on('update', (update, origin: CuboCrdtClientDocOrigin) => {
581
+ yjsDoc.on('update', (update, origin: CuboCrdtClientDocOrigin) => {
675
582
  // react = false, если это апдейт с бэка
676
583
  if (origin?.react !== false) {
677
584
  const data: CuboCrdtServerDocumentIncomingAction = {
@@ -689,18 +596,19 @@ export class CuboCrdtClient<M> {
689
596
  }
690
597
  }
691
598
 
692
- void this.ws.request({
599
+ this.ws.request({
693
600
  method: CUBO_CRDT_EVENT.EVENT,
694
601
  data
695
- }).catch((error) => console.error('[CRDT] update request failed', error))
602
+ })
696
603
  }
697
604
  })
698
605
 
699
606
  // добавляем документ в хранилище документов
700
- 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)
701
609
 
702
610
  // добавляем документ в реактивное хранилище
703
- const json: any = doc.getMap().toJSON()
611
+ const json: any = yjsDoc.getMap().toJSON()
704
612
  const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === entity_id)
705
613
  if (index !== undefined && index >= 0) {
706
614
  if (this.debug) {
@@ -713,10 +621,41 @@ export class CuboCrdtClient<M> {
713
621
  }
714
622
 
715
623
  if (opts?.onAfterCreate) {
716
- await opts.onAfterCreate(doc, update, ctx, opts)
624
+ await opts.onAfterCreate(yjsDoc, update, ctx, opts)
717
625
  }
718
626
 
719
- 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
+ }
720
659
  }
721
660
 
722
661
  private async onDocumentUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
@@ -725,7 +664,7 @@ export class CuboCrdtClient<M> {
725
664
  return
726
665
  }
727
666
 
728
- const doc = this.store[opts.storeKey]?.docs.get(entity_id)
667
+ const doc = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
729
668
  if (!doc) {
730
669
  console.warn('[CRDT] onDocumentUpdate: doc not exists', { ctx, opts })
731
670
  return
@@ -734,20 +673,20 @@ export class CuboCrdtClient<M> {
734
673
  const update = new Uint8Array(ctx.data as any)
735
674
 
736
675
  if (opts?.onBeforeUpdate) {
737
- const result = await opts.onBeforeUpdate(doc, update, ctx, opts)
676
+ const result = await opts.onBeforeUpdate(doc.doc, update, ctx, opts)
738
677
  if (!result) {
739
678
  return
740
679
  }
741
680
  }
742
681
 
743
682
  // обновляем yjs-ный документ
744
- applyUpdate(doc, update, { react: false })
683
+ applyUpdate(doc.doc, update, { react: false })
745
684
 
746
685
  // обновляем документ в реактивном хранилище
747
- this.applyRowJson(opts.storeKey, entity_id, doc)
686
+ this.applyRowJson(opts.storeKey, entity_id, doc.doc)
748
687
 
749
688
  if (opts?.onAfterUpdate) {
750
- await opts.onAfterUpdate(doc, update, ctx, opts)
689
+ await opts.onAfterUpdate(doc.doc, update, ctx, opts)
751
690
  }
752
691
  }
753
692
 
@@ -757,23 +696,21 @@ export class CuboCrdtClient<M> {
757
696
  return
758
697
  }
759
698
 
760
- const doc = this.store[opts.storeKey]?.docs.get(entity_id)
699
+ const doc = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
761
700
  if (!doc) {
762
701
  console.warn('[CRDT] onDocumentDelete: doc not exists', { ctx, opts })
763
702
  return
764
703
  }
765
704
 
766
705
  if (opts?.onBeforeDelete) {
767
- const result = await opts.onBeforeDelete(doc, ctx, opts)
706
+ const result = await opts.onBeforeDelete(doc.doc, ctx, opts)
768
707
  if (!result) {
769
708
  return
770
709
  }
771
710
  }
772
711
 
773
- // console.log('onDocumentDelete', ctx, opts)
774
-
775
- doc.destroy()
776
- this.store[opts.storeKey]?.docs.delete(entity_id)
712
+ doc.doc.destroy()
713
+ this.docs.delete(`${ctx.entity}:${ctx.entity_id}`)
777
714
 
778
715
  const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === entity_id)
779
716
  if (index !== undefined && index >= 0) {
@@ -793,7 +730,7 @@ export class CuboCrdtClient<M> {
793
730
  }
794
731
 
795
732
  if (opts?.onAfterDelete) {
796
- await opts.onAfterDelete(doc, ctx, opts)
733
+ await opts.onAfterDelete(doc.doc, ctx, opts)
797
734
  }
798
735
  }
799
736
 
@@ -828,7 +765,9 @@ export class CuboCrdtClient<M> {
828
765
  const ids = data.ids.filter((id) => Number.isFinite(id))
829
766
  const idsSet = new Set(ids)
830
767
 
831
- 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
+
832
771
  if (idsSet.has(entity_id)) {
833
772
  continue
834
773
  }
@@ -841,14 +780,15 @@ export class CuboCrdtClient<M> {
841
780
  item.awarenesses.delete(entity_id)
842
781
  }
843
782
 
844
- doc.destroy()
845
- 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
+ }
846
788
  }
847
789
 
848
790
  const rowsById = new Map(item.state.rows.map((row: any) => [Number(row.id), row]))
849
- const orderedRows = ids
850
- .map((id) => rowsById.get(id))
851
- .filter((row) => row !== undefined)
791
+ const orderedRows = ids.map((id) => rowsById.get(id)).filter((row) => row !== undefined)
852
792
 
853
793
  item.state.rows.splice(0, item.state.rows.length, ...orderedRows)
854
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>
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
  }
@@ -156,138 +133,13 @@ export class CuboCrdtServerDocument {
156
133
  this.ydoc.destroy()
157
134
  }
158
135
 
159
- /**
160
- * Немедленно передаёт trailing-пакет в очередь и ждёт все уже принятые
161
- * сохранения документа. Используется перед вытеснением Y.Doc из серверного
162
- * кэша, чтобы refresh последнего клиента не потерял его update.
163
- */
164
- public async flushStoreQueue() {
165
- this.flushPendingStore()
166
-
167
- while (this.storeQueue) {
168
- await this.storeQueue
169
- this.flushPendingStore()
170
- }
171
- }
172
-
173
- /** Есть ли принятое клиентское изменение в debounce или очереди записи. */
174
- public hasPendingStore() {
175
- return !!this.pendingStore || !!this.storeQueue
176
- }
177
-
178
- public store(body: object, origin: CuboCrdtServerDocumentOrigin, row = this.getJson()) {
179
- const queuedBody = cloneDeep(body)
180
- const queuedRow = cloneDeep(row)
181
- // debounce ограничивает частоту вызовов, но не сериализует async store.
182
- // Без очереди следующий PATCH мог завершиться раньше предыдущего и затем
183
- // быть затёрт старым полным снимком составного поля (например case.extra).
184
- const previous = this.storeQueue?.catch(() => undefined) ?? Promise.resolve()
185
- const current = previous
186
- .then(async () => {
187
- const previousRow = this.getPersistedState()
188
-
189
- await this.opts?.onStore?.(queuedBody, origin, {
190
- previousRow,
191
- row: queuedRow
192
- })
193
- this.setPersistedState(queuedRow)
194
- })
195
-
196
- const queued = current.finally(() => {
197
- // Если после этой записи не добавилась следующая, очередь опустела.
198
- // Сама Promise-цепочка является источником истины — отдельный счётчик не нужен.
199
- if (this.storeQueue === queued) {
200
- this.storeQueue = undefined
201
- }
202
- })
203
- this.storeQueue = queued
204
-
205
- return queued
206
- }
207
-
208
- /**
209
- * Схлопывает только соседние изменения одного origin и одного набора
210
- * leaf-путей. Смена пользователя или поля немедленно фиксирует предыдущий
211
- * пакет, сохраняя фактический порядок collaborative-обновлений.
212
- */
213
- public debounceStore(body: object, origin: CuboCrdtServerDocumentOrigin) {
214
- const row = cloneDeep(this.getJson())
215
- const bodyKeys = Object.keys(body)
216
- const previousBody = pick(this.observedState, bodyKeys)
217
- const changeKey = getCrdtStoreChangeKey(previousBody, body)
218
- const originKey = `${origin.client_id || ''}:${origin.subscribe_id || ''}`
219
-
220
- this.observedState = cloneDeep(row)
221
-
222
- if (!changeKey) {
223
- return Promise.resolve()
224
- }
225
-
226
- return new Promise<void>((resolve, reject) => {
227
- const sameBatch = this.pendingStore?.originKey === originKey &&
228
- this.pendingStore.changeKey === changeKey
229
-
230
- if (this.pendingStore && !sameBatch) {
231
- this.flushPendingStore()
232
- }
136
+ public async store(body: object, origin: CuboCrdtServerDocumentOrigin) {
137
+ const previousRow = this.getPersistedState()
138
+ const row = this.getJson()
233
139
 
234
- if (!this.pendingStore) {
235
- this.pendingStore = {
236
- body: cloneDeep(body),
237
- changeKey,
238
- origin: cloneDeep(origin),
239
- originKey,
240
- row,
241
- waiters: []
242
- }
243
- } else {
244
- const keys = new Set([
245
- ...(this.pendingStore.origin.keys || []),
246
- ...(origin.keys || [])
247
- ])
248
-
249
- this.pendingStore.body = {
250
- ...this.pendingStore.body,
251
- ...cloneDeep(body)
252
- }
253
- this.pendingStore.origin = {
254
- ...cloneDeep(origin),
255
- keys: [...keys]
256
- }
257
- this.pendingStore.row = row
258
- }
259
-
260
- this.pendingStore.waiters.push({ resolve, reject })
261
- this.schedulePendingStore()
262
- })
140
+ await this.opts?.onStore?.(body, origin, { previousRow, row })
141
+ this.setPersistedState(row)
263
142
  }
264
143
 
265
- /** Перезапускает trailing debounce для текущего однородного пакета. */
266
- private schedulePendingStore() {
267
- if (this.storeTimeout) {
268
- clearTimeout(this.storeTimeout)
269
- }
270
-
271
- this.storeTimeout = setTimeout(() => this.flushPendingStore(), STORE_DEBOUNCE_MS)
272
- }
273
-
274
- /** Передаёт накопленный пакет в общую последовательную очередь записи. */
275
- private flushPendingStore() {
276
- if (!this.pendingStore) {
277
- return
278
- }
279
-
280
- if (this.storeTimeout) {
281
- clearTimeout(this.storeTimeout)
282
- this.storeTimeout = undefined
283
- }
284
-
285
- const pending = this.pendingStore
286
- this.pendingStore = undefined
287
-
288
- this.store(pending.body, pending.origin, pending.row).then(
289
- () => pending.waiters.forEach(({ resolve }) => resolve()),
290
- (error) => pending.waiters.forEach(({ reject }) => reject(error))
291
- )
292
- }
144
+ public debounceStore = debounce(this.store.bind(this), 300)
293
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'
@@ -40,7 +36,6 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
40
36
  private subscribeGenerations = new Map<string, number>()
41
37
 
42
38
  private documents = new Map<string, CuboCrdtServerDocument>()
43
- private documentEvictions = new Map<string, object>()
44
39
  private subscribesByDocument = new Map<string, Set<string>>()
45
40
  // обратный индекс: подписка -> имена документов, к которым она привязана.
46
41
  // Нужен, чтобы отписка/очистка не сканировала ВСЕ документы сервера (O(subs x docs)).
@@ -112,53 +107,34 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
112
107
  this.cleanSubscribe(subscribe_id)
113
108
  })
114
109
 
115
- this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, async ({ client, message }) => {
110
+ this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, async ({ message }) => {
116
111
  let data = message.data as any
117
112
  if (!Array.isArray(data)) {
118
113
  data = [data]
119
114
  }
120
115
 
121
116
  if (this.debug) {
122
- console.log('[CRDT] incoming event', data.map((row: any) => ({
123
- action: row.action,
124
- entity: row.entity,
125
- entity_id: row.entity_id,
126
- keys: row.origin?.keys,
127
- subscribe_id: row.origin?.subscribe_id
128
- })))
117
+ console.log('[CRDT] incoming event', data)
129
118
  }
130
119
 
131
120
  for (const row of data) {
132
121
  const update = new Uint8Array(row.data)
133
- const origin: CuboCrdtServerDocumentOrigin = {
134
- ...(row.origin || {}),
135
- client_id: client.id,
136
- store_context: {
137
- client_id: client.id,
138
- auth: cloneDeep(client.auth)
139
- }
140
- }
141
- const action = { ...row, origin }
142
122
 
143
123
  switch (row.action) {
144
124
  case 'create':
145
- this.onDocumentExternalCreate(update, action)
125
+ this.onDocumentExternalCreate(update, row)
146
126
  break
147
127
  case 'update':
148
- this.onDocumentExternalUpdate(update, action)
128
+ this.onDocumentExternalUpdate(update, row)
149
129
  break
150
130
  case 'delete':
151
- this.onDocumentExternalDelete(update, action)
131
+ this.onDocumentExternalDelete(update, row)
152
132
  break
153
133
  case 'awareness':
154
- this.onAwarenessExternalUpdate(update, action)
134
+ this.onAwarenessExternalUpdate(update, row)
155
135
  break
156
136
  }
157
137
  }
158
-
159
- // Ответ означает только «update применён к живому серверному Y.Doc».
160
- // Debounce и последовательная запись в БД продолжаются независимо.
161
- return { accepted: true }
162
138
  })
163
139
 
164
140
  this.ws.registerHttpHandler('GET', '/stats', () => {
@@ -194,7 +170,6 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
194
170
  this.subscribeRefreshStates.forEach((state) => clearTimeout(state.timer))
195
171
  this.subscribeRefreshStates.clear()
196
172
  this.subscribeGenerations.clear()
197
- this.documentEvictions.clear()
198
173
 
199
174
  this.subscribes.forEach((sub) => this.ws.deleteHandler(sub.id))
200
175
  }
@@ -307,10 +282,6 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
307
282
 
308
283
  // привязываем подписку к документу (в обоих индексах)
309
284
  private linkSubscribeToDocument(subscribe_id: string, docName: string) {
310
- // Новая подписка снова использует живой Y.Doc. Асинхронный flush может
311
- // продолжаться, но больше не имеет права уничтожить этот документ.
312
- this.documentEvictions.delete(docName)
313
-
314
285
  let byDoc = this.subscribesByDocument.get(docName)
315
286
  if (!byDoc) {
316
287
  byDoc = new Set()
@@ -334,56 +305,17 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
334
305
 
335
306
  // если у документа нет подписок - сносим его
336
307
  private checkDocumentNeedRemove(name: string) {
337
- if (this.subscribesByDocument.get(name)?.size || this.documentEvictions.has(name)) {
338
- return
339
- }
308
+ if (!this.subscribesByDocument.get(name)?.size) {
309
+ if (this.debug) {
310
+ console.log('[CRDT] delete document', name)
311
+ }
340
312
 
341
- const document = this.documents.get(name)
342
- if (!document) {
313
+ this.documents.get(name)?.destroy()
314
+ this.documents.delete(name)
315
+ // не оставляем пустой Set в индексе (иначе — медленная утечка записей мапы);
316
+ // getOrCreateDocument пересоздаст его при повторном появлении документа
343
317
  this.subscribesByDocument.delete(name)
344
- return
345
- }
346
-
347
- // Последний клиент мог закрыться сразу после update (обычный refresh).
348
- // Сначала сохраняем всю принятую очередь, а до её завершения оставляем Y.Doc
349
- // доступным повторной подписке. Никакого фиксированного ожидания здесь нет.
350
- const eviction = {}
351
- this.documentEvictions.set(name, eviction)
352
-
353
- if (this.debug) {
354
- console.log('[CRDT] flush document before eviction', name)
355
318
  }
356
-
357
- void document.flushStoreQueue()
358
- .then(() => {
359
- if (
360
- this.documentEvictions.get(name) !== eviction ||
361
- this.subscribesByDocument.get(name)?.size ||
362
- this.documents.get(name) !== document
363
- ) {
364
- return
365
- }
366
-
367
- this.documentEvictions.delete(name)
368
-
369
- if (this.debug) {
370
- console.log('[CRDT] delete document', name)
371
- }
372
-
373
- document.destroy()
374
- this.documents.delete(name)
375
- this.subscribesByDocument.delete(name)
376
- })
377
- .catch((error) => {
378
- // Не уничтожаем единственную живую копию документа, если запись в БД
379
- // не завершилась. Снимаем eviction, чтобы следующая отписка или явная
380
- // проверка могла повторно инициировать flush.
381
- if (this.documentEvictions.get(name) === eviction) {
382
- this.documentEvictions.delete(name)
383
- }
384
-
385
- console.error('[CRDT] flush document before eviction', name, error)
386
- })
387
319
  }
388
320
 
389
321
  // очистка подписки (при отписке клиента - через emit-метод или options-хук).
@@ -501,12 +433,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
501
433
  const generation = this.subscribeGenerations.get(subscribe_id) || 0
502
434
  const forceSync = state.forceSync
503
435
  state.forceSync = false
504
- const reconciled = await this.reconcileSubscribe(
505
- client,
506
- subscribe,
507
- generation,
508
- forceSync
509
- )
436
+ const reconciled = await this.reconcileSubscribe(client, subscribe, generation, forceSync)
510
437
  if (!reconciled && forceSync) {
511
438
  state.forceSync = true
512
439
  }
@@ -518,11 +445,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
518
445
  return state.running
519
446
  }
520
447
 
521
- private requestSubscribeRefresh(
522
- subscribe_id: string,
523
- immediate = false,
524
- forceSync = immediate
525
- ) {
448
+ private requestSubscribeRefresh(subscribe_id: string, immediate = false, forceSync = immediate) {
526
449
  const state = this.getSubscribeRefreshState(subscribe_id)
527
450
  state.dirty = true
528
451
  state.forceSync ||= forceSync
@@ -608,16 +531,8 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
608
531
  }
609
532
 
610
533
  if (mutation.previousRow) {
611
- const previousSuitable = this.checkRowIsSutable(
612
- mutation.previousRow,
613
- subscribe,
614
- entity
615
- )
616
- const currentSuitable = this.checkRowIsSutable(
617
- mutation.row,
618
- subscribe,
619
- entity
620
- )
534
+ const previousSuitable = this.checkRowIsSutable(mutation.previousRow, subscribe, entity)
535
+ const currentSuitable = this.checkRowIsSutable(mutation.row, subscribe, entity)
621
536
 
622
537
  if (previousSuitable !== currentSuitable) {
623
538
  return true
@@ -669,9 +584,20 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
669
584
 
670
585
  let document = this.documents.get(documentName)
671
586
  if (document) {
672
- if (awareness) {
673
- document.enableAwareness()
674
- }
587
+ // Освежаем существующий документ строкой из БД.
588
+ //
589
+ // Раньше документ возвращался как есть, а подписка отправляла клиенту его
590
+ // stateAsUpdate — то есть КЭШ документа, а не только что прочитанную строку.
591
+ // Документ, проспавший мутацию, оставался устаревшим навсегда: даже перезагрузка
592
+ // страницы отдавала старое значение, потому что reconcile брал его же.
593
+ //
594
+ // Стало заметно после того, как документы без подписчиков начали удаляться:
595
+ // документ может быть создан, остаться без подписок, пропустить мутации и
596
+ // «воскреснуть» на новой подписке уже неактуальным.
597
+ //
598
+ // store: false — это не правка от клиента, а синхронизация с БД, писать обратно нечего.
599
+ document.write(row, { expose: 'all', store: false })
600
+ document.setPersistedState(row)
675
601
 
676
602
  return document
677
603
  }
@@ -688,13 +614,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
688
614
  },
689
615
  onStore: (item: object, origin: CuboCrdtServerDocumentOrigin, store) => {
690
616
  const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
691
- const liveClient = subscribe && this.clients.get(subscribe.client_id)
692
- const client = liveClient || (origin.store_context
693
- ? ({
694
- id: origin.store_context.client_id,
695
- auth: origin.store_context.auth
696
- } as CuboCrdtSocketClient<A>)
697
- : undefined)
617
+ const client = subscribe && this.clients.get(subscribe.client_id)
698
618
 
699
619
  if (client) {
700
620
  return this.options?.storeRow?.(entity as any, entity_id, item as any, {
@@ -730,13 +650,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
730
650
  const existed = this.documents.has(documentName)
731
651
  const document = this.getOrCreateDocument(entity, row, awareness)
732
652
 
733
- if (existed && !document.hasPendingStore()) {
734
- // Только внешний CRUD является подтверждённым источником из БД.
735
- // Обычная повторная подписка не должна применять потенциально старую строку
736
- // поверх живого документа с ещё не сохранённым collaborative update.
737
- document.write(row, { expose: 'all', store: false })
738
- document.setPersistedState(row)
739
- } else {
653
+ if (!existed) {
740
654
  this.pushDocumentAction('create', entity, document, document.stateAsUpdate)
741
655
  }
742
656
 
@@ -901,13 +815,9 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
901
815
 
902
816
  const subscribeSutable = this.checkSubscribeIsSutable(origin, subscribe)
903
817
  const documentExistsInSubscribe = this.subscribesByDocument.get(document.name)?.has(subscribe.id)
904
- const rowSutable = subscribe.paginated
905
- ? documentExistsInSubscribe
906
- : this.checkRowIsSutable(row, subscribe, entity)
818
+ const rowSutable = subscribe.paginated ? documentExistsInSubscribe : this.checkRowIsSutable(row, subscribe, entity)
907
819
 
908
- const sutable = subscribeSutable && (subscribe.paginated
909
- ? documentExistsInSubscribe
910
- : rowSutable || documentExistsInSubscribe)
820
+ const sutable = subscribeSutable && (subscribe.paginated ? documentExistsInSubscribe : rowSutable || documentExistsInSubscribe)
911
821
 
912
822
  if (sutable) {
913
823
  if (this.debug && !rowSutable) {
@@ -1008,21 +918,13 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
1008
918
  }
1009
919
  }
1010
920
 
1011
- private async reconcileSubscribe(
1012
- client: WsServerSocket,
1013
- subscribe: CuboCrdtServerSubscribe,
1014
- generation: number,
1015
- forceSync: boolean
1016
- ) {
921
+ private async reconcileSubscribe(client: WsServerSocket, subscribe: CuboCrdtServerSubscribe, generation: number, forceSync: boolean) {
1017
922
  const fetched = await this.options.fetchRows?.(client, subscribe)
1018
923
  const result = Array.isArray(fetched)
1019
924
  ? { rows: fetched, totals: subscribe.totals }
1020
925
  : { rows: fetched?.rows || [], totals: fetched?.totals || subscribe.totals }
1021
926
 
1022
- if (
1023
- this.subscribes.get(subscribe.id) !== subscribe ||
1024
- this.subscribeGenerations.get(subscribe.id) !== generation
1025
- ) {
927
+ if (this.subscribes.get(subscribe.id) !== subscribe || this.subscribeGenerations.get(subscribe.id) !== generation) {
1026
928
  return false
1027
929
  }
1028
930
 
@@ -1095,10 +997,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
1095
997
  }
1096
998
 
1097
999
  const idsChanged = !areCrdtListIdsEqual(subscribe.row_ids, nextIds)
1098
- const totalsChanged = !areCrdtListTotalsEqual(
1099
- subscribe.totals,
1100
- result.totals
1101
- )
1000
+ const totalsChanged = !areCrdtListTotalsEqual(subscribe.totals, result.totals)
1102
1001
 
1103
1002
  subscribe.row_ids = nextIds
1104
1003
  subscribe.totals = result.totals
@@ -1137,10 +1036,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
1137
1036
  if (upgrade.filters !== undefined) {
1138
1037
  subscribe.filters = cloneDeep(upgrade.filters || {})
1139
1038
  }
1140
- subscribe.paginated = this.resolvePaginated(
1141
- subscribe.filters,
1142
- upgrade.paginated
1143
- )
1039
+ subscribe.paginated = this.resolvePaginated(subscribe.filters, upgrade.paginated)
1144
1040
 
1145
1041
  return this.requestSubscribeRefresh(subscribe.id, true)
1146
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?: (
@@ -77,16 +63,4 @@ export type CuboCrdtServerDocumentOrigin = {
77
63
 
78
64
  // id клиента
79
65
  client_id?: string
80
-
81
- /**
82
- * Снимок серверного контекста автора в момент получения update.
83
- *
84
- * Он нужен только для отложенного сохранения: исходная подписка и сокет могут
85
- * закрыться при refresh раньше, чем сработает debounce. Клиентское значение
86
- * этого поля сервер всегда перезаписывает собственными данными.
87
- */
88
- store_context?: {
89
- client_id: string
90
- auth?: unknown
91
- }
92
66
  }
@@ -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
- }