@cuboapp/crdt 1.0.26 → 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.
- package/package.json +1 -1
- package/src/client/index.ts +88 -30
- package/src/client/types/index.ts +0 -2
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { keyBy, pick, uuid } from '@cuboapp/utils'
|
|
2
2
|
import { WsClientEvent } from '@cuboapp/ws'
|
|
3
|
-
import { computed, reactive } from 'vue'
|
|
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'
|
|
@@ -27,7 +27,7 @@ export class CuboCrdtClient<M> {
|
|
|
27
27
|
public store: CuboCrdtClientStore<M> = {}
|
|
28
28
|
// Реактивен только реестр: Vue отслеживает set/delete и повторно вычисляет
|
|
29
29
|
// computed(useDoc). Сами Y.Doc/Awareness остаются исходными объектами без proxy.
|
|
30
|
-
public docs = new Map<string, CuboCrdtClientDoc>()
|
|
30
|
+
public docs = shallowReactive(new Map<string, CuboCrdtClientDoc>())
|
|
31
31
|
|
|
32
32
|
private listeners: Map<string, Map<string, (ctx: CuboCrdtClientSubscribeEvent) => void | Promise<void>>> = new Map()
|
|
33
33
|
|
|
@@ -351,7 +351,7 @@ export class CuboCrdtClient<M> {
|
|
|
351
351
|
|
|
352
352
|
const item = this.store[storeKey]
|
|
353
353
|
item?.state.rows.forEach((row) => {
|
|
354
|
-
this.detachDocument(`${entity}`, (row as any).id, subscribe_id
|
|
354
|
+
this.detachDocument(`${entity}`, (row as any).id, subscribe_id)
|
|
355
355
|
})
|
|
356
356
|
|
|
357
357
|
// очищаем стор
|
|
@@ -547,7 +547,7 @@ export class CuboCrdtClient<M> {
|
|
|
547
547
|
}
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
-
private detachDocument(entity: string, entity_id: number, subscribe_id: string
|
|
550
|
+
private detachDocument(entity: string, entity_id: number, subscribe_id: string) {
|
|
551
551
|
const key = `${entity}:${entity_id}`
|
|
552
552
|
const document = this.docs.get(key)
|
|
553
553
|
if (!document) {
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -620,11 +698,11 @@ export class CuboCrdtClient<M> {
|
|
|
620
698
|
|
|
621
699
|
applyUpdate(yjsDoc, update, { react: false })
|
|
622
700
|
|
|
623
|
-
const document
|
|
701
|
+
const document = shallowReactive<CuboCrdtClientDoc>({
|
|
624
702
|
doc: yjsDoc,
|
|
625
703
|
subscribes: new Set(),
|
|
626
704
|
awarenessSubscribes: new Set()
|
|
627
|
-
}
|
|
705
|
+
})
|
|
628
706
|
this.attachDocument(ctx.entity, entity_id, document, opts)
|
|
629
707
|
// Публикуем контейнер реактивному реестру только после полной инициализации.
|
|
630
708
|
// Тогда первый computed(useDoc) не увидит промежуточный doc без Awareness.
|
|
@@ -636,27 +714,7 @@ export class CuboCrdtClient<M> {
|
|
|
636
714
|
|
|
637
715
|
// react = false, если это апдейт с бэка
|
|
638
716
|
if (origin?.react !== false) {
|
|
639
|
-
|
|
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
|
|
|
@@ -785,7 +843,7 @@ export class CuboCrdtClient<M> {
|
|
|
785
843
|
continue
|
|
786
844
|
}
|
|
787
845
|
|
|
788
|
-
this.detachDocument(ctx.entity, entity_id, opts.subscribe_id
|
|
846
|
+
this.detachDocument(ctx.entity, entity_id, opts.subscribe_id)
|
|
789
847
|
}
|
|
790
848
|
|
|
791
849
|
const rowsById = new Map(item.state.rows.map((row: any) => [Number(row.id), row]))
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { type WsClient } from '@cuboapp/ws'
|
|
2
2
|
import { ComputedRef } from 'vue'
|
|
3
|
-
import { Awareness } from 'y-protocols/awareness'
|
|
4
3
|
|
|
5
4
|
import { CuboCrdtAction, CuboCrdtListSyncData, CuboCrdtListTotals, CuboCrdtSubscriptionStatus } from '../../types'
|
|
6
5
|
|
|
@@ -39,7 +38,6 @@ export type CuboCrdtClientSubscribeEvent = {
|
|
|
39
38
|
export type CuboCrdtClientList<T> = {
|
|
40
39
|
subscribe_id: string
|
|
41
40
|
storeKey: string
|
|
42
|
-
awarenesses?: ComputedRef<Map<number, Awareness> | undefined>
|
|
43
41
|
subscribe: (filters?: any) => void
|
|
44
42
|
upgrade: (filters?: any) => void
|
|
45
43
|
unsubscribe: (clear?: boolean) => void
|