@cuboapp/crdt 1.0.24 → 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.24",
3
+ "version": "1.0.25",
4
4
  "description": "CRDT for CUBO",
5
5
  "main": "src/index.ts",
6
6
  "repository": {
@@ -25,6 +25,8 @@ export * from './types'
25
25
 
26
26
  export class CuboCrdtClient<M> {
27
27
  public store: CuboCrdtClientStore<M> = {}
28
+ // Реактивен только реестр: Vue отслеживает set/delete и повторно вычисляет
29
+ // computed(useDoc). Сами Y.Doc/Awareness остаются исходными объектами без proxy.
28
30
  public docs = new Map<string, CuboCrdtClientDoc>()
29
31
 
30
32
  private listeners: Map<string, Map<string, (ctx: CuboCrdtClientSubscribeEvent) => void | Promise<void>>> = new Map()
@@ -108,6 +110,11 @@ export class CuboCrdtClient<M> {
108
110
 
109
111
  this.ws.deleteHandler(CUBO_CRDT_EVENT.EVENT)
110
112
 
113
+ for (const document of this.docs.values()) {
114
+ document.awareness?.destroy()
115
+ document.doc.destroy()
116
+ }
117
+ this.docs.clear()
111
118
  this.listeners.clear()
112
119
  this.subscriptions.clear()
113
120
  }
@@ -231,8 +238,7 @@ export class CuboCrdtClient<M> {
231
238
  })
232
239
 
233
240
  this.store[storeKey] = {
234
- state,
235
- awarenesses: new Map()
241
+ state
236
242
  }
237
243
  }
238
244
 
@@ -343,31 +349,13 @@ export class CuboCrdtClient<M> {
343
349
  this.store[storeKey]!.state.status = 'idle'
344
350
  this.store[storeKey]!.state.error = undefined
345
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
+
346
357
  // очищаем стор
347
358
  if (clear !== false) {
348
- const item = this.store[storeKey]
349
- if (item) {
350
- item.awarenesses?.forEach((a) => {
351
- removeAwarenessStates(a, [a.doc.clientID], 'unsubscribe')
352
-
353
- a.setLocalState(null)
354
- a.destroy()
355
- })
356
- item.awarenesses?.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
- })
369
- }
370
-
371
359
  this.store[storeKey] = undefined
372
360
  }
373
361
  }
@@ -389,7 +377,6 @@ export class CuboCrdtClient<M> {
389
377
  subscribe,
390
378
  upgrade,
391
379
  unsubscribe,
392
- awarenesses: opts?.awareness ? computed(() => this.store[storeKey]?.awarenesses) : undefined,
393
380
  subscribed: () => computed(() => this.store[storeKey]?.state.subscribed || false),
394
381
  loading: () => computed(() => this.store[storeKey]?.state.loading || false),
395
382
  ready: () => computed(() => this.store[storeKey]?.state.status === 'ready'),
@@ -402,6 +389,10 @@ export class CuboCrdtClient<M> {
402
389
  }
403
390
  }
404
391
 
392
+ public useDoc<K extends Extract<keyof M, string>>(entity: K, id: number) {
393
+ return this.docs.get(`${entity}:${id}`)
394
+ }
395
+
405
396
  public useRow<K extends Extract<keyof M, string>, T = CuboCrdtKey<K, M>>(
406
397
  entity: K | string,
407
398
  id: number,
@@ -409,18 +400,15 @@ export class CuboCrdtClient<M> {
409
400
  ): CuboCrdtClientRow<T> {
410
401
  // console.log('store key', opts?.storeKey ?? `${entity}:${id}`)
411
402
 
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
- })
403
+ const { subscribe_id, storeKey, subscribe, upgrade, unsubscribe, subscribed, loading, ready, status, error } = this.useList(entity, {
404
+ ...opts,
405
+ filters: opts?.filters ?? { id },
406
+ storeKey: opts?.storeKey ?? `${entity}:${id}`
407
+ })
418
408
 
