@cuboapp/crdt 1.0.13 → 1.0.15
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 +114 -20
- package/src/server/document/index.ts +12 -2
- package/src/server/index.ts +236 -113
- 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.15",
|
|
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
|
|
|
@@ -314,6 +413,10 @@ export class CuboCrdtClient<M> {
|
|
|
314
413
|
entity_id: ctx.entity_id,
|
|
315
414
|
data: Array.from(update),
|
|
316
415
|
origin: {
|
|
416
|
+
// по умолчанию 'other' — исходную подписку исключаем (её yjs-документ уже
|
|
417
|
+
// применил изменение локально). Без этого апдейты от TipTap/y-prosemirror
|
|
418
|
+
// приходят без expose и сервер эхом шлёт их обратно самому автору.
|
|
419
|
+
expose: 'other',
|
|
317
420
|
...pick(origin || {}, ['store', 'keys', 'expose']),
|
|
318
421
|
subscribe_id: opts?.subscribe_id
|
|
319
422
|
}
|
|
@@ -369,7 +472,8 @@ export class CuboCrdtClient<M> {
|
|
|
369
472
|
entity: ctx.entity,
|
|
370
473
|
entity_id: ctx.entity_id,
|
|
371
474
|
data: Array.from(update),
|
|
372
|
-
|
|
475
|
+
// 'other' — не шлём свой же курсор обратно исходной подписке
|
|
476
|
+
origin: { expose: 'other', subscribe_id: opts.subscribe_id }
|
|
373
477
|
}
|
|
374
478
|
})
|
|
375
479
|
})
|
|
@@ -399,17 +503,7 @@ export class CuboCrdtClient<M> {
|
|
|
399
503
|
applyUpdate(doc, update, { react: false })
|
|
400
504
|
|
|
401
505
|
// обновляем документ в реактивном хранилище
|
|
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
|
-
}
|
|
506
|
+
this.applyRowJson(opts.storeKey, ctx.entity_id, doc)
|
|
413
507
|
|
|
414
508
|
if (opts?.onAfterUpdate) {
|
|
415
509
|
await opts.onAfterUpdate(doc, update, ctx, opts)
|
|
@@ -60,7 +60,7 @@ export class CuboCrdtServerDocument {
|
|
|
60
60
|
const row = this.getJson()
|
|
61
61
|
const body = pick(row, origin.keys ?? Object.keys(row))
|
|
62
62
|
|
|
63
|
-
if (Object.keys(body)) {
|
|
63
|
+
if (Object.keys(body).length) {
|
|
64
64
|
this.debounceStore(body, origin)
|
|
65
65
|
}
|
|
66
66
|
}
|
|
@@ -94,8 +94,18 @@ export class CuboCrdtServerDocument {
|
|
|
94
94
|
async write(dto: object, origin?: CuboCrdtServerDocumentOrigin) {
|
|
95
95
|
const map = this.getMap()
|
|
96
96
|
|
|
97
|
+
// Пропускаем скалярные ключи с тем же значением: Y.Map.set всегда создаёт новый Item и
|
|
98
|
+
// тумбстонит старый (безостановочный рост документа + лишний update-эвент), даже если
|
|
99
|
+
// значение не изменилось. Сравнение по ссылке — дёшево и не трогает объекты (для них
|
|
100
|
+
// set выполняется как и раньше, без лишней сериализации на горячем пути).
|
|
101
|
+
const entries = Object.entries(dto).filter(([key, value]) => map.get(key) !== value)
|
|
102
|
+
|
|
103
|
+
if (!entries.length) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
97
107
|
this.ydoc.transact(() => {
|
|
98
|
-
|
|
108
|
+
entries.forEach(([key, value]) => {
|
|
99
109
|
map.set(key, value)
|
|
100
110
|
})
|
|
101
111
|
}, origin)
|
package/src/server/index.ts
CHANGED
|
@@ -36,6 +36,9 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
36
36
|
|
|
37
37
|
private documents = new Map<string, CuboCrdtServerDocument>()
|
|
38
38
|
private subscribesByDocument = new Map<string, Set<string>>()
|
|
39
|
+
// обратный индекс: подписка -> имена документов, к которым она привязана.
|
|
40
|
+
// Нужен, чтобы отписка/очистка не сканировала ВСЕ документы сервера (O(subs x docs)).
|
|
41
|
+
private documentsBySubscribe = new Map<string, Set<string>>()
|
|
39
42
|
|
|
40
43
|
public async init() {
|
|
41
44
|
this.entities.forEach((e) => this.subscribesByEntity.set(e, new Set()))
|
|
@@ -163,12 +166,17 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
163
166
|
}
|
|
164
167
|
|
|
165
168
|
public removeClient(client: WsServerSocket) {
|
|
166
|
-
// удаляем все подписки по клиенту
|
|
167
|
-
|
|
169
|
+
// удаляем все подписки по клиенту (но только те, что всё ещё принадлежат ЭТОМУ сокету —
|
|
170
|
+
// подписка могла быть переподвязана к новому сокету при reconnect с тем же subscribe_id)
|
|
171
|
+
this.subscribesByClient.get(client.id)?.forEach((subscribe_id) => this.cleanSubscribe(subscribe_id, client.id))
|
|
168
172
|
|
|
169
173
|
// удаляем мапу подписок по клиенту
|
|
170
174
|
this.subscribesByClient.delete(client.id)
|
|
171
175
|
|
|
176
|
+
// снимаем запланированный флаш очереди (чтобы не слать в закрытый сокет)
|
|
177
|
+
clearTimeout(this.sendTimeouts.get(client.id))
|
|
178
|
+
this.sendTimeouts.delete(client.id)
|
|
179
|
+
|
|
172
180
|
// удаляем очередь под клиента
|
|
173
181
|
this.queue.delete(client.id)
|
|
174
182
|
|
|
@@ -176,25 +184,35 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
176
184
|
this.clients.delete(client.id)
|
|
177
185
|
}
|
|
178
186
|
|
|
187
|
+
private get batchMaxSize() {
|
|
188
|
+
return this.options.batch?.maxSize ?? 50
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private get batchDebounce() {
|
|
192
|
+
return this.options.batch?.debounce ?? 100
|
|
193
|
+
}
|
|
194
|
+
|
|
179
195
|
private sendQueueToClient(client: CuboCrdtSocketClient<A>) {
|
|
196
|
+
// снимаем запланированный флаш — мы отправляем прямо сейчас
|
|
197
|
+
clearTimeout(this.sendTimeouts.get(client.id))
|
|
198
|
+
this.sendTimeouts.delete(client.id)
|
|
199
|
+
|
|
180
200
|
const queue = this.queue.get(client.id)
|
|
181
|
-
if (!queue) {
|
|
201
|
+
if (!queue || queue.size === 0) {
|
|
182
202
|
return
|
|
183
203
|
}
|
|
184
204
|
|
|
185
|
-
//
|
|
186
|
-
|
|
205
|
+
// Дедупликация побайтово идентичных событий внутри батча + сборка кадра за ОДИН проход
|
|
206
|
+
// сериализации: сериализуем каждый элемент один раз (он же служит ключом дедупа) и
|
|
207
|
+
// склеиваем готовые JSON-строки, а не сериализуем весь массив повторно.
|
|
208
|
+
const items = new Set<string>()
|
|
209
|
+
for (const item of queue) {
|
|
210
|
+
items.add(JSON.stringify(item))
|
|
211
|
+
}
|
|
187
212
|
|
|
188
213
|
queue.clear()
|
|
189
214
|
|
|
190
|
-
client.send(
|
|
191
|
-
JSON.stringify({
|
|
192
|
-
method: CUBO_CRDT_EVENT.EVENT,
|
|
193
|
-
data
|
|
194
|
-
})
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
queue.clear()
|
|
215
|
+
client.send(`{"method":${JSON.stringify(CUBO_CRDT_EVENT.EVENT)},"data":[${Array.from(items).join(',')}]}`)
|
|
198
216
|
}
|
|
199
217
|
|
|
200
218
|
public sendToClient(client_id: string, data: any) {
|
|
@@ -203,25 +221,53 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
203
221
|
return
|
|
204
222
|
}
|
|
205
223
|
|
|
206
|
-
|
|
207
|
-
|
|
224
|
+
const queue = this.queue.get(client_id)
|
|
225
|
+
if (!queue) {
|
|
226
|
+
return
|
|
227
|
+
}
|
|
208
228
|
|
|
209
229
|
// добавляем в очередь
|
|
210
|
-
|
|
230
|
+
queue.add(data)
|
|
211
231
|
|
|
212
|
-
// если
|
|
213
|
-
if (
|
|
232
|
+
// если у ЭТОГО клиента в очереди накопилось много событий — отправляем сразу
|
|
233
|
+
if (queue.size >= this.batchMaxSize) {
|
|
214
234
|
this.sendQueueToClient(client)
|
|
235
|
+
return
|
|
215
236
|
}
|
|
216
|
-
|
|
217
|
-
|
|
237
|
+
|
|
238
|
+
// иначе — дебаунсим отправку, но НЕ сбрасываем уже запланированный флаш,
|
|
239
|
+
// иначе при непрерывном потоке апдейтов очередь никогда не отправится
|
|
240
|
+
if (!this.sendTimeouts.has(client_id)) {
|
|
218
241
|
this.sendTimeouts.set(
|
|
219
242
|
client_id,
|
|
220
|
-
setTimeout(() => this.sendQueueToClient(client),
|
|
243
|
+
setTimeout(() => this.sendQueueToClient(client), this.batchDebounce)
|
|
221
244
|
)
|
|
222
245
|
}
|
|
223
246
|
}
|
|
224
247
|
|
|
248
|
+
// привязываем подписку к документу (в обоих индексах)
|
|
249
|
+
private linkSubscribeToDocument(subscribe_id: string, docName: string) {
|
|
250
|
+
let byDoc = this.subscribesByDocument.get(docName)
|
|
251
|
+
if (!byDoc) {
|
|
252
|
+
byDoc = new Set()
|
|
253
|
+
this.subscribesByDocument.set(docName, byDoc)
|
|
254
|
+
}
|
|
255
|
+
byDoc.add(subscribe_id)
|
|
256
|
+
|
|
257
|
+
let bySub = this.documentsBySubscribe.get(subscribe_id)
|
|
258
|
+
if (!bySub) {
|
|
259
|
+
bySub = new Set()
|
|
260
|
+
this.documentsBySubscribe.set(subscribe_id, bySub)
|
|
261
|
+
}
|
|
262
|
+
bySub.add(docName)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// отвязываем подписку от документа (в обоих индексах)
|
|
266
|
+
private unlinkSubscribeFromDocument(subscribe_id: string, docName: string) {
|
|
267
|
+
this.subscribesByDocument.get(docName)?.delete(subscribe_id)
|
|
268
|
+
this.documentsBySubscribe.get(subscribe_id)?.delete(docName)
|
|
269
|
+
}
|
|
270
|
+
|
|
225
271
|
// если у документа нет подписок - сносим его
|
|
226
272
|
private checkDocumentNeedRemove(name: string) {
|
|
227
273
|
if (!this.subscribesByDocument.get(name)?.size) {
|
|
@@ -231,55 +277,62 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
231
277
|
|
|
232
278
|
this.documents.get(name)?.destroy()
|
|
233
279
|
this.documents.delete(name)
|
|
280
|
+
// не оставляем пустой Set в индексе (иначе — медленная утечка записей мапы);
|
|
281
|
+
// getOrCreateDocument пересоздаст его при повторном появлении документа
|
|
282
|
+
this.subscribesByDocument.delete(name)
|
|
234
283
|
}
|
|
235
284
|
}
|
|
236
285
|
|
|
237
|
-
// очистка подписки (при отписке клиента - через emit-метод или options-хук)
|
|
238
|
-
|
|
286
|
+
// очистка подписки (при отписке клиента - через emit-метод или options-хук).
|
|
287
|
+
// ownerClientId (опционально): при отключении сокета передаётся id этого сокета —
|
|
288
|
+
// если подписка уже переподвязана к НОВОМУ клиенту (reconnect с тем же subscribe_id),
|
|
289
|
+
// старый close-хендлер не должен её сносить.
|
|
290
|
+
private cleanSubscribe(subscribe_id: string, ownerClientId?: string) {
|
|
239
291
|
const subscribe = this.subscribes.get(subscribe_id)
|
|
240
|
-
if (subscribe) {
|
|
292
|
+
if (!subscribe) {
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// подписка была переподвязана к другому (более новому) клиенту — не трогаем
|
|
297
|
+
if (ownerClientId !== undefined && subscribe.client_id !== ownerClientId) {
|
|
241
298
|
if (this.debug) {
|
|
242
|
-
console.log('[CRDT]
|
|
299
|
+
console.log('[CRDT] cleanSubscribe skipped (rebound to newer client)', subscribe_id)
|
|
243
300
|
}
|
|
301
|
+
return
|
|
302
|
+
}
|
|
244
303
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (!subs.has(subscribe_id)) {
|
|
249
|
-
return
|
|
250
|
-
}
|
|
304
|
+
if (this.debug) {
|
|
305
|
+
console.log('[CRDT] unsubscribe', subscribe.entity, subscribe.filters)
|
|
306
|
+
}
|
|
251
307
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
return
|
|
255
|
-
}
|
|
308
|
+
// документы этой подписки берём из обратного индекса, а не сканируем все
|
|
309
|
+
const docNames = this.documentsBySubscribe.get(subscribe_id)
|
|
256
310
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
311
|
+
if (docNames) {
|
|
312
|
+
for (const docName of docNames) {
|
|
313
|
+
if (subscribe.awareness) {
|
|
314
|
+
//убираем аварнесс стейт при отписке
|
|
315
|
+
const doc = this.documents.get(docName)
|
|
316
|
+
const ids = doc?.awareness ? Array.from(doc.awarenessBySubscribe.get(subscribe_id) || []) : []
|
|
317
|
+
if (doc?.awareness && ids.length) {
|
|
318
|
+
removeAwarenessStates(doc.awareness, ids, { subscribe_id, expose: 'all' })
|
|
319
|
+
doc.awarenessBySubscribe.delete(subscribe_id)
|
|
260
320
|
}
|
|
321
|
+
}
|
|
261
322
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
})
|
|
323
|
+
// отписываем документ от подписки (только по одной стороне индекса — другую чистим ниже)
|
|
324
|
+
this.subscribesByDocument.get(docName)?.delete(subscribe_id)
|
|
325
|
+
this.checkDocumentNeedRemove(docName)
|
|
266
326
|
}
|
|
327
|
+
}
|
|
267
328
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
// отписываем все документы от подписки
|
|
272
|
-
this.subscribesByDocument.forEach((subscribes, docName) => {
|
|
273
|
-
subscribes.delete(subscribe_id)
|
|
274
|
-
|
|
275
|
-
this.checkDocumentNeedRemove(docName)
|
|
276
|
-
})
|
|
329
|
+
// удаляем обратный индекс
|
|
330
|
+
this.documentsBySubscribe.delete(subscribe_id)
|
|
277
331
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
332
|
+
// удаляем подписки
|
|
333
|
+
this.subscribes.delete(subscribe_id)
|
|
334
|
+
this.subscribesByClient.get(subscribe.client_id)?.delete(subscribe_id)
|
|
335
|
+
this.subscribesByEntity.get(subscribe.entity as E)?.delete(subscribe_id)
|
|
283
336
|
}
|
|
284
337
|
|
|
285
338
|
public getDocument(entity: string, entity_id: number) {
|
|
@@ -287,74 +340,112 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
287
340
|
}
|
|
288
341
|
|
|
289
342
|
public getSutableSubscribes(entity: string, row: any) {
|
|
290
|
-
return Array.from(this.subscribesByEntity.get(entity as E) || [])
|
|
291
|
-
.
|
|
292
|
-
|
|
343
|
+
return Array.from(this.subscribesByEntity.get(entity as E) || []).filter((subscribe_id) => {
|
|
344
|
+
const subscribe = this.subscribes.get(subscribe_id)
|
|
345
|
+
if (!subscribe) {
|
|
346
|
+
return false
|
|
347
|
+
}
|
|
293
348
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
349
|
+
let state = checkRowIsSutable(row, subscribe.filters || {})
|
|
350
|
+
if (this.options.checkRowIsSutable) {
|
|
351
|
+
state = this.options.checkRowIsSutable(state, { entity: entity as any, subscribe, row })
|
|
352
|
+
}
|
|
298
353
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
.map((m) => m[1])
|
|
354
|
+
return state
|
|
355
|
+
})
|
|
302
356
|
}
|
|
303
357
|
|
|
304
|
-
|
|
305
|
-
|
|
358
|
+
// создаёт (или возвращает существующий) серверный документ БЕЗ рассылки подписчикам.
|
|
359
|
+
// Используется как для рассылочного пути (ensureDocument), так и для точечной
|
|
360
|
+
// доставки на init подписки (pushSubscribeDocuments).
|
|
361
|
+
private getOrCreateDocument(entity: E, row: any, awareness?: boolean): CuboCrdtServerDocument {
|
|
306
362
|
const entity_id = row.id
|
|
307
363
|
const documentName = `${entity}:${entity_id}`
|
|
308
364
|
|
|
309
|
-
// проверяем документ
|
|
310
365
|
let document = this.documents.get(documentName)
|
|
311
|
-
if (
|
|
312
|
-
document
|
|
313
|
-
|
|
314
|
-
initialState: row,
|
|
315
|
-
awareness: awareness,
|
|
316
|
-
onUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
|
|
317
|
-
// пушим обновление документа
|
|
318
|
-
// console.log('push document update', document.name)
|
|
319
|
-
|
|
320
|
-
this.pushDocumentAction('update', entity, document!, data, origin)
|
|
321
|
-
},
|
|
322
|
-
onAwarenessUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
|
|
323
|
-
// пушим обновление документа
|
|
324
|
-
// console.log('push document update', document.name)
|
|
325
|
-
|
|
326
|
-
this.pushDocumentAction('awareness', entity, document!, data, origin)
|
|
327
|
-
},
|
|
328
|
-
onStore: (item: object, origin: CuboCrdtServerDocumentOrigin) => {
|
|
329
|
-
const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
|
|
330
|
-
const client = subscribe && this.clients.get(subscribe.client_id)
|
|
366
|
+
if (document) {
|
|
367
|
+
return document
|
|
368
|
+
}
|
|
331
369
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
370
|
+
document = new CuboCrdtServerDocument({
|
|
371
|
+
name: documentName,
|
|
372
|
+
initialState: row,
|
|
373
|
+
awareness: awareness,
|
|
374
|
+
onUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
|
|
375
|
+
this.pushDocumentAction('update', entity, document!, data, origin)
|
|
376
|
+
},
|
|
377
|
+
onAwarenessUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
|
|
378
|
+
this.pushDocumentAction('awareness', entity, document!, data, origin)
|
|
379
|
+
},
|
|
380
|
+
onStore: (item: object, origin: CuboCrdtServerDocumentOrigin) => {
|
|
381
|
+
const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
|
|
382
|
+
const client = subscribe && this.clients.get(subscribe.client_id)
|
|
383
|
+
|
|
384
|
+
if (client) {
|
|
385
|
+
return this.options?.storeRow?.(entity as any, entity_id, item as any, { document, client, origin })
|
|
335
386
|
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// создаём документ
|
|
339
|
-
this.documents.set(documentName, document)
|
|
387
|
+
}
|
|
388
|
+
})
|
|
340
389
|
|
|
341
|
-
|
|
390
|
+
this.documents.set(documentName, document)
|
|
391
|
+
if (!this.subscribesByDocument.has(documentName)) {
|
|
342
392
|
this.subscribesByDocument.set(documentName, new Set())
|
|
393
|
+
}
|
|
343
394
|
|
|
344
|
-
|
|
345
|
-
|
|
395
|
+
return document
|
|
396
|
+
}
|
|
346
397
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
398
|
+
// Рассылочный путь (хук onAfterCreate/onAfterUpdate из бэка): создаёт документ и
|
|
399
|
+
// раскатывает 'create'/'upsert' по ВСЕМ подходящим подпискам сущности.
|
|
400
|
+
public ensureDocument(entity: E, row: any, awareness?: boolean) {
|
|
401
|
+
if (row?.id == null) {
|
|
402
|
+
if (this.debug) {
|
|
403
|
+
console.warn('[CRDT] ensureDocument: row without id', entity, row)
|
|
404
|
+
}
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const documentName = `${entity}:${row.id}`
|
|
409
|
+
const existed = this.documents.has(documentName)
|
|
410
|
+
const document = this.getOrCreateDocument(entity, row, awareness)
|
|
350
411
|
|
|
351
|
-
|
|
412
|
+
if (!existed) {
|
|
413
|
+
this.pushDocumentAction('create', entity, document, document.stateAsUpdate)
|
|
414
|
+
} else {
|
|
352
415
|
this.pushDocumentAction('upsert', entity, document, document.stateAsUpdate)
|
|
353
416
|
}
|
|
354
417
|
|
|
355
418
|
return document
|
|
356
419
|
}
|
|
357
420
|
|
|
421
|
+
// Точечная доставка документа ТОЛЬКО инициирующей подписке (init/upgrade/resubscribe).
|
|
422
|
+
// Раньше это шло через ensureDocument -> pushDocumentAction('upsert') с полным сканом
|
|
423
|
+
// всех подписок сущности — на каждый ряд из fetchRows. Теперь — один send на подписку.
|
|
424
|
+
private sendDocumentToSubscribe(subscribe: CuboCrdtServerSubscribe, document: CuboCrdtServerDocument, entity_id: number) {
|
|
425
|
+
this.linkSubscribeToDocument(subscribe.id, document.name)
|
|
426
|
+
|
|
427
|
+
this.sendToClient(subscribe.client_id, {
|
|
428
|
+
action: 'create',
|
|
429
|
+
entity: subscribe.entity,
|
|
430
|
+
entity_id,
|
|
431
|
+
data: Array.from(document.stateAsUpdate),
|
|
432
|
+
subscribe_id: subscribe.id
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
if (subscribe.awareness && document.awareness) {
|
|
436
|
+
const ids = Array.from(document.awareness.getStates().keys())
|
|
437
|
+
if (ids.length) {
|
|
438
|
+
this.sendToClient(subscribe.client_id, {
|
|
439
|
+
action: 'awareness',
|
|
440
|
+
entity: subscribe.entity,
|
|
441
|
+
entity_id,
|
|
442
|
+
data: Array.from(encodeAwarenessUpdate(document.awareness, ids)),
|
|
443
|
+
subscribe_id: subscribe.id
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
358
449
|
public deleteDocument(entity: E, row: any, origin?: CuboCrdtClientDocOrigin) {
|
|
359
450
|
if (this.debug) {
|
|
360
451
|
console.log('[CRDT] delete document', entity, row.id, origin)
|
|
@@ -367,6 +458,15 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
367
458
|
// сначала пушим удаление документа
|
|
368
459
|
this.pushDocumentAction('delete', entity, document, null, origin)
|
|
369
460
|
|
|
461
|
+
// чистим обратный индекс: убираем документ из documentsBySubscribe у всех его подписок
|
|
462
|
+
const subs = this.subscribesByDocument.get(documentName)
|
|
463
|
+
if (subs) {
|
|
464
|
+
for (const subscribe_id of subs) {
|
|
465
|
+
this.documentsBySubscribe.get(subscribe_id)?.delete(documentName)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
this.subscribesByDocument.delete(documentName)
|
|
469
|
+
|
|
370
470
|
// удаляем документ
|
|
371
471
|
this.documents.delete(documentName)
|
|
372
472
|
|
|
@@ -382,11 +482,19 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
382
482
|
private onDocumentExternalUpdate(update: Uint8Array, action: CuboCrdtServerDocumentIncomingAction) {
|
|
383
483
|
let document = this.getDocument(action.entity, action.entity_id)
|
|
384
484
|
if (!document) {
|
|
485
|
+
// документ мог быть вытеснен (эвикция при отключении всех подписчиков), пока
|
|
486
|
+
// клиент ещё шлёт апдейты. Пересоздаём под ПРАВИЛЬНЫМ ключом ({ id }, а не число),
|
|
487
|
+
// без рассылки пустого 'create'. Раньше сюда передавался entity_id как row ->
|
|
488
|
+
// row.id === undefined -> общий документ-призрак `entity:undefined`.
|
|
489
|
+
if (action.entity_id == null) {
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
|
|
385
493
|
if (this.debug) {
|
|
386
494
|
console.warn('[CRDT] onDocumentExternalUpdate - document not found, creating:', action.entity, action.entity_id)
|
|
387
495
|
}
|
|
388
496
|
|
|
389
|
-
document = this.
|
|
497
|
+
document = this.getOrCreateDocument(action.entity as E, { id: action.entity_id }, true)
|
|
390
498
|
}
|
|
391
499
|
|
|
392
500
|
document.applyUpdate(new Uint8Array(update), action.origin)
|
|
@@ -445,9 +553,18 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
445
553
|
const row = document.getJson()
|
|
446
554
|
|
|
447
555
|
// берём все подписки по сущности
|
|
448
|
-
const sutableSubscribes = Array.from(this.subscribesByEntity.get(entity))
|
|
556
|
+
const sutableSubscribes = Array.from(this.subscribesByEntity.get(entity) || [])
|
|
449
557
|
.map((subscribe_id) => {
|
|
450
558
|
const subscribe = this.subscribes.get(subscribe_id)
|
|
559
|
+
if (!subscribe) {
|
|
560
|
+
return null
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// аварнесс-события (курсоры/выделения) раскатываем ТОЛЬКО подпискам с awareness,
|
|
564
|
+
// а не всем спискам сущности, которым awareness не нужен
|
|
565
|
+
if (action === 'awareness' && !subscribe.awareness) {
|
|
566
|
+
return null
|
|
567
|
+
}
|
|
451
568
|
|
|
452
569
|
const subscribeSutable = this.checkSubscribeIsSutable(origin, subscribe)
|
|
453
570
|
const documentExistsInSubscribe = this.subscribesByDocument.get(document.name)?.has(subscribe.id)
|
|
@@ -514,8 +631,6 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
514
631
|
.filter((i) => !!i) as { subscribe: CuboCrdtServerSubscribe; action: CuboCrdtAction; data: undefined | number[] }[]
|
|
515
632
|
|
|
516
633
|
if (sutableSubscribes.length) {
|
|
517
|
-
const documentSubscribes = this.subscribesByDocument.get(document.name)
|
|
518
|
-
|
|
519
634
|
sutableSubscribes.forEach(({ subscribe, action, data }) => {
|
|
520
635
|
this.sendToClient(subscribe.client_id, {
|
|
521
636
|
action,
|
|
@@ -529,10 +644,10 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
529
644
|
case 'create':
|
|
530
645
|
case 'upsert':
|
|
531
646
|
case 'update':
|
|
532
|
-
|
|
647
|
+
this.linkSubscribeToDocument(subscribe.id, document.name)
|
|
533
648
|
break
|
|
534
649
|
case 'delete':
|
|
535
|
-
|
|
650
|
+
this.unlinkSubscribeFromDocument(subscribe.id, document.name)
|
|
536
651
|
this.checkDocumentNeedRemove(document.name)
|
|
537
652
|
break
|
|
538
653
|
}
|
|
@@ -557,13 +672,21 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
|
|
|
557
672
|
}
|
|
558
673
|
|
|
559
674
|
private async pushSubscribeDocuments(client: WsServerSocket, subscribe: CuboCrdtServerSubscribe) {
|
|
560
|
-
// получаем
|
|
675
|
+
// получаем строки под фильтры ЭТОЙ подписки
|
|
561
676
|
const rows = await this.options.fetchRows?.(client, subscribe)
|
|
562
677
|
|
|
563
|
-
//
|
|
678
|
+
// Доставляем документы ТОЧЕЧНО инициирующей подписке. Раньше это шло через
|
|
679
|
+
// ensureDocument -> pushDocumentAction('upsert') с полным сканом всех подписок сущности
|
|
680
|
+
// НА КАЖДУЮ строку из fetchRows (до 10k) — главный усилитель нагрузки при (ре)подписке.
|
|
681
|
+
// Строки уже отфильтрованы под фильтры подписки в fetchRows, поэтому повторная
|
|
682
|
+
// проверка/рассылка остальным не нужна: живые create/update придут через onAfterCreate.
|
|
564
683
|
for (const row of rows || []) {
|
|
565
|
-
|
|
566
|
-
|
|
684
|
+
if ((row as any)?.id == null) {
|
|
685
|
+
continue
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const document = this.getOrCreateDocument(subscribe.entity as E, row, subscribe.awareness)
|
|
689
|
+
this.sendDocumentToSubscribe(subscribe, document, (row as any).id)
|
|
567
690
|
}
|
|
568
691
|
}
|
|
569
692
|
|
|
@@ -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
|
-
}
|