@cuboapp/api-backend 1.0.23 → 1.0.24

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/api-backend",
3
- "version": "1.0.23",
3
+ "version": "1.0.24",
4
4
  "description": "Backend Api for CuboApp",
5
5
  "main": "src/index.ts",
6
6
  "repository": "git@github.com:cuboapp/api-backend.git",
@@ -8,14 +8,16 @@
8
8
  "license": "MIT",
9
9
  "type": "module",
10
10
  "dependencies": {
11
- "@cuboapp/constants": "^2.0.5",
12
- "@cuboapp/database": "^1.0.2",
13
- "@cuboapp/types": "^2.0.6",
14
- "@cuboapp/utils": "1.0.4",
15
- "@hocuspocus/extension-database": "^3.2.3",
16
- "@hocuspocus/server": "^3.2.3",
11
+ "@cuboapp/constants": "^2.0.6",
12
+ "@cuboapp/crdt": "1.0.2",
13
+ "@cuboapp/database": "^1.0.3",
14
+ "@cuboapp/types": "^2.0.11",
15
+ "@cuboapp/utils": "1.0.9",
16
+ "@cuboapp/ws": "1.0.4",
17
+ "@hocuspocus/extension-database": "^3.4.3",
18
+ "@hocuspocus/server": "^3.4.3",
17
19
  "pg": "^8.16.3",
18
- "yjs": "^13.6.27"
20
+ "yjs": "^13.6.29"
19
21
  },
20
22
  "devDependencies": {
21
23
  "@types/node": "^24.10.1",
@@ -8,6 +8,8 @@ export const CUBO_CRUD_QUERY_SYMBOL_NOT = 'not'
8
8
  export const CUBO_CRUD_DEFAULT_PAGE_LIMIT = 25
9
9
  export const CUBO_CRUD_MAX_PAGE_LIMIT = 500
10
10
 
11
+ export const CUBO_CRUD_ACTION = ['create', 'read', 'update', 'delete'] as const
12
+
11
13
  export const CUBO_CRUD_QUERY_CONDITION = {
12
14
  EQ: 'eq',
13
15
  EX: 'ex',
package/src/crdt/index.ts CHANGED
@@ -123,21 +123,25 @@ export class CuboCrdt<T extends CuboApiEntitiesMap<T>> {
123
123
 
124
124
  body.modified_at = new Date().toISOString()
125
125
 
126
- await this.api.updateOne(
127
- entityAlias as Extract<keyof T, string>,
128
- {
129
- query: { id },
130
- body
131
- },
132
- {
133
- crdt: false,
134
- extra: {
135
- auth: context.auth
126
+ await this.api
127
+ .updateOne(
128
+ entityAlias as Extract<keyof T, string>,
129
+ {
130
+ query: { id },
131
+ body
136
132
  },
137
- excludeHooks: [],
138
- queryOptions: { withDeleted: true, withCrdt: true }
139
- }
140
- )
133
+ {
134
+ crdt: false,
135
+ extra: {
136
+ auth: context.auth
137
+ },
138
+ excludeHooks: [],
139
+ queryOptions: { withDeleted: true, withCrdt: true }
140
+ }
141
+ )
142
+ .catch((e) => {
143
+ console.log('crdt on store error', { body })
144
+ })
141
145
  }
142
146
  })
143
147
  ],
@@ -18,8 +18,8 @@ import { ApiHelpersData } from './data'
18
18
 
19
19
  import { ApiHelpersWithes } from './withes'
20
20
 
