@cuboapp/crdt 1.0.9 → 1.0.11
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 +5 -4
- package/src/client/index.ts +125 -43
- package/src/client/queue/index.ts +153 -0
- package/src/client/types/document.ts +3 -1
- package/src/client/types/index.ts +4 -0
- package/src/client/types/store.ts +2 -0
- package/src/server/document/index.ts +29 -0
- package/src/server/index.ts +75 -4
- package/src/server/types/document.ts +2 -1
- package/src/server/types/subscribe.ts +2 -1
- package/src/types/index.ts +1 -1
- package/src/utils/index.ts +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cuboapp/crdt",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.11",
|
|
4
4
|
"description": "CRDT for CUBO",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"repository": "git@github.com:cuboapp/crdt.git",
|
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
"@cuboapp/utils": "1.0.10",
|
|
16
16
|
"@cuboapp/ws": "1.0.6",
|
|
17
17
|
"ws": "^8.19.0",
|
|
18
|
-
"
|
|
18
|
+
"y-protocols": "^1.0.7",
|
|
19
|
+
"yjs": "^13.6.30"
|
|
19
20
|
},
|
|
20
21
|
"peerDependencies": {
|
|
21
|
-
"vue": "^3.5.
|
|
22
|
-
"vue-router": "^
|
|
22
|
+
"vue": "^3.5.30",
|
|
23
|
+
"vue-router": "^5.0.2"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
25
26
|
"@types/node": "^24.10.1",
|
package/src/client/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { keyBy, pick, uuid } from '@cuboapp/utils'
|
|
2
2
|
import { computed, reactive } from 'vue'
|
|
3
3
|
import { applyUpdate, Doc } from 'yjs'
|
|
4
|
+
import { applyAwarenessUpdate, Awareness, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'
|
|
4
5
|
|
|
5
6
|
import { CUBO_CRDT_EVENT } from '../constants'
|
|
6
7
|
import { CuboCrdtServerDocumentIncomingAction } from '../server'
|
|
@@ -16,13 +17,19 @@ import {
|
|
|
16
17
|
CuboCrdtClientSubscribeEvent,
|
|
17
18
|
CuboCrdtClientUseOptions
|
|
18
19
|
} from './types'
|
|
20
|
+
import { AsyncSerialQueue } from './queue'
|
|
19
21
|
|
|
20
22
|
export * from './types'
|
|
21
23
|
|
|
22
24
|
export class CuboCrdtClient<M> {
|
|
23
25
|
public store: CuboCrdtClientStore<M> = {}
|
|
24
26
|
|
|
25
|
-
private listeners: Map<string, Map<string, (ctx: CuboCrdtClientSubscribeEvent) => void
|
|
27
|
+
private listeners: Map<string, Map<string, (ctx: CuboCrdtClientSubscribeEvent) => void | Promise<void>>> = new Map()
|
|
28
|
+
private eventQueue = new AsyncSerialQueue({
|
|
29
|
+
errorMode: 'continue',
|
|
30
|
+
onError: (e) => console.error('[CRDT] inbound queue task failed', e),
|
|
31
|
+
maxSize: 10_000
|
|
32
|
+
})
|
|
26
33
|
|
|
27
34
|
constructor(private opts: CuboCrdtClientOptions<M>) {
|
|
28
35
|
opts.entities.forEach((e) => this.listeners.set(e, new Map()))
|
|
@@ -30,30 +37,33 @@ export class CuboCrdtClient<M> {
|
|
|
30
37
|
|
|
31
38
|
async start() {
|
|
32
39
|
this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, ({ message }) => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
this.eventQueue
|
|
41
|
+
.enqueue(async () => {
|
|
42
|
+
let events: CuboCrdtClientSubscribeEvent[] = message.data as any
|
|
43
|
+
if (!Array.isArray(events)) events = [events as any]
|
|
44
|
+
|
|
45
|
+
for (const event of events) {
|
|
46
|
+
const listeners = this.listeners.get(event.entity)
|
|
47
|
+
|
|
48
|
+
if (this.debug) {
|
|
49
|
+
console.log('[CRDT] incoming event', { listeners: listeners?.size, event })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (event.subscribe_id) {
|
|
53
|
+
const listener = listeners?.get(event.subscribe_id)
|
|
54
|
+
if (!listener) {
|
|
55
|
+
console.warn('[CRDT] listener not found:', event.subscribe_id, event)
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
await listener(event)
|
|
59
|
+
} else {
|
|
60
|
+
for (const cb of listeners?.values() ?? []) {
|
|
61
|
+
await cb(event)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
52
64
|
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
})
|
|
65
|
+
})
|
|
66
|
+
.catch((e) => console.error('[CRDT] enqueue failed', e))
|
|
57
67
|
})
|
|
58
68
|
}
|
|
59
69
|
|
|
@@ -91,7 +101,8 @@ export class CuboCrdtClient<M> {
|
|
|
91
101
|
|
|
92
102
|
this.store[storeKey] = {
|
|
93
103
|
state,
|
|
94
|
-
docs: new Map()
|
|
104
|
+
docs: new Map(),
|
|
105
|
+
awarenesses: new Map()
|
|
95
106
|
}
|
|
96
107
|
}
|
|
97
108
|
|
|
@@ -111,17 +122,24 @@ export class CuboCrdtClient<M> {
|
|
|
111
122
|
}
|
|
112
123
|
|
|
113
124
|
// подписываемся на фронте
|
|
114
|
-
this.listeners.get(entity)?.set(subscribe_id, (ctx) => {
|
|
115
|
-
this.onIncomingUpdate(ctx, {
|
|
125
|
+
this.listeners.get(entity)?.set(subscribe_id, async (ctx) => {
|
|
126
|
+
await this.onIncomingUpdate(ctx, {
|
|
116
127
|
subscribe_id,
|
|
117
128
|
storeKey,
|
|
118
|
-
...(pick(opts || {}, [
|
|
119
|
-
|
|
129
|
+
...(pick(opts || {}, [
|
|
130
|
+
'onBeforeCreate',
|
|
131
|
+
'onBeforeUpdate',
|
|
132
|
+
'onBeforeDelete',
|
|
133
|
+
'onAfterCreate',
|
|
134
|
+
'onAfterUpdate',
|
|
135
|
+
'onAfterDelete',
|
|
136
|
+
'awareness'
|
|
137
|
+
]) || {})
|
|
120
138
|
})
|
|
121
139
|
})
|
|
122
140
|
|
|
123
141
|
// подписываемся на бэке
|
|
124
|
-
this.ws.request({ method: CUBO_CRDT_EVENT.SUBSCRIBE, data: { subscribe_id, entity, filters } })
|
|
142
|
+
this.ws.request({ method: CUBO_CRDT_EVENT.SUBSCRIBE, data: { subscribe_id, entity, filters, awareness: opts?.awareness } })
|
|
125
143
|
}
|
|
126
144
|
|
|
127
145
|
// отписка
|
|
@@ -138,6 +156,19 @@ export class CuboCrdtClient<M> {
|
|
|
138
156
|
|
|
139
157
|
// очищаем стор
|
|
140
158
|
if (clear !== false) {
|
|
159
|
+
const item = this.store[storeKey]
|
|
160
|
+
if (item) {
|
|
161
|
+
item.awarenesses?.forEach((a) => {
|
|
162
|
+
removeAwarenessStates(a, [a.doc.clientID], 'unsubscribe')
|
|
163
|
+
|
|
164
|
+
a.setLocalState(null)
|
|
165
|
+
a.destroy()
|
|
166
|
+
})
|
|
167
|
+
item.awarenesses?.clear()
|
|
168
|
+
item.docs?.forEach((d) => d.destroy())
|
|
169
|
+
item.docs?.clear()
|
|
170
|
+
}
|
|
171
|
+
|
|
141
172
|
this.store[storeKey] = undefined
|
|
142
173
|
}
|
|
143
174
|
}
|
|
@@ -160,6 +191,7 @@ export class CuboCrdtClient<M> {
|
|
|
160
191
|
upgrade,
|
|
161
192
|
unsubscribe,
|
|
162
193
|
docs: computed(() => this.store[storeKey]?.docs),
|
|
194
|
+
awarenesses: opts?.awareness ? computed(() => this.store[storeKey]?.awarenesses) : undefined,
|
|
163
195
|
subscribed: () => computed(() => this.store[storeKey]?.state.subscribed || false),
|
|
164
196
|
rows: () => computed(() => this.store[storeKey]?.state.rows as T[]),
|
|
165
197
|
rowsById: () => computed(() => keyBy(this.store[storeKey]?.state.rows as T[], 'id' as keyof T))
|
|
@@ -173,7 +205,7 @@ export class CuboCrdtClient<M> {
|
|
|
173
205
|
): CuboCrdtClientRow<T> {
|
|
174
206
|
// console.log('store key', opts?.storeKey ?? `${entity}:${id}`)
|
|
175
207
|
|
|
176
|
-
const { subscribe_id, storeKey, docs, subscribe, upgrade, unsubscribe, subscribed } = this.useList(entity, {
|
|
208
|
+
const { subscribe_id, storeKey, docs, awarenesses, subscribe, upgrade, unsubscribe, subscribed } = this.useList(entity, {
|
|
177
209
|
...opts,
|
|
178
210
|
filters: opts?.filters ?? { id },
|
|
179
211
|
storeKey: opts?.storeKey ?? `${entity}:${id}`
|
|
@@ -183,6 +215,7 @@ export class CuboCrdtClient<M> {
|
|
|
183
215
|
subscribe_id,
|
|
184
216
|
storeKey,
|
|
185
217
|
doc: computed(() => docs.value?.get(id)),
|
|
218
|
+
awareness: opts?.awareness ? computed(() => awarenesses?.value?.get(id)) : undefined,
|
|
186
219
|
subscribe,
|
|
187
220
|
upgrade,
|
|
188
221
|
unsubscribe,
|
|
@@ -310,6 +343,37 @@ export class CuboCrdtClient<M> {
|
|
|
310
343
|
if (opts?.onAfterCreate) {
|
|
311
344
|
await opts.onAfterCreate(doc, update, ctx, opts)
|
|
312
345
|
}
|
|
346
|
+
|
|
347
|
+
if (opts?.awareness) {
|
|
348
|
+
let awareness = this.store[opts.storeKey]?.awarenesses.get(ctx.entity_id)
|
|
349
|
+
|
|
350
|
+
if (!awareness) {
|
|
351
|
+
awareness = new Awareness(doc)
|
|
352
|
+
|
|
353
|
+
awareness.on('update', ({ added, updated, removed }, origin) => {
|
|
354
|
+
console.log('[CRDT] awarness update', origin)
|
|
355
|
+
if (origin === 'remote') {
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const changed = added.concat(updated).concat(removed)
|
|
360
|
+
const update = encodeAwarenessUpdate(awareness, changed)
|
|
361
|
+
|
|
362
|
+
this.ws.request({
|
|
363
|
+
method: CUBO_CRDT_EVENT.EVENT,
|
|
364
|
+
data: {
|
|
365
|
+
action: 'awareness',
|
|
366
|
+
entity: ctx.entity,
|
|
367
|
+
entity_id: ctx.entity_id,
|
|
368
|
+
data: Array.from(update),
|
|
369
|
+
origin: { subscribe_id: opts.subscribe_id }
|
|
370
|
+
}
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
this.store[opts.storeKey]?.awarenesses.set(ctx.entity_id, awareness)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
313
377
|
}
|
|
314
378
|
|
|
315
379
|
private async onDocumentUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
|
|
@@ -377,31 +441,49 @@ export class CuboCrdtClient<M> {
|
|
|
377
441
|
}
|
|
378
442
|
}
|
|
379
443
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
444
|
+
const awareness = this.store[opts.storeKey]?.awarenesses.get(ctx.entity_id)
|
|
445
|
+
if (awareness) {
|
|
446
|
+
removeAwarenessStates(awareness, [awareness.doc.clientID], 'unsubscribe')
|
|
384
447
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
console.log('[CRDT] call event listener', ctx, opts)
|
|
448
|
+
awareness.destroy()
|
|
449
|
+
this.store[opts.storeKey]?.awarenesses?.delete(ctx.entity_id)
|
|
388
450
|
}
|
|
389
451
|
|
|
390
|
-
if (
|
|
391
|
-
|
|
452
|
+
if (opts?.onAfterDelete) {
|
|
453
|
+
await opts.onAfterDelete(doc, ctx, opts)
|
|
392
454
|
}
|
|
455
|
+
}
|
|
393
456
|
|
|
457
|
+
private async onIncomingUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
|
|
394
458
|
switch (ctx.action) {
|
|
395
459
|
case 'create':
|
|
396
|
-
this.onDocumentCreate(ctx, opts)
|
|
460
|
+
await this.onDocumentCreate(ctx, opts)
|
|
397
461
|
break
|
|
398
462
|
case 'update':
|
|
399
463
|
case 'upsert':
|
|
400
|
-
this.onDocumentUpdate(ctx, opts)
|
|
464
|
+
await this.onDocumentUpdate(ctx, opts)
|
|
401
465
|
break
|
|
402
466
|
case 'delete':
|
|
403
|
-
this.onDocumentDelete(ctx, opts)
|
|
467
|
+
await this.onDocumentDelete(ctx, opts)
|
|
404
468
|
break
|
|
469
|
+
case 'awareness':
|
|
470
|
+
await this.onAwarenessUpdate(ctx, opts)
|
|
471
|
+
break
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private onAwarenessUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
|
|
476
|
+
const awareness = this.store[opts.storeKey]?.awarenesses?.get(ctx.entity_id)
|
|
477
|
+
if (!awareness) {
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const update = new Uint8Array(ctx.data as any)
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
applyAwarenessUpdate(awareness, update, 'remote')
|
|
485
|
+
} catch (e) {
|
|
486
|
+
console.warn('[CRDT] applyAwarenessUpdate failed ', e)
|
|
405
487
|
}
|
|
406
488
|
}
|
|
407
489
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
type QueueErrorMode = 'continue' | 'stop'
|
|
2
|
+
|
|
3
|
+
export interface AsyncQueueOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Что делать, если задача упала:
|
|
6
|
+
* - continue: логируем/пробрасываем через onError и идём дальше
|
|
7
|
+
* - stop: останавливаем очередь (следующие задачи не стартуют)
|
|
8
|
+
*/
|
|
9
|
+
errorMode?: QueueErrorMode
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Хук на ошибки задач (если errorMode=continue — обязательно пригодится)
|
|
13
|
+
*/
|
|
14
|
+
onError?: (err: unknown) => void
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Максимальный размер очереди (для защиты от утечек, опционально)
|
|
18
|
+
*/
|
|
19
|
+
maxSize?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class AsyncSerialQueue {
|
|
23
|
+
private running = false
|
|
24
|
+
private stopped = false
|
|
25
|
+
|
|
26
|
+
private readonly errorMode: QueueErrorMode
|
|
27
|
+
private readonly onError?: (err: unknown) => void
|
|
28
|
+
private readonly maxSize?: number
|
|
29
|
+
|
|
30
|
+
private tasks: Array<{
|
|
31
|
+
run: () => Promise<void>
|
|
32
|
+
resolve: () => void
|
|
33
|
+
reject: (e: unknown) => void
|
|
34
|
+
}> = []
|
|
35
|
+
|
|
36
|
+
private drainWaiters: Array<() => void> = []
|
|
37
|
+
|
|
38
|
+
constructor(opts: AsyncQueueOptions = {}) {
|
|
39
|
+
this.errorMode = opts.errorMode ?? 'continue'
|
|
40
|
+
this.onError = opts.onError
|
|
41
|
+
this.maxSize = opts.maxSize
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get size() {
|
|
45
|
+
return this.tasks.length
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get isRunning() {
|
|
49
|
+
return this.running
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get isStopped() {
|
|
53
|
+
return this.stopped
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Добавляет задачу в очередь.
|
|
58
|
+
* Возвращает промис, который резолвится когда задача реально выполнена.
|
|
59
|
+
*/
|
|
60
|
+
enqueue(fn: () => void | Promise<void>): Promise<void> {
|
|
61
|
+
if (this.stopped) {
|
|
62
|
+
return Promise.reject(new Error('Queue is stopped'))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (this.maxSize !== undefined && this.tasks.length >= this.maxSize) {
|
|
66
|
+
return Promise.reject(new Error(`Queue overflow: maxSize=${this.maxSize}`))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return new Promise<void>((resolve, reject) => {
|
|
70
|
+
this.tasks.push({
|
|
71
|
+
run: async () => {
|
|
72
|
+
await fn()
|
|
73
|
+
},
|
|
74
|
+
resolve,
|
|
75
|
+
reject
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
void this.pump()
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Ждёт, пока очередь станет пустой и ничего не выполняется.
|
|
84
|
+
*/
|
|
85
|
+
async drain(): Promise<void> {
|
|
86
|
+
if (!this.running && this.tasks.length === 0) return
|
|
87
|
+
await new Promise<void>((res) => this.drainWaiters.push(res))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Очищает очередь (не прерывая текущую выполняемую задачу).
|
|
92
|
+
* Невыполненные задачи получат reject.
|
|
93
|
+
*/
|
|
94
|
+
clear(reason: unknown = new Error('Queue cleared')): void {
|
|
95
|
+
const pending = this.tasks
|
|
96
|
+
this.tasks = []
|
|
97
|
+
for (const t of pending) t.reject(reason)
|
|
98
|
+
this.notifyDrainedIfNeeded()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Останавливает очередь: новые enqueue будут отвергнуты.
|
|
103
|
+
* Можно опционально очистить pending.
|
|
104
|
+
*/
|
|
105
|
+
stop(opts: { clear?: boolean; reason?: unknown } = {}): void {
|
|
106
|
+
this.stopped = true
|
|
107
|
+
if (opts.clear) this.clear(opts.reason ?? new Error('Queue stopped'))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Разрешает снова enqueue (не запускает автоматически).
|
|
112
|
+
*/
|
|
113
|
+
resume(): void {
|
|
114
|
+
this.stopped = false
|
|
115
|
+
void this.pump()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async pump(): Promise<void> {
|
|
119
|
+
if (this.running) return
|
|
120
|
+
this.running = true
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
while (!this.stopped) {
|
|
124
|
+
const task = this.tasks.shift()
|
|
125
|
+
if (!task) break
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await task.run()
|
|
129
|
+
task.resolve()
|
|
130
|
+
} catch (e) {
|
|
131
|
+
this.onError?.(e)
|
|
132
|
+
task.reject(e)
|
|
133
|
+
|
|
134
|
+
if (this.errorMode === 'stop') {
|
|
135
|
+
this.stopped = true
|
|
136
|
+
break
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} finally {
|
|
141
|
+
this.running = false
|
|
142
|
+
this.notifyDrainedIfNeeded()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private notifyDrainedIfNeeded() {
|
|
147
|
+
if (!this.running && this.tasks.length === 0 && this.drainWaiters.length) {
|
|
148
|
+
const waiters = this.drainWaiters
|
|
149
|
+
this.drainWaiters = []
|
|
150
|
+
for (const w of waiters) w()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CuboCrdtClientUseOptions } from '..'
|
|
1
2
|
import { CuboCrdtExposeStrategy } from '../../types'
|
|
2
3
|
|
|
3
4
|
import { CuboCrdtClientBaseOptions } from './utils'
|
|
@@ -19,4 +20,5 @@ export type CuboCrdtClientDocOrigin = {
|
|
|
19
20
|
export type CuboCrdtClientDocUpdateOptions = { subscribe_id: string; storeKey: string } & Pick<
|
|
20
21
|
CuboCrdtClientBaseOptions,
|
|
21
22
|
'onBeforeCreate' | 'onAfterCreate' | 'onBeforeUpdate' | 'onAfterUpdate' | 'onBeforeDelete' | 'onAfterDelete'
|
|
22
|
-
>
|
|
23
|
+
> &
|
|
24
|
+
Pick<CuboCrdtClientUseOptions, 'awareness'>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type WsClient } from '@cuboapp/ws'
|
|
2
2
|
import { ComputedRef } from 'vue'
|
|
3
|
+
import { Awareness } from 'y-protocols/awareness'
|
|
3
4
|
import { type Doc } from 'yjs'
|
|
4
5
|
|
|
5
6
|
import { CuboCrdtAction } from '../../types'
|
|
@@ -19,6 +20,7 @@ export type CuboCrdtClientOptions<M> = {
|
|
|
19
20
|
export type CuboCrdtClientUseOptions = CuboCrdtClientBaseOptions & {
|
|
20
21
|
storeKey?: string
|
|
21
22
|
filters?: Record<string, any>
|
|
23
|
+
awareness?: boolean
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export type CuboCrdtClientSubscribeEvent = {
|
|
@@ -33,6 +35,7 @@ export type CuboCrdtClientList<T> = {
|
|
|
33
35
|
subscribe_id: string
|
|
34
36
|
storeKey: string
|
|
35
37
|
docs: ComputedRef<Map<number, Doc> | undefined>
|
|
38
|
+
awarenesses?: ComputedRef<Map<number, Awareness> | undefined>
|
|
36
39
|
subscribe: (filters?: any) => void
|
|
37
40
|
upgrade: (filters?: any) => void
|
|
38
41
|
unsubscribe: (clear?: boolean) => void
|
|
@@ -45,6 +48,7 @@ export type CuboCrdtClientRow<T> = {
|
|
|
45
48
|
subscribe_id: string
|
|
46
49
|
storeKey: string
|
|
47
50
|
doc: ComputedRef<Doc | undefined>
|
|
51
|
+
awareness?: ComputedRef<Awareness | undefined>
|
|
48
52
|
subscribe: () => void
|
|
49
53
|
upgrade: (filters?: any) => void
|
|
50
54
|
unsubscribe: (clear?: boolean) => void
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Reactive } from 'vue'
|
|
2
2
|
import { Doc } from 'yjs'
|
|
3
|
+
import { Awareness } from 'y-protocols/awareness'
|
|
3
4
|
|
|
4
5
|
import { CuboCrdtKey } from '../../types'
|
|
5
6
|
|
|
@@ -9,6 +10,7 @@ export type CuboCrdtClientStoreItem<T> = {
|
|
|
9
10
|
subscribed: boolean
|
|
10
11
|
}>
|
|
11
12
|
docs: Map<number, Doc>
|
|
13
|
+
awarenesses: Map<number, Awareness>
|
|
12
14
|
}
|
|
13
15
|
export type CuboCrdtClientStore<M> = Partial<{
|
|
14
16
|
[K in string]: CuboCrdtClientStoreItem<CuboCrdtKey<K, M>>
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { debounce, pick } from '@cuboapp/utils'
|
|
2
2
|
import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs'
|
|
3
|
+
import { Awareness, encodeAwarenessUpdate } from 'y-protocols/awareness'
|
|
3
4
|
|
|
4
5
|
import { CuboCrdtServerDocumentOptions, CuboCrdtServerDocumentOrigin } from '../types'
|
|
5
6
|
|
|
6
7
|
export class CuboCrdtServerDocument {
|
|
7
8
|
private ydoc: Doc
|
|
9
|
+
public awareness: Awareness
|
|
10
|
+
public awarenessBySubscribe = new Map<string, Set<number>>()
|
|
8
11
|
|
|
9
12
|
// private firstUpdate = false
|
|
10
13
|
|
|
@@ -14,6 +17,31 @@ export class CuboCrdtServerDocument {
|
|
|
14
17
|
autoLoad: false
|
|
15
18
|
})
|
|
16
19
|
|
|
20
|
+
if (opts.awareness) {
|
|
21
|
+
this.awareness = new Awareness(this.ydoc)
|
|
22
|
+
this.awareness.setLocalState(null)
|
|
23
|
+
|
|
24
|
+
this.awareness.on('update', ({ added, updated, removed }, origin: CuboCrdtServerDocumentOrigin) => {
|
|
25
|
+
const subId = origin?.subscribe_id
|
|
26
|
+
|
|
27
|
+
if (subId) {
|
|
28
|
+
let set = this.awarenessBySubscribe.get(subId)
|
|
29
|
+
if (!set) {
|
|
30
|
+
set = new Set()
|
|
31
|
+
this.awarenessBySubscribe.set(subId, set)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
added.concat(updated).forEach((id) => set!.add(id))
|
|
35
|
+
removed.forEach((id) => set!.delete(id))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const changed = added.concat(updated).concat(removed)
|
|
39
|
+
const update = encodeAwarenessUpdate(this.awareness!, changed)
|
|
40
|
+
|
|
41
|
+
this.opts?.onAwarenessUpdate?.(update, origin)
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
17
45
|
// записываем исходное состояние
|
|
18
46
|
this.write(opts.initialState ?? {})
|
|
19
47
|
|
|
@@ -78,6 +106,7 @@ export class CuboCrdtServerDocument {
|
|
|
78
106
|
}
|
|
79
107
|
|
|
80
108
|
destroy() {
|
|
109
|
+
this.awareness?.destroy()
|
|
81
110
|
this.ydoc.destroy()
|
|
82
111
|
}
|
|
83
112
|
|
package/src/server/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
CuboCrdtServerUnsubscribeDto,
|
|
17
17
|
CuboCrdtSocketClient
|
|
18
18
|
} from './types'
|
|
19
|
+
import { applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'
|
|
19
20
|
|
|
20
21
|
export * from './document'
|
|
21
22
|
export * from './types'
|
|
@@ -40,7 +41,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
40
41
|
this.entities.forEach((e) => this.subscribesByEntity.set(e, new Set()))
|
|
41
42
|
|
|
42
43
|
this.ws.registerHandler(CUBO_CRDT_EVENT.SUBSCRIBE, async ({ client, message }) => {
|
|
43
|
-
const { subscribe_id: id, entity, filters } = message.data as CuboCrdtServerSubscribeDto
|
|
44
|
+
const { subscribe_id: id, entity, filters, awareness = false } = message.data as CuboCrdtServerSubscribeDto
|
|
44
45
|
|
|
45
46
|
if (this.debug) {
|
|
46
47
|
console.log('[CRDT] subscribe', entity, filters)
|
|
@@ -50,7 +51,8 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
50
51
|
id,
|
|
51
52
|
client_id: client.id,
|
|
52
53
|
entity,
|
|
53
|
-
filters
|
|
54
|
+
filters,
|
|
55
|
+
awareness
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
this.subscribes.set(id, subscribe)
|
|
@@ -109,6 +111,10 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
109
111
|
break
|
|
110
112
|
case 'delete':
|
|
111
113
|
this.onDocumentExternalDelete(update, row)
|
|
114
|
+
break
|
|
115
|
+
case 'awareness':
|
|
116
|
+
this.onAwarenessExternalUpdate(update, row)
|
|
117
|
+
|
|
112
118
|
break
|
|
113
119
|
}
|
|
114
120
|
})
|
|
@@ -173,6 +179,9 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
173
179
|
|
|
174
180
|
private sendQueueToClient(client: CuboCrdtSocketClient<A>) {
|
|
175
181
|
const queue = this.queue.get(client.id)
|
|
182
|
+
if (!queue) {
|
|
183
|
+
return
|
|
184
|
+
}
|
|
176
185
|
|
|
177
186
|
// const data = Array.from(queue)
|
|
178
187
|
const data = [...new Map((Array.from(queue) || []).map((item) => [JSON.stringify(item), item])).values()]
|
|
@@ -234,6 +243,29 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
234
243
|
console.log('[CRDT] unsubscribe', subscribe.entity, subscribe.filters)
|
|
235
244
|
}
|
|
236
245
|
|
|
246
|
+
if (subscribe.awareness) {
|
|
247
|
+
//убираем аварнесс стейт при отписке
|
|
248
|
+
this.subscribesByDocument.forEach((subs, docName) => {
|
|
249
|
+
if (!subs.has(subscribe_id)) {
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const doc = this.documents.get(docName)
|
|
254
|
+
if (!doc?.awareness) {
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const ids = Array.from(doc.awarenessBySubscribe.get(subscribe_id) || [])
|
|
259
|
+
if (!ids.length) {
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
removeAwarenessStates(doc.awareness, ids, { subscribe_id, expose: 'all' })
|
|
264
|
+
|
|
265
|
+
doc.awarenessBySubscribe.delete(subscribe_id)
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
237
269
|
// удаляем подписку по сущности
|
|
238
270
|
this.subscribesByEntity.get(subscribe.entity as E)?.delete(subscribe.id)
|
|
239
271
|
|
|
@@ -270,7 +302,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
270
302
|
.map((m) => m[1])
|
|
271
303
|
}
|
|
272
304
|
|
|
273
|
-
public ensureDocument(entity: E, row: any) {
|
|
305
|
+
public ensureDocument(entity: E, row: any, awareness?: boolean) {
|
|
274
306
|
// todo: врапнуть всё это в Promise
|
|
275
307
|
const entity_id = row.id
|
|
276
308
|
const documentName = `${entity}:${entity_id}`
|
|
@@ -281,12 +313,19 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
281
313
|
document = new CuboCrdtServerDocument({
|
|
282
314
|
name: documentName,
|
|
283
315
|
initialState: row,
|
|
316
|
+
awareness: awareness,
|
|
284
317
|
onUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
|
|
285
318
|
// пушим обновление документа
|
|
286
319
|
// console.log('push document update', document.name)
|
|
287
320
|
|
|
288
321
|
this.pushDocumentAction('update', entity, document!, data, origin)
|
|
289
322
|
},
|
|
323
|
+
onAwarenessUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
|
|
324
|
+
// пушим обновление документа
|
|
325
|
+
// console.log('push document update', document.name)
|
|
326
|
+
|
|
327
|
+
this.pushDocumentAction('awareness', entity, document!, data, origin)
|
|
328
|
+
},
|
|
290
329
|
onStore: (item: object, origin: CuboCrdtServerDocumentOrigin) => {
|
|
291
330
|
const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
|
|
292
331
|
const client = subscribe && this.clients.get(subscribe.client_id)
|
|
@@ -353,6 +392,22 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
353
392
|
}
|
|
354
393
|
}
|
|
355
394
|
|
|
395
|
+
private onAwarenessExternalUpdate(update: Uint8Array, action: CuboCrdtServerDocumentIncomingAction) {
|
|
396
|
+
const document = this.getDocument(action.entity, action.entity_id)
|
|
397
|
+
|
|
398
|
+
if (!document?.awareness) {
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
applyAwarenessUpdate(document.awareness, update, action.origin)
|
|
404
|
+
} catch (e) {
|
|
405
|
+
if (this.debug) {
|
|
406
|
+
console.warn('[CRDT] applyAwarenessUpdate failed', e)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
356
411
|
private onDocumentExternalDelete(update: Uint8Array, action: CuboCrdtServerDocumentIncomingAction) {
|
|
357
412
|
console.warn('[CRDT] onDocumentExternalDelete not implemented')
|
|
358
413
|
}
|
|
@@ -481,6 +536,22 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
481
536
|
this.checkDocumentNeedRemove(document.name)
|
|
482
537
|
break
|
|
483
538
|
}
|
|
539
|
+
|
|
540
|
+
if (action === 'create' && subscribe.awareness && document.awareness) {
|
|
541
|
+
const ids = Array.from(document.awareness.getStates().keys())
|
|
542
|
+
|
|
543
|
+
if (ids.length) {
|
|
544
|
+
const aUpdate = encodeAwarenessUpdate(document.awareness, ids)
|
|
545
|
+
|
|
546
|
+
this.sendToClient(subscribe.client_id, {
|
|
547
|
+
action: 'awareness',
|
|
548
|
+
entity: subscribe.entity,
|
|
549
|
+
entity_id: row.id,
|
|
550
|
+
data: Array.from(aUpdate),
|
|
551
|
+
subscribe_id: subscribe.id
|
|
552
|
+
})
|
|
553
|
+
}
|
|
554
|
+
}
|
|
484
555
|
})
|
|
485
556
|
}
|
|
486
557
|
}
|
|
@@ -492,7 +563,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
492
563
|
// пушим их в сокеты
|
|
493
564
|
for (const row of rows || []) {
|
|
494
565
|
// this.ensureDocument(subscribe.entity as E, row, { subscribes_ids: [subscribe.id] })
|
|
495
|
-
this.ensureDocument(subscribe.entity as E, row)
|
|
566
|
+
this.ensureDocument(subscribe.entity as E, row, subscribe.awareness)
|
|
496
567
|
}
|
|
497
568
|
}
|
|
498
569
|
|
|
@@ -5,10 +5,11 @@ export type CuboCrdtServerDocumentOptions = {
|
|
|
5
5
|
onStore?: (body: object, origin: CuboCrdtServerDocumentOrigin) => void
|
|
6
6
|
// onCreate?: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => void
|
|
7
7
|
onUpdate?: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => void
|
|
8
|
+
onAwarenessUpdate?: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => void
|
|
8
9
|
// onInit?: (document: CuboCrdtServerDocument) => void
|
|
9
10
|
|
|
10
11
|
initialState?: object
|
|
11
|
-
|
|
12
|
+
awareness?: boolean
|
|
12
13
|
yjsOptions?: {
|
|
13
14
|
guid?: string
|
|
14
15
|
collectionid?: string
|
|
@@ -7,9 +7,10 @@ export type CuboCrdtServerSubscribeDto = {
|
|
|
7
7
|
filters: {
|
|
8
8
|
[K in 'id' | string]: any
|
|
9
9
|
}
|
|
10
|
+
awareness: boolean
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export type CuboCrdtServerSubscribe = {
|
|
13
14
|
id: string
|
|
14
15
|
client_id: string
|
|
15
|
-
} & Pick<CuboCrdtServerSubscribeDto, 'entity' | 'filters'>
|
|
16
|
+
} & Pick<CuboCrdtServerSubscribeDto, 'entity' | 'filters' | 'awareness'>
|
package/src/types/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type CuboCrdtKey<K, M> = K extends Extract<keyof M, string> ? M[K] : any
|
|
2
2
|
|
|
3
|
-
export type CuboCrdtAction = 'create' | 'upsert' | 'update' | 'delete'
|
|
3
|
+
export type CuboCrdtAction = 'create' | 'upsert' | 'update' | 'delete' | 'awareness'
|
|
4
4
|
|
|
5
5
|
export type CuboCrdtExposeStrategy = 'all' | 'other' | 'subscribe' | 'client'
|
package/src/utils/index.ts
CHANGED
|
@@ -54,9 +54,12 @@ export function checkRowIsSutable(
|
|
|
54
54
|
} else {
|
|
55
55
|
const [formula, str] = `${value}`.split(':')
|
|
56
56
|
|
|
57
|
-
// console.log({ formula, str, value: row[key] })
|
|
58
|
-
|
|
59
57
|
if (!str) {
|
|
58
|
+
const maybeNum = Number(value)
|
|
59
|
+
if (!isNaN(maybeNum)) {
|
|
60
|
+
value = maybeNum
|
|
61
|
+
}
|
|
62
|
+
|
|
60
63
|
if (row[key as keyof typeof row] !== value) {
|
|
61
64
|
sutable = false
|
|
62
65
|
}
|