@cuboapp/crdt 1.0.24 → 1.0.25
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 +138 -135
- package/src/client/types/document.ts +7 -0
- package/src/client/types/index.ts +0 -3
- package/src/client/types/store.ts +0 -2
- package/src/server/document/index.ts +179 -31
- package/src/server/index.ts +129 -30
- 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
|
@@ -25,6 +25,8 @@ export * from './types'
|
|
|
25
25
|
|
|
26
26
|
export class CuboCrdtClient<M> {
|
|
27
27
|
public store: CuboCrdtClientStore<M> = {}
|
|
28
|
+
// Реактивен только реестр: Vue отслеживает set/delete и повторно вычисляет
|
|
29
|
+
// computed(useDoc). Сами Y.Doc/Awareness остаются исходными объектами без proxy.
|
|
28
30
|
public docs = new Map<string, CuboCrdtClientDoc>()
|
|
29
31
|
|
|
30
32
|
private listeners: Map<string, Map<string, (ctx: CuboCrdtClientSubscribeEvent) => void | Promise<void>>> = new Map()
|
|
@@ -108,6 +110,11 @@ export class CuboCrdtClient<M> {
|
|
|
108
110
|
|
|
109
111
|
this.ws.deleteHandler(CUBO_CRDT_EVENT.EVENT)
|
|
110
112
|
|
|
113
|
+
for (const document of this.docs.values()) {
|
|
114
|
+
document.awareness?.destroy()
|
|
115
|
+
document.doc.destroy()
|
|
116
|
+
}
|
|
117
|
+
this.docs.clear()
|
|
111
118
|
this.listeners.clear()
|
|
112
119
|
this.subscriptions.clear()
|
|
113
120
|
}
|
|
@@ -231,8 +238,7 @@ export class CuboCrdtClient<M> {
|
|
|
231
238
|
})
|
|
232
239
|
|
|
233
240
|
this.store[storeKey] = {
|
|
234
|
-
state
|
|
235
|
-
awarenesses: new Map()
|
|
241
|
+
state
|
|
236
242
|
}
|
|
237
243
|
}
|
|
238
244
|
|
|
@@ -343,31 +349,13 @@ export class CuboCrdtClient<M> {
|
|
|
343
349
|
this.store[storeKey]!.state.status = 'idle'
|
|
344
350
|
this.store[storeKey]!.state.error = undefined
|
|
345
351
|
|
|
352
|
+
const item = this.store[storeKey]
|
|
353
|
+
item?.state.rows.forEach((row) => {
|
|
354
|
+
this.detachDocument(`${entity}`, (row as any).id, subscribe_id, storeKey)
|
|
355
|
+
})
|
|
356
|
+
|
|
346
357
|
// очищаем стор
|
|
347
358
|
if (clear !== false) {
|
|
348
|
-
const item = this.store[storeKey]
|
|
349
|
-
if (item) {
|
|
350
|
-
item.awarenesses?.forEach((a) => {
|
|
351
|
-
removeAwarenessStates(a, [a.doc.clientID], 'unsubscribe')
|
|
352
|
-
|
|
353
|
-
a.setLocalState(null)
|
|
354
|
-
a.destroy()
|
|
355
|
-
})
|
|
356
|
-
item.awarenesses?.clear()
|
|
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
|
-
})
|
|
369
|
-
}
|
|
370
|
-
|
|
371
359
|
this.store[storeKey] = undefined
|
|
372
360
|
}
|
|
373
361
|
}
|
|
@@ -389,7 +377,6 @@ export class CuboCrdtClient<M> {
|
|
|
389
377
|
subscribe,
|
|
390
378
|
upgrade,
|
|
391
379
|
unsubscribe,
|
|
392
|
-
awarenesses: opts?.awareness ? computed(() => this.store[storeKey]?.awarenesses) : undefined,
|
|
393
380
|
subscribed: () => computed(() => this.store[storeKey]?.state.subscribed || false),
|
|
394
381
|
loading: () => computed(() => this.store[storeKey]?.state.loading || false),
|
|
395
382
|
ready: () => computed(() => this.store[storeKey]?.state.status === 'ready'),
|
|
@@ -402,6 +389,10 @@ export class CuboCrdtClient<M> {
|
|
|
402
389
|
}
|
|
403
390
|
}
|
|
404
391
|
|
|
392
|
+
public useDoc<K extends Extract<keyof M, string>>(entity: K, id: number) {
|
|
393
|
+
return this.docs.get(`${entity}:${id}`)
|
|
394
|
+
}
|
|
395
|
+
|
|
405
396
|
public useRow<K extends Extract<keyof M, string>, T = CuboCrdtKey<K, M>>(
|
|
406
397
|
entity: K | string,
|
|
407
398
|
id: number,
|
|
@@ -409,18 +400,15 @@ export class CuboCrdtClient<M> {
|
|
|
409
400
|
): CuboCrdtClientRow<T> {
|
|
410
401
|
// console.log('store key', opts?.storeKey ?? `${entity}:${id}`)
|
|
411
402
|
|
|
412
|
-
const { subscribe_id, storeKey,
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
})
|
|
403
|
+
const { subscribe_id, storeKey, subscribe, upgrade, unsubscribe, subscribed, loading, ready, status, error } = this.useList(entity, {
|
|
404
|
+
...opts,
|
|
405
|
+
filters: opts?.filters ?? { id },
|
|
406
|
+
storeKey: opts?.storeKey ?? `${entity}:${id}`
|
|
407
|
+
})
|
|
418
408
|
|
|
419
409
|
return {
|
|
420
410
|
subscribe_id,
|
|
421
411
|
storeKey,
|
|
422
|
-
doc: this.docs.get(`${entity}:${id}`)?.doc,
|
|
423
|
-
awareness: computed(() => awarenesses?.value?.get(id)),
|
|
424
412
|
subscribe,
|
|
425
413
|
upgrade,
|
|
426
414
|
unsubscribe,
|
|
@@ -445,46 +433,28 @@ export class CuboCrdtClient<M> {
|
|
|
445
433
|
) {
|
|
446
434
|
const storeKey = `${entity}:${entity_id}`
|
|
447
435
|
|
|
448
|
-
const
|
|
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()
|
|
436
|
+
const document = this.docs.get(storeKey)
|
|
455
437
|
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) =>
|
|
438
|
+
if (document) {
|
|
439
|
+
const map = document.doc.getMap()
|
|
440
|
+
const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => map.get(key) !== value))
|
|
459
441
|
|
|
460
442
|
if (Object.keys(toUpdate).length) {
|
|
461
443
|
const origin: CuboCrdtClientDocOrigin = {
|
|
462
444
|
store: opts?.store ?? true,
|
|
463
445
|
expose: opts?.expose ?? 'other',
|
|
464
446
|
keys: opts?.keys ?? Object.keys(toUpdate),
|
|
465
|
-
react: true
|
|
447
|
+
react: true,
|
|
448
|
+
subscribe_id: opts?.subscribe_id ?? document.subscribes.values().next().value
|
|
466
449
|
}
|
|
467
450
|
|
|
468
|
-
|
|
451
|
+
document.doc.transact(() => {
|
|
469
452
|
Object.entries(toUpdate).forEach(([key, value]) => {
|
|
470
453
|
map.set(key, value)
|
|
471
454
|
})
|
|
472
455
|
}, origin)
|
|
473
456
|
}
|
|
474
457
|
}
|
|
475
|
-
|
|
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))
|
|
480
|
-
|
|
481
|
-
if (Object.keys(toUpdate).length) {
|
|
482
|
-
Object.entries(toUpdate).forEach(([key, value]) => {
|
|
483
|
-
row[key as keyof typeof row] = value
|
|
484
|
-
})
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
458
|
}
|
|
489
459
|
|
|
490
460
|
public upgrade(subscribe_id: string, filters?: Record<string, any>) {
|
|
@@ -528,6 +498,80 @@ export class CuboCrdtClient<M> {
|
|
|
528
498
|
})
|
|
529
499
|
}
|
|
530
500
|
|
|
501
|
+
private applyDocumentRows(entity_id: number, document: CuboCrdtClientDoc) {
|
|
502
|
+
for (const subscribe_id of document.subscribes) {
|
|
503
|
+
const subscription = this.subscriptions.get(subscribe_id)
|
|
504
|
+
if (subscription) {
|
|
505
|
+
this.applyRowJson(subscription.storeKey, entity_id, document.doc)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private attachDocument(entity: string, entity_id: number, document: CuboCrdtClientDoc, opts: CuboCrdtClientDocUpdateOptions) {
|
|
511
|
+
document.subscribes.add(opts.subscribe_id)
|
|
512
|
+
|
|
513
|
+
if (!opts.awareness) {
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
document.awarenessSubscribes.add(opts.subscribe_id)
|
|
518
|
+
|
|
519
|
+
if (!document.awareness) {
|
|
520
|
+
const awareness = new Awareness(document.doc)
|
|
521
|
+
|
|
522
|
+
awareness.on('update', ({ added, updated, removed }, origin) => {
|
|
523
|
+
if (origin === 'remote') {
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const subscribe_id = document.awarenessSubscribes.values().next().value
|
|
528
|
+
if (!subscribe_id) {
|
|
529
|
+
return
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const changed = added.concat(updated).concat(removed)
|
|
533
|
+
const update = encodeAwarenessUpdate(awareness, changed)
|
|
534
|
+
|
|
535
|
+
void this.ws
|
|
536
|
+
.request({
|
|
537
|
+
method: CUBO_CRDT_EVENT.EVENT,
|
|
538
|
+
data: {
|
|
539
|
+
action: 'awareness',
|
|
540
|
+
entity,
|
|
541
|
+
entity_id,
|
|
542
|
+
data: Array.from(update),
|
|
543
|
+
origin: { expose: 'other', subscribe_id }
|
|
544
|
+
}
|
|
545
|
+
})
|
|
546
|
+
.catch((error) => console.error('[CRDT] awareness request failed', error))
|
|
547
|
+
})
|
|
548
|
+
document.awareness = awareness
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private detachDocument(entity: string, entity_id: number, subscribe_id: string, storeKey: string) {
|
|
553
|
+
const key = `${entity}:${entity_id}`
|
|
554
|
+
const document = this.docs.get(key)
|
|
555
|
+
if (!document) {
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
document.subscribes.delete(subscribe_id)
|
|
560
|
+
document.awarenessSubscribes.delete(subscribe_id)
|
|
561
|
+
|
|
562
|
+
if (!document.awarenessSubscribes.size && document.awareness) {
|
|
563
|
+
removeAwarenessStates(document.awareness, [document.awareness.doc.clientID], 'unsubscribe')
|
|
564
|
+
document.awareness.setLocalState(null)
|
|
565
|
+
document.awareness.destroy()
|
|
566
|
+
document.awareness = undefined
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (!document.subscribes.size) {
|
|
570
|
+
document.doc.destroy()
|
|
571
|
+
this.docs.delete(key)
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
531
575
|
// апсертит строку в реактивное хранилище из текущего состояния yjs-документа
|
|
532
576
|
private applyRowJson(storeKey: string, entity_id: number, doc: Doc) {
|
|
533
577
|
const json: any = doc.getMap().toJSON()
|
|
@@ -559,6 +603,7 @@ export class CuboCrdtClient<M> {
|
|
|
559
603
|
console.log('[CRDT] onDocumentCreate: doc already exists, merging state', { ctx, opts })
|
|
560
604
|
}
|
|
561
605
|
|
|
606
|
+
this.attachDocument(ctx.entity, entity_id, existing, opts)
|
|
562
607
|
applyUpdate(existing.doc, new Uint8Array(ctx.data as any), { react: false })
|
|
563
608
|
this.applyRowJson(opts.storeKey, entity_id, existing.doc)
|
|
564
609
|
return
|
|
@@ -577,8 +622,20 @@ export class CuboCrdtClient<M> {
|
|
|
577
622
|
|
|
578
623
|
applyUpdate(yjsDoc, update, { react: false })
|
|
579
624
|
|
|
625
|
+
const document: CuboCrdtClientDoc = {
|
|
626
|
+
doc: yjsDoc,
|
|
627
|
+
subscribes: new Set(),
|
|
628
|
+
awarenessSubscribes: new Set()
|
|
629
|
+
}
|
|
630
|
+
this.attachDocument(ctx.entity, entity_id, document, opts)
|
|
631
|
+
// Публикуем контейнер реактивному реестру только после полной инициализации.
|
|
632
|
+
// Тогда первый computed(useDoc) не увидит промежуточный doc без Awareness.
|
|
633
|
+
this.docs.set(`${ctx.entity}:${ctx.entity_id}`, document)
|
|
634
|
+
|
|
580
635
|
// подписываемся на обновления документа
|
|
581
636
|
yjsDoc.on('update', (update, origin: CuboCrdtClientDocOrigin) => {
|
|
637
|
+
this.applyDocumentRows(entity_id, document)
|
|
638
|
+
|
|
582
639
|
// react = false, если это апдейт с бэка
|
|
583
640
|
if (origin?.react !== false) {
|
|
584
641
|
const data: CuboCrdtServerDocumentIncomingAction = {
|
|
@@ -592,21 +649,19 @@ export class CuboCrdtClient<M> {
|
|
|
592
649
|
// приходят без expose и сервер эхом шлёт их обратно самому автору.
|
|
593
650
|
expose: 'other',
|
|
594
651
|
...pick(origin || {}, ['store', 'keys', 'expose']),
|
|
595
|
-
subscribe_id:
|
|
652
|
+
subscribe_id: origin?.subscribe_id ?? document.subscribes.values().next().value
|
|
596
653
|
}
|
|
597
654
|
}
|
|
598
655
|
|
|
599
|
-
this.ws
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
656
|
+
void this.ws
|
|
657
|
+
.request({
|
|
658
|
+
method: CUBO_CRDT_EVENT.EVENT,
|
|
659
|
+
data
|
|
660
|
+
})
|
|
661
|
+
.catch((error) => console.error('[CRDT] update request failed', error))
|
|
603
662
|
}
|
|
604
663
|
})
|
|
605
664
|
|
|
606
|
-
// добавляем документ в хранилище документов
|
|
607
|
-
const doc: CuboCrdtClientDoc = { doc: yjsDoc, subscribes: new Set() }
|
|
608
|
-
this.docs.set(`${ctx.entity}:${ctx.entity_id}`, doc)
|
|
609
|
-
|
|
610
665
|
// добавляем документ в реактивное хранилище
|
|
611
666
|
const json: any = yjsDoc.getMap().toJSON()
|
|
612
667
|
const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === entity_id)
|
|
@@ -623,39 +678,6 @@ export class CuboCrdtClient<M> {
|
|
|
623
678
|
if (opts?.onAfterCreate) {
|
|
624
679
|
await opts.onAfterCreate(yjsDoc, update, ctx, opts)
|
|
625
680
|
}
|
|
626
|
-
|
|
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
|
-
}
|
|
659
681
|
}
|
|
660
682
|
|
|
661
683
|
private async onDocumentUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
|
|
@@ -682,8 +704,8 @@ export class CuboCrdtClient<M> {
|
|
|
682
704
|
// обновляем yjs-ный документ
|
|
683
705
|
applyUpdate(doc.doc, update, { react: false })
|
|
684
706
|
|
|
685
|
-
//
|
|
686
|
-
this.
|
|
707
|
+
// один канонический документ обновляет проекции всех активных подписок
|
|
708
|
+
this.applyDocumentRows(entity_id, doc)
|
|
687
709
|
|
|
688
710
|
if (opts?.onAfterUpdate) {
|
|
689
711
|
await opts.onAfterUpdate(doc.doc, update, ctx, opts)
|
|
@@ -709,25 +731,18 @@ export class CuboCrdtClient<M> {
|
|
|
709
731
|
}
|
|
710
732
|
}
|
|
711
733
|
|
|
712
|
-
doc.
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
} else {
|
|
719
|
-
if (this.debug) {
|
|
720
|
-
console.log('[CRDT] row not found', index, ctx, opts)
|
|
734
|
+
for (const subscribe_id of doc.subscribes) {
|
|
735
|
+
const subscription = this.subscriptions.get(subscribe_id)
|
|
736
|
+
const item = subscription && this.store[subscription.storeKey]
|
|
737
|
+
const index = item?.state.rows.findIndex((row: any) => row.id === entity_id) ?? -1
|
|
738
|
+
if (item && index >= 0) {
|
|
739
|
+
item.state.rows.splice(index, 1)
|
|
721
740
|
}
|
|
722
741
|
}
|
|
723
742
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
awareness.destroy()
|
|
729
|
-
this.store[opts.storeKey]?.awarenesses?.delete(entity_id)
|
|
730
|
-
}
|
|
743
|
+
doc.awareness?.destroy()
|
|
744
|
+
doc.doc.destroy()
|
|
745
|
+
this.docs.delete(`${ctx.entity}:${ctx.entity_id}`)
|
|
731
746
|
|
|
732
747
|
if (opts?.onAfterDelete) {
|
|
733
748
|
await opts.onAfterDelete(doc.doc, ctx, opts)
|
|
@@ -772,19 +787,7 @@ export class CuboCrdtClient<M> {
|
|
|
772
787
|
continue
|
|
773
788
|
}
|
|
774
789
|
|
|
775
|
-
|
|
776
|
-
if (awareness) {
|
|
777
|
-
removeAwarenessStates(awareness, [awareness.doc.clientID], 'sync')
|
|
778
|
-
awareness.setLocalState(null)
|
|
779
|
-
awareness.destroy()
|
|
780
|
-
item.awarenesses.delete(entity_id)
|
|
781
|
-
}
|
|
782
|
-
|
|
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
|
-
}
|
|
790
|
+
this.detachDocument(ctx.entity, entity_id, opts.subscribe_id, opts.storeKey)
|
|
788
791
|
}
|
|
789
792
|
|
|
790
793
|
const rowsById = new Map(item.state.rows.map((row: any) => [Number(row.id), row]))
|
|
@@ -805,15 +808,15 @@ export class CuboCrdtClient<M> {
|
|
|
805
808
|
return
|
|
806
809
|
}
|
|
807
810
|
|
|
808
|
-
const
|
|
809
|
-
if (!awareness) {
|
|
811
|
+
const doc = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
|
|
812
|
+
if (!doc?.awareness) {
|
|
810
813
|
return
|
|
811
814
|
}
|
|
812
815
|
|
|
813
816
|
const update = new Uint8Array(ctx.data as any)
|
|
814
817
|
|
|
815
818
|
try {
|
|
816
|
-
applyAwarenessUpdate(awareness, update, 'remote')
|
|
819
|
+
applyAwarenessUpdate(doc.awareness, update, 'remote')
|
|
817
820
|
} catch (e) {
|
|
818
821
|
console.warn('[CRDT] applyAwarenessUpdate failed ', e)
|
|
819
822
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { Awareness } from 'y-protocols/awareness'
|
|
1
2
|
import { Doc } from 'yjs'
|
|
3
|
+
|
|
2
4
|
import { CuboCrdtClientUseOptions } from '..'
|
|
3
5
|
import { CuboCrdtExposeStrategy } from '../../types'
|
|
4
6
|
|
|
@@ -7,6 +9,8 @@ import { CuboCrdtClientBaseOptions } from './utils'
|
|
|
7
9
|
export type CuboCrdtClientDoc = {
|
|
8
10
|
doc: Doc
|
|
9
11
|
subscribes: Set<string>
|
|
12
|
+
awareness?: Awareness
|
|
13
|
+
awarenessSubscribes: Set<string>
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
export type CuboCrdtClientDocOrigin = {
|
|
@@ -21,6 +25,9 @@ export type CuboCrdtClientDocOrigin = {
|
|
|
21
25
|
|
|
22
26
|
// куда раскатывать обновления - всем или всем кроме себя
|
|
23
27
|
expose?: CuboCrdtExposeStrategy
|
|
28
|
+
|
|
29
|
+
// подписка, из которой инициировано локальное изменение
|
|
30
|
+
subscribe_id?: string
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
export type CuboCrdtClientDocUpdateOptions = { subscribe_id: string; storeKey: string } & Pick<
|
|
@@ -4,7 +4,6 @@ import { Awareness } from 'y-protocols/awareness'
|
|
|
4
4
|
|
|
5
5
|
import { CuboCrdtAction, CuboCrdtListSyncData, CuboCrdtListTotals, CuboCrdtSubscriptionStatus } from '../../types'
|
|
6
6
|
|
|
7
|
-
import { Doc } from 'yjs'
|
|
8
7
|
import { CuboCrdtClientBaseOptions } from './utils'
|
|
9
8
|
|
|
10
9
|
export * from './document'
|
|
@@ -58,8 +57,6 @@ export type CuboCrdtClientList<T> = {
|
|
|
58
57
|
export type CuboCrdtClientRow<T> = {
|
|
59
58
|
subscribe_id: string
|
|
60
59
|
storeKey: string
|
|
61
|
-
doc: Doc
|
|
62
|
-
awareness?: ComputedRef<Awareness | undefined>
|
|
63
60
|
subscribe: () => void
|
|
64
61
|
upgrade: (filters?: any) => void
|
|
65
62
|
unsubscribe: (clear?: boolean) => void
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Reactive } from 'vue'
|
|
2
|
-
import { Awareness } from 'y-protocols/awareness'
|
|
3
2
|
|
|
4
3
|
import { CuboCrdtKey, CuboCrdtListTotals, CuboCrdtSubscriptionStatus } from '../../types'
|
|
5
4
|
|
|
@@ -13,7 +12,6 @@ export type CuboCrdtClientStoreItem<T> = {
|
|
|
13
12
|
totals: CuboCrdtListTotals
|
|
14
13
|
revision: number
|
|
15
14
|
}>
|
|
16
|
-
awarenesses: Map<number, Awareness>
|
|
17
15
|
}
|
|
18
16
|
export type CuboCrdtClientStore<M> = Partial<{
|
|
19
17
|
[K in string]: CuboCrdtClientStoreItem<CuboCrdtKey<K, M>>
|
|
@@ -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
|
|
|
@@ -19,31 +30,11 @@ export class CuboCrdtServerDocument {
|
|
|
19
30
|
})
|
|
20
31
|
|
|
21
32
|
if (opts.awareness) {
|
|
22
|
-
this.
|
|
23
|
-
this.awareness.setLocalState(null)
|
|
24
|
-
|
|
25
|
-
this.awareness.on('update', ({ added, updated, removed }, origin: CuboCrdtServerDocumentOrigin) => {
|
|
26
|
-
const subId = origin?.subscribe_id
|
|
27
|
-
|
|
28
|
-
if (subId) {
|
|
29
|
-
let set = this.awarenessBySubscribe.get(subId)
|
|
30
|
-
if (!set) {
|
|
31
|
-
set = new Set()
|
|
32
|
-
this.awarenessBySubscribe.set(subId, set)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
added.concat(updated).forEach((id) => set!.add(id))
|
|
36
|
-
removed.forEach((id) => set!.delete(id))
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const changed = added.concat(updated).concat(removed)
|
|
40
|
-
const update = encodeAwarenessUpdate(this.awareness!, changed)
|
|
41
|
-
|
|
42
|
-
this.opts?.onAwarenessUpdate?.(update, origin)
|
|
43
|
-
})
|
|
33
|
+
this.enableAwareness()
|
|
44
34
|
}
|
|
45
35
|
|
|
46
36
|
this.persistedState = cloneDeep(opts.initialState ?? {})
|
|
37
|
+
this.observedState = cloneDeep(opts.initialState ?? {})
|
|
47
38
|
|
|
48
39
|
// записываем исходное состояние
|
|
49
40
|
this.write(opts.initialState ?? {})
|
|
@@ -58,7 +49,8 @@ export class CuboCrdtServerDocument {
|
|
|
58
49
|
this.opts?.onUpdate?.(data, origin)
|
|
59
50
|
// }
|
|
60
51
|
|
|
61
|
-
//
|
|
52
|
+
// Состояние отслеживается синхронно с Y.Doc. Для клиентских изменений
|
|
53
|
+
// debounceStore сам обновит observedState после вычисления leaf-diff.
|
|
62
54
|
if (origin?.store) {
|
|
63
55
|
const row = this.getJson()
|
|
64
56
|
const body = pick(row, origin.keys ?? Object.keys(row))
|
|
@@ -68,10 +60,41 @@ export class CuboCrdtServerDocument {
|
|
|
68
60
|
console.error('[CRDT] store document', this.name, error)
|
|
69
61
|
})
|
|
70
62
|
}
|
|
63
|
+
} else {
|
|
64
|
+
this.observedState = cloneDeep(this.getJson())
|
|
71
65
|
}
|
|
72
66
|
})
|
|
73
67
|
}
|
|
74
68
|
|
|
69
|
+
public enableAwareness() {
|
|
70
|
+
if (this.awareness) {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.awareness = new Awareness(this.ydoc)
|
|
75
|
+
this.awareness.setLocalState(null)
|
|
76
|
+
|
|
77
|
+
this.awareness.on('update', ({ added, updated, removed }, origin: CuboCrdtServerDocumentOrigin) => {
|
|
78
|
+
const subId = origin?.subscribe_id
|
|
79
|
+
|
|
80
|
+
if (subId) {
|
|
81
|
+
let set = this.awarenessBySubscribe.get(subId)
|
|
82
|
+
if (!set) {
|
|
83
|
+
set = new Set()
|
|
84
|
+
this.awarenessBySubscribe.set(subId, set)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
added.concat(updated).forEach((id) => set!.add(id))
|
|
88
|
+
removed.forEach((id) => set!.delete(id))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const changed = added.concat(updated).concat(removed)
|
|
92
|
+
const update = encodeAwarenessUpdate(this.awareness!, changed)
|
|
93
|
+
|
|
94
|
+
this.opts?.onAwarenessUpdate?.(update, origin)
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
75
98
|
public get name() {
|
|
76
99
|
return this.opts.name
|
|
77
100
|
}
|
|
@@ -133,13 +156,138 @@ export class CuboCrdtServerDocument {
|
|
|
133
156
|
this.ydoc.destroy()
|
|
134
157
|
}
|
|
135
158
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
+
}
|
|
139
233
|
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
})
|
|
142
263
|
}
|
|
143
264
|
|
|
144
|
-
|
|
265
|
+
/** Перезапускает trailing debounce для текущего однородного пакета. */
|
|
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
|
+
}
|
|
145
293
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
36
36
|
private subscribeGenerations = new Map<string, number>()
|
|
37
37
|
|
|
38
38
|
private documents = new Map<string, CuboCrdtServerDocument>()
|
|
39
|
+
private documentEvictions = new Map<string, object>()
|
|
39
40
|
private subscribesByDocument = new Map<string, Set<string>>()
|
|
40
41
|
// обратный индекс: подписка -> имена документов, к которым она привязана.
|
|
41
42
|
// Нужен, чтобы отписка/очистка не сканировала ВСЕ документы сервера (O(subs x docs)).
|
|
@@ -107,34 +108,76 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
107
108
|
this.cleanSubscribe(subscribe_id)
|
|
108
109
|
})
|
|
109
110
|
|
|
110
|
-
this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, async ({ message }) => {
|
|
111
|
+
this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, async ({ client, message }) => {
|
|
111
112
|
let data = message.data as any
|
|
112
113
|
if (!Array.isArray(data)) {
|
|
113
114
|
data = [data]
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
if (this.debug) {
|
|
117
|
-
console.log('[CRDT] incoming event', data)
|
|
118
|
+
console.log('[CRDT] incoming event', data.map((row: any) => ({
|
|
119
|
+
action: row.action,
|
|
120
|
+
entity: row.entity,
|
|
121
|
+
entity_id: row.entity_id,
|
|
122
|
+
keys: row.origin?.keys,
|
|
123
|
+
subscribe_id: row.origin?.subscribe_id
|
|
124
|
+
})))
|
|
118
125
|
}
|
|
119
126
|
|
|
120
127
|
for (const row of data) {
|
|
128
|
+
const subscribe_id = row.origin?.subscribe_id
|
|
129
|
+
const subscribe = subscribe_id && this.subscribes.get(subscribe_id)
|
|
130
|
+
const documentName = `${row.entity}:${row.entity_id}`
|
|
131
|
+
const requiresActiveSubscription = row.action === 'update' || row.action === 'awareness'
|
|
132
|
+
|
|
133
|
+
if (
|
|
134
|
+
requiresActiveSubscription &&
|
|
135
|
+
(!subscribe ||
|
|
136
|
+
subscribe.client_id !== client.id ||
|
|
137
|
+
subscribe.entity !== row.entity ||
|
|
138
|
+
!this.documentsBySubscribe.get(subscribe_id)?.has(documentName))
|
|
139
|
+
) {
|
|
140
|
+
if (this.debug) {
|
|
141
|
+
console.warn('[CRDT] ignored update from inactive subscription', {
|
|
142
|
+
action: row.action,
|
|
143
|
+
entity: row.entity,
|
|
144
|
+
entity_id: row.entity_id,
|
|
145
|
+
subscribe_id
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
|
|
121
151
|
const update = new Uint8Array(row.data)
|
|
152
|
+
const origin: CuboCrdtServerDocumentOrigin = {
|
|
153
|
+
...(row.origin || {}),
|
|
154
|
+
client_id: client.id,
|
|
155
|
+
store_context: {
|
|
156
|
+
client_id: client.id,
|
|
157
|
+
auth: cloneDeep(client.auth)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const action = { ...row, origin }
|
|
122
161
|
|
|
123
162
|
switch (row.action) {
|
|
124
163
|
case 'create':
|
|
125
|
-
this.onDocumentExternalCreate(update,
|
|
164
|
+
this.onDocumentExternalCreate(update, action)
|
|
126
165
|
break
|
|
127
166
|
case 'update':
|
|
128
|
-
this.onDocumentExternalUpdate(update,
|
|
167
|
+
this.onDocumentExternalUpdate(update, action)
|
|
129
168
|
break
|
|
130
169
|
case 'delete':
|
|
131
|
-
this.onDocumentExternalDelete(update,
|
|
170
|
+
this.onDocumentExternalDelete(update, action)
|
|
132
171
|
break
|
|
133
172
|
case 'awareness':
|
|
134
|
-
this.onAwarenessExternalUpdate(update,
|
|
173
|
+
this.onAwarenessExternalUpdate(update, action)
|
|
135
174
|
break
|
|
136
175
|
}
|
|
137
176
|
}
|
|
177
|
+
|
|
178
|
+
// Ответ означает только «update применён к живому серверному Y.Doc».
|
|
179
|
+
// Debounce и последовательная запись в БД продолжаются независимо.
|
|
180
|
+
return { accepted: true }
|
|
138
181
|
})
|
|
139
182
|
|
|
140
183
|
this.ws.registerHttpHandler('GET', '/stats', () => {
|
|
@@ -170,8 +213,20 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
170
213
|
this.subscribeRefreshStates.forEach((state) => clearTimeout(state.timer))
|
|
171
214
|
this.subscribeRefreshStates.clear()
|
|
172
215
|
this.subscribeGenerations.clear()
|
|
216
|
+
this.documentEvictions.clear()
|
|
173
217
|
|
|
174
218
|
this.subscribes.forEach((sub) => this.ws.deleteHandler(sub.id))
|
|
219
|
+
|
|
220
|
+
const documents = [...this.documents.values()]
|
|
221
|
+
const results = await Promise.allSettled(documents.map((document) => document.flushStoreQueue()))
|
|
222
|
+
results.forEach((result, index) => {
|
|
223
|
+
if (result.status === 'rejected') {
|
|
224
|
+
console.error('[CRDT] flush document on destroy', documents[index]?.name, result.reason)
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
documents.forEach((document) => document.destroy())
|
|
228
|
+
this.documents.clear()
|
|
229
|
+
this.subscribesByDocument.clear()
|
|
175
230
|
}
|
|
176
231
|
|
|
177
232
|
public addClient(client: WsServerSocket<{ auth?: A }>) {
|
|
@@ -282,6 +337,10 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
282
337
|
|
|
283
338
|
// привязываем подписку к документу (в обоих индексах)
|
|
284
339
|
private linkSubscribeToDocument(subscribe_id: string, docName: string) {
|
|
340
|
+
// Новая подписка снова использует живой Y.Doc. Асинхронный flush может
|
|
341
|
+
// продолжаться, но больше не имеет права уничтожить этот документ.
|
|
342
|
+
this.documentEvictions.delete(docName)
|
|
343
|
+
|
|
285
344
|
let byDoc = this.subscribesByDocument.get(docName)
|
|
286
345
|
if (!byDoc) {
|
|
287
346
|
byDoc = new Set()
|
|
@@ -305,17 +364,56 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
305
364
|
|
|
306
365
|
// если у документа нет подписок - сносим его
|
|
307
366
|
private checkDocumentNeedRemove(name: string) {
|
|
308
|
-
if (
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
367
|
+
if (this.subscribesByDocument.get(name)?.size || this.documentEvictions.has(name)) {
|
|
368
|
+
return
|
|
369
|
+
}
|
|
312
370
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
// не оставляем пустой Set в индексе (иначе — медленная утечка записей мапы);
|
|
316
|
-
// getOrCreateDocument пересоздаст его при повторном появлении документа
|
|
371
|
+
const document = this.documents.get(name)
|
|
372
|
+
if (!document) {
|
|
317
373
|
this.subscribesByDocument.delete(name)
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Последний клиент мог закрыться сразу после update (обычный refresh).
|
|
378
|
+
// Сначала сохраняем всю принятую очередь, а до её завершения оставляем Y.Doc
|
|
379
|
+
// доступным повторной подписке. Никакого фиксированного ожидания здесь нет.
|
|
380
|
+
const eviction = {}
|
|
381
|
+
this.documentEvictions.set(name, eviction)
|
|
382
|
+
|
|
383
|
+
if (this.debug) {
|
|
384
|
+
console.log('[CRDT] flush document before eviction', name)
|
|
318
385
|
}
|
|
386
|
+
|
|
387
|
+
void document.flushStoreQueue()
|
|
388
|
+
.then(() => {
|
|
389
|
+
if (
|
|
390
|
+
this.documentEvictions.get(name) !== eviction ||
|
|
391
|
+
this.subscribesByDocument.get(name)?.size ||
|
|
392
|
+
this.documents.get(name) !== document
|
|
393
|
+
) {
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
this.documentEvictions.delete(name)
|
|
398
|
+
|
|
399
|
+
if (this.debug) {
|
|
400
|
+
console.log('[CRDT] delete document', name)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
document.destroy()
|
|
404
|
+
this.documents.delete(name)
|
|
405
|
+
this.subscribesByDocument.delete(name)
|
|
406
|
+
})
|
|
407
|
+
.catch((error) => {
|
|
408
|
+
// Не уничтожаем единственную живую копию документа, если запись в БД
|
|
409
|
+
// не завершилась. Снимаем eviction, чтобы следующая отписка или явная
|
|
410
|
+
// проверка могла повторно инициировать flush.
|
|
411
|
+
if (this.documentEvictions.get(name) === eviction) {
|
|
412
|
+
this.documentEvictions.delete(name)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
console.error('[CRDT] flush document before eviction', name, error)
|
|
416
|
+
})
|
|
319
417
|
}
|
|
320
418
|
|
|
321
419
|
// очистка подписки (при отписке клиента - через emit-метод или options-хук).
|
|
@@ -584,20 +682,9 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
584
682
|
|
|
585
683
|
let document = this.documents.get(documentName)
|
|
586
684
|
if (document) {
|
|
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)
|
|
685
|
+
if (awareness) {
|
|
686
|
+
document.enableAwareness()
|
|
687
|
+
}
|
|
601
688
|
|
|
602
689
|
return document
|
|
603
690
|
}
|
|
@@ -614,7 +701,13 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
614
701
|
},
|
|
615
702
|
onStore: (item: object, origin: CuboCrdtServerDocumentOrigin, store) => {
|
|
616
703
|
const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
|
|
617
|
-
const
|
|
704
|
+
const liveClient = subscribe && this.clients.get(subscribe.client_id)
|
|
705
|
+
const client = liveClient || (origin.store_context
|
|
706
|
+
? ({
|
|
707
|
+
id: origin.store_context.client_id,
|
|
708
|
+
auth: origin.store_context.auth
|
|
709
|
+
} as CuboCrdtSocketClient<A>)
|
|
710
|
+
: undefined)
|
|
618
711
|
|
|
619
712
|
if (client) {
|
|
620
713
|
return this.options?.storeRow?.(entity as any, entity_id, item as any, {
|
|
@@ -650,7 +743,13 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
650
743
|
const existed = this.documents.has(documentName)
|
|
651
744
|
const document = this.getOrCreateDocument(entity, row, awareness)
|
|
652
745
|
|
|
653
|
-
if (!
|
|
746
|
+
if (existed && !document.hasPendingStore()) {
|
|
747
|
+
// Только внешний CRUD является подтверждённым источником из БД.
|
|
748
|
+
// Обычная повторная подписка не должна применять потенциально старую строку
|
|
749
|
+
// поверх живого документа с ещё не сохранённым collaborative update.
|
|
750
|
+
document.write(row, { expose: 'all', store: false })
|
|
751
|
+
document.setPersistedState(row)
|
|
752
|
+
} else {
|
|
654
753
|
this.pushDocumentAction('create', entity, document, document.stateAsUpdate)
|
|
655
754
|
}
|
|
656
755
|
|
|
@@ -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
|
+
}
|