@cuboapp/crdt 1.0.24 → 1.0.26

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.26",
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,
@@ -437,54 +425,34 @@ export class CuboCrdtClient<M> {
437
425
  }
438
426
  }
439
427
 
440
- public update<K extends string>(
428
+ public update<K extends Extract<keyof M, string>>(
441
429
  entity: K,
442
430
  entity_id: number,
443
431
  dto: K extends Extract<keyof M, string> ? Partial<M[K]> : object,
444
432
  opts?: CuboCrdtClientDocOrigin
445
433
  ) {
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)
434
+ const document = this.docs.get(`${entity}:${entity_id}`)
451
435
 
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))
436
+ if (document) {
437
+ const map = document.doc.getMap()
438
+ const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => map.get(key) !== value))
459
439
 
460
440
  if (Object.keys(toUpdate).length) {
461
441
  const origin: CuboCrdtClientDocOrigin = {
462
442
  store: opts?.store ?? true,
463
443
  expose: opts?.expose ?? 'other',
464
444
  keys: opts?.keys ?? Object.keys(toUpdate),
465
- react: true // отправляем на бэк
445
+ react: true,
446
+ subscribe_id: opts?.subscribe_id ?? document.subscribes.values().next().value
466
447
  }
467
448
 
468
- doc.doc.transact(() => {
449
+ document.doc.transact(() => {
469
450
  Object.entries(toUpdate).forEach(([key, value]) => {
470
451
  map.set(key, value)
471
452
  })
472
453
  }, origin)
473
454
  }
474
455
  }
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
456
  }
489
457
 
490
458
  public upgrade(subscribe_id: string, filters?: Record<string, any>) {
@@ -528,6 +496,80 @@ export class CuboCrdtClient<M> {
528
496
  })
529
497
  }
530
498
 
499
+ private applyDocumentRows(entity_id: number, document: CuboCrdtClientDoc) {
500
+ for (const subscribe_id of document.subscribes) {
501
+ const subscription = this.subscriptions.get(subscribe_id)
502
+ if (subscription) {
503
+ this.applyRowJson(subscription.storeKey, entity_id, document.doc)
504
+ }
505
+ }
506
+ }
507
+
508
+ private attachDocument(entity: string, entity_id: number, document: CuboCrdtClientDoc, opts: CuboCrdtClientDocUpdateOptions) {
509
+ document.subscribes.add(opts.subscribe_id)
510
+
511
+ if (!opts.awareness) {
512
+ return
513
+ }
514
+
515
+ document.awarenessSubscribes.add(opts.subscribe_id)
516
+
517
+ if (!document.awareness) {
518
+ const awareness = new Awareness(document.doc)
519
+
520
+ awareness.on('update', ({ added, updated, removed }, origin) => {
521
+ if (origin === 'remote') {
522
+ return
523
+ }
524
+
525
+ const subscribe_id = document.awarenessSubscribes.values().next().value
526
+ if (!subscribe_id) {
527
+ return
528
+ }
529
+
530
+ const changed = added.concat(updated).concat(removed)
531
+ const update = encodeAwarenessUpdate(awareness, changed)
532
+
533
+ void this.ws
534
+ .request({
535
+ method: CUBO_CRDT_EVENT.EVENT,
536
+ data: {
537
+ action: 'awareness',
538
+ entity,
539
+ entity_id,
540
+ data: Array.from(update),
541
+ origin: { expose: 'other', subscribe_id }
542
+ }
543
+ })
544
+ .catch((error) => console.error('[CRDT] awareness request failed', error))
545
+ })
546
+ document.awareness = awareness
547
+ }
548
+ }
549
+
550
+ private detachDocument(entity: string, entity_id: number, subscribe_id: string, storeKey: string) {
551
+ const key = `${entity}:${entity_id}`
552
+ const document = this.docs.get(key)
553
+ if (!document) {
554
+ return
555
+ }
556
+
557
+ document.subscribes.delete(subscribe_id)
558
+ document.awarenessSubscribes.delete(subscribe_id)
559
+
560
+ if (!document.awarenessSubscribes.size && document.awareness) {
561
+ removeAwarenessStates(document.awareness, [document.awareness.doc.clientID], 'unsubscribe')
562
+ document.awareness.setLocalState(null)
563
+ document.awareness.destroy()
564
+ document.awareness = undefined
565
+ }
566
+
567
+ if (!document.subscribes.size) {
568
+ document.doc.destroy()
569
+ this.docs.delete(key)
570
+ }
571
+ }
572
+
531
573
  // апсертит строку в реактивное хранилище из текущего состояния yjs-документа