419
409
  return {
420
410
  subscribe_id,
421
411
  storeKey,
422
- doc: this.docs.get(`${entity}:${id}`)?.doc,
423
- awareness: computed(() => awarenesses?.value?.get(id)),
424
412
  subscribe,
425
413
  upgrade,
426
414
  unsubscribe,
@@ -445,46 +433,28 @@ export class CuboCrdtClient<M> {
445
433
  ) {
446
434
  const storeKey = `${entity}:${entity_id}`
447
435
 
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()
436
+ const document = this.docs.get(storeKey)
455
437
 
456
- // "или" - потому что они синхронизированы (без разницы какой брать)
457
- const row = (cardRow || listRow) as any
458
- const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => row[key] !== value))
438
+ if (document) {
439
+ const map = document.doc.getMap()
440
+ const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => map.get(key) !== value))
459
441
 
460
442
  if (Object.keys(toUpdate).length) {
461
443
  const origin: CuboCrdtClientDocOrigin = {
462
444
  store: opts?.store ?? true,
463
445
  expose: opts?.expose ?? 'other',
464
446
  keys: opts?.keys ?? Object.keys(toUpdate),
465
- react: true // отправляем на бэк
447
+ react: true,
448
+ subscribe_id: opts?.subscribe_id ?? document.subscribes.values().next().value
466
449
  }
467
450
 
468
- doc.doc.transact(() => {
451
+ document.doc.transact(() => {
469
452
  Object.entries(toUpdate).forEach(([key, value]) => {
470
453
  map.set(key, value)
471
454
  })
472
455
  }, origin)
473
456
  }
474
457
  }
475
-
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))
480
-
481
- if (Object.keys(toUpdate).length) {
482
- Object.entries(toUpdate).forEach(([key, value]) => {
483
- row[key as keyof typeof row] = value
484
- })
485
- }
486
- }
487
- }
488
458
  }
489
459
 
490
460
  public upgrade(subscribe_id: string, filters?: Record<string, any>) {
@@ -528,6 +498,80 @@ export class CuboCrdtClient<M> {
528
498
  })
529
499
  }
530
500
 
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
+ }
507
+ }
508
+ }
509
+
510
+ private attachDocument(entity: string, entity_id: number, document: CuboCrdtClientDoc, opts: CuboCrdtClientDocUpdateOptions) {
511
+ document.subscribes.add(opts.subscribe_id)
512
+
513
+ if (!opts.awareness) {
514
+ return
515
+ }
516
+
517
+ document.awarenessSubscribes.add(opts.subscribe_id)
518
+
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) {
556
+ return
557
+ }
558
+
559
+ document.subscribes.delete(subscribe_id)
560
+ document.awarenessSubscribes.delete(subscribe_id)
561
+
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
+ }
568
+
569
+ if (!document.subscribes.size) {
570
+ document.doc.destroy()
571
+ this.docs.delete(key)
572
+ }
573
+ }
574
+
531
575
  // апсертит строку в реактивное хранилище из текущего состояния yjs-документа
