@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 ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "trailingComma": "none",
3
+ "tabWidth": 2,
4
+ "semi": false,
5
+ "singleQuote": true,
6
+ "eslintIntegration": true,
7
+ "printWidth": 140
8
+ }
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
+ }
@@ -0,0 +1,7 @@
1
+ import { CuboApiEntitiesMap } from '@cuboapp/types'
2
+
3
+ import { CuboCrdt, CuboCrdtOptions } from '..'
4
+
5
+ export function createCrdt<T extends CuboApiEntitiesMap<T>, A = {}>(opts: CuboCrdtOptions<A>) {
6
+ return new CuboCrdt<T, A>(opts)
7
+ }
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4
+ }
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
+ }