@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.
@@ -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
+ }