532
576
  private applyRowJson(storeKey: string, entity_id: number, doc: Doc) {
533
577
  const json: any = doc.getMap().toJSON()
@@ -559,6 +603,7 @@ export class CuboCrdtClient<M> {
559
603
  console.log('[CRDT] onDocumentCreate: doc already exists, merging state', { ctx, opts })
560
604
  }
561
605
 
606
+ this.attachDocument(ctx.entity, entity_id, existing, opts)
562
607
  applyUpdate(existing.doc, new Uint8Array(ctx.data as any), { react: false })
563
608
  this.applyRowJson(opts.storeKey, entity_id, existing.doc)
564
609
  return
@@ -577,8 +622,20 @@ export class CuboCrdtClient<M> {
577
622
 
578
623
  applyUpdate(yjsDoc, update, { react: false })
579
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)
634
+
580
635
  // подписываемся на обновления документа
581
636
  yjsDoc.on('update', (update, origin: CuboCrdtClientDocOrigin) => {
637
+ this.applyDocumentRows(entity_id, document)
638
+
582
639
  // react = false, если это апдейт с бэка
583
640
  if (origin?.react !== false) {
584
641
  const data: CuboCrdtServerDocumentIncomingAction = {
@@ -592,21 +649,19 @@ export class CuboCrdtClient<M> {
592
649
  // приходят без expose и сервер эхом шлёт их обратно самому автору.
593
650
  expose: 'other',
594
651
  ...pick(origin || {}, ['store', 'keys', 'expose']),
595
- subscribe_id: opts?.subscribe_id
652
+ subscribe_id: origin?.subscribe_id ?? document.subscribes.values().next().value
596
653
  }
597
654
  }
598
655
 
599
- this.ws.request({
600
- method: CUBO_CRDT_EVENT.EVENT,
601
- data
602
- })
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))
603
662
  }
604
663
  })
605
664
 
606
- // добавляем документ в хранилище документов
607
- const doc: CuboCrdtClientDoc = { doc: yjsDoc, subscribes: new Set() }
608
- this.docs.set(`${ctx.entity}:${ctx.entity_id}`, doc)
609
-
610
665
  // добавляем документ в реактивное хранилище
611
666
  const json: any = yjsDoc.getMap().toJSON()
612
667
  const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === entity_id)
@@ -623,39 +678,6 @@ export class CuboCrdtClient<M> {
623
678
  if (opts?.onAfterCreate) {
624
679
  await opts.onAfterCreate(yjsDoc, update, ctx, opts)
625
680
  }
626
-
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
- }
659
681
  }
660
682
 
661
683
  private async onDocumentUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
@@ -682,8 +704,8 @@ export class CuboCrdtClient<M> {
682
704
  // обновляем yjs-ный документ
683
705
  applyUpdate(doc.doc, update, { react: false })
684
706
 
685
- // обновляем документ в реактивном хранилище
686
- this.applyRowJson(opts.storeKey, entity_id, doc.doc)
707
+ // один канонический документ обновляет проекции всех активных подписок
708
+ this.applyDocumentRows(entity_id, doc)
687
709
 
688
710
  if (opts?.onAfterUpdate) {
689
711
  await opts.onAfterUpdate(doc.doc, update, ctx, opts)
@@ -709,25 +731,18 @@ export class CuboCrdtClient<M> {
709
731
  }
710
732
  }
711
733
 
712
- doc.doc.destroy()
713
- this.docs.delete(`${ctx.entity}:${ctx.entity_id}`)
714
-
715
- const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === entity_id)
716
- if (index !== undefined && index >= 0) {
717
- this.store[opts.storeKey]?.state.rows.splice(index, 1)
718
- } else {
719
- if (this.debug) {
720
- 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)
721
740
  }
722
741
  }
723
742
 
724
- const awareness = this.store[opts.storeKey]?.awarenesses.get(entity_id)
725
- if (awareness) {
726
- removeAwarenessStates(awareness, [awareness.doc.clientID], 'unsubscribe')
727
-
728
- awareness.destroy()
729
- this.store[opts.storeKey]?.awarenesses?.delete(entity_id)
730
- }
743
+ doc.awareness?.destroy()
744
+ doc.doc.destroy()
745
+ this.docs.delete(`${ctx.entity}:${ctx.entity_id}`)
731
746
 
732
747
  if (opts?.onAfterDelete) {
733
748
  await opts.onAfterDelete(doc.doc, ctx, opts)
@@ -772,19 +787,7 @@ export class CuboCrdtClient<M> {
772
787
  continue
773
788
  }
774
789
 
775
- const awareness = item.awarenesses.get(entity_id)
776
- if (awareness) {
777
- removeAwarenessStates(awareness, [awareness.doc.clientID], 'sync')
778
- awareness.setLocalState(null)
779
- awareness.destroy()
780
- item.awarenesses.delete(entity_id)
781
- }
782
-
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
- }
790
+ this.detachDocument(ctx.entity, entity_id, opts.subscribe_id, opts.storeKey)
788
791
  }
