@cuboapp/crdt 1.0.14 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cuboapp/crdt",
3
- "version": "1.0.14",
3
+ "version": "1.0.15",
4
4
  "description": "CRDT for CUBO",
5
5
  "main": "src/index.ts",
6
6
  "repository": {
@@ -413,6 +413,10 @@ export class CuboCrdtClient<M> {
413
413
  entity_id: ctx.entity_id,
414
414
  data: Array.from(update),
415
415
  origin: {
416
+ // по умолчанию 'other' — исходную подписку исключаем (её yjs-документ уже
417
+ // применил изменение локально). Без этого апдейты от TipTap/y-prosemirror
418
+ // приходят без expose и сервер эхом шлёт их обратно самому автору.
419
+ expose: 'other',
416
420
  ...pick(origin || {}, ['store', 'keys', 'expose']),
417
421
  subscribe_id: opts?.subscribe_id
418
422
  }
@@ -468,7 +472,8 @@ export class CuboCrdtClient<M> {
468
472
  entity: ctx.entity,
469
473
  entity_id: ctx.entity_id,
470
474
  data: Array.from(update),
471
- origin: { subscribe_id: opts.subscribe_id }
475
+ // 'other' не шлём свой же курсор обратно исходной подписке
476
+ origin: { expose: 'other', subscribe_id: opts.subscribe_id }
472
477
  }
473
478
  })
474
479
  })
@@ -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
- Object.entries(dto).forEach(([key, value]) => {
108
+ entries.forEach(([key, value]) => {
99
109
  map.set(key, value)
100
110
  })
101
111
  }, origin)
@@ -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,8 +166,9 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
163
166
  }
164
167
 
165
168
  public removeClient(client: WsServerSocket) {
166
- // удаляем все подписки по клиенту
167
- this.subscribesByClient.get(client.id)?.forEach((subscribe_id) => this.cleanSubscribe(subscribe_id))
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)
@@ -198,17 +202,17 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
198
202
  return
199
203
  }
200
204
 
201
- // дедупликация одинаковых событий внутри батча
202
- const data = [...new Map(Array.from(queue).map((item) => [JSON.stringify(item), item])).values()]
205
+ // Дедупликация побайтово идентичных событий внутри батча + сборка кадра за ОДИН проход
206
+ // сериализации: сериализуем каждый элемент один раз (он же служит ключом дедупа) и
207
+ // склеиваем готовые JSON-строки, а не сериализуем весь массив повторно.
208
+ const items = new Set<string>()
209
+ for (const item of queue) {
210
+ items.add(JSON.stringify(item))
211
+ }
203
212
 
204
213
  queue.clear()
205
214
 
206
- client.send(
207
- JSON.stringify({
208
- method: CUBO_CRDT_EVENT.EVENT,
209
- data
210
- })
211
- )
215
+ client.send(`{"method":${JSON.stringify(CUBO_CRDT_EVENT.EVENT)},"data":[${Array.from(items).join(',')}]}`)
212
216
  }
213
217
 
214
218
  public sendToClient(client_id: string, data: any) {
@@ -241,6 +245,29 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
241
245
  }
242
246
  }
243
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
+
244
271
  // если у документа нет подписок - сносим его
245
272
  private checkDocumentNeedRemove(name: string) {
246
273
  if (!this.subscribesByDocument.get(name)?.size) {
@@ -250,55 +277,62 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
250
277
 
251
278
  this.documents.get(name)?.destroy()
252
279
  this.documents.delete(name)
280
+ // не оставляем пустой Set в индексе (иначе — медленная утечка записей мапы);
281
+ // getOrCreateDocument пересоздаст его при повторном появлении документа
282
+ this.subscribesByDocument.delete(name)
253
283
  }
254
284
  }
255
285
 
