@cuboapp/crdt 1.0.21 → 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.21",
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,48 +450,92 @@ 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 cardRow = this.store[`${entity}:${entity_id}`]?.state.rows?.[0]
454
- const listRow = this.store[entity]?.state?.rows.find((r: any) => r.id === entity_id)
455
- const cardDoc = this.store[`${entity}:${entity_id}`]?.docs.get(entity_id)
456
- const listDoc = this.store[entity]?.docs.get(entity_id)
457
-
458
- // обновляем документы
459
- for (const doc of [cardDoc, listDoc]) {
460
- if (doc) {
461
- const map = doc.getMap()
462
-
463
- const row = (cardRow || listRow) as any
464
- const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => row[key] !== value))
465
-
466
- if (Object.keys(toUpdate).length) {
467
- const origin: CuboCrdtClientDocOrigin = {
468
- store: opts?.store ?? true,
469
- expose: opts?.expose ?? 'other',
470
- keys: opts?.keys ?? Object.keys(toUpdate),
471
- react: true // отправляем на бэк
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')
463
+ const document = store?.docs.get(entity_id)
464
+
465
+ if (!store || !document) {
466
+ return false
467
+ }
468
+
469
+ const map = document.getMap()
470
+ const toUpdate = Object.fromEntries(
471
+ Object.entries(dto).filter(([key, value]) => map.get(key) !== value)
472
+ )
473
+
474
+ if (!Object.keys(toUpdate).length) {
475
+ // Состояние уже совпадает (например, update успел прийти из другой
476
+ // вкладки). Это успешный no-op, а не ошибка изменения.
477
+ return true
478
+ }
479
+
480
+ const origin: CuboCrdtClientDocOrigin = {
481
+ store: opts?.store ?? true,
482
+ expose: opts?.expose ?? 'other',
483
+ keys: opts?.keys ?? Object.keys(toUpdate),
484
+ // update() сам отправляет ровно один пакет ниже. Общий listener остаётся
485
+ // для изменений, которые создаются напрямую сторонними Yjs-binding'ами.
486
+ react: false
487
+ }
488
+
489
+ const stateVector = encodeStateVector(document)
490
+
491
+ // Одна сущность может присутствовать в нескольких подписках с разными
492
+ // storeKey. Мутацию отправляем только из одного документа: остальные
493
+ // подписки получат подтверждённый update через обычную CRDT-рассылку.
494
+ document.transact(() => {
495
+ Object.entries(toUpdate).forEach(([key, value]) => {
496
+ map.set(key, value)
497
+ })
498
+ }, origin)
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
+ }
472
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
+ })
473
524
 
474
- doc.transact(() => {
475
- Object.entries(toUpdate).forEach(([key, value]) => {
476
- map.set(key, value)
477
- })
478
- }, origin)
479
- }
480
- }
481
- }
525
+ // Этот документ является каноническим локальным состоянием строки. Обновляем
526
+ // его реактивную строку сразу, а другие подписки получат тот же Yjs-update
527
+ // через серверную рассылку.
528
+ this.applyRowJson(`${entity}:${entity_id}`, entity_id, document)
482
529
 
483
- // обновляем реактивку
484
- for (const row of [cardRow, listRow]) {
485
- if (row) {
486
- const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => row[key as keyof typeof row] !== value))
530
+ const row = store.state.rows.find((item: any) => Number(item.id) === Number(entity_id))
487
531
 
488
- if (Object.keys(toUpdate).length) {
489
- Object.entries(toUpdate).forEach(([key, value]) => {
490
- row[key as keyof typeof row] = value
491
- })
492
- }
493
- }
532
+ if (row) {
533
+ Object.entries(toUpdate).forEach(([key, value]) => {
534
+ row[key] = value
535
+ })
494
536
  }
537
+
538
+ return receipt
495
539
  }
496
540
 
497
541
  public upgrade(subscribe_id: string, filters?: Record<string, any>) {
@@ -645,10 +689,10 @@ export class CuboCrdtClient<M> {
645
689
  }
646
690
  }
647
691
 
648
- this.ws.request({
692
+ void this.ws.request({
649
693
  method: CUBO_CRDT_EVENT.EVENT,
650
694
  data
651
- })
695
+ }).catch((error) => console.error('[CRDT] update request failed', error))
652
696
  }
653
697
  })
654
698
 
@@ -1,12 +1,23 @@
1
- import { cloneDeep, debounce, pick } from '@cuboapp/utils'
1
+ import { cloneDeep, pick } from '@cuboapp/utils'
2
2
  import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs'
3
3
  import { Awareness, encodeAwarenessUpdate } from 'y-protocols/awareness'
4
4
 