789
792
 
790
793
  const rowsById = new Map(item.state.rows.map((row: any) => [Number(row.id), row]))
@@ -805,15 +808,15 @@ export class CuboCrdtClient<M> {
805
808
  return
806
809
  }
807
810
 
808
- const awareness = this.store[opts.storeKey]?.awarenesses?.get(entity_id)
809
- if (!awareness) {
811
+ const doc = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
812
+ if (!doc?.awareness) {
810
813
  return
811
814
  }
812
815
 
813
816
  const update = new Uint8Array(ctx.data as any)
814
817
 
815
818
  try {
816
- applyAwarenessUpdate(awareness, update, 'remote')
819
+ applyAwarenessUpdate(doc.awareness, update, 'remote')
817
820
  } catch (e) {
818
821
  console.warn('[CRDT] applyAwarenessUpdate failed ', e)
819
822
  }
@@ -1,4 +1,6 @@
1
+ import { Awareness } from 'y-protocols/awareness'
1
2
  import { Doc } from 'yjs'
3
+
2
4
  import { CuboCrdtClientUseOptions } from '..'
3
5
  import { CuboCrdtExposeStrategy } from '../../types'
4
6
 
@@ -7,6 +9,8 @@ import { CuboCrdtClientBaseOptions } from './utils'
7
9
  export type CuboCrdtClientDoc = {
8
10
  doc: Doc
9
11
  subscribes: Set<string>
12
+ awareness?: Awareness
13
+ awarenessSubscribes: Set<string>
10
14
  }
11
15
 
12
16
  export type CuboCrdtClientDocOrigin = {
@@ -21,6 +25,9 @@ export type CuboCrdtClientDocOrigin = {
21
25
 
22
26
  // куда раскатывать обновления - всем или всем кроме себя
23
27
  expose?: CuboCrdtExposeStrategy
28
+
29
+ // подписка, из которой инициировано локальное изменение
30
+ subscribe_id?: string
24
31
  }
25
32
 
26
33
  export type CuboCrdtClientDocUpdateOptions = { subscribe_id: string; storeKey: string } & Pick<
@@ -4,7 +4,6 @@ import { Awareness } from 'y-protocols/awareness'
4
4
 
5
5
  import { CuboCrdtAction, CuboCrdtListSyncData, CuboCrdtListTotals, CuboCrdtSubscriptionStatus } from '../../types'
6
6
 
7
- import { Doc } from 'yjs'
8
7
  import { CuboCrdtClientBaseOptions } from './utils'
9
8
 
10
9
  export * from './document'
@@ -58,8 +57,6 @@ export type CuboCrdtClientList<T> = {
58
57
  export type CuboCrdtClientRow<T> = {
59
58
  subscribe_id: string
60
59
  storeKey: string
61
- doc: Doc
62
- awareness?: ComputedRef<Awareness | undefined>
63
60
  subscribe: () => void
64
61
  upgrade: (filters?: any) => void
65
62
  unsubscribe: (clear?: boolean) => void
@@ -1,5 +1,4 @@
1
1
  import { Reactive } from 'vue'
2
- import { Awareness } from 'y-protocols/awareness'
3
2
 
4
3
  import { CuboCrdtKey, CuboCrdtListTotals, CuboCrdtSubscriptionStatus } from '../../types'
5
4
 
@@ -13,7 +12,6 @@ export type CuboCrdtClientStoreItem<T> = {
13
12
  totals: CuboCrdtListTotals
14
13
  revision: number
15
14
  }>
16
- awarenesses: Map<number, Awareness>
17
15
  }
18
16
  export type CuboCrdtClientStore<M> = Partial<{
19
17
  [K in string]: CuboCrdtClientStoreItem<CuboCrdtKey<K, M>>
@@ -1,12 +1,23 @@
1
- import { cloneDeep, debounce, pick } from '@cuboapp/utils'
1
+ import { cloneDeep, pick } from '@cuboapp/utils'
2
2
  import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs'
3
3
  import { Awareness, encodeAwarenessUpdate } from 'y-protocols/awareness'
4
4
 
5
- import { CuboCrdtServerDocumentOptions, CuboCrdtServerDocumentOrigin } from '../types'
5
+ import {
6
+ CuboCrdtServerDocumentOptions,
7
+ CuboCrdtServerDocumentOrigin,
8
+ CuboCrdtServerDocumentPendingStore
9
+ } from '../types'
10
+ import { getCrdtStoreChangeKey } from '../utils'
11
+
12
+ const STORE_DEBOUNCE_MS = 300
6
13
 
7
14
  export class CuboCrdtServerDocument {
8
15
  private ydoc: Doc
9
16
  private persistedState: object = {}
17
+ private observedState: object = {}
18
+ private storeQueue?: Promise<void>
19
+ private pendingStore?: CuboCrdtServerDocumentPendingStore
20
+ private storeTimeout?: ReturnType<typeof setTimeout>
10
21
  public awareness: Awareness
11
22
  public awarenessBySubscribe = new Map<string, Set<number>>()
12
23
 
@@ -19,31 +30,11 @@ export class CuboCrdtServerDocument {
19
30
  })
20
31
 
21
32
  if (opts.awareness) {
22
- this.awareness = new Awareness(this.ydoc)
23
- this.awareness.setLocalState(null)
24
-
25
- this.awareness.on('update', ({ added, updated, removed }, origin: CuboCrdtServerDocumentOrigin) => {
26
- const subId = origin?.subscribe_id
27
-
28
- if (subId) {
29
- let set = this.awarenessBySubscribe.get(subId)
30
- if (!set) {
31
- set = new Set()
32
- this.awarenessBySubscribe.set(subId, set)
33
- }
34
-
35
- added.concat(updated).forEach((id) => set!.add(id))
36
- removed.forEach((id) => set!.delete(id))
37
- }
38
-
39
- const changed = added.concat(updated).concat(removed)
40
- const update = encodeAwarenessUpdate(this.awareness!, changed)
41
-
42
- this.opts?.onAwarenessUpdate?.(update, origin)
43
- })
33
+ this.enableAwareness()
44
34
  }
45
35
 
46
36
  this.persistedState = cloneDeep(opts.initialState ?? {})
37
+ this.observedState = cloneDeep(opts.initialState ?? {})
47
38
 
48
39
  // записываем исходное состояние
49
40
  this.write(opts.initialState ?? {})
@@ -58,7 +49,8 @@ export class CuboCrdtServerDocument {
58
49
  this.opts?.onUpdate?.(data, origin)
59
50
  // }
60
51
 
61
- // если нужно сохранять стейт документа - вызываем store по дебаунсу
52
+ // Состояние отслеживается синхронно с Y.Doc. Для клиентских изменений
53
+ // debounceStore сам обновит observedState после вычисления leaf-diff.
62
54
  if (origin?.store) {
63
55
  const row = this.getJson()
64
56
  const body = pick(row, origin.keys ?? Object.keys(row))
@@ -68,10 +60,41 @@ export class CuboCrdtServerDocument {
68
60
  console.error('[CRDT] store document', this.name, error)
69
61
  })
70
62
  }
63
+ } else {
64
+ this.observedState = cloneDeep(this.getJson())
71
65
  }
72
66
  })
73
67
  }