21
- export class ApiHelpers<T extends CuboApiEntitiesMap<T>> {
22
- constructor(public api: CuboBackendApi<T>) {}
21
+ export class ApiHelpers<T extends CuboApiEntitiesMap<T>, A> {
22
+ constructor(public api: CuboBackendApi<T, A>) {}
23
23
 
24
24
  public withes = new ApiHelpersWithes<T>(this)
25
25
  public convert = new ApiHelpersConvert<T>(this)
@@ -450,11 +450,11 @@ export class ApiHelpers<T extends CuboApiEntitiesMap<T>> {
450
450
 
451
451
  let entityFields = entity.fields
452
452
 
453
- if (this.api.crdt?.isCrdtSupported(entity) && !withCrdt) {
454
- const crdtKey = this.api.crdt?.getCrdtField(entity)
453
+ // if (this.api.crdt?.isCrdtSupported(entity) && !withCrdt) {
454
+ // const crdtKey = this.api.crdt?.getCrdtField(entity)
455
455
 
456
- entityFields = entityFields.filter((f) => f.alias !== crdtKey?.alias)
457
- }
456
+ // entityFields = entityFields.filter((f) => f.alias !== crdtKey?.alias)
457
+ // }
458
458
 
459
459
  selects.push(...entityFields.map((f) => `t1.${f.alias}`))
460
460
 
package/src/index.ts CHANGED
@@ -1,21 +1,26 @@
1
+ import { CUBO_CRDT_EVENT, CuboCrdt } from '@cuboapp/crdt'
1
2
  import { QueryTypes } from '@cuboapp/database'
2
3
  import { CuboApiEntitiesMap } from '@cuboapp/types'
4
+ import { createWsServer, WsServer } from '@cuboapp/ws'
3
5
 
4
- import { CuboCrdt } from './crdt'
5
6
  import { ApiHelpers } from './helpers'
6
- import { CuboBackendApiAuth, CuboBackendApiOptions, CuboCrudGetManyResponse, CuboCrudMethodOptions, CuboCrudRequest } from './types'
7
+ import {
8
+ CuboBackendApiAuth,
9
+ CuboBackendApiOptions,
10
+ CuboCrudAction,
11
+ CuboCrudGetManyResponse,
12
+ CuboCrudMethodOptions,
13
+ CuboCrudRequest
14
+ } from './types'
7
15
 
8
- export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
9
- constructor(public options: CuboBackendApiOptions<T>) {
10
- if (options.crdt) {
11
- this.crdt = new CuboCrdt<T>(this)
12
- this.crdt.listen()
13
- }
14
- }
16
+ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>, A extends unknown> {
17
+ public crdt?: CuboCrdt<T, A>
18
+ public ws?: WsServer<{ auth: A; subscribes: Map<string, any[]> }>
19
+
20
+ constructor(public options: CuboBackendApiOptions<T, A>) {}
15
21
 
16
22
  public auth: CuboBackendApiAuth
17
- public crdt?: CuboCrdt<T>
18
- public helpers = new ApiHelpers<T>(this)
23
+ public helpers = new ApiHelpers<T, A>(this)
19
24
 
20
25
  async init() {
21
26
  try {
@@ -37,6 +42,239 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
37
42
  }
38
43
  }
39
44
  }
45
+
46
+ if (this.options.crdt !== undefined) {
47
+ type ID = `${Extract<keyof T, string>}:${number | string}`
48
+
49
+ this.crdt = new CuboCrdt<T, A>({
50
+ ...this.options.crdt,
51
+ beforeLoadDocument: async (name: ID) => {
52
+ const [entityAlias, id] = name.split(':') as [Extract<keyof T, string>, string]
53
+
54
+ // console.log('beforeLoadDocument', { entityAlias, id })
55
+
56
+ return this.getOne(entityAlias, { query: { id: +id } })
57
+ }
58
+ })
59
+
60
+ this.ws = createWsServer<{ auth: A; subscribes: Map<string, any[]> }>({
61
+ host: this.options.crdt.host,
62
+ port: this.options.crdt.port,
63
+ debug: this.options.crdt.debug,
64
+ auth:
65
+ this.options.crdt.authHandler !== undefined
66
+ ? {
67
+ default: true,
68
+ handler: async (ctx) => {
69
+ ctx.request.auth = await this.options.crdt.authHandler!(ctx)
70
+ }
71
+ }
72
+ : undefined,
73
+
74
+ onDisconnect: (socket) => {
75
+ this.crdt.documents.forEach((doc) => {
76
+ doc.removeClient(socket)
77
+
78
+ this.crdt.checkDocumentUnload(doc.opts.name as any)
79
+ })
80
+ }
81
+ })
82
+
83
+ await this.ws.start()
84
+ await this.crdt.start()
85
+
86
+ this.ws.registerHandler<{ name: ID }>('crdt:open', async ({ client, message }) => {
87
+ const { name } = message.data || {}
88
+ if (!name) {
89
+ throw 'Имя документа обязательно: "name"'
90
+ }
91
+
92
+ console.log('open document', name)
93
+
94
+ try {
95
+ const document = await this.crdt.getDocument(name)
96
+ document.addClient(client)
97
+
98
+ if (this.options.crdt.debug) {
99
+ console.log('document', name, 'subscribed', document.stat)
100
+ }
101
+
102
+ return Array.from(document.stateAsUpdate)
103
+ } catch (e) {
104
+ console.error('CRDT open document', e)
105
+ throw e
106
+ }
107
+ })
108
+
109
+ this.ws.registerHandler<{ name: ID }>('crdt:close', async ({ client, message }) => {
110
+ const { name } = message.data || {}
111
+ if (!name) {
112
+ throw 'Имя документа обязательно: "name"'
113
+ }
114
+
115
+ const document = await this.crdt.getDocument(name)
116
+ document.removeClient(client)
117
+
118
+ if (this.options.crdt.debug) {
119
+ console.log('document', name, 'unsubscribed', document.stat)
120
+ }
121
+
122
+ await this.crdt.checkDocumentUnload(name)
123
+
124
+ return true
125
+ })
126
+
127
+ this.ws.registerHandler<string>('crdt:subscribe', async ({ client, message }) => {
128
+ let { crdt, event, filters } = message.data as any
129
+ filters = filters || {}
130
+
131
+ if (crdt) {
132
+ event = `crdt:update:${crdt.entity}:${crdt.id}`
133
+ filters = { id: crdt.id }
134
+
135
+ // console.log('subscribe to crdt', event, filters)
136
+ }
137
+
138
+ client.subscribes = client.subscribes || new Map()
139
+ const subs = client.subscribes.get(event) || []
140
+ const ex = subs.find((s) => JSON.stringify(s) === JSON.stringify(filters))
141
+ if (!ex) {
142
+ subs.push(filters)
143
+ }
144
+ client.subscribes.set(event, subs)
145
+
146
+ if (this.options.crdt.debug) {
147
+ console.log(
148
+ 'crdt:subscribe',
149
+ client.id,
150
+ JSON.stringify({ event, filters }),
151
+ Array.from(client.subscribes).map((s) => [s[0], JSON.stringify(s[1])])
152
+ )
153
+ }
154
+ })
155
+
156
+ this.ws.registerHandler<string>('crdt:unsubscribe', async ({ client, message }) => {
157
+ let { event, filters } = message.data as any
158
+ filters = filters || {}
159
+
160
+ client.subscribes = client.subscribes || new Map()
161
+ const subs = client.subscribes.get(event) || []
162
+ const exIndex = subs.findIndex((s) => JSON.stringify(s) === JSON.stringify(filters))
163
+ if (exIndex >= 0) {
164
+ subs.splice(exIndex)
165
+ }
166
+ if (!subs.length) {
167
+ client.subscribes.delete(event)
168
+ } else {
169
+ client.subscribes.set(event, subs)
170
+ }
171
+
172
+ if (this.options.crdt.debug) {
173
+ console.log('crdt:unsubscribe', client.id, { event, filters }, Array.from(client.subscribes))
174
+ }
175
+ })
176
+
177
+ const subFilterCheck = (row: any, filters: any) => {
178
+ let sutable = true
179
+
180
+ Object.entries(filters).forEach(([key, value]) => {
181
+ if (!['limit', 'with'].includes(key)) {
182
+ const [formula, str] = `${value}`.split(':')
183
+
184
+ console.log({ formula, str, value: row[key] })
185
+
186
+ if (!str) {
187
+ if (row[key] !== value) {
188
+ sutable = false
189
+ }
190
+ } else {
191
+ switch (formula) {
192
+ case 'in':
193
+ if (
194
+ !str
195
+ .split(',')
196
+ .map(Number)
197
+ .includes(row[key] as number)
198
+ ) {
199
+ sutable = false
200
+ }
201
+ break
202
+ default:
203
+ sutable = false
204
+ break
205
+ }
206
+ }
207
+ }
208
+ })
209
+
210
+ return sutable
211
+ }
212
+
213
+ const onDocumentEvent = async (name: string, action: CuboCrudAction, data?: Uint8Array) => {
214
+ const [entityAlias, id] = name.split(':') as [Extract<keyof T, string>, string]
215
+
216
+ const entity = await this.helpers.getEntityByAlias(entityAlias)
217
+
218
+ const row = await this.getOne(entityAlias, { query: { id: +id } }, { queryOptions: { withDeleted: true } })
219
+
220
+ const a = this.options?.augmentations?.[entityAlias]
221
+ const filterCheck = a?.filterRowForCrdtEvent ?? subFilterCheck
222
+
223
+ try {
224
+ for (const client of Array.from(this.ws?.clients)) {
225
+ const hasCardSub = client.subscribes?.get(`crdt:${entityAlias}:${id}`)
226
+ const hasEntitySub = client.subscribes?.get(`crdt:${entityAlias}`)
227
+ const hasCrdtSub = client.subscribes?.get(`crdt:update:${entityAlias}:${id}`)
228
+
229
+ const needCardPush = !hasCardSub?.length || !!hasCardSub.find((f) => !!filterCheck(row, f)) || false
230
+ const needEntityPush = !hasEntitySub?.length || !!hasEntitySub.find((f) => !!filterCheck(row, f)) || false
231
+ const needCrdtPush = !hasCrdtSub?.length || !!hasCrdtSub.find((f) => !!filterCheck(row, f)) || false
232
+
233
+ // console.log(id, name, action, {
234
+ // hasCardSub,
235
+ // hasEntitySub,
236
+ // hasCrdtSub,
237
+ // needCardPush,
238
+ // needEntityPush,
239
+ // needCrdtPush,
240
+ // filterCheck
241
+ // })
242
+
243
+ if (needCrdtPush) {
244
+ client.send(JSON.stringify({ method: 'message', event: `crdt:update:${entityAlias}:${id}`, data: Array.from(data) }))
245
+ }
246
+
247
+ if (needCardPush || needEntityPush) {
248
+ if (!a?.can || (await a.can<A>(action, { auth: client.auth, entity, row }))) {
249
+ const dto = {
250
+ event: `crdt:${entityAlias}`,
251
+ action,
252
+ id: id ? +id : null,
253
+ row
254
+ }
255
+
256
+ if (a?.sendCrdtEvent) {
257
+ await a.sendCrdtEvent({ client, auth: client.auth, entity, dto })
258
+ } else {
259
+ client.send(JSON.stringify({ method: 'message', ...dto }))
260
+ }
261
+ } else {
262
+ console.log('sutable pushes cannot', name)
263
+ }
264
+ } else {
265
+ console.log('no sutable pushes', name)
266
+ }
267
+ }
268
+ } catch (e) {
269
+ console.log(e)
270
+ }
271
+ }
272
+
273
+ // document events
274
+ this.crdt.ee.addListener(CUBO_CRDT_EVENT.DOCUMENT_UPDATED, (ctx) => onDocumentEvent(ctx.name, 'update', ctx.data))
275
+ this.crdt.ee.addListener(CUBO_CRDT_EVENT.DOCUMENT_CREATED, (ctx) => onDocumentEvent(ctx.name, 'create', ctx.data))
276
+ this.crdt.ee.addListener(CUBO_CRDT_EVENT.DOCUMENT_DELETED, (ctx) => onDocumentEvent(ctx.name, 'delete', ctx.data))
277
+ }
40
278
  } catch (e) {
41
279
  console.error('[API-BACKEND] startup error', e)
42
280
  }
@@ -151,7 +389,7 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
151
389
  // get all joins
152
390
  const queryDto = await this.helpers.withes.prepare(req, entity, opts)
153
391
  queryDto.withDeleted = opts.queryOptions?.withDeleted
154
- queryDto.withCrdt = opts.queryOptions?.withCrdt
392
+ // queryDto.withCrdt = opts.queryOptions?.withCrdt
155
393
 
156
394
  // create find query
157
395
  const regularQuery = await this.helpers.createFindQuery(req, entity, queryDto)
@@ -271,17 +509,8 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
271
509
  response = await augmentation.afterCreate(response, req, opts)
272
510
  }
273
511
 
274
- if (opts.crdt !== false && this.crdt?.isCrdtSupported(entity)) {
275
- const state = await this.crdt.createState({ entityAlias, id: created_id, entity }, response)
276
-
277
- const fieldKey = entity.fields.find((f) => f.type === 'binary').alias
278
-
279
- const db = await this.helpers.getConnection('read')
280
- await db.update(entity!.alias, 'id=:id', { id: created_id }, { [fieldKey]: state }, opts)
281
-
282
- if (augmentation?.afterCrdtCreate && !opts?.excludeHooks?.includes('afterCrdtCreate')) {
283
- augmentation.afterCrdtCreate(response, req, opts)
284
- }
512
+ if (this.crdt) {
513
+ this.crdt.ee.emit(CUBO_CRDT_EVENT.DOCUMENT_CREATED, { name: `${entityAlias}:${(response as any).id}` })
285
514
  }
286
515
 
287
516
  return response
@@ -306,6 +535,14 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
306
535
  throw { status: 404, text: 'Entity not found' }
307
536
  }
308
537
 
538
+ try {
539
+ if (!!augmentation?.can) {
540
+ await augmentation.can('update', { entity, row: item, auth: opts?.extra?.auth, req })
541
+ }
542
+ } catch {
543
+ throw { status: 403, text: 'Updating entity forbidden' }
544
+ }
545
+
309
546
  const dto = await this.helpers.data.prepare('update', entity!.fields, req.body, opts?.performer_id || 0)
310
547
  if (!Object.keys(dto)) {
311
548
  throw { status: 400, text: 'No keys to update' }
@@ -323,13 +560,8 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
323
560
  response = await augmentation.afterUpdate(response, req, opts)
324
561
  }
325
562
 
326
- if (opts.crdt !== false && this.crdt?.isCrdtSupported(entity)) {
327
- const context = { auth: opts?.extra?.auth }
328
- await this.crdt.updateDocument({ entityAlias, id: item.id, entity }, response, context)
329
-
330
- if (augmentation?.afterCrdtUpdate && !opts?.excludeHooks?.includes('afterCrdtUpdate')) {
331
- augmentation.afterCrdtUpdate(response, req, opts)
332
- }
563
+ if (this.crdt) {
564
+ await this.crdt.updateDocument(`${entityAlias}:${item.id}`, dto)
333
565
  }
334
566
 
335
567
  return response
@@ -365,10 +597,14 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
365
597
  return augmentation.afterDelete(item, req, opts)
366
598
  }
367
599
 
600
+ if (this.crdt) {
601
+ this.crdt.ee.emit(CUBO_CRDT_EVENT.DOCUMENT_DELETED, { name: `${entityAlias}:${item.id}` })
602
+ }
603
+
368
604
  return true
369
605
  }