256
- // очистка подписки (при отписке клиента - через emit-метод или options-хук)
257
- private cleanSubscribe(subscribe_id: string) {
286
+ // очистка подписки (при отписке клиента - через emit-метод или options-хук).
287
+ // ownerClientId (опционально): при отключении сокета передаётся id этого сокета —
288
+ // если подписка уже переподвязана к НОВОМУ клиенту (reconnect с тем же subscribe_id),
289
+ // старый close-хендлер не должен её сносить.
290
+ private cleanSubscribe(subscribe_id: string, ownerClientId?: string) {
258
291
  const subscribe = this.subscribes.get(subscribe_id)
259
- if (subscribe) {
292
+ if (!subscribe) {
293
+ return
294
+ }
295
+
296
+ // подписка была переподвязана к другому (более новому) клиенту — не трогаем
297
+ if (ownerClientId !== undefined && subscribe.client_id !== ownerClientId) {
260
298
  if (this.debug) {
261
- console.log('[CRDT] unsubscribe', subscribe.entity, subscribe.filters)
299
+ console.log('[CRDT] cleanSubscribe skipped (rebound to newer client)', subscribe_id)
262
300
  }
301
+ return
302
+ }
263
303
 
264
- if (subscribe.awareness) {
265
- //убираем аварнесс стейт при отписке
266
- this.subscribesByDocument.forEach((subs, docName) => {
267
- if (!subs.has(subscribe_id)) {
268
- return
269
- }
304
+ if (this.debug) {
305
+ console.log('[CRDT] unsubscribe', subscribe.entity, subscribe.filters)
306
+ }
270
307
 
271
- const doc = this.documents.get(docName)
272
- if (!doc?.awareness) {
273
- return
274
- }
308
+ // документы этой подписки берём из обратного индекса, а не сканируем все
309
+ const docNames = this.documentsBySubscribe.get(subscribe_id)
275
310
 
276
- const ids = Array.from(doc.awarenessBySubscribe.get(subscribe_id) || [])
277
- if (!ids.length) {
278
- return
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)
279
320
  }
321
+ }
280
322
 
281
- removeAwarenessStates(doc.awareness, ids, { subscribe_id, expose: 'all' })
282
-
283
- doc.awarenessBySubscribe.delete(subscribe_id)
284
- })
323
+ // отписываем документ от подписки (только по одной стороне индекса другую чистим ниже)
324
+ this.subscribesByDocument.get(docName)?.delete(subscribe_id)
325
+ this.checkDocumentNeedRemove(docName)
285
326
  }
327
+ }
286
328
 
287
- // удаляем подписку по сущности
288
- this.subscribesByEntity.get(subscribe.entity as E)?.delete(subscribe.id)
289
-
290
- // отписываем все документы от подписки
291
- this.subscribesByDocument.forEach((subscribes, docName) => {
292
- subscribes.delete(subscribe_id)
293
-
294
- this.checkDocumentNeedRemove(docName)
295
- })
329
+ // удаляем обратный индекс
330
+ this.documentsBySubscribe.delete(subscribe_id)
296
331
 
297
- // удаляем подписки
298
- this.subscribes.delete(subscribe_id)
299
- this.subscribesByClient.get(subscribe.client_id)?.delete(subscribe_id)
300
- this.subscribesByEntity.get(subscribe.entity as E)?.delete(subscribe_id)
301
- }
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)
302
336
  }
303
337
 
304
338
  public getDocument(entity: string, entity_id: number) {
@@ -321,60 +355,97 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
321
355
  })
322
356
  }
323
357
 
324
- public ensureDocument(entity: E, row: any, awareness?: boolean) {
325
- // todo: врапнуть всё это в Promise
358
+ // создаёт (или возвращает существующий) серверный документ БЕЗ рассылки подписчикам.
359
+ // Используется как для рассылочного пути (ensureDocument), так и для точечной
360
+ // доставки на init подписки (pushSubscribeDocuments).
361
+ private getOrCreateDocument(entity: E, row: any, awareness?: boolean): CuboCrdtServerDocument {
326
362
  const entity_id = row.id
327
363
  const documentName = `${entity}:${entity_id}`
328
364
 
329
- // проверяем документ
330
365
  let document = this.documents.get(documentName)
331
- if (!document) {
332
- document = new CuboCrdtServerDocument({
333
- name: documentName,
334
- initialState: row,
335
- awareness: awareness,
336
- onUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
337
- // пушим обновление документа
338
- // console.log('push document update', document.name)
339
-
340
- this.pushDocumentAction('update', entity, document!, data, origin)
341
- },
342
- onAwarenessUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
343
- // пушим обновление документа
344
- // console.log('push document update', document.name)
345
-
346
- this.pushDocumentAction('awareness', entity, document!, data, origin)
347
- },
348
- onStore: (item: object, origin: CuboCrdtServerDocumentOrigin) => {
349
- const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
350
- const client = subscribe && this.clients.get(subscribe.client_id)
366
+ if (document) {
367
+ return document
368
+ }
351
369
 
352
- if (client) {
353
- return this.options?.storeRow?.(entity as any, entity_id, item as any, { document, client, origin })
354
- }
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 })
355
386
  }
356
- })
357
-
358
- // создаём документ
359
- this.documents.set(documentName, document)
387
+ }
388
+ })
360
389
 
361
- // создаём пул подписок по документу
390
+ this.documents.set(documentName, document)
391
+ if (!this.subscribesByDocument.has(documentName)) {
362
392
  this.subscribesByDocument.set(documentName, new Set())
393
+ }
363
394
 
