@cuboapp/crdt 1.0.20 → 1.0.22
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 +87 -72
- package/src/server/document/index.ts +154 -31
- package/src/server/index.ts +4 -0
- package/src/server/types/document.ts +14 -0
- package/src/server/utils/index.ts +41 -0
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
5
|
import { applyUpdate, Doc } from 'yjs'
|
|
6
6
|
|
|
@@ -234,8 +234,8 @@ export class CuboCrdtClient<M> {
|
|
|
234
234
|
|
|
235
235
|
this.store[storeKey] = {
|
|
236
236
|
state,
|
|
237
|
-
docs: new Map(),
|
|
238
|
-
awarenesses: new Map()
|
|
237
|
+
docs: shallowReactive(new Map()),
|
|
238
|
+
awarenesses: shallowReactive(new Map())
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
241
|
|
|
@@ -450,47 +450,51 @@ export class CuboCrdtClient<M> {
|
|
|
450
450
|
dto: K extends Extract<keyof M, string> ? Partial<M[K]> : object,
|
|
451
451
|
opts?: CuboCrdtClientDocOrigin
|
|
452
452
|
) {
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
if (Object.keys(toUpdate).length) {
|
|
467
|
-
const origin: CuboCrdtClientDocOrigin = {
|
|
468
|
-
store: opts?.store ?? true,
|
|
469
|
-
expose: opts?.expose ?? 'other',
|
|
470
|
-
keys: opts?.keys ?? Object.keys(toUpdate),
|
|
471
|
-
react: true // отправляем на бэк
|
|
472
|
-
}
|
|
453
|
+
const entityStores = [...new Set(
|
|
454
|
+
[...this.subscriptions.values()]
|
|
455
|
+
.filter((subscription) => subscription.entity === entity)
|
|
456
|
+
.map((subscription) => this.store[subscription.storeKey])
|
|
457
|
+
)]
|
|
458
|
+
const fallbackStores = [this.store[entity], this.store[`${entity}:${entity_id}`]]
|
|
459
|
+
const store = [...entityStores, ...fallbackStores]
|
|
460
|
+
.find((candidate) => candidate?.docs.has(entity_id))
|
|
461
|
+
const document = store?.docs.get(entity_id)
|
|
462
|
+
|
|
463
|
+
if (!store || !document) {
|
|
464
|
+
return
|
|
465
|
+
}
|
|
473
466
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
467
|
+
const map = document.getMap()
|
|
468
|
+
const toUpdate = Object.fromEntries(
|
|
469
|
+
Object.entries(dto).filter(([key, value]) => map.get(key) !== value)
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
if (!Object.keys(toUpdate).length) {
|
|
473
|
+
return
|
|
481
474
|
}
|
|
482
475
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
476
|
+
const origin: CuboCrdtClientDocOrigin = {
|
|
477
|
+
store: opts?.store ?? true,
|
|
478
|
+
expose: opts?.expose ?? 'other',
|
|
479
|
+
keys: opts?.keys ?? Object.keys(toUpdate),
|
|
480
|
+
react: true
|
|
481
|
+
}
|
|
487
482
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
483
|
+
// Одна сущность может присутствовать в нескольких подписках с разными
|
|
484
|
+
// storeKey. Мутацию отправляем только из одного документа: остальные
|
|
485
|
+
// подписки получат подтверждённый update через обычную CRDT-рассылку.
|
|
486
|
+
document.transact(() => {
|
|
487
|
+
Object.entries(toUpdate).forEach(([key, value]) => {
|
|
488
|
+
map.set(key, value)
|
|
489
|
+
})
|
|
490
|
+
}, origin)
|
|
491
|
+
|
|
492
|
+
const row = store.state.rows.find((item: any) => Number(item.id) === Number(entity_id))
|
|
493
|
+
|
|
494
|
+
if (row) {
|
|
495
|
+
Object.entries(toUpdate).forEach(([key, value]) => {
|
|
496
|
+
row[key] = value
|
|
497
|
+
})
|
|
494
498
|
}
|
|
495
499
|
}
|
|
496
500
|
|
|
@@ -551,6 +555,47 @@ export class CuboCrdtClient<M> {
|
|
|
551
555
|
}
|
|
552
556
|
}
|
|
553
557
|
|
|
558
|
+
private ensureDocumentAwareness(
|
|
559
|
+
ctx: CuboCrdtClientSubscribeEvent,
|
|
560
|
+
opts: CuboCrdtClientDocUpdateOptions,
|
|
561
|
+
doc: Doc,
|
|
562
|
+
entity_id: number
|
|
563
|
+
) {
|
|
564
|
+
if (!opts.awareness) {
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
let awareness = this.store[opts.storeKey]?.awarenesses.get(entity_id)
|
|
569
|
+
|
|
570
|
+
if (awareness) {
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
awareness = new Awareness(doc)
|
|
575
|
+
|
|
576
|
+
awareness.on('update', ({ added, updated, removed }, origin) => {
|
|
577
|
+
if (origin === 'remote') {
|
|
578
|
+
return
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const changed = added.concat(updated).concat(removed)
|
|
582
|
+
const update = encodeAwarenessUpdate(awareness!, changed)
|
|
583
|
+
|
|
584
|
+
this.ws.request({
|
|
585
|
+
method: CUBO_CRDT_EVENT.EVENT,
|
|
586
|
+
data: {
|
|
587
|
+
action: 'awareness',
|
|
588
|
+
entity: ctx.entity,
|
|
589
|
+
entity_id,
|
|
590
|
+
data: Array.from(update),
|
|
591
|
+
origin: { expose: 'other', subscribe_id: opts.subscribe_id }
|
|
592
|
+
}
|
|
593
|
+
})
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
this.store[opts.storeKey]?.awarenesses.set(entity_id, awareness)
|
|
597
|
+
}
|
|
598
|
+
|
|
554
599
|
private async onDocumentCreate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
|
|
555
600
|
const entity_id = ctx.entity_id
|
|
556
601
|
if (entity_id === undefined) {
|
|
@@ -568,6 +613,7 @@ export class CuboCrdtClient<M> {
|
|
|
568
613
|
|
|
569
614
|
applyUpdate(existing, new Uint8Array(ctx.data as any), { react: false })
|
|
570
615
|
this.applyRowJson(opts.storeKey, entity_id, existing)
|
|
616
|
+
this.ensureDocumentAwareness(ctx, opts, existing, entity_id)
|
|
571
617
|
return
|
|
572
618
|
}
|
|
573
619
|
|
|
@@ -630,38 +676,7 @@ export class CuboCrdtClient<M> {
|
|
|
630
676
|
await opts.onAfterCreate(doc, update, ctx, opts)
|
|
631
677
|
}
|
|
632
678
|
|
|
633
|
-
|
|
634
|
-
let awareness = this.store[opts.storeKey]?.awarenesses.get(entity_id)
|
|
635
|
-
|
|
636
|
-
if (!awareness) {
|
|
637
|
-
awareness = new Awareness(doc)
|
|
638
|
-
|
|
639
|
-
awareness.on('update', ({ added, updated, removed }, origin) => {
|
|
640
|
-
// console.log('[CRDT] awarness update', origin)
|
|
641
|
-
|
|
642
|
-
if (origin === 'remote') {
|
|
643
|
-
return
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
const changed = added.concat(updated).concat(removed)
|
|
647
|
-
const update = encodeAwarenessUpdate(awareness, changed)
|
|
648
|
-
|
|
649
|
-
this.ws.request({
|
|
650
|
-
method: CUBO_CRDT_EVENT.EVENT,
|
|
651
|
-
data: {
|
|
652
|
-
action: 'awareness',
|
|
653
|
-
entity: ctx.entity,
|
|
654
|
-
entity_id,
|
|
655
|
-
data: Array.from(update),
|
|
656
|
-
// 'other' — не шлём свой же курсор обратно исходной подписке
|
|
657
|
-
origin: { expose: 'other', subscribe_id: opts.subscribe_id }
|
|
658
|
-
}
|
|
659
|
-
})
|
|
660
|
-
})
|
|
661
|
-
|
|
662
|
-
this.store[opts.storeKey]?.awarenesses.set(entity_id, awareness)
|
|
663
|
-
}
|
|
664
|
-
}
|
|
679
|
+
this.ensureDocumentAwareness(ctx, opts, doc, entity_id)
|
|
665
680
|
}
|
|
666
681
|
|
|
667
682
|
private async onDocumentUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
|
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
import { cloneDeep,
|
|
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 {
|
|
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> = Promise.resolve()
|
|
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.
|
|
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
|
-
//
|
|
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
|
}
|
|
@@ -129,17 +152,117 @@ export class CuboCrdtServerDocument {
|
|
|
129
152
|
}
|
|
130
153
|
|
|
131
154
|
destroy() {
|
|
155
|
+
this.flushPendingStore()
|
|
132
156
|
this.awareness?.destroy()
|
|
133
157
|
this.ydoc.destroy()
|
|
134
158
|
}
|
|
135
159
|
|
|
136
|
-
public
|
|
137
|
-
const
|
|
138
|
-
const
|
|
160
|
+
public store(body: object, origin: CuboCrdtServerDocumentOrigin, row = this.getJson()) {
|
|
161
|
+
const queuedBody = cloneDeep(body)
|
|
162
|
+
const queuedRow = cloneDeep(row)
|
|
163
|
+
|
|
164
|
+
// debounce ограничивает частоту вызовов, но не сериализует async store.
|
|
165
|
+
// Без очереди следующий PATCH мог завершиться раньше предыдущего и затем
|
|
166
|
+
// быть затёрт старым полным снимком составного поля (например case.extra).
|
|
167
|
+
const current = this.storeQueue
|
|
168
|
+
.catch(() => undefined)
|
|
169
|
+
.then(async () => {
|
|
170
|
+
const previousRow = this.getPersistedState()
|
|
171
|
+
|
|
172
|
+
await this.opts?.onStore?.(queuedBody, origin, {
|
|
173
|
+
previousRow,
|
|
174
|
+
row: queuedRow
|
|
175
|
+
})
|
|
176
|
+
this.setPersistedState(queuedRow)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
this.storeQueue = current
|
|
180
|
+
return current
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Схлопывает только соседние изменения одного origin и одного набора
|
|
185
|
+
* leaf-путей. Смена пользователя или поля немедленно фиксирует предыдущий
|
|
186
|
+
* пакет, сохраняя фактический порядок collaborative-обновлений.
|
|
187
|
+
*/
|
|
188
|
+
public debounceStore(body: object, origin: CuboCrdtServerDocumentOrigin) {
|
|
189
|
+
const row = cloneDeep(this.getJson())
|
|
190
|
+
const bodyKeys = Object.keys(body)
|
|
191
|
+
const previousBody = pick(this.observedState, bodyKeys)
|
|
192
|
+
const changeKey = getCrdtStoreChangeKey(previousBody, body)
|
|
193
|
+
const originKey = `${origin.client_id || ''}:${origin.subscribe_id || ''}`
|
|
194
|
+
|
|
195
|
+
this.observedState = cloneDeep(row)
|
|
196
|
+
|
|
197
|
+
if (!changeKey) {
|
|
198
|
+
return Promise.resolve()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return new Promise<void>((resolve, reject) => {
|
|
202
|
+
const sameBatch = this.pendingStore?.originKey === originKey &&
|
|
203
|
+
this.pendingStore.changeKey === changeKey
|
|
204
|
+
|
|
205
|
+
if (this.pendingStore && !sameBatch) {
|
|
206
|
+
this.flushPendingStore()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!this.pendingStore) {
|
|
210
|
+
this.pendingStore = {
|
|
211
|
+
body: cloneDeep(body),
|
|
212
|
+
changeKey,
|
|
213
|
+
origin: cloneDeep(origin),
|
|
214
|
+
originKey,
|
|
215
|
+
row,
|
|
216
|
+
waiters: []
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
const keys = new Set([
|
|
220
|
+
...(this.pendingStore.origin.keys || []),
|
|
221
|
+
...(origin.keys || [])
|
|
222
|
+
])
|
|
223
|
+
|
|
224
|
+
this.pendingStore.body = {
|
|
225
|
+
...this.pendingStore.body,
|
|
226
|
+
...cloneDeep(body)
|
|
227
|
+
}
|
|
228
|
+
this.pendingStore.origin = {
|
|
229
|
+
...cloneDeep(origin),
|
|
230
|
+
keys: [...keys]
|
|
231
|
+
}
|
|
232
|
+
this.pendingStore.row = row
|
|
233
|
+
}
|
|
139
234
|
|
|
140
|
-
|
|
141
|
-
|
|
235
|
+
this.pendingStore.waiters.push({ resolve, reject })
|
|
236
|
+
this.schedulePendingStore()
|
|
237
|
+
})
|
|
142
238
|
}
|
|
143
239
|
|
|
144
|
-
|
|
240
|
+
/** Перезапускает trailing debounce для текущего однородного пакета. */
|
|
241
|
+
private schedulePendingStore() {
|
|
242
|
+
if (this.storeTimeout) {
|
|
243
|
+
clearTimeout(this.storeTimeout)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
this.storeTimeout = setTimeout(() => this.flushPendingStore(), STORE_DEBOUNCE_MS)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Передаёт накопленный пакет в общую последовательную очередь записи. */
|
|
250
|
+
private flushPendingStore() {
|
|
251
|
+
if (!this.pendingStore) {
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (this.storeTimeout) {
|
|
256
|
+
clearTimeout(this.storeTimeout)
|
|
257
|
+
this.storeTimeout = undefined
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const pending = this.pendingStore
|
|
261
|
+
this.pendingStore = undefined
|
|
262
|
+
|
|
263
|
+
this.store(pending.body, pending.origin, pending.row).then(
|
|
264
|
+
() => pending.waiters.forEach(({ resolve }) => resolve()),
|
|
265
|
+
(error) => pending.waiters.forEach(({ reject }) => reject(error))
|
|
266
|
+
)
|
|
267
|
+
}
|
|
145
268
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -605,6 +605,10 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
605
605
|
|
|
606
606
|
let document = this.documents.get(documentName)
|
|
607
607
|
if (document) {
|
|
608
|
+
if (awareness) {
|
|
609
|
+
document.enableAwareness()
|
|
610
|
+
}
|
|
611
|
+
|
|
608
612
|
// Освежаем существующий документ строкой из БД.
|
|
609
613
|
//
|
|
610
614
|
// Раньше документ возвращался как есть, а подписка отправляла клиенту его
|
|
@@ -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?: (
|
|
@@ -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
|
+
}
|