@cuboapp/crdt 1.0.21 → 1.0.23
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 +84 -40
- package/src/server/document/index.ts +149 -9
- package/src/server/index.ts +92 -31
- package/src/server/types/document.ts +26 -0
- package/src/server/utils/index.ts +41 -0
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -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'
|
|
@@ -450,48 +450,92 @@ export class CuboCrdtClient<M> {
|
|
|
450
450
|
dto: K extends Extract<keyof M, string> ? Partial<M[K]> : object,
|
|
451
451
|
opts?: CuboCrdtClientDocOrigin
|
|
452
452
|
) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
453
|
+
// Если строка открыта через useRow, mutating API работает только с её
|
|
454
|
+
// каноническим документом. Списки могут содержать тот же id, но не должны
|
|
455
|
+
// становиться источником записи для карточки.
|
|
456
|
+
const rowStore = this.store[`${entity}:${entity_id}`]
|
|
457
|
+
const store = rowStore?.state.status === 'ready' && rowStore.docs.has(entity_id)
|
|
458
|
+
? rowStore
|
|
459
|
+
: [...this.subscriptions.values()]
|
|
460
|
+
.filter((subscription) => subscription.entity === entity)
|
|
461
|
+
.map((subscription) => this.store[subscription.storeKey])
|
|
462
|
+
.find((candidate) => candidate?.docs.has(entity_id) && candidate.state.status === 'ready')
|
|
463
|
+
const document = store?.docs.get(entity_id)
|
|
464
|
+
|
|
465
|
+
if (!store || !document) {
|
|
466
|
+
return false
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const map = document.getMap()
|
|
470
|
+
const toUpdate = Object.fromEntries(
|
|
471
|
+
Object.entries(dto).filter(([key, value]) => map.get(key) !== value)
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
if (!Object.keys(toUpdate).length) {
|
|
475
|
+
// Состояние уже совпадает (например, update успел прийти из другой
|
|
476
|
+
// вкладки). Это успешный no-op, а не ошибка изменения.
|
|
477
|
+
return true
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const origin: CuboCrdtClientDocOrigin = {
|
|
481
|
+
store: opts?.store ?? true,
|
|
482
|
+
expose: opts?.expose ?? 'other',
|
|
483
|
+
keys: opts?.keys ?? Object.keys(toUpdate),
|
|
484
|
+
// update() сам отправляет ровно один пакет ниже. Общий listener остаётся
|
|
485
|
+
// для изменений, которые создаются напрямую сторонними Yjs-binding'ами.
|
|
486
|
+
react: false
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const stateVector = encodeStateVector(document)
|
|
490
|
+
|
|
491
|
+
// Одна сущность может присутствовать в нескольких подписках с разными
|
|
492
|
+
// storeKey. Мутацию отправляем только из одного документа: остальные
|
|
493
|
+
// подписки получат подтверждённый update через обычную CRDT-рассылку.
|
|
494
|
+
document.transact(() => {
|
|
495
|
+
Object.entries(toUpdate).forEach(([key, value]) => {
|
|
496
|
+
map.set(key, value)
|
|
497
|
+
})
|
|
498
|
+
}, origin)
|
|
499
|
+
|
|
500
|
+
const update = encodeStateAsUpdate(document, stateVector)
|
|
501
|
+
const receipt = this.ws
|
|
502
|
+
.request(
|
|
503
|
+
{
|
|
504
|
+
method: CUBO_CRDT_EVENT.EVENT,
|
|
505
|
+
data: {
|
|
506
|
+
action: 'update',
|
|
507
|
+
entity,
|
|
508
|
+
entity_id,
|
|
509
|
+
data: Array.from(update),
|
|
510
|
+
origin: {
|
|
511
|
+
...pick(origin, ['store', 'keys', 'expose']),
|
|
512
|
+
subscribe_id: [...this.subscriptions.entries()]
|
|
513
|
+
.find(([, subscription]) => this.store[subscription.storeKey] === store)?.[0]
|
|
514
|
+
}
|
|
472
515
|
}
|
|
516
|
+
},
|
|
517
|
+
{ wait: true, timeout: 10_000 }
|
|
518
|
+
)
|
|
519
|
+
.then(() => true)
|
|
520
|
+
.catch((error) => {
|
|
521
|
+
console.error('[CRDT] update request failed', error)
|
|
522
|
+
return false
|
|
523
|
+
})
|
|
473
524
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}, origin)
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
}
|
|
525
|
+
// Этот документ является каноническим локальным состоянием строки. Обновляем
|
|
526
|
+
// его реактивную строку сразу, а другие подписки получат тот же Yjs-update
|
|
527
|
+
// через серверную рассылку.
|
|
528
|
+
this.applyRowJson(`${entity}:${entity_id}`, entity_id, document)
|
|
482
529
|
|
|
483
|
-
|
|
484
|
-
for (const row of [cardRow, listRow]) {
|
|
485
|
-
if (row) {
|
|
486
|
-
const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => row[key as keyof typeof row] !== value))
|
|
530
|
+
const row = store.state.rows.find((item: any) => Number(item.id) === Number(entity_id))
|
|
487
531
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
}
|
|
532
|
+
if (row) {
|
|
533
|
+
Object.entries(toUpdate).forEach(([key, value]) => {
|
|
534
|
+
row[key] = value
|
|
535
|
+
})
|
|
494
536
|
}
|
|
537
|
+
|
|
538
|
+
return receipt
|
|
495
539
|
}
|
|
496
540
|
|
|
497
541
|
public upgrade(subscribe_id: string, filters?: Record<string, any>) {
|
|
@@ -645,10 +689,10 @@ export class CuboCrdtClient<M> {
|
|
|
645
689
|
}
|
|
646
690
|
}
|
|
647
691
|
|
|
648
|
-
this.ws.request({
|
|
692
|
+
void this.ws.request({
|
|
649
693
|
method: CUBO_CRDT_EVENT.EVENT,
|
|
650
694
|
data
|
|
651
|
-
})
|
|
695
|
+
}).catch((error) => console.error('[CRDT] update request failed', error))
|
|
652
696
|
}
|
|
653
697
|
})
|
|
654
698
|
|
|
@@ -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>
|
|
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
|
}
|
|
@@ -141,13 +156,138 @@ export class CuboCrdtServerDocument {
|
|
|
141
156
|
this.ydoc.destroy()
|
|
142
157
|
}
|
|
143
158
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
+
}
|
|
233
|
+
|
|
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
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Перезапускает trailing debounce для текущего однородного пакета. */
|
|
266
|
+
private schedulePendingStore() {
|
|
267
|
+
if (this.storeTimeout) {
|
|
268
|
+
clearTimeout(this.storeTimeout)
|
|
269
|
+
}
|
|
147
270
|
|
|
148
|
-
|
|
149
|
-
this.setPersistedState(row)
|
|
271
|
+
this.storeTimeout = setTimeout(() => this.flushPendingStore(), STORE_DEBOUNCE_MS)
|
|
150
272
|
}
|
|
151
273
|
|
|
152
|
-
|
|
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
|
+
}
|
|
153
293
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
40
40
|
private subscribeGenerations = new Map<string, number>()
|
|
41
41
|
|
|
42
42
|
private documents = new Map<string, CuboCrdtServerDocument>()
|
|
43
|
+
private documentEvictions = new Map<string, object>()
|
|
43
44
|
private subscribesByDocument = new Map<string, Set<string>>()
|
|
44
45
|
// обратный индекс: подписка -> имена документов, к которым она привязана.
|
|
45
46
|
// Нужен, чтобы отписка/очистка не сканировала ВСЕ документы сервера (O(subs x docs)).
|
|
@@ -111,34 +112,53 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
111
112
|
this.cleanSubscribe(subscribe_id)
|
|
112
113
|
})
|
|
113
114
|
|
|
114
|
-
this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, async ({ message }) => {
|
|
115
|
+
this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, async ({ client, message }) => {
|
|
115
116
|
let data = message.data as any
|
|
116
117
|
if (!Array.isArray(data)) {
|
|
117
118
|
data = [data]
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
if (this.debug) {
|
|
121
|
-
console.log('[CRDT] incoming event', data)
|
|
122
|
+
console.log('[CRDT] incoming event', data.map((row: any) => ({
|
|
123
|
+
action: row.action,
|
|
124
|
+
entity: row.entity,
|
|
125
|
+
entity_id: row.entity_id,
|
|
126
|
+
keys: row.origin?.keys,
|
|
127
|
+
subscribe_id: row.origin?.subscribe_id
|
|
128
|
+
})))
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
for (const row of data) {
|
|
125
132
|
const update = new Uint8Array(row.data)
|
|
133
|
+
const origin: CuboCrdtServerDocumentOrigin = {
|
|
134
|
+
...(row.origin || {}),
|
|
135
|
+
client_id: client.id,
|
|
136
|
+
store_context: {
|
|
137
|
+
client_id: client.id,
|
|
138
|
+
auth: cloneDeep(client.auth)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const action = { ...row, origin }
|
|
126
142
|
|
|
127
143
|
switch (row.action) {
|
|
128
144
|
case 'create':
|
|
129
|
-
this.onDocumentExternalCreate(update,
|
|
145
|
+
this.onDocumentExternalCreate(update, action)
|
|
130
146
|
break
|
|
131
147
|
case 'update':
|
|
132
|
-
this.onDocumentExternalUpdate(update,
|
|
148
|
+
this.onDocumentExternalUpdate(update, action)
|
|
133
149
|
break
|
|
134
150
|
case 'delete':
|
|
135
|
-
this.onDocumentExternalDelete(update,
|
|
151
|
+
this.onDocumentExternalDelete(update, action)
|
|
136
152
|
break
|
|
137
153
|
case 'awareness':
|
|
138
|
-
this.onAwarenessExternalUpdate(update,
|
|
154
|
+
this.onAwarenessExternalUpdate(update, action)
|
|
139
155
|
break
|
|
140
156
|
}
|
|
141
157
|
}
|
|
158
|
+
|
|
159
|
+
// Ответ означает только «update применён к живому серверному Y.Doc».
|
|
160
|
+
// Debounce и последовательная запись в БД продолжаются независимо.
|
|
161
|
+
return { accepted: true }
|
|
142
162
|
})
|
|
143
163
|
|
|
144
164
|
this.ws.registerHttpHandler('GET', '/stats', () => {
|
|
@@ -174,6 +194,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
174
194
|
this.subscribeRefreshStates.forEach((state) => clearTimeout(state.timer))
|
|
175
195
|
this.subscribeRefreshStates.clear()
|
|
176
196
|
this.subscribeGenerations.clear()
|
|
197
|
+
this.documentEvictions.clear()
|
|
177
198
|
|
|
178
199
|
this.subscribes.forEach((sub) => this.ws.deleteHandler(sub.id))
|
|
179
200
|
}
|
|
@@ -286,6 +307,10 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
286
307
|
|
|
287
308
|
// привязываем подписку к документу (в обоих индексах)
|
|
288
309
|
private linkSubscribeToDocument(subscribe_id: string, docName: string) {
|
|
310
|
+
// Новая подписка снова использует живой Y.Doc. Асинхронный flush может
|
|
311
|
+
// продолжаться, но больше не имеет права уничтожить этот документ.
|
|
312
|
+
this.documentEvictions.delete(docName)
|
|
313
|
+
|
|
289
314
|
let byDoc = this.subscribesByDocument.get(docName)
|
|
290
315
|
if (!byDoc) {
|
|
291
316
|
byDoc = new Set()
|
|
@@ -309,17 +334,56 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
309
334
|
|
|
310
335
|
// если у документа нет подписок - сносим его
|
|
311
336
|
private checkDocumentNeedRemove(name: string) {
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
337
|
+
if (this.subscribesByDocument.get(name)?.size || this.documentEvictions.has(name)) {
|
|
338
|
+
return
|
|
339
|
+
}
|
|
316
340
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
// не оставляем пустой Set в индексе (иначе — медленная утечка записей мапы);
|
|
320
|
-
// getOrCreateDocument пересоздаст его при повторном появлении документа
|
|
341
|
+
const document = this.documents.get(name)
|
|
342
|
+
if (!document) {
|
|
321
343
|
this.subscribesByDocument.delete(name)
|
|
344
|
+
return
|
|
322
345
|
}
|
|
346
|
+
|
|
347
|
+
// Последний клиент мог закрыться сразу после update (обычный refresh).
|
|
348
|
+
// Сначала сохраняем всю принятую очередь, а до её завершения оставляем Y.Doc
|
|
349
|
+
// доступным повторной подписке. Никакого фиксированного ожидания здесь нет.
|
|
350
|
+
const eviction = {}
|
|
351
|
+
this.documentEvictions.set(name, eviction)
|
|
352
|
+
|
|
353
|
+
if (this.debug) {
|
|
354
|
+
console.log('[CRDT] flush document before eviction', name)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
void document.flushStoreQueue()
|
|
358
|
+
.then(() => {
|
|
359
|
+
if (
|
|
360
|
+
this.documentEvictions.get(name) !== eviction ||
|
|
361
|
+
this.subscribesByDocument.get(name)?.size ||
|
|
362
|
+
this.documents.get(name) !== document
|
|
363
|
+
) {
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
this.documentEvictions.delete(name)
|
|
368
|
+
|
|
369
|
+
if (this.debug) {
|
|
370
|
+
console.log('[CRDT] delete document', name)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
document.destroy()
|
|
374
|
+
this.documents.delete(name)
|
|
375
|
+
this.subscribesByDocument.delete(name)
|
|
376
|
+
})
|
|
377
|
+
.catch((error) => {
|
|
378
|
+
// Не уничтожаем единственную живую копию документа, если запись в БД
|
|
379
|
+
// не завершилась. Снимаем eviction, чтобы следующая отписка или явная
|
|
380
|
+
// проверка могла повторно инициировать flush.
|
|
381
|
+
if (this.documentEvictions.get(name) === eviction) {
|
|
382
|
+
this.documentEvictions.delete(name)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
console.error('[CRDT] flush document before eviction', name, error)
|
|
386
|
+
})
|
|
323
387
|
}
|
|
324
388
|
|
|
325
389
|
// очистка подписки (при отписке клиента - через emit-метод или options-хук).
|
|
@@ -609,21 +673,6 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
609
673
|
document.enableAwareness()
|
|
610
674
|
}
|
|
611
675
|
|
|
612
|
-
// Освежаем существующий документ строкой из БД.
|
|
613
|
-
//
|
|
614
|
-
// Раньше документ возвращался как есть, а подписка отправляла клиенту его
|
|
615
|
-
// stateAsUpdate — то есть КЭШ документа, а не только что прочитанную строку.
|
|
616
|
-
// Документ, проспавший мутацию, оставался устаревшим навсегда: даже перезагрузка
|
|
617
|
-
// страницы отдавала старое значение, потому что reconcile брал его же.
|
|
618
|
-
//
|
|
619
|
-
// Стало заметно после того, как документы без подписчиков начали удаляться:
|
|
620
|
-
// документ может быть создан, остаться без подписок, пропустить мутации и
|
|
621
|
-
// «воскреснуть» на новой подписке уже неактуальным.
|
|
622
|
-
//
|
|
623
|
-
// store: false — это не правка от клиента, а синхронизация с БД, писать обратно нечего.
|
|
624
|
-
document.write(row, { expose: 'all', store: false })
|
|
625
|
-
document.setPersistedState(row)
|
|
626
|
-
|
|
627
676
|
return document
|
|
628
677
|
}
|
|
629
678
|
|
|
@@ -639,7 +688,13 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
639
688
|
},
|
|
640
689
|
onStore: (item: object, origin: CuboCrdtServerDocumentOrigin, store) => {
|
|
641
690
|
const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
|
|
642
|
-
const
|
|
691
|
+
const liveClient = subscribe && this.clients.get(subscribe.client_id)
|
|
692
|
+
const client = liveClient || (origin.store_context
|
|
693
|
+
? ({
|
|
694
|
+
id: origin.store_context.client_id,
|
|
695
|
+
auth: origin.store_context.auth
|
|
696
|
+
} as CuboCrdtSocketClient<A>)
|
|
697
|
+
: undefined)
|
|
643
698
|
|
|
644
699
|
if (client) {
|
|
645
700
|
return this.options?.storeRow?.(entity as any, entity_id, item as any, {
|
|
@@ -675,7 +730,13 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
675
730
|
const existed = this.documents.has(documentName)
|
|
676
731
|
const document = this.getOrCreateDocument(entity, row, awareness)
|
|
677
732
|
|
|
678
|
-
if (!
|
|
733
|
+
if (existed && !document.hasPendingStore()) {
|
|
734
|
+
// Только внешний CRUD является подтверждённым источником из БД.
|
|
735
|
+
// Обычная повторная подписка не должна применять потенциально старую строку
|
|
736
|
+
// поверх живого документа с ещё не сохранённым collaborative update.
|
|
737
|
+
document.write(row, { expose: 'all', store: false })
|
|
738
|
+
document.setPersistedState(row)
|
|
739
|
+
} else {
|
|
679
740
|
this.pushDocumentAction('create', entity, document, document.stateAsUpdate)
|
|
680
741
|
}
|
|
681
742
|
|
|
@@ -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
|
+
}
|