@cuboapp/crdt 1.0.22 → 1.0.23

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.22",
3
+ "version": "1.0.23",
4
4
  "description": "CRDT for CUBO",
5
5
  "main": "src/index.ts",
6
6
  "repository": {
@@ -2,7 +2,7 @@ import { keyBy, pick, uuid } from '@cuboapp/utils'
2
2
  import { WsClientEvent } from '@cuboapp/ws'
3
3
  import { computed, reactive, shallowReactive } from 'vue'
4
4
  import { applyAwarenessUpdate, Awareness, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'
5
- import { applyUpdate, Doc } from 'yjs'
5
+ import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs'
6
6
 
7
7
  import { CUBO_CRDT_EVENT } from '../constants'
8
8
  import { CuboCrdtServerDocumentIncomingAction } from '../server'
@@ -450,18 +450,20 @@ export class CuboCrdtClient<M> {
450
450
  dto: K extends Extract<keyof M, string> ? Partial<M[K]> : object,
451
451
  opts?: CuboCrdtClientDocOrigin
452
452
  ) {
453
- const entityStores = [...new Set(
454
- [...this.subscriptions.values()]
455
- .filter((subscription) => subscription.entity === entity)
456
- .map((subscription) => this.store[subscription.storeKey])
457
- )]
458
- const fallbackStores = [this.store[entity], this.store[`${entity}:${entity_id}`]]
459
- const store = [...entityStores, ...fallbackStores]
460
- .find((candidate) => candidate?.docs.has(entity_id))
453
+ // Если строка открыта через useRow, mutating API работает только с её
454
+ // каноническим документом. Списки могут содержать тот же id, но не должны
455
+ // становиться источником записи для карточки.
456
+ const rowStore = this.store[`${entity}:${entity_id}`]
457
+ const store = rowStore?.state.status === 'ready' && rowStore.docs.has(entity_id)
458
+ ? rowStore
459
+ : [...this.subscriptions.values()]
460
+ .filter((subscription) => subscription.entity === entity)
461
+ .map((subscription) => this.store[subscription.storeKey])
462
+ .find((candidate) => candidate?.docs.has(entity_id) && candidate.state.status === 'ready')
461
463
  const document = store?.docs.get(entity_id)
462
464
 
463
465
  if (!store || !document) {
464
- return
466
+ return false
465
467
  }
466
468
 
467
469
  const map = document.getMap()
@@ -470,16 +472,22 @@ export class CuboCrdtClient<M> {
470
472
  )
471
473
 
472
474
  if (!Object.keys(toUpdate).length) {
473
- return
475
+ // Состояние уже совпадает (например, update успел прийти из другой
476
+ // вкладки). Это успешный no-op, а не ошибка изменения.
477
+ return true
474
478
  }
475
479
 
476
480
  const origin: CuboCrdtClientDocOrigin = {
477
481
  store: opts?.store ?? true,
478
482
  expose: opts?.expose ?? 'other',
479
483
  keys: opts?.keys ?? Object.keys(toUpdate),
480
- react: true
484
+ // update() сам отправляет ровно один пакет ниже. Общий listener остаётся
485
+ // для изменений, которые создаются напрямую сторонними Yjs-binding'ами.
486
+ react: false
481
487
  }
482
488
 
489
+ const stateVector = encodeStateVector(document)
490
+
483
491
  // Одна сущность может присутствовать в нескольких подписках с разными
484
492
  // storeKey. Мутацию отправляем только из одного документа: остальные
485
493
  // подписки получат подтверждённый update через обычную CRDT-рассылку.
@@ -489,6 +497,36 @@ export class CuboCrdtClient<M> {
489
497
  })
490
498
  }, origin)
491
499
 
500
+ const update = encodeStateAsUpdate(document, stateVector)
501
+ const receipt = this.ws
502
+ .request(
503
+ {
504
+ method: CUBO_CRDT_EVENT.EVENT,
505
+ data: {
506
+ action: 'update',
507
+ entity,
508
+ entity_id,
509
+ data: Array.from(update),
510
+ origin: {
511
+ ...pick(origin, ['store', 'keys', 'expose']),
512
+ subscribe_id: [...this.subscriptions.entries()]
513
+ .find(([, subscription]) => this.store[subscription.storeKey] === store)?.[0]
514
+ }
515
+ }
516
+ },
517
+ { wait: true, timeout: 10_000 }
518
+ )
519
+ .then(() => true)
520
+ .catch((error) => {
521
+ console.error('[CRDT] update request failed', error)
522
+ return false
523
+ })
524
+
525
+ // Этот документ является каноническим локальным состоянием строки. Обновляем
526
+ // его реактивную строку сразу, а другие подписки получат тот же Yjs-update
527
+ // через серверную рассылку.
528
+ this.applyRowJson(`${entity}:${entity_id}`, entity_id, document)
529
+
492
530
  const row = store.state.rows.find((item: any) => Number(item.id) === Number(entity_id))
