@cuboapp/crdt 1.0.23 → 1.0.25

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.25",
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,9 @@ export * from './types'
28
25
 
29
26
  export class CuboCrdtClient<M> {
30
27
  public store: CuboCrdtClientStore<M> = {}
28
+ // Реактивен только реестр: Vue отслеживает set/delete и повторно вычисляет
29
+ // computed(useDoc). Сами Y.Doc/Awareness остаются исходными объектами без proxy.
30
+ public docs = new Map<string, CuboCrdtClientDoc>()
31
31
 
32
32
  private listeners: Map<string, Map<string, (ctx: CuboCrdtClientSubscribeEvent) => void | Promise<void>>> = new Map()
33
33
 
@@ -110,6 +110,11 @@ export class CuboCrdtClient<M> {
110
110
 
111
111
  this.ws.deleteHandler(CUBO_CRDT_EVENT.EVENT)
112
112
 
113
+ for (const document of this.docs.values()) {
114
+ document.awareness?.destroy()
115
+ document.doc.destroy()
116
+ }
117
+ this.docs.clear()
113
118
  this.listeners.clear()
114
119
  this.subscriptions.clear()
115
120
  }
@@ -233,9 +238,7 @@ export class CuboCrdtClient<M> {
233
238
  })
234
239
 
235
240
  this.store[storeKey] = {
236
- state,
237
- docs: shallowReactive(new Map()),
238
- awarenesses: shallowReactive(new Map())
241
+ state
239
242
  }
240
243
  }
241
244
 
@@ -260,6 +263,7 @@ export class CuboCrdtClient<M> {
260
263
  }
261
264
 
262
265
  // подписываемся на фронте
