@cuboapp/crdt 1.0.13 → 1.0.14
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 +16 -11
- package/src/client/index.ts +108 -19
- package/src/server/document/index.ts +1 -1
- package/src/server/index.ts +44 -24
- package/src/server/types/index.ts +9 -0
- package/.prettierrc +0 -8
- package/tsconfig.build.json +0 -4
- package/tsconfig.json +0 -22
package/package.json
CHANGED
|
@@ -1,30 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cuboapp/crdt",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.14",
|
|
4
4
|
"description": "CRDT for CUBO",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
|
-
"repository":
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git@cuboapp.gitlab.yandexcloud.net:cubo/crdt.git"
|
|
9
|
+
},
|
|
7
10
|
"author": "CuboSoft",
|
|
8
11
|
"license": "MIT",
|
|
9
12
|
"type": "module",
|
|
10
13
|
"publishConfig": {
|
|
11
14
|
"access": "public"
|
|
12
15
|
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
13
19
|
"dependencies": {
|
|
14
|
-
"@cuboapp/types": "^2.0.
|
|
15
|
-
"@cuboapp/utils": "1.0.
|
|
16
|
-
"@cuboapp/ws": "1.0.
|
|
17
|
-
"ws": "^8.19.0",
|
|
20
|
+
"@cuboapp/types": "^2.0.14",
|
|
21
|
+
"@cuboapp/utils": "1.0.12",
|
|
22
|
+
"@cuboapp/ws": "1.0.9",
|
|
18
23
|
"y-protocols": "^1.0.7",
|
|
19
|
-
"yjs": "^13.6.
|
|
24
|
+
"yjs": "^13.6.31"
|
|
20
25
|
},
|
|
21
26
|
"peerDependencies": {
|
|
22
|
-
"vue": "^3.5.
|
|
23
|
-
"vue-router": "^5.0
|
|
27
|
+
"vue": "^3.5.38",
|
|
28
|
+
"vue-router": "^5.1.0"
|
|
24
29
|
},
|
|
25
30
|
"devDependencies": {
|
|
26
|
-
"@types/node": "^
|
|
31
|
+
"@types/node": "^26.0.0",
|
|
27
32
|
"@types/ws": "^8.18.1",
|
|
28
|
-
"typescript": "^
|
|
33
|
+
"typescript": "^6.0.3"
|
|
29
34
|
}
|
|
30
35
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { keyBy, pick, uuid } from '@cuboapp/utils'
|
|
2
|
+
import { WsClientEvent } from '@cuboapp/ws'
|
|
2
3
|
import { computed, reactive } from 'vue'
|
|
3
4
|
import { applyAwarenessUpdate, Awareness, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'
|
|
4
5
|
import { applyUpdate, Doc } from 'yjs'
|
|
@@ -25,6 +26,12 @@ export class CuboCrdtClient<M> {
|
|
|
25
26
|
public store: CuboCrdtClientStore<M> = {}
|
|
26
27
|
|
|
27
28
|
private listeners: Map<string, Map<string, (ctx: CuboCrdtClientSubscribeEvent) => void | Promise<void>>> = new Map()
|
|
29
|
+
|
|
30
|
+
// реестр активных подписок — нужен, чтобы переподписаться после реконнекта
|
|
31
|
+
private subscriptions = new Map<string, { entity: string; filters?: Record<string, any>; awareness?: boolean }>()
|
|
32
|
+
private connectedOnce = false
|
|
33
|
+
private onReconnect = () => this.resubscribeAll()
|
|
34
|
+
|
|
28
35
|
private eventQueue = new AsyncSerialQueue({
|
|
29
36
|
errorMode: 'continue',
|
|
30
37
|
onError: (e) => console.error('[CRDT] inbound queue task failed', e),
|
|
@@ -36,6 +43,13 @@ export class CuboCrdtClient<M> {
|
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
async start() {
|
|
46
|
+
// если сокет уже подключён к моменту старта — считаем текущий коннект «первым»,
|
|
47
|
+
// чтобы первое же событие CONNECTED (после обрыва) сработало как переподписка
|
|
48
|
+
this.connectedOnce = this.ws.connected
|
|
49
|
+
|
|
50
|
+
// переподписываемся на все документы при (ре)коннекте сокета
|
|
51
|
+
this.ws.ee.on(WsClientEvent.CONNECTED, this.onReconnect)
|
|
52
|
+
|
|
39
53
|
this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, ({ message }) => {
|
|
40
54
|
this.eventQueue
|
|
41
55
|
.enqueue(async () => {
|
|
@@ -68,9 +82,45 @@ export class CuboCrdtClient<M> {
|
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
async stop() {
|
|
85
|
+
this.ws.ee.off(WsClientEvent.CONNECTED, this.onReconnect)
|
|
86
|
+
|
|
71
87
|
this.ws.deleteHandler(CUBO_CRDT_EVENT.EVENT)
|
|
72
88
|
|
|
73
89
|
this.listeners.clear()
|
|
90
|
+
this.subscriptions.clear()
|
|
91
|
+
this.connectedOnce = false
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Переподписка на все активные подписки после реконнекта.
|
|
96
|
+
*
|
|
97
|
+
* При обрыве сокета бэкенд удаляет клиента вместе со всеми его подписками
|
|
98
|
+
* (removeClient -> cleanSubscribe), а после реконнекта сокету выдаётся новый id.
|
|
99
|
+
* Локальные слушатели (this.listeners) и yjs-документы при этом сохраняются,
|
|
100
|
+
* поэтому достаточно заново отправить SUBSCRIBE по реестру — заново
|
|
101
|
+
* навешивать слушатели не нужно.
|
|
102
|
+
*/
|
|
103
|
+
private resubscribeAll() {
|
|
104
|
+
// первый коннект — useList() уже сам отправил SUBSCRIBE, дублировать не нужно
|
|
105
|
+
if (!this.connectedOnce) {
|
|
106
|
+
this.connectedOnce = true
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (this.debug) {
|
|
111
|
+
console.log('[CRDT] reconnected, resubscribing', { count: this.subscriptions.size })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const [subscribe_id, sub] of this.subscriptions) {
|
|
115
|
+
try {
|
|
116
|
+
this.ws.request({
|
|
117
|
+
method: CUBO_CRDT_EVENT.SUBSCRIBE,
|
|
118
|
+
data: { subscribe_id, entity: sub.entity, filters: sub.filters, awareness: sub.awareness }
|
|
119
|
+
})
|
|
120
|
+
} catch (e) {
|
|
121
|
+
console.error('[CRDT] resubscribe failed', subscribe_id, e)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
74
124
|
}
|
|
75
125
|
|
|
76
126
|
public useComputedList<K extends Extract<keyof M, string>>(entity: K) {
|
|
@@ -138,8 +188,17 @@ export class CuboCrdtClient<M> {
|
|
|
138
188
|
})
|
|
139
189
|
})
|
|
140
190
|
|
|
141
|
-
//
|
|
142
|
-
this.
|
|
191
|
+
// регистрируем подписку, чтобы переподписаться после реконнекта
|
|
192
|
+
this.subscriptions.set(subscribe_id, { entity: `${entity}`, filters, awareness: opts?.awareness })
|
|
193
|
+
|
|
194
|
+
// подписываемся на бэке (если сокет ещё не подключён — переподпишемся по событию CONNECTED)
|
|
195
|
+
try {
|
|
196
|
+
this.ws.request({ method: CUBO_CRDT_EVENT.SUBSCRIBE, data: { subscribe_id, entity, filters, awareness: opts?.awareness } })
|
|
197
|
+
} catch (e) {
|
|
198
|
+
if (this.debug) {
|
|
199
|
+
console.warn('[CRDT] subscribe deferred until reconnect', subscribe_id, e)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
143
202
|
}
|
|
144
203
|
|
|
145
204
|
// отписка
|
|
@@ -148,8 +207,17 @@ export class CuboCrdtClient<M> {
|
|
|
148
207
|
console.log('[CRDT] unsubscribe', { entity, opts, filters })
|
|
149
208
|
}
|
|
150
209
|
|
|
210
|
+
// снимаем подписку из реестра переподписки
|
|
211
|
+
this.subscriptions.delete(subscribe_id)
|
|
212
|
+
|
|
151
213
|
// отписываемся на бэке
|
|
152
|
-
|
|
214
|
+
try {
|
|
215
|
+
this.ws.request({ method: CUBO_CRDT_EVENT.UNSUBSCRIBE, data: { subscribe_id } })
|
|
216
|
+
} catch (e) {
|
|
217
|
+
if (this.debug) {
|
|
218
|
+
console.warn('[CRDT] unsubscribe request failed (socket down)', subscribe_id, e)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
153
221
|
|
|
154
222
|
// отписываемся на фронте
|
|
155
223
|
this.listeners.get(entity)?.delete(subscribe_id)
|
|
@@ -281,17 +349,48 @@ export class CuboCrdtClient<M> {
|
|
|
281
349
|
}
|
|
282
350
|
|
|
283
351
|
public upgrade(subscribe_id: string, filters?: Record<string, any>) {
|
|
352
|
+
// держим реестр в актуальном состоянии, чтобы переподписка после реконнекта
|
|
353
|
+
// использовала уже обновлённые фильтры
|
|
354
|
+
const sub = this.subscriptions.get(subscribe_id)
|
|
355
|
+
if (sub) {
|
|
356
|
+
sub.filters = filters
|
|
357
|
+
}
|
|
358
|
+
|
|
284
359
|
this.ws.request({ method: CUBO_CRDT_EVENT.UPGRADE, data: { subscribe_id, filters } })
|
|
285
360
|
}
|
|
286
361
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
362
|
+
// апсертит строку в реактивное хранилище из текущего состояния yjs-документа
|
|
363
|
+
private applyRowJson(storeKey: string, entity_id: number, doc: Doc) {
|
|
364
|
+
const json: any = doc.getMap().toJSON()
|
|
365
|
+
const rows = this.store[storeKey]?.state.rows
|
|
366
|
+
if (!rows) {
|
|
291
367
|
return
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const index = rows.findIndex((r: any) => r.id === entity_id)
|
|
371
|
+
if (index >= 0) {
|
|
372
|
+
rows[index] = json
|
|
292
373
|
} else {
|
|
293
|
-
|
|
374
|
+
rows.push(json)
|
|
294
375
|
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private async onDocumentCreate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
|
|
379
|
+
const existing = this.store[opts.storeKey]?.docs.get(ctx.entity_id)
|
|
380
|
+
if (existing) {
|
|
381
|
+
// документ уже есть локально (типичный кейс — повторный SUBSCRIBE после реконнекта).
|
|
382
|
+
// вместо игнорирования вмёрживаем входящее состояние в существующий yjs-документ
|
|
383
|
+
// (CRDT-merge), сохраняя локальные слушатели и awareness.
|
|
384
|
+
if (this.debug) {
|
|
385
|
+
console.log('[CRDT] onDocumentCreate: doc already exists, merging state', { ctx, opts })
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
applyUpdate(existing, new Uint8Array(ctx.data as any), { react: false })
|
|
389
|
+
this.applyRowJson(opts.storeKey, ctx.entity_id, existing)
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const doc = new Doc()
|
|
295
394
|
|
|
296
395
|
const update = new Uint8Array(ctx.data as any)
|
|
297
396
|
|
|
@@ -399,17 +498,7 @@ export class CuboCrdtClient<M> {
|
|
|
399
498
|
applyUpdate(doc, update, { react: false })
|
|
400
499
|
|
|
401
500
|
// обновляем документ в реактивном хранилище
|
|
402
|
-
|
|
403
|
-
const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === ctx.entity_id)
|
|
404
|
-
if (index !== undefined && index >= 0) {
|
|
405
|
-
this.store[opts.storeKey]!.state.rows[index] = json
|
|
406
|
-
} else {
|
|
407
|
-
if (this.debug) {
|
|
408
|
-
console.warn('[CRDT] onDocumentUpdate - row not exists: "' + ctx.entity_id + '"', { ctx, opts })
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
this.store[opts.storeKey]!.state.rows.push(json)
|
|
412
|
-
}
|
|
501
|
+
this.applyRowJson(opts.storeKey, ctx.entity_id, doc)
|
|
413
502
|
|
|
414
503
|
if (opts?.onAfterUpdate) {
|
|
415
504
|
await opts.onAfterUpdate(doc, update, ctx, opts)
|
package/src/server/index.ts
CHANGED
|
@@ -169,6 +169,10 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
169
169
|
// удаляем мапу подписок по клиенту
|
|
170
170
|
this.subscribesByClient.delete(client.id)
|
|
171
171
|
|
|
172
|
+
// снимаем запланированный флаш очереди (чтобы не слать в закрытый сокет)
|
|
173
|
+
clearTimeout(this.sendTimeouts.get(client.id))
|
|
174
|
+
this.sendTimeouts.delete(client.id)
|
|
175
|
+
|
|
172
176
|
// удаляем очередь под клиента
|
|
173
177
|
this.queue.delete(client.id)
|
|
174
178
|
|
|
@@ -176,14 +180,26 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
176
180
|
this.clients.delete(client.id)
|
|
177
181
|
}
|
|
178
182
|
|
|
183
|
+
private get batchMaxSize() {
|
|
184
|
+
return this.options.batch?.maxSize ?? 50
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private get batchDebounce() {
|
|
188
|
+
return this.options.batch?.debounce ?? 100
|
|
189
|
+
}
|
|
190
|
+
|
|
179
191
|
private sendQueueToClient(client: CuboCrdtSocketClient<A>) {
|
|
192
|
+
// снимаем запланированный флаш — мы отправляем прямо сейчас
|
|
193
|
+
clearTimeout(this.sendTimeouts.get(client.id))
|
|
194
|
+
this.sendTimeouts.delete(client.id)
|
|
195
|
+
|
|
180
196
|
const queue = this.queue.get(client.id)
|
|
181
|
-
if (!queue) {
|
|
197
|
+
if (!queue || queue.size === 0) {
|
|
182
198
|
return
|
|
183
199
|
}
|
|
184
200
|
|
|
185
|
-
//
|
|
186
|
-
const data = [...new Map(
|
|
201
|
+
// дедупликация одинаковых событий внутри батча
|
|
202
|
+
const data = [...new Map(Array.from(queue).map((item) => [JSON.stringify(item), item])).values()]
|
|
187
203
|
|
|
188
204
|
queue.clear()
|
|
189
205
|
|
|
@@ -193,8 +209,6 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
193
209
|
data
|
|
194
210
|
})
|
|
195
211
|
)
|
|
196
|
-
|
|
197
|
-
queue.clear()
|
|
198
212
|
}
|
|
199
213
|
|
|
200
214
|
public sendToClient(client_id: string, data: any) {
|
|
@@ -203,21 +217,26 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
203
217
|
return
|
|
204
218
|
}
|
|
205
219
|
|
|
206
|
-
|
|
207
|
-
|
|
220
|
+
const queue = this.queue.get(client_id)
|
|
221
|
+
if (!queue) {
|
|
222
|
+
return
|
|
223
|
+
}
|
|
208
224
|
|
|
209
225
|
// добавляем в очередь
|
|
210
|
-
|
|
226
|
+
queue.add(data)
|
|
211
227
|
|
|
212
|
-
// если
|
|
213
|
-
if (
|
|
228
|
+
// если у ЭТОГО клиента в очереди накопилось много событий — отправляем сразу
|
|
229
|
+
if (queue.size >= this.batchMaxSize) {
|
|
214
230
|
this.sendQueueToClient(client)
|
|
231
|
+
return
|
|
215
232
|
}
|
|
216
|
-
|
|
217
|
-
|
|
233
|
+
|
|
234
|
+
// иначе — дебаунсим отправку, но НЕ сбрасываем уже запланированный флаш,
|
|
235
|
+
// иначе при непрерывном потоке апдейтов очередь никогда не отправится
|
|
236
|
+
if (!this.sendTimeouts.has(client_id)) {
|
|
218
237
|
this.sendTimeouts.set(
|
|
219
238
|
client_id,
|
|
220
|
-
setTimeout(() => this.sendQueueToClient(client),
|
|
239
|
+
setTimeout(() => this.sendQueueToClient(client), this.batchDebounce)
|
|
221
240
|
)
|
|
222
241
|
}
|
|
223
242
|
}
|
|
@@ -287,18 +306,19 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
287
306
|
}
|
|
288
307
|
|
|
289
308
|
public getSutableSubscribes(entity: string, row: any) {
|
|
290
|
-
return Array.from(this.subscribesByEntity.get(entity as E) || [])
|
|
291
|
-
.
|
|
292
|
-
|
|
309
|
+
return Array.from(this.subscribesByEntity.get(entity as E) || []).filter((subscribe_id) => {
|
|
310
|
+
const subscribe = this.subscribes.get(subscribe_id)
|
|
311
|
+
if (!subscribe) {
|
|
312
|
+
return false
|
|
313
|
+
}
|
|
293
314
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
315
|
+
let state = checkRowIsSutable(row, subscribe.filters || {})
|
|
316
|
+
if (this.options.checkRowIsSutable) {
|
|
317
|
+
state = this.options.checkRowIsSutable(state, { entity: entity as any, subscribe, row })
|
|
318
|
+
}
|
|
298
319
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
.map((m) => m[1])
|
|
320
|
+
return state
|
|
321
|
+
})
|
|
302
322
|
}
|
|
303
323
|
|
|
304
324
|
public ensureDocument(entity: E, row: any, awareness?: boolean) {
|
|
@@ -445,7 +465,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
445
465
|
const row = document.getJson()
|
|
446
466
|
|
|
447
467
|
// берём все подписки по сущности
|
|
448
|
-
const sutableSubscribes = Array.from(this.subscribesByEntity.get(entity))
|
|
468
|
+
const sutableSubscribes = Array.from(this.subscribesByEntity.get(entity) || [])
|
|
449
469
|
.map((subscribe_id) => {
|
|
450
470
|
const subscribe = this.subscribes.get(subscribe_id)
|
|
451
471
|
|
|
@@ -10,6 +10,15 @@ export type CuboCrdtServerOptions<M, A, E extends Extract<keyof M, string>> = {
|
|
|
10
10
|
ws: WsServer
|
|
11
11
|
entities: E[]
|
|
12
12
|
debug?: boolean
|
|
13
|
+
/**
|
|
14
|
+
* Параметры батчинга исходящих событий (защита от перегрузки бэка).
|
|
15
|
+
* - maxSize: при достижении такого размера очереди клиента батч отправляется сразу
|
|
16
|
+
* - debounce: задержка перед отправкой неполного батча (мс)
|
|
17
|
+
*/
|
|
18
|
+
batch?: {
|
|
19
|
+
maxSize?: number
|
|
20
|
+
debounce?: number
|
|
21
|
+
}
|
|
13
22
|
fetchRows?: (client: WsServerSocket, subscribe: CuboCrdtServerSubscribe) => Promise<M[E][]>
|
|
14
23
|
checkRowIsSutable?: (baseState: boolean, ctx: { entity: E; subscribe: CuboCrdtServerSubscribe; row: any }) => boolean
|
|
15
24
|
storeRow?: <K extends E>(
|
package/.prettierrc
DELETED
package/tsconfig.build.json
DELETED
package/tsconfig.json
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"module": "commonjs",
|
|
4
|
-
"declaration": true,
|
|
5
|
-
"removeComments": true,
|
|
6
|
-
"emitDecoratorMetadata": true,
|
|
7
|
-
"experimentalDecorators": true,
|
|
8
|
-
"allowSyntheticDefaultImports": true,
|
|
9
|
-
"target": "ES2021",
|
|
10
|
-
"sourceMap": true,
|
|
11
|
-
"outDir": "./dist",
|
|
12
|
-
"strict": false,
|
|
13
|
-
"rootDir": "./",
|
|
14
|
-
"incremental": true,
|
|
15
|
-
"skipLibCheck": true,
|
|
16
|
-
"paths": {
|
|
17
|
-
"@/*": ["./src/*"]
|
|
18
|
-
}
|
|
19
|
-
},
|
|
20
|
-
"include": ["src/**/*"],
|
|
21
|
-
"exclude": ["node_modules", "dist"]
|
|
22
|
-
}
|