@cuboapp/crdt 1.0.0
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/.prettierrc +8 -0
- package/package.json +28 -0
- package/src/client/index.ts +83 -0
- package/src/index.ts +91 -0
- package/src/types/index.ts +11 -0
- package/src/utils/index.ts +7 -0
- package/src/yjs/index.ts +100 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.json +21 -0
package/.prettierrc
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cuboapp/crdt",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CRDT for CUBO",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"repository": "git@github.com:cuboapp/crdt.git",
|
|
7
|
+
"author": "CuboSoft",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@cuboapp/constants": "^2.0.6",
|
|
15
|
+
"@cuboapp/database": "^1.0.3",
|
|
16
|
+
"@cuboapp/types": "^2.0.11",
|
|
17
|
+
"@cuboapp/utils": "1.0.9",
|
|
18
|
+
"@cuboapp/ws": "../ws",
|
|
19
|
+
"pg": "^8.16.3",
|
|
20
|
+
"ws": "^8.19.0",
|
|
21
|
+
"yjs": "^13.6.29"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^24.10.1",
|
|
25
|
+
"@types/ws": "^8.18.1",
|
|
26
|
+
"typescript": "^5.9.3"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { EventEmitter } from '@cuboapp/utils'
|
|
2
|
+
import { createWsClient, WsClient } from '@cuboapp/ws'
|
|
3
|
+
|
|
4
|
+
export type CuboCrdtClientOptions = {
|
|
5
|
+
host?: string
|
|
6
|
+
port?: number
|
|
7
|
+
authToken?: () => Promise<string>
|
|
8
|
+
debug?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class CuboCrdtClient {
|
|
12
|
+
private ws: WsClient
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
private opts: CuboCrdtClientOptions,
|
|
16
|
+
private ee = new EventEmitter()
|
|
17
|
+
) {
|
|
18
|
+
this.ws = createWsClient({
|
|
19
|
+
host: this.opts.host,
|
|
20
|
+
port: this.opts.port,
|
|
21
|
+
autoReconnect: true,
|
|
22
|
+
pingInterval: 5000,
|
|
23
|
+
authToken: this.opts.authToken ? () => this.opts.authToken!() : () => ''
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public async start() {
|
|
28
|
+
await this.ws.connect()
|
|
29
|
+
|
|
30
|
+
this.ws.registerHandler('message', this.onMessage.bind(this))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public async stop() {
|
|
34
|
+
try {
|
|
35
|
+
await this.ws.disconnect()
|
|
36
|
+
} catch {}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private onMessage({ message }: any) {
|
|
40
|
+
if (message.event) {
|
|
41
|
+
this.ee.emit(message.event, message)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async subscribe(event: string, cb: any, filters?: any) {
|
|
46
|
+
if (this.opts.debug) {
|
|
47
|
+
console.log('subscribe', event)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.ee.addListener(event, cb)
|
|
51
|
+
this.ws.request({ method: 'crdt:subscribe', data: { event, filters } })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async unsubscribe(event: string, cb: any) {
|
|
55
|
+
if (this.opts.debug) {
|
|
56
|
+
console.log('unsubscribe', event)
|
|
57
|
+
}
|
|
58
|
+
this.ee.removeListener(event, cb)
|
|
59
|
+
this.ws.request({ method: 'crdt:unsubscribe', data: { event } })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async openDocument<T>(entity: string, id: number): Promise<T> {
|
|
63
|
+
const state = await this.ws.request(
|
|
64
|
+
{
|
|
65
|
+
method: 'crdt:open',
|
|
66
|
+
data: { name: `${entity}:${id}` }
|
|
67
|
+
},
|
|
68
|
+
{ wait: true }
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return state as T
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async closeDocument(entity: string, id: number) {
|
|
75
|
+
await this.ws.request(
|
|
76
|
+
{
|
|
77
|
+
method: 'crdt:close',
|
|
78
|
+
data: { name: `${entity}:${id}` }
|
|
79
|
+
},
|
|
80
|
+
{ wait: true }
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
export * from './client'
|
|
8
|
+
export * from './types'
|
|
9
|
+
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
|
+
private 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
|
+
|
|
33
|
+
public async stop() {}
|
|
34
|
+
|
|
35
|
+
public async getDocument(name: ID) {
|
|
36
|
+
// существующий документ
|
|
37
|
+
if (!this.documents.has(name)) {
|
|
38
|
+
if (this.options.debug) {
|
|
39
|
+
console.log('CRDT create document', name)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!this.loading.has(name)) {
|
|
43
|
+
this.loading.set(
|
|
44
|
+
name,
|
|
45
|
+
new Promise(async (resolve) => {
|
|
46
|
+
const document = new CuboCrdtDocument({
|
|
47
|
+
autoRemove: true,
|
|
48
|
+
debug: this.options.debug,
|
|
49
|
+
initialState: () => {
|
|
50
|
+
return this.options.beforeLoadDocument?.(name)
|
|
51
|
+
},
|
|
52
|
+
onUpdate: ({ data, document }) => {
|
|
53
|
+
this.ee.emit(CUBO_CRDT_EVENT.DOCUMENT_UPDATED, { name, data, document })
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
await document.init()
|
|
58
|
+
|
|
59
|
+
resolve(document)
|
|
60
|
+
})
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.documents.set(name, await this.loading.get(name)!)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return this.documents.get(name)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public async updateDocument(name: ID, dto: object) {
|
|
71
|
+
const document = await this.getDocument(name)
|
|
72
|
+
|
|
73
|
+
if (document) {
|
|
74
|
+
await document.write(dto)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await this.checkDocumentUnload(name)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public async checkDocumentUnload(name: ID) {
|
|
81
|
+
const doc = this.documents.get(name)
|
|
82
|
+
|
|
83
|
+
if (doc?.opts?.autoRemove && doc.clients.size === 0) {
|
|
84
|
+
if (this.options.debug) {
|
|
85
|
+
console.log('CRDT destroy document', name)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this.documents.delete(name)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { WsServer, WsServerRequest } from '@cuboapp/ws'
|
|
2
|
+
|
|
3
|
+
export type CuboCrdtOptions<A> = {
|
|
4
|
+
host?: string
|
|
5
|
+
port?: number
|
|
6
|
+
debug?: boolean
|
|
7
|
+
|
|
8
|
+
authHandler?: (ctx: { server: WsServer<{ auth: A }>; request: WsServerRequest<{ auth: A }> }) => Promise<A> | A
|
|
9
|
+
|
|
10
|
+
beforeLoadDocument?: (name: string) => Promise<any> | any
|
|
11
|
+
}
|
package/src/yjs/index.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { type WsServerSocket } from '@cuboapp/ws'
|
|
2
|
+
import { Doc } from 'yjs'
|
|
3
|
+
|
|
4
|
+
export type CuboCrdtDocumentHooks = {
|
|
5
|
+
onBeforeDestroy?: () => Promise<void> | void
|
|
6
|
+
onAfterDestroy?: () => Promise<void> | void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type CuboCrdtDocumentOptions = CuboCrdtDocumentHooks & {
|
|
10
|
+
autoRemove?: boolean
|
|
11
|
+
debug?: boolean
|
|
12
|
+
initial?: any
|
|
13
|
+
|
|
14
|
+
initialState?: () => Promise<any>
|
|
15
|
+
onUpdate?: (ctx: { data: Uint8Array<ArrayBufferLike>; document: CuboCrdtDocument }) => void
|
|
16
|
+
|
|
17
|
+
yjsOptions?: {
|
|
18
|
+
guid?: string
|
|
19
|
+
collectionid?: string
|
|
20
|
+
gc?: boolean
|
|
21
|
+
gcFilter?: () => true
|
|
22
|
+
meta?: any
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class CuboCrdtDocument {
|
|
27
|
+
private ydoc: Doc
|
|
28
|
+
public clients = new Set<WsServerSocket>()
|
|
29
|
+
|
|
30
|
+
constructor(public opts?: CuboCrdtDocumentOptions) {
|
|
31
|
+
opts = opts || {}
|
|
32
|
+
opts.autoRemove = opts?.autoRemove ?? true
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get stat() {
|
|
36
|
+
return {
|
|
37
|
+
clients: this.clients.size
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async init() {
|
|
42
|
+
this.ydoc = new Doc({
|
|
43
|
+
...(this.opts?.yjsOptions || {}),
|
|
44
|
+
autoLoad: false,
|
|
45
|
+
shouldLoad: false
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const state = this.opts?.initialState ? await this.opts?.initialState() : {}
|
|
49
|
+
|
|
50
|
+
return new Promise<void>((resolve) => {
|
|
51
|
+
this.ydoc.once('updateV2', () => {
|
|
52
|
+
this.ydoc.on('updateV2', (data) => {
|
|
53
|
+
this.opts?.onUpdate?.({ data, document: this })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
resolve()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
this.ydoc.transact((t) => {
|
|
60
|
+
const map = t.doc.getMap()
|
|
61
|
+
|
|
62
|
+
Object.entries(state).forEach(([key, value]) => {
|
|
63
|
+
map.set(key, value)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getMap(name?: string) {
|
|
70
|
+
return this.ydoc.getMap(name)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async hasClient(client: WsServerSocket) {
|
|
74
|
+
return this.clients.has(client)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async addClient(client: WsServerSocket) {
|
|
78
|
+
this.clients.add(client)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async removeClient(client: WsServerSocket) {
|
|
82
|
+
this.clients.delete(client)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async write(dto: object) {
|
|
86
|
+
return new Promise<void>(async (resolve) => {
|
|
87
|
+
this.ydoc.transact(({ doc }) => {
|
|
88
|
+
const map = doc.getMap()
|
|
89
|
+
|
|
90
|
+
Object.entries(dto).forEach(([key, value]) => {
|
|
91
|
+
map.set(key, value)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Внимание! Резолв промиса не означает раскатку по всем подключениям,
|
|
95
|
+
// только лишь запись в Yjs-документ
|
|
96
|
+
resolve()
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"module": "commonjs",
|
|
4
|
+
"declaration": true,
|
|
5
|
+
"removeComments": true,
|
|
6
|
+
"emitDecoratorMetadata": true,
|
|
7
|
+
"experimentalDecorators": true,
|
|
8
|
+
"allowSyntheticDefaultImports": true,
|
|
9
|
+
"target": "ES2021",
|
|
10
|
+
"sourceMap": true,
|
|
11
|
+
"outDir": "./dist",
|
|
12
|
+
"baseUrl": "./",
|
|
13
|
+
"incremental": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"paths": {
|
|
16
|
+
"@/*": ["src/*"]
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"include": ["src/**/*"],
|
|
20
|
+
"exclude": ["node_modules", "dist"]
|
|
21
|
+
}
|