5
- import { CuboCrdtServerDocumentOptions, CuboCrdtServerDocumentOrigin } from '../types'
5
+ import {
6
+ CuboCrdtServerDocumentOptions,
7
+ CuboCrdtServerDocumentOrigin,
8
+ CuboCrdtServerDocumentPendingStore
9
+ } from '../types'
10
+ import { getCrdtStoreChangeKey } from '../utils'
11
+
12
+ const STORE_DEBOUNCE_MS = 300
6
13
 
7
14
  export class CuboCrdtServerDocument {
8
15
  private ydoc: Doc
9
16
  private persistedState: object = {}
17
+ private observedState: object = {}
18
+ private storeQueue?: Promise<void>
19
+ private pendingStore?: CuboCrdtServerDocumentPendingStore
20
+ private storeTimeout?: ReturnType<typeof setTimeout>
10
21
  public awareness: Awareness
11
22
  public awarenessBySubscribe = new Map<string, Set<number>>()
12
23
 
@@ -23,6 +34,7 @@ export class CuboCrdtServerDocument {
23
34
  }
24
35
 
25
36
  this.persistedState = cloneDeep(opts.initialState ?? {})
37
+ this.observedState = cloneDeep(opts.initialState ?? {})
26
38
 
27
39
  // записываем исходное состояние
28
40
  this.write(opts.initialState ?? {})
@@ -37,7 +49,8 @@ export class CuboCrdtServerDocument {
37
49
  this.opts?.onUpdate?.(data, origin)
38
50
  // }
39
51
 
40
- // если нужно сохранять стейт документа - вызываем store по дебаунсу
52
+ // Состояние отслеживается синхронно с Y.Doc. Для клиентских изменений
53
+ // debounceStore сам обновит observedState после вычисления leaf-diff.
41
54
  if (origin?.store) {
42
55
  const row = this.getJson()
43
56
  const body = pick(row, origin.keys ?? Object.keys(row))
@@ -47,6 +60,8 @@ export class CuboCrdtServerDocument {
47
60
  console.error('[CRDT] store document', this.name, error)
48
61
  })
49
62
  }
63
+ } else {
64
+ this.observedState = cloneDeep(this.getJson())
50
65
  }
51
66
  })
52
67
  }
@@ -141,13 +156,138 @@ export class CuboCrdtServerDocument {
141
156
  this.ydoc.destroy()
142
157
  }
143
158
 
