@cuboapp/crdt 1.0.5 → 1.0.6

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.5",
3
+ "version": "1.0.6",
4
4
  "description": "CRDT for CUBO",
5
5
  "main": "src/index.ts",
6
6
  "repository": "git@github.com:cuboapp/crdt.git",
@@ -11,12 +11,16 @@
11
11
  "access": "public"
12
12
  },
13
13
  "dependencies": {
14
- "@cuboapp/types": "^2.0.11",
15
- "@cuboapp/utils": "1.0.9",
16
- "@cuboapp/ws": "1.0.4",
14
+ "@cuboapp/types": "^2.0.12",
15
+ "@cuboapp/utils": "1.0.10",
16
+ "@cuboapp/ws": "1.0.5",
17
17
  "ws": "^8.19.0",
18
18
  "yjs": "^13.6.29"
19
19
  },
20
+ "peerDependencies": {
21
+ "vue": "^3.5.26",
22
+ "vue-router": "^4.6.3"
23
+ },
20
24
  "devDependencies": {
21
25
  "@types/node": "^24.10.1",
22
26
  "@types/ws": "^8.18.1",
@@ -1,106 +1,384 @@
1
- import { EventEmitter } from '@cuboapp/utils'
2
- import { createWsClient, WsClient } from '@cuboapp/ws'
1
+ import { keyBy, pick, uuid } from '@cuboapp/utils'
2
+ import { computed, reactive } from 'vue'
3
3
  import { applyUpdate, Doc } from 'yjs'
4
4
 
5
- export type CuboCrdtClientOptions = {
6
- host?: string
7
- port?: number
8
- protocol?: 'wss' | 'ws' | string
9
- authToken?: () => Promise<string>
10
- debug?: boolean
11
- }
5
+ import { CUBO_CRDT_EVENT } from '../constants'
6
+ import { CuboCrdtServerDocumentIncomingAction } from '../server'
7
+ import { CuboCrdtAction, CuboCrdtKey } from '../types'
12
8
 
13
- export class CuboCrdtClient {
14
- private ws: WsClient
9
+ import {
10
+ CuboCrdtClientDocOrigin,
11
+ CuboCrdtClientDocUpdateOptions,
12
+ CuboCrdtClientList,
13
+ CuboCrdtClientOptions,
14
+ CuboCrdtClientRow,
15
+ CuboCrdtClientStore,
16
+ CuboCrdtClientSubscribeEvent,
17
+ CuboCrdtClientUseOptions
18
+ } from './types'
15
19
 
16
- constructor(
17
- private opts: CuboCrdtClientOptions,
18
- private ee = new EventEmitter()
19
- ) {
20
- this.ws = createWsClient({
21
- host: this.opts.host,
22
- port: this.opts.port,
23
- protocol: this.opts.protocol,
24
- autoReconnect: true,
25
- pingInterval: 5000,
26
- authToken: this.opts.authToken ? () => this.opts.authToken!() : () => ''
20
+ export * from './types'
21
+
22
+ export class CuboCrdtClient<M> {
23
+ public store: CuboCrdtClientStore<M> = {}
24
+
25
+ private listeners: Map<string, Map<string, (ctx: CuboCrdtClientSubscribeEvent) => void>> = new Map()
26
+
27
+ constructor(private opts: CuboCrdtClientOptions<M>) {
28
+ opts.entities.forEach((e) => this.listeners.set(e, new Map()))
29
+ }
30
+
31
+ async start() {
32
+ 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)
52
+ }
53
+ } else {
54
+ listeners?.forEach((cb) => cb(event))
55
+ }
56
+ })
27
57
  })
28
58
  }
29
59
 
