@cuboapp/crdt 1.0.27 → 1.0.28

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/client/index.ts +81 -23
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cuboapp/crdt",
3
- "version": "1.0.27",
3
+ "version": "1.0.28",
4
4
  "description": "CRDT for CUBO",
5
5
  "main": "src/index.ts",
6
6
  "repository": {
@@ -2,7 +2,7 @@ import { keyBy, pick, uuid } from '@cuboapp/utils'
2
2
  import { WsClientEvent } from '@cuboapp/ws'
3
3
  import { computed, reactive, shallowReactive } from 'vue'
4
4
  import { applyAwarenessUpdate, Awareness, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'
5
- import { applyUpdate, Doc } from 'yjs'
5
+ import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs'
6
6
 
7
7
  import { CUBO_CRDT_EVENT } from '../constants'
8
8
  import { CuboCrdtServerDocumentIncomingAction } from '../server'
@@ -586,6 +586,81 @@ export class CuboCrdtClient<M> {
586
586
  }
587
587
  }
588
588
 
589
+ private sendDocumentUpdate(
590
+ entity: string,
591
+ entity_id: number,
592
+ document: CuboCrdtClientDoc,
593
+ update: Uint8Array,
594
+ origin?: CuboCrdtClientDocOrigin
595
+ ) {
596
+ const data: CuboCrdtServerDocumentIncomingAction = {
597
+ action: 'update',
598
+ entity,
599
+ entity_id,
600
+ data: Array.from(update),
601
+ origin: {
602
+ // по умолчанию 'other' — исходную подписку исключаем (её yjs-документ уже
603
+ // применил изменение локально). Без этого апдейты от TipTap/y-prosemirror
604
+ // приходят без expose и сервер эхом шлёт их обратно самому автору.
605
+ expose: 'other',
606
+ ...pick(origin || {}, ['store', 'keys', 'expose']),
607
+ subscribe_id: origin?.subscribe_id ?? document.subscribes.values().next().value
608
+ }
609
+ }
610
+
611
+ void this.ws
612
+ .request({
613
+ method: CUBO_CRDT_EVENT.EVENT,
614
+ data
615
+ })
616
+ .catch((error) => console.error('[CRDT] update request failed', error))
617
+ }
618
+
619
+ /**
620
+ * Сверка локального документа с состоянием, присланным сервером при повторной подписке
621
+ * (реконнект). Сервер мог за это время пересобрать документ из БД — он выселяет Y.Doc, как
622
+ * только уходит последний подписчик, а обрыв связи это и есть уход. Пересобранный документ
623
+ * ничего не знает о структурах этого клиента, и у обычного CRDT-merge два последствия:
624
+ *
625
+ * 1. При конкурентных set одного ключа строки Yjs выбирает победителя по clientID, а не по
626
+ * времени — локальная (устаревшая) копия могла «победить» строку, сохранённую в БД
627
+ * другим окном. Так в карточке задачи после реконнекта описание откатывалось к версии
628
+ * этого окна.
629
+ * 2. Следующие локальные правки ссылаются на структуры, которых у сервера нет, — Yjs
630
+ * откладывает их в pending навсегда, и всё набранное после реконнекта молча пропадало.
631
+ *
632
+ * Поэтому строка сервера считается истиной: отличающиеся ключи переставляем явно (это
633
+ * причинно позже обеих версий, значит побеждает везде), а серверу досылаем одним апдейтом
634
+ * всё, чего у него нет, — вместе с этой переустановкой, чтобы значение строки на сервере
635
+ * не мигало через устаревшую версию. Без флага store такой апдейт ничего не пишет в БД:
636
+ * значения и так серверные. Локальные правки, набранные во время обрыва, тоже уезжают в
637
+ * этом апдейте.
638
+ */
639
+ private resyncWithServer(entity: string, entity_id: number, existing: CuboCrdtClientDoc, incoming: Uint8Array) {
640
+ const server = new Doc()
641
+ applyUpdate(server, incoming)
642
+
643
+ const serverRow: Record<string, unknown> = server.getMap().toJSON()
644
+ const map = existing.doc.getMap()
645
+
646
+ // react: false — наружу не шлём по одному, всё уедет ниже одним диффом
647
+ existing.doc.transact(() => {
648
+ for (const [key, value] of Object.entries(serverRow)) {
649
+ if (JSON.stringify(map.get(key)) !== JSON.stringify(value)) {
650
+ map.set(key, value)
651
+ }
652
+ }
653
+ }, { react: false })
654
+
655
+ // пустой апдейт кодируется двумя байтами
656
+ const missing = encodeStateAsUpdate(existing.doc, encodeStateVector(server))
657
+ if (missing.length > 2) {
658
+ this.sendDocumentUpdate(entity, entity_id, existing, missing)
659
+ }
660
+
661
+ server.destroy()
662
+ }
663
+
589
664
  private async onDocumentCreate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
590
665
  const entity_id = ctx.entity_id
591
666
  if (entity_id === undefined) {
@@ -602,7 +677,10 @@ export class CuboCrdtClient<M> {
602
677
  }
603
678
 
604
679
  this.attachDocument(ctx.entity, entity_id, existing, opts)
605
- applyUpdate(existing.doc, new Uint8Array(ctx.data as any), { react: false })
680
+
681
+ const incoming = new Uint8Array(ctx.data as any)
682
+ applyUpdate(existing.doc, incoming, { react: false })
683
+ this.resyncWithServer(ctx.entity, entity_id, existing, incoming)
606
684
  this.applyRowJson(opts.storeKey, entity_id, existing.doc)
607
685
  return
608
686
  }
@@ -636,27 +714,7 @@ export class CuboCrdtClient<M> {
636
714
 
637
715
  // react = false, если это апдейт с бэка
638
716
  if (origin?.react !== false) {
639
- const data: CuboCrdtServerDocumentIncomingAction = {
640
- action: 'update',
641
- entity: ctx.entity,
642
- entity_id,
643
- data: Array.from(update),
644
- origin: {
645
- // по умолчанию 'other' — исходную подписку исключаем (её yjs-документ уже
646
- // применил изменение локально). Без этого апдейты от TipTap/y-prosemirror
647
- // приходят без expose и сервер эхом шлёт их обратно самому автору.
648
- expose: 'other',
649
- ...pick(origin || {}, ['store', 'keys', 'expose']),
650
- subscribe_id: origin?.subscribe_id ?? document.subscribes.values().next().value
651
- }
652
- }
653
-
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))
717
+ this.sendDocumentUpdate(ctx.entity, entity_id, document, update, origin)
660
718
  }
661
719
  })
662
720