144
- public async store(body: object, origin: CuboCrdtServerDocumentOrigin) {
145
- const previousRow = this.getPersistedState()
146
- const row = this.getJson()
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
+
178
+ public store(body: object, origin: CuboCrdtServerDocumentOrigin, row = this.getJson()) {
179
+ const queuedBody = cloneDeep(body)
180
+ const queuedRow = cloneDeep(row)
181
+ // debounce ограничивает частоту вызовов, но не сериализует async store.
182
+ // Без очереди следующий PATCH мог завершиться раньше предыдущего и затем
183
+ // быть затёрт старым полным снимком составного поля (например case.extra).
184
+ const previous = this.storeQueue?.catch(() => undefined) ?? Promise.resolve()
185
+ const current = previous
186
+ .then(async () => {
187
+ const previousRow = this.getPersistedState()
188
+
189
+ await this.opts?.onStore?.(queuedBody, origin, {
190
+ previousRow,
191
+ row: queuedRow
192
+ })
193
+ this.setPersistedState(queuedRow)
194
+ })
195
+
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
206
+ }
207
+
208
+ /**
209
+ * Схлопывает только соседние изменения одного origin и одного набора
210
+ * leaf-путей. Смена пользователя или поля немедленно фиксирует предыдущий
211
+ * пакет, сохраняя фактический порядок collaborative-обновлений.
212
+ */
213
+ public debounceStore(body: object, origin: CuboCrdtServerDocumentOrigin) {
214
+ const row = cloneDeep(this.getJson())
215
+ const bodyKeys = Object.keys(body)
216
+ const previousBody = pick(this.observedState, bodyKeys)
217
+ const changeKey = getCrdtStoreChangeKey(previousBody, body)
218
+ const originKey = `${origin.client_id || ''}:${origin.subscribe_id || ''}`
219
+
220
+ this.observedState = cloneDeep(row)
221
+
222
+ if (!changeKey) {
223
+ return Promise.resolve()
224
+ }
225
+
226
+ return new Promise<void>((resolve, reject) => {
227
+ const sameBatch = this.pendingStore?.originKey === originKey &&
228
+ this.pendingStore.changeKey === changeKey
229
+
230
+ if (this.pendingStore && !sameBatch) {
231
+ this.flushPendingStore()
232
+ }
233
+
234
+ if (!this.pendingStore) {
235
+ this.pendingStore = {
236
+ body: cloneDeep(body),
237
+ changeKey,
238
+ origin: cloneDeep(origin),
239
+ originKey,
240
+ row,
241
+ waiters: []
242
+ }
243
+ } else {
244
+ const keys = new Set([
245
+ ...(this.pendingStore.origin.keys || []),
246
+ ...(origin.keys || [])
247
+ ])
248
+
249
+ this.pendingStore.body = {
250
+ ...this.pendingStore.body,
251
+ ...cloneDeep(body)
252
+ }
253
+ this.pendingStore.origin = {
254
+ ...cloneDeep(origin),
255
+ keys: [...keys]
256
+ }
257
+ this.pendingStore.row = row
258
+ }
259
+
260
+ this.pendingStore.waiters.push({ resolve, reject })
261
+ this.schedulePendingStore()
262
+ })
263
+ }
264
+
265
+ /** Перезапускает trailing debounce для текущего однородного пакета. */
266
+ private schedulePendingStore() {
267
+ if (this.storeTimeout) {
268
+ clearTimeout(this.storeTimeout)
269
+ }
147
270
 
148
- await this.opts?.onStore?.(body, origin, { previousRow, row })
149
- this.setPersistedState(row)
271
+ this.storeTimeout = setTimeout(() => this.flushPendingStore(), STORE_DEBOUNCE_MS)
150
272
  }
151
273
 
152
- public debounceStore = debounce(this.store.bind(this), 300)
274
+ /** Передаёт накопленный пакет в общую последовательную очередь записи. */
275
+ private flushPendingStore() {
276
+ if (!this.pendingStore) {
277
+ return
278
+ }
279
+
280
+ if (this.storeTimeout) {
281
+ clearTimeout(this.storeTimeout)
282
+ this.storeTimeout = undefined
283
+ }
284
+
285
+ const pending = this.pendingStore
286
+ this.pendingStore = undefined
287
+
288
+ this.store(pending.body, pending.origin, pending.row).then(
289
+ () => pending.waiters.forEach(({ resolve }) => resolve()),
290
+ (error) => pending.waiters.forEach(({ reject }) => reject(error))
291
+ )
292
+ }
153
293
  }
@@ -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
 
@@ -5,6 +5,20 @@ export type CuboCrdtServerDocumentStoreContext = {
5
5
  row: object
6
6
  }
7
7
 
8
+ export type CuboCrdtServerDocumentStoreWaiter = {
9
+ resolve: () => void
10
+ reject: (reason?: unknown) => void
11
+ }
12
+
13
+ export type CuboCrdtServerDocumentPendingStore = {
14
+ body: object
15
+ changeKey: string
16
+ origin: CuboCrdtServerDocumentOrigin
17
+ originKey: string
18
+ row: object
19
+ waiters: CuboCrdtServerDocumentStoreWaiter[]
20
+ }
21
+
8
22
  export type CuboCrdtServerDocumentOptions = {
9
23
  name: string
10
24
  onStore?: (
@@ -63,4 +77,16 @@ export type CuboCrdtServerDocumentOrigin = {
63
77
 
64
78
  // id клиента
65
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
+ }
66
92
  }
@@ -0,0 +1,41 @@
1
+ const isObject = (value: unknown): value is Record<string, unknown> => {
2
+ return value !== null && typeof value === 'object'
3
+ }
4
+
5
+ const collectChangedPaths = (
6
+ previous: unknown,
7
+ current: unknown,
8
+ path: string,
9
+ result: string[]
10
+ ) => {
11
+ if (Object.is(previous, current)) {
12
+ return
13
+ }
14
+
15
+ if (!isObject(previous) || !isObject(current)) {
16
+ result.push(path || '$')
17
+ return
18
+ }
19
+
20
+ const keys = new Set([
21
+ ...Object.keys(previous),
22
+ ...Object.keys(current)
23
+ ])
24
+
25
+ for (const key of [...keys].sort()) {
26
+ const nextPath = Array.isArray(previous) || Array.isArray(current)
27
+ ? `${path}[${key}]`
28
+ : path ? `${path}.${key}` : key
29
+
30
+ collectChangedPaths(previous[key], current[key], nextPath, result)
31
+ }
32
+ }
33
+
34
+ /** Возвращает стабильный ключ фактически изменённых leaf-путей объекта. */
35
+ export const getCrdtStoreChangeKey = (previous: object, current: object) => {
36
+ const paths: string[] = []
37
+
38
+ collectChangedPaths(previous, current, '', paths)
39
+
40
+ return paths.join('|')
41
+ }