30
- public async start() {
31
- await this.ws.connect()
60
+ async stop() {
61
+ this.ws.deleteHandler(CUBO_CRDT_EVENT.EVENT)
32
62
 
33
- this.ws.registerHandler('message', this.onMessage.bind(this))
63
+ this.listeners.clear()
34
64
  }
35
65
 
36
- public async stop() {
37
- try {
38
- await this.ws.disconnect()
39
- } catch {}
66
+ public useComputedList<K extends Extract<keyof M, string>>(entity: K) {
67
+ return computed(() => (this.store[entity]?.state.rows || []) as M[K][])
40
68
  }
41
69
 
42
- private onMessage({ message }: any) {
43
- if (message.event) {
44
- this.ee.emit(message.event, message)
70
+ public useList<K extends Extract<keyof M, string>, T = CuboCrdtKey<K, M>>(
71
+ entity: K | string,
72
+ opts?: CuboCrdtClientUseOptions
73
+ ): CuboCrdtClientList<T> {
74
+ const storeKey = opts?.storeKey ?? `${entity}`
75
+ const filters = opts?.filters || {}
76
+
77
+ const subscribe_id = uuid()
78
+
79
+ // создаём хранилище
80
+ if (this.store[storeKey] === undefined) {
81
+ const state = reactive({
82
+ rows: [],
83
+ subscribed: false
84
+ })
85
+
86
+ this.store[storeKey] = {
87
+ state,
88
+ docs: new Map()
89
+ }
45
90
  }
46
- }
47
91
 
48
- async subscribe({ entity, id, filters }: { entity: string; id?: number; filters?: any }, cb: any) {
49
- if (this.opts.debug) {
50
- console.log('subscribeEntity', { entity, id })
92
+ // подписка
93
+ const subscribe = (filters?: Record<string, any>) => {
94
+ if (this.store[storeKey]?.state.subscribed) {
95
+ return
96
+ }
97
+
98
+ // ставим сразу флаг, потому что не может быть ошибок (считаем так)
99
+ this.store[storeKey]!.state.subscribed = true
100
+
101
+ filters = filters !== undefined ? filters : opts?.filters
102
+
103
+ if (this.debug) {
104
+ console.log('[CRDT] subscribe', { entity, opts, filters })
105
+ }
106
+
107
+ // подписываемся на фронте
108
+ this.listeners.get(entity)?.set(subscribe_id, (ctx) =>
109
+ this.onIncomingUpdate(ctx, {
110
+ subscribe_id,
111
+ storeKey,
112
+ ...(pick(opts || {}, ['onBeforeCreate', 'onBeforeUpdate', 'onBeforeDelete', 'onAfterCreate', 'onAfterUpdate', 'onAfterDelete']) ||
113
+ {})
114
+ })
115
+ )
116
+
117
+ // подписываемся на бэке
118
+ this.ws.request({ method: CUBO_CRDT_EVENT.SUBSCRIBE, data: { subscribe_id, entity, filters } })
51
119
  }
52
120
 
53
- filters = filters || {}
54
- if (id) {
55
- filters.id = id
121
+ // отписка
122
+ const unsubscribe = (clear?: boolean) => {
123
+ if (this.debug) {
124
+ console.log('[CRDT] unsubscribe', { entity, opts, filters })
125
+ }
126
+
127
+ // отписываемся на бэке
128
+ this.ws.request({ method: CUBO_CRDT_EVENT.UNSUBSCRIBE, data: { subscribe_id } })
129
+
130
+ // отписываемся на фронте
131
+ this.listeners.get(entity)?.delete(subscribe_id)
132
+
133
+ // очищаем стор
134
+ if (clear !== false) {
135
+ this.store[storeKey] = undefined
136
+ }
56
137
  }
57
138
 
58
- const event = `crdt:${entity}`
59
- // const event = id ? `crdt:${entity}:${id}` : `crdt:${entity}`
139
+ // авто-подписка
140
+ if (opts?.autoSubscribe !== false) {
141
+ subscribe()
142
+ }
143
+
144
+ return {
145
+ subscribe_id,
146
+ storeKey,
147
+ subscribe,
148
+ unsubscribe,
149
+ docs: computed(() => this.store[storeKey]?.docs),
150
+ subscribed: () => computed(() => this.store[storeKey]?.state.subscribed || false),
151
+ rows: () => computed(() => this.store[storeKey]?.state.rows as T[]),
152
+ rowsById: () => computed(() => keyBy(this.store[storeKey]?.state.rows as T[], 'id' as keyof T))
153
+ }
154
+ }
155
+
156
+ public useRow<K extends Extract<keyof M, string>, T = CuboCrdtKey<K, M>>(
157
+ entity: K | string,
158
+ id: number,
159
+ opts?: CuboCrdtClientUseOptions
160
+ ): CuboCrdtClientRow<T> {
161
+ // console.log('store key', opts?.storeKey ?? `${entity}:${id}`)
60
162
 
61
- this.ee.addListener(event, cb)
62
- this.ws.request({ method: 'crdt:subscribe', data: { event, filters } })
163
+ const { subscribe_id, storeKey, docs, subscribe, unsubscribe, subscribed } = this.useList(entity, {
164
+ ...opts,
165
+ filters: opts?.filters ?? { id },
166
+ storeKey: opts?.storeKey ?? `${entity}:${id}`
167
+ })
168
+
169
+ return {
170
+ subscribe_id,
171
+ storeKey,
172
+ doc: computed(() => docs.value?.get(id)),
173
+ subscribe,
174
+ unsubscribe,
175
+ subscribed,
176
+ row: () => {
177
+ return computed(() => {
178
+ return (this.store[storeKey]?.state.rows?.[0] ?? null) as T
179
+ })
180
+ }
181
+ }
63
182
  }
64
183
 
65
- async unsubscribe({ entity, id, filters }: { entity: string; id?: number; filters?: any }, cb: any) {
66
- if (this.opts.debug) {
67
- console.log('subscribeEntity', { entity, id })
184
+ public update<K extends string>(
185
+ entity: K,
186
+ entity_id: number,
187
+ dto: K extends Extract<keyof M, string> ? Partial<M[K]> : object,
188
+ opts?: CuboCrdtClientDocOrigin
189
+ ) {
190
+ const cardRow = this.store[`${entity}:${entity_id}`]?.state.rows?.[0]
191
+ const listRow = this.store[entity]?.state?.rows.find((r: any) => r.id === entity_id)
192
+ const cardDoc = this.store[`${entity}:${entity_id}`]?.docs.get(entity_id)
193
+ const listDoc = this.store[entity]?.docs.get(entity_id)
194
+
195
+ // обновляем документы
196
+ for (const doc of [cardDoc, listDoc]) {
197
+ if (doc) {
198
+ const map = doc.getMap()
199
+
200
+ const row = (cardRow || listRow) as any
201
+ const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => row[key] !== value))
202
+
203
+ if (Object.keys(toUpdate).length) {
204
+ const origin: CuboCrdtClientDocOrigin = {
205
+ store: opts?.store ?? true,
206
+ expose: opts?.expose ?? 'other',
207
+ keys: opts?.keys ?? Object.keys(toUpdate),
208
+ react: true // отправляем на бэк
209
+ }
210
+
211
+ doc.transact(() => {
212
+ Object.entries(toUpdate).forEach(([key, value]) => {
213
+ map.set(key, value)
214
+ })
215
+ }, origin)
216
+ }
217
+ }
68
218
  }
69
219
 
70
- const event = `crdt:${entity}`
71
- // const event = id ? `crdt:${entity}:${id}` : `crdt:${entity}`
220
+ // обновляем реактивку
221
+ for (const row of [cardRow, listRow]) {
222
+ if (row) {
223
+ const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => row[key as keyof typeof row] !== value))
72
224
 
73
- this.ee.removeListener(event, cb)
74
- this.ws.request({ method: 'crdt:unsubscribe', data: { event, filters } })
225
+ if (Object.keys(toUpdate).length) {
226
+ Object.entries(toUpdate).forEach(([key, value]) => {
227
+ row[key as keyof typeof row] = value
228
+ })
229
+ }
230
+ }
231
+ }
75
232
  }
76
233
 
77
- async openDocument(entity: string, id: number, doc: Doc): Promise<void> {
78
- const state: any = await this.ws.request(
79
- {
80
- method: 'crdt:open',
81
- data: { name: `${entity}:${id}` }
82
- },
83
- { wait: true }
84
- )
85
-
86
- this.ee.addListener(`crdt:update:${entity}:${id}`, (ctx) => {
87
- applyUpdate(doc, new Uint8Array(ctx.data))
234
+ private async onDocumentCreate(update: Uint8Array, ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
235
+ if (opts?.onBeforeCreate) {
236
+ const result = await opts.onBeforeCreate(update, ctx, opts)
237
+ if (!result) {
238
+ return
239
+ }
240
+ }
241
+
242
+ const doc = new Doc()
243
+ applyUpdate(doc, update, { react: false })
244
+
245
+ // подписываемся на обновления документа
246
+ doc.on('update', (update, origin: CuboCrdtClientDocOrigin) => {
247
+ // react = false, если это апдейт с бэка
248
+ if (origin?.react !== false) {
249
+ const data: CuboCrdtServerDocumentIncomingAction = {
250
+ action: 'update',
251
+ entity: ctx.entity,
252
+ entity_id: ctx.entity_id,
253
+ data: Array.from(update),
254
+ origin: {
255
+ ...pick(origin || {}, ['store', 'keys', 'expose']),
256
+ subscribe_id: opts?.subscribe_id
257
+ }
258
+ }
259
+
260
+ this.ws.request({
261
+ method: CUBO_CRDT_EVENT.EVENT,
262
+ data
263
+ })
264
+ }
88
265
  })
89
266
 
90
- this.ws.request({ method: 'crdt:subscribe', data: { crdt: { entity, id } } })
267
+ // добавляем документ в хранилище документов
268
+ this.store[opts.storeKey]?.docs.set(ctx.entity_id, doc)
269
+
270
+ // добавляем документ в реактивное хранилище
271
+ const json: any = doc.getMap().toJSON()
272
+ const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === ctx.entity_id)
273
+ if (index !== undefined && index >= 0) {
274
+ if (this.debug) {
275
+ console.warn('[CRDT] onDocumentCreate - row already exists: "' + ctx.entity_id + '"', { ctx, opts })
276
+ }
277
+
278
+ this.store[opts.storeKey]!.state.rows[index] = json
279
+ } else {
280
+ this.store[opts.storeKey]?.state.rows.push(json)
281
+ }
282
+
283
+ if (opts?.onAfterCreate) {
284
+ await opts.onAfterCreate(doc, update, ctx, opts)
285
+ }
286
+ }
287
+
288
+ private async onDocumentUpdate(doc: Doc, update: Uint8Array, ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
289
+ if (opts?.onBeforeUpdate) {
290
+ const result = await opts.onBeforeUpdate(doc, update, ctx, opts)
291
+ if (!result) {
292
+ return
293
+ }
294
+ }
295
+
296
+ // обновляем yjs-ный документ
297
+ applyUpdate(doc, update, { react: false })
298
+
299
+ // обновляем документ в реактивном хранилище
300
+ const json: any = doc.getMap().toJSON()
301
+ const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === ctx.entity_id)
302
+ if (index !== undefined && index >= 0) {
303
+ this.store[opts.storeKey]!.state.rows[index] = json
304
+ } else {
305
+ if (this.debug) {
306
+ console.warn('[CRDT] onDocumentUpdate - row not exists: "' + ctx.entity_id + '"', { ctx, opts })
307
+ }
308
+
309
+ this.store[opts.storeKey]!.state.rows.push(json)
310
+ }
311
+
312
+ if (opts?.onAfterUpdate) {
313
+ await opts.onAfterUpdate(doc, update, ctx, opts)
314
+ }
315
+ }
316
+
317
+ private async onDocumentDelete(doc: Doc, ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
318
+ if (opts?.onBeforeDelete) {
319
+ const result = await opts.onBeforeDelete(doc, ctx, opts)
320
+ if (!result) {
321
+ return
322
+ }
323
+ }
324
+
325
+ // console.log('onDocumentDelete', ctx, opts)
326
+
327
+ doc.destroy()
328
+ this.store[opts.storeKey]?.docs.delete(ctx.entity_id)
329
+
330
+ const index = this.store[opts.storeKey]?.state.rows.findIndex((r: any) => r.id === ctx.entity_id)
331
+ if (index !== undefined && index >= 0) {
332
+ this.store[opts.storeKey]?.state.rows.splice(index, 1)
333
+ } else {
334
+ console.log('row not found', index, ctx, opts)
335
+ }
336
+
337
+ if (opts?.onAfterDelete) {
338
+ await opts.onAfterDelete(doc, ctx, opts)
339
+ }
340
+ }
341
+
342
+ private onIncomingUpdate(ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) {
343
+ if (this.debug) {
344
+ console.log('[CRDT] call event listener', ctx, opts)
345
+ }
346
+
347
+ if (!this.store[opts.storeKey]) {
348
+ console.warn('[CRDT] on incoming update, store key not found: "' + opts.storeKey + '"')
349
+ }
91
350
 
92
- applyUpdate(doc, new Uint8Array(state))
351
+ const doc = this.store[opts.storeKey]?.docs.get(ctx.entity_id)
352
+ const action: CuboCrdtAction = !doc ? 'create' : ctx.action === 'delete' ? 'delete' : 'update'
353
+
354
+ // console.log('action', action)
355
+
356
+ switch (action) {
357
+ case 'create':
358
+ {
359
+ const update = new Uint8Array(ctx.data as any)
360
+ this.onDocumentCreate(update, ctx, opts)
361
+ }
362
+ break
363
+ case 'update':
364
+ if (doc) {
365
+ const update = new Uint8Array(ctx.data as any)
366
+ this.onDocumentUpdate(doc, update, ctx, opts)
367
+ }
368
+ break
369
+ case 'delete':
370
+ if (doc) {
371
+ this.onDocumentDelete(doc, ctx, opts)
372
+ }
373
+ break
374
+ }
93
375
  }
94
376
 
95
- async closeDocument(entity: string, id: number) {
96
- await this.ws.request(
97
- {
98
- method: 'crdt:close',
99
- data: { name: `${entity}:${id}` }
100
- },
101
- { wait: true }
102
- )
377
+ private get ws() {
378
+ return this.opts.ws
379
+ }
103
380
 
104
- this.ee.removeAllListeners(`crdt:document:${entity}:${id}:update`)
381
+ private get debug() {
382
+ return this.opts.debug
105
383
  }
106
384
  }