@cuboapp/crdt 1.0.23 → 1.0.24
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 +133 -193
- package/src/client/types/document.ts +6 -0
- package/src/client/types/index.ts +3 -9
- package/src/client/types/store.ts +1 -7
- package/src/server/document/index.ts +31 -179
- package/src/server/index.ts +41 -145
- package/src/server/types/document.ts +0 -26
- package/src/server/utils/index.ts +0 -41
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
import { keyBy, pick, uuid } from '@cuboapp/utils'
|
|
2
2
|
import { WsClientEvent } from '@cuboapp/ws'
|
|
3
|
-
import { computed, reactive
|
|
3
|
+
import { computed, reactive } from 'vue'
|
|
4
4
|
import { applyAwarenessUpdate, Awareness, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'
|
|
5
|
-
import { applyUpdate, Doc
|
|
5
|
+
import { applyUpdate, Doc } from 'yjs'
|
|
6
6
|
|
|
7
7
|
import { CUBO_CRDT_EVENT } from '../constants'
|
|
8
8
|
import { CuboCrdtServerDocumentIncomingAction } from '../server'
|
|
9
|
-
import {
|
|
10
|
-
CuboCrdtKey,
|
|
11
|
-
CuboCrdtListSyncData,
|
|
12
|
-
CuboCrdtSubscriptionStatus
|
|
13
|
-
} from '../types'
|
|
9
|
+
import { CuboCrdtKey, CuboCrdtListSyncData, CuboCrdtSubscriptionStatus } from '../types'
|
|
14
10
|
|
|
15
11
|
import { AsyncSerialQueue } from './queue'
|
|
16
12
|
import {
|
|
13
|
+
CuboCrdtClientDoc,
|
|
17
14
|
CuboCrdtClientDocOrigin,
|
|
18
15
|
CuboCrdtClientDocUpdateOptions,
|
|
19
16
|
CuboCrdtClientList,
|
|
@@ -28,6 +25,7 @@ export * from './types'
|
|
|
28
25
|
|
|
29
26
|
export class CuboCrdtClient<M> {
|
|
30
27
|
public store: CuboCrdtClientStore<M> = {}
|
|
28
|
+
public docs = new Map<string, CuboCrdtClientDoc>()
|
|
31
29
|
|
|
32
30
|
private listeners: Map<string, Map<string, (ctx: CuboCrdtClientSubscribeEvent) => void | Promise<void>>> = new Map()
|
|
33
31
|
|
|
@@ -234,8 +232,7 @@ export class CuboCrdtClient<M> {
|
|
|
234
232
|
|
|
235
233
|
this.store[storeKey] = {
|
|
236
234
|
state,
|
|
237
|
-
|
|
238
|
-
awarenesses: shallowReactive(new Map())
|
|
235
|
+
awarenesses: new Map()
|
|
239
236
|
}
|
|
240
237
|
}
|
|
241
238
|
|
|
@@ -260,6 +257,7 @@ export class CuboCrdtClient<M> {
|
|
|
260
257
|
}
|
|
261
258
|
|
|
262
259
|
// подписываемся на фронте
|
|
260
|
+
// console.log('subscribe', opts)
|
|
263
261
|
this.listeners.get(entity)?.set(subscribe_id, async (ctx) => {
|
|
264
262
|
await this.onIncomingUpdate(ctx, {
|
|
265
263
|
subscribe_id,
|
|
@@ -330,13 +328,11 @@ export class CuboCrdtClient<M> {
|
|
|
330
328
|
|
|
331
329
|
// отписываемся на бэке
|
|
332
330
|
if (this.ws.connected) {
|
|
333
|
-
void this.ws
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
})
|
|
331
|
+
void this.ws.request({ method: CUBO_CRDT_EVENT.UNSUBSCRIBE, data: { subscribe_id } }).catch((e) => {
|
|
332
|
+
if (this.debug) {
|
|
333
|
+
console.warn('[CRDT] unsubscribe request failed (socket down)', subscribe_id, e)
|
|
334
|
+
}
|
|
335
|
+
})
|
|
340
336
|
}
|
|
341
337
|
|
|
342
338
|
// отписываемся на фронте
|
|
@@ -358,8 +354,18 @@ export class CuboCrdtClient<M> {
|
|
|
358
354
|
a.destroy()
|
|
359
355
|
})
|
|
360
356
|
item.awarenesses?.clear()
|
|
361
|
-
|
|
362
|
-
item.
|
|
357
|
+
|
|
358
|
+
item.state.rows?.map((row) => {
|
|
359
|
+
const doc = this.docs.get(`${entity}:${(row as any).id}`)
|
|
360
|
+
if (doc) {
|
|
361
|
+
doc.subscribes.delete(subscribe_id)
|
|
362
|
+
|
|
363
|
+
if (doc.subscribes?.size === 0) {
|
|
364
|
+
doc.doc.destroy()
|
|
365
|
+
this.docs.delete(`${entity}:${(row as any).id}`)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
})
|
|
363
369
|
}
|
|
364
370
|
|
|
365
371
|
this.store[storeKey] = undefined
|
|
@@ -383,7 +389,6 @@ export class CuboCrdtClient<M> {
|
|
|
383
389
|
subscribe,
|
|
384
390
|
upgrade,
|
|
385
391
|
unsubscribe,
|
|
386
|
-
docs: computed(() => this.store[storeKey]?.docs),
|
|
387
392
|
awarenesses: opts?.awareness ? computed(() => this.store[storeKey]?.awarenesses) : undefined,
|
|
388
393
|
subscribed: () => computed(() => this.store[storeKey]?.state.subscribed || false),
|
|
389
394
|
loading: () => computed(() => this.store[storeKey]?.state.loading || false),
|
|
@@ -404,30 +409,18 @@ export class CuboCrdtClient<M> {
|
|
|
404
409
|
): CuboCrdtClientRow<T> {
|
|
405
410
|
// console.log('store key', opts?.storeKey ?? `${entity}:${id}`)
|
|
406
411
|
|
|
407
|
-
const {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
upgrade,
|
|
414
|
-
unsubscribe,
|
|
415
|
-
subscribed,
|
|
416
|
-
loading,
|
|
417
|
-
ready,
|
|
418
|
-
status,
|
|
419
|
-
error
|
|
420
|
-
} = this.useList(entity, {
|
|
421
|
-
...opts,
|
|
422
|
-
filters: opts?.filters ?? { id },
|
|
423
|
-
storeKey: opts?.storeKey ?? `${entity}:${id}`
|
|
424
|
-
})
|
|
412
|
+
const { subscribe_id, storeKey, awarenesses, subscribe, upgrade, unsubscribe, subscribed, loading, ready, status, error } =
|
|
413
|
+
this.useList(entity, {
|
|
414
|
+
...opts,
|
|
415
|
+
filters: opts?.filters ?? { id },
|
|
416
|
+
storeKey: opts?.storeKey ?? `${entity}:${id}`
|
|
417
|
+
})
|
|
425
418
|
|
|
426
419
|
return {
|
|
427
420
|
subscribe_id,
|
|
428
421
|
storeKey,
|
|
429
|
-
doc:
|
|
430
|
-
awareness:
|
|
422
|
+
doc: this.docs.get(`${entity}:${id}`)?.doc,
|
|
423
|
+
awareness: computed(() => awarenesses?.value?.get(id)),
|
|
431
424
|
subscribe,
|
|
432
425
|
upgrade,
|
|
433
426
|
unsubscribe,
|
|
@@ -450,92 +443,48 @@ export class CuboCrdtClient<M> {
|
|
|
450
443
|
dto: K extends Extract<keyof M, string> ? Partial<M[K]> : object,
|
|
451
444
|
opts?: CuboCrdtClientDocOrigin
|
|
452
445
|
) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
}
|
|
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
|
-
})
|
|
446
|
+
const storeKey = `${entity}:${entity_id}`
|
|
447
|
+
|
|
448
|
+
const cardRow = this.store[storeKey]?.state.rows?.[0]
|
|
449
|
+
const listRow = this.store[entity]?.state?.rows.find((r: any) => r.id === entity_id)
|
|
450
|
+
const doc = this.docs.get(storeKey)
|
|
451
|
+
|
|
452
|
+
// обновляем документ
|
|
453
|
+
if (doc) {
|
|
454
|
+
const map = doc.doc.getMap()
|
|
455
|
+
|
|
456
|
+
// "или" - потому что они синхронизированы (без разницы какой брать)
|
|
457
|
+
const row = (cardRow || listRow) as any
|
|
458
|
+
const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => row[key] !== value))
|
|
459
|
+
|
|
460
|
+
if (Object.keys(toUpdate).length) {
|
|
461
|
+
const origin: CuboCrdtClientDocOrigin = {
|
|
462
|
+
store: opts?.store ?? true,
|
|
463
|
+
expose: opts?.expose ?? 'other',
|
|
464
|
+
keys: opts?.keys ?? Object.keys(toUpdate),
|
|
465
|
+
react: true // отправляем на бэк
|
|
466
|
+
}
|
|
524
467
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
468
|
+
doc.doc.transact(() => {
|
|
469
|
+
Object.entries(toUpdate).forEach(([key, value]) => {
|
|
470
|
+
map.set(key, value)
|
|
471
|
+
})
|
|
472
|
+
}, origin)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
529
475
|
|
|
530
|
-
|
|
476
|
+
// обновляем реактивку
|
|
477
|
+
for (const row of [cardRow, listRow]) {
|
|
478
|
+
if (row) {
|
|
479
|
+
const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => row[key as keyof typeof row] !== value))
|
|
531
480
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
481
|
+
if (Object.keys(toUpdate).length) {
|
|
482
|
+
Object.entries(toUpdate).forEach(([key, value]) => {
|
|
483
|
+
row[key as keyof typeof row] = value
|
|
484
|
+
})
|
|
485
|
+
}
|
|
486
|
+
}
|
|
536
487
|
}
|
|
537
|
-
|
|
538
|
-
return receipt
|
|
539
488
|
}
|
|
540
489
|
|
|
541
490
|
public upgrade(subscribe_id: string, filters?: Record<string, any>) {
|
|
@@ -595,54 +544,13 @@ export class CuboCrdtClient<M> {
|
|
|
595
544
|
}
|
|
596
545
|
}
|
|
597
546
|
|
|
598
|
-
private ensureDocumentAwareness(
|
|
599
|
-
ctx: CuboCrdtClientSubscribeEvent,
|
|
600
|
-
opts: CuboCrdtClientDocUpdateOptions,
|
|
601
|
-
doc: Doc,
|
|
602
|
-
entity_id: number
|
|
603
|
-
) {
|
|
604
|
-
if (!opts.awareness) {
|
|
605
|
-
return
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
let awareness = this.store[opts.storeKey]?.awarenesses.get(entity_id)
|
|
609
|
-
|
|
610
|
-
if (awareness) {
|
|
611
|
-
return
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
awareness = new Awareness(doc)
|
|
615
|
-
|
|
616
|
-
awareness.on('update', ({ added, updated, removed }, origin) => {
|
|
617
|
-
if (origin === 'remote') {
|
|
618
|
-
return
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
const changed = added.concat(updated).concat(removed)
|
|
622
|
-
const update = encodeAwarenessUpdate(awareness!, changed)
|
|
623
|
-
|
|
624
|
-
this.ws.request({
|
|
625
|
-
method: CUBO_CRDT_EVENT.EVENT,
|
|
626
|
-
data: {
|
|
627
|
-
action: 'awareness',
|
|
628
|
-
entity: ctx.entity,
|
|
629
|
-
entity_id,
|
|
630
|
-
data: Array.from(update),
|
|
631
|
-
origin: { expose: 'other', subscribe_id: opts.subscribe_id }
|
|
632
|
-
}
|
|
633
|
-
})
|
|
634
|
-
})
|
|
635
|
-
|
|
636
|
-
this.store[opts.storeKey]?.awarenesses.set(entity_id, awareness)
|
|
637
|
-
}
|
|
638
|
-
|
|
639
547
|
private async onDocumentCreate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
|
|
640
548
|
const entity_id = ctx.entity_id
|
|
641
549
|
if (entity_id === undefined) {
|
|
642
550
|
return
|
|
643
551
|
}
|
|
644
552
|
|
|
645
|
-
const existing = this.
|
|
553
|
+
const existing = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
|
|
646
554
|
if (existing) {
|
|
647
555
|
// документ уже есть локально (типичный кейс — повторный SUBSCRIBE после реконнекта).
|
|
648
556
|
// вместо игнорирования вмёрживаем входящее состояние в существующий yjs-документ
|
|
@@ -651,13 +559,12 @@ export class CuboCrdtClient<M> {
|
|
|
651
559
|
console.log('[CRDT] onDocumentCreate: doc already exists, merging state', { ctx, opts })
|
|
652
560
|
}
|
|
653
561
|
|
|
654
|
-
applyUpdate(existing, new Uint8Array(ctx.data as any), { react: false })
|
|
655
|
-
this.applyRowJson(opts.storeKey, entity_id, existing)
|
|
656
|
-
this.ensureDocumentAwareness(ctx, opts, existing, entity_id)
|
|
562
|
+
applyUpdate(existing.doc, new Uint8Array(ctx.data as any), { react: false })
|
|
563
|
+
this.applyRowJson(opts.storeKey, entity_id, existing.doc)
|
|
657
564
|
return
|
|
658
565
|
}
|
|
659
566
|
|
|
660
|
-
const
|
|
567
|
+
const yjsDoc = new Doc()
|
|
661
568
|
|
|
662
569
|
const update = new Uint8Array(ctx.data as any)
|
|
663
570
|
|
|
@@ -668,10 +575,10 @@ export class CuboCrdtClient<M> {
|
|
|
668
575
|
}
|
|
669
576
|
}
|
|
670
577
|
|
|
671
|
-
applyUpdate(
|
|
578
|
+
applyUpdate(yjsDoc, update, { react: false })
|
|
672
579
|
|
|
673
580
|
// подписываемся на обновления документа
|
|
674
|
-
|
|
581
|
+
yjsDoc.on('update', (update, origin: CuboCrdtClientDocOrigin) => {
|
|
675
582
|
// react = false, если это апдейт с бэка
|
|
676
583
|
if (origin?.react !== false) {
|
|
677
584
|
const data: CuboCrdtServerDocumentIncomingAction = {
|
|
@@ -689,18 +596,19 @@ export class CuboCrdtClient<M> {
|
|
|
689
596
|
}
|
|
690
597
|
}
|
|
691
598
|
|
|
692
|
-
|
|
599
|
+
this.ws.request({
|
|
693
600
|
method: CUBO_CRDT_EVENT.EVENT,
|
|
694
601
|
data
|
|
695
|
-
})
|
|
602
|
+
})
|
|
696
603
|
}
|
|
697
604
|
})
|
|
698
605
|
|
|
699
606
|
// добавляем документ в хранилище документов
|
|
700
|
-
|
|
607
|
+
const doc: CuboCrdtClientDoc = { doc: yjsDoc, subscribes: new Set() }
|
|
608
|
+
this.docs.set(`${ctx.entity}:${ctx.entity_id}`, doc)
|
|
701
609
|
|
|
702
610
|
// добавляем документ в реактивное хранилище
|
|
703
|
-
const json: any =
|
|
611
|
+
const json: any = yjsDoc.getMap().toJSON()
|
|
704
612
|
const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === entity_id)
|
|
705
613
|
if (index !== undefined && index >= 0) {
|
|
706
614
|
if (this.debug) {
|
|
@@ -713,10 +621,41 @@ export class CuboCrdtClient<M> {
|
|
|
713
621
|
}
|
|
714
622
|
|
|
715
623
|
if (opts?.onAfterCreate) {
|
|
716
|
-
await opts.onAfterCreate(
|
|
624
|
+
await opts.onAfterCreate(yjsDoc, update, ctx, opts)
|
|
717
625
|
}
|
|
718
626
|
|
|
719
|
-
|
|
627
|
+
if (opts?.awareness) {
|
|
628
|
+
let awareness = this.store[opts.storeKey]?.awarenesses.get(entity_id)
|
|
629
|
+
|
|
630
|
+
if (!awareness) {
|
|
631
|
+
awareness = new Awareness(yjsDoc)
|
|
632
|
+
|
|
633
|
+
awareness.on('update', ({ added, updated, removed }, origin) => {
|
|
634
|
+
// console.log('[CRDT] awarness update', origin)
|
|
635
|
+
|
|
636
|
+
if (origin === 'remote') {
|
|
637
|
+
return
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const changed = added.concat(updated).concat(removed)
|
|
641
|
+
const update = encodeAwarenessUpdate(awareness, changed)
|
|
642
|
+
|
|
643
|
+
this.ws.request({
|
|
644
|
+
method: CUBO_CRDT_EVENT.EVENT,
|
|
645
|
+
data: {
|
|
646
|
+
action: 'awareness',
|
|
647
|
+
entity: ctx.entity,
|
|
648
|
+
entity_id,
|
|
649
|
+
data: Array.from(update),
|
|
650
|
+
// 'other' — не шлём свой же курсор обратно исходной подписке
|
|
651
|
+
origin: { expose: 'other', subscribe_id: opts.subscribe_id }
|
|
652
|
+
}
|
|
653
|
+
})
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
this.store[opts.storeKey]?.awarenesses.set(entity_id, awareness)
|
|
657
|
+
}
|
|
658
|
+
}
|
|
720
659
|
}
|
|
721
660
|
|
|
722
661
|
private async onDocumentUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
|
|
@@ -725,7 +664,7 @@ export class CuboCrdtClient<M> {
|
|
|
725
664
|
return
|
|
726
665
|
}
|
|
727
666
|
|
|
728
|
-
const doc = this.
|
|
667
|
+
const doc = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
|
|
729
668
|
if (!doc) {
|
|
730
669
|
console.warn('[CRDT] onDocumentUpdate: doc not exists', { ctx, opts })
|
|
731
670
|
return
|
|
@@ -734,20 +673,20 @@ export class CuboCrdtClient<M> {
|
|
|
734
673
|
const update = new Uint8Array(ctx.data as any)
|
|
735
674
|
|
|
736
675
|
if (opts?.onBeforeUpdate) {
|
|
737
|
-
const result = await opts.onBeforeUpdate(doc, update, ctx, opts)
|
|
676
|
+
const result = await opts.onBeforeUpdate(doc.doc, update, ctx, opts)
|
|
738
677
|
if (!result) {
|
|
739
678
|
return
|
|
740
679
|
}
|
|
741
680
|
}
|
|
742
681
|
|
|
743
682
|
// обновляем yjs-ный документ
|
|
744
|
-
applyUpdate(doc, update, { react: false })
|
|
683
|
+
applyUpdate(doc.doc, update, { react: false })
|
|
745
684
|
|
|
746
685
|
// обновляем документ в реактивном хранилище
|
|
747
|
-
this.applyRowJson(opts.storeKey, entity_id, doc)
|
|
686
|
+
this.applyRowJson(opts.storeKey, entity_id, doc.doc)
|
|
748
687
|
|
|
749
688
|
if (opts?.onAfterUpdate) {
|
|
750
|
-
await opts.onAfterUpdate(doc, update, ctx, opts)
|
|
689
|
+
await opts.onAfterUpdate(doc.doc, update, ctx, opts)
|
|
751
690
|
}
|
|
752
691
|
}
|
|
753
692
|
|
|
@@ -757,23 +696,21 @@ export class CuboCrdtClient<M> {
|
|
|
757
696
|
return
|
|
758
697
|
}
|
|
759
698
|
|
|
760
|
-
const doc = this.
|
|
699
|
+
const doc = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
|
|
761
700
|
if (!doc) {
|
|
762
701
|
console.warn('[CRDT] onDocumentDelete: doc not exists', { ctx, opts })
|
|
763
702
|
return
|
|
764
703
|
}
|
|
765
704
|
|
|
766
705
|
if (opts?.onBeforeDelete) {
|
|
767
|
-
const result = await opts.onBeforeDelete(doc, ctx, opts)
|
|
706
|
+
const result = await opts.onBeforeDelete(doc.doc, ctx, opts)
|
|
768
707
|
if (!result) {
|
|
769
708
|
return
|
|
770
709
|
}
|
|
771
710
|
}
|
|
772
711
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
doc.destroy()
|
|
776
|
-
this.store[opts.storeKey]?.docs.delete(entity_id)
|
|
712
|
+
doc.doc.destroy()
|
|
713
|
+
this.docs.delete(`${ctx.entity}:${ctx.entity_id}`)
|
|
777
714
|
|
|
778
715
|
const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === entity_id)
|
|
779
716
|
if (index !== undefined && index >= 0) {
|
|
@@ -793,7 +730,7 @@ export class CuboCrdtClient<M> {
|
|
|
793
730
|
}
|
|
794
731
|
|
|
795
732
|
if (opts?.onAfterDelete) {
|
|
796
|
-
await opts.onAfterDelete(doc, ctx, opts)
|
|
733
|
+
await opts.onAfterDelete(doc.doc, ctx, opts)
|
|
797
734
|
}
|
|
798
735
|
}
|
|
799
736
|
|
|
@@ -828,7 +765,9 @@ export class CuboCrdtClient<M> {
|
|
|
828
765
|
const ids = data.ids.filter((id) => Number.isFinite(id))
|
|
829
766
|
const idsSet = new Set(ids)
|
|
830
767
|
|
|
831
|
-
for (const
|
|
768
|
+
for (const row of item.state.rows) {
|
|
769
|
+
const entity_id = (row as any).id
|
|
770
|
+
|
|
832
771
|
if (idsSet.has(entity_id)) {
|
|
833
772
|
continue
|
|
834
773
|
}
|
|
@@ -841,14 +780,15 @@ export class CuboCrdtClient<M> {
|
|
|
841
780
|
item.awarenesses.delete(entity_id)
|
|
842
781
|
}
|
|
843
782
|
|
|
844
|
-
doc.
|
|
845
|
-
|
|
783
|
+
const doc = this.docs.get(`${ctx.entity}:${entity_id}`)
|
|
784
|
+
if (doc) {
|
|
785
|
+
doc.doc.destroy()
|
|
786
|
+
this.docs.delete(`${ctx.entity}:${entity_id}`)
|
|
787
|
+
}
|
|
846
788
|
}
|
|
847
789
|
|
|
848
790
|
const rowsById = new Map(item.state.rows.map((row: any) => [Number(row.id), row]))
|
|
849
|
-
const orderedRows = ids
|
|
850
|
-
.map((id) => rowsById.get(id))
|
|
851
|
-
.filter((row) => row !== undefined)
|
|
791
|
+
const orderedRows = ids.map((id) => rowsById.get(id)).filter((row) => row !== undefined)
|
|
852
792
|
|
|
853
793
|
item.state.rows.splice(0, item.state.rows.length, ...orderedRows)
|
|
854
794
|
item.state.totals = data.totals || {}
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import { Doc } from 'yjs'
|
|
1
2
|
import { CuboCrdtClientUseOptions } from '..'
|
|
2
3
|
import { CuboCrdtExposeStrategy } from '../../types'
|
|
3
4
|
|
|
4
5
|
import { CuboCrdtClientBaseOptions } from './utils'
|
|
5
6
|
|
|
7
|
+
export type CuboCrdtClientDoc = {
|
|
8
|
+
doc: Doc
|
|
9
|
+
subscribes: Set<string>
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
export type CuboCrdtClientDocOrigin = {
|
|
7
13
|
// сохранять ли на бэке в дебаунсе
|
|
8
14
|
store?: boolean
|
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import { type WsClient } from '@cuboapp/ws'
|
|
2
2
|
import { ComputedRef } from 'vue'
|
|
3
3
|
import { Awareness } from 'y-protocols/awareness'
|
|
4
|
-
import { type Doc } from 'yjs'
|
|
5
4
|
|
|
6
|
-
import {
|
|
7
|
-
CuboCrdtAction,
|
|
8
|
-
CuboCrdtListSyncData,
|
|
9
|
-
CuboCrdtListTotals,
|
|
10
|
-
CuboCrdtSubscriptionStatus
|
|
11
|
-
} from '../../types'
|
|
5
|
+
import { CuboCrdtAction, CuboCrdtListSyncData, CuboCrdtListTotals, CuboCrdtSubscriptionStatus } from '../../types'
|
|
12
6
|
|
|
7
|
+
import { Doc } from 'yjs'
|
|
13
8
|
import { CuboCrdtClientBaseOptions } from './utils'
|
|
14
9
|
|
|
15
10
|
export * from './document'
|
|
@@ -45,7 +40,6 @@ export type CuboCrdtClientSubscribeEvent = {
|
|
|
45
40
|
export type CuboCrdtClientList<T> = {
|
|
46
41
|
subscribe_id: string
|
|
47
42
|
storeKey: string
|
|
48
|
-
docs: ComputedRef<Map<number, Doc> | undefined>
|
|
49
43
|
awarenesses?: ComputedRef<Map<number, Awareness> | undefined>
|
|
50
44
|
subscribe: (filters?: any) => void
|
|
51
45
|
upgrade: (filters?: any) => void
|
|
@@ -64,7 +58,7 @@ export type CuboCrdtClientList<T> = {
|
|
|
64
58
|
export type CuboCrdtClientRow<T> = {
|
|
65
59
|
subscribe_id: string
|
|
66
60
|
storeKey: string
|
|
67
|
-
doc:
|
|
61
|
+
doc: Doc
|
|
68
62
|
awareness?: ComputedRef<Awareness | undefined>
|
|
69
63
|
subscribe: () => void
|
|
70
64
|
upgrade: (filters?: any) => void
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import { Reactive } from 'vue'
|
|
2
|
-
import { Doc } from 'yjs'
|
|
3
2
|
import { Awareness } from 'y-protocols/awareness'
|
|
4
3
|
|
|
5
|
-
import {
|
|
6
|
-
CuboCrdtKey,
|
|
7
|
-
CuboCrdtListTotals,
|
|
8
|
-
CuboCrdtSubscriptionStatus
|
|
9
|
-
} from '../../types'
|
|
4
|
+
import { CuboCrdtKey, CuboCrdtListTotals, CuboCrdtSubscriptionStatus } from '../../types'
|
|
10
5
|
|
|
11
6
|
export type CuboCrdtClientStoreItem<T> = {
|
|
12
7
|
state: Reactive<{
|
|
@@ -18,7 +13,6 @@ export type CuboCrdtClientStoreItem<T> = {
|
|
|
18
13
|
totals: CuboCrdtListTotals
|
|
19
14
|
revision: number
|
|
20
15
|
}>
|
|
21
|
-
docs: Map<number, Doc>
|
|
22
16
|
awarenesses: Map<number, Awareness>
|
|
23
17
|
}
|
|
24
18
|
export type CuboCrdtClientStore<M> = Partial<{
|
|
@@ -1,23 +1,12 @@
|
|
|
1
|
-
import { cloneDeep, pick } from '@cuboapp/utils'
|
|
1
|
+
import { cloneDeep, debounce, 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 {
|
|
6
|
-
CuboCrdtServerDocumentOptions,
|
|
7
|
-
CuboCrdtServerDocumentOrigin,
|
|
8
|
-
CuboCrdtServerDocumentPendingStore
|
|
9
|
-
} from '../types'
|
|
10
|
-
import { getCrdtStoreChangeKey } from '../utils'
|
|
11
|
-
|
|
12
|
-
const STORE_DEBOUNCE_MS = 300
|
|
5
|
+
import { CuboCrdtServerDocumentOptions, CuboCrdtServerDocumentOrigin } from '../types'
|
|
13
6
|
|
|
14
7
|
export class CuboCrdtServerDocument {
|
|
15
8
|
private ydoc: Doc
|
|
16
9
|
private persistedState: object = {}
|
|
17
|
-
private observedState: object = {}
|
|
18
|
-
private storeQueue?: Promise<void>
|
|
19
|
-
private pendingStore?: CuboCrdtServerDocumentPendingStore
|
|
20
|
-
private storeTimeout?: ReturnType<typeof setTimeout>
|
|
21
10
|
public awareness: Awareness
|
|
22
11
|
public awarenessBySubscribe = new Map<string, Set<number>>()
|
|
23
12
|
|
|
@@ -30,11 +19,31 @@ export class CuboCrdtServerDocument {
|
|
|
30
19
|
})
|
|
31
20
|
|
|
32
21
|
if (opts.awareness) {
|
|
33
|
-
this.
|
|
22
|
+
this.awareness = new Awareness(this.ydoc)
|
|
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
|
+
})
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
this.persistedState = cloneDeep(opts.initialState ?? {})
|
|
37
|
-
this.observedState = cloneDeep(opts.initialState ?? {})
|
|
38
47
|
|
|
39
48
|
// записываем исходное состояние
|
|
40
49
|
this.write(opts.initialState ?? {})
|
|
@@ -49,8 +58,7 @@ export class CuboCrdtServerDocument {
|
|
|
49
58
|
this.opts?.onUpdate?.(data, origin)
|
|
50
59
|
// }
|
|
51
60
|
|
|
52
|
-
//
|
|
53
|
-
// debounceStore сам обновит observedState после вычисления leaf-diff.
|
|
61
|
+
// если нужно сохранять стейт документа - вызываем store по дебаунсу
|
|
54
62
|
if (origin?.store) {
|
|
55
63
|
const row = this.getJson()
|
|
56
64
|
const body = pick(row, origin.keys ?? Object.keys(row))
|
|
@@ -60,41 +68,10 @@ export class CuboCrdtServerDocument {
|
|
|
60
68
|
console.error('[CRDT] store document', this.name, error)
|
|
61
69
|
})
|
|
62
70
|
}
|
|
63
|
-
} else {
|
|
64
|
-
this.observedState = cloneDeep(this.getJson())
|
|
65
71
|
}
|
|
66
72
|
})
|
|
67
73
|
}
|
|
68
74
|
|
|
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
|
-
|
|
98
75
|
public get name() {
|
|
99
76
|
return this.opts.name
|
|
100
77
|
}
|
|
@@ -156,138 +133,13 @@ export class CuboCrdtServerDocument {
|
|
|
156
133
|
this.ydoc.destroy()
|
|
157
134
|
}
|
|
158
135
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
}
|
|
136
|
+
public async store(body: object, origin: CuboCrdtServerDocumentOrigin) {
|
|
137
|
+
const previousRow = this.getPersistedState()
|
|
138
|
+
const row = this.getJson()
|
|
233
139
|
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
})
|
|
140
|
+
await this.opts?.onStore?.(body, origin, { previousRow, row })
|
|
141
|
+
this.setPersistedState(row)
|
|
263
142
|
}
|
|
264
143
|
|
|
265
|
-
|
|
266
|
-
private schedulePendingStore() {
|
|
267
|
-
if (this.storeTimeout) {
|
|
268
|
-
clearTimeout(this.storeTimeout)
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
this.storeTimeout = setTimeout(() => this.flushPendingStore(), STORE_DEBOUNCE_MS)
|
|
272
|
-
}
|
|
273
|
-
|
|
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
|
-
}
|
|
144
|
+
public debounceStore = debounce(this.store.bind(this), 300)
|
|
293
145
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -4,11 +4,7 @@ import { applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } fr
|
|
|
4
4
|
|
|
5
5
|
import { CUBO_CRDT_EVENT } from '../constants'
|
|
6
6
|
import { CuboCrdtAction, CuboCrdtListSyncData, CuboCrdtMutation } from '../types'
|
|
7
|
-
import {
|
|
8
|
-
areCrdtListIdsEqual,
|
|
9
|
-
areCrdtListTotalsEqual,
|
|
10
|
-
checkRowIsSutable
|
|
11
|
-
} from '../utils'
|
|
7
|
+
import { areCrdtListIdsEqual, areCrdtListTotalsEqual, checkRowIsSutable } from '../utils'
|
|
12
8
|
|
|
13
9
|
import { CuboCrdtClientDocOrigin } from '../client'
|
|
14
10
|
import { CuboCrdtServerDocument } from './document'
|
|
@@ -40,7 +36,6 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
40
36
|
private subscribeGenerations = new Map<string, number>()
|
|
41
37
|
|
|
42
38
|
private documents = new Map<string, CuboCrdtServerDocument>()
|
|
43
|
-
private documentEvictions = new Map<string, object>()
|
|
44
39
|
private subscribesByDocument = new Map<string, Set<string>>()
|
|
45
40
|
// обратный индекс: подписка -> имена документов, к которым она привязана.
|
|
46
41
|
// Нужен, чтобы отписка/очистка не сканировала ВСЕ документы сервера (O(subs x docs)).
|
|
@@ -112,53 +107,34 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
112
107
|
this.cleanSubscribe(subscribe_id)
|
|
113
108
|
})
|
|
114
109
|
|
|
115
|
-
this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, async ({
|
|
110
|
+
this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, async ({ message }) => {
|
|
116
111
|
let data = message.data as any
|
|
117
112
|
if (!Array.isArray(data)) {
|
|
118
113
|
data = [data]
|
|
119
114
|
}
|
|
120
115
|
|
|
121
116
|
if (this.debug) {
|
|
122
|
-
console.log('[CRDT] incoming event', data
|
|
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
|
-
})))
|
|
117
|
+
console.log('[CRDT] incoming event', data)
|
|
129
118
|
}
|
|
130
119
|
|
|
131
120
|
for (const row of data) {
|
|
132
121
|
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 }
|
|
142
122
|
|
|
143
123
|
switch (row.action) {
|
|
144
124
|
case 'create':
|
|
145
|
-
this.onDocumentExternalCreate(update,
|
|
125
|
+
this.onDocumentExternalCreate(update, row)
|
|
146
126
|
break
|
|
147
127
|
case 'update':
|
|
148
|
-
this.onDocumentExternalUpdate(update,
|
|
128
|
+
this.onDocumentExternalUpdate(update, row)
|
|
149
129
|
break
|
|
150
130
|
case 'delete':
|
|
151
|
-
this.onDocumentExternalDelete(update,
|
|
131
|
+
this.onDocumentExternalDelete(update, row)
|
|
152
132
|
break
|
|
153
133
|
case 'awareness':
|
|
154
|
-
this.onAwarenessExternalUpdate(update,
|
|
134
|
+
this.onAwarenessExternalUpdate(update, row)
|
|
155
135
|
break
|
|
156
136
|
}
|
|
157
137
|
}
|
|
158
|
-
|
|
159
|
-
// Ответ означает только «update применён к живому серверному Y.Doc».
|
|
160
|
-
// Debounce и последовательная запись в БД продолжаются независимо.
|
|
161
|
-
return { accepted: true }
|
|
162
138
|
})
|
|
163
139
|
|
|
164
140
|
this.ws.registerHttpHandler('GET', '/stats', () => {
|
|
@@ -194,7 +170,6 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
194
170
|
this.subscribeRefreshStates.forEach((state) => clearTimeout(state.timer))
|
|
195
171
|
this.subscribeRefreshStates.clear()
|
|
196
172
|
this.subscribeGenerations.clear()
|
|
197
|
-
this.documentEvictions.clear()
|
|
198
173
|
|
|
199
174
|
this.subscribes.forEach((sub) => this.ws.deleteHandler(sub.id))
|
|
200
175
|
}
|
|
@@ -307,10 +282,6 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
307
282
|
|
|
308
283
|
// привязываем подписку к документу (в обоих индексах)
|
|
309
284
|
private linkSubscribeToDocument(subscribe_id: string, docName: string) {
|
|
310
|
-
// Новая подписка снова использует живой Y.Doc. Асинхронный flush может
|
|
311
|
-
// продолжаться, но больше не имеет права уничтожить этот документ.
|
|
312
|
-
this.documentEvictions.delete(docName)
|
|
313
|
-
|
|
314
285
|
let byDoc = this.subscribesByDocument.get(docName)
|
|
315
286
|
if (!byDoc) {
|
|
316
287
|
byDoc = new Set()
|
|
@@ -334,56 +305,17 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
334
305
|
|
|
335
306
|
// если у документа нет подписок - сносим его
|
|
336
307
|
private checkDocumentNeedRemove(name: string) {
|
|
337
|
-
if (this.subscribesByDocument.get(name)?.size
|
|
338
|
-
|
|
339
|
-
|
|
308
|
+
if (!this.subscribesByDocument.get(name)?.size) {
|
|
309
|
+
if (this.debug) {
|
|
310
|
+
console.log('[CRDT] delete document', name)
|
|
311
|
+
}
|
|
340
312
|
|
|
341
|
-
|
|
342
|
-
|
|
313
|
+
this.documents.get(name)?.destroy()
|
|
314
|
+
this.documents.delete(name)
|
|
315
|
+
// не оставляем пустой Set в индексе (иначе — медленная утечка записей мапы);
|
|
316
|
+
// getOrCreateDocument пересоздаст его при повторном появлении документа
|
|
343
317
|
this.subscribesByDocument.delete(name)
|
|
344
|
-
return
|
|
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
318
|
}
|
|
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
|
-
})
|
|
387
319
|
}
|
|
388
320
|
|
|
389
321
|
// очистка подписки (при отписке клиента - через emit-метод или options-хук).
|
|
@@ -501,12 +433,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
501
433
|
const generation = this.subscribeGenerations.get(subscribe_id) || 0
|
|
502
434
|
const forceSync = state.forceSync
|
|
503
435
|
state.forceSync = false
|
|
504
|
-
const reconciled = await this.reconcileSubscribe(
|
|
505
|
-
client,
|
|
506
|
-
subscribe,
|
|
507
|
-
generation,
|
|
508
|
-
forceSync
|
|
509
|
-
)
|
|
436
|
+
const reconciled = await this.reconcileSubscribe(client, subscribe, generation, forceSync)
|
|
510
437
|
if (!reconciled && forceSync) {
|
|
511
438
|
state.forceSync = true
|
|
512
439
|
}
|
|
@@ -518,11 +445,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
518
445
|
return state.running
|
|
519
446
|
}
|
|
520
447
|
|
|
521
|
-
private requestSubscribeRefresh(
|
|
522
|
-
subscribe_id: string,
|
|
523
|
-
immediate = false,
|
|
524
|
-
forceSync = immediate
|
|
525
|
-
) {
|
|
448
|
+
private requestSubscribeRefresh(subscribe_id: string, immediate = false, forceSync = immediate) {
|
|
526
449
|
const state = this.getSubscribeRefreshState(subscribe_id)
|
|
527
450
|
state.dirty = true
|
|
528
451
|
state.forceSync ||= forceSync
|
|
@@ -608,16 +531,8 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
608
531
|
}
|
|
609
532
|
|
|
610
533
|
if (mutation.previousRow) {
|
|
611
|
-
const previousSuitable = this.checkRowIsSutable(
|
|
612
|
-
|
|
613
|
-
subscribe,
|
|
614
|
-
entity
|
|
615
|
-
)
|
|
616
|
-
const currentSuitable = this.checkRowIsSutable(
|
|
617
|
-
mutation.row,
|
|
618
|
-
subscribe,
|
|
619
|
-
entity
|
|
620
|
-
)
|
|
534
|
+
const previousSuitable = this.checkRowIsSutable(mutation.previousRow, subscribe, entity)
|
|
535
|
+
const currentSuitable = this.checkRowIsSutable(mutation.row, subscribe, entity)
|
|
621
536
|
|
|
622
537
|
if (previousSuitable !== currentSuitable) {
|
|
623
538
|
return true
|
|
@@ -669,9 +584,20 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
669
584
|
|
|
670
585
|
let document = this.documents.get(documentName)
|
|
671
586
|
if (document) {
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
587
|
+
// Освежаем существующий документ строкой из БД.
|
|
588
|
+
//
|
|
589
|
+
// Раньше документ возвращался как есть, а подписка отправляла клиенту его
|
|
590
|
+
// stateAsUpdate — то есть КЭШ документа, а не только что прочитанную строку.
|
|
591
|
+
// Документ, проспавший мутацию, оставался устаревшим навсегда: даже перезагрузка
|
|
592
|
+
// страницы отдавала старое значение, потому что reconcile брал его же.
|
|
593
|
+
//
|
|
594
|
+
// Стало заметно после того, как документы без подписчиков начали удаляться:
|
|
595
|
+
// документ может быть создан, остаться без подписок, пропустить мутации и
|
|
596
|
+
// «воскреснуть» на новой подписке уже неактуальным.
|
|
597
|
+
//
|
|
598
|
+
// store: false — это не правка от клиента, а синхронизация с БД, писать обратно нечего.
|
|
599
|
+
document.write(row, { expose: 'all', store: false })
|
|
600
|
+
document.setPersistedState(row)
|
|
675
601
|
|
|
676
602
|
return document
|
|
677
603
|
}
|
|
@@ -688,13 +614,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
688
614
|
},
|
|
689
615
|
onStore: (item: object, origin: CuboCrdtServerDocumentOrigin, store) => {
|
|
690
616
|
const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
|
|
691
|
-
const
|
|
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)
|
|
617
|
+
const client = subscribe && this.clients.get(subscribe.client_id)
|
|
698
618
|
|
|
699
619
|
if (client) {
|
|
700
620
|
return this.options?.storeRow?.(entity as any, entity_id, item as any, {
|
|
@@ -730,13 +650,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
730
650
|
const existed = this.documents.has(documentName)
|
|
731
651
|
const document = this.getOrCreateDocument(entity, row, awareness)
|
|
732
652
|
|
|
733
|
-
if (existed
|
|
734
|
-
// Только внешний CRUD является подтверждённым источником из БД.
|
|
735
|
-
// Обычная повторная подписка не должна применять потенциально старую строку
|
|
736
|
-
// поверх живого документа с ещё не сохранённым collaborative update.
|
|
737
|
-
document.write(row, { expose: 'all', store: false })
|
|
738
|
-
document.setPersistedState(row)
|
|
739
|
-
} else {
|
|
653
|
+
if (!existed) {
|
|
740
654
|
this.pushDocumentAction('create', entity, document, document.stateAsUpdate)
|
|
741
655
|
}
|
|
742
656
|
|
|
@@ -901,13 +815,9 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
901
815
|
|
|
902
816
|
const subscribeSutable = this.checkSubscribeIsSutable(origin, subscribe)
|
|
903
817
|
const documentExistsInSubscribe = this.subscribesByDocument.get(document.name)?.has(subscribe.id)
|
|
904
|
-
const rowSutable = subscribe.paginated
|
|
905
|
-
? documentExistsInSubscribe
|
|
906
|
-
: this.checkRowIsSutable(row, subscribe, entity)
|
|
818
|
+
const rowSutable = subscribe.paginated ? documentExistsInSubscribe : this.checkRowIsSutable(row, subscribe, entity)
|
|
907
819
|
|
|
908
|
-
const sutable = subscribeSutable && (subscribe.paginated
|
|
909
|
-
? documentExistsInSubscribe
|
|
910
|
-
: rowSutable || documentExistsInSubscribe)
|
|
820
|
+
const sutable = subscribeSutable && (subscribe.paginated ? documentExistsInSubscribe : rowSutable || documentExistsInSubscribe)
|
|
911
821
|
|
|
912
822
|
if (sutable) {
|
|
913
823
|
if (this.debug && !rowSutable) {
|
|
@@ -1008,21 +918,13 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
1008
918
|
}
|
|
1009
919
|
}
|
|
1010
920
|
|
|
1011
|
-
private async reconcileSubscribe(
|
|
1012
|
-
client: WsServerSocket,
|
|
1013
|
-
subscribe: CuboCrdtServerSubscribe,
|
|
1014
|
-
generation: number,
|
|
1015
|
-
forceSync: boolean
|
|
1016
|
-
) {
|
|
921
|
+
private async reconcileSubscribe(client: WsServerSocket, subscribe: CuboCrdtServerSubscribe, generation: number, forceSync: boolean) {
|
|
1017
922
|
const fetched = await this.options.fetchRows?.(client, subscribe)
|
|
1018
923
|
const result = Array.isArray(fetched)
|
|
1019
924
|
? { rows: fetched, totals: subscribe.totals }
|
|
1020
925
|
: { rows: fetched?.rows || [], totals: fetched?.totals || subscribe.totals }
|
|
1021
926
|
|
|
1022
|
-
if (
|
|
1023
|
-
this.subscribes.get(subscribe.id) !== subscribe ||
|
|
1024
|
-
this.subscribeGenerations.get(subscribe.id) !== generation
|
|
1025
|
-
) {
|
|
927
|
+
if (this.subscribes.get(subscribe.id) !== subscribe || this.subscribeGenerations.get(subscribe.id) !== generation) {
|
|
1026
928
|
return false
|
|
1027
929
|
}
|
|
1028
930
|
|
|
@@ -1095,10 +997,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
1095
997
|
}
|
|
1096
998
|
|
|
1097
999
|
const idsChanged = !areCrdtListIdsEqual(subscribe.row_ids, nextIds)
|
|
1098
|
-
const totalsChanged = !areCrdtListTotalsEqual(
|
|
1099
|
-
subscribe.totals,
|
|
1100
|
-
result.totals
|
|
1101
|
-
)
|
|
1000
|
+
const totalsChanged = !areCrdtListTotalsEqual(subscribe.totals, result.totals)
|
|
1102
1001
|
|
|
1103
1002
|
subscribe.row_ids = nextIds
|
|
1104
1003
|
subscribe.totals = result.totals
|
|
@@ -1137,10 +1036,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
1137
1036
|
if (upgrade.filters !== undefined) {
|
|
1138
1037
|
subscribe.filters = cloneDeep(upgrade.filters || {})
|
|
1139
1038
|
}
|
|
1140
|
-
subscribe.paginated = this.resolvePaginated(
|
|
1141
|
-
subscribe.filters,
|
|
1142
|
-
upgrade.paginated
|
|
1143
|
-
)
|
|
1039
|
+
subscribe.paginated = this.resolvePaginated(subscribe.filters, upgrade.paginated)
|
|
1144
1040
|
|
|
1145
1041
|
return this.requestSubscribeRefresh(subscribe.id, true)
|
|
1146
1042
|
}
|
|
@@ -5,20 +5,6 @@ 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
|
-
|
|
22
8
|
export type CuboCrdtServerDocumentOptions = {
|
|
23
9
|
name: string
|
|
24
10
|
onStore?: (
|
|
@@ -77,16 +63,4 @@ export type CuboCrdtServerDocumentOrigin = {
|
|
|
77
63
|
|
|
78
64
|
// id клиента
|
|
79
65
|
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
|
-
}
|
|
92
66
|
}
|
|
@@ -1,41 +0,0 @@
|
|
|
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
|
-
}
|