@cuboapp/crdt 1.0.21 → 1.0.22

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.22",
4
4
  "description": "CRDT for CUBO",
5
5
  "main": "src/index.ts",
6
6
  "repository": {
@@ -450,47 +450,51 @@ 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 // отправляем на бэк
472
- }
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))
461
+ const document = store?.docs.get(entity_id)
462
+
463
+ if (!store || !document) {
464
+ return
465
+ }
473
466
 
474
- doc.transact(() => {
475
- Object.entries(toUpdate).forEach(([key, value]) => {
476
- map.set(key, value)
477
- })
478
- }, origin)
479
- }
480
- }
467
+ const map = document.getMap()
468
+ const toUpdate = Object.fromEntries(
469
+ Object.entries(dto).filter(([key, value]) => map.get(key) !== value)
470
+ )
471
+
472
+ if (!Object.keys(toUpdate).length) {
473
+ return
481
474
  }
482
475
 
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))
476
+ const origin: CuboCrdtClientDocOrigin = {
477
+ store: opts?.store ?? true,
478
+ expose: opts?.expose ?? 'other',
479
+ keys: opts?.keys ?? Object.keys(toUpdate),
480
+ react: true
481
+ }
487
482
 
488
- if (Object.keys(toUpdate).length) {
489
- Object.entries(toUpdate).forEach(([key, value]) => {
490
- row[key as keyof typeof row] = value
491
- })
492
- }
493
- }
483
+ // Одна сущность может присутствовать в нескольких подписках с разными
484
+ // storeKey. Мутацию отправляем только из одного документа: остальные
485
+ // подписки получат подтверждённый update через обычную CRDT-рассылку.
486
+ document.transact(() => {
487
+ Object.entries(toUpdate).forEach(([key, value]) => {
488
+ map.set(key, value)
489
+ })
490
+ }, origin)
491
+
492
+ const row = store.state.rows.find((item: any) => Number(item.id) === Number(entity_id))
493
+
494
+ if (row) {
495
+ Object.entries(toUpdate).forEach(([key, value]) => {
496
+ row[key] = value
497
+ })
494
498
  }
495
499
  }
496
500
 
@@ -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> = Promise.resolve()
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
  }
@@ -137,17 +152,117 @@ export class CuboCrdtServerDocument {
137
152
  }
138
153
 
139
154
  destroy() {
155
+ this.flushPendingStore()
140
156
  this.awareness?.destroy()
141
157
  this.ydoc.destroy()
142
158
  }
143
159
 
144
- public async store(body: object, origin: CuboCrdtServerDocumentOrigin) {
145
- const previousRow = this.getPersistedState()
146
- const row = this.getJson()
160
+ public store(body: object, origin: CuboCrdtServerDocumentOrigin, row = this.getJson()) {
161
+ const queuedBody = cloneDeep(body)
162
+ const queuedRow = cloneDeep(row)
163
+
164
+ // debounce ограничивает частоту вызовов, но не сериализует async store.
165
+ // Без очереди следующий PATCH мог завершиться раньше предыдущего и затем
166
+ // быть затёрт старым полным снимком составного поля (например case.extra).
167
+ const current = this.storeQueue
168
+ .catch(() => undefined)
169
+ .then(async () => {
170
+ const previousRow = this.getPersistedState()
171
+
172
+ await this.opts?.onStore?.(queuedBody, origin, {
173
+ previousRow,
174
+ row: queuedRow
175
+ })
176
+ this.setPersistedState(queuedRow)
177
+ })
178
+
179
+ this.storeQueue = current
180
+ return current
181
+ }
182
+
183
+ /**
184
+ * Схлопывает только соседние изменения одного origin и одного набора
185
+ * leaf-путей. Смена пользователя или поля немедленно фиксирует предыдущий
186
+ * пакет, сохраняя фактический порядок collaborative-обновлений.
187
+ */
188
+ public debounceStore(body: object, origin: CuboCrdtServerDocumentOrigin) {
189
+ const row = cloneDeep(this.getJson())
190
+ const bodyKeys = Object.keys(body)
191
+ const previousBody = pick(this.observedState, bodyKeys)
192
+ const changeKey = getCrdtStoreChangeKey(previousBody, body)
193
+ const originKey = `${origin.client_id || ''}:${origin.subscribe_id || ''}`
194
+
195
+ this.observedState = cloneDeep(row)
196
+
197
+ if (!changeKey) {
198
+ return Promise.resolve()
199
+ }
200
+
201
+ return new Promise<void>((resolve, reject) => {
202
+ const sameBatch = this.pendingStore?.originKey === originKey &&
203
+ this.pendingStore.changeKey === changeKey
204
+
205
+ if (this.pendingStore && !sameBatch) {
206
+ this.flushPendingStore()
207
+ }
208
+
209
+ if (!this.pendingStore) {
210
+ this.pendingStore = {
211
+ body: cloneDeep(body),
212
+ changeKey,
213
+ origin: cloneDeep(origin),
214
+ originKey,
215
+ row,
216
+ waiters: []
217
+ }
218
+ } else {
219
+ const keys = new Set([
220
+ ...(this.pendingStore.origin.keys || []),
221
+ ...(origin.keys || [])
222
+ ])
223
+
224
+ this.pendingStore.body = {
225
+ ...this.pendingStore.body,
226
+ ...cloneDeep(body)
227
+ }
228
+ this.pendingStore.origin = {
229
+ ...cloneDeep(origin),
230
+ keys: [...keys]
231
+ }
232
+ this.pendingStore.row = row
233
+ }
234
+
235
+ this.pendingStore.waiters.push({ resolve, reject })
236
+ this.schedulePendingStore()
237
+ })
238
+ }
239
+
240
+ /** Перезапускает trailing debounce для текущего однородного пакета. */
241
+ private schedulePendingStore() {
242
+ if (this.storeTimeout) {
243
+ clearTimeout(this.storeTimeout)
244
+ }
147
245
 
148
- await this.opts?.onStore?.(body, origin, { previousRow, row })
149
- this.setPersistedState(row)
246
+ this.storeTimeout = setTimeout(() => this.flushPendingStore(), STORE_DEBOUNCE_MS)
150
247
  }
151
248
 
152
- public debounceStore = debounce(this.store.bind(this), 300)
249
+ /** Передаёт накопленный пакет в общую последовательную очередь записи. */
250
+ private flushPendingStore() {
251
+ if (!this.pendingStore) {
252
+ return
253
+ }
254
+
255
+ if (this.storeTimeout) {
256
+ clearTimeout(this.storeTimeout)
257
+ this.storeTimeout = undefined
258
+ }
259
+
260
+ const pending = this.pendingStore
261
+ this.pendingStore = undefined
262
+
263
+ this.store(pending.body, pending.origin, pending.row).then(
264
+ () => pending.waiters.forEach(({ resolve }) => resolve()),
265
+ (error) => pending.waiters.forEach(({ reject }) => reject(error))
266
+ )
267
+ }
153
268
  }
@@ -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?: (
@@ -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
+ }