@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.
@@ -0,0 +1,249 @@
1
+ import { uuid } from '@cuboapp/utils'
2
+ import { WsClient } from '@cuboapp/ws'
3
+ import { computed, ComputedRef, Reactive, reactive } from 'vue'
4
+ import { applyUpdate, Doc } from 'yjs'
5
+
6
+ export type CuboCrdtClientStoreElement<M, K> = {
7
+ rows: Reactive<Map<number, K extends keyof M ? M[K] : unknown>>
8
+ docs: Map<number, Doc>
9
+ flags: Reactive<{
10
+ inited: boolean
11
+ }>
12
+ }
13
+
14
+ export type CuboCrdtClientSubscribeOptions<S, F> = {
15
+ entity_id?: number
16
+ storeKey?: S
17
+ filters?: F
18
+ autoInit?: boolean
19
+ onUpdate?: () => void
20
+ onInit?: () => void
21
+ debug?: boolean
22
+ }
23
+
24
+ export type CuboCrdtClientSubscribe<M = {}, K = {}, S = {}, F = {}> = {
25
+ id: string
26
+ storeKey: S
27
+ store: CuboCrdtClientStoreElement<M, K>
28
+ init: (filters?: F) => void
29
+ destroy: (opts?: { clear?: boolean }) => void
30
+ }
31
+
32
+ export type CuboCrdtClientOptions = {
33
+ ws: WsClient
34
+ debug?: boolean
35
+ }
36
+
37
+ export class CuboCrdtClient<M> {
38
+ constructor(private opts: CuboCrdtClientOptions) {}
39
+
40
+ public store: Partial<{ [K in Extract<keyof M, string>]: CuboCrdtClientStoreElement<M, K> }> = {}
41
+
42
+ private get ws() {
43
+ return this.opts.ws
44
+ }
45
+
46
+ private getFilters<F>(filters: F) {
47
+ if (Array.isArray(filters)) {
48
+ return filters.map(this.getFilters).filter(Boolean)
49
+ } else if (typeof filters === 'object' && filters !== null) {
50
+ const newObj: any = {}
51
+
52
+ for (const key in filters) {
53
+ const value = this.getFilters(filters[key])
54
+
55
+ if (value !== undefined && value !== null && value !== '' && !(typeof value === 'object' && Object.keys(value).length === 0)) {
56
+ newObj[key] = value
57
+ }
58
+ }
59
+
60
+ return newObj
61
+ }
62
+
63
+ return filters
64
+ }
65
+
66
+ subscribe<K extends Extract<keyof M, string>, S extends string, F extends Record<string, any>>(
67
+ entity: K,
68
+ opts?: CuboCrdtClientSubscribeOptions<S, F>
69
+ ): CuboCrdtClientSubscribe<M, K, S, F> {
70
+ const subscribe_id = uuid()
71
+ const storeKey = opts?.storeKey ?? (entity as any)
72
+
73
+ if (opts?.debug) {
74
+ console.trace('subscribe tasks', subscribe_id, opts)
75
+ }
76
+
77
+ if (this.store[storeKey] === undefined) {
78
+ this.store[storeKey] = {
79
+ flags: reactive({
80
+ inited: false
81
+ }),
82
+ rows: reactive(new Map()),
83
+ docs: new Map()
84
+ }
85
+ }
86
+
87
+ const onUpdateDocument = (id: number, data: Uint8Array, fireOnUpdate?: boolean) => {
88
+ let item: any
89
+
90
+ // обновляем документ
91
+
92
+ let exDoc = this.store[storeKey].docs.get(id)
93
+ if (!exDoc) {
94
+ const doc = new Doc()
95
+ applyUpdate(doc, data)
96
+
97
+ doc.on('update', (update, origin) => {
98
+ if (!origin?.no_push) {
99
+ this.ws.request({
100
+ method: 'crdt:update',
101
+ data: {
102
+ subscribe_id,
103
+ name: `${entity}:${id}`,
104
+ update: Array.from(update),
105
+ origin
106
+ }
107
+ })
108
+ }
109
+ })
110
+
111
+ this.store[storeKey].docs.set(id, doc)
112
+ exDoc = doc
113
+ } else {
114
+ applyUpdate(exDoc, data, { no_push: true })
115
+ }
116
+
117
+ item = exDoc.getMap().toJSON()
118
+
119
+ // обновляем строку
120
+ this.store[storeKey].rows.set(id, item as any)
121
+
122
+ if (opts?.debug) {
123
+ console.log('fire onUpdate', { fireOnUpdate })
124
+ }
125
+
126
+ if (fireOnUpdate !== false) {
127
+ opts?.onUpdate?.()
128
+ }
129
+ }
130
+
131
+ const init = (filters?: F) => {
132
+ filters = filters !== undefined ? filters : opts?.filters
133
+
134
+ if (this.store[storeKey].flags.inited) {
135
+ return
136
+ }
137
+
138
+ // подписываемся на обновления
139
+ this.ws.registerHandler('crdt:action:' + subscribe_id, (ctx) => {
140
+ const { action, entity_id, data } = ctx.message.data as any
141
+
142
+ switch (action) {
143
+ case 'update':
144
+ onUpdateDocument(entity_id, new Uint8Array(data), true)
145
+ break
146
+ }
147
+ })
148
+
149
+ this.ws
150
+ .request(
151
+ {
152
+ method: 'crdt:subscribe',
153
+ data: this.getFilters({
154
+ id: subscribe_id,
155
+ entity,
156
+ entity_id: opts?.entity_id,
157
+ filters
158
+ })
159
+ },
160
+ true
161
+ )
162
+ .then((data: any) => {
163
+ if (opts?.entity_id) {
164
+ onUpdateDocument(data.id, new Uint8Array(data.row), false)
165
+ } else {
166
+ data.map(({ id, row }: { id: number; row: number[] }) => onUpdateDocument(id, new Uint8Array(row), false))
167
+ }
168
+
169
+ this.store[storeKey].flags.inited = true
170
+ opts?.onInit?.()
171
+ })
172
+ }
173
+
174
+ const destroy = ({ clear }: { clear?: boolean } = {}) => {
175
+ if (!this.store[storeKey].flags.inited) {
176
+ return
177
+ }
178
+
179
+ this.ws.request(
180
+ {
181
+ method: 'crdt:unsubscribe',
182
+ data: this.getFilters({
183
+ id: subscribe_id,
184
+ entity,
185
+ entity_id: opts?.entity_id,
186
+ filters: opts?.filters
187
+ })
188
+ },
189
+ true
190
+ )
191
+
192
+ this.ws.deleteHandler('crdt:update:' + subscribe_id)
193
+
194
+ if (clear !== false) {
195
+ this.store[storeKey] = undefined
196
+ }
197
+ }
198
+
199
+ if (opts?.autoInit !== false) {
200
+ init()
201
+ }
202
+
203
+ return {
204
+ id: subscribe_id,
205
+ storeKey,
206
+ store: this.store[storeKey] as any,
207
+ init,
208
+ destroy
209
+ }
210
+ }
211
+
212
+ useList<K extends string, T = K extends keyof M ? M[K] : unknown>(key: K): ComputedRef<T[]> {
213
+ return computed(() => {
214
+ return Array.from(this.store[key as any].rows || []).map((m) => m[1] as T)
215
+ })
216
+ }
217
+
218
+ change<K extends string = Extract<keyof M, string>, T = K extends keyof M ? M[K] : unknown>(
219
+ entity: K,
220
+ entity_id: number,
221
+ dto: Partial<T> & Record<string, any>,
222
+ opts?: { strategy?: 'other' | 'all' }
223
+ ) {
224
+ const row = this.store[entity as any].rows.get(entity_id)
225
+ const doc = this.store[entity as any].docs.get(entity_id)
226
+
227
+ if (doc) {
228
+ const map = doc.getMap()
229
+
230
+ const toUpdate = Object.fromEntries(Object.entries(dto).filter(([key, value]) => row[key] !== value))
231
+
232
+ if (Object.keys(toUpdate).length) {
233
+ doc.transact(
234
+ () => {
235
+ Object.entries(toUpdate).forEach(([key, value]) => {
236
+ map.set(key, value)
237
+ row[key] = value
238
+ })
239
+ },
240
+ { with_save: true, strategy: opts?.strategy ?? 'other', keys: Object.keys(toUpdate) }
241
+ )
242
+ }
243
+ }
244
+ }
245
+
246
+ useRow<K extends Extract<keyof M, string>, T = K extends keyof M ? M[K] : unknown>(entity: K, entity_id: number): ComputedRef<T> {
247
+ return computed(() => this.store[entity].rows.get(entity_id)) as ComputedRef<T>
248
+ }
249
+ }
@@ -0,0 +1,22 @@
1
+ import { CuboCrdtExposeStrategy } from '../../types'
2
+
3
+ import { CuboCrdtClientBaseOptions } from './utils'
4
+
5
+ export type CuboCrdtClientDocOrigin = {
6
+ // сохранять ли на бэке в дебаунсе
7
+ store?: boolean
8
+
9
+ // реагировать ли в onUpdate (на фронте)
10
+ react?: boolean
11
+
12
+ // обновлённые ключи
13
+ keys?: string[]
14
+
15
+ // куда раскатывать обновления - всем или всем кроме себя
16
+ expose?: CuboCrdtExposeStrategy
17
+ }
18
+
19
+ export type CuboCrdtClientDocUpdateOptions = { subscribe_id: string; storeKey: string } & Pick<
20
+ CuboCrdtClientBaseOptions,
21
+ 'onBeforeCreate' | 'onAfterCreate' | 'onBeforeUpdate' | 'onAfterUpdate' | 'onBeforeDelete' | 'onAfterDelete'
22
+ >
@@ -0,0 +1,51 @@
1
+ import { WsClient } from '@cuboapp/ws'
2
+ import { ComputedRef } from 'vue'
3
+ import { Doc } from 'yjs'
4
+
5
+ import { CuboCrdtAction } from '../../types'
6
+
7
+ import { CuboCrdtClientBaseOptions } from './utils'
8
+
9
+ export * from './document'
10
+ export * from './store'
11
+ export * from './utils'
12
+
13
+ export type CuboCrdtClientOptions<M> = {
14
+ ws: WsClient
15
+ entities: Extract<keyof M, string>[]
16
+ debug?: boolean
17
+ }
18
+
19
+ export type CuboCrdtClientUseOptions = CuboCrdtClientBaseOptions & {
20
+ storeKey?: string
21
+ filters?: Record<string, any>
22
+ }
23
+
24
+ export type CuboCrdtClientSubscribeEvent = {
25
+ action: CuboCrdtAction
26
+ entity: string
27
+ entity_id: number
28
+ subscribe_id?: string
29
+ data?: number[]
30
+ }
31
+
32
+ export type CuboCrdtClientList<T> = {
33
+ subscribe_id: string
34
+ storeKey: string
35
+ docs: ComputedRef<Map<number, Doc> | undefined>
36
+ subscribe: (filters?: any) => void
37
+ unsubscribe: (clear?: boolean) => void
38
+ subscribed: () => ComputedRef<boolean>
39
+ rows: () => ComputedRef<T[]>
40
+ rowsById: () => ComputedRef<Record<string, T>>
41
+ }
42
+
43
+ export type CuboCrdtClientRow<T> = {
44
+ subscribe_id: string
45
+ storeKey: string
46
+ doc: ComputedRef<Doc | undefined>
47
+ subscribe: () => void
48
+ unsubscribe: (clear?: boolean) => void
49
+ subscribed: () => ComputedRef<boolean>
50
+ row: () => ComputedRef<T>
51
+ }
@@ -0,0 +1,15 @@
1
+ import { Reactive } from 'vue'
2
+ import { Doc } from 'yjs'
3
+
4
+ import { CuboCrdtKey } from '../../types'
5
+
6
+ export type CuboCrdtClientStoreItem<T> = {
7
+ state: Reactive<{
8
+ rows: T[]
9
+ subscribed: boolean
10
+ }>
11
+ docs: Map<number, Doc>
12
+ }
13
+ export type CuboCrdtClientStore<M> = Partial<{
14
+ [K in string]: CuboCrdtClientStoreItem<CuboCrdtKey<K, M>>
15
+ }>
@@ -0,0 +1,35 @@
1
+ import { Doc } from 'yjs'
2
+ import { CuboCrdtClientSubscribeEvent } from '.'
3
+ import { CuboCrdtClientDocUpdateOptions } from './document'
4
+
5
+ export type CuboCrdtClientBaseOptions = {
6
+ autoSubscribe?: boolean
7
+
8
+ onBeforeCreate?: (
9
+ update: Uint8Array,
10
+ ctx: CuboCrdtClientSubscribeEvent,
11
+ opts: CuboCrdtClientDocUpdateOptions
12
+ ) => Promise<boolean> | boolean
13
+ onAfterCreate?: (
14
+ doc: Doc,
15
+ update: Uint8Array,
16
+ ctx: CuboCrdtClientSubscribeEvent,
17
+ opts: CuboCrdtClientDocUpdateOptions
18
+ ) => Promise<void> | void
19
+
20
+ onBeforeUpdate?: (
21
+ doc: Doc,
22
+ update: Uint8Array,
23
+ ctx: CuboCrdtClientSubscribeEvent,
24
+ opts: CuboCrdtClientDocUpdateOptions
25
+ ) => Promise<boolean> | boolean
26
+ onAfterUpdate?: (
27
+ doc: Doc,
28
+ update: Uint8Array,
29
+ ctx: CuboCrdtClientSubscribeEvent,
30
+ opts: CuboCrdtClientDocUpdateOptions
31
+ ) => Promise<void> | void
32
+
33
+ onBeforeDelete?: (doc: Doc, ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) => Promise<boolean> | boolean
34
+ onAfterDelete?: (doc: Doc, ctx: CuboCrdtClientSubscribeEvent, opts: CuboCrdtClientDocUpdateOptions) => Promise<void> | void
35
+ }
@@ -0,0 +1,5 @@
1
+ export const CUBO_CRDT_EVENT = {
2
+ EVENT: 'crdt:event',
3
+ SUBSCRIBE: 'crdt:subscribe',
4
+ UNSUBSCRIBE: 'crdt:unsubscribe'
5
+ }
package/src/index.ts CHANGED
@@ -1,132 +1,4 @@
1
- import { type CuboApiEntitiesMap } from '@cuboapp/types'
2
- import { EventEmitter } from '@cuboapp/utils'
3
-
4
- import { CuboCrdtOptions } from './types'
5
- import { CuboCrdtDocument } from './yjs'
6
-
7
1
  export * from './client'