266
+ // console.log('subscribe', opts)
263
267
  this.listeners.get(entity)?.set(subscribe_id, async (ctx) => {
264
268
  await this.onIncomingUpdate(ctx, {
265
269
  subscribe_id,
@@ -330,13 +334,11 @@ export class CuboCrdtClient<M> {
330
334
 
331
335
  // отписываемся на бэке
332
336
  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
- })
337
+ void this.ws.request({ method: CUBO_CRDT_EVENT.UNSUBSCRIBE, data: { subscribe_id } }).catch((e) => {
338
+ if (this.debug) {
339
+ console.warn('[CRDT] unsubscribe request failed (socket down)', subscribe_id, e)
340
+ }
341
+ })
340
342
  }
341
343
 
342
344
  // отписываемся на фронте
@@ -347,21 +349,13 @@ export class CuboCrdtClient<M> {
347
349
  this.store[storeKey]!.state.status = 'idle'
348
350
  this.store[storeKey]!.state.error = undefined
349
351
 
352
+ const item = this.store[storeKey]
353
+ item?.state.rows.forEach((row) => {
354
+ this.detachDocument(`${entity}`, (row as any).id, subscribe_id, storeKey)
355
+ })
356
+
350
357
  // очищаем стор
351
358
  if (clear !== false) {
352
- const item = this.store[storeKey]
353
- if (item) {
354
- item.awarenesses?.forEach((a) => {
355
- removeAwarenessStates(a, [a.doc.clientID], 'unsubscribe')
356
-
357
- a.setLocalState(null)
358
- a.destroy()
359
- })
360
- item.awarenesses?.clear()
361
- item.docs?.forEach((d) => d.destroy())
362
- item.docs?.clear()
363
- }
364
-
365
359
  this.store[storeKey] = undefined
366
360
  }
367
361
  }
@@ -383,8 +377,6 @@ export class CuboCrdtClient<M> {
383
377
  subscribe,
384
378
  upgrade,
385
379
  unsubscribe,
386
- docs: computed(() => this.store[storeKey]?.docs),
387
- awarenesses: opts?.awareness ? computed(() => this.store[storeKey]?.awarenesses) : undefined,
388
380
  subscribed: () => computed(() => this.store[storeKey]?.state.subscribed || false),
389
381
  loading: () => computed(() => this.store[storeKey]?.state.loading || false),
390
382
  ready: () => computed(() => this.store[storeKey]?.state.status === 'ready'),
@@ -397,6 +389,10 @@ export class CuboCrdtClient<M> {
397
389
  }
398
390
  }
399
391
 
392
+ public useDoc<K extends Extract<keyof M, string>>(entity: K, id: number) {
393
+ return this.docs.get(`${entity}:${id}`)
394
+ }
395
+
400
396
  public useRow<K extends Extract<keyof M, string>, T = CuboCrdtKey<K, M>>(
401
397
  entity: K | string,
402
398
  id: number,
@@ -404,20 +400,7 @@ export class CuboCrdtClient<M> {
404
400
  ): CuboCrdtClientRow<T> {
405
401
  // console.log('store key', opts?.storeKey ?? `${entity}:${id}`)
406
402
 
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, {
403
+ const { subscribe_id, storeKey, subscribe, upgrade, unsubscribe, subscribed, loading, ready, status, error } = this.useList(entity, {
421
404
  ...opts,
422
405
  filters: opts?.filters ?? { id },
423
406
  storeKey: opts?.storeKey ?? `${entity}:${id}`
@@ -426,8 +409,6 @@ export class CuboCrdtClient<M> {
426
409
  return {
427
410
  subscribe_id,
428
411
  storeKey,
429
- doc: computed(() => docs.value?.get(id)),
430
- awareness: opts?.awareness ? computed(() => awarenesses?.value?.get(id)) : undefined,
431
412
  subscribe,
432
413
  upgrade,
433
414
  unsubscribe,
@@ -450,92 +431,30 @@ export class CuboCrdtClient<M> {
450
431
  dto: K extends Extract<keyof M, string> ? Partial<M[K]> : object,
451
432
  opts?: CuboCrdtClientDocOrigin
452
433
  ) {
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)
434
+ const storeKey = `${entity}:${entity_id}`
499
435
 
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
- })
436
+ const document = this.docs.get(storeKey)
524
437
 
525
- // Этот документ является каноническим локальным состоянием строки. Обновляем
526
- // его реактивную строку сразу, а другие подписки получат тот же Yjs-update
527
- // через серверную рассылку.
528
- this.applyRowJson(`${entity}:${entity_id}`, entity_id, document)
438
+ if (document) {
439
+ const map = document.doc.getMap()
440
+ const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => map.get(key) !== value))
529
441
 
530
- const row = store.state.rows.find((item: any) => Number(item.id) === Number(entity_id))
442
+ if (Object.keys(toUpdate).length) {
443
+ const origin: CuboCrdtClientDocOrigin = {
444
+ store: opts?.store ?? true,
445
+ expose: opts?.expose ?? 'other',
446
+ keys: opts?.keys ?? Object.keys(toUpdate),
447
+ react: true,
448
+ subscribe_id: opts?.subscribe_id ?? document.subscribes.values().next().value
449
+ }
531
450
 
532
- if (row) {
533
- Object.entries(toUpdate).forEach(([key, value]) => {
534
- row[key] = value
535
- })
451
+ document.doc.transact(() => {
452
+ Object.entries(toUpdate).forEach(([key, value]) => {
453
+ map.set(key, value)
454
+ })
455
+ }, origin)
456
+ }
536
457
  }
537
-
538
- return receipt
539
458
  }
540
459
 
541
460
  public upgrade(subscribe_id: string, filters?: Record<string, any>) {
@@ -579,61 +498,94 @@ export class CuboCrdtClient<M> {
579
498
  })
580
499
  }
581
500
 
582
- // апсертит строку в реактивное хранилище из текущего состояния yjs-документа
583
- private applyRowJson(storeKey: string, entity_id: number, doc: Doc) {
584
- const json: any = doc.getMap().toJSON()
585
- const rows = this.store[storeKey]?.state.rows
586
- if (!rows) {
587
- return
588
- }
589
-
590
- const index = rows.findIndex((r: any) => r.id === entity_id)
591
- if (index >= 0) {
592
- rows[index] = json
593
- } else {
594
- rows.push(json)
501
+ private applyDocumentRows(entity_id: number, document: CuboCrdtClientDoc) {
502
+ for (const subscribe_id of document.subscribes) {
503
+ const subscription = this.subscriptions.get(subscribe_id)
504
+ if (subscription) {
505
+ this.applyRowJson(subscription.storeKey, entity_id, document.doc)
506
+ }
595
507
  }
596
508
  }
597
509
 
598
- private ensureDocumentAwareness(
599
- ctx: CuboCrdtClientSubscribeEvent,
600
- opts: CuboCrdtClientDocUpdateOptions,
601
- doc: Doc,
602
- entity_id: number
603
- ) {
510
+ private attachDocument(entity: string, entity_id: number, document: CuboCrdtClientDoc, opts: CuboCrdtClientDocUpdateOptions) {
511
+ document.subscribes.add(opts.subscribe_id)
512
+
604
513
  if (!opts.awareness) {
605
514
  return
606
515
  }
607
516
 
608
- let awareness = this.store[opts.storeKey]?.awarenesses.get(entity_id)
517
+ document.awarenessSubscribes.add(opts.subscribe_id)
609
518
 
610
- if (awareness) {
519
+ if (!document.awareness) {
520
+ const awareness = new Awareness(document.doc)
521
+
522
+ awareness.on('update', ({ added, updated, removed }, origin) => {
523
+ if (origin === 'remote') {
524
+ return
525
+ }
526
+
527
+ const subscribe_id = document.awarenessSubscribes.values().next().value
528
+ if (!subscribe_id) {
529
+ return
530
+ }
531
+
532
+ const changed = added.concat(updated).concat(removed)
533
+ const update = encodeAwarenessUpdate(awareness, changed)
534
+
535
+ void this.ws
536
+ .request({
537
+ method: CUBO_CRDT_EVENT.EVENT,
538
+ data: {
539
+ action: 'awareness',
540
+ entity,
541
+ entity_id,
542
+ data: Array.from(update),
543
+ origin: { expose: 'other', subscribe_id }
544
+ }
545
+ })
546
+ .catch((error) => console.error('[CRDT] awareness request failed', error))
547
+ })
548
+ document.awareness = awareness
549
+ }
550
+ }
551
+
552
+ private detachDocument(entity: string, entity_id: number, subscribe_id: string, storeKey: string) {
553
+ const key = `${entity}:${entity_id}`
554
+ const document = this.docs.get(key)
555
+ if (!document) {
611
556
  return
612
557
  }
613
558
 
614
- awareness = new Awareness(doc)
559
+ document.subscribes.delete(subscribe_id)
560
+ document.awarenessSubscribes.delete(subscribe_id)
615
561
 
616
- awareness.on('update', ({ added, updated, removed }, origin) => {
617
- if (origin === 'remote') {
618
- return
619
- }
562
+ if (!document.awarenessSubscribes.size && document.awareness) {
563
+ removeAwarenessStates(document.awareness, [document.awareness.doc.clientID], 'unsubscribe')
564
+ document.awareness.setLocalState(null)
565
+ document.awareness.destroy()
566
+ document.awareness = undefined
567
+ }
620
568
 
621
- const changed = added.concat(updated).concat(removed)
622
- const update = encodeAwarenessUpdate(awareness!, changed)
569
+ if (!document.subscribes.size) {
570
+ document.doc.destroy()
571
+ this.docs.delete(key)
572
+ }
573
+ }
623
574
 
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
- })
575
+ // апсертит строку в реактивное хранилище из текущего состояния yjs-документа
576
+ private applyRowJson(storeKey: string, entity_id: number, doc: Doc) {
577
+ const json: any = doc.getMap().toJSON()
578
+ const rows = this.store[storeKey]?.state.rows
579
+ if (!rows) {
580
+ return
581
+ }
635
582
 
636
- this.store[opts.storeKey]?.awarenesses.set(entity_id, awareness)
583
+ const index = rows.findIndex((r: any) => r.id === entity_id)
584
+ if (index >= 0) {
585
+ rows[index] = json
586
+ } else {
587
+ rows.push(json)
588
+ }
637
589
  }
638
590
 
639
591
  private async onDocumentCreate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
@@ -642,7 +594,7 @@ export class CuboCrdtClient<M> {
642
594
  return
643
595
  }
644
596
 
645
- const existing = this.store[opts.storeKey]?.docs.get(entity_id)
597
+ const existing = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
646
598
  if (existing) {
647
599
  // документ уже есть локально (типичный кейс — повторный SUBSCRIBE после реконнекта).
648
600
  // вместо игнорирования вмёрживаем входящее состояние в существующий yjs-документ
@@ -651,13 +603,13 @@ export class CuboCrdtClient<M> {
651
603
  console.log('[CRDT] onDocumentCreate: doc already exists, merging state', { ctx, opts })
652
604
  }
653
605
 
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)
606
+ this.attachDocument(ctx.entity, entity_id, existing, opts)
607
+ applyUpdate(existing.doc, new Uint8Array(ctx.data as any), { react: false })
608
+ this.applyRowJson(opts.storeKey, entity_id, existing.doc)
657
609
  return
658
610
  }
659
611
 
660
- const doc = new Doc()
612
+ const yjsDoc = new Doc()
661
613
 
662
614
  const update = new Uint8Array(ctx.data as any)
663
615
 
@@ -668,10 +620,22 @@ export class CuboCrdtClient<M> {
668
620
  }
669
621
  }
670
622
 
671
- applyUpdate(doc, update, { react: false })
623
+ applyUpdate(yjsDoc, update, { react: false })
624
+
625
+ const document: CuboCrdtClientDoc = {
626
+ doc: yjsDoc,
627
+ subscribes: new Set(),
628
+ awarenessSubscribes: new Set()
629
+ }
630
+ this.attachDocument(ctx.entity, entity_id, document, opts)
631
+ // Публикуем контейнер реактивному реестру только после полной инициализации.
632
+ // Тогда первый computed(useDoc) не увидит промежуточный doc без Awareness.
633
+ this.docs.set(`${ctx.entity}:${ctx.entity_id}`, document)
672
634
 
673
635
  // подписываемся на обновления документа
674
- doc.on('update', (update, origin: CuboCrdtClientDocOrigin) => {
636
+ yjsDoc.on('update', (update, origin: CuboCrdtClientDocOrigin) => {
637
+ this.applyDocumentRows(entity_id, document)
638
+
675
639
  // react = false, если это апдейт с бэка
676
640
  if (origin?.react !== false) {
677
641
  const data: CuboCrdtServerDocumentIncomingAction = {
@@ -685,22 +649,21 @@ export class CuboCrdtClient<M> {
685
649
  // приходят без expose и сервер эхом шлёт их обратно самому автору.
686
650
  expose: 'other',
687
651
  ...pick(origin || {}, ['store', 'keys', 'expose']),
688
- subscribe_id: opts?.subscribe_id
652
+ subscribe_id: origin?.subscribe_id ?? document.subscribes.values().next().value
689
653
  }
690
654
  }
691
655
 
692
- void this.ws.request({
693
- method: CUBO_CRDT_EVENT.EVENT,
694
- data
695
- }).catch((error) => console.error('[CRDT] update request failed', error))
656
+ void this.ws
657
+ .request({
658
+ method: CUBO_CRDT_EVENT.EVENT,
659
+ data
660
+ })
661
+ .catch((error) => console.error('[CRDT] update request failed', error))
696
662
  }
697
663
  })
698
664
 
699
- // добавляем документ в хранилище документов
700
- this.store[opts.storeKey]?.docs.set(entity_id, doc)
701
-
702
665
  // добавляем документ в реактивное хранилище
703
- const json: any = doc.getMap().toJSON()
666
+ const json: any = yjsDoc.getMap().toJSON()
704
667
  const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === entity_id)
705
668
  if (index !== undefined && index >= 0) {
706
669
  if (this.debug) {
@@ -713,10 +676,8 @@ export class CuboCrdtClient<M> {
713
676
  }
714
677
 
715
678
  if (opts?.onAfterCreate) {
716
- await opts.onAfterCreate(doc, update, ctx, opts)
679
+ await opts.onAfterCreate(yjsDoc, update, ctx, opts)
717
680
  }
718
-
719
- this.ensureDocumentAwareness(ctx, opts, doc, entity_id)
720
681
  }
721
682
 
722
683
  private async onDocumentUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
@@ -725,7 +686,7 @@ export class CuboCrdtClient<M> {
725
686
  return
726
687
  }
727
688
 
728
- const doc = this.store[opts.storeKey]?.docs.get(entity_id)
689
+ const doc = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
729
690
  if (!doc) {
730
691
  console.warn('[CRDT] onDocumentUpdate: doc not exists', { ctx, opts })
731
692
  return
@@ -734,20 +695,20 @@ export class CuboCrdtClient<M> {
734
695
  const update = new Uint8Array(ctx.data as any)
735
696
 
736
697
  if (opts?.onBeforeUpdate) {
737
- const result = await opts.onBeforeUpdate(doc, update, ctx, opts)
698
+ const result = await opts.onBeforeUpdate(doc.doc, update, ctx, opts)
738
699
  if (!result) {
739
700
  return
740
701
  }
741
702
  }
742
703
 
743
704
  // обновляем yjs-ный документ
744
- applyUpdate(doc, update, { react: false })
705
+ applyUpdate(doc.doc, update, { react: false })
745
706
 
746
- // обновляем документ в реактивном хранилище
747
- this.applyRowJson(opts.storeKey, entity_id, doc)
707
+ // один канонический документ обновляет проекции всех активных подписок
708
+ this.applyDocumentRows(entity_id, doc)
748
709
 
749
710
  if (opts?.onAfterUpdate) {
750
- await opts.onAfterUpdate(doc, update, ctx, opts)
711
+ await opts.onAfterUpdate(doc.doc, update, ctx, opts)
751
712
  }
752
713
  }
753
714
 
@@ -757,43 +718,34 @@ export class CuboCrdtClient<M> {
757
718
  return
758
719
  }
759
720
 
760
- const doc = this.store[opts.storeKey]?.docs.get(entity_id)
721
+ const doc = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
761
722
  if (!doc) {
762
723
  console.warn('[CRDT] onDocumentDelete: doc not exists', { ctx, opts })
763
724
  return
764
725
  }
765
726
 
766
727
  if (opts?.onBeforeDelete) {
767
- const result = await opts.onBeforeDelete(doc, ctx, opts)
728
+ const result = await opts.onBeforeDelete(doc.doc, ctx, opts)
768
729
  if (!result) {
769
730
  return
770
731
  }
771
732
  }
772
733
 
773
- // console.log('onDocumentDelete', ctx, opts)
774
-
775
- doc.destroy()
776
- this.store[opts.storeKey]?.docs.delete(entity_id)
777
-
778
- const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === entity_id)
779
- if (index !== undefined && index >= 0) {
780
- this.store[opts.storeKey]?.state.rows.splice(index, 1)
781
- } else {
782
- if (this.debug) {
783
- console.log('[CRDT] row not found', index, ctx, opts)
734
+ for (const subscribe_id of doc.subscribes) {
735
+ const subscription = this.subscriptions.get(subscribe_id)
736
+ const item = subscription && this.store[subscription.storeKey]
737
+ const index = item?.state.rows.findIndex((row: any) => row.id === entity_id) ?? -1
738
+ if (item && index >= 0) {
739
+ item.state.rows.splice(index, 1)
784
740
  }
785
741
  }
786
742
 
787
- const awareness = this.store[opts.storeKey]?.awarenesses.get(entity_id)
788
- if (awareness) {
789
- removeAwarenessStates(awareness, [awareness.doc.clientID], 'unsubscribe')
790
-
791
- awareness.destroy()
792
- this.store[opts.storeKey]?.awarenesses?.delete(entity_id)
793
- }
743
+ doc.awareness?.destroy()
744
+ doc.doc.destroy()
745
+ this.docs.delete(`${ctx.entity}:${ctx.entity_id}`)
794
746
 
795
747
  if (opts?.onAfterDelete) {
796
- await opts.onAfterDelete(doc, ctx, opts)
748
+ await opts.onAfterDelete(doc.doc, ctx, opts)
797
749
  }
798
750
  }
799
751
 
@@ -828,27 +780,18 @@ export class CuboCrdtClient<M> {
828
780
  const ids = data.ids.filter((id) => Number.isFinite(id))
829
781
  const idsSet = new Set(ids)
830
782
 
831
- for (const [entity_id, doc] of item.docs) {
783
+ for (const row of item.state.rows) {
784
+ const entity_id = (row as any).id
785
+
832
786
  if (idsSet.has(entity_id)) {
833
787
  continue
834
788
  }
835
789
 
836
- const awareness = item.awarenesses.get(entity_id)
837
- if (awareness) {
838
- removeAwarenessStates(awareness, [awareness.doc.clientID], 'sync')
839
- awareness.setLocalState(null)
840
- awareness.destroy()
841
- item.awarenesses.delete(entity_id)
842
- }
843
-
844
- doc.destroy()
845
- item.docs.delete(entity_id)
790
+ this.detachDocument(ctx.entity, entity_id, opts.subscribe_id, opts.storeKey)
846
791
  }
847
792
 
848
793
  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)
794
+ const orderedRows = ids.map((id) => rowsById.get(id)).filter((row) => row !== undefined)
852
795
 
853
796
  item.state.rows.splice(0, item.state.rows.length, ...orderedRows)
854
797
  item.state.totals = data.totals || {}
@@ -865,15 +808,15 @@ export class CuboCrdtClient<M> {
865
808
  return
866
809
  }
867
810
 
868
- const awareness = this.store[opts.storeKey]?.awarenesses?.get(entity_id)
869
- if (!awareness) {
811
+ const doc = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
812
+ if (!doc?.awareness) {
870
813
  return
871
814
  }
872
815
 
873
816
  const update = new Uint8Array(ctx.data as any)
874
817
 
875
818
  try {
876
- applyAwarenessUpdate(awareness, update, 'remote')
819
+ applyAwarenessUpdate(doc.awareness, update, 'remote')
877
820
  } catch (e) {
878
821
  console.warn('[CRDT] applyAwarenessUpdate failed ', e)
879
822
  }
@@ -1,8 +1,18 @@
1
+ import { Awareness } from 'y-protocols/awareness'
2
+ import { Doc } from 'yjs'
3
+
1
4
  import { CuboCrdtClientUseOptions } from '..'
2
5
  import { CuboCrdtExposeStrategy } from '../../types'
3
6
 
4
7
  import { CuboCrdtClientBaseOptions } from './utils'
5
8
 
9
+ export type CuboCrdtClientDoc = {
10
+ doc: Doc
11
+ subscribes: Set<string>
12
+ awareness?: Awareness
13
+ awarenessSubscribes: Set<string>
14
+ }
15
+
6
16
  export type CuboCrdtClientDocOrigin = {
7
17
  // сохранять ли на бэке в дебаунсе
8
18
  store?: boolean
@@ -15,6 +25,9 @@ export type CuboCrdtClientDocOrigin = {
15
25
 
16
26
  // куда раскатывать обновления - всем или всем кроме себя
17
27
  expose?: CuboCrdtExposeStrategy
28
+
29
+ // подписка, из которой инициировано локальное изменение
30
+ subscribe_id?: string
18
31
  }
19
32
 
20
33
  export type CuboCrdtClientDocUpdateOptions = { subscribe_id: string; storeKey: string } & Pick<
@@ -1,14 +1,8 @@
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
 
13
7
  import { CuboCrdtClientBaseOptions } from './utils'
14
8
 
@@ -45,7 +39,6 @@ export type CuboCrdtClientSubscribeEvent = {
45
39
  export type CuboCrdtClientList<T> = {
46
40
  subscribe_id: string
47
41
  storeKey: string
48
- docs: ComputedRef<Map<number, Doc> | undefined>
49
42
  awarenesses?: ComputedRef<Map<number, Awareness> | undefined>
50
43
  subscribe: (filters?: any) => void
51
44
  upgrade: (filters?: any) => void
@@ -64,8 +57,6 @@ export type CuboCrdtClientList<T> = {
64
57
  export type CuboCrdtClientRow<T> = {
65
58
  subscribe_id: string
66
59
  storeKey: string
67
- doc: ComputedRef<Doc | undefined>
68
- awareness?: ComputedRef<Awareness | undefined>
69
60
  subscribe: () => void
70
61
  upgrade: (filters?: any) => void
71
62
  unsubscribe: (clear?: boolean) => void
@@ -1,12 +1,6 @@
1
1
  import { Reactive } from 'vue'
2
- import { Doc } from 'yjs'
3
- import { Awareness } from 'y-protocols/awareness'
4
2
 
5
- import {
6
- CuboCrdtKey,
7
- CuboCrdtListTotals,
8
- CuboCrdtSubscriptionStatus
9
- } from '../../types'
3
+ import { CuboCrdtKey, CuboCrdtListTotals, CuboCrdtSubscriptionStatus } from '../../types'
10
4
 
11
5
  export type CuboCrdtClientStoreItem<T> = {
12
6
  state: Reactive<{
@@ -18,8 +12,6 @@ export type CuboCrdtClientStoreItem<T> = {
18
12
  totals: CuboCrdtListTotals
19
13
  revision: number
20
14
  }>
21
- docs: Map<number, Doc>
22
- awarenesses: Map<number, Awareness>
23
15
  }
24
16
  export type CuboCrdtClientStore<M> = Partial<{
25
17
  [K in string]: CuboCrdtClientStoreItem<CuboCrdtKey<K, M>>
@@ -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'
@@ -129,6 +125,29 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
129
125
  }
130
126
 
131
127
  for (const row of data) {
128
+ const subscribe_id = row.origin?.subscribe_id
129
+ const subscribe = subscribe_id && this.subscribes.get(subscribe_id)
130
+ const documentName = `${row.entity}:${row.entity_id}`
131
+ const requiresActiveSubscription = row.action === 'update' || row.action === 'awareness'
132
+
133
+ if (
134
+ requiresActiveSubscription &&
135
+ (!subscribe ||
136
+ subscribe.client_id !== client.id ||
137
+ subscribe.entity !== row.entity ||
138
+ !this.documentsBySubscribe.get(subscribe_id)?.has(documentName))
139
+ ) {
140
+ if (this.debug) {
141
+ console.warn('[CRDT] ignored update from inactive subscription', {
142
+ action: row.action,
143
+ entity: row.entity,
144
+ entity_id: row.entity_id,
145
+ subscribe_id
146
+ })
147
+ }
148
+ continue
149
+ }
150
+
132
151
  const update = new Uint8Array(row.data)
133
152
  const origin: CuboCrdtServerDocumentOrigin = {
134
153
  ...(row.origin || {}),
@@ -197,6 +216,17 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
197
216
  this.documentEvictions.clear()
198
217
 
199
218
  this.subscribes.forEach((sub) => this.ws.deleteHandler(sub.id))
219
+
220
+ const documents = [...this.documents.values()]
221
+ const results = await Promise.allSettled(documents.map((document) => document.flushStoreQueue()))
222
+ results.forEach((result, index) => {
223
+ if (result.status === 'rejected') {
224
+ console.error('[CRDT] flush document on destroy', documents[index]?.name, result.reason)
225
+ }
226
+ })
227
+ documents.forEach((document) => document.destroy())
228
+ this.documents.clear()
229
+ this.subscribesByDocument.clear()
200
230
  }
201
231
 
202
232
  public addClient(client: WsServerSocket<{ auth?: A }>) {
@@ -501,12 +531,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
501
531
  const generation = this.subscribeGenerations.get(subscribe_id) || 0
502
532
  const forceSync = state.forceSync
503
533
  state.forceSync = false
504
- const reconciled = await this.reconcileSubscribe(
505
- client,
506
- subscribe,
507
- generation,
508
- forceSync
509
- )
534
+ const reconciled = await this.reconcileSubscribe(client, subscribe, generation, forceSync)
510
535
  if (!reconciled && forceSync) {
511
536
  state.forceSync = true
512
537
  }
@@ -518,11 +543,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
518
543
  return state.running
519
544
  }
520
545
 
521
- private requestSubscribeRefresh(
522
- subscribe_id: string,
523
- immediate = false,
524
- forceSync = immediate
525
- ) {
546
+ private requestSubscribeRefresh(subscribe_id: string, immediate = false, forceSync = immediate) {
526
547
  const state = this.getSubscribeRefreshState(subscribe_id)
527
548
  state.dirty = true
528
549
  state.forceSync ||= forceSync
@@ -608,16 +629,8 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
608
629
  }
609
630
 
610
631
  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
- )
632
+ const previousSuitable = this.checkRowIsSutable(mutation.previousRow, subscribe, entity)
633
+ const currentSuitable = this.checkRowIsSutable(mutation.row, subscribe, entity)
621
634
 
622
635
  if (previousSuitable !== currentSuitable) {
623
636
  return true
@@ -901,13 +914,9 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
901
914
 
902
915
  const subscribeSutable = this.checkSubscribeIsSutable(origin, subscribe)
903
916
  const documentExistsInSubscribe = this.subscribesByDocument.get(document.name)?.has(subscribe.id)
904
- const rowSutable = subscribe.paginated
905
- ? documentExistsInSubscribe
906
- : this.checkRowIsSutable(row, subscribe, entity)
917
+ const rowSutable = subscribe.paginated ? documentExistsInSubscribe : this.checkRowIsSutable(row, subscribe, entity)
907
918
 
908
- const sutable = subscribeSutable && (subscribe.paginated
909
- ? documentExistsInSubscribe
910
- : rowSutable || documentExistsInSubscribe)
919
+ const sutable = subscribeSutable && (subscribe.paginated ? documentExistsInSubscribe : rowSutable || documentExistsInSubscribe)
911
920
 
912
921
  if (sutable) {
913
922
  if (this.debug && !rowSutable) {
@@ -1008,21 +1017,13 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
1008
1017
  }
1009
1018
  }
1010
1019
 
1011
- private async reconcileSubscribe(
1012
- client: WsServerSocket,
1013
- subscribe: CuboCrdtServerSubscribe,
1014
- generation: number,
1015
- forceSync: boolean
1016
- ) {
1020
+ private async reconcileSubscribe(client: WsServerSocket, subscribe: CuboCrdtServerSubscribe, generation: number, forceSync: boolean) {
1017
1021
  const fetched = await this.options.fetchRows?.(client, subscribe)
1018
1022
  const result = Array.isArray(fetched)
1019
1023
  ? { rows: fetched, totals: subscribe.totals }
1020
1024
  : { rows: fetched?.rows || [], totals: fetched?.totals || subscribe.totals }
1021
1025
 
1022
- if (
1023
- this.subscribes.get(subscribe.id) !== subscribe ||
1024
- this.subscribeGenerations.get(subscribe.id) !== generation
1025
- ) {
1026
+ if (this.subscribes.get(subscribe.id) !== subscribe || this.subscribeGenerations.get(subscribe.id) !== generation) {
1026
1027
  return false
1027
1028
  }
1028
1029
 
@@ -1095,10 +1096,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
1095
1096
  }
1096
1097
 
1097
1098
  const idsChanged = !areCrdtListIdsEqual(subscribe.row_ids, nextIds)
1098
- const totalsChanged = !areCrdtListTotalsEqual(
1099
- subscribe.totals,
1100
- result.totals
1101
- )
1099
+ const totalsChanged = !areCrdtListTotalsEqual(subscribe.totals, result.totals)
1102
1100
 
1103
1101
  subscribe.row_ids = nextIds
1104
1102
  subscribe.totals = result.totals
@@ -1137,10 +1135,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
1137
1135
  if (upgrade.filters !== undefined) {
1138
1136
  subscribe.filters = cloneDeep(upgrade.filters || {})
1139
1137
  }
1140
- subscribe.paginated = this.resolvePaginated(
1141
- subscribe.filters,
1142
- upgrade.paginated
1143
- )
1138
+ subscribe.paginated = this.resolvePaginated(subscribe.filters, upgrade.paginated)
1144
1139
 
1145
1140
  return this.requestSubscribeRefresh(subscribe.id, true)
1146
1141
  }