364
- // пушим создание документа
365
- this.pushDocumentAction('create', entity, document, document.stateAsUpdate)
395
+ return document
396
+ }
366
397
 
367
- // console.log('create document', documentName)
368
- } else {
369
- // console.log('update document', documentName)
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)
370
411
 
371
- // пушим обновление документа
412
+ if (!existed) {
413
+ this.pushDocumentAction('create', entity, document, document.stateAsUpdate)
414
+ } else {
372
415
  this.pushDocumentAction('upsert', entity, document, document.stateAsUpdate)
373
416
  }
374
417
 
375
418
  return document
376
419
  }
377
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
+
378
449
  public deleteDocument(entity: E, row: any, origin?: CuboCrdtClientDocOrigin) {
379
450
  if (this.debug) {
380
451
  console.log('[CRDT] delete document', entity, row.id, origin)
@@ -387,6 +458,15 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
387
458
  // сначала пушим удаление документа
388
459
  this.pushDocumentAction('delete', entity, document, null, origin)
389
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
+
390
470
  // удаляем документ
391
471
  this.documents.delete(documentName)
392
472
 
@@ -402,11 +482,19 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
402
482
  private onDocumentExternalUpdate(update: Uint8Array, action: CuboCrdtServerDocumentIncomingAction) {
403
483
  let document = this.getDocument(action.entity, action.entity_id)
404
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
+
405
493
  if (this.debug) {
406
494
  console.warn('[CRDT] onDocumentExternalUpdate - document not found, creating:', action.entity, action.entity_id)
407
495
  }
408
496
 
409
- document = this.ensureDocument(action.entity as E, action.entity_id, true)
497
+ document = this.getOrCreateDocument(action.entity as E, { id: action.entity_id }, true)
410
498
  }
411
499
 
412
500
  document.applyUpdate(new Uint8Array(update), action.origin)
@@ -468,6 +556,15 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
468
556
  const sutableSubscribes = Array.from(this.subscribesByEntity.get(entity) || [])
469
557
  .map((subscribe_id) => {
470
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
+ }
471
568
 
472
569
  const subscribeSutable = this.checkSubscribeIsSutable(origin, subscribe)
473
570
  const documentExistsInSubscribe = this.subscribesByDocument.get(document.name)?.has(subscribe.id)
@@ -534,8 +631,6 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
534
631
  .filter((i) => !!i) as { subscribe: CuboCrdtServerSubscribe; action: CuboCrdtAction; data: undefined | number[] }[]
535
632
 
536
633
  if (sutableSubscribes.length) {
537
- const documentSubscribes = this.subscribesByDocument.get(document.name)
538
-
539
634
  sutableSubscribes.forEach(({ subscribe, action, data }) => {
540
635
  this.sendToClient(subscribe.client_id, {
541
636
  action,
@@ -549,10 +644,10 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
549
644
  case 'create':
550
645
  case 'upsert':
551
646
  case 'update':
552
- documentSubscribes.add(subscribe.id)
647
+ this.linkSubscribeToDocument(subscribe.id, document.name)
553
648
  break
554
649
  case 'delete':
555
- documentSubscribes.delete(subscribe.id)
650
+ this.unlinkSubscribeFromDocument(subscribe.id, document.name)
556
651
  this.checkDocumentNeedRemove(document.name)
557
652
  break
558
653
  }
@@ -577,13 +672,21 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
577
672
  }
578
673
 
579
674
  private async pushSubscribeDocuments(client: WsServerSocket, subscribe: CuboCrdtServerSubscribe) {
580
- // получаем список всех документов
675
+ // получаем строки под фильтры ЭТОЙ подписки
581
676
  const rows = await this.options.fetchRows?.(client, subscribe)
582
677
 
583
- // пушим их в сокеты
678
+ // Доставляем документы ТОЧЕЧНО инициирующей подписке. Раньше это шло через
679
+ // ensureDocument -> pushDocumentAction('upsert') с полным сканом всех подписок сущности
680
+ // НА КАЖДУЮ строку из fetchRows (до 10k) — главный усилитель нагрузки при (ре)подписке.
681
+ // Строки уже отфильтрованы под фильтры подписки в fetchRows, поэтому повторная
682
+ // проверка/рассылка остальным не нужна: живые create/update придут через onAfterCreate.
584
683
  for (const row of rows || []) {
585
- // this.ensureDocument(subscribe.entity as E, row, { subscribes_ids: [subscribe.id] })
586
- this.ensureDocument(subscribe.entity as E, row, subscribe.awareness)
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)
587
690
  }
588
691
  }
589
692