@cuboapp/crdt 1.0.9 → 1.0.10

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.9",
3
+ "version": "1.0.10",
4
4
  "description": "CRDT for CUBO",
5
5
  "main": "src/index.ts",
6
6
  "repository": "git@github.com:cuboapp/crdt.git",
@@ -15,6 +15,7 @@
15
15
  "@cuboapp/utils": "1.0.10",
16
16
  "@cuboapp/ws": "1.0.6",
17
17
  "ws": "^8.19.0",
18
+ "y-protocols": "^1.0.7",
18
19
  "yjs": "^13.6.29"
19
20
  },
20
21
  "peerDependencies": {
@@ -1,6 +1,7 @@
1
1
  import { keyBy, pick, uuid } from '@cuboapp/utils'
2
2
  import { computed, reactive } from 'vue'
3
3
  import { applyUpdate, Doc } from 'yjs'
4
+ import { applyAwarenessUpdate, Awareness, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'
4
5
 
5
6
  import { CUBO_CRDT_EVENT } from '../constants'
6
7
  import { CuboCrdtServerDocumentIncomingAction } from '../server'
@@ -16,13 +17,19 @@ import {
16
17
  CuboCrdtClientSubscribeEvent,
17
18
  CuboCrdtClientUseOptions
18
19
  } from './types'
20
+ import { AsyncSerialQueue } from './queue'
19
21
 
20
22
  export * from './types'
21
23
 
22
24
  export class CuboCrdtClient<M> {
23
25
  public store: CuboCrdtClientStore<M> = {}
24
26
 
25
- private listeners: Map<string, Map<string, (ctx: CuboCrdtClientSubscribeEvent) => void>> = new Map()
27
+ private listeners: Map<string, Map<string, (ctx: CuboCrdtClientSubscribeEvent) => void | Promise<void>>> = new Map()
28
+ private eventQueue = new AsyncSerialQueue({
29
+ errorMode: 'continue',
30
+ onError: (e) => console.error('[CRDT] inbound queue task failed', e),
31
+ maxSize: 10_000
32
+ })
26
33
 
27
34
  constructor(private opts: CuboCrdtClientOptions<M>) {
28
35
  opts.entities.forEach((e) => this.listeners.set(e, new Map()))
@@ -30,30 +37,33 @@ export class CuboCrdtClient<M> {
30
37
 
31
38
  async start() {
32
39
  this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, ({ message }) => {
33
- let events: CuboCrdtClientSubscribeEvent[] = message.data as any
34
- if (!Array.isArray(events)) {
35
- events = [events as any]
36
- }
37
-
38
- // todo: perform events through queue in await mode
39
- events.forEach((event) => {
40
- const listeners = this.listeners.get(event.entity)
41
-
42
- if (this.debug) {
43
- console.log('[CRDT] incoming event', { listeners: listeners?.size, event })
44
- }
45
-
46
- if (event.subscribe_id) {
47
- const listener = listeners?.get(event.subscribe_id)
48
- if (!listener) {
49
- console.warn('[CRDT] listener not found:', event.subscribe_id, event)
50
- } else {
51
- listener(event)
40
+ this.eventQueue
41
+ .enqueue(async () => {
42
+ let events: CuboCrdtClientSubscribeEvent[] = message.data as any
43
+ if (!Array.isArray(events)) events = [events as any]
44
+
45
+ for (const event of events) {
46
+ const listeners = this.listeners.get(event.entity)
47
+
48
+ if (this.debug) {
49
+ console.log('[CRDT] incoming event', { listeners: listeners?.size, event })
50
+ }
51
+
52
+ if (event.subscribe_id) {
53
+ const listener = listeners?.get(event.subscribe_id)
54
+ if (!listener) {
55
+ console.warn('[CRDT] listener not found:', event.subscribe_id, event)
56
+ continue
57
+ }
58
+ await listener(event)
59
+ } else {
60
+ for (const cb of listeners?.values() ?? []) {
61
+ await cb(event)
62
+ }
63
+ }
52
64
  }
53
- } else {
54
- listeners?.forEach((cb) => cb(event))
55
- }
56
- })
65
+ })
66
+ .catch((e) => console.error('[CRDT] enqueue failed', e))
57
67
  })
58
68
  }
59
69
 
@@ -91,7 +101,8 @@ export class CuboCrdtClient<M> {
91
101
 
92
102
  this.store[storeKey] = {
93
103
  state,
94
- docs: new Map()
104
+ docs: new Map(),
105
+ awarenesses: new Map()
95
106
  }
96
107
  }
97
108
 
@@ -111,17 +122,24 @@ export class CuboCrdtClient<M> {
111
122
  }
112
123
 
113
124
  // подписываемся на фронте
114
- this.listeners.get(entity)?.set(subscribe_id, (ctx) => {
115
- this.onIncomingUpdate(ctx, {
125
+ this.listeners.get(entity)?.set(subscribe_id, async (ctx) => {
126
+ await this.onIncomingUpdate(ctx, {
116
127
  subscribe_id,
117
128
  storeKey,
118
- ...(pick(opts || {}, ['onBeforeCreate', 'onBeforeUpdate', 'onBeforeDelete', 'onAfterCreate', 'onAfterUpdate', 'onAfterDelete']) ||
119
- {})
129
+ ...(pick(opts || {}, [
130
+ 'onBeforeCreate',
131
+ 'onBeforeUpdate',
132
+ 'onBeforeDelete',
133
+ 'onAfterCreate',
134
+ 'onAfterUpdate',
135
+ 'onAfterDelete',
136
+ 'awareness'
137
+ ]) || {})
120
138
  })
121
139
  })
122
140
 
123
141
  // подписываемся на бэке
124
- this.ws.request({ method: CUBO_CRDT_EVENT.SUBSCRIBE, data: { subscribe_id, entity, filters } })
142
+ this.ws.request({ method: CUBO_CRDT_EVENT.SUBSCRIBE, data: { subscribe_id, entity, filters, awareness: opts?.awareness } })
125
143
  }
126
144
 
127
145
  // отписка
@@ -138,6 +156,19 @@ export class CuboCrdtClient<M> {
138
156
 
139
157
  // очищаем стор
140
158
  if (clear !== false) {
159
+ const item = this.store[storeKey]
160
+ if (item) {
161
+ item.awarenesses?.forEach((a) => {
162
+ removeAwarenessStates(a, [a.doc.clientID], 'unsubscribe')
163
+
164
+ a.setLocalState(null)
165
+ a.destroy()
166
+ })
167
+ item.awarenesses?.clear()
168
+ item.docs?.forEach((d) => d.destroy())
169
+ item.docs?.clear()
170
+ }
171
+
141
172
  this.store[storeKey] = undefined
142
173
  }
143
174
  }
@@ -160,6 +191,7 @@ export class CuboCrdtClient<M> {
160
191
  upgrade,
161
192
  unsubscribe,
162
193
  docs: computed(() => this.store[storeKey]?.docs),
194
+ awarenesses: opts?.awareness ? computed(() => this.store[storeKey]?.awarenesses) : undefined,
163
195
  subscribed: () => computed(() => this.store[storeKey]?.state.subscribed || false),
164
196
  rows: () => computed(() => this.store[storeKey]?.state.rows as T[]),
165
197
  rowsById: () => computed(() => keyBy(this.store[storeKey]?.state.rows as T[], 'id' as keyof T))
@@ -173,7 +205,7 @@ export class CuboCrdtClient<M> {
173
205
  ): CuboCrdtClientRow<T> {
174
206
  // console.log('store key', opts?.storeKey ?? `${entity}:${id}`)
175
207
 
176
- const { subscribe_id, storeKey, docs, subscribe, upgrade, unsubscribe, subscribed } = this.useList(entity, {
208
+ const { subscribe_id, storeKey, docs, awarenesses, subscribe, upgrade, unsubscribe, subscribed } = this.useList(entity, {
177
209
  ...opts,
178
210
  filters: opts?.filters ?? { id },
179
211
  storeKey: opts?.storeKey ?? `${entity}:${id}`
@@ -183,6 +215,7 @@ export class CuboCrdtClient<M> {
183
215
  subscribe_id,
184
216
  storeKey,
185
217
  doc: computed(() => docs.value?.get(id)),
218
+ awareness: opts?.awareness ? computed(() => awarenesses?.value?.get(id)) : undefined,
186
219
  subscribe,
187
220
  upgrade,
188
221
  unsubscribe,
@@ -310,6 +343,37 @@ export class CuboCrdtClient<M> {
310
343
  if (opts?.onAfterCreate) {
311
344
  await opts.onAfterCreate(doc, update, ctx, opts)
312
345
  }
346
+
347
+ if (opts?.awareness) {
348
+ let awareness = this.store[opts.storeKey]?.awarenesses.get(ctx.entity_id)
349
+
350
+ if (!awareness) {
351
+ awareness = new Awareness(doc)
352
+
353
+ awareness.on('update', ({ added, updated, removed }, origin) => {
354
+ console.log('[CRDT] awarness update', origin)
355
+ if (origin === 'remote') {
356
+ return
357
+ }
358
+
359
+ const changed = added.concat(updated).concat(removed)
360
+ const update = encodeAwarenessUpdate(awareness, changed)
361
+
362
+ this.ws.request({
363
+ method: CUBO_CRDT_EVENT.EVENT,
364
+ data: {
365
+ action: 'awareness',
366
+ entity: ctx.entity,
367
+ entity_id: ctx.entity_id,
368
+ data: Array.from(update),
369
+ origin: { subscribe_id: opts.subscribe_id }
370
+ }
371
+ })
372
+ })
373
+
374
+ this.store[opts.storeKey]?.awarenesses.set(ctx.entity_id, awareness)
375
+ }
376
+ }
313
377
  }
314
378
 
315
379
  private async onDocumentUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
@@ -377,31 +441,49 @@ export class CuboCrdtClient<M> {
377
441
  }
378
442
  }
379
443
 
380
- if (opts?.onAfterDelete) {
381
- await opts.onAfterDelete(doc, ctx, opts)
382
- }
383
- }
444
+ const awareness = this.store[opts.storeKey]?.awarenesses.get(ctx.entity_id)
445
+ if (awareness) {
446
+ removeAwarenessStates(awareness, [awareness.doc.clientID], 'unsubscribe')
384
447
 
385
- private onIncomingUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
386
- if (this.debug) {
387
- console.log('[CRDT] call event listener', ctx, opts)
448
+ awareness.destroy()
449
+ this.store[opts.storeKey]?.awarenesses?.delete(ctx.entity_id)
388
450
  }
389
451
 
390
- if (!this.store[opts.storeKey]) {
391
- console.warn('[CRDT] on incoming update, store key not found: "' + opts.storeKey + '"')
452
+ if (opts?.onAfterDelete) {
453
+ await opts.onAfterDelete(doc, ctx, opts)
392
454
  }
455
+ }
393
456
 
457
+ private async onIncomingUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
394
458
  switch (ctx.action) {
395
459
  case 'create':
396
- this.onDocumentCreate(ctx, opts)
460
+ await this.onDocumentCreate(ctx, opts)
397
461
  break
398
462
  case 'update':
399
463
  case 'upsert':
400
- this.onDocumentUpdate(ctx, opts)
464
+ await this.onDocumentUpdate(ctx, opts)
401
465
  break
402
466
  case 'delete':
403
- this.onDocumentDelete(ctx, opts)
467
+ await this.onDocumentDelete(ctx, opts)
404
468
  break
469
+ case 'awareness':
470
+ await this.onAwarenessUpdate(ctx, opts)
471
+ break
472
+ }
473
+ }
474
+
475
+ private onAwarenessUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
476
+ const awareness = this.store[opts.storeKey]?.awarenesses?.get(ctx.entity_id)
477
+ if (!awareness) {
478
+ return
479
+ }
480
+
481
+ const update = new Uint8Array(ctx.data as any)
482
+
483
+ try {
484
+ applyAwarenessUpdate(awareness, update, 'remote')
485
+ } catch (e) {
486
+ console.warn('[CRDT] applyAwarenessUpdate failed ', e)
405
487
  }
406
488
  }
407
489
 
@@ -0,0 +1,153 @@
1
+ type QueueErrorMode = 'continue' | 'stop'
2
+
3
+ export interface AsyncQueueOptions {
4
+ /**
5
+ * Что делать, если задача упала:
6
+ * - continue: логируем/пробрасываем через onError и идём дальше
7
+ * - stop: останавливаем очередь (следующие задачи не стартуют)
8
+ */
9
+ errorMode?: QueueErrorMode
10
+
11
+ /**
12
+ * Хук на ошибки задач (если errorMode=continue — обязательно пригодится)
13
+ */
14
+ onError?: (err: unknown) => void
15
+
16
+ /**
17
+ * Максимальный размер очереди (для защиты от утечек, опционально)
18
+ */
19
+ maxSize?: number
20
+ }
21
+
22
+ export class AsyncSerialQueue {
23
+ private running = false
24
+ private stopped = false
25
+
26
+ private readonly errorMode: QueueErrorMode
27
+ private readonly onError?: (err: unknown) => void
28
+ private readonly maxSize?: number
29
+
30
+ private tasks: Array<{
31
+ run: () => Promise<void>
32
+ resolve: () => void
33
+ reject: (e: unknown) => void
34
+ }> = []
35
+
36
+ private drainWaiters: Array<() => void> = []
37
+
38
+ constructor(opts: AsyncQueueOptions = {}) {
39
+ this.errorMode = opts.errorMode ?? 'continue'
40
+ this.onError = opts.onError
41
+ this.maxSize = opts.maxSize
42
+ }
43
+
44
+ get size() {
45
+ return this.tasks.length
46
+ }
47
+
48
+ get isRunning() {
49
+ return this.running
50
+ }
51
+
52
+ get isStopped() {
53
+ return this.stopped
54
+ }
55
+
56
+ /**
57
+ * Добавляет задачу в очередь.
58
+ * Возвращает промис, который резолвится когда задача реально выполнена.
59
+ */
60
+ enqueue(fn: () => void | Promise<void>): Promise<void> {
61
+ if (this.stopped) {
62
+ return Promise.reject(new Error('Queue is stopped'))
63
+ }
64
+
65
+ if (this.maxSize !== undefined && this.tasks.length >= this.maxSize) {
66
+ return Promise.reject(new Error(`Queue overflow: maxSize=${this.maxSize}`))
67
+ }
68
+
69
+ return new Promise<void>((resolve, reject) => {
70
+ this.tasks.push({
71
+ run: async () => {
72
+ await fn()
73
+ },
74
+ resolve,
75
+ reject
76
+ })
77
+
78
+ void this.pump()
79
+ })
80
+ }
81
+
82
+ /**
83
+ * Ждёт, пока очередь станет пустой и ничего не выполняется.
84
+ */
85
+ async drain(): Promise<void> {
86
+ if (!this.running && this.tasks.length === 0) return
87
+ await new Promise<void>((res) => this.drainWaiters.push(res))
88
+ }
89
+
90
+ /**
91
+ * Очищает очередь (не прерывая текущую выполняемую задачу).
92
+ * Невыполненные задачи получат reject.
93
+ */
94
+ clear(reason: unknown = new Error('Queue cleared')): void {
95
+ const pending = this.tasks
96
+ this.tasks = []
97
+ for (const t of pending) t.reject(reason)
98
+ this.notifyDrainedIfNeeded()
99
+ }
100
+
101
+ /**
102
+ * Останавливает очередь: новые enqueue будут отвергнуты.
103
+ * Можно опционально очистить pending.
104
+ */
105
+ stop(opts: { clear?: boolean; reason?: unknown } = {}): void {
106
+ this.stopped = true
107
+ if (opts.clear) this.clear(opts.reason ?? new Error('Queue stopped'))
108
+ }
109
+
110
+ /**
111
+ * Разрешает снова enqueue (не запускает автоматически).
112
+ */
113
+ resume(): void {
114
+ this.stopped = false
115
+ void this.pump()
116
+ }
117
+
118
+ private async pump(): Promise<void> {
119
+ if (this.running) return
120
+ this.running = true
121
+
122
+ try {
123
+ while (!this.stopped) {
124
+ const task = this.tasks.shift()
125
+ if (!task) break
126
+
127
+ try {
128
+ await task.run()
129
+ task.resolve()
130
+ } catch (e) {
131
+ this.onError?.(e)
132
+ task.reject(e)
133
+
134
+ if (this.errorMode === 'stop') {
135
+ this.stopped = true
136
+ break
137
+ }
138
+ }
139
+ }
140
+ } finally {
141
+ this.running = false
142
+ this.notifyDrainedIfNeeded()
143
+ }
144
+ }
145
+
146
+ private notifyDrainedIfNeeded() {
147
+ if (!this.running && this.tasks.length === 0 && this.drainWaiters.length) {
148
+ const waiters = this.drainWaiters
149
+ this.drainWaiters = []
150
+ for (const w of waiters) w()
151
+ }
152
+ }
153
+ }
@@ -1,3 +1,4 @@
1
+ import { CuboCrdtClientUseOptions } from '..'
1
2
  import { CuboCrdtExposeStrategy } from '../../types'
2
3
 
3
4
  import { CuboCrdtClientBaseOptions } from './utils'
@@ -19,4 +20,5 @@ export type CuboCrdtClientDocOrigin = {
19
20
  export type CuboCrdtClientDocUpdateOptions = { subscribe_id: string; storeKey: string } & Pick<
20
21
  CuboCrdtClientBaseOptions,
21
22
  'onBeforeCreate' | 'onAfterCreate' | 'onBeforeUpdate' | 'onAfterUpdate' | 'onBeforeDelete' | 'onAfterDelete'
22
- >
23
+ > &
24
+ Pick<CuboCrdtClientUseOptions, 'awareness'>
@@ -1,5 +1,6 @@
1
1
  import { type WsClient } from '@cuboapp/ws'
2
2
  import { ComputedRef } from 'vue'
3
+ import { Awareness } from 'y-protocols/awareness'
3
4
  import { type Doc } from 'yjs'
4
5
 
5
6
  import { CuboCrdtAction } from '../../types'
@@ -19,6 +20,7 @@ export type CuboCrdtClientOptions<M> = {
19
20
  export type CuboCrdtClientUseOptions = CuboCrdtClientBaseOptions & {
20
21
  storeKey?: string
21
22
  filters?: Record<string, any>
23
+ awareness?: boolean
22
24
  }
23
25
 
24
26
  export type CuboCrdtClientSubscribeEvent = {
@@ -33,6 +35,7 @@ export type CuboCrdtClientList<T> = {
33
35
  subscribe_id: string
34
36
  storeKey: string
35
37
  docs: ComputedRef<Map<number, Doc> | undefined>
38
+ awarenesses?: ComputedRef<Map<number, Awareness> | undefined>
36
39
  subscribe: (filters?: any) => void
37
40
  upgrade: (filters?: any) => void
38
41
  unsubscribe: (clear?: boolean) => void
@@ -45,6 +48,7 @@ export type CuboCrdtClientRow<T> = {
45
48
  subscribe_id: string
46
49
  storeKey: string
47
50
  doc: ComputedRef<Doc | undefined>
51
+ awareness?: ComputedRef<Awareness | undefined>
48
52
  subscribe: () => void
49
53
  upgrade: (filters?: any) => void
50
54
  unsubscribe: (clear?: boolean) => void
@@ -1,5 +1,6 @@
1
1
  import { Reactive } from 'vue'
2
2
  import { Doc } from 'yjs'
3
+ import { Awareness } from 'y-protocols/awareness'
3
4
 
4
5
  import { CuboCrdtKey } from '../../types'
5
6
 
@@ -9,6 +10,7 @@ export type CuboCrdtClientStoreItem<T> = {
9
10
  subscribed: boolean
10
11
  }>
11
12
  docs: Map<number, Doc>
13
+ awarenesses: Map<number, Awareness>
12
14
  }
13
15
  export type CuboCrdtClientStore<M> = Partial<{
14
16
  [K in string]: CuboCrdtClientStoreItem<CuboCrdtKey<K, M>>
@@ -1,10 +1,13 @@
1
1
  import { debounce, pick } from '@cuboapp/utils'
2
2
  import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs'
3
+ import { Awareness, encodeAwarenessUpdate } from 'y-protocols/awareness'
3
4
 
4
5
  import { CuboCrdtServerDocumentOptions, CuboCrdtServerDocumentOrigin } from '../types'
5
6
 
6
7
  export class CuboCrdtServerDocument {
7
8
  private ydoc: Doc
9
+ public awareness: Awareness
10
+ public awarenessBySubscribe = new Map<string, Set<number>>()
8
11
 
9
12
  // private firstUpdate = false
10
13
 
@@ -14,6 +17,31 @@ export class CuboCrdtServerDocument {
14
17
  autoLoad: false
15
18
  })
16
19
 
20
+ if (opts.awareness) {
21
+ this.awareness = new Awareness(this.ydoc)
22
+ this.awareness.setLocalState(null)
23
+
24
+ this.awareness.on('update', ({ added, updated, removed }, origin: CuboCrdtServerDocumentOrigin) => {
25
+ const subId = origin?.subscribe_id
26
+
27
+ if (subId) {
28
+ let set = this.awarenessBySubscribe.get(subId)
29
+ if (!set) {
30
+ set = new Set()
31
+ this.awarenessBySubscribe.set(subId, set)
32
+ }
33
+
34
+ added.concat(updated).forEach((id) => set!.add(id))
35
+ removed.forEach((id) => set!.delete(id))
36
+ }
37
+
38
+ const changed = added.concat(updated).concat(removed)
39
+ const update = encodeAwarenessUpdate(this.awareness!, changed)
40
+
41
+ this.opts?.onAwarenessUpdate?.(update, origin)
42
+ })
43
+ }
44
+
17
45
  // записываем исходное состояние
18
46
  this.write(opts.initialState ?? {})
19
47
 
@@ -78,6 +106,7 @@ export class CuboCrdtServerDocument {
78
106
  }
79
107
 
80
108
  destroy() {
109
+ this.awareness?.destroy()
81
110
  this.ydoc.destroy()
82
111
  }
83
112
 
@@ -16,6 +16,7 @@ import {
16
16
  CuboCrdtServerUnsubscribeDto,
17
17
  CuboCrdtSocketClient
18
18
  } from './types'
19
+ import { applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness'
19
20
 
20
21
  export * from './document'
21
22
  export * from './types'
@@ -40,7 +41,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
40
41
  this.entities.forEach((e) => this.subscribesByEntity.set(e, new Set()))
41
42
 
42
43
  this.ws.registerHandler(CUBO_CRDT_EVENT.SUBSCRIBE, async ({ client, message }) => {
43
- const { subscribe_id: id, entity, filters } = message.data as CuboCrdtServerSubscribeDto
44
+ const { subscribe_id: id, entity, filters, awareness = false } = message.data as CuboCrdtServerSubscribeDto
44
45
 
45
46
  if (this.debug) {
46
47
  console.log('[CRDT] subscribe', entity, filters)
@@ -50,7 +51,8 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
50
51
  id,
51
52
  client_id: client.id,
52
53
  entity,
53
- filters
54
+ filters,
55
+ awareness
54
56
  }
55
57
 
56
58
  this.subscribes.set(id, subscribe)
@@ -109,6 +111,10 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
109
111
  break
110
112
  case 'delete':
111
113
  this.onDocumentExternalDelete(update, row)
114
+ break
115
+ case 'awareness':
116
+ this.onAwarenessExternalUpdate(update, row)
117
+
112
118
  break
113
119
  }
114
120
  })
@@ -173,6 +179,9 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
173
179
 
174
180
  private sendQueueToClient(client: CuboCrdtSocketClient<A>) {
175
181
  const queue = this.queue.get(client.id)
182
+ if (!queue) {
183
+ return
184
+ }
176
185
 
177
186
  // const data = Array.from(queue)
178
187
  const data = [...new Map((Array.from(queue) || []).map((item) => [JSON.stringify(item), item])).values()]
@@ -234,6 +243,29 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
234
243
  console.log('[CRDT] unsubscribe', subscribe.entity, subscribe.filters)
235
244
  }
236
245
 
246
+ if (subscribe.awareness) {
247
+ //убираем аварнесс стейт при отписке
248
+ this.subscribesByDocument.forEach((subs, docName) => {
249
+ if (!subs.has(subscribe_id)) {
250
+ return
251
+ }
252
+
253
+ const doc = this.documents.get(docName)
254
+ if (!doc?.awareness) {
255
+ return
256
+ }
257
+
258
+ const ids = Array.from(doc.awarenessBySubscribe.get(subscribe_id) || [])
259
+ if (!ids.length) {
260
+ return
261
+ }
262
+
263
+ removeAwarenessStates(doc.awareness, ids, { subscribe_id, expose: 'all' })
264
+
265
+ doc.awarenessBySubscribe.delete(subscribe_id)
266
+ })
267
+ }
268
+
237
269
  // удаляем подписку по сущности
238
270
  this.subscribesByEntity.get(subscribe.entity as E)?.delete(subscribe.id)
239
271
 
@@ -270,7 +302,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
270
302
  .map((m) => m[1])
271
303
  }
272
304
 
273
- public ensureDocument(entity: E, row: any) {
305
+ public ensureDocument(entity: E, row: any, awareness?: boolean) {
274
306
  // todo: врапнуть всё это в Promise
275
307
  const entity_id = row.id
276
308
  const documentName = `${entity}:${entity_id}`
@@ -281,12 +313,19 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
281
313
  document = new CuboCrdtServerDocument({
282
314
  name: documentName,
283
315
  initialState: row,
316
+ awareness: awareness,
284
317
  onUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
285
318
  // пушим обновление документа
286
319
  // console.log('push document update', document.name)
287
320
 
288
321
  this.pushDocumentAction('update', entity, document!, data, origin)
289
322
  },
323
+ onAwarenessUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
324
+ // пушим обновление документа
325
+ // console.log('push document update', document.name)
326
+
327
+ this.pushDocumentAction('awareness', entity, document!, data, origin)
328
+ },
290
329
  onStore: (item: object, origin: CuboCrdtServerDocumentOrigin) => {
291
330
  const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
292
331
  const client = subscribe && this.clients.get(subscribe.client_id)
@@ -353,6 +392,22 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
353
392
  }
354
393
  }
355
394
 
395
+ private onAwarenessExternalUpdate(update: Uint8Array, action: CuboCrdtServerDocumentIncomingAction) {
396
+ const document = this.getDocument(action.entity, action.entity_id)
397
+
398
+ if (!document?.awareness) {
399
+ return
400
+ }
401
+
402
+ try {
403
+ applyAwarenessUpdate(document.awareness, update, action.origin)
404
+ } catch (e) {
405
+ if (this.debug) {
406
+ console.warn('[CRDT] applyAwarenessUpdate failed', e)
407
+ }
408
+ }
409
+ }
410
+
356
411
  private onDocumentExternalDelete(update: Uint8Array, action: CuboCrdtServerDocumentIncomingAction) {
357
412
  console.warn('[CRDT] onDocumentExternalDelete not implemented')
358
413
  }
@@ -481,6 +536,22 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
481
536
  this.checkDocumentNeedRemove(document.name)
482
537
  break
483
538
  }
539
+
540
+ if (action === 'create' && subscribe.awareness && document.awareness) {
541
+ const ids = Array.from(document.awareness.getStates().keys())
542
+
543
+ if (ids.length) {
544
+ const aUpdate = encodeAwarenessUpdate(document.awareness, ids)
545
+
546
+ this.sendToClient(subscribe.client_id, {
547
+ action: 'awareness',
548
+ entity: subscribe.entity,
549
+ entity_id: row.id,
550
+ data: Array.from(aUpdate),
551
+ subscribe_id: subscribe.id
552
+ })
553
+ }
554
+ }
484
555
  })
485
556
  }
486
557
  }
@@ -492,7 +563,7 @@ export class CuboCrdtServer<M, A = {}, E extends Extract<keyof M, string> = Extr
492
563
  // пушим их в сокеты
493
564
  for (const row of rows || []) {
494
565
  // this.ensureDocument(subscribe.entity as E, row, { subscribes_ids: [subscribe.id] })
495
- this.ensureDocument(subscribe.entity as E, row)
566
+ this.ensureDocument(subscribe.entity as E, row, subscribe.awareness)
496
567
  }
497
568
  }
498
569
 
@@ -5,10 +5,11 @@ export type CuboCrdtServerDocumentOptions = {
5
5
  onStore?: (body: object, origin: CuboCrdtServerDocumentOrigin) => void
6
6
  // onCreate?: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => void
7
7
  onUpdate?: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => void
8
+ onAwarenessUpdate?: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => void
8
9
  // onInit?: (document: CuboCrdtServerDocument) => void
9
10
 
10
11
  initialState?: object
11
-
12
+ awareness?: boolean
12
13
  yjsOptions?: {
13
14
  guid?: string
14
15
  collectionid?: string
@@ -7,9 +7,10 @@ export type CuboCrdtServerSubscribeDto = {
7
7
  filters: {
8
8
  [K in 'id' | string]: any
9
9
  }
10
+ awareness: boolean
10
11
  }
11
12
 
12
13
  export type CuboCrdtServerSubscribe = {
13
14
  id: string
14
15
  client_id: string
15
- } & Pick<CuboCrdtServerSubscribeDto, 'entity' | 'filters'>
16
+ } & Pick<CuboCrdtServerSubscribeDto, 'entity' | 'filters' | 'awareness'>
@@ -1,5 +1,5 @@
1
1
  export type CuboCrdtKey<K, M> = K extends Extract<keyof M, string> ? M[K] : any
2
2
 
3
- export type CuboCrdtAction = 'create' | 'upsert' | 'update' | 'delete'
3
+ export type CuboCrdtAction = 'create' | 'upsert' | 'update' | 'delete' | 'awareness'
4
4
 
5
5
  export type CuboCrdtExposeStrategy = 'all' | 'other' | 'subscribe' | 'client'
@@ -54,9 +54,12 @@ export function checkRowIsSutable(
54
54
  } else {
55
55
  const [formula, str] = `${value}`.split(':')
56
56
 
57
- // console.log({ formula, str, value: row[key] })
58
-
59
57
  if (!str) {
58
+ const maybeNum = Number(value)
59
+ if (!isNaN(maybeNum)) {
60
+ value = maybeNum
61
+ }
62
+
60
63
  if (row[key as keyof typeof row] !== value) {
61
64
  sutable = false
62
65
  }