@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 +8 -4
- package/src/client/index.ts +350 -72
- package/src/client/old.ts +249 -0
- package/src/client/types/document.ts +22 -0
- package/src/client/types/index.ts +51 -0
- package/src/client/types/store.ts +15 -0
- package/src/client/types/utils.ts +35 -0
- package/src/constants/index.ts +5 -0
- package/src/index.ts +1 -129
- package/src/server/document/index.ts +91 -0
- package/src/server/index.ts +449 -0
- package/src/server/old/document/index.ts +107 -0
- package/src/server/old/index.ts +49 -0
- package/src/server/types/document.ts +54 -0
- package/src/server/types/index.ts +22 -0
- package/src/server/types/subscribe.ts +15 -0
- package/src/types/index.ts +3 -9
- package/src/utils/index.ts +65 -3
- package/src/yjs/index.ts +0 -118
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { WsServerSocket } from '@cuboapp/ws'
|
|
2
|
+
|
|
3
|
+
import { CUBO_CRDT_EVENT } from '../constants'
|
|
4
|
+
import { CuboCrdtAction } from '../types'
|
|
5
|
+
import { checkRowIsSutable } from '../utils'
|
|
6
|
+
|
|
7
|
+
import { CuboCrdtServerDocument } from './document'
|
|
8
|
+
import {
|
|
9
|
+
CuboCrdtServerDocumentIncomingAction,
|
|
10
|
+
CuboCrdtServerDocumentOrigin,
|
|
11
|
+
CuboCrdtServerOptions,
|
|
12
|
+
CuboCrdtServerSubscribe,
|
|
13
|
+
CuboCrdtServerSubscribeDto,
|
|
14
|
+
CuboCrdtServerUnsubscribeDto
|
|
15
|
+
} from './types'
|
|
16
|
+
|
|
17
|
+
export * from './document'
|
|
18
|
+
export * from './types'
|
|
19
|
+
|
|
20
|
+
export class CuboCrdtServer<M, A = {}> {
|
|
21
|
+
constructor(private options: CuboCrdtServerOptions<M, A>) {}
|
|
22
|
+
|
|
23
|
+
private clients = new Map<string, WsServerSocket<{ auth?: A }>>()
|
|
24
|
+
|
|
25
|
+
private subscribes = new Map<string, CuboCrdtServerSubscribe>()
|
|
26
|
+
private subscribesByClient = new Map<string, Map<string, CuboCrdtServerSubscribe>>()
|
|
27
|
+
private subscribesByEntity = new Map<string, Map<string, CuboCrdtServerSubscribe>>()
|
|
28
|
+
private subscribesIniting = new Map<string, Promise<void>>()
|
|
29
|
+
|
|
30
|
+
private documents = new Map<string, CuboCrdtServerDocument>()
|
|
31
|
+
private documentsSubsrcibes = new Map<string, Set<string>>()
|
|
32
|
+
|
|
33
|
+
public async init() {
|
|
34
|
+
this.entities.forEach((e) => this.subscribesByEntity.set(e, new Map()))
|
|
35
|
+
|
|
36
|
+
this.ws.registerHandler(CUBO_CRDT_EVENT.SUBSCRIBE, async ({ client, message }) => {
|
|
37
|
+
const { subscribe_id: id, entity, filters } = message.data as CuboCrdtServerSubscribeDto
|
|
38
|
+
|
|
39
|
+
if (this.debug) {
|
|
40
|
+
console.log('[CRDT] subscribe', entity, filters)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const subscribe = {
|
|
44
|
+
id,
|
|
45
|
+
client_id: client.id,
|
|
46
|
+
entity,
|
|
47
|
+
filters
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.subscribes.set(id, subscribe)
|
|
51
|
+
|
|
52
|
+
// создаём мапу подписок по клиенту
|
|
53
|
+
this.subscribesByClient.get(client.id)?.set(id, subscribe)
|
|
54
|
+
|
|
55
|
+
// создаём мапу подписок по сущности
|
|
56
|
+
this.subscribesByEntity.get(entity)?.set(id, subscribe)
|
|
57
|
+
|
|
58
|
+
this.initSubscribe(client, subscribe)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
this.ws.registerHandler(CUBO_CRDT_EVENT.UNSUBSCRIBE, async ({ message }) => {
|
|
62
|
+
const { subscribe_id } = message.data as CuboCrdtServerUnsubscribeDto
|
|
63
|
+
|
|
64
|
+
this.cleanSubscribe(subscribe_id)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
this.ws.registerHandler(CUBO_CRDT_EVENT.EVENT, async ({ message }) => {
|
|
68
|
+
let data = message.data as any
|
|
69
|
+
if (!Array.isArray(data)) {
|
|
70
|
+
data = [data]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (this.debug) {
|
|
74
|
+
console.log('[CRDT] incoming event', data)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
data.forEach((row: any) => {
|
|
78
|
+
const update = new Uint8Array(row.data)
|
|
79
|
+
|
|
80
|
+
switch (row.action) {
|
|
81
|
+
case 'create':
|
|
82
|
+
this.onDocumentExternalCreate(update, row)
|
|
83
|
+
break
|
|
84
|
+
case 'update':
|
|
85
|
+
this.onDocumentExternalUpdate(update, row)
|
|
86
|
+
break
|
|
87
|
+
case 'delete':
|
|
88
|
+
this.onDocumentExternalDelete(update, row)
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
this.ws.registerHttpHandler('GET', '/stats', () => {
|
|
95
|
+
return {
|
|
96
|
+
clients: this.clients.size,
|
|
97
|
+
subscribes: {
|
|
98
|
+
size: this.subscribes.size,
|
|
99
|
+
byClient: this.subscribesByClient.size,
|
|
100
|
+
byEntity: Array.from(this.subscribesByEntity)
|
|
101
|
+
.map((m) => ({
|
|
102
|
+
entity: m[0],
|
|
103
|
+
size: m[1].size
|
|
104
|
+
}))
|
|
105
|
+
.filter((s) => s.size > 0)
|
|
106
|
+
},
|
|
107
|
+
documents: {
|
|
108
|
+
size: this.documents.size,
|
|
109
|
+
rows: Array.from(this.documents).map((d) => ({
|
|
110
|
+
id: d[0],
|
|
111
|
+
subs: Array.from(this.documentsSubsrcibes.get(d[0]) || [])
|
|
112
|
+
}))
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public async destroy() {
|
|
119
|
+
this.ws.deleteHandler(CUBO_CRDT_EVENT.SUBSCRIBE)
|
|
120
|
+
this.ws.deleteHandler(CUBO_CRDT_EVENT.UNSUBSCRIBE)
|
|
121
|
+
this.ws.deleteHandler(CUBO_CRDT_EVENT.EVENT)
|
|
122
|
+
|
|
123
|
+
this.subscribes.forEach((sub) => this.ws.deleteHandler(sub.id))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
public addClient(client: WsServerSocket<{ auth?: A }>) {
|
|
127
|
+
// добавляем клиента
|
|
128
|
+
this.clients.set(client.id, client)
|
|
129
|
+
|
|
130
|
+
// создаем мапу подписок по клиенту
|
|
131
|
+
this.subscribesByClient.set(client.id, new Map())
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
public removeClient(client: WsServerSocket) {
|
|
135
|
+
// удаляем все подписки по клиенту
|
|
136
|
+
this.subscribesByClient.get(client.id)?.forEach((sub) => this.cleanSubscribe(sub.id))
|
|
137
|
+
|
|
138
|
+
// удаляем мапу подписок по клиенту
|
|
139
|
+
this.subscribesByClient.delete(client.id)
|
|
140
|
+
|
|
141
|
+
// и в принципе клиента
|
|
142
|
+
this.clients.delete(client.id)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private cleanSubscribe(subscribe_id: string) {
|
|
146
|
+
const subscribe = this.subscribes.get(subscribe_id)
|
|
147
|
+
if (subscribe) {
|
|
148
|
+
if (this.debug) {
|
|
149
|
+
console.log('[CRDT] unsubscribe', subscribe.entity, subscribe.filters)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// удаляем подписку по сущности
|
|
153
|
+
this.subscribesByEntity.get(subscribe.entity)?.delete(subscribe.id)
|
|
154
|
+
|
|
155
|
+
// отписываем все документы
|
|
156
|
+
this.documentsSubsrcibes.forEach((subs, docName) => {
|
|
157
|
+
subs.delete(subscribe_id)
|
|
158
|
+
|
|
159
|
+
if (!subs?.size) {
|
|
160
|
+
if (this.debug) {
|
|
161
|
+
console.log('[CRDT] delete document', docName)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.documents.get(docName)?.destroy()
|
|
165
|
+
this.documents.delete(docName)
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// удаляем подписки
|
|
170
|
+
this.subscribes.delete(subscribe_id)
|
|
171
|
+
this.subscribesByClient.get(subscribe.client_id)?.delete(subscribe_id)
|
|
172
|
+
this.subscribesByEntity.get(subscribe.entity)?.delete(subscribe_id)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
public getDocument(entity: string, entity_id: number) {
|
|
177
|
+
return this.documents.get(`${entity}:${entity_id}`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
public getSutableSubscribes(entity: string, row: any) {
|
|
181
|
+
return Array.from(this.subscribesByEntity.get(entity) || [])
|
|
182
|
+
.filter((m) => checkRowIsSutable(row, m[1].filters))
|
|
183
|
+
.map((m) => m[1])
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
public ensureDocument(entity: string, row: any, subscribes_ids?: string[]) {
|
|
187
|
+
const entity_id = row.id
|
|
188
|
+
const documentName = `${entity}:${entity_id}`
|
|
189
|
+
|
|
190
|
+
// проверяем документ
|
|
191
|
+
let document = this.documents.get(documentName)
|
|
192
|
+
if (!document) {
|
|
193
|
+
document = new CuboCrdtServerDocument({
|
|
194
|
+
name: documentName,
|
|
195
|
+
onCreate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
|
|
196
|
+
this.pushDocumentAction('create', document!, data, origin)
|
|
197
|
+
},
|
|
198
|
+
onUpdate: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => {
|
|
199
|
+
this.pushDocumentAction('update', document!, data, origin)
|
|
200
|
+
},
|
|
201
|
+
onStore: (item: object, origin: CuboCrdtServerDocumentOrigin) => {
|
|
202
|
+
const subscribe = origin.subscribe_id && this.subscribes.get(origin.subscribe_id)
|
|
203
|
+
const client = subscribe && this.clients.get(subscribe.client_id)
|
|
204
|
+
|
|
205
|
+
if (client) {
|
|
206
|
+
return this.options?.storeRow?.(entity as any, entity_id, item as any, { client, origin })
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
this.documents.set(documentName, document)
|
|
212
|
+
|
|
213
|
+
this.documentsSubsrcibes.set(documentName, new Set())
|
|
214
|
+
if (subscribes_ids?.length) {
|
|
215
|
+
const subs = this.documentsSubsrcibes.get(documentName)
|
|
216
|
+
subscribes_ids?.forEach((id) => {
|
|
217
|
+
// добавляем подписку к документу
|
|
218
|
+
subs?.add(id)
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// инициализируем документ
|
|
223
|
+
document.init(row)
|
|
224
|
+
} else {
|
|
225
|
+
// добавляем подписки к документу
|
|
226
|
+
const subs = this.documentsSubsrcibes.get(documentName)
|
|
227
|
+
subscribes_ids?.forEach((subscribe_id) => {
|
|
228
|
+
subs?.add(subscribe_id)
|
|
229
|
+
|
|
230
|
+
// пушим документ в сокеты (в текущем состоянии)
|
|
231
|
+
this.pushDocumentAction('update', document!, document!.stateAsUpdate, { subscribe_id, expose: 'subscribe' })
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return this.documents.get(documentName)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
public deleteDocument(entity: string, row: any) {
|
|
239
|
+
if (this.debug) {
|
|
240
|
+
console.log('[CRDT] delete document', entity, row.id)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const documentName = `${entity}:${row.id}`
|
|
244
|
+
const document = this.documents.get(documentName)
|
|
245
|
+
|
|
246
|
+
if (document) {
|
|
247
|
+
// отписываемся от всех подписок на документ
|
|
248
|
+
this.pushDocumentAction('delete', document, null, { expose: 'subscribe' })
|
|
249
|
+
|
|
250
|
+
// удаляем все подписки по документу
|
|
251
|
+
this.documentsSubsrcibes.delete(documentName)
|
|
252
|
+
|
|
253
|
+
// удаляем документ
|
|
254
|
+
this.documents.delete(documentName)
|
|
255
|
+
|
|
256
|
+
// дестроим документ
|
|
257
|
+
document.destroy()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private onDocumentExternalCreate(update: Uint8Array, action: CuboCrdtServerDocumentIncomingAction) {
|
|
262
|
+
console.warn('[CRDT] onDocumentExternalCreate not implemented')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private onDocumentExternalUpdate(update: Uint8Array, action: CuboCrdtServerDocumentIncomingAction) {
|
|
266
|
+
const document = this.getDocument(action.entity, action.entity_id)
|
|
267
|
+
|
|
268
|
+
// console.log('onDocumentExternalUpdate', action)
|
|
269
|
+
|
|
270
|
+
if (!document) {
|
|
271
|
+
console.warn('[CRDT] onDocumentExternalUpdate - document not found:', action.entity, action.entity_id)
|
|
272
|
+
} else {
|
|
273
|
+
document.applyUpdate(new Uint8Array(update), action.origin)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private onDocumentExternalDelete(update: Uint8Array, action: CuboCrdtServerDocumentIncomingAction) {
|
|
278
|
+
console.warn('[CRDT] onDocumentExternalDelete not implemented')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private documentUnsubscribe(docName: string, subscribe_id: string, push = true) {
|
|
282
|
+
const document = this.documents.get(docName)
|
|
283
|
+
|
|
284
|
+
if (document) {
|
|
285
|
+
// сначала отсылаем, затем удаляем подписки (иначе не будет куда слать)
|
|
286
|
+
if (push) {
|
|
287
|
+
this.pushDocumentAction('delete', document, null, { subscribe_id, expose: 'subscribe' })
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const documentSubscribes = this.documentsSubsrcibes.get(docName)
|
|
291
|
+
documentSubscribes?.delete(subscribe_id)
|
|
292
|
+
|
|
293
|
+
// если нет больше подписок - удаляем документ
|
|
294
|
+
if (!documentSubscribes?.size) {
|
|
295
|
+
if (this.debug) {
|
|
296
|
+
console.log('[CRDT] delete document', name)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
document?.destroy()
|
|
300
|
+
this.documents.delete(document.name)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private pushDocumentAction(
|
|
306
|
+
action: CuboCrdtAction,
|
|
307
|
+
document: CuboCrdtServerDocument,
|
|
308
|
+
data?: Uint8Array | null,
|
|
309
|
+
origin?: CuboCrdtServerDocumentOrigin
|
|
310
|
+
) {
|
|
311
|
+
const row = document.getJson()
|
|
312
|
+
|
|
313
|
+
if (this.debug) {
|
|
314
|
+
console.log('[CRDT] pushDocumentAction ' + action, {
|
|
315
|
+
action,
|
|
316
|
+
row_id: row.id,
|
|
317
|
+
subscribes: Array.from(this.documentsSubsrcibes.get(document.name)!)
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
switch (action) {
|
|
322
|
+
case 'delete': {
|
|
323
|
+
this.documentsSubsrcibes.get(document.name)?.forEach((subscribe_id) => {
|
|
324
|
+
const subscribe = this.subscribes.get(subscribe_id)
|
|
325
|
+
if (subscribe) {
|
|
326
|
+
const client = this.clients.get(subscribe.client_id)
|
|
327
|
+
|
|
328
|
+
if (client) {
|
|
329
|
+
if (this.debug) {
|
|
330
|
+
console.log('[CRDT] send action ' + action, subscribe.entity, row.id, subscribe_id)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// todo: send сделать через очередь (с группировкой по клиенту)
|
|
334
|
+
client.send(
|
|
335
|
+
JSON.stringify({
|
|
336
|
+
method: CUBO_CRDT_EVENT.EVENT,
|
|
337
|
+
data: {
|
|
338
|
+
action,
|
|
339
|
+
entity: subscribe.entity,
|
|
340
|
+
entity_id: row.id,
|
|
341
|
+
subscribe_id: subscribe.id
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
break
|
|
349
|
+
}
|
|
350
|
+
case 'update':
|
|
351
|
+
case 'create': {
|
|
352
|
+
this.documentsSubsrcibes.get(document.name)?.forEach((subscribe_id) => {
|
|
353
|
+
const subscribe = this.subscribes.get(subscribe_id)
|
|
354
|
+
|
|
355
|
+
if (subscribe) {
|
|
356
|
+
const client = this.clients.get(subscribe.client_id)
|
|
357
|
+
|
|
358
|
+
if (client) {
|
|
359
|
+
const noStrategyResolved = !origin?.expose || origin?.expose === 'all'
|
|
360
|
+
const strategyOtherResolved = (origin?.expose === 'other' && origin.subscribe_id !== subscribe_id) || false
|
|
361
|
+
const strategyClientResolved = origin?.expose === 'client' && subscribe.client_id === origin.client_id
|
|
362
|
+
const strategySubscribeResolved = origin?.expose === 'subscribe' && subscribe.id === origin.subscribe_id
|
|
363
|
+
|
|
364
|
+
if (noStrategyResolved || strategyOtherResolved || strategyClientResolved || strategySubscribeResolved) {
|
|
365
|
+
const isSutable = checkRowIsSutable(document.getJson(), subscribe.filters || {})
|
|
366
|
+
|
|
367
|
+
// если документ перестал быть доступным в рамках указанных фильтров - удаляем его
|
|
368
|
+
if (!isSutable) {
|
|
369
|
+
this.documentUnsubscribe(document.name, subscribe.id)
|
|
370
|
+
} else {
|
|
371
|
+
if (this.debug) {
|
|
372
|
+
console.log('[CRDT] send action ' + action, subscribe.entity, row.id, origin, {
|
|
373
|
+
noStrategyResolved,
|
|
374
|
+
strategyOtherResolved,
|
|
375
|
+
strategyClientResolved,
|
|
376
|
+
strategySubscribeResolved
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// todo: send сделать через очередь (с группировкой по клиенту)
|
|
381
|
+
client.send(
|
|
382
|
+
JSON.stringify({
|
|
383
|
+
method: CUBO_CRDT_EVENT.EVENT,
|
|
384
|
+
data: {
|
|
385
|
+
action,
|
|
386
|
+
entity: subscribe.entity,
|
|
387
|
+
entity_id: row.id,
|
|
388
|
+
data: Array.from(data as any),
|
|
389
|
+
subscribe_id: subscribe.id
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private async initSubscribe(client: WsServerSocket, subscribe: CuboCrdtServerSubscribe) {
|
|
403
|
+
if (this.subscribesIniting.has(subscribe.id)) {
|
|
404
|
+
return this.subscribesIniting.get(subscribe.id)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
this.subscribesIniting.set(
|
|
408
|
+
subscribe.id,
|
|
409
|
+
new Promise<void>(async (resolve, reject) => {
|
|
410
|
+
try {
|
|
411
|
+
if (this.debug) {
|
|
412
|
+
console.log('[CRDT] initSubscribe ' + client.id, subscribe)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// получаем список всех документов
|
|
416
|
+
const rows = await this.options.fetchRows?.(client, subscribe)
|
|
417
|
+
|
|
418
|
+
for (const row of rows || []) {
|
|
419
|
+
this.ensureDocument(subscribe.entity, row, [subscribe.id])
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
resolve()
|
|
423
|
+
} catch (e) {
|
|
424
|
+
if (this.debug) {
|
|
425
|
+
console.error(`[CRDT] initSubscribe`, e)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
reject(e)
|
|
429
|
+
} finally {
|
|
430
|
+
this.subscribesIniting.delete(subscribe.id)
|
|
431
|
+
}
|
|
432
|
+
})
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
return this.subscribesIniting.get(subscribe.id)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private get debug() {
|
|
439
|
+
return this.options.debug
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private get ws() {
|
|
443
|
+
return this.options.ws
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private get entities() {
|
|
447
|
+
return this.options.entities
|
|
448
|
+
}
|
|
449
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs'
|
|
2
|
+
|
|
3
|
+
export class CuboCrdtDocument {
|
|
4
|
+
private ydoc: Doc
|
|
5
|
+
private subs = new Map<string, (data: Uint8Array<ArrayBufferLike>, origin?: any) => void>()
|
|
6
|
+
|
|
7
|
+
constructor(
|
|
8
|
+
public opts: {
|
|
9
|
+
name: string
|
|
10
|
+
initialState?: any
|
|
11
|
+
autoRemove?: boolean
|
|
12
|
+
onDelete?: () => void
|
|
13
|
+
yjsOptions?: {
|
|
14
|
+
guid?: string
|
|
15
|
+
collectionid?: string
|
|
16
|
+
gc?: boolean
|
|
17
|
+
gcFilter?: () => true
|
|
18
|
+
meta?: any
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
get stateAsUpdate() {
|
|
24
|
+
return encodeStateAsUpdate(this.ydoc)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async init() {
|
|
28
|
+
this.ydoc = new Doc({
|
|
29
|
+
...(this.opts.yjsOptions || {}),
|
|
30
|
+
autoLoad: false
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const state = this.opts?.initialState ?? {}
|
|
34
|
+
|
|
35
|
+
return new Promise<Uint8Array<ArrayBufferLike>>((resolve) => {
|
|
36
|
+
this.ydoc.once('update', () => {
|
|
37
|
+
this.ydoc.on('update', (data, origin) => {
|
|
38
|
+
this.subs.forEach((sub) => sub(data, origin))
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
resolve(this.stateAsUpdate)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const map = this.getMap()
|
|
45
|
+
|
|
46
|
+
this.ydoc.transact(() => {
|
|
47
|
+
if (Object.keys(state).length) {
|
|
48
|
+
Object.entries(state).forEach(([key, value]) => {
|
|
49
|
+
map.set(key, value)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.getState().setAttribute('inited', 'Y')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
get inited() {
|
|
59
|
+
return this.getState().getAttribute('inited') === 'Y'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get id() {
|
|
63
|
+
return this.ydoc.guid
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
applyUpdate(update: Uint8Array, origin?: any) {
|
|
67
|
+
return applyUpdate(this.ydoc, update, origin)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
subscribe(id: string, cb: (data: Uint8Array<ArrayBufferLike>, origin?: any) => void) {
|
|
71
|
+
this.subs.set(id, cb)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
unsubscribe(id: string) {
|
|
75
|
+
this.subs.delete(id)
|
|
76
|
+
|
|
77
|
+
if (!this.subs.size && this.opts?.autoRemove !== false) {
|
|
78
|
+
this.opts?.onDelete?.()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getState() {
|
|
83
|
+
return this.ydoc.getText('state')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getMap() {
|
|
87
|
+
return this.ydoc.getMap()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getXmlFragment() {
|
|
91
|
+
return this.ydoc.getXmlFragment()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async writeMap(dto: object, origin?: any) {
|
|
95
|
+
return new Promise<void>(async (resolve) => {
|
|
96
|
+
const map = this.getMap()
|
|
97
|
+
|
|
98
|
+
this.ydoc.transact(() => {
|
|
99
|
+
Object.entries(dto).forEach(([key, value]) => {
|
|
100
|
+
map.set(key, value)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
resolve()
|
|
104
|
+
}, origin)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { CuboCrdtDocument } from './document'
|
|
2
|
+
|
|
3
|
+
export * from './document'
|
|
4
|
+
|
|
5
|
+
export class CuboCrdtServer<T> {
|
|
6
|
+
constructor(private options: { debug?: boolean }) {}
|
|
7
|
+
|
|
8
|
+
public documents = new Map<string, CuboCrdtDocument>()
|
|
9
|
+
private loading = new Map<string, Promise<CuboCrdtDocument>>()
|
|
10
|
+
|
|
11
|
+
public async getDocument(name: string, opts?: { autoCreate?: boolean; initialState?: any }) {
|
|
12
|
+
// существующий документ
|
|
13
|
+
if (!this.documents.has(name)) {
|
|
14
|
+
if (!this.loading.has(name) && opts?.autoCreate !== false) {
|
|
15
|
+
if (this.options.debug) {
|
|
16
|
+
console.log('CRDT create document load start', name)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
this.loading.set(
|
|
20
|
+
name,
|
|
21
|
+
new Promise(async (resolve) => {
|
|
22
|
+
const document = new CuboCrdtDocument({
|
|
23
|
+
name,
|
|
24
|
+
initialState: opts?.initialState,
|
|
25
|
+
autoRemove: true,
|
|
26
|
+
onDelete: () => {
|
|
27
|
+
this.documents.delete(name)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
await document.init()
|
|
32
|
+
|
|
33
|
+
resolve(document)
|
|
34
|
+
})
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const document = await this.loading.get(name)
|
|
39
|
+
|
|
40
|
+
this.loading.delete(name)
|
|
41
|
+
|
|
42
|
+
if (document) {
|
|
43
|
+
this.documents.set(name, document)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return this.documents.get(name)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { CuboCrdtAction, CuboCrdtExposeStrategy } from '../../types'
|
|
2
|
+
|
|
3
|
+
export type CuboCrdtServerDocumentOptions = {
|
|
4
|
+
name: string
|
|
5
|
+
onStore?: (body: object, origin: CuboCrdtServerDocumentOrigin) => void
|
|
6
|
+
onCreate?: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => void
|
|
7
|
+
onUpdate?: (data: Uint8Array, origin?: CuboCrdtServerDocumentOrigin) => void
|
|
8
|
+
// onInit?: (document: CuboCrdtServerDocument) => void
|
|
9
|
+
|
|
10
|
+
yjsOptions?: {
|
|
11
|
+
guid?: string
|
|
12
|
+
collectionid?: string
|
|
13
|
+
gc?: boolean
|
|
14
|
+
gcFilter?: () => true
|
|
15
|
+
meta?: any
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type CuboCrdtServerDocumentIncomingAction = {
|
|
20
|
+
// действие
|
|
21
|
+
action: CuboCrdtAction
|
|
22
|
+
|
|
23
|
+
// сущность
|
|
24
|
+
entity: string
|
|
25
|
+
|
|
26
|
+
// ид сущности
|
|
27
|
+
entity_id: number
|
|
28
|
+
|
|
29
|
+
// контент апдейта
|
|
30
|
+
data: number[]
|
|
31
|
+
|
|
32
|
+
// ориджин
|
|
33
|
+
origin: CuboCrdtServerDocumentOrigin
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type CuboCrdtServerDocumentOrigin = {
|
|
37
|
+
// сохранять ли на бэке в дебаунсе
|
|
38
|
+
store?: boolean
|
|
39
|
+
|
|
40
|
+
// реагировать ли в onUpdate (на бэке)
|
|
41
|
+
react?: boolean
|
|
42
|
+
|
|
43
|
+
// обновлённые ключи
|
|
44
|
+
keys?: string[]
|
|
45
|
+
|
|
46
|
+
// куда раскатывать обновления - всем или всем кроме себя
|
|
47
|
+
expose?: CuboCrdtExposeStrategy
|
|
48
|
+
|
|
49
|
+
// id подписки-исходника
|
|
50
|
+
subscribe_id?: string
|
|
51
|
+
|
|
52
|
+
// id клиента
|
|
53
|
+
client_id?: string
|
|
54
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { WsServer, WsServerSocket } from '@cuboapp/ws'
|
|
2
|
+
import { CuboCrdtServerDocumentOrigin } from './document'
|
|
3
|
+
import { CuboCrdtServerSubscribe } from './subscribe'
|
|
4
|
+
|
|
5
|
+
export * from './document'
|
|
6
|
+
export * from './subscribe'
|
|
7
|
+
|
|
8
|
+
export type CuboCrdtServerOptions<M, A> = {
|
|
9
|
+
ws: WsServer
|
|
10
|
+
entities: Extract<keyof M, string>[]
|
|
11
|
+
debug?: boolean
|
|
12
|
+
fetchRows?: (client: WsServerSocket, subscribe: CuboCrdtServerSubscribe) => Promise<M[Extract<keyof M, string>][]>
|
|
13
|
+
storeRow?: <K extends Extract<keyof M, string>>(
|
|
14
|
+
entity: K,
|
|
15
|
+
entity_id: number,
|
|
16
|
+
row: M[K],
|
|
17
|
+
opts: {
|
|
18
|
+
client: WsServerSocket<{ auth?: A }>
|
|
19
|
+
origin: CuboCrdtServerDocumentOrigin
|
|
20
|
+
}
|
|
21
|
+
) => Promise<void> | void
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type CuboCrdtServerUnsubscribeDto = {
|
|
2
|
+
subscribe_id: string
|
|
3
|
+
}
|
|
4
|
+
export type CuboCrdtServerSubscribeDto = {
|
|
5
|
+
subscribe_id: string
|
|
6
|
+
entity: string
|
|
7
|
+
filters: {
|
|
8
|
+
[K in 'id' | string]: any
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type CuboCrdtServerSubscribe = {
|
|
13
|
+
id: string
|
|
14
|
+
client_id: string
|
|
15
|
+
} & Pick<CuboCrdtServerSubscribeDto, 'entity' | 'filters'>
|