@cuboapp/api-backend 1.0.23 → 1.0.25

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.25",
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.5",
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,241 @@ 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
+ if (this.options.crdt.debug) {
93
+ console.log('open document', name)
94
+ }
95
+
96
+ try {
97
+ const document = await this.crdt.getDocument(name)
98
+ document.addClient(client)
99
+
100
+ if (this.options.crdt.debug) {
101
+ console.log('document', name, 'subscribed', document.stat)
102
+ }
103
+
104
+ return Array.from(document.stateAsUpdate)
105
+ } catch (e) {
106
+ console.error('CRDT open document', e)
107
+ throw e
108
+ }
109
+ })
110
+
111
+ this.ws.registerHandler<{ name: ID }>('crdt:close', async ({ client, message }) => {
112
+ const { name } = message.data || {}
113
+ if (!name) {
114
+ throw 'Имя документа обязательно: "name"'
115
+ }
116
+
117
+ const document = await this.crdt.getDocument(name)
118
+ document.removeClient(client)
119
+
120
+ if (this.options.crdt.debug) {
121
+ console.log('document', name, 'unsubscribed', document.stat)
122
+ }
123
+
124
+ await this.crdt.checkDocumentUnload(name)
125
+
126
+ return true
127
+ })
128
+
129
+ this.ws.registerHandler<string>('crdt:subscribe', async ({ client, message }) => {
130
+ let { crdt, event, filters } = message.data as any
131
+ filters = filters || {}
132
+
133
+ if (crdt) {
134
+ event = `crdt:update:${crdt.entity}:${crdt.id}`
135
+ filters = { id: crdt.id }
136
+
137
+ // console.log('subscribe to crdt', event, filters)
138
+ }
139
+
140
+ client.subscribes = client.subscribes || new Map()
141
+ const subs = client.subscribes.get(event) || []
142
+ const ex = subs.find((s) => JSON.stringify(s) === JSON.stringify(filters))
143
+ if (!ex) {
144
+ subs.push(filters)
145
+ }
146
+ client.subscribes.set(event, subs)
147
+
148
+ if (this.options.crdt.debug) {
149
+ console.log(
150
+ 'crdt:subscribe',
151
+ client.id,
152
+ JSON.stringify({ event, filters }),
153
+ Array.from(client.subscribes).map((s) => [s[0], JSON.stringify(s[1])])
154
+ )
155
+ }
156
+ })
157
+
158
+ this.ws.registerHandler<string>('crdt:unsubscribe', async ({ client, message }) => {
159
+ let { event, filters } = message.data as any
160
+ filters = filters || {}
161
+
162
+ client.subscribes = client.subscribes || new Map()
163
+ const subs = client.subscribes.get(event) || []
164
+ const exIndex = subs.findIndex((s) => JSON.stringify(s) === JSON.stringify(filters))
165
+ if (exIndex >= 0) {
166
+ subs.splice(exIndex)
167
+ }
168
+ if (!subs.length) {
169
+ client.subscribes.delete(event)
170
+ } else {
171
+ client.subscribes.set(event, subs)
172
+ }
173
+
174
+ if (this.options.crdt.debug) {
175
+ console.log('crdt:unsubscribe', client.id, { event, filters }, Array.from(client.subscribes))
176
+ }
177
+ })
178
+
179
+ const subFilterCheck = (row: any, filters: any) => {
180
+ let sutable = true
181
+
182
+ Object.entries(filters).forEach(([key, value]) => {
183
+ if (!['limit', 'with'].includes(key)) {
184
+ const [formula, str] = `${value}`.split(':')
185
+
186
+ // console.log({ formula, str, value: row[key] })
187
+
188
+ if (!str) {
189
+ if (row[key] !== value) {
190
+ sutable = false
191
+ }
192
+ } else {
193
+ switch (formula) {
194
+ case 'in':
195
+ if (
196
+ !str
197
+ .split(',')
198
+ .map(Number)
199
+ .includes(row[key] as number)
200
+ ) {
201
+ sutable = false
202
+ }
203
+ break
204
+ default:
205
+ sutable = false
206
+ break
207
+ }
208
+ }
209
+ }
210
+ })
211
+
212
+ return sutable
213
+ }
214
+
215
+ const onDocumentEvent = async (name: string, action: CuboCrudAction, data?: Uint8Array) => {
216
+ const [entityAlias, id] = name.split(':') as [Extract<keyof T, string>, string]
217
+
218
+ const entity = await this.helpers.getEntityByAlias(entityAlias)
219
+
220
+ const row = await this.getOne(entityAlias, { query: { id: +id } }, { queryOptions: { withDeleted: true } })
221
+
222
+ const a = this.options?.augmentations?.[entityAlias]
223
+ const filterCheck = a?.filterRowForCrdtEvent ?? subFilterCheck
224
+
225
+ try {
226
+ for (const client of Array.from(this.ws?.clients)) {
227
+ const hasCardSub = client.subscribes?.get(`crdt:${entityAlias}:${id}`)
228
+ const hasEntitySub = client.subscribes?.get(`crdt:${entityAlias}`)
229
+ const hasCrdtSub = client.subscribes?.get(`crdt:update:${entityAlias}:${id}`)
230
+
231
+ const needCardPush = !hasCardSub?.length || !!hasCardSub.find((f) => !!filterCheck(row, f)) || false
232
+ const needEntityPush = !hasEntitySub?.length || !!hasEntitySub.find((f) => !!filterCheck(row, f)) || false
233
+ const needCrdtPush = !hasCrdtSub?.length || !!hasCrdtSub.find((f) => !!filterCheck(row, f)) || false
234
+
235
+ // console.log(id, name, action, {
236
+ // hasCardSub,
237
+ // hasEntitySub,
238
+ // hasCrdtSub,
239
+ // needCardPush,
240
+ // needEntityPush,
241
+ // needCrdtPush,
242
+ // filterCheck
243
+ // })
244
+
245
+ if (needCrdtPush && data?.length) {
246
+ client.send(JSON.stringify({ method: 'message', event: `crdt:update:${entityAlias}:${id}`, data: Array.from(data) }))
247
+ }
248
+
249
+ if (needCardPush || needEntityPush) {
250
+ if (!a?.can || (await a.can<A>(action, { auth: client.auth, entity, row }))) {
251
+ const dto = {
252
+ event: `crdt:${entityAlias}`,
253
+ action,
254
+ id: id ? +id : null,
255
+ row
256
+ }
257
+
258
+ if (a?.sendCrdtEvent) {
259
+ await a.sendCrdtEvent({ client, auth: client.auth, entity, dto })
260
+ } else {
261
+ client.send(JSON.stringify({ method: 'message', ...dto }))
262
+ }
263
+ } else {
264
+ console.log('sutable pushes cannot', name)
265
+ }
266
+ } else {
267
+ console.log('no sutable pushes', name)
268
+ }
269
+ }
270
+ } catch (e) {
271
+ console.log(e)
272
+ }
273
+ }
274
+
275
+ // document events
276
+ this.crdt.ee.addListener(CUBO_CRDT_EVENT.DOCUMENT_UPDATED, (ctx) => onDocumentEvent(ctx.name, 'update', ctx.data))
277
+ this.crdt.ee.addListener(CUBO_CRDT_EVENT.DOCUMENT_CREATED, (ctx) => onDocumentEvent(ctx.name, 'create', ctx.data))
278
+ this.crdt.ee.addListener(CUBO_CRDT_EVENT.DOCUMENT_DELETED, (ctx) => onDocumentEvent(ctx.name, 'delete', ctx.data))
279
+ }
40
280
  } catch (e) {
41
281
  console.error('[API-BACKEND] startup error', e)
42
282
  }