74
68
 
69
+ public enableAwareness() {
70
+ if (this.awareness) {
71
+ return
72
+ }
73
+
74
+ this.awareness = new Awareness(this.ydoc)
75
+ this.awareness.setLocalState(null)
76
+
77
+ this.awareness.on('update', ({ added, updated, removed }, origin: CuboCrdtServerDocumentOrigin) => {
78
+ const subId = origin?.subscribe_id
79
+
80
+ if (subId) {
81
+ let set = this.awarenessBySubscribe.get(subId)
82
+ if (!set) {
83
+ set = new Set()
84
+ this.awarenessBySubscribe.set(subId, set)
85
+ }
86
+
87
+ added.concat(updated).forEach((id) => set!.add(id))
88
+ removed.forEach((id) => set!.delete(id))
89
+ }
90
+
91
+ const changed = added.concat(updated).concat(removed)
92
+ const update = encodeAwarenessUpdate(this.awareness!, changed)
93
+
94
+ this.opts?.onAwarenessUpdate?.(update, origin)
95
+ })
96
+ }
97
+
75
98
  public get name() {
76
99
  return this.opts.name
77
100
  }
@@ -133,13 +156,138 @@ export class CuboCrdtServerDocument {
133
156
  this.ydoc.destroy()
134
157
  }