493
531
 
494
532
  if (row) {
@@ -496,6 +534,8 @@ export class CuboCrdtClient<M> {
496
534
  row[key] = value
497
535
  })
498
536
  }
537
+
538
+ return receipt
499
539
  }
500
540
 
501
541
  public upgrade(subscribe_id: string, filters?: Record<string, any>) {
@@ -649,10 +689,10 @@ export class CuboCrdtClient<M> {
649
689
  }
650
690
  }
651
691
 
652
- this.ws.request({
692
+ void this.ws.request({
653
693
  method: CUBO_CRDT_EVENT.EVENT,
654
694
  data
655
- })
695
+ }).catch((error) => console.error('[CRDT] update request failed', error))
656
696
  }
657
697
  })
658
698
 
@@ -15,7 +15,7 @@ export class CuboCrdtServerDocument {
15
15
  private ydoc: Doc
16
16
  private persistedState: object = {}
17
17
  private observedState: object = {}
18
- private storeQueue: Promise<void> = Promise.resolve()
18
+ private storeQueue?: Promise<void>
19
19
  private pendingStore?: CuboCrdtServerDocumentPendingStore
20
20
  private storeTimeout?: ReturnType<typeof setTimeout>
21
21
  public awareness: Awareness
@@ -152,20 +152,37 @@ export class CuboCrdtServerDocument {
152
152
  }
153
153
 
154
154
  destroy() {
155
- this.flushPendingStore()
156
155
  this.awareness?.destroy()
157
156
  this.ydoc.destroy()
158
157
  }
159
158
 
159
+ /**
160
+ * Немедленно передаёт trailing-пакет в очередь и ждёт все уже принятые
161
+ * сохранения документа. Используется перед вытеснением Y.Doc из серверного
162
+ * кэша, чтобы refresh последнего клиента не потерял его update.
163
+ */
164
+ public async flushStoreQueue() {
165
+ this.flushPendingStore()
166
+
167
+ while (this.storeQueue) {
168
+ await this.storeQueue
169
+ this.flushPendingStore()
170
+ }
171
+ }
172
+
173
+ /** Есть ли принятое клиентское изменение в debounce или очереди записи. */
174
+ public hasPendingStore() {
175
+ return !!this.pendingStore || !!this.storeQueue
176
+ }
177
+
160
178
  public store(body: object, origin: CuboCrdtServerDocumentOrigin, row = this.getJson()) {
161
179
  const queuedBody = cloneDeep(body)
162
180
  const queuedRow = cloneDeep(row)
163
-
164
181
  // debounce ограничивает частоту вызовов, но не сериализует async store.
165
182
  // Без очереди следующий PATCH мог завершиться раньше предыдущего и затем
166
183
  // быть затёрт старым полным снимком составного поля (например case.extra).
167
- const current = this.storeQueue
168
- .catch(() => undefined)
184
+ const previous = this.storeQueue?.catch(() => undefined) ?? Promise.resolve()
185
+ const current = previous
169
186
  .then(async () => {
170
187
  const previousRow = this.getPersistedState()
171
188
 
@@ -176,8 +193,16 @@ export class CuboCrdtServerDocument {
176
193
  this.setPersistedState(queuedRow)
177
194
  })
178
195
 
179
- this.storeQueue = current
180
- return current
196
+ const queued = current.finally(() => {
197
+ // Если после этой записи не добавилась следующая, очередь опустела.
198
+ // Сама Promise-цепочка является источником истины — отдельный счётчик не нужен.
199
+ if (this.storeQueue === queued) {
200
+ this.storeQueue = undefined
201
+ }
202
+ })
203
+ this.storeQueue = queued
204
+
205
+ return queued
181
206
  }
182
207
 
183
208
  /**
@@ -40,6 +40,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
40
40
  private subscribeGenerations = new Map<string, number>()
41
41
 
42
42
  private documents = new Map<string, CuboCrdtServerDocument>()
43
+ private documentEvictions = new Map<string, object>()
43
44
  private subscribesByDocument = new Map<string, Set<string>>()
44
45
  // обратный индекс: подписка -> имена документов, к которым она привязана.
45
46
  // Нужен, чтобы отписка/очистка не сканировала ВСЕ документы сервера (O(subs x docs)).
@@ -111,34 +112,53 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
111
112
  this.cleanSubscribe(subscribe_id)
112
113
  })
113
114
 
114
- this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, async ({ message }) => {
115
+ this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, async ({ client, message }) => {
115
116
  let data = message.data as any
116
117
  if (!Array.isArray(data)) {
117
118
  data = [data]
118
119
  }
119
120
 
120
121
  if (this.debug) {
121
- console.log('[CRDT] incoming event', data)
122
+ console.log('[CRDT] incoming event', data.map((row: any) => ({
123
+ action: row.action,
124
+ entity: row.entity,
125
+ entity_id: row.entity_id,
126
+ keys: row.origin?.keys,
127
+ subscribe_id: row.origin?.subscribe_id
128
+ })))
122
129
  }
123
130
 
124
131
  for (const row of data) {
125
132
  const update = new Uint8Array(row.data)
133
+ const origin: CuboCrdtServerDocumentOrigin = {
134
+ ...(row.origin || {}),
135
+ client_id: client.id,
136
+ store_context: {
137
+ client_id: client.id,
138
+ auth: cloneDeep(client.auth)
139
+ }
140
+ }
141
+ const action = { ...row, origin }
126
142
 
127
143
  switch (row.action) {
128
144
  case 'create':
129
- this.onDocumentExternalCreate(update, row)
145
+ this.onDocumentExternalCreate(update, action)
130
146
  break
131
147
  case 'update':
132
- this.onDocumentExternalUpdate(update, row)
148
+ this.onDocumentExternalUpdate(update, action)
133
149
  break
134
150
  case 'delete':
135
- this.onDocumentExternalDelete(update, row)
151
+ this.onDocumentExternalDelete(update, action)
136
152
  break
137
153
  case 'awareness':
138
- this.onAwarenessExternalUpdate(update, row)
154
+ this.onAwarenessExternalUpdate(update, action)
139
155
  break
140
156
  }
141
157
  }
158
+
159
+ // Ответ означает только «update применён к живому серверному Y.Doc».
160
+ // Debounce и последовательная запись в БД продолжаются независимо.
161
+ return { accepted: true }
142
162
  })
143
163
 
144
164
  this.ws.registerHttpHandler('GET', '/stats', () => {
@@ -174,6 +194,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
174
194
  this.subscribeRefreshStates.forEach((state) => clearTimeout(state.timer))
175
195
  this.subscribeRefreshStates.clear()
176
196
  this.subscribeGenerations.clear()
197
+ this.documentEvictions.clear()
177
198
 
178
199
  this.subscribes.forEach((sub) => this.ws.deleteHandler(sub.id))
179
200
  }
@@ -286,6 +307,10 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
286
307
 
287
308
  // привязываем подписку к документу (в обоих индексах)
288
309
  private linkSubscribeToDocument(subscribe_id: string, docName: string) {
310
+ // Новая подписка снова использует живой Y.Doc. Асинхронный flush может
311
+ // продолжаться, но больше не имеет права уничтожить этот документ.
312
+ this.documentEvictions.delete(docName)
313
+
289
314
  let byDoc = this.subscribesByDocument.get(docName)
290
315
  if (!byDoc) {
291
316
  byDoc = new Set()
@@ -309,17 +334,56 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
309
334
 
310
335
  // если у документа нет подписок - сносим его
311
336
  private checkDocumentNeedRemove(name: string) {
312
- if (!this.subscribesByDocument.get(name)?.size) {
313
- if (this.debug) {
314
- console.log('[CRDT] delete document', name)
315
- }
337
+ if (this.subscribesByDocument.get(name)?.size || this.documentEvictions.has(name)) {
338
+ return
339
+ }
316
340
 
317
- this.documents.get(name)?.destroy()
318
- this.documents.delete(name)
319
- // не оставляем пустой Set в индексе (иначе — медленная утечка записей мапы);
320
- // getOrCreateDocument пересоздаст его при повторном появлении документа
341
+ const document = this.documents.get(name)
342
+ if (!document) {
321
343
  this.subscribesByDocument.delete(name)
344
+ return
322
345
  }
346
+
347
+ // Последний клиент мог закрыться сразу после update (обычный refresh).
348
+ // Сначала сохраняем всю принятую очередь, а до её завершения оставляем Y.Doc
349
+ // доступным повторной подписке. Никакого фиксированного ожидания здесь нет.
350
+ const eviction = {}
351
+ this.documentEvictions.set(name, eviction)
352
+
353
+ if (this.debug) {
354
+ console.log('[CRDT] flush document before eviction', name)
355
+ }
356
+
357
+ void document.flushStoreQueue()
358
+ .then(() => {
359
+ if (
360
+ this.documentEvictions.get(name) !== eviction ||
361
+ this.subscribesByDocument.get(name)?.size ||
362
+ this.documents.get(name) !== document
363
+ ) {
364
+ return
365
+ }
366
+
367
+ this.documentEvictions.delete(name)
368
+
369
+ if (this.debug) {
370
+ console.log('[CRDT] delete document', name)
371
+ }
372
+
373
+ document.destroy()
374
+ this.documents.delete(name)
375
+ this.subscribesByDocument.delete(name)
376
+ })
377
+ .catch((error) => {
378
+ // Не уничтожаем единственную живую копию документа, если запись в БД
379
+ // не завершилась. Снимаем eviction, чтобы следующая отписка или явная
380
+ // проверка могла повторно инициировать flush.
381
+ if (this.documentEvictions.get(name) === eviction) {
382
+ this.documentEvictions.delete(name)
383
+ }
384
+
385
+ console.error('[CRDT] flush document before eviction', name, error)
386
+ })
323
387
  }
324
388
 
325
389
  // очистка подписки (при отписке клиента - через emit-метод или options-хук).
@@ -609,21 +673,6 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
609
673
  document.enableAwareness()
610
674
  }
611
675
 
612
- // Освежаем существующий документ строкой из БД.
613
- //
614
- // Раньше документ возвращался как есть, а подписка отправляла клиенту его
615
- // stateAsUpdate — то есть КЭШ документа, а не только что прочитанную строку.
616
- // Документ, проспавший мутацию, оставался устаревшим навсегда: даже перезагрузка
617
- // страницы отдавала старое значение, потому что reconcile брал его же.
618
- //
619
- // Стало заметно после того, как документы без подписчиков начали удаляться:
620
- // документ может быть создан, остаться без подписок, пропустить мутации и
621
- // «воскреснуть» на новой подписке уже неактуальным.
622
- //
623
- // store: false — это не правка от клиента, а синхронизация с БД, писать обратно нечего.
624
- document.write(row, { expose: 'all', store: false })
625
- document.setPersistedState(row)
626
-
627
676
  return document
628
677
  }
629
678
 
@@ -639,7 +688,13 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
639
688
  },
640
689
  onStore: (item: object, origin: CuboCrdtServerDocumentOrigin, store) => {
641
690
  const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
642
- const client = subscribe && this.clients.get(subscribe.client_id)
691
+ const liveClient = subscribe && this.clients.get(subscribe.client_id)
692
+ const client = liveClient || (origin.store_context
693
+ ? ({
694
+ id: origin.store_context.client_id,
695
+ auth: origin.store_context.auth
696
+ } as CuboCrdtSocketClient<A>)
697
+ : undefined)
643
698
 
644
699
  if (client) {
645
700
  return this.options?.storeRow?.(entity as any, entity_id, item as any, {
@@ -675,7 +730,13 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
675
730
  const existed = this.documents.has(documentName)
676
731
  const document = this.getOrCreateDocument(entity, row, awareness)
677
732
 
678
- if (!existed) {
733
+ if (existed && !document.hasPendingStore()) {
734
+ // Только внешний CRUD является подтверждённым источником из БД.
735
+ // Обычная повторная подписка не должна применять потенциально старую строку
736
+ // поверх живого документа с ещё не сохранённым collaborative update.
737
+ document.write(row, { expose: 'all', store: false })
738
+ document.setPersistedState(row)
739
+ } else {
679
740
  this.pushDocumentAction('create', entity, document, document.stateAsUpdate)
680
741
  }
681
742
 
@@ -77,4 +77,16 @@ export type CuboCrdtServerDocumentOrigin = {
77
77
 
78
78
  // id клиента
79
79
  client_id?: string
80
+
81
+ /**
82
+ * Снимок серверного контекста автора в момент получения update.
83
+ *
84
+ * Он нужен только для отложенного сохранения: исходная подписка и сокет могут
85
+ * закрыться при refresh раньше, чем сработает debounce. Клиентское значение
86
+ * этого поля сервер всегда перезаписывает собственными данными.
87
+ */
88
+ store_context?: {
89
+ client_id: string
90
+ auth?: unknown
91
+ }
80
92
  }