532
574
  private applyRowJson(storeKey: string, entity_id: number, doc: Doc) {
533
575
  const json: any = doc.getMap().toJSON()
@@ -559,6 +601,7 @@ export class CuboCrdtClient<M> {
559
601
  console.log('[CRDT] onDocumentCreate: doc already exists, merging state', { ctx, opts })
560
602
  }
561
603
 
604
+ this.attachDocument(ctx.entity, entity_id, existing, opts)
562
605
  applyUpdate(existing.doc, new Uint8Array(ctx.data as any), { react: false })
563
606
  this.applyRowJson(opts.storeKey, entity_id, existing.doc)
564
607
  return
@@ -577,8 +620,20 @@ export class CuboCrdtClient<M> {
577
620
 
578
621
  applyUpdate(yjsDoc, update, { react: false })
579
622
 
623
+ const document: CuboCrdtClientDoc = {
624
+ doc: yjsDoc,
625
+ subscribes: new Set(),
626
+ awarenessSubscribes: new Set()
627
+ }
628
+ this.attachDocument(ctx.entity, entity_id, document, opts)
629
+ // Публикуем контейнер реактивному реестру только после полной инициализации.
630
+ // Тогда первый computed(useDoc) не увидит промежуточный doc без Awareness.
631
+ this.docs.set(`${ctx.entity}:${ctx.entity_id}`, document)
632
+
580
633
  // подписываемся на обновления документа
581
634
  yjsDoc.on('update', (update, origin: CuboCrdtClientDocOrigin) => {
635
+ this.applyDocumentRows(entity_id, document)
636
+
582
637
  // react = false, если это апдейт с бэка
583
638
  if (origin?.react !== false) {
584
639
  const data: CuboCrdtServerDocumentIncomingAction = {
@@ -592,21 +647,19 @@ export class CuboCrdtClient<M> {
592
647
  // приходят без expose и сервер эхом шлёт их обратно самому автору.
593
648
  expose: 'other',
594
649
  ...pick(origin || {}, ['store', 'keys', 'expose']),
595
- subscribe_id: opts?.subscribe_id
650
+ subscribe_id: origin?.subscribe_id ?? document.subscribes.values().next().value
596
651
  }
597
652
  }
598
653
 
599
- this.ws.request({
600
- method: CUBO_CRDT_EVENT.EVENT,
601
- data
602
- })
654
+ void this.ws
655
+ .request({
656
+ method: CUBO_CRDT_EVENT.EVENT,
657
+ data
658
+ })
659
+ .catch((error) => console.error('[CRDT] update request failed', error))
603
660
  }
604
661
  })
605
662
 
606
- // добавляем документ в хранилище документов
607
- const doc: CuboCrdtClientDoc = { doc: yjsDoc, subscribes: new Set() }
608
- this.docs.set(`${ctx.entity}:${ctx.entity_id}`, doc)
609
-
610
663
  // добавляем документ в реактивное хранилище
611
664
  const json: any = yjsDoc.getMap().toJSON()
612
665
  const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === entity_id)
@@ -623,39 +676,6 @@ export class CuboCrdtClient<M> {
623
676
  if (opts?.onAfterCreate) {
624
677
  await opts.onAfterCreate(yjsDoc, update, ctx, opts)
625
678
  }
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
679
  }
660
680
 
661
681
  private async onDocumentUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
@@ -682,8 +702,8 @@ export class CuboCrdtClient<M> {
682
702
  // обновляем yjs-ный документ
683
703
  applyUpdate(doc.doc, update, { react: false })
684
704
 
685
- // обновляем документ в реактивном хранилище
686
- this.applyRowJson(opts.storeKey, entity_id, doc.doc)
705
+ // один канонический документ обновляет проекции всех активных подписок
706
+ this.applyDocumentRows(entity_id, doc)
687
707
 
688
708
  if (opts?.onAfterUpdate) {
689
709
  await opts.onAfterUpdate(doc.doc, update, ctx, opts)
@@ -709,25 +729,18 @@ export class CuboCrdtClient<M> {
709
729
  }
710
730
  }
711
731
 
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)
732
+ for (const subscribe_id of doc.subscribes) {
733
+ const subscription = this.subscriptions.get(subscribe_id)
734
+ const item = subscription && this.store[subscription.storeKey]
735
+ const index = item?.state.rows.findIndex((row: any) => row.id === entity_id) ?? -1
736
+ if (item && index >= 0) {
737
+ item.state.rows.splice(index, 1)
721
738
  }
722
739
  }
723
740
 
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
- }
741
+ doc.awareness?.destroy()
742
+ doc.doc.destroy()
743
+ this.docs.delete(`${ctx.entity}:${ctx.entity_id}`)
731
744
 
732
745
  if (opts?.onAfterDelete) {
733
746
  await opts.onAfterDelete(doc.doc, ctx, opts)
@@ -772,19 +785,7 @@ export class CuboCrdtClient<M> {
772
785
  continue
773
786
  }
774
787
 
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
- }
788
+ this.detachDocument(ctx.entity, entity_id, opts.subscribe_id, opts.storeKey)
788
789
  }
789
790
 
790
791
  const rowsById = new Map(item.state.rows.map((row: any) => [Number(row.id), row]))
@@ -805,15 +806,15 @@ export class CuboCrdtClient<M> {
805
806
  return
806
807
  }
807
808
 
808
- const awareness = this.store[opts.storeKey]?.awarenesses?.get(entity_id)
809
- if (!awareness) {
809
+ const doc = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
810
+ if (!doc?.awareness) {
810
811
  return
811
812
  }
812
813
 
813
814
  const update = new Uint8Array(ctx.data as any)
814
815
 
815
816
  try {
816
- applyAwarenessUpdate(awareness, update, 'remote')
817
+ applyAwarenessUpdate(doc.awareness, update, 'remote')
817
818
  } catch (e) {
818
819
  console.warn('[CRDT] applyAwarenessUpdate failed ', e)
819
820
  }
@@ -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
+ }