2
+ export * from './server'
8
3
  export * from './types'
9
4
  export * from './utils'
10
- export * from './yjs'
11
-
12
- export const CUBO_CRDT_EVENT = {
13
- DOCUMENT_UPDATED: 'document:updated',
14
- DOCUMENT_DELETED: 'document:deleted',
15
- DOCUMENT_CREATED: 'document:created'
16
- }
17
-
18
- export class CuboCrdt<
19
- T extends CuboApiEntitiesMap<T>,
20
- A = {},
21
- K extends string = Extract<keyof T, string>,
22
- ID extends string = `${K}:${number | string}`
23
- > {
24
- public documents = new Map<ID, CuboCrdtDocument>()
25
- private loading = new Map<ID, Promise<CuboCrdtDocument>>()
26
-
27
- public ee = new EventEmitter()
28
-
29
- constructor(private options: CuboCrdtOptions<A>) {}
30
-
31
- public async start() {
32
- // setInterval(() => {
33
- // console.log('documents', this.documents.size)
34
- // }, 1000)
35
- // const doc = new Doc()
36
- // const map = doc.getMap('store')
37
- // doc.on('update', (upd) => {
38
- // const map1 = doc.getMap('store')
39
- // console.log('new state', map1.toJSON())
40
- // })
41
- // doc.transact(() => {
42
- // map.set('key', 'value')
43
- // })
44
- }
45
-
46
- public async stop() {}
47
-
48
- public async getDocument(name: ID, autoCreate = true) {
49
- // существующий документ
50
- if (!this.documents.has(name)) {
51
- if (!this.loading.has(name) && autoCreate) {
52
- if (this.options.debug) {
53
- console.log('CRDT create document load start', name)
54
- }
55
-
56
- this.loading.set(
57
- name,
58
- new Promise(async (resolve) => {
59
- const document = new CuboCrdtDocument({
60
- name,
61
- autoRemove: true,
62
- debug: this.options.debug,
63
- initialState: () => {
64
- return this.options.beforeLoadDocument?.(name)
65
- },
66
- onUpdate: ({ data, document }) => {
67
- this.ee.emit(CUBO_CRDT_EVENT.DOCUMENT_UPDATED, { name, data, document })
68
- }
69
- })
70
-
71
- await document.init()
72
-
73
- resolve(document)
74
- })
75
- )
76
- }
77
-
78
- const document = await this.loading.get(name)
79
-
80
- this.loading.delete(name)
81
-
82
- if (document) {
83
- this.documents.set(name, document)
84
- }
85
- }
86
-
87
- if (this.options.debug) {
88
- console.log('CRDT create document load completed', name, this.documents.size)
89
- }
90
-
91
- return this.documents.get(name)
92
- }
93
-
94
- public async removeDocument(name: ID) {
95
- if (this.options.debug) {
96
- console.log('CRDT remove document', name)
97
- }
98
-
99
- if (this.loading.has(name)) {
100
- try {
101
- Promise.reject(this.loading.get(name))
102
- this.loading.delete(name)
103
- } catch {}
104
- }
105
-
106
- if (this.documents.has(name)) {
107
- this.documents.delete(name)
108
- }
109
- }
110
-
111
- public async updateDocument(name: ID, dto: object) {
112
- const document = await this.getDocument(name)
113
-
114
- if (document) {
115
- await document.write(dto)
116
- }
117
-
118
- await this.checkDocumentUnload(name)
119
- }
120
-
121
- public async checkDocumentUnload(name: ID) {
122
- const doc = this.documents.get(name)
123
-
124
- if (doc?.opts?.autoRemove && doc.clients.size === 0) {
125
- if (this.options.debug) {
126
- console.log('CRDT destroy document', name)
127
- }
128
-
129
- this.documents.delete(name)
130
- }
131
- }
132
- }
@@ -0,0 +1,91 @@
1
+ import { debounce, pick } from '@cuboapp/utils'
2
+ import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs'
3
+
4
+ import { CuboCrdtExposeStrategy } from '../../types'
5
+
6
+ import { CuboCrdtServerDocumentOptions, CuboCrdtServerDocumentOrigin } from '../types'
7
+
8
+ export class CuboCrdtServerDocument {
9
+ private ydoc: Doc
10
+
11
+ private firstUpdate = true
12
+
13
+ constructor(public opts: CuboCrdtServerDocumentOptions) {
14
+ this.ydoc = new Doc({
15
+ ...(this.opts.yjsOptions || {}),
16
+ autoLoad: false
17
+ })
18
+
19
+ this.ydoc.on('update', (data, origin: CuboCrdtServerDocumentOrigin) => {
20
+ if (this.firstUpdate) {
21
+ this.firstUpdate = true
22
+ this.opts?.onCreate?.(data, origin)
23
+ } else {
24
+ this.opts?.onUpdate?.(data, origin)
25
+ }
26
+
27
+ if (origin?.store) {
28
+ const row = this.getJson()
29
+ const body = pick(row, origin.keys ?? Object.keys(row))
30
+
31
+ if (Object.keys(body)) {
32
+ this.debounceStore(body, origin)
33
+ }
34
+ }
35
+ })
36
+ }
37
+
38
+ public get name() {
39
+ return this.opts.name
40
+ }
41
+
42
+ public get stateAsUpdate() {
43
+ return encodeStateAsUpdate(this.ydoc)
44
+ }
45
+
46
+ applyUpdate(update: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) {
47
+ return applyUpdate(this.ydoc, update, origin)
48
+ }
49
+
50
+ getMap() {
51
+ return this.ydoc.getMap()
52
+ }
53
+
54
+ getJson() {
55
+ return this.getMap().toJSON()
56
+ }
57
+
58
+ async write(dto: object, origin?: CuboCrdtServerDocumentOrigin) {
59
+ const map = this.getMap()
60
+
61
+ this.ydoc.transact(() => {
62
+ Object.entries(dto).forEach(([key, value]) => {
63
+ map.set(key, value)
64
+ })
65
+ }, origin)
66
+ }
67
+
68
+ get id() {
69
+ return this.ydoc.guid
70
+ }
71
+
72
+ destroy() {
73
+ this.ydoc.destroy()
74
+ }
75
+
76
+ private _inited = false
77
+ public init(state?: object, expose: CuboCrdtExposeStrategy = 'all') {
78
+ if (this._inited) {
79
+ return
80
+ }
81
+
82
+ this._inited = true
83
+
84
+ this.write(state ?? {}, { expose })
85
+ }
86
+
87
+ public store(body: object, origin: CuboCrdtServerDocumentOrigin) {
88
+ this.opts?.onStore?.(body, origin)
89
+ }
90
+ public debounceStore = debounce(this.store.bind(this), 300)
91
+ }