@cuboapp/api-backend 1.0.11 → 1.0.13

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.11",
3
+ "version": "1.0.13",
4
4
  "description": "Backend Api for CuboApp",
5
5
  "main": "src/index.ts",
6
6
  "repository": "git@github.com:cuboapp/api-backend.git",
@@ -8,10 +8,14 @@
8
8
  "license": "MIT",
9
9
  "type": "module",
10
10
  "dependencies": {
11
- "@cuboapp/constants": "2.0.4",
12
- "@cuboapp/database": "1.0.1",
13
- "@cuboapp/types": "2.0.5",
14
- "@cuboapp/utils": "1.0.4"
11
+ "@cuboapp/constants": "^2.0.5",
12
+ "@cuboapp/database": "^1.0.1",
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",
17
+ "pg": "^8.16.3",
18
+ "yjs": "^13.6.27"
15
19
  },
16
20
  "devDependencies": {
17
21
  "@types/node": "^24.10.1",
@@ -18,7 +18,8 @@ export const CUBO_CRUD_QUERY_CONDITION = {
18
18
  GT: 'gt',
19
19
  LT: 'lt',
20
20
  GTE: 'gte',
21
- LTE: 'lte'
21
+ LTE: 'lte',
22
+ BTW: 'btw'
22
23
  }
23
24
 
24
25
  export const CUBO_CRUD_DEFAULT_FIELDS: Pick<CuboEntityField, 'type' | 'alias' | 'extra'>[] = [
@@ -0,0 +1,418 @@
1
+ import { QueryTypes } from '@cuboapp/database'
2
+ import { CuboApiEntitiesMap, CuboEntity } from '@cuboapp/types'
3
+ import { Database } from '@hocuspocus/extension-database'
4
+ import {
5
+ beforeSyncPayload,
6
+ Document,
7
+ Hocuspocus,
8
+ onChangePayload,
9
+ onLoadDocumentPayload,
10
+ onStoreDocumentPayload,
11
+ Server
12
+ } from '@hocuspocus/server'
13
+ import { IncomingHttpHeaders } from 'http'
14
+ import * as Y from 'yjs'
15
+
16
+ import { CuboBackendApi } from '..'
17
+ import { DirectConnection } from './types'
18
+
19
+ export * from './types'
20
+
21
+ export class CuboCrdt<T extends CuboApiEntitiesMap<T>> {
22
+ public server: Server
23
+ private documentPrefix = 'cubo'
24
+ private defaultCrdtFieldAlias = 'crdt_data'
25
+
26
+ constructor(public api: CuboBackendApi<T>) {
27
+ this.init()
28
+ }
29
+
30
+ private init() {
31
+ const { onAuthenticate, onLoadDocument, onChange, onStoreDocument, afterLoadDocument, connected, ...config } = this.api.options.crdt
32
+
33
+ this.server = new Server({
34
+ port: config.port,
35
+ extensions: [
36
+ new Database({
37
+ fetch: async ({ documentName, context, document, instance, requestHeaders, requestParameters, socketId, connectionConfig }) => {
38
+ try {
39
+ const [prefix] = documentName.split('.')
40
+ if (prefix !== this.documentPrefix) {
41
+ return null
42
+ }
43
+
44
+ const { entityAlias, id } = this.parseDocumentName(documentName)
45
+
46
+ console.log('fetch', entityAlias, id)
47
+ if (id <= 0) {
48
+ return null
49
+ }
50
+
51
+ const entity = await this.api.helpers.getEntityByAlias(entityAlias as keyof T)
52
+ if (!entity) {
53
+ throw new Error(`Entity not found: "${entityAlias}"`)
54
+ }
55
+
56
+ const isSupported = this.isCrdtSupported(entity)
57
+ if (!isSupported) {
58
+ throw new Error(`Entity "${entityAlias}" does not support CRDT`)
59
+ }
60
+
61
+ const fieldKey = this.getCrdtField(entity)?.alias
62
+
63
+ if (!fieldKey) {
64
+ return null
65
+ }
66
+
67
+ const db = await this.api.helpers.getConnection('write')
68
+
69
+ const [data] = await db.connection.query(`SELECT ${fieldKey} from ${entityAlias} where id=:id`, {
70
+ type: QueryTypes.SELECT,
71
+ replacements: {
72
+ id
73
+ }
74
+ })
75
+
76
+ if (!data) {
77
+ throw new Error(`Entity "${entityAlias}" with id=${id} not found`)
78
+ }
79
+
80
+ const raw = (data as any)[fieldKey]
81
+ // console.log('raw', raw)
82
+
83
+ if (!raw) {
84
+ return null
85
+ }
86
+
87
+ return raw
88
+ } catch (error) {
89
+ console.log('error', error)
90
+ }
91
+ },
92
+ store: async ({ documentName, document, state, context }) => {
93
+ const [prefix] = documentName.split('.')
94
+ if (prefix !== this.documentPrefix) {
95
+ return null
96
+ }
97
+
98
+ const { entityAlias, id } = this.parseDocumentName(documentName)
99
+
100
+ if (id <= 0) {
101
+ return
102
+ }
103
+
104
+ const entity = await this.api.helpers.getEntityByAlias(entityAlias as keyof T)
105
+ if (!entity) {
106
+ throw new Error(`Entity not found: "${entityAlias}"`)
107
+ }
108
+
109
+ const isSupported = this.isCrdtSupported(entity)
110
+ if (!isSupported) {
111
+ throw new Error(`Entity "${entityAlias}" does not support CRDT`)
112
+ }
113
+
114
+ const entityMap = document.getMap(entityAlias)
115
+ const body: Record<string, any> = {}
116
+
117
+ const fieldKey = this.getCrdtField(entity)?.alias
118
+
119
+ for (const [key, value] of entityMap.entries()) {
120
+ if (key === fieldKey) {
121
+ continue
122
+ }
123
+
124
+ body[key] = value
125
+ }
126
+
127
+ // console.log('state', state)
128
+
129
+ body[fieldKey] = state
130
+
131
+ body.modified_at = new Date().toISOString()
132
+
133
+ await this.api.updateOne(
134
+ entityAlias as Extract<keyof T, string>,
135
+ {
136
+ query: { id },
137
+ body
138
+ },
139
+ {
140
+ crdt: false,
141
+ extra: {
142
+ auth: context.auth
143
+ },
144
+ excludeHooks: [],
145
+ queryOptions: { withDeleted: true }
146
+ }
147
+ )
148
+ }
149
+ })
150
+ ],
151
+ onAuthenticate: async (data) => {
152
+ try {
153
+ return onAuthenticate?.(this.api, data)
154
+ } catch (err) {
155
+ throw new Error('Ошибка авторизации: ' + err)
156
+ }
157
+ },
158
+ onLoadDocument: async (data) => {
159
+ const { documentName } = data
160
+
161
+ const [prefix] = documentName.split('.')
162
+ if (prefix === this.documentPrefix) {
163
+ const { entityAlias, id } = this.parseDocumentName(documentName)
164
+
165
+ if (entityAlias) {
166
+ const augmentation = this.api.options.augmentations?.[entityAlias]
167
+
168
+ if (augmentation?.onLoadDocument) {
169
+ await augmentation.onLoadDocument(data)
170
+ }
171
+ }
172
+ }
173
+
174
+ return onLoadDocument?.(this.api, data)
175
+ },
176
+ afterLoadDocument: async (data) => {
177
+ const { documentName, document, instance } = data
178
+
179
+ return afterLoadDocument?.(this.api, data)
180
+ },
181
+ connected: async (data) => {
182
+ const { documentName, instance } = data
183
+
184
+ const [prefix] = documentName?.split('.')
185
+ if (prefix === this.documentPrefix) {
186
+ const document = instance.documents.get(documentName)
187
+
188
+ const { entityAlias, id } = this.parseDocumentName(documentName)
189
+
190
+ if (entityAlias && document) {
191
+ const entityMap = document.getMap(entityAlias)
192
+ const id = entityMap.get('id')
193
+
194
+ if (!id) {
195
+ document.connections.forEach(({ connection }) => {
196
+ connection.close({
197
+ reason: 'Document not loaded',
198
+ code: 500
199
+ })
200
+ })
201
+ instance.unloadDocument(document)
202
+ }
203
+ }
204
+ }
205
+
206
+ return connected?.(this.api, data)
207
+ },
208
+ onChange: async (data) => {
209
+ const { documentName } = data
210
+
211
+ const [prefix] = documentName.split('.')
212
+ if (prefix === this.documentPrefix) {
213
+ const { entityAlias, id } = this.parseDocumentName(documentName)
214
+
215
+ if (entityAlias) {
216
+ const augmentation = this.api.options.augmentations?.[entityAlias]
217
+
218
+ if (augmentation?.onChangeDocument) {
219
+ await augmentation.onChangeDocument(data)
220
+ }
221
+ }
222
+ }
223
+
224
+ return onChange?.(this.api, data)
225
+ },
226
+ onStoreDocument: async (data) => {
227
+ const { documentName } = data
228
+
229
+ const [prefix] = documentName.split('.')
230
+ if (prefix === this.documentPrefix) {
231
+ const { entityAlias, id } = this.parseDocumentName(documentName)
232
+
233
+ if (entityAlias) {
234
+ const augmentation = this.api.options.augmentations?.[entityAlias]
235
+
236
+ if (augmentation?.onStoreDocument) {
237
+ await augmentation.onStoreDocument(data)
238
+ }
239
+ }
240
+ }
241
+
242
+ return onStoreDocument?.(this.api, data)
243
+ },
244
+
245
+ ...config
246
+ })
247
+ }
248
+
249
+ public async listen() {
250
+ await this.server.listen()
251
+ }
252
+
253
+ private parseDocumentName(documentName: string) {
254
+ const parts = documentName.split('.')
255
+
256
+ const [prefix, entityAlias, idRaw] = parts
257
+
258
+ if (prefix !== this.documentPrefix) {
259
+ throw new Error(`Invalid prefix: "${prefix}", expected "${this.documentPrefix}"`)
260
+ }
261
+
262
+ if (!entityAlias) {
263
+ throw new Error(`Missing entity alias in documentName: "${documentName}"`)
264
+ }
265
+
266
+ const id = Number(idRaw)
267
+ if (Number.isNaN(id)) {
268
+ throw new Error(`Invalid id in documentName: "${documentName}"`)
269
+ }
270
+
271
+ return { entityAlias: entityAlias as Extract<keyof T, string>, id }
272
+ }
273
+
274
+ public createDocumentName(entityAlias: string, id: number | string, prefix?: string) {
275
+ const parts = [prefix || this.documentPrefix, entityAlias, id]
276
+
277
+ return parts.join('.')
278
+ }
279
+
280
+ public isCrdtSupported(entity: CuboEntity) {
281
+ const hasCrdtField = entity.fields.some((f) => f.alias === this.api.options.crdt?.fieldAlias || this.defaultCrdtFieldAlias)
282
+
283
+ return hasCrdtField
284
+ }
285
+
286
+ public getCrdtField(entity: CuboEntity) {
287
+ const crdtField = entity.fields.find((f) => f.alias === this.api.options.crdt?.fieldAlias || this.defaultCrdtFieldAlias)
288
+
289
+ return crdtField
290
+ }
291
+
292
+ public getDocumentByEntity(entityAlias: string, id: number, requestHeaders: IncomingHttpHeaders) {
293
+ const documentName = this.createDocumentName(entityAlias, id)
294
+ const fakeSocketId = 'api-' + crypto.randomUUID()
295
+
296
+ return this.server.hocuspocus.createDocument(documentName, { headers: requestHeaders, url: '/collaboration' }, fakeSocketId, {
297
+ readOnly: false,
298
+ isAuthenticated: false
299
+ })
300
+ }
301
+
302
+ public async openDirectConnection(entityAlias: string, id: number, context?: any) {
303
+ const documentName = this.createDocumentName(entityAlias, id)
304
+ if (!documentName) {
305
+ return
306
+ }
307
+
308
+ return this.server.hocuspocus.openDirectConnection(documentName, context)
309
+ }
310
+
311
+ public async connectionDisconnect(connection: DirectConnection) {
312
+ const { document, instance, context } = connection
313
+
314
+ if (document) {
315
+ document.removeDirectConnection()
316
+
317
+ await instance.storeDocumentHooks(document, {
318
+ clientsCount: document.getConnectionsCount(),
319
+ context: context,
320
+ document: document,
321
+ documentName: document.name,
322
+ instance: instance,
323
+ requestHeaders: {},
324
+ requestParameters: new URLSearchParams(),
325
+ socketId: 'server'
326
+ })
327
+
328
+ // // If the direct connection was the only connection to the document
329
+ // // then we should trigger the onDisconnect hook for
330
+ // // this doc and unload the document
331
+ // if (document.getConnectionsCount() === 0) {
332
+ // await instance.hooks('onDisconnect', {
333
+ // instance: instance,
334
+ // clientsCount: document.getConnectionsCount(),
335
+ // context: context,
336
+ // document: document,
337
+ // socketId: 'server',
338
+ // documentName: document.name,
339
+ // requestHeaders: {},
340
+ // requestParameters: new URLSearchParams()
341
+ // })
342
+
343
+ // await instance.unloadDocument(document)
344
+ // }
345
+
346
+ connection.document = null
347
+ }
348
+ }
349
+
350
+ public async updateDocument(params: { entityAlias: string; id: number; entity: CuboEntity }, dto: Record<string, any>, context?: any) {
351
+ const { entityAlias, id, entity } = params
352
+
353
+ const connection = await this.openDirectConnection(entityAlias, id, context)
354
+ if (!connection) {
355
+ return
356
+ }
357
+
358
+ const { document } = connection
359
+ if (!document) {
360
+ return
361
+ }
362
+
363
+ const entityMap = document.getMap(entityAlias)
364
+
365
+ const fieldKey = this.getCrdtField(entity)?.alias
366
+
367
+ document.transact(() => {
368
+ for (const [key, value] of Object.entries(dto)) {
369
+ if (key === fieldKey) {
370
+ continue
371
+ }
372
+
373
+ let preparedValue = value
374
+
375
+ if (value instanceof Date) {
376
+ preparedValue = value.toISOString()
377
+ }
378
+
379
+ entityMap.set(key, preparedValue)
380
+ }
381
+ })
382
+
383
+ return this.connectionDisconnect(connection)
384
+ }
385
+
386
+ public async createState(params: { entityAlias: string; id: number; entity: CuboEntity }, dto: Record<string, any>) {
387
+ const { entityAlias, id, entity } = params
388
+
389
+ const document = new Y.Doc()
390
+ if (!document) {
391
+ return
392
+ }
393
+
394
+ const entityMap = document.getMap(entityAlias)
395
+
396
+ const fieldKey = this.getCrdtField(entity)?.alias
397
+
398
+ document.transact(() => {
399
+ for (const [key, value] of Object.entries(dto)) {
400
+ if (key === fieldKey) {
401
+ continue
402
+ }
403
+
404
+ let preparedValue = value
405
+
406
+ if (value instanceof Date) {
407
+ preparedValue = value.toISOString()
408
+ }
409
+
410
+ entityMap.set(key, preparedValue)
411
+ }
412
+ })
413
+
414
+ return Buffer.from(Y.encodeStateAsUpdate(document))
415
+ }
416
+ }
417
+
418
+ export { beforeSyncPayload, DirectConnection, Document, Hocuspocus, onChangePayload, onLoadDocumentPayload, onStoreDocumentPayload }
@@ -0,0 +1,34 @@
1
+ import { CuboApiEntitiesMap } from '@cuboapp/types'
2
+ import {
3
+ DirectConnection as DirectConnectionInterface,
4
+ Document,
5
+ Hocuspocus,
6
+ ServerConfiguration,
7
+ afterLoadDocumentPayload,
8
+ connectedPayload,
9
+ onAuthenticatePayload,
10
+ onChangePayload,
11
+ onLoadDocumentPayload,
12
+ onStoreDocumentPayload
13
+ } from '@hocuspocus/server'
14
+
15
+ import { CuboBackendApi } from '..'
16
+
17
+ export type CuboCrdtConfiguration<T extends CuboApiEntitiesMap<T>> = Omit<
18
+ Partial<ServerConfiguration>,
19
+ 'onAuthenticate' | 'onLoadDocument' | 'onChange' | 'onStoreDocument' | 'afterLoadDocument' | 'connected'
20
+ > & {
21
+ onAuthenticate?: (api: CuboBackendApi<T>, data: onAuthenticatePayload) => Promise<any>
22
+ onLoadDocument?: (api: CuboBackendApi<T>, data: onLoadDocumentPayload) => Promise<any>
23
+ onChange?: (api: CuboBackendApi<T>, data: onChangePayload) => Promise<any>
24
+ onStoreDocument?: (api: CuboBackendApi<T>, data: onStoreDocumentPayload) => Promise<any>
25
+ afterLoadDocument?: (api: CuboBackendApi<T>, data: afterLoadDocumentPayload) => Promise<any>
26
+ connected?: (api: CuboBackendApi<T>, data: connectedPayload) => Promise<any>
27
+ fieldAlias?: string
28
+ }
29
+
30
+ export type DirectConnection = {
31
+ document: Document | null
32
+ instance: Hocuspocus
33
+ context: any
34
+ } & DirectConnectionInterface
@@ -1,10 +1,10 @@
1
1
  import { CuboApiEntitiesMap, CuboEntity, CuboEntityField } from '@cuboapp/types'
2
2
  import { get, set, unset } from '@cuboapp/utils'
3
+ import { CUBO_ENTITY_FIELD_TYPE } from '@cuboapp/constants'
3
4
 
4
5
  import { CUBO_CRUD_DEFAULT_FIELDS } from '../constants'
5
6
  import { CuboCrudFindQuery, CuboCrudRequest } from '../types'
6
7
 
7
- import { CUBO_ENTITY_FIELD_TYPE } from '@cuboapp/constants'
8
8
  import { ApiHelpers } from '.'
9
9
 
10
10
  export class ApiHelpersConvert<T extends CuboApiEntitiesMap<T>> {
@@ -98,6 +98,7 @@ export class ApiHelpersConvert<T extends CuboApiEntitiesMap<T>> {
98
98
  console.warn('field type "file" is not implemented yet')
99
99
  return undefined
100
100
  case CUBO_ENTITY_FIELD_TYPE.JSON:
101
+ case CUBO_ENTITY_FIELD_TYPE.BINARY:
101
102
  return value
102
103
  case CUBO_ENTITY_FIELD_TYPE.NUMBER:
103
104
  return ![undefined, null].includes(value) ? parseFloat(value) : null
@@ -128,6 +128,7 @@ export class ApiHelpersData<T extends CuboApiEntitiesMap<T>> {
128
128
  errors.push(`"${key}": file field type is not already implemented`)
129
129
  break
130
130
  case CUBO_ENTITY_FIELD_TYPE.JSON:
131
+ case CUBO_ENTITY_FIELD_TYPE.BINARY:
131
132
  if (value === null) {
132
133
  prepared[key] = value
133
134
  } else if (Buffer.byteLength(JSON.stringify(value)) >= 65535 * 50) {
@@ -1,8 +1,8 @@
1
1
  import { CUBO_ENTITY_FIELD_TYPE } from '@cuboapp/constants'
2
- import { Database, createDatabase, type Options } from '@cuboapp/database'
2
+ import { Database, createDatabase } from '@cuboapp/database'
3
3
  import { CuboApiEntitiesMap, CuboEntity, CuboEntityField, CuboEntityFieldExtra, CuboEntityFieldType } from '@cuboapp/types'
4
4
 
5
- import { CuboBackendApi, CuboBackendApiDbConnectionType, CuboCrudFindQuery, CuboCrudRequest } from '../..'
5
+ import { CuboBackendApi } from '../..'
6
6
  import {
7
7
  CUBO_CRUD_DEFAULT_FIELDS,
8
8
  CUBO_CRUD_DEFAULT_PAGE_LIMIT,
@@ -11,6 +11,7 @@ import {
11
11
  CUBO_CRUD_QUERY_KEY_REGEX,
12
12
  CUBO_CRUD_QUERY_SYMBOL_NOT
13
13
  } from '../constants'
14
+ import { CuboBackendApiDbConnectionType, CuboCrudFindQuery, CuboCrudRequest } from '../types'
14
15
 
15
16
  import { ApiHelpersConvert } from './convert'
16
17
  import { ApiHelpersData } from './data'
@@ -65,7 +66,10 @@ export class ApiHelpers<T extends CuboApiEntitiesMap<T>> {
65
66
  // condition parts
66
67
  switch (parts.length) {
67
68
  case 3:
68
- if (parts[0] !== CUBO_CRUD_QUERY_SYMBOL_NOT) {
69
+ if (parts[0] === 'btw') {
70
+ condition = parts[0]
71
+ value = `${parts[1]}:${parts[2]}`
72
+ } else if (parts[0] !== CUBO_CRUD_QUERY_SYMBOL_NOT) {
69
73
  throw new Error(`"${field.alias}": three-level value allow only "not" condition at first level (${value})`)
70
74
  } else if (!Object.values(CUBO_CRUD_QUERY_CONDITION).includes(parts[1])) {
71
75
  throw new Error(`"${field.alias}": incorrect condition "${parts[1]}" in second level (${value})`)
@@ -140,9 +144,18 @@ export class ApiHelpers<T extends CuboApiEntitiesMap<T>> {
140
144
  } else {
141
145
  if ([CUBO_CRUD_QUERY_CONDITION.IN, CUBO_CRUD_QUERY_CONDITION.NIN].includes(condition)) {
142
146
  value = value.split(',').map(Number)
147
+ } else if (condition === CUBO_CRUD_QUERY_CONDITION.BTW) {
148
+ const [fromStr, toStr] = value.split(':')
149
+ const from = precision === 0 ? parseInt(fromStr) : parseFloat(fromStr)
150
+ const to = precision === 0 ? parseInt(toStr) : parseFloat(toStr)
151
+
152
+ if (isNaN(from) || isNaN(to)) {
153
+ throw new Error(`"${field.alias}": both values for "btw" must be valid numbers`)
154
+ }
155
+
156
+ value = precision > 0 ? `${from.toFixed(precision)}:${to.toFixed(precision)}` : `${from}:${to}`
143
157
  } else {
144
158
  const potentialValue = precision === 0 ? parseInt(value) : parseFloat(value)
145
-
146
159
  if (isNaN(potentialValue)) {
147
160
  throw new Error('"' + field.alias + '" must be number')
148
161
  } else {
@@ -211,7 +224,7 @@ export class ApiHelpers<T extends CuboApiEntitiesMap<T>> {
211
224
  ): { conditions: string[]; replacements: Record<string, any> } {
212
225
  const conditions: string[] = []
213
226
  const replacements: Record<string, any> = {}
214
-
227
+ // console.log(field, condition, value)
215
228
  switch (condition) {
216
229
  case CUBO_CRUD_QUERY_CONDITION.ILIKE:
217
230
  conditions.push(`${field} ${is_not ? 'not ilike' : 'ilike'} :${replacement}`)
@@ -253,6 +266,22 @@ export class ApiHelpers<T extends CuboApiEntitiesMap<T>> {
253
266
  conditions.push(`${field} ${is_not ? '>=' : '<='} :${replacement}`)
254
267
  replacements[replacement] = value
255
268
  break
269
+ case CUBO_CRUD_QUERY_CONDITION.BTW: {
270
+ const [from, to] = String(value).split(':')
271
+ const fromKey = `${replacement}_from`
272
+ const toKey = `${replacement}_to`
273
+
274
+ if (!from || !to) {
275
+ throw new Error(`Invalid "btw" value: "${value}"`)
276
+ }
277
+
278
+ const expr = `${field} >= :${fromKey} AND ${field} <= :${toKey}`
279
+ conditions.push(is_not ? `NOT (${expr})` : `(${expr})`)
280
+
281
+ replacements[fromKey] = from
282
+ replacements[toKey] = to
283
+ break
284
+ }
256
285
  case CUBO_CRUD_QUERY_CONDITION.EQ:
257
286
  default:
258
287
  if (value === null) {
@@ -287,75 +316,13 @@ export class ApiHelpers<T extends CuboApiEntitiesMap<T>> {
287
316
  }
288
317
 
289
318
  this.connecting[type] = new Promise(async (success) => {
290
- let database: string = ''
291
- let host: string = ''
292
- let port: number = 0
293
- let username: string = ''
294
- let password: string = ''
295
- let schema: string = ''
296
- let dialect: string = ''
297
- let dialectModule: any = undefined
298
-
299
- if (!this.api.options.db.options) {
300
- throw 'DB connection is not configured'
301
- }
302
-
303
- const dbConfig = this.api.options.db.options
304
-
305
- let cfg: Options = {}
306
- if (dbConfig) {
307
- if (dbConfig[type]) {
308
- cfg = dbConfig[type]
309
- } else {
310
- cfg = dbConfig as Options
311
- }
312
- }
313
-
314
- database = cfg.database as string
315
- dialectModule = cfg.dialectModule
316
- host = cfg.host as string
317
- port = cfg.port as number
318
- username = cfg.username as string
319
- password = cfg.password as string
320
- schema = cfg.schema as string
321
- dialect = cfg.dialect as string
322
-
323
- const config: Options = {
324
- host,
325
- port,
326
- username,
327
- password,
328
- database,
329
- dialect: dialect as any,
330
- timezone: '+03:00',
331
- logging: false,
332
- schema
333
- }
334
-
335
- if (dialectModule) {
336
- config.dialectModule = dialectModule
337
- }
338
-
339
- // cert for yandex cloud
340
- if (/yandexcloud/.test(config.host!)) {
341
- config.dialectOptions = {
342
- ...(config.dialectOptions || {}),
343
- ...{
344
- ssl: {
345
- ca: await fetch('https://storage.yandexcloud.net/cloud-certs/CA.pem').then((res) => res.text()),
346
- rejectUnauthorized: true,
347
- target_session_attrs: 'read-write',
348
- require: true
349
- }
350
- }
351
- }
352
- }
319
+ const { iaas, pk_key, pk_type, ...config } = this.api.options.db.options
353
320
 
354
- this.connections[type] = createDatabase(config)
321
+ this.connections[type] = createDatabase(config, JSON.parse(JSON.stringify({ iaas, pk_key, pk_type })))
355
322
  await this.connections[type].connect()
356
323
 
357
- if (dialect === 'postgres') {
358
- await this.connections[type].connection.query(`SET schema '${schema}';`)
324
+ if (config.dialect === 'postgres' && config.schema) {
325
+ await this.connections[type].connection.query(`SET schema '${config.schema}';`)
359
326
  }
360
327
 
361
328
  success(this.connections[type])
package/src/index.ts CHANGED
@@ -1,56 +1,50 @@
1
- import { Database, Options, QueryTypes } from '@cuboapp/database'
2
- import { CuboApiEntitiesMap, CuboEntity } from '@cuboapp/types'
1
+ import { QueryTypes } from '@cuboapp/database'
2
+ import { CuboApiEntitiesMap } from '@cuboapp/types'
3
3
 
4
+ import { CuboCrdt } from './crdt'
4
5
  import { ApiHelpers } from './helpers'
5
- import { CuboCrudAugmentationsStore, CuboCrudGetManyResponse, CuboCrudMethodOptions, CuboCrudRequest } from './types'
6
-
7
- export * from './types'
8
-
9
- export type CuboBackendApiOptions<T extends CuboApiEntitiesMap<T>> = {
10
- api: {
11
- base_url: string
12
- token: string
13
- }
14
-
15
- db: {
16
- connection?: Database
17
- options?: Options
18
- }
19
-
20
- entities: CuboEntity[]
21
-
22
- auth?: {
23
- base_url?: string
24
- }
25
-
26
- augmentations?: Partial<CuboCrudAugmentationsStore<T>>
27
- }
6
+ import { CuboBackendApiAuth, CuboBackendApiOptions, CuboCrudGetManyResponse, CuboCrudMethodOptions, CuboCrudRequest } from './types'
28
7
 
29
8
  export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
30
- constructor(public options: CuboBackendApiOptions<T>) {}
9
+ constructor(public options: CuboBackendApiOptions<T>) {
10
+ if (options.crdt) {
11
+ this.crdt = new CuboCrdt<T>(this)
12
+ this.crdt.listen()
13
+ }
14
+ }
31
15
 
16
+ public auth: CuboBackendApiAuth
17
+ public crdt?: CuboCrdt<T>
32
18
  public helpers = new ApiHelpers<T>(this)
33
19
 
34
20
  async init() {
35
- const auth = await this.authRequest('/me')
21
+ try {
22
+ this.auth = await this.request<{ variables: Record<string, any> }>('/auth/me?with=variables', {
23
+ headers: this.options.api?.headers || {}
24
+ })
36
25
 
37
- if (!auth) {
38
- throw { code: 403, text: 'Авторизация Cubo-Backend не пройдена' }
39
- }
40
- }
26
+ if (!this.auth) {
27
+ throw { code: 403, text: 'Авторизация Cubo-Backend не пройдена' }
28
+ }
41
29
 
42
- async apiRequest<T>(url: string, opts?: RequestInit) {
43
- return this.request<T>(`https://${this.options.api.base_url}`, url, opts)
44
- }
30
+ if (this.auth.variables.db !== undefined) {
31
+ this.options.db = this.options.db || {}
45
32
 
46
- async authRequest<T>(url: string, opts?: RequestInit) {
47
- return this.request<T>(this.options?.auth?.base_url || 'https://auth.cubo.sh', url, opts)
33
+ this.options.db.options = {
34
+ ...(this.options.db.options || {}),
35
+ ...(this.auth.variables.db || {})
36
+ }
37
+ }
38
+ } catch (e) {
39
+ console.error('[API-BACKEND] startup error', e)
40
+ }
48
41
  }
49
42
 
50
- private async request<T>(baseUrl: string, url: string, opts?: RequestInit & { withoutContentType?: boolean }) {
43
+ public async request<T>(url: string, opts?: RequestInit & { debug?: boolean; withoutContentType?: boolean }) {
44
+ const baseUrl = this.options?.api?.base_url || 'https://api.cubo.sh'
45
+
51
46
  const headers: Record<string, any> = {
52
- ...(opts?.headers || {}),
53
- Authorization: this.options.api.token || ''
47
+ ...(opts?.headers || {})
54
48
  }
55
49
 
56
50
  if (!headers['Content-Type'] && !opts?.withoutContentType) {
@@ -58,33 +52,57 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
58
52
  headers['Content-Type'] = 'application/json'
59
53
  }
60
54
 
61
- const response = await fetch(baseUrl.replace(/\/$/, '') + '/' + url.replace(/^\//, ''), {
55
+ const requestUrl = baseUrl.replace(/\/$/, '') + '/' + url.replace(/^\//, '')
56
+ const requestOpts = {
62
57
  ...(opts || {}),
63
58
  headers
64
- })
65
-
66
- if ([201, 200].includes(response.status)) {
67
- try {
68
- const json = await response.json()
59
+ }
69
60
 
70
- return json as Promise<T>
71
- } catch (e) {
72
- const text = await response.text()
61
+ try {
62
+ const response = await fetch(requestUrl, requestOpts)
73
63
 
64
+ if ([201, 200].includes(response.status)) {
74
65
  try {
75
- const json = text ? JSON.parse(text) : null
66
+ const json = await response.json()
76
67
 
77
68
  return json as Promise<T>
78
- } catch {
79
- throw { status: 500, error: 'Invalid response', text }
69
+ } catch (e) {
70
+ const text = await response.text()
71
+
72
+ try {
73
+ const json = text ? JSON.parse(text) : null
74
+
75
+ return json as Promise<T>
76
+ } catch {
77
+ throw { status: 500, error: 'Invalid response', text }
78
+ }
79
+ }
80
+ } else {
81
+ throw {
82
+ status: response.status,
83
+ error: response.statusText,
84
+ text: await response.text()
80
85
  }
81
86
  }
82
- } else {
83
- throw {
84
- status: response.status,
85
- error: response.statusText,
86
- text: await response.text()
87
+ } catch (e) {
88
+ if (opts?.debug) {
89
+ console.log(
90
+ '[API BACKEND]',
91
+ JSON.stringify(
92
+ {
93
+ request: {
94
+ url: requestUrl,
95
+ opts: requestOpts
96
+ },
97
+ error: e
98
+ },
99
+ null,
100
+ 2
101
+ )
102
+ )
87
103
  }
104
+
105
+ throw e
88
106
  }
89
107
  }
90
108
 
@@ -222,14 +240,27 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
222
240
  throw { status: 400, text: 'Unable to create entity' }
223
241
  }
224
242
 
225
- const response = await this.getOne<K>(entityAlias, { query: { id: created_id } }, opts)
243
+ let response = await this.getOne<K>(entityAlias, { query: { id: created_id } }, opts)
226
244
 
227
245
  if (!response) {
228
246
  throw { status: 400, text: 'Unable to find entity after create' }
229
247
  }
230
248
 
231
249
  if (augmentation?.afterCreate && !opts?.excludeHooks?.includes('afterCreate')) {
232
- return augmentation.afterCreate(response, req, opts)
250
+ response = await augmentation.afterCreate(response, req, opts)
251
+ }
252
+
253
+ if (opts.crdt !== false && this.crdt.isCrdtSupported(entity)) {
254
+ const state = await this.crdt.createState({ entityAlias, id: created_id, entity }, response)
255
+
256
+ const fieldKey = entity.fields.find((f) => f.type === 'binary').alias
257
+
258
+ const db = await this.helpers.getConnection('read')
259
+ await db.update(entity!.alias, 'id=:id', { id: created_id }, { [fieldKey]: state }, opts)
260
+
261
+ if (augmentation?.afterCrdtCreate && !opts?.excludeHooks?.includes('afterCrdtCreate')) {
262
+ augmentation.afterCrdtCreate(response, req, opts)
263
+ }
233
264
  }
234
265
 
235
266
  return response
@@ -262,13 +293,22 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
262
293
  const db = await this.helpers.getConnection('write')
263
294
  await db.update(entity.alias, 'id = :id', { id: item.id }, dto, { transaction: opts?.transaction, log: opts?.log })
264
295
 
265
- const response = await this.getOne<K>(entityAlias, req, opts)
296
+ let response = await this.getOne<K>(entityAlias, req, opts)
266
297
  if (!response) {
267
298
  throw { status: 400, text: 'Unable to find entity after update' }
268
299
  }
269
300
 
270
301
  if (augmentation?.afterUpdate && !opts?.excludeHooks?.includes('afterUpdate')) {
271
- return augmentation.afterUpdate(response, req, opts)
302
+ response = await augmentation.afterUpdate(response, req, opts)
303
+ }
304
+
305
+ if (opts.crdt !== false && this.crdt.isCrdtSupported(entity)) {
306
+ const context = { auth: opts?.extra?.auth }
307
+ await this.crdt.updateDocument({ entityAlias, id: item.id, entity }, response, context)
308
+
309
+ if (augmentation?.afterCrdtUpdate && !opts?.excludeHooks?.includes('afterCrdtUpdate')) {
310
+ augmentation.afterCrdtUpdate(response, req, opts)
311
+ }
272
312
  }
273
313
 
274
314
  return response
@@ -308,4 +348,6 @@ export class CuboBackendApi<T extends CuboApiEntitiesMap<T>> {
308
348
  }
309
349
  }
310
350
 
351
+ export * from './crdt'
311
352
  export * from './helpers'
353
+ export * from './types'
@@ -2,6 +2,7 @@ import { CuboApiEntitiesMap } from '@cuboapp/types'
2
2
 
3
3
  import { CuboCrudGetManyResponse, CuboCrudRequest } from './basic'
4
4
  import { CuboCrudMethodOptions, CuboCrudQueryOptions } from './db'
5
+ import { onChangePayload, onLoadDocumentPayload, onStoreDocumentPayload } from '@hocuspocus/server'
5
6
 
6
7
  export type CuboCrudAugmentationsStore<T extends CuboApiEntitiesMap<T>> = {
7
8
  [K in Extract<keyof T, string>]?: CuboCrudAugmentationInstance<T[K]>
@@ -20,10 +21,16 @@ export interface CuboCrudAugmentationInstance<T = unknown> {
20
21
 
21
22
  beforeCreate?: (req: CuboCrudRequest, opts?: CuboCrudMethodOptions) => Promise<CuboCrudQueryOptions>
22
23
  afterCreate?: (result: T, req: CuboCrudRequest, opts?: CuboCrudMethodOptions) => Promise<T>
24
+ afterCrdtCreate?: (result: T, req: CuboCrudRequest, opts?: CuboCrudMethodOptions) => Promise<void>
23
25
 
24
26
  beforeUpdate?: (req: CuboCrudRequest, opts?: CuboCrudMethodOptions) => Promise<CuboCrudQueryOptions>
25
27
  afterUpdate?: (result: T, req: CuboCrudRequest, opts?: CuboCrudMethodOptions) => Promise<T>
28
+ afterCrdtUpdate?: (result: T, req: CuboCrudRequest, opts?: CuboCrudMethodOptions) => Promise<void>
26
29
 
27
30
  beforeDelete?: (req: CuboCrudRequest, opts?: CuboCrudMethodOptions) => Promise<CuboCrudQueryOptions>
28
31
  afterDelete?: (result: T, req: CuboCrudRequest, opts?: CuboCrudMethodOptions) => Promise<boolean>
32
+
33
+ onLoadDocument?: (data: onLoadDocumentPayload) => Promise<void>
34
+ onChangeDocument?: (data: onChangePayload) => Promise<void>
35
+ onStoreDocument?: (data: onStoreDocumentPayload) => Promise<void>
29
36
  }
package/src/types/db.ts CHANGED
@@ -27,9 +27,13 @@ export type CuboCrudMethodOptions<E extends object = {}> = {
27
27
  | 'afterGetMany'
28
28
  | 'afterGetOne'
29
29
  | 'beforeUpdate'
30
+ | 'afterCrdtCreate'
31
+ | 'afterCrdtUpdate'
30
32
  )[]
31
33
 
32
34
  log?: boolean
35
+ replace?: boolean
36
+ crdt?: boolean
33
37
 
34
38
  queryOptions?: CuboCrudQueryOptions
35
39
  } & E
@@ -1,3 +1,31 @@
1
+ import { Database, DatabaseOptions, Options } from '@cuboapp/database'
2
+ import { CuboApiEntitiesMap, CuboAuthTokenType, CuboEntity } from '@cuboapp/types'
3
+
4
+ import { CuboCrdtConfiguration } from '../crdt'
5
+
6
+ import { CuboCrudAugmentationsStore } from './augmentation'
7
+
1
8
  export * from './augmentation'
2
9
  export * from './basic'
3
10
  export * from './db'
11
+
12
+ export type CuboBackendApiOptions<T extends CuboApiEntitiesMap<T>> = {
13
+ api: {
14
+ base_url?: string
15
+ headers: Partial<Record<`cubo-${CuboAuthTokenType}`, string>> & Record<string, any>
16
+ }
17
+
18
+ entities?: CuboEntity[]
19
+
20
+ db?: {
21
+ connection?: Database
22
+ options?: Options & DatabaseOptions
23
+ }
24
+
25
+ crdt?: CuboCrdtConfiguration<T>
26
+ augmentations?: Partial<CuboCrudAugmentationsStore<T>>
27
+ }
28
+
29
+ export type CuboBackendApiAuth = {
30
+ variables: Record<string, any>
31
+ }
@@ -1,18 +0,0 @@
1
- {
2
- "editor.minimap.enabled": false,
3
- "editor.formatOnSave": true,
4
- "editor.codeActionsOnSave": {
5
- "source.fixAll.eslint": "explicit",
6
- "source.fixAll.stylelint": "explicit",
7
- "source.organizeImports": "explicit",
8
- "source.organizeExports": "explicit"
9
- },
10
- "emmet.includeLanguages": {
11
- "javascriptreact": "html",
12
- "typescriptreact": "html"
13
- },
14
- "files.exclude": {
15
- "node_modules/": true
16
- },
17
- "typescript.tsdk": "node_modules/typescript/lib"
18
- }
package/package-lock.json DELETED
@@ -1,104 +0,0 @@
1
- {
2
- "name": "@cuboapp/api-backend",
3
- "version": "1.0.10",
4
- "lockfileVersion": 3,
5
- "requires": true,
6
- "packages": {
7
- "": {
8
- "name": "@cuboapp/api-backend",
9
- "version": "1.0.10",
10
- "license": "MIT",
11
- "dependencies": {
12
- "@cuboapp/constants": "2.0.4",
13
- "@cuboapp/database": "1.0.1",
14
- "@cuboapp/types": "2.0.5",
15
- "@cuboapp/utils": "1.0.4"
16
- },
17
- "devDependencies": {
18
- "@types/node": "^24.10.1",
19
- "typescript": "^5.9.3"
20
- }
21
- },
22
- "../constants": {
23
- "name": "@cuboapp/constants",
24
- "version": "2.0.4",
25
- "license": "MIT",
26
- "dependencies": {
27
- "@cuboapp/types": "2.0.5"
28
- }
29
- },
30
- "../database": {
31
- "name": "@cuboapp/database",
32
- "version": "1.0.1",
33
- "license": "MIT",
34
- "dependencies": {
35
- "sequelize": "^6.37.7"
36
- },
37
- "devDependencies": {
38
- "@types/node": "^24.0.0",
39
- "typescript": "^5.9.3"
40
- }
41
- },
42
- "../types": {
43
- "name": "@cuboapp/types",
44
- "version": "2.0.5",
45
- "license": "MIT"
46
- },
47
- "../utils": {
48
- "name": "@cuboapp/utils",
49
- "version": "1.0.4",
50
- "license": "MIT",
51
- "devDependencies": {
52
- "@types/node": "^24.9.1",
53
- "typescript": "^5.9.3"
54
- }
55
- },
56
- "node_modules/@cuboapp/constants": {
57
- "resolved": "../constants",
58
- "link": true
59
- },
60
- "node_modules/@cuboapp/database": {
61
- "resolved": "../database",
62
- "link": true
63
- },
64
- "node_modules/@cuboapp/types": {
65
- "resolved": "../types",
66
- "link": true
67
- },
68
- "node_modules/@cuboapp/utils": {
69
- "resolved": "../utils",
70
- "link": true
71
- },
72
- "node_modules/@types/node": {
73
- "version": "24.10.1",
74
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
75
- "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
76
- "dev": true,
77
- "license": "MIT",
78
- "dependencies": {
79
- "undici-types": "~7.16.0"
80
- }
81
- },
82
- "node_modules/typescript": {
83
- "version": "5.9.3",
84
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
85
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
86
- "dev": true,
87
- "license": "Apache-2.0",
88
- "bin": {
89
- "tsc": "bin/tsc",
90
- "tsserver": "bin/tsserver"
91
- },
92
- "engines": {
93
- "node": ">=14.17"
94
- }
95
- },
96
- "node_modules/undici-types": {
97
- "version": "7.16.0",
98
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
99
- "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
100
- "dev": true,
101
- "license": "MIT"
102
- }
103
- }
104
- }