@cuboapp/api-backend 1.0.38 → 3.0.1
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 +33 -11
- package/src/core/index.ts +743 -0
- package/src/crdt/index.ts +110 -0
- package/src/dialects/index.ts +36 -0
- package/src/helpers/index.ts +113 -6
- package/src/helpers/withes.ts +16 -12
- package/src/hooks/constants.ts +21 -0
- package/src/hooks/define.ts +76 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/types.ts +52 -0
- package/src/index.ts +6 -443
- package/src/query/compile.ts +212 -0
- package/src/query/index.ts +2 -0
- package/src/query/types.ts +92 -0
- package/src/types/basic.ts +1 -1
- package/src/types/db.ts +74 -23
- package/src/types/filters.ts +50 -0
- package/src/types/index.ts +20 -8
- package/tsconfig.tsbuildinfo +1 -0
- package/src/old.ts +0 -744
- package/src/s3/index.ts +0 -104
- package/src/s3/types/index.ts +0 -19
- package/src/s3/utils/index.ts +0 -74
- package/src/types/augmentation.ts +0 -51
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
import { QueryTypes } from '@cuboapp/database'
|
|
2
|
+
import { CuboApiEntitiesMap, CuboEntity } from '@cuboapp/types'
|
|
3
|
+
import { cloneDeep } from '@cuboapp/utils'
|
|
4
|
+
|
|
5
|
+
import { CuboCrdtAdapter } from '../crdt'
|
|
6
|
+
import { ApiHelpers } from '../helpers'
|
|
7
|
+
import { CuboHookName } from '../hooks/constants'
|
|
8
|
+
import { compileQuery, CuboQuery } from '../query'
|
|
9
|
+
import {
|
|
10
|
+
CuboBackendApiAuth,
|
|
11
|
+
CuboBackendApiOptions,
|
|
12
|
+
CuboCrudGetManyResponse,
|
|
13
|
+
CuboCrudMethodOptions,
|
|
14
|
+
CuboCrudQueryOptions,
|
|
15
|
+
CuboCrudRequest,
|
|
16
|
+
normalizeCuboCrudQueryOptions
|
|
17
|
+
} from '../types'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* CUBO Backend API — метаданные-ориентированный CRUD-движок.
|
|
21
|
+
*
|
|
22
|
+
* Перерисованная архитектура:
|
|
23
|
+
* - типизированный query-DSL (../query) поверх «строкового» движка;
|
|
24
|
+
* - ИММУТАБЕЛЬНЫЕ query-опции: на каждый вызов строится новый объект, хуки
|
|
25
|
+
* `before*` возвращают патч, который мёржится в новый объект — состояние
|
|
26
|
+
* больше не «протекает» между вложенными вызовами;
|
|
27
|
+
* - пакетные операции createMany/updateMany/deleteMany одним запросом + транзакция;
|
|
28
|
+
* - корректные read-back'и внутри транзакции (write-соединение, тот же tx);
|
|
29
|
+
* - встроенная поддержка cubo-crdt (../crdt) для сущностей с entity.crdt;
|
|
30
|
+
* - диалекты Postgres/MySQL (../dialects); s3-хелперы удалены.
|
|
31
|
+
*/
|
|
32
|
+
export class CuboBackendApi<T extends CuboApiEntitiesMap<T>, A extends unknown = unknown> {
|
|
33
|
+
constructor(public options: CuboBackendApiOptions<T, A>) {}
|
|
34
|
+
|
|
35
|
+
public auth: CuboBackendApiAuth
|
|
36
|
+
public helpers = new ApiHelpers<T, A>(this)
|
|
37
|
+
public crdt?: CuboCrdtAdapter<T, A>
|
|
38
|
+
|
|
39
|
+
private debounces = new Map<`${string}:${number}`, { started: number; promise: Promise<any> }>()
|
|
40
|
+
|
|
41
|
+
async init() {
|
|
42
|
+
try {
|
|
43
|
+
if (this.options.api !== undefined) {
|
|
44
|
+
this.auth = await this.request<{ variables: Record<string, any> }>('/auth/me?with=variables', { withAuth: true })
|
|
45
|
+
|
|
46
|
+
if (!this.auth) {
|
|
47
|
+
throw { code: 403, text: 'Авторизация Cubo-Backend не пройдена' }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (this.auth.variables?.db !== undefined) {
|
|
51
|
+
this.options.db = this.options.db || {}
|
|
52
|
+
this.options.db.options = { ...(this.options.db.options || {}), ...(this.auth.variables?.db || {}) }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (e) {
|
|
56
|
+
console.error('[API-BACKEND] startup error', e)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// встроенный cubo-crdt «из коробки».
|
|
60
|
+
// @cuboapp/crdt (и yjs) — опциональная зависимость: грузим лениво только если
|
|
61
|
+
// включён crdt, чтобы проекты без CRDT не тянули yjs.
|
|
62
|
+
if (this.options.crdt?.ws) {
|
|
63
|
+
let createCrdtServer: (typeof import('@cuboapp/crdt'))['createCrdtServer']
|
|
64
|
+
try {
|
|
65
|
+
;({ createCrdtServer } = await import('@cuboapp/crdt'))
|
|
66
|
+
} catch (e) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
"[API-BACKEND] опция 'crdt' требует пакета '@cuboapp/crdt' (и 'yjs'). Установите их: npm i @cuboapp/crdt @cuboapp/ws yjs"
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.crdt = new CuboCrdtAdapter<T, A>(this, this.options.crdt.ws, createCrdtServer, this.options.crdt.debug)
|
|
73
|
+
await this.crdt.init()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async destroy() {
|
|
78
|
+
await this.crdt?.destroy()
|
|
79
|
+
|
|
80
|
+
const read = await this.helpers.getConnection('read', false)
|
|
81
|
+
const write = await this.helpers.getConnection('write', false)
|
|
82
|
+
|
|
83
|
+
if (read) await read.disconnect()
|
|
84
|
+
if (write) await write.disconnect()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ───────────────────────────────────────────────────────── transactions ──
|
|
88
|
+
|
|
89
|
+
/** Back-compat: вернуть «голую» транзакцию write-соединения. */
|
|
90
|
+
public async transaction() {
|
|
91
|
+
const db = await this.helpers.getConnection('write')
|
|
92
|
+
return db.connection.transaction()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Выполнить fn в транзакции с авто-commit/rollback. Внутри fn все CRUD-вызовы
|
|
97
|
+
* должны получать opts.transaction (и тогда read-back'и пойдут в write-соединение).
|
|
98
|
+
*/
|
|
99
|
+
public async withTransaction<R>(fn: (tx: any) => Promise<R>): Promise<R> {
|
|
100
|
+
const db = await this.helpers.getConnection('write')
|
|
101
|
+
const tx = await db.connection.transaction()
|
|
102
|
+
try {
|
|
103
|
+
const result = await fn(tx)
|
|
104
|
+
await tx.commit()
|
|
105
|
+
return result
|
|
106
|
+
} catch (e) {
|
|
107
|
+
await tx.rollback()
|
|
108
|
+
throw e
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ───────────────────────────────────────────────────── typed shortcuts ──
|
|
113
|
+
|
|
114
|
+
find<K extends Extract<keyof T, string>>(alias: K, query?: CuboQuery<T[K]>, opts?: CuboCrudMethodOptions) {
|
|
115
|
+
return this.getMany<K>(alias, { query: compileQuery(query) }, opts)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
findOne<K extends Extract<keyof T, string>>(alias: K, query?: CuboQuery<T[K]>, opts?: CuboCrudMethodOptions) {
|
|
119
|
+
return this.getOne<K>(alias, { query: compileQuery(query) }, opts)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
create<K extends Extract<keyof T, string>>(alias: K, body: Partial<T[K]>, opts?: CuboCrudMethodOptions) {
|
|
123
|
+
return this.createOne<K>(alias, { body }, opts)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
update<K extends Extract<keyof T, string>>(alias: K, id: number, body: Partial<T[K]>, opts?: CuboCrudMethodOptions) {
|
|
127
|
+
return this.updateOne<K>(alias, { query: { id }, body }, opts)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
remove<K extends Extract<keyof T, string>>(alias: K, id: number, opts?: CuboCrudMethodOptions) {
|
|
131
|
+
return this.deleteOne<K>(alias, { query: { id } }, opts)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─────────────────────────────────────────────────────────── read CRUD ──
|
|
135
|
+
|
|
136
|
+
async getMany<K extends Extract<keyof T, string> = Extract<keyof T, string>>(
|
|
137
|
+
entityAlias: K,
|
|
138
|
+
req?: CuboCrudRequest,
|
|
139
|
+
opts?: CuboCrudMethodOptions
|
|
140
|
+
): Promise<CuboCrudGetManyResponse<T[K]>> {
|
|
141
|
+
opts = opts || {}
|
|
142
|
+
req = { ...(req || {}), query: cloneDeep(req?.query || {}) }
|
|
143
|
+
this.helpers.applyDataFilters(req.query)
|
|
144
|
+
|
|
145
|
+
const entity = await this.getEntity(entityAlias)
|
|
146
|
+
const augmentation = this.options?.augmentations?.[entityAlias]
|
|
147
|
+
|
|
148
|
+
let queryOptions = normalizeCuboCrudQueryOptions(opts.queryOptions)
|
|
149
|
+
|
|
150
|
+
if (augmentation?.beforeGetMany && !this.excluded(opts, 'beforeGetMany')) {
|
|
151
|
+
const result = await augmentation.beforeGetMany(this.ctx(entity, req, opts, queryOptions))
|
|
152
|
+
if (result) queryOptions = normalizeCuboCrudQueryOptions(result)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const localOpts: CuboCrudMethodOptions = { ...opts, queryOptions }
|
|
156
|
+
|
|
157
|
+
const queryDto = await this.helpers.withes.prepare(req, entity, localOpts)
|
|
158
|
+
queryDto.withDeleted = queryOptions.withDeleted
|
|
159
|
+
this.applyRawQueryOptions(queryDto, queryOptions)
|
|
160
|
+
|
|
161
|
+
const regularQuery = await this.helpers.createFindQuery(req, entity, queryDto)
|
|
162
|
+
const countQuery = await this.helpers.createFindQuery(req, entity, queryDto, true)
|
|
163
|
+
|
|
164
|
+
const db = await this.connForRead(opts)
|
|
165
|
+
|
|
166
|
+
if (opts.log) {
|
|
167
|
+
console.log(`QUERY:getMany "${String(entityAlias)}":`, regularQuery.sql, regularQuery.replacements)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const rows = await db.connection.query<any>(regularQuery.sql!, {
|
|
171
|
+
type: QueryTypes.SELECT,
|
|
172
|
+
replacements: regularQuery.replacements,
|
|
173
|
+
transaction: opts.transaction
|
|
174
|
+
})
|
|
175
|
+
const [totals] = await db.connection.query<any>(countQuery.sql!, {
|
|
176
|
+
type: QueryTypes.SELECT,
|
|
177
|
+
replacements: countQuery.replacements,
|
|
178
|
+
transaction: opts.transaction
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
let response: CuboCrudGetManyResponse<T[K]> = {
|
|
182
|
+
rows: await this.helpers.convert.prepareAll(req, entity, rows, regularQuery),
|
|
183
|
+
totals: { count: totals?.total !== undefined ? +totals.total : 0 }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (augmentation?.afterGetMany && !this.excluded(opts, 'afterGetMany')) {
|
|
187
|
+
response = await augmentation.afterGetMany({ ...this.ctx(entity, req, opts, queryOptions), result: response })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return response
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async getOne<K extends Extract<keyof T, string> = Extract<keyof T, string>>(
|
|
194
|
+
entityAlias: K,
|
|
195
|
+
req: CuboCrudRequest,
|
|
196
|
+
opts?: CuboCrudMethodOptions
|
|
197
|
+
): Promise<T[K] | undefined> {
|
|
198
|
+
opts = opts || {}
|
|
199
|
+
req = { ...(req || {}), query: cloneDeep(req?.query || {}) }
|
|
200
|
+
this.helpers.applyDataFilters(req.query)
|
|
201
|
+
|
|
202
|
+
const entity = await this.getEntity(entityAlias)
|
|
203
|
+
const augmentation = this.options?.augmentations?.[entityAlias]
|
|
204
|
+
|
|
205
|
+
let queryOptions = normalizeCuboCrudQueryOptions(opts.queryOptions)
|
|
206
|
+
|
|
207
|
+
if (augmentation?.beforeGetOne && !this.excluded(opts, 'beforeGetOne')) {
|
|
208
|
+
const result = await augmentation.beforeGetOne(this.ctx(entity, req, opts, queryOptions))
|
|
209
|
+
if (result) queryOptions = normalizeCuboCrudQueryOptions(result)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const localOpts: CuboCrudMethodOptions = { ...opts, queryOptions }
|
|
213
|
+
|
|
214
|
+
const queryDto = await this.helpers.withes.prepare(req, entity, localOpts)
|
|
215
|
+
queryDto.withDeleted = queryOptions.withDeleted
|
|
216
|
+
queryDto.limit = 1
|
|
217
|
+
this.applyRawQueryOptions(queryDto, queryOptions)
|
|
218
|
+
|
|
219
|
+
const query = await this.helpers.createFindQuery(req, entity, queryDto)
|
|
220
|
+
|
|
221
|
+
const db = await this.connForRead(opts)
|
|
222
|
+
|
|
223
|
+
if (opts.log) {
|
|
224
|
+
console.log(`QUERY:getOne "${String(entityAlias)}":`, query.sql, query.replacements)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const [row] = await db.connection.query<any>(query.sql!, {
|
|
228
|
+
type: QueryTypes.SELECT,
|
|
229
|
+
replacements: query.replacements,
|
|
230
|
+
transaction: opts.transaction
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
if (!row) {
|
|
234
|
+
return null as any
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let response = await this.helpers.convert.prepareOne(req, entity, row, query)
|
|
238
|
+
|
|
239
|
+
if (augmentation?.afterGetOne && !this.excluded(opts, 'afterGetOne')) {
|
|
240
|
+
response = await augmentation.afterGetOne({ ...this.ctx(entity, req, opts, queryOptions), row: response })
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return response
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ────────────────────────────────────────────────────────── write CRUD ──
|
|
247
|
+
|
|
248
|
+
async createOne<K extends Extract<keyof T, string> = Extract<keyof T, string>>(
|
|
249
|
+
entityAlias: K,
|
|
250
|
+
req: CuboCrudRequest,
|
|
251
|
+
opts?: CuboCrudMethodOptions
|
|
252
|
+
): Promise<T[K]> {
|
|
253
|
+
opts = opts || {}
|
|
254
|
+
const entity = await this.getEntity(entityAlias)
|
|
255
|
+
const augmentation = this.options?.augmentations?.[entityAlias]
|
|
256
|
+
|
|
257
|
+
let queryOptions = normalizeCuboCrudQueryOptions(opts.queryOptions)
|
|
258
|
+
if (augmentation?.beforeCreate && !this.excluded(opts, 'beforeCreate' as CuboHookName)) {
|
|
259
|
+
const result = await augmentation.beforeCreate(this.ctx(entity, req, opts, queryOptions))
|
|
260
|
+
if (result) queryOptions = normalizeCuboCrudQueryOptions(result)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const dto = this.helpers.data.prepare('create', entity.fields, req.body, opts.performer_id || 0)
|
|
264
|
+
if (!Object.keys(dto).length) {
|
|
265
|
+
throw { status: 400, text: 'No keys to create' }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const db = await this.helpers.getConnection('write')
|
|
269
|
+
|
|
270
|
+
// INSERT + read-back в одной транзакции: getOne-хуки на re-select'е теперь
|
|
271
|
+
// срабатывают; если beforeGetOne «спрячет» свежесозданную строку (или
|
|
272
|
+
// afterGetOne вернёт пусто), throw откатит INSERT — без «осиротевшей» записи.
|
|
273
|
+
const run = async (tx: any): Promise<T[K]> => {
|
|
274
|
+
const [created_id] = await db.create(entity.alias, dto, {
|
|
275
|
+
transaction: tx,
|
|
276
|
+
log: opts!.log,
|
|
277
|
+
pk_key: this.pkKey,
|
|
278
|
+
pk_type: this.pkType
|
|
279
|
+
})
|
|
280
|
+
if (!created_id) {
|
|
281
|
+
throw { status: 400, text: 'Unable to create entity' }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const created = await this.getOne<K>(entityAlias, { query: { id: created_id } }, this.readBackOpts({ ...opts, transaction: tx }))
|
|
285
|
+
if (!created) {
|
|
286
|
+
throw { status: 400, text: 'Unable to find entity after create' }
|
|
287
|
+
}
|
|
288
|
+
return created as T[K]
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let response = opts.transaction ? await run(opts.transaction) : await this.withTransaction(run)
|
|
292
|
+
|
|
293
|
+
if (augmentation?.afterCreate && !this.excluded(opts, 'afterCreate')) {
|
|
294
|
+
response = await augmentation.afterCreate({ ...this.ctx(entity, req, opts, queryOptions), row: response })
|
|
295
|
+
}
|
|
296
|
+
if (this.options.hooks?.onAfterCreate && !this.excluded(opts, 'baseHooks')) {
|
|
297
|
+
await this.options.hooks.onAfterCreate(entity, response, opts)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
this.crdtPush('create', entity, response, opts)
|
|
301
|
+
if (augmentation?.afterCrdtCreate && !this.excluded(opts, 'afterCrdtCreate')) {
|
|
302
|
+
await augmentation.afterCrdtCreate({ ...this.ctx(entity, req, opts, queryOptions), row: response })
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return response
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async updateOne<K extends Extract<keyof T, string> = Extract<keyof T, string>>(
|
|
309
|
+
entityAlias: K,
|
|
310
|
+
req: CuboCrudRequest,
|
|
311
|
+
opts?: CuboCrudMethodOptions
|
|
312
|
+
): Promise<T[K] | undefined> {
|
|
313
|
+
if (opts?.debounce) {
|
|
314
|
+
if (!req.query?.id) {
|
|
315
|
+
throw 'Debounce "updateOne" allowed only with "id"'
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const key = `${entityAlias}:${+req.query.id}` as `${string}:${number}`
|
|
319
|
+
const ex = this.debounces.get(key)
|
|
320
|
+
if (ex && ex.started > +new Date() - opts.debounce) {
|
|
321
|
+
return ex.promise
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const promise = new Promise<T[K] | undefined>((resolve, reject) => {
|
|
325
|
+
setTimeout(() => {
|
|
326
|
+
this.debounces.delete(key)
|
|
327
|
+
this.updateOne(entityAlias, req, { ...(opts || {}), debounce: undefined })
|
|
328
|
+
.then(resolve)
|
|
329
|
+
.catch(reject)
|
|
330
|
+
}, opts.debounce)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
this.debounces.set(key, { started: +new Date(), promise })
|
|
334
|
+
return promise
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
opts = opts || {}
|
|
338
|
+
const entity = await this.getEntity(entityAlias)
|
|
339
|
+
const augmentation = this.options?.augmentations?.[entityAlias]
|
|
340
|
+
|
|
341
|
+
const item = await this.getOne(entityAlias, req, this.readBackOpts(opts))
|
|
342
|
+
if (!item) {
|
|
343
|
+
throw { status: 404, text: 'Entity not found' }
|
|
344
|
+
}
|
|
345
|
+
const prev = cloneDeep(item)
|
|
346
|
+
|
|
347
|
+
let queryOptions = normalizeCuboCrudQueryOptions(opts.queryOptions)
|
|
348
|
+
if (augmentation?.beforeUpdate && !this.excluded(opts, 'beforeUpdate')) {
|
|
349
|
+
const result = await augmentation.beforeUpdate({ ...this.ctx(entity, req, opts, queryOptions), row: item as T[K] })
|
|
350
|
+
if (result) queryOptions = normalizeCuboCrudQueryOptions(result)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (augmentation?.can) {
|
|
354
|
+
const allowed = await augmentation.can('update', { ...this.ctx(entity, req, opts, queryOptions), row: item as T[K] })
|
|
355
|
+
if (!allowed) {
|
|
356
|
+
throw { status: 403, text: 'Updating entity forbidden' }
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const dto = this.helpers.data.prepare('update', entity.fields, req.body, opts.performer_id || 0)
|
|
361
|
+
if (!Object.keys(dto).length) {
|
|
362
|
+
throw { status: 400, text: 'No keys to update' }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const db = await this.helpers.getConnection('write')
|
|
366
|
+
const id = (item as any).id
|
|
367
|
+
|
|
368
|
+
// UPDATE + read-back в одной транзакции: getOne-хуки на re-select'е теперь
|
|
369
|
+
// срабатывают; если они «спрячут» обновлённую строку, throw откатит UPDATE.
|
|
370
|
+
const run = async (tx: any): Promise<T[K]> => {
|
|
371
|
+
await db.update(entity.alias, 'id = :id', { id }, dto, {
|
|
372
|
+
transaction: tx,
|
|
373
|
+
log: opts!.log,
|
|
374
|
+
pk_key: this.pkKey,
|
|
375
|
+
pk_type: this.pkType
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
const updated = await this.getOne<K>(entityAlias, { query: { id } }, this.readBackOpts({ ...opts, transaction: tx }))
|
|
379
|
+
if (!updated) {
|
|
380
|
+
throw { status: 400, text: 'Unable to find entity after update' }
|
|
381
|
+
}
|
|
382
|
+
return updated as T[K]
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
let response = opts.transaction ? await run(opts.transaction) : await this.withTransaction(run)
|
|
386
|
+
|
|
387
|
+
if (augmentation?.afterUpdate && !this.excluded(opts, 'afterUpdate')) {
|
|
388
|
+
response = await augmentation.afterUpdate({ ...this.ctx(entity, req, opts, queryOptions), row: response, prev: prev as T[K] })
|
|
389
|
+
}
|
|
390
|
+
if (this.options.hooks?.onAfterUpdate && !this.excluded(opts, 'baseHooks')) {
|
|
391
|
+
await this.options.hooks.onAfterUpdate(entity, response, prev as T[K], opts)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
this.crdtPush('update', entity, response, opts)
|
|
395
|
+
if (augmentation?.afterCrdtUpdate && !this.excluded(opts, 'afterCrdtUpdate')) {
|
|
396
|
+
await augmentation.afterCrdtUpdate({ ...this.ctx(entity, req, opts, queryOptions), row: response })
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return response
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async deleteOne<K extends Extract<keyof T, string>>(
|
|
403
|
+
entityAlias: K,
|
|
404
|
+
req: CuboCrudRequest,
|
|
405
|
+
opts?: CuboCrudMethodOptions
|
|
406
|
+
): Promise<boolean> {
|
|
407
|
+
opts = opts || {}
|
|
408
|
+
const entity = await this.getEntity(entityAlias)
|
|
409
|
+
const augmentation = this.options?.augmentations?.[entityAlias]
|
|
410
|
+
|
|
411
|
+
const item = await this.getOne(entityAlias, req, this.readBackOpts(opts))
|
|
412
|
+
if (!item) {
|
|
413
|
+
throw { status: 404, text: 'Entity not found' }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let queryOptions = normalizeCuboCrudQueryOptions(opts.queryOptions)
|
|
417
|
+
if (augmentation?.beforeDelete && !this.excluded(opts, 'beforeDelete' as CuboHookName)) {
|
|
418
|
+
const result = await augmentation.beforeDelete({ ...this.ctx(entity, req, opts, queryOptions), row: item as T[K] })
|
|
419
|
+
if (result) queryOptions = normalizeCuboCrudQueryOptions(result)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (augmentation?.can) {
|
|
423
|
+
const allowed = await augmentation.can('delete', { ...this.ctx(entity, req, opts, queryOptions), row: item as T[K] })
|
|
424
|
+
if (!allowed) {
|
|
425
|
+
throw { status: 403, text: 'Deleting entity forbidden' }
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const dto = this.helpers.data.prepare('delete', entity.fields, req.body || {}, opts.performer_id)
|
|
430
|
+
if (!Object.keys(dto).length) {
|
|
431
|
+
throw { status: 400, text: 'No keys to delete' }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const db = await this.helpers.getConnection('write')
|
|
435
|
+
const id = (item as any).id
|
|
436
|
+
|
|
437
|
+
const runDelete = (tx: any) =>
|
|
438
|
+
db.update(entity.alias, 'id = :id', { id }, dto, {
|
|
439
|
+
transaction: tx,
|
|
440
|
+
log: opts!.log,
|
|
441
|
+
pk_key: this.pkKey,
|
|
442
|
+
pk_type: this.pkType
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
await (opts.transaction ? runDelete(opts.transaction) : this.withTransaction(runDelete))
|
|
446
|
+
|
|
447
|
+
if (this.options.hooks?.onAfterDelete && !this.excluded(opts, 'baseHooks')) {
|
|
448
|
+
await this.options.hooks.onAfterDelete(entity, item as T[K], opts)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
this.crdtPush('delete', entity, item, opts)
|
|
452
|
+
|
|
453
|
+
if (augmentation?.afterDelete && !this.excluded(opts, 'afterDelete')) {
|
|
454
|
+
return augmentation.afterDelete({ ...this.ctx(entity, req, opts, queryOptions), row: item as T[K] })
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return true
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ──────────────────────────────────────────────────────── batch CRUD ──
|
|
461
|
+
|
|
462
|
+
/** Пакетное создание одним INSERT (+ транзакция). Возвращает созданные строки. */
|
|
463
|
+
async createMany<K extends Extract<keyof T, string>>(
|
|
464
|
+
entityAlias: K,
|
|
465
|
+
rows: Partial<T[K]>[],
|
|
466
|
+
opts?: CuboCrudMethodOptions
|
|
467
|
+
): Promise<T[K][]> {
|
|
468
|
+
opts = opts || {}
|
|
469
|
+
if (!rows.length) return []
|
|
470
|
+
|
|
471
|
+
const entity = await this.getEntity(entityAlias)
|
|
472
|
+
const dtos = rows.map((row) => this.helpers.data.prepare('create', entity.fields, row, opts!.performer_id || 0))
|
|
473
|
+
|
|
474
|
+
const run = async (tx: any): Promise<T[K][]> => {
|
|
475
|
+
const ids = await this.insertMany(entity, dtos, { transaction: tx, log: opts!.log })
|
|
476
|
+
if (!ids.length) return []
|
|
477
|
+
const { rows: created } = await this.getMany<K>(
|
|
478
|
+
entityAlias,
|
|
479
|
+
{ query: { id: `in:${ids.join(',')}` } },
|
|
480
|
+
this.readBackOpts({ ...opts, transaction: tx })
|
|
481
|
+
)
|
|
482
|
+
return created
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const created = opts.transaction ? await run(opts.transaction) : await this.withTransaction(run)
|
|
486
|
+
|
|
487
|
+
for (const row of created) {
|
|
488
|
+
if (this.options.hooks?.onAfterCreate && !this.excluded(opts, 'baseHooks')) {
|
|
489
|
+
await this.options.hooks.onAfterCreate(entity, row, opts)
|
|
490
|
+
}
|
|
491
|
+
this.crdtPush('create', entity, row, opts)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return created
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Пакетное обновление: один UPDATE по id IN (...). Возвращает обновлённые строки. */
|
|
498
|
+
async updateMany<K extends Extract<keyof T, string>>(
|
|
499
|
+
entityAlias: K,
|
|
500
|
+
req: CuboCrudRequest,
|
|
501
|
+
opts?: CuboCrudMethodOptions
|
|
502
|
+
): Promise<T[K][]> {
|
|
503
|
+
opts = opts || {}
|
|
504
|
+
const entity = await this.getEntity(entityAlias)
|
|
505
|
+
|
|
506
|
+
const { rows: items } = await this.getMany<K>(entityAlias, req, this.readBackOpts(opts))
|
|
507
|
+
const ids = items.map((r: any) => r.id)
|
|
508
|
+
if (!ids.length) return []
|
|
509
|
+
|
|
510
|
+
const dto = this.helpers.data.prepare('update', entity.fields, req.body, opts.performer_id || 0)
|
|
511
|
+
if (!Object.keys(dto).length) {
|
|
512
|
+
throw { status: 400, text: 'No keys to update' }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const run = async (tx: any): Promise<T[K][]> => {
|
|
516
|
+
await this.updateWhereIds(entity, ids, dto, { transaction: tx, log: opts!.log })
|
|
517
|
+
const { rows: updated } = await this.getMany<K>(
|
|
518
|
+
entityAlias,
|
|
519
|
+
{ query: { id: `in:${ids.join(',')}` } },
|
|
520
|
+
this.readBackOpts({ ...opts, transaction: tx })
|
|
521
|
+
)
|
|
522
|
+
return updated
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const updated = opts.transaction ? await run(opts.transaction) : await this.withTransaction(run)
|
|
526
|
+
|
|
527
|
+
const prevById = new Map(items.map((r: any) => [r.id, r]))
|
|
528
|
+
for (const row of updated) {
|
|
529
|
+
if (this.options.hooks?.onAfterUpdate && !this.excluded(opts, 'baseHooks')) {
|
|
530
|
+
await this.options.hooks.onAfterUpdate(entity, row, prevById.get((row as any).id) as T[K], opts)
|
|
531
|
+
}
|
|
532
|
+
this.crdtPush('update', entity, row, opts)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return updated
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Пакетное (мягкое) удаление: один UPDATE по id IN (...). Возвращает кол-во. */
|
|
539
|
+
async deleteMany<K extends Extract<keyof T, string>>(
|
|
540
|
+
entityAlias: K,
|
|
541
|
+
req: CuboCrudRequest,
|
|
542
|
+
opts?: CuboCrudMethodOptions
|
|
543
|
+
): Promise<number> {
|
|
544
|
+
opts = opts || {}
|
|
545
|
+
const entity = await this.getEntity(entityAlias)
|
|
546
|
+
|
|
547
|
+
const { rows: items } = await this.getMany<K>(entityAlias, req, this.readBackOpts(opts))
|
|
548
|
+
const ids = items.map((r: any) => r.id)
|
|
549
|
+
if (!ids.length) return 0
|
|
550
|
+
|
|
551
|
+
const dto = this.helpers.data.prepare('delete', entity.fields, req.body || {}, opts.performer_id)
|
|
552
|
+
if (!Object.keys(dto).length) {
|
|
553
|
+
throw { status: 400, text: 'No keys to delete' }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
await (opts.transaction
|
|
557
|
+
? this.updateWhereIds(entity, ids, dto, { transaction: opts.transaction, log: opts.log })
|
|
558
|
+
: this.withTransaction((tx) => this.updateWhereIds(entity, ids, dto, { transaction: tx, log: opts!.log })))
|
|
559
|
+
|
|
560
|
+
for (const item of items) {
|
|
561
|
+
if (this.options.hooks?.onAfterDelete && !this.excluded(opts, 'baseHooks')) {
|
|
562
|
+
await this.options.hooks.onAfterDelete(entity, item as T[K], opts)
|
|
563
|
+
}
|
|
564
|
+
this.crdtPush('delete', entity, item, opts)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return ids.length
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ─────────────────────────────────────────────────────── http client ──
|
|
571
|
+
|
|
572
|
+
public async request<R>(url: string, opts?: RequestInit & { debug?: boolean; withAuth?: boolean; withoutContentType?: boolean }) {
|
|
573
|
+
const baseUrl = this.options?.api?.base_url || 'https://api.cubo.sh'
|
|
574
|
+
|
|
575
|
+
const headers: Record<string, any> = { ...(opts?.headers || {}) }
|
|
576
|
+
if (opts?.withAuth) {
|
|
577
|
+
Object.assign(headers, this.options.api?.headers || {})
|
|
578
|
+
}
|
|
579
|
+
if (!headers['Content-Type'] && !opts?.withoutContentType) {
|
|
580
|
+
headers['Accept'] = 'application/json'
|
|
581
|
+
headers['Content-Type'] = 'application/json'
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const requestUrl = baseUrl.replace(/\/$/, '') + '/' + url.replace(/^\//, '')
|
|
585
|
+
const requestOpts = { ...(opts || {}), headers }
|
|
586
|
+
|
|
587
|
+
try {
|
|
588
|
+
const response = await fetch(requestUrl, requestOpts)
|
|
589
|
+
if ([201, 200].includes(response.status)) {
|
|
590
|
+
try {
|
|
591
|
+
return (await response.json()) as Promise<R>
|
|
592
|
+
} catch {
|
|
593
|
+
const text = await response.text()
|
|
594
|
+
try {
|
|
595
|
+
return (text ? JSON.parse(text) : null) as Promise<R>
|
|
596
|
+
} catch {
|
|
597
|
+
throw { status: 500, error: 'Invalid response', text }
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
} else {
|
|
601
|
+
throw { status: response.status, error: response.statusText, text: await response.text() }
|
|
602
|
+
}
|
|
603
|
+
} catch (e) {
|
|
604
|
+
if (opts?.debug) {
|
|
605
|
+
console.log('[API BACKEND]', JSON.stringify({ request: { url: requestUrl, opts: requestOpts }, error: e }, null, 2))
|
|
606
|
+
}
|
|
607
|
+
throw e
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ─────────────────────────────────────────────────────────── internals ──
|
|
612
|
+
|
|
613
|
+
private async getEntity(entityAlias: Extract<keyof T, string>): Promise<CuboEntity> {
|
|
614
|
+
const entity = await this.helpers.getEntityByAlias(entityAlias)
|
|
615
|
+
if (!entity) {
|
|
616
|
+
throw new Error('Entity not found: "' + String(entityAlias) + '"')
|
|
617
|
+
}
|
|
618
|
+
return entity
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private get pkKey() {
|
|
622
|
+
return this.options.db?.options?.pk_key ?? 'id'
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private get pkType(): 'string' | 'number' {
|
|
626
|
+
return this.options.db?.options?.pk_type ?? 'number'
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private excluded(opts: CuboCrudMethodOptions, name: CuboHookName) {
|
|
630
|
+
return !!opts.excludeHooks?.includes(name)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** read-back внутри write-транзакции должен идти в write-соединение и тем же tx. */
|
|
634
|
+
private async connForRead(opts: CuboCrudMethodOptions) {
|
|
635
|
+
return this.helpers.getConnection(opts.transaction ? 'write' : 'read')
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Опции для внутренних read-back'ов: без наследования queryOptions.
|
|
640
|
+
* getOne-хуки чтения (beforeGetOne/afterGetOne) ВКЛЮЧЕНЫ — они должны срабатывать
|
|
641
|
+
* на select/re-select внутри createOne/updateOne/deleteOne (запись+read-back идут
|
|
642
|
+
* в одной транзакции, поэтому «спрятавший» строку beforeGetOne откатит запись).
|
|
643
|
+
* getMany-хуки подавляются — они нужны только для batch read-back'ов.
|
|
644
|
+
*/
|
|
645
|
+
private readBackOpts(opts: CuboCrudMethodOptions): CuboCrudMethodOptions {
|
|
646
|
+
return {
|
|
647
|
+
transaction: opts.transaction,
|
|
648
|
+
performer_id: opts.performer_id,
|
|
649
|
+
auth: opts.auth,
|
|
650
|
+
log: opts.log,
|
|
651
|
+
excludeHooks: ['beforeGetMany', 'afterGetMany']
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Контекст хука. `queryOptions` передаётся ССЫЛКОЙ на per-call рабочую копию
|
|
657
|
+
* (свежий cloneDeep на каждый вызов), поэтому хук может либо мутировать её,
|
|
658
|
+
* либо вернуть свой объект — а накопления/протечки между вызовами нет.
|
|
659
|
+
*/
|
|
660
|
+
private ctx(entity: CuboEntity, req: CuboCrudRequest, opts: CuboCrudMethodOptions, queryOptions: CuboCrudQueryOptions) {
|
|
661
|
+
return {
|
|
662
|
+
entity,
|
|
663
|
+
req,
|
|
664
|
+
auth: opts.auth as A,
|
|
665
|
+
performer_id: opts.performer_id,
|
|
666
|
+
queryOptions
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Вливает raw-условия из query-опций в find-query. */
|
|
671
|
+
private applyRawQueryOptions(queryDto: any, queryOptions: CuboCrudQueryOptions) {
|
|
672
|
+
const raw = queryOptions.raw
|
|
673
|
+
if (!raw) return
|
|
674
|
+
if (raw.conditions?.length) queryDto.conditions = [...(queryDto.conditions || []), ...raw.conditions]
|
|
675
|
+
if (raw.havings?.length) queryDto.havings = [...(queryDto.havings || []), ...raw.havings]
|
|
676
|
+
if (raw.replacements) queryDto.replacements = { ...(queryDto.replacements || {}), ...raw.replacements }
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
private crdtPush(action: 'create' | 'update' | 'delete', entity: CuboEntity, row: any, opts: CuboCrudMethodOptions) {
|
|
680
|
+
// если апдейт пришёл ИЗ crdt (storeRow) — не пушим повторно, чтобы не зациклиться
|
|
681
|
+
if (!this.crdt || !entity.crdt || opts.crdt) return
|
|
682
|
+
|
|
683
|
+
if (action === 'create') this.crdt.pushCreate(entity, row)
|
|
684
|
+
else if (action === 'update') this.crdt.pushUpdate(entity, row)
|
|
685
|
+
else this.crdt.pushDelete(entity, row)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Многострочный INSERT. Postgres -> RETURNING; MySQL -> последовательные autoincrement id. */
|
|
689
|
+
private async insertMany(entity: CuboEntity, dtos: any[], opts: { transaction?: any; log?: boolean }): Promise<number[]> {
|
|
690
|
+
if (!dtos.length) return []
|
|
691
|
+
|
|
692
|
+
const db = await this.helpers.getConnection('write')
|
|
693
|
+
const dialect = this.helpers.dialect
|
|
694
|
+
|
|
695
|
+
// объединение колонок по всем строкам (отсутствующие -> null)
|
|
696
|
+
const cols = Array.from(new Set(dtos.flatMap((d) => Object.keys(d))))
|
|
697
|
+
const replacements: Record<string, any> = {}
|
|
698
|
+
|
|
699
|
+
const valuesSql = dtos
|
|
700
|
+
.map(
|
|
701
|
+
(dto, i) =>
|
|
702
|
+
'(' +
|
|
703
|
+
cols
|
|
704
|
+
.map((c) => {
|
|
705
|
+
const key = `b_${i}_${c}`
|
|
706
|
+
replacements[key] = dto[c] ?? null
|
|
707
|
+
return `:${key}`
|
|
708
|
+
})
|
|
709
|
+
.join(', ') +
|
|
710
|
+
')'
|
|
711
|
+
)
|
|
712
|
+
.join(', ')
|
|
713
|
+
|
|
714
|
+
let sql = `insert into ${entity.alias} (${cols.join(', ')}) values ${valuesSql}`
|
|
715
|
+
if (dialect.supportsReturning) {
|
|
716
|
+
sql += ` returning ${this.pkKey}`
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (opts.log) {
|
|
720
|
+
console.warn('CREATE MANY:', sql)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const res: any = await db.connection.query(sql, { type: QueryTypes.INSERT, replacements, transaction: opts.transaction })
|
|
724
|
+
|
|
725
|
+
if (dialect.supportsReturning) {
|
|
726
|
+
return (res?.[0] || []).map((r: any) => +r[this.pkKey])
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// MySQL: query() возвращает [insertId, affectedRows]; строки получают последовательные id
|
|
730
|
+
const insertId = Array.isArray(res) ? +res[0] : +res
|
|
731
|
+
return dtos.map((_, i) => insertId + i)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
private async updateWhereIds(entity: CuboEntity, ids: number[], dto: any, opts: { transaction?: any; log?: boolean }) {
|
|
735
|
+
const db = await this.helpers.getConnection('write')
|
|
736
|
+
await db.update(entity.alias, 'id in (:__ids)', { __ids: ids }, dto, {
|
|
737
|
+
transaction: opts.transaction,
|
|
738
|
+
log: opts.log,
|
|
739
|
+
pk_key: this.pkKey,
|
|
740
|
+
pk_type: this.pkType
|
|
741
|
+
})
|
|
742
|
+
}
|
|
743
|
+
}
|