@cuboapp/api-backend 1.0.38 → 3.0.0

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