@@ -151,7 +391,7 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
151
391
  // get all joins
152
392
  const queryDto = await this.helpers.withes.prepare(req, entity, opts)
153
393
  queryDto.withDeleted = opts.queryOptions?.withDeleted
154
- queryDto.withCrdt = opts.queryOptions?.withCrdt
394
+ // queryDto.withCrdt = opts.queryOptions?.withCrdt
155
395
 
156
396
  // create find query
157
397
  const regularQuery = await this.helpers.createFindQuery(req, entity, queryDto)
@@ -271,17 +511,8 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
271
511
  response = await augmentation.afterCreate(response, req, opts)
272
512
  }
273
513
 
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
- }
514
+ if (this.crdt) {
515
+ this.crdt.ee.emit(CUBO_CRDT_EVENT.DOCUMENT_CREATED, { name: `${entityAlias}:${(response as any).id}` })
285
516
  }
286
517
 
287
518
  return response
@@ -306,6 +537,14 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
306
537
  throw { status: 404, text: 'Entity not found' }
307
538
  }
308
539
 
540
+ try {
541
+ if (!!augmentation?.can) {
542
+ await augmentation.can('update', { entity, row: item, auth: opts?.extra?.auth, req })
543
+ }
544
+ } catch {
545
+ throw { status: 403, text: 'Updating entity forbidden' }
546
+ }
547
+
309
548
  const dto = await this.helpers.data.prepare('update', entity!.fields, req.body, opts?.performer_id || 0)
310
549
  if (!Object.keys(dto)) {
311
550
  throw { status: 400, text: 'No keys to update' }
@@ -323,13 +562,8 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
323
562
  response = await augmentation.afterUpdate(response, req, opts)
324
563
  }
325
564
 
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
- }
565
+ if (this.crdt) {
566
+ await this.crdt.updateDocument(`${entityAlias}:${item.id}`, dto)
333
567
  }
334
568
 
335
569
  return response
@@ -365,10 +599,14 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
365
599
  return augmentation.afterDelete(item, req, opts)
366
600
  }
367
601
 
602
+ if (this.crdt) {
603
+ this.crdt.ee.emit(CUBO_CRDT_EVENT.DOCUMENT_DELETED, { name: `${entityAlias}:${item.id}` })
604
+ }
605
+
368
606
  return true
369
607
  }
370
608
  }
371
609
 
372
- export * from './crdt'
610
+ // export * from './crdt'
373
611
  export * from './helpers'
374
612
  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
  }