@cuboapp/crdt 1.0.21 → 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 +41 -37
- package/src/server/document/index.ts +124 -9
- 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
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -23,6 +34,7 @@ export class CuboCrdtServerDocument {
|
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
this.persistedState = cloneDeep(opts.initialState ?? {})
|
|
37
|
+
this.observedState = cloneDeep(opts.initialState ?? {})
|
|
26
38
|
|
|
27
39
|
// записываем исходное состояние
|
|
28
40
|
this.write(opts.initialState ?? {})
|
|
@@ -37,7 +49,8 @@ export class CuboCrdtServerDocument {
|
|
|
37
49
|
this.opts?.onUpdate?.(data, origin)
|
|
38
50
|
// }
|
|
39
51
|
|
|
40
|
-
//
|
|
52
|
+
// Состояние отслеживается синхронно с Y.Doc. Для клиентских изменений
|
|
53
|
+
// debounceStore сам обновит observedState после вычисления leaf-diff.
|
|
41
54
|
if (origin?.store) {
|
|
42
55
|
const row = this.getJson()
|
|
43
56
|
const body = pick(row, origin.keys ?? Object.keys(row))
|
|
@@ -47,6 +60,8 @@ export class CuboCrdtServerDocument {
|
|
|
47
60
|
console.error('[CRDT] store document', this.name, error)
|
|
48
61
|
})
|
|
49
62
|
}
|
|
63
|
+
} else {
|
|
64
|
+
this.observedState = cloneDeep(this.getJson())
|
|
50
65
|
}
|
|
51
66
|
})
|
|
52
67
|
}
|
|
@@ -137,17 +152,117 @@ export class CuboCrdtServerDocument {
|
|
|
137
152
|
}
|
|
138
153
|
|
|
139
154
|
destroy() {
|
|
155
|
+
this.flushPendingStore()
|
|
140
156
|
this.awareness?.destroy()
|
|
141
157
|
this.ydoc.destroy()
|
|
142
158
|
}
|
|
143
159
|
|
|
144
|
-
public
|
|
145
|
-
const
|
|
146
|
-
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
|
+
}
|
|
234
|
+
|
|
235
|
+
this.pendingStore.waiters.push({ resolve, reject })
|
|
236
|
+
this.schedulePendingStore()
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Перезапускает trailing debounce для текущего однородного пакета. */
|
|
241
|
+
private schedulePendingStore() {
|
|
242
|
+
if (this.storeTimeout) {
|
|
243
|
+
clearTimeout(this.storeTimeout)
|
|
244
|
+
}
|
|
147
245
|
|
|
148
|
-
|
|
149
|
-
this.setPersistedState(row)
|
|
246
|
+
this.storeTimeout = setTimeout(() => this.flushPendingStore(), STORE_DEBOUNCE_MS)
|
|
150
247
|
}
|
|
151
248
|
|
|
152
|
-
|
|
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
|
+
}
|
|
153
268
|
}
|
|
@@ -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
|
+
}
|