@cuboapp/crdt 1.0.22 → 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 +54 -14
- package/src/server/document/index.ts +32 -7
- package/src/server/index.ts +92 -31
- package/src/server/types/document.ts +12 -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,18 +450,20 @@ 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
|
-
|
|
457
|
-
)
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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')
|
|
461
463
|
const document = store?.docs.get(entity_id)
|
|
462
464
|
|
|
463
465
|
if (!store || !document) {
|
|
464
|
-
return
|
|
466
|
+
return false
|
|
465
467
|
}
|
|
466
468
|
|
|
467
469
|
const map = document.getMap()
|
|
@@ -470,16 +472,22 @@ export class CuboCrdtClient<M> {
|
|
|
470
472
|
)
|
|
471
473
|
|
|
472
474
|
if (!Object.keys(toUpdate).length) {
|
|
473
|
-
|
|
475
|
+
// Состояние уже совпадает (например, update успел прийти из другой
|
|
476
|
+
// вкладки). Это успешный no-op, а не ошибка изменения.
|
|
477
|
+
return true
|
|
474
478
|
}
|
|
475
479
|
|
|
476
480
|
const origin: CuboCrdtClientDocOrigin = {
|
|
477
481
|
store: opts?.store ?? true,
|
|
478
482
|
expose: opts?.expose ?? 'other',
|
|
479
483
|
keys: opts?.keys ?? Object.keys(toUpdate),
|
|
480
|
-
|
|
484
|
+
// update() сам отправляет ровно один пакет ниже. Общий listener остаётся
|
|
485
|
+
// для изменений, которые создаются напрямую сторонними Yjs-binding'ами.
|
|
486
|
+
react: false
|
|
481
487
|
}
|
|
482
488
|
|
|
489
|
+
const stateVector = encodeStateVector(document)
|
|
490
|
+
|
|
483
491
|
// Одна сущность может присутствовать в нескольких подписках с разными
|
|
484
492
|
// storeKey. Мутацию отправляем только из одного документа: остальные
|
|
485
493
|
// подписки получат подтверждённый update через обычную CRDT-рассылку.
|
|
@@ -489,6 +497,36 @@ export class CuboCrdtClient<M> {
|
|
|
489
497
|
})
|
|
490
498
|
}, origin)
|
|
491
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
|
+
}
|
|
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
|
+
})
|
|
524
|
+
|
|
525
|
+
// Этот документ является каноническим локальным состоянием строки. Обновляем
|
|
526
|
+
// его реактивную строку сразу, а другие подписки получат тот же Yjs-update
|
|
527
|
+
// через серверную рассылку.
|
|
528
|
+
this.applyRowJson(`${entity}:${entity_id}`, entity_id, document)
|
|
529
|
+
|
|
492
530
|
const row = store.state.rows.find((item: any) => Number(item.id) === Number(entity_id))
|
|
493
531
|
|
|
494
532
|
if (row) {
|
|
@@ -496,6 +534,8 @@ export class CuboCrdtClient<M> {
|
|
|
496
534
|
row[key] = value
|
|
497
535
|
})
|
|
498
536
|
}
|
|
537
|
+
|
|
538
|
+
return receipt
|
|
499
539
|
}
|
|
500
540
|
|
|
501
541
|
public upgrade(subscribe_id: string, filters?: Record<string, any>) {
|
|
@@ -649,10 +689,10 @@ export class CuboCrdtClient<M> {
|
|
|
649
689
|
}
|
|
650
690
|
}
|
|
651
691
|
|
|
652
|
-
this.ws.request({
|
|
692
|
+
void this.ws.request({
|
|
653
693
|
method: CUBO_CRDT_EVENT.EVENT,
|
|
654
694
|
data
|
|
655
|
-
})
|
|
695
|
+
}).catch((error) => console.error('[CRDT] update request failed', error))
|
|
656
696
|
}
|
|
657
697
|
})
|
|
658
698
|
|
|
@@ -15,7 +15,7 @@ export class CuboCrdtServerDocument {
|
|
|
15
15
|
private ydoc: Doc
|
|
16
16
|
private persistedState: object = {}
|
|
17
17
|
private observedState: object = {}
|
|
18
|
-
private storeQueue
|
|
18
|
+
private storeQueue?: Promise<void>
|
|
19
19
|
private pendingStore?: CuboCrdtServerDocumentPendingStore
|
|
20
20
|
private storeTimeout?: ReturnType<typeof setTimeout>
|
|
21
21
|
public awareness: Awareness
|
|
@@ -152,20 +152,37 @@ export class CuboCrdtServerDocument {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
destroy() {
|
|
155
|
-
this.flushPendingStore()
|
|
156
155
|
this.awareness?.destroy()
|
|
157
156
|
this.ydoc.destroy()
|
|
158
157
|
}
|
|
159
158
|
|
|
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
|
+
|
|
160
178
|
public store(body: object, origin: CuboCrdtServerDocumentOrigin, row = this.getJson()) {
|
|
161
179
|
const queuedBody = cloneDeep(body)
|
|
162
180
|
const queuedRow = cloneDeep(row)
|
|
163
|
-
|
|
164
181
|
// debounce ограничивает частоту вызовов, но не сериализует async store.
|
|
165
182
|
// Без очереди следующий PATCH мог завершиться раньше предыдущего и затем
|
|
166
183
|
// быть затёрт старым полным снимком составного поля (например case.extra).
|
|
167
|
-
const
|
|
168
|
-
|
|
184
|
+
const previous = this.storeQueue?.catch(() => undefined) ?? Promise.resolve()
|
|
185
|
+
const current = previous
|
|
169
186
|
.then(async () => {
|
|
170
187
|
const previousRow = this.getPersistedState()
|
|
171
188
|
|
|
@@ -176,8 +193,16 @@ export class CuboCrdtServerDocument {
|
|
|
176
193
|
this.setPersistedState(queuedRow)
|
|
177
194
|
})
|
|
178
195
|
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
181
206
|
}
|
|
182
207
|
|
|
183
208
|
/**
|
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
|
|
|
@@ -77,4 +77,16 @@ export type CuboCrdtServerDocumentOrigin = {
|
|
|
77
77
|
|
|
78
78
|
// id клиента
|
|
79
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
|
+
}
|
|
80
92
|
}
|