370
606
  }
371
607
 
372
- export * from './crdt'
608
+ // export * from './crdt'
373
609
  export * from './helpers'
374
610
  export * from './types'
@@ -1,14 +1,28 @@
1
- import { CuboApiEntitiesMap } from '@cuboapp/types'
1
+ import { CuboApiEntitiesMap, CuboEntity } from '@cuboapp/types'
2
2
 
3
- import { CuboCrudGetManyResponse, CuboCrudRequest } from './basic'
3
+ import { WsServerSocket } from '@cuboapp/ws'
4
+ import { CuboCrudAction, CuboCrudGetManyResponse, CuboCrudRequest } from './basic'
4
5
  import { CuboCrudMethodOptions, CuboCrudQueryOptions } from './db'
5
- import { onChangePayload, onLoadDocumentPayload, onStoreDocumentPayload } from '@hocuspocus/server'
6
6
 
7
7
  export type CuboCrudAugmentationsStore<T extends CuboApiEntitiesMap<T>> = {
8
8
  [K in Extract<keyof T, string>]?: CuboCrudAugmentationInstance<T[K]>
9
9
  }
10
10
 
11
- export interface CuboCrudAugmentationInstance<T = unknown> {
11
+ export type CuboCrudCrdtEvent<A, T> = {
12
+ client: WsServerSocket
13
+ auth: A
14
+ entity: CuboEntity
15
+ req?: CuboCrudRequest
16
+ dto: {
17
+ // type: string
18
+ event: string
19
+ action: CuboCrudAction
20
+ row: T
21
+ id: number
22
+ }
23
+ }
24
+
25
+ export interface CuboCrudAugmentationInstance<T> {
12
26
  beforeGetMany?: (req: CuboCrudRequest, opts?: CuboCrudMethodOptions) => Promise<CuboCrudQueryOptions>
13
27
  afterGetMany?: (
14
28
  result: CuboCrudGetManyResponse<T>,
@@ -30,7 +44,8 @@ export interface CuboCrudAugmentationInstance<T = unknown> {
30
44
  beforeDelete?: (req: CuboCrudRequest, opts?: CuboCrudMethodOptions) => Promise<CuboCrudQueryOptions>
31
45
  afterDelete?: (result: T, req: CuboCrudRequest, opts?: CuboCrudMethodOptions) => Promise<boolean>
32
46
 
33
- onLoadDocument?: (data: onLoadDocumentPayload) => Promise<void>
34
- onChangeDocument?: (data: onChangePayload) => Promise<void>
35
- onStoreDocument?: (data: onStoreDocumentPayload) => Promise<void>
47
+ filterRowForCrdtEvent?: (row: T, filters: any) => boolean
48
+ sendCrdtEvent?: <A>(context: CuboCrudCrdtEvent<A, T>) => void | Promise<void>
49
+
50
+ can?: <A>(action: CuboCrudAction, context: { auth: A; entity: CuboEntity; row: T; req?: CuboCrudRequest }) => boolean | Promise<boolean>
36
51
  }
@@ -1,6 +1,10 @@
1
1
  import { type Transaction } from '@cuboapp/database'
2
2
  import { CuboEntity, CuboEntityField } from '@cuboapp/types'
3
3
 
4
+ import { CUBO_CRUD_ACTION } from '@/constants'
5
+
6
+ export type CuboCrudAction = (typeof CUBO_CRUD_ACTION)[number]
7
+
4
8
  export type CuboCrudWith = {
5
9
  entity: CuboEntity | { id: undefined; alias: string; fields: CuboEntityField[] }
6
10
  key: string
@@ -1,15 +1,14 @@
1
+ import { CuboCrdtOptions } from '@cuboapp/crdt'
1
2
  import { Database, DatabaseOptions, Options } from '@cuboapp/database'
2
3
  import { CuboApiEntitiesMap, CuboAuthTokenType, CuboEntity } from '@cuboapp/types'
3
4
 
4
- import { CuboCrdtConfiguration } from '../crdt'
5
-
6
5
  import { CuboCrudAugmentationsStore } from './augmentation'
7
6
 
8
7
  export * from './augmentation'
9
8
  export * from './basic'
10
9
  export * from './db'
11
10
 
12
- export type CuboBackendApiOptions<T extends CuboApiEntitiesMap<T>> = {
11
+ export type CuboBackendApiOptions<T extends CuboApiEntitiesMap<T>, A> = {
13
12
  api?: {
14
13
  base_url?: string
15
14
  headers: Partial<Record<`cubo-${CuboAuthTokenType}`, string>> & Record<string, any>
@@ -22,7 +21,7 @@ export type CuboBackendApiOptions<T extends CuboApiEntitiesMap<T>> = {
22
21
  options?: Options & DatabaseOptions
23
22
  }
24
23
 
25
- crdt?: CuboCrdtConfiguration<T>
24
+ crdt?: Omit<CuboCrdtOptions<A>, 'beforeLoadDocument'>
26
25
 
27
26
  augmentations?: Partial<CuboCrudAugmentationsStore<T>>
28
27
  }