135
158
 
136
- public async store(body: object, origin: CuboCrdtServerDocumentOrigin) {
137
- const previousRow = this.getPersistedState()
138
- const row = this.getJson()
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
+ }
139
233
 
140
- await this.opts?.onStore?.(body, origin, { previousRow, row })
141
- this.setPersistedState(row)
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
+ })
142
263
  }
143
264
 
144
- public debounceStore = debounce(this.store.bind(this), 300)
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
+ }
145
293
  }
@@ -36,6 +36,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
36
36
  private subscribeGenerations = new Map<string, number>()
37
37
 
38
38
  private documents = new Map<string, CuboCrdtServerDocument>()
39
+ private documentEvictions = new Map<string, object>()
39
40
  private subscribesByDocument = new Map<string, Set<string>>()
40
41
  // обратный индекс: подписка -> имена документов, к которым она привязана.
41
42
  // Нужен, чтобы отписка/очистка не сканировала ВСЕ документы сервера (O(subs x docs)).
@@ -107,34 +108,76 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
107
108
  this.cleanSubscribe(subscribe_id)
108
109
  })
109
110
 
110
- this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, async ({ message }) => {
111
+ this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, async ({ client, message }) => {
111
112
  let data = message.data as any
112
113
  if (!Array.isArray(data)) {
113
114
  data = [data]
114
115
  }
115
116
 
116
117
  if (this.debug) {
117
- console.log('[CRDT] incoming event', data)
118
+ console.log('[CRDT] incoming event', data.map((row: any) => ({
119
+ action: row.action,
120
+ entity: row.entity,
121
+ entity_id: row.entity_id,
122
+ keys: row.origin?.keys,
123
+ subscribe_id: row.origin?.subscribe_id
124
+ })))
118
125
  }
119
126
 
120
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
+
121
151
  const update = new Uint8Array(row.data)
152
+ const origin: CuboCrdtServerDocumentOrigin = {
153
+ ...(row.origin || {}),
154
+ client_id: client.id,
155
+ store_context: {
156
+ client_id: client.id,
157
+ auth: cloneDeep(client.auth)
158
+ }
159
+ }
160
+ const action = { ...row, origin }
122
161
 
123
162
  switch (row.action) {
124
163
  case 'create':
125
- this.onDocumentExternalCreate(update, row)
164
+ this.onDocumentExternalCreate(update, action)
126
165
  break
127
166
  case 'update':
128
- this.onDocumentExternalUpdate(update, row)
167
+ this.onDocumentExternalUpdate(update, action)
129
168
  break
130
169
  case 'delete':
131
- this.onDocumentExternalDelete(update, row)
170
+ this.onDocumentExternalDelete(update, action)
132
171
  break
133
172
  case 'awareness':
134
- this.onAwarenessExternalUpdate(update, row)
173
+ this.onAwarenessExternalUpdate(update, action)
135
174
  break
136
175
  }
137
176
  }
177
+
178
+ // Ответ означает только «update применён к живому серверному Y.Doc».
179
+ // Debounce и последовательная запись в БД продолжаются независимо.
180
+ return { accepted: true }
138
181
  })
139
182
 
140
183
  this.ws.registerHttpHandler('GET', '/stats', () => {
@@ -170,8 +213,20 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
170
213
  this.subscribeRefreshStates.forEach((state) => clearTimeout(state.timer))
171
214
  this.subscribeRefreshStates.clear()
172
215
  this.subscribeGenerations.clear()
216
+ this.documentEvictions.clear()
173
217
 
174
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()
175
230
  }
176
231
 
177
232
  public addClient(client: WsServerSocket<{ auth?: A }>) {
@@ -282,6 +337,10 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
282
337
 
283
338
  // привязываем подписку к документу (в обоих индексах)
284
339
  private linkSubscribeToDocument(subscribe_id: string, docName: string) {
340
+ // Новая подписка снова использует живой Y.Doc. Асинхронный flush может
341
+ // продолжаться, но больше не имеет права уничтожить этот документ.
342
+ this.documentEvictions.delete(docName)
343
+
285
344
  let byDoc = this.subscribesByDocument.get(docName)
286
345
  if (!byDoc) {
287
346
  byDoc = new Set()
@@ -305,17 +364,56 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
305
364
 
306
365
  // если у документа нет подписок - сносим его
307
366
  private checkDocumentNeedRemove(name: string) {
308
- if (!this.subscribesByDocument.get(name)?.size) {
309
- if (this.debug) {
310
- console.log('[CRDT] delete document', name)
311
- }
367
+ if (this.subscribesByDocument.get(name)?.size || this.documentEvictions.has(name)) {
368
+ return
369
+ }
312
370
 
313
- this.documents.get(name)?.destroy()
314
- this.documents.delete(name)
315
- // не оставляем пустой Set в индексе (иначе — медленная утечка записей мапы);
316
- // getOrCreateDocument пересоздаст его при повторном появлении документа
371
+ const document = this.documents.get(name)
372
+ if (!document) {
317
373
  this.subscribesByDocument.delete(name)
374
+ return
375
+ }
376
+
377
+ // Последний клиент мог закрыться сразу после update (обычный refresh).
378
+ // Сначала сохраняем всю принятую очередь, а до её завершения оставляем Y.Doc
379
+ // доступным повторной подписке. Никакого фиксированного ожидания здесь нет.
380
+ const eviction = {}
381
+ this.documentEvictions.set(name, eviction)
382
+
383
+ if (this.debug) {
384
+ console.log('[CRDT] flush document before eviction', name)
318
385
  }
386
+
387
+ void document.flushStoreQueue()
388
+ .then(() => {
389
+ if (
390
+ this.documentEvictions.get(name) !== eviction ||
391
+ this.subscribesByDocument.get(name)?.size ||
392
+ this.documents.get(name) !== document
393
+ ) {
394
+ return
395
+ }
396
+
397
+ this.documentEvictions.delete(name)
398
+
399
+ if (this.debug) {
400
+ console.log('[CRDT] delete document', name)
401
+ }
402
+
403
+ document.destroy()
404
+ this.documents.delete(name)
405
+ this.subscribesByDocument.delete(name)
406
+ })
407
+ .catch((error) => {
408
+ // Не уничтожаем единственную живую копию документа, если запись в БД
409
+ // не завершилась. Снимаем eviction, чтобы следующая отписка или явная
410
+ // проверка могла повторно инициировать flush.
411
+ if (this.documentEvictions.get(name) === eviction) {
412
+ this.documentEvictions.delete(name)
413
+ }
414
+
415
+ console.error('[CRDT] flush document before eviction', name, error)
416
+ })
319
417
  }
320
418
 
321
419
  // очистка подписки (при отписке клиента - через emit-метод или options-хук).
@@ -584,20 +682,9 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
584
682
 
585
683
  let document = this.documents.get(documentName)
586
684
  if (document) {
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)
685
+ if (awareness) {
686
+ document.enableAwareness()
687
+ }
601
688
 
602
689
  return document
603
690
  }
@@ -614,7 +701,13 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
614
701
  },
615
702
  onStore: (item: object, origin: CuboCrdtServerDocumentOrigin, store) => {
616
703
  const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
617
- const client = subscribe && this.clients.get(subscribe.client_id)
704
+ const liveClient = subscribe && this.clients.get(subscribe.client_id)
705
+ const client = liveClient || (origin.store_context
706
+ ? ({
707
+ id: origin.store_context.client_id,
708
+ auth: origin.store_context.auth
709
+ } as CuboCrdtSocketClient<A>)
710
+ : undefined)
618
711
 
619
712
  if (client) {
620
713
  return this.options?.storeRow?.(entity as any, entity_id, item as any, {
@@ -650,7 +743,13 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
650
743
  const existed = this.documents.has(documentName)
651
744
  const document = this.getOrCreateDocument(entity, row, awareness)
652
745
 
653
- if (!existed) {
746
+ if (existed && !document.hasPendingStore()) {
747
+ // Только внешний CRUD является подтверждённым источником из БД.
748
+ // Обычная повторная подписка не должна применять потенциально старую строку
749
+ // поверх живого документа с ещё не сохранённым collaborative update.
750
+ document.write(row, { expose: 'all', store: false })
751
+ document.setPersistedState(row)
752
+ } else {
654
753
  this.pushDocumentAction('create', entity, document, document.stateAsUpdate)
655
754
  }
656
755
 
@@ -5,6 +5,20 @@ export type CuboCrdtServerDocumentStoreContext = {
5
5
  row: object
6
6
  }
7
7
 
8
+ export type CuboCrdtServerDocumentStoreWaiter = {
9
+ resolve: () => void
10
+ reject: (reason?: unknown) => void
11
+ }
12
+
13
+ export type CuboCrdtServerDocumentPendingStore = {
14
+ body: object
15
+ changeKey: string
16
+ origin: CuboCrdtServerDocumentOrigin
17
+ originKey: string
18
+ row: object
19
+ waiters: CuboCrdtServerDocumentStoreWaiter[]
20
+ }
21
+
8
22
  export type CuboCrdtServerDocumentOptions = {
9
23
  name: string
10
24
  onStore?: (
@@ -63,4 +77,16 @@ export type CuboCrdtServerDocumentOrigin = {
63
77
 
64
78
  // id клиента
65
79
  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
+ }
66
92
  }
@@ -0,0 +1,41 @@
1
+ const isObject = (value: unknown): value is Record<string, unknown> => {
2
+ return value !== null && typeof value === 'object'
3
+ }
4
+
5
+ const collectChangedPaths = (
6
+ previous: unknown,
7
+ current: unknown,
8
+ path: string,
9
+ result: string[]
10
+ ) => {
11
+ if (Object.is(previous, current)) {
12
+ return
13
+ }
14
+
15
+ if (!isObject(previous) || !isObject(current)) {
16
+ result.push(path || '$')
17
+ return
18
+ }
19
+
20
+ const keys = new Set([
21
+ ...Object.keys(previous),
22
+ ...Object.keys(current)
23
+ ])
24
+
25
+ for (const key of [...keys].sort()) {
26
+ const nextPath = Array.isArray(previous) || Array.isArray(current)
27
+ ? `${path}[${key}]`
28
+ : path ? `${path}.${key}` : key
29
+
30
+ collectChangedPaths(previous[key], current[key], nextPath, result)
31
+ }
32
+ }
33
+
34
+ /** Возвращает стабильный ключ фактически изменённых leaf-путей объекта. */
35
+ export const getCrdtStoreChangeKey = (previous: object, current: object) => {
36
+ const paths: string[] = []
37
+
38
+ collectChangedPaths(previous, current, '', paths)
39
+
40
+ return paths.join('|')
41
+ }