@cuboapp/crdt 1.0.24 → 1.0.26
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 +139 -138
- 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,
|
|
@@ -437,54 +425,34 @@ export class CuboCrdtClient<M> {
|
|
|
437
425
|
}
|
|
438
426
|
}
|
|
439
427
|
|
|
440
|
-
public update<K extends string
|
|
428
|
+
public update<K extends Extract<keyof M, string>>(
|
|
441
429
|
entity: K,
|
|
442
430
|
entity_id: number,
|
|
443
431
|
dto: K extends Extract<keyof M, string> ? Partial<M[K]> : object,
|
|
444
432
|
opts?: CuboCrdtClientDocOrigin
|
|
445
433
|
) {
|
|
446
|
-
const
|
|
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)
|
|
434
|
+
const document = this.docs.get(`${entity}:${entity_id}`)
|
|
451
435
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
// "или" - потому что они синхронизированы (без разницы какой брать)
|
|
457
|
-
const row = (cardRow || listRow) as any
|
|
458
|
-
const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => row[key] !== value))
|
|
436
|
+
if (document) {
|
|
437
|
+
const map = document.doc.getMap()
|
|
438
|
+
const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => map.get(key) !== value))
|
|
459
439
|
|
|
460
440
|
if (Object.keys(toUpdate).length) {
|
|
461
441
|
const origin: CuboCrdtClientDocOrigin = {
|
|
462
442
|
store: opts?.store ?? true,
|
|
463
443
|
expose: opts?.expose ?? 'other',
|
|
464
444
|
keys: opts?.keys ?? Object.keys(toUpdate),
|
|
465
|
-
react: true
|
|
445
|
+
react: true,
|
|
446
|
+
subscribe_id: opts?.subscribe_id ?? document.subscribes.values().next().value
|
|
466
447
|
}
|
|
467
448
|
|
|
468
|
-
|
|
449
|
+
document.doc.transact(() => {
|
|
469
450
|
Object.entries(toUpdate).forEach(([key, value]) => {
|
|
470
451
|
map.set(key, value)
|
|
471
452
|
})
|
|
472
453
|
}, origin)
|
|
473
454
|
}
|
|
474
455
|
}
|
|
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
456
|
}
|
|
489
457
|
|
|
490
458
|
public upgrade(subscribe_id: string, filters?: Record<string, any>) {
|
|
@@ -528,6 +496,80 @@ export class CuboCrdtClient<M> {
|
|
|
528
496
|
})
|
|
529
497
|
}
|
|
530
498
|
|
|
499
|
+
private applyDocumentRows(entity_id: number, document: CuboCrdtClientDoc) {
|
|
500
|
+
for (const subscribe_id of document.subscribes) {
|
|
501
|
+
const subscription = this.subscriptions.get(subscribe_id)
|
|
502
|
+
if (subscription) {
|
|
503
|
+
this.applyRowJson(subscription.storeKey, entity_id, document.doc)
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private attachDocument(entity: string, entity_id: number, document: CuboCrdtClientDoc, opts: CuboCrdtClientDocUpdateOptions) {
|
|
509
|
+
document.subscribes.add(opts.subscribe_id)
|
|
510
|
+
|
|
511
|
+
if (!opts.awareness) {
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
document.awarenessSubscribes.add(opts.subscribe_id)
|
|
516
|
+
|
|
517
|
+
if (!document.awareness) {
|
|
518
|
+
const awareness = new Awareness(document.doc)
|
|
519
|
+
|
|
520
|
+
awareness.on('update', ({ added, updated, removed }, origin) => {
|
|
521
|
+
if (origin === 'remote') {
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const subscribe_id = document.awarenessSubscribes.values().next().value
|
|
526
|
+
if (!subscribe_id) {
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const changed = added.concat(updated).concat(removed)
|
|
531
|
+
const update = encodeAwarenessUpdate(awareness, changed)
|
|
532
|
+
|
|
533
|
+
void this.ws
|
|
534
|
+
.request({
|
|
535
|
+
method: CUBO_CRDT_EVENT.EVENT,
|
|
536
|
+
data: {
|
|
537
|
+
action: 'awareness',
|
|
538
|
+
entity,
|
|
539
|
+
entity_id,
|
|
540
|
+
data: Array.from(update),
|
|
541
|
+
origin: { expose: 'other', subscribe_id }
|
|
542
|
+
}
|
|
543
|
+
})
|
|
544
|
+
.catch((error) => console.error('[CRDT] awareness request failed', error))
|
|
545
|
+
})
|
|
546
|
+
document.awareness = awareness
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private detachDocument(entity: string, entity_id: number, subscribe_id: string, storeKey: string) {
|
|
551
|
+
const key = `${entity}:${entity_id}`
|
|
552
|
+
const document = this.docs.get(key)
|
|
553
|
+
if (!document) {
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
document.subscribes.delete(subscribe_id)
|
|
558
|
+
document.awarenessSubscribes.delete(subscribe_id)
|
|
559
|
+
|
|
560
|
+
if (!document.awarenessSubscribes.size && document.awareness) {
|
|
561
|
+
removeAwarenessStates(document.awareness, [document.awareness.doc.clientID], 'unsubscribe')
|
|
562
|
+
document.awareness.setLocalState(null)
|
|
563
|
+
document.awareness.destroy()
|
|
564
|
+
document.awareness = undefined
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (!document.subscribes.size) {
|
|
568
|
+
document.doc.destroy()
|
|
569
|
+
this.docs.delete(key)
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
531
573
|
// апсертит строку в реактивное хранилище из текущего состояния yjs-документа
|
|
532
574
|
private applyRowJson(storeKey: string, entity_id: number, doc: Doc) {
|
|
533
575
|
const json: any = doc.getMap().toJSON()
|
|
@@ -559,6 +601,7 @@ export class CuboCrdtClient<M> {
|
|
|
559
601
|
console.log('[CRDT] onDocumentCreate: doc already exists, merging state', { ctx, opts })
|
|
560
602
|
}
|
|
561
603
|
|
|
604
|
+
this.attachDocument(ctx.entity, entity_id, existing, opts)
|
|
562
605
|
applyUpdate(existing.doc, new Uint8Array(ctx.data as any), { react: false })
|
|
563
606
|
this.applyRowJson(opts.storeKey, entity_id, existing.doc)
|
|
564
607
|
return
|
|
@@ -577,8 +620,20 @@ export class CuboCrdtClient<M> {
|
|
|
577
620
|
|
|
578
621
|
applyUpdate(yjsDoc, update, { react: false })
|
|
579
622
|
|
|
623
|
+
const document: CuboCrdtClientDoc = {
|
|
624
|
+
doc: yjsDoc,
|
|
625
|
+
subscribes: new Set(),
|
|
626
|
+
awarenessSubscribes: new Set()
|
|
627
|
+
}
|
|
628
|
+
this.attachDocument(ctx.entity, entity_id, document, opts)
|
|
629
|
+
// Публикуем контейнер реактивному реестру только после полной инициализации.
|
|
630
|
+
// Тогда первый computed(useDoc) не увидит промежуточный doc без Awareness.
|
|
631
|
+
this.docs.set(`${ctx.entity}:${ctx.entity_id}`, document)
|
|
632
|
+
|
|
580
633
|
// подписываемся на обновления документа
|
|
581
634
|
yjsDoc.on('update', (update, origin: CuboCrdtClientDocOrigin) => {
|
|
635
|
+
this.applyDocumentRows(entity_id, document)
|
|
636
|
+
|
|
582
637
|
// react = false, если это апдейт с бэка
|
|
583
638
|
if (origin?.react !== false) {
|
|
584
639
|
const data: CuboCrdtServerDocumentIncomingAction = {
|
|
@@ -592,21 +647,19 @@ export class CuboCrdtClient<M> {
|
|
|
592
647
|
// приходят без expose и сервер эхом шлёт их обратно самому автору.
|
|
593
648
|
expose: 'other',
|
|
594
649
|
...pick(origin || {}, ['store', 'keys', 'expose']),
|
|
595
|
-
subscribe_id:
|
|
650
|
+
subscribe_id: origin?.subscribe_id ?? document.subscribes.values().next().value
|
|
596
651
|
}
|
|
597
652
|
}
|
|
598
653
|
|
|
599
|
-
this.ws
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
654
|
+
void this.ws
|
|
655
|
+
.request({
|
|
656
|
+
method: CUBO_CRDT_EVENT.EVENT,
|
|
657
|
+
data
|
|
658
|
+
})
|
|
659
|
+
.catch((error) => console.error('[CRDT] update request failed', error))
|
|
603
660
|
}
|
|
604
661
|
})
|
|
605
662
|
|
|
606
|
-
// добавляем документ в хранилище документов
|
|
607
|
-
const doc: CuboCrdtClientDoc = { doc: yjsDoc, subscribes: new Set() }
|
|
608
|
-
this.docs.set(`${ctx.entity}:${ctx.entity_id}`, doc)
|
|
609
|
-
|
|
610
663
|
// добавляем документ в реактивное хранилище
|
|
611
664
|
const json: any = yjsDoc.getMap().toJSON()
|
|
612
665
|
const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === entity_id)
|
|
@@ -623,39 +676,6 @@ export class CuboCrdtClient<M> {
|
|
|
623
676
|
if (opts?.onAfterCreate) {
|
|
624
677
|
await opts.onAfterCreate(yjsDoc, update, ctx, opts)
|
|
625
678
|
}
|
|
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
679
|
}
|
|
660
680
|
|
|
661
681
|
private async onDocumentUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
|
|
@@ -682,8 +702,8 @@ export class CuboCrdtClient<M> {
|
|
|
682
702
|
// обновляем yjs-ный документ
|
|
683
703
|
applyUpdate(doc.doc, update, { react: false })
|
|
684
704
|
|
|
685
|
-
//
|
|
686
|
-
this.
|
|
705
|
+
// один канонический документ обновляет проекции всех активных подписок
|
|
706
|
+
this.applyDocumentRows(entity_id, doc)
|
|
687
707
|
|
|
688
708
|
if (opts?.onAfterUpdate) {
|
|
689
709
|
await opts.onAfterUpdate(doc.doc, update, ctx, opts)
|
|
@@ -709,25 +729,18 @@ export class CuboCrdtClient<M> {
|
|
|
709
729
|
}
|
|
710
730
|
}
|
|
711
731
|
|
|
712
|
-
doc.
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
} else {
|
|
719
|
-
if (this.debug) {
|
|
720
|
-
console.log('[CRDT] row not found', index, ctx, opts)
|
|
732
|
+
for (const subscribe_id of doc.subscribes) {
|
|
733
|
+
const subscription = this.subscriptions.get(subscribe_id)
|
|
734
|
+
const item = subscription && this.store[subscription.storeKey]
|
|
735
|
+
const index = item?.state.rows.findIndex((row: any) => row.id === entity_id) ?? -1
|
|
736
|
+
if (item && index >= 0) {
|
|
737
|
+
item.state.rows.splice(index, 1)
|
|
721
738
|
}
|
|
722
739
|
}
|
|
723
740
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
awareness.destroy()
|
|
729
|
-
this.store[opts.storeKey]?.awarenesses?.delete(entity_id)
|
|
730
|
-
}
|
|
741
|
+
doc.awareness?.destroy()
|
|
742
|
+
doc.doc.destroy()
|
|
743
|
+
this.docs.delete(`${ctx.entity}:${ctx.entity_id}`)
|
|
731
744
|
|
|
732
745
|
if (opts?.onAfterDelete) {
|
|
733
746
|
await opts.onAfterDelete(doc.doc, ctx, opts)
|
|
@@ -772,19 +785,7 @@ export class CuboCrdtClient<M> {
|
|
|
772
785
|
continue
|
|
773
786
|
}
|
|
774
787
|
|
|
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
|
-
}
|
|
788
|
+
this.detachDocument(ctx.entity, entity_id, opts.subscribe_id, opts.storeKey)
|
|
788
789
|
}
|
|
789
790
|
|
|
790
791
|
const rowsById = new Map(item.state.rows.map((row: any) => [Number(row.id), row]))
|
|
@@ -805,15 +806,15 @@ export class CuboCrdtClient<M> {
|
|
|
805
806
|
return
|
|
806
807
|
}
|
|
807
808
|
|
|
808
|
-
const
|
|
809
|
-
if (!awareness) {
|
|
809
|
+
const doc = this.docs.get(`${ctx.entity}:${ctx.entity_id}`)
|
|
810
|
+
if (!doc?.awareness) {
|
|
810
811
|
return
|
|
811
812
|
}
|
|
812
813
|
|
|
813
814
|
const update = new Uint8Array(ctx.data as any)
|
|
814
815
|
|
|
815
816
|
try {
|
|
816
|
-
applyAwarenessUpdate(awareness, update, 'remote')
|
|
817
|
+
applyAwarenessUpdate(doc.awareness, update, 'remote')
|
|
817
818
|
} catch (e) {
|
|
818
819
|
console.warn('[CRDT] applyAwarenessUpdate failed ', e)
|
|
819
820
|
}
|
|
@@ -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
|
+
}
|