@bakery-framework/orm 1.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,1128 @@
1
+ import { Bakery } from '@bakery-framework/core/core/bakery'
2
+ import { Logger } from '@bakery-framework/core/logger'
3
+ import type { MapOf } from '@bakery-framework/core/types'
4
+ import { Case, Try } from '@bakery-framework/core/utils'
5
+ import { throws } from '@bakery-framework/core/utils/common'
6
+ import type * as SyncTypes from '../sync/types'
7
+ import { observe, observeIterate } from './observe'
8
+ import type { Driver as RegisteredDriver } from './registry'
9
+
10
+ export namespace SQLAdapter {
11
+ /**
12
+ * Kept as `SQLAdapter.Driver` because that is what the ~40 existing call
13
+ * sites say, but the list itself now lives in `registry.ts` — a namespace
14
+ * member cannot be declaration-merged from another package, and adding a
15
+ * driver has to be possible from outside.
16
+ */
17
+ export type Driver = RegisteredDriver
18
+ export interface RunResult {
19
+ lastInsertRowid: number | bigint | null
20
+ changes: number
21
+ }
22
+ export interface BackupResult {
23
+ file: string
24
+ cleanupCount?: number
25
+ }
26
+ export interface TableColumnInfo {
27
+ name: string
28
+ type: string
29
+ notnull: boolean
30
+ pk: boolean
31
+ }
32
+ export interface TableIndexInfo {
33
+ name: string
34
+ unique: boolean
35
+ }
36
+ export interface TableDetails {
37
+ name: string
38
+ rowCount: number
39
+ columns: TableColumnInfo[]
40
+ indexes: TableIndexInfo[]
41
+ }
42
+ export interface TableDataResult {
43
+ rows: any[]
44
+ totalRows: number
45
+ page: number
46
+ pageSize: number
47
+ totalPages: number
48
+ }
49
+ // `ColumnConstraint` and `IndexConstraint` used to be declared here as well
50
+ // as in `sync/types.ts`, and this copy had fallen behind: no `length`, no
51
+ // `_enum`, no `_oldColumn`/`_transform`, and a `type` union missing `bigint`
52
+ // and `json`. The three sync call sites that cast to it were therefore
53
+ // *erasing* the fields at the exact point they are read — harmless only
54
+ // because `colDef` takes `unknown`. One declaration now, in `sync/types.ts`.
55
+
56
+ export type RowRecord = MapOf<any>
57
+ export interface FilterSortOptions {
58
+ sortBy?: string | null
59
+ sortOrder?: string | null
60
+ filters?: MapOf<unknown>
61
+ }
62
+ export interface TableDataOptions extends FilterSortOptions {
63
+ page: number
64
+ pageSize: number
65
+ }
66
+ export interface NameRow {
67
+ name: string
68
+ }
69
+ export interface ColumnNameRow {
70
+ column_name: string
71
+ }
72
+ export interface TableNameRow {
73
+ table_name?: string
74
+ }
75
+ export interface CountRow {
76
+ count: number
77
+ }
78
+ /**
79
+ * The slice of a Bun `SQL` handle that transaction nesting needs.
80
+ *
81
+ * Structural rather than `SQL`, because the base class holds `sql` as
82
+ * `unknown` — each adapter narrows it — and because both members are
83
+ * verified by probe rather than by the type: `savepoint` is present on the
84
+ * root connection and on every transaction and savepoint handle in Bun
85
+ * 1.3.14, on all three dialects.
86
+ */
87
+ export interface TxHandle {
88
+ transaction<T>(callback: (tx: unknown) => Promise<T>): Promise<T>
89
+ savepoint<T>(callback: (sp: unknown) => Promise<T>): Promise<T>
90
+ }
91
+
92
+ export interface Executor {
93
+ all(sqlText: string, params?: unknown[]): Promise<RowRecord[]> | RowRecord[]
94
+ run(sqlText: string, params?: unknown[]): Promise<RunResult> | RunResult
95
+ iterate(
96
+ sqlText: string,
97
+ params?: unknown[],
98
+ ): AsyncIterable<RowRecord> | Iterable<RowRecord>
99
+ get(sqlText: string, params?: unknown[]): Promise<RowRecord | undefined>
100
+ values(sqlText: string, params?: unknown[]): Promise<unknown[][]>
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The most bound parameters one statement is allowed to carry.
106
+ *
107
+ * A wire-format limit, not a tuning knob. Postgres sends the parameter count as
108
+ * an Int16 and MySQL's prepared-statement protocol does the same, so 65,535 is
109
+ * the hard ceiling on both; SQLite's `SQLITE_MAX_VARIABLE_NUMBER` has been
110
+ * 32,766 since 3.32 and 999 before that.
111
+ *
112
+ * 32,766 for every dialect, deliberately below the two that could go higher.
113
+ * The counter that overflows is 16 bits and it does not *fail* on overflow, it
114
+ * wraps: a 120,000-parameter insert reported `expected 54464 values, received
115
+ * 120000` — 120000 − 65536 — which reads like memory corruption rather than a
116
+ * limit being hit. Half the 16-bit ceiling leaves room for whatever bookkeeping
117
+ * a driver adds on top of the parameters we counted, and the cost of the extra
118
+ * round trips is nothing next to the bytes those parameters weigh.
119
+ */
120
+ export const DEFAULT_MAX_QUERY_PARAMS = 32766
121
+
122
+ /**
123
+ * Is this argument an already-open Bun `SQL` handle rather than a target to
124
+ * open one from?
125
+ *
126
+ * A duck check, because `value instanceof SQL` is unusable: Bun's `SQL` export
127
+ * has no `prototype` property at all — it is `undefined` as of 1.3.14 — and
128
+ * `instanceof` against it does not return false, it **throws**
129
+ * `instanceof called on an object with an invalid prototype property` for every
130
+ * object operand. The MySQL and Postgres constructors read as if they tested
131
+ * this and did not: `instanceof` short-circuits to false for a primitive
132
+ * *before* it touches the right operand, so the string form never reached the
133
+ * throw and the only caller that passes a real handle — `transaction()`,
134
+ * wrapping the connection Bun hands its callback — failed every time it ran.
135
+ *
136
+ * `typeof` covers `function` as well as `object` because a Bun connection is
137
+ * callable: it is the tagged-template entry point, with `unsafe` hung off it.
138
+ */
139
+ export function isOpenConnection(value: unknown): boolean {
140
+ return (
141
+ (typeof value === 'function' || typeof value === 'object') &&
142
+ value !== null &&
143
+ typeof (value as { unsafe?: unknown }).unsafe === 'function'
144
+ )
145
+ }
146
+
147
+ export function quoteIdentifier(name: string, quoteChar: string): string {
148
+ // The `includes` guard is not redundant: `replaceAll` walks and rebuilds the
149
+ // string even when there is nothing to replace, and an identifier containing
150
+ // its own dialect's quote character is the rare case, not the common one.
151
+ // This runs for every identifier of every emitted statement.
152
+ return name.includes(quoteChar)
153
+ ? `${quoteChar}${name.replaceAll(quoteChar, '')}${quoteChar}`
154
+ : `${quoteChar}${name}${quoteChar}`
155
+ }
156
+ /**
157
+ * How many rows one `iterate()` chunk fetches. See {@link pagedIterate}.
158
+ *
159
+ * Big enough that the round trips are amortised, small enough that the point of
160
+ * streaming — never holding the whole result — survives. Not a tuning knob
161
+ * anyone has measured; it is a default, and `createExecutor` takes an override.
162
+ */
163
+ export const DEFAULT_STREAM_CHUNK = 500
164
+
165
+ /**
166
+ * `iterate()`, built out of `all()` by paging.
167
+ *
168
+ * **Bun cannot stream.** As of 1.3.14 an `SQLQuery` is a thenable and nothing
169
+ * more — no `Symbol.asyncIterator`, no `Symbol.iterator`, and its own methods
170
+ * (`raw`, `simple`, `values`, `execute`, `run`) all resolve the whole result.
171
+ * The adapters used to hand their raw query object to `for await`, which is why
172
+ * `iterate` threw `… .iterate is not a function` on **every** dialect and why
173
+ * `QBExecutable.iterable()` had never once worked.
174
+ *
175
+ * So this pages instead: the caller's statement becomes a derived table and the
176
+ * generator walks it a window at a time.
177
+ *
178
+ * ```sql
179
+ * SELECT * FROM (<the caller's SELECT>) AS bakery_stream LIMIT ? OFFSET ?
180
+ * ```
181
+ *
182
+ * Verified on all three dialects against live servers, including a statement
183
+ * that already carries its own `ORDER BY` or `LIMIT` — the derived table
184
+ * contains it, so the window composes rather than colliding. MySQL needs the
185
+ * alias; the other two tolerate it.
186
+ *
187
+ * **What this is not.** It is not a server-side cursor, and the difference is
188
+ * observable, so say it plainly: the statement is re-executed once per chunk,
189
+ * and chunk boundaries are only stable under a total order. Rows inserted or
190
+ * deleted mid-walk can therefore be seen twice or missed — the same hazard
191
+ * offset pagination has, and exactly what `seek()` exists to avoid. What it
192
+ * does buy is the thing people reach for streaming to get: memory bounded by
193
+ * the chunk instead of by the result.
194
+ */
195
+ export function pagedIterate(
196
+ all: SQLAdapter.Executor['all'],
197
+ chunkSize: number = DEFAULT_STREAM_CHUNK,
198
+ ): SQLAdapter.Executor['iterate'] {
199
+ return async function* iterate(sqlText: string, params: unknown[] = []) {
200
+ const derived = `(${sqlText}) AS bakery_stream`
201
+ const windowed = `SELECT * FROM ${derived} LIMIT ? OFFSET ?`
202
+ for (let offset = 0; ; offset += chunkSize) {
203
+ const rows = await all(windowed, [...params, chunkSize, offset])
204
+ for (const row of rows) yield row
205
+ // A short chunk is the end. Checked against the requested size rather
206
+ // than against zero, so the common case costs one round trip fewer than
207
+ // walking until an empty result.
208
+ if (rows.length < chunkSize) return
209
+ }
210
+ }
211
+ }
212
+
213
+ export function createExecutor(
214
+ all: SQLAdapter.Executor['all'],
215
+ run: SQLAdapter.Executor['run'],
216
+ driver: SQLAdapter.Driver,
217
+ options: {
218
+ /** Replace the paging walker — for a driver that can genuinely stream. */
219
+ iterate?: SQLAdapter.Executor['iterate']
220
+ chunkSize?: number
221
+ } = {},
222
+ ): SQLAdapter.Executor {
223
+ const iterate = options.iterate ?? pagedIterate(all, options.chunkSize)
224
+ // `get` and `values` call the raw `all` rather than `exec.all`, which is a
225
+ // behavioural detail worth stating: it is what keeps one executed statement
226
+ // to exactly one observer event. Routing them through the observed `exec.all`
227
+ // would report every `.get()` twice — once as `get`, once as `all` — and a
228
+ // "slowest queries" panel built on double-counted rows is worse than none.
229
+ return {
230
+ all: observe(driver, 'all', all, rows =>
231
+ Array.isArray(rows) ? rows.length : null,
232
+ ),
233
+ run: observe(driver, 'run', run, result => Number(result?.changes ?? 0)),
234
+ iterate: observeIterate(driver, iterate),
235
+ get: observe(
236
+ driver,
237
+ 'get',
238
+ async (sqlText: string, params: unknown[] = []) =>
239
+ (await all(sqlText, params))[0],
240
+ row => (row === undefined ? 0 : 1),
241
+ ),
242
+ values: observe(
243
+ driver,
244
+ 'values',
245
+ async (sqlText: string, params: unknown[] = []) =>
246
+ (await all(sqlText, params)).map(Object.values),
247
+ rows => (Array.isArray(rows) ? rows.length : null),
248
+ ),
249
+ }
250
+ }
251
+
252
+ export abstract class SQLAdapter {
253
+ protected abstract sql: unknown
254
+ static readonly DATE_NOW = ''
255
+ readonly DATE_NOW = SQLAdapter.DATE_NOW
256
+ readonly quoteChar: string = '`'
257
+
258
+ quote(name: string): string {
259
+ return quoteIdentifier(name, this.quoteChar)
260
+ }
261
+
262
+ /**
263
+ * The database this connection is pointed at, or `undefined`.
264
+ *
265
+ * Parsed from the URL rather than asked of the server, because the one caller
266
+ * — normalising a stored view body — runs inside the diff and must not add a
267
+ * round trip to it. SQLite has no such name and needs none: it does not
268
+ * qualify a view's tables in the first place.
269
+ *
270
+ * MySQL writes the schema into every table reference of a stored view, so the
271
+ * body carries a hard-coded database name. Left in, the same schema deployed
272
+ * against a differently-named database compares unequal and the view is
273
+ * recreated on every sync.
274
+ */
275
+ get databaseName(): string | undefined {
276
+ if (!this.url) return undefined
277
+ return Try.return(() => {
278
+ const path = new URL(this.url!).pathname.replace(/^\//, '')
279
+ return path || undefined
280
+ }, undefined)
281
+ }
282
+
283
+ /**
284
+ * Placeholder ceiling for a single statement — see
285
+ * {@link DEFAULT_MAX_QUERY_PARAMS} for why it is one number and not three.
286
+ *
287
+ * A getter rather than a field so a dialect that genuinely differs can
288
+ * override it, and so a future adapter can read it off the live server
289
+ * (Postgres exposes nothing for it; SQLite has `sqlite3_limit`).
290
+ */
291
+ get maxQueryParams(): number {
292
+ return DEFAULT_MAX_QUERY_PARAMS
293
+ }
294
+
295
+ constructor(
296
+ public readonly driver: SQLAdapter.Driver,
297
+ public readonly filename?: string,
298
+ public readonly url?: string,
299
+ ) {}
300
+
301
+ abstract readonly execute: SQLAdapter.Executor
302
+ abstract hasCol(table: string, column: string): Promise<boolean>
303
+ async addCol(table: string, column: string, def: unknown): Promise<void> {
304
+ await this.query(
305
+ `ALTER TABLE ${this.quote(table)}` +
306
+ ` ADD COLUMN ${this.quote(column)} ${this.colDef(def, column)}`,
307
+ ).run()
308
+ }
309
+ abstract colDef(def: unknown, column?: string): string
310
+ abstract backup(keepCount?: number): Promise<SQLAdapter.BackupResult | null>
311
+
312
+ /**
313
+ * How deep in `BEGIN` this adapter's handle already is. 0 is a root
314
+ * connection; every level below is a savepoint.
315
+ *
316
+ * Set by {@link transaction} on the child it just built, never by a
317
+ * constructor. Inferring it from "was I handed an open connection?" would be
318
+ * a guess — a pooled handle is open too, and issuing `SAVEPOINT` outside a
319
+ * transaction block is an error on Postgres and a silent no-op on MySQL.
320
+ * Only `transaction` knows for certain, because it is what opened the thing.
321
+ */
322
+ protected transactionDepth = 0
323
+
324
+ /**
325
+ * Wrap an already-open connection in a sibling adapter, so a transaction body
326
+ * gets the same API as the connection it came from.
327
+ */
328
+ protected abstract withConnection(sql: unknown): SQLAdapter
329
+
330
+ /**
331
+ * Run `callback` atomically — `BEGIN` at the top level, `SAVEPOINT` within an
332
+ * enclosing transaction.
333
+ *
334
+ * The dispatch is the whole point. Bun refuses a nested `BEGIN` outright
335
+ * (`cannot call begin inside a transaction use savepoint() instead`, verbatim
336
+ * on all three dialects), so before this, any two transactional functions
337
+ * that composed — `createUser()` called from `importUsers()`, both perfectly
338
+ * reasonable alone — crashed the moment they met.
339
+ *
340
+ * A failed inner block rolls back to its own savepoint and nothing more, so
341
+ * an outer transaction that catches the error keeps its own work. It only
342
+ * gets the whole thing if it lets the error propagate — which is the same
343
+ * rule a single transaction already follows.
344
+ */
345
+ async transaction<T>(
346
+ callback: (tx: SQLAdapter) => T | Promise<T>,
347
+ ): Promise<T> {
348
+ const handle = this.sql as SQLAdapter.TxHandle
349
+ const run = async (child: unknown): Promise<T> => {
350
+ const tx = this.withConnection(child)
351
+ tx.transactionDepth = this.transactionDepth + 1
352
+ return await callback(tx)
353
+ }
354
+ return this.transactionDepth > 0
355
+ ? await handle.savepoint(run)
356
+ : await handle.transaction(run)
357
+ }
358
+ protected abstract parseConstraints(
359
+ col: unknown,
360
+ ...params: unknown[]
361
+ ): SyncTypes.ColumnConstraint
362
+ abstract getConstraints(): Promise<SyncTypes.DBConstraints>
363
+ abstract getIndexes(): Promise<SyncTypes.DBIndexes>
364
+
365
+ /**
366
+ * A table-level `FOREIGN KEY` clause, for inclusion in `CREATE TABLE`.
367
+ *
368
+ * Emitted inline rather than by `ALTER` wherever possible, because SQLite has
369
+ * no `ALTER TABLE ADD FOREIGN KEY` at all — inline is the only spelling all
370
+ * three dialects share.
371
+ */
372
+ foreignKeyClause(fk: SyncTypes.ForeignKeyInfo): string {
373
+ const name = fk.name || SQLAdapter.foreignKeyName(fk)
374
+ const cols = fk.cols.map(c => this.quote(Case.snake(c))).join(', ')
375
+ const refCols = fk.refCols.map(c => this.quote(Case.snake(c))).join(', ')
376
+ // Omitted when NO ACTION: that is the default in every dialect, and every
377
+ // dialect also *reports* it back as NO ACTION, so emitting it explicitly
378
+ // would only add noise to the DDL without changing the read-back.
379
+ const onDelete =
380
+ fk.onDelete && fk.onDelete !== 'NO ACTION'
381
+ ? ` ON DELETE ${fk.onDelete}`
382
+ : ''
383
+ const onUpdate =
384
+ fk.onUpdate && fk.onUpdate !== 'NO ACTION'
385
+ ? ` ON UPDATE ${fk.onUpdate}`
386
+ : ''
387
+ return (
388
+ `CONSTRAINT ${this.quote(name)} FOREIGN KEY (${cols}) ` +
389
+ `REFERENCES ${this.quote(Case.snake(fk.refTable))} (${refCols})` +
390
+ onDelete +
391
+ onUpdate
392
+ )
393
+ }
394
+
395
+ /**
396
+ * One spelling for a referential action, whatever the dialect called it.
397
+ *
398
+ * Postgres reports a single character rather than a word; MySQL and SQLite
399
+ * report the word. Anything unrecognised becomes `NO ACTION` — the SQL
400
+ * default — so a dialect that grows a new code cannot make the diff churn.
401
+ */
402
+ static normalizeForeignKeyAction(raw: unknown): SyncTypes.ForeignKeyAction {
403
+ const v = String(raw ?? '')
404
+ .trim()
405
+ .toUpperCase()
406
+ const byChar: Record<string, SyncTypes.ForeignKeyAction> = {
407
+ A: 'NO ACTION',
408
+ R: 'RESTRICT',
409
+ C: 'CASCADE',
410
+ N: 'SET NULL',
411
+ D: 'SET DEFAULT',
412
+ }
413
+ if (v.length === 1 && byChar[v]) return byChar[v]!
414
+ const words: SyncTypes.ForeignKeyAction[] = [
415
+ 'NO ACTION',
416
+ 'RESTRICT',
417
+ 'CASCADE',
418
+ 'SET NULL',
419
+ 'SET DEFAULT',
420
+ ]
421
+ return words.find(w => w === v) ?? 'NO ACTION'
422
+ }
423
+
424
+ /** Deterministic name, so a re-run produces the same constraint. */
425
+ static foreignKeyName(fk: SyncTypes.ForeignKeyInfo): string {
426
+ return `fk_${Case.snake(fk.table)}_${fk.cols.map(Case.snake).join('_')}`
427
+ }
428
+
429
+ /**
430
+ * The upsert clause of an `INSERT`: `ON CONFLICT (...) DO UPDATE SET ...`.
431
+ *
432
+ * The SQL-standard spelling, which SQLite and Postgres both take. MySQL uses
433
+ * a different construct entirely and overrides this.
434
+ *
435
+ * `cols` names the unique columns that decide "already there"; `targets` are
436
+ * the columns to overwrite, empty meaning "insert if absent". Both arrive as
437
+ * declared — snake-casing and quoting happen here, so the caller never has to
438
+ * know which dialect it is talking to.
439
+ */
440
+ upsertClause(cols: string[], targets: string[]): string {
441
+ const target = cols.map(c => this.quote(Case.snake(c))).join(', ')
442
+ if (!targets.length) return ` ON CONFLICT (${target}) DO NOTHING`
443
+ const sets = targets.map(k => {
444
+ const q = this.quote(Case.snake(k))
445
+ return `${q} = excluded.${q}`
446
+ })
447
+ return ` ON CONFLICT (${target}) DO UPDATE SET ${sets.join(', ')}`
448
+ }
449
+
450
+ /**
451
+ * Which end of a batched multi-row insert `lastInsertRowid` refers to.
452
+ *
453
+ * A large insert is split into batches, so the id has to be taken from one of
454
+ * them — and the dialects do not agree which. SQLite and Postgres report the
455
+ * *last* row written; MySQL's `insertId` reports the *first* of the block.
456
+ * Taking the matching end keeps each dialect's own answer true rather than
457
+ * inventing a third one.
458
+ */
459
+ get batchInsertIdPosition(): 'first' | 'last' {
460
+ return 'last'
461
+ }
462
+
463
+ /**
464
+ * Can this dialect attach a foreign key to a table that already exists?
465
+ *
466
+ * SQLite cannot: the constraint is part of the table definition and the only
467
+ * way to add one is to rebuild the table. The planner uses this to choose
468
+ * between an ALTER and a rebuild rather than emitting DDL that fails.
469
+ */
470
+ get supportsAlterForeignKey(): boolean {
471
+ return true
472
+ }
473
+
474
+ /**
475
+ * Does a view standing on a table prevent that table from being rebuilt?
476
+ *
477
+ * A rebuild is `CREATE t_temp` → copy → `DROP TABLE t` →
478
+ * `RENAME t_temp TO t`,
479
+ * and two of the three dialects refuse to run it while a view still names `t`
480
+ * — at different steps, and with different messages:
481
+ *
482
+ * - **SQLite** refuses the *rename*: `error in view v: no such table: main.t`
483
+ * - **Postgres** refuses the *drop*: `cannot drop table t because other
484
+ * objects depend on it`
485
+ * - **MySQL** allows the whole sequence, and the view still reads afterwards:
486
+ * it resolves a view's tables at query time rather than binding them at
487
+ * creation.
488
+ *
489
+ * So this is `true` by default and MySQL is the exception — the reverse of
490
+ * how it first reads. Where it holds, the planner drops declared views before
491
+ * the rebuild phase and recreates them a moment later; where it does not, the
492
+ * drop is skipped and the views are simply left alone.
493
+ */
494
+ get viewsBlockTableRebuild(): boolean {
495
+ return true
496
+ }
497
+
498
+ /**
499
+ * `INTERSECT ALL` and `EXCEPT ALL` — the duplicate-preserving forms.
500
+ *
501
+ * `UNION ALL` is universal and is not covered by this; only the other two
502
+ * are. MySQL grew them in 8.0.31 and Postgres has always had them; SQLite
503
+ * has neither and reports `near "ALL": syntax error`, which names the
504
+ * keyword but not the construct.
505
+ */
506
+ get supportsSetOperationAll(): boolean {
507
+ return true
508
+ }
509
+
510
+ /**
511
+ * `FULL OUTER JOIN`.
512
+ *
513
+ * Postgres has it; SQLite gained it in 3.39 and the version Bun bundles has
514
+ * it. **MySQL has never had it**, at any version — the workaround there is a
515
+ * `LEFT JOIN` unioned with a `RIGHT JOIN`, which is a different query rather
516
+ * than a flag, so the builder refuses instead of rewriting silently.
517
+ */
518
+ get supportsFullOuterJoin(): boolean {
519
+ return true
520
+ }
521
+
522
+ async addForeignKey(fk: SyncTypes.ForeignKeyInfo): Promise<void> {
523
+ await this.query(
524
+ `ALTER TABLE ${this.quote(Case.snake(fk.table))}` +
525
+ ` ADD ${this.foreignKeyClause(fk)}`,
526
+ ).run()
527
+ }
528
+
529
+ async dropForeignKey(fk: SyncTypes.ForeignKeyInfo): Promise<void> {
530
+ const name = fk.name || SQLAdapter.foreignKeyName(fk)
531
+ await this.query(
532
+ `ALTER TABLE ${this.quote(Case.snake(fk.table))}` +
533
+ ` DROP CONSTRAINT ${this.quote(name)}`,
534
+ ).run()
535
+ }
536
+
537
+ abstract getSchema(): Promise<SQLAdapter.TableDetails[]>
538
+
539
+ /**
540
+ * Foreign keys as the database has them, keyed by the tuple that identifies
541
+ * one rather than by constraint name.
542
+ *
543
+ * Not abstract: an adapter without an implementation reports "none declared"
544
+ * and the diff simply has nothing to compare, instead of throwing mid-sync.
545
+ */
546
+ async getForeignKeys(): Promise<SyncTypes.DBForeignKeys> {
547
+ return {}
548
+ }
549
+
550
+ /**
551
+ * Stable identity for a foreign key: child table, its columns, the parent,
552
+ * its columns.
553
+ *
554
+ * Names are deliberately excluded. SQLite's `PRAGMA foreign_key_list` does
555
+ * not report one, so a name-keyed diff would see every SQLite foreign key as
556
+ * new on every sync — the perpetual-rebuild failure this project has hit
557
+ * repeatedly. The tuple is the same on all three dialects.
558
+ */
559
+ static foreignKeyId(fk: {
560
+ table: string
561
+ cols: string[]
562
+ refTable: string
563
+ refCols: string[]
564
+ }): string {
565
+ const snake = (s: string) => Case.snake(s)
566
+ return [
567
+ snake(fk.table),
568
+ fk.cols.map(snake).join('+'),
569
+ snake(fk.refTable),
570
+ fk.refCols.map(snake).join('+'),
571
+ ].join('->')
572
+ }
573
+
574
+ /** Fold one-row-per-column introspection results into one entry per key. */
575
+ static groupForeignKeyRows(rows: any[]): SyncTypes.DBForeignKeys {
576
+ const byName = new Map<string, any>()
577
+ for (const r of rows) {
578
+ const key = String(r.name)
579
+ const g = byName.get(key) ?? {
580
+ table: String(r.child),
581
+ cols: [] as string[],
582
+ refTable: String(r.parent),
583
+ refCols: [] as string[],
584
+ name: key,
585
+ // Normalised here, not at the call site: Postgres reports a single
586
+ // character where MySQL reports a word, and the diff has to compare one
587
+ // vocabulary or it replaces every key on every sync.
588
+ onDelete: SQLAdapter.normalizeForeignKeyAction(r.on_delete),
589
+ onUpdate: SQLAdapter.normalizeForeignKeyAction(r.on_update),
590
+ }
591
+ g.cols.push(String(r.child_col))
592
+ g.refCols.push(String(r.parent_col))
593
+ byName.set(key, g)
594
+ }
595
+ const out: SyncTypes.DBForeignKeys = {}
596
+ for (const fk of byName.values()) out[SQLAdapter.foreignKeyId(fk)] = fk
597
+ return out
598
+ }
599
+ abstract getData(
600
+ table: string,
601
+ opts: SQLAdapter.TableDataOptions,
602
+ ): Promise<SQLAdapter.TableDataResult>
603
+ abstract remove(table: string, rowid: unknown): Promise<SQLAdapter.RunResult>
604
+ abstract truncate(table: string): Promise<SQLAdapter.RunResult>
605
+ async insert(
606
+ table: string,
607
+ rowOrRows: SQLAdapter.RowRecord | SQLAdapter.RowRecord[],
608
+ mapSnake = true,
609
+ ): Promise<SQLAdapter.RunResult> {
610
+ const records = Array.isArray(rowOrRows) ? rowOrRows : [rowOrRows]
611
+ if (!records.length) return { lastInsertRowid: null, changes: 0 }
612
+ const formattedRecords = mapSnake
613
+ ? records.map(r => {
614
+ const keys = Object.keys(r)
615
+ const obj: SQLAdapter.RowRecord = {}
616
+ for (let i = 0; i < keys.length; i++) {
617
+ const k = keys[i]
618
+ obj[Case.snake(k)] = r[k]
619
+ }
620
+ return obj
621
+ })
622
+ : records
623
+ const columnsList = [...new Set(formattedRecords.flatMap(Object.keys))]
624
+ const columns = columnsList.map(k => this.quote(k)).join(', ')
625
+ const placeholderRow = Array(columnsList.length).fill('?').join(', ')
626
+ const placeholderGroup = `(${placeholderRow})`
627
+ const placeholders = Array(formattedRecords.length)
628
+ .fill(placeholderGroup)
629
+ .join(', ')
630
+ const params = formattedRecords.flatMap(r =>
631
+ columnsList.map(k => r[k] ?? null),
632
+ )
633
+ return await this.execute.run(
634
+ `INSERT INTO ${this.quote(Case.snake(table))} (${columns})` +
635
+ ` VALUES ${placeholders}`,
636
+ params,
637
+ )
638
+ }
639
+ abstract update(
640
+ table: string,
641
+ rowid: unknown,
642
+ row: SQLAdapter.RowRecord,
643
+ ): Promise<SQLAdapter.RunResult>
644
+
645
+ async drop(
646
+ type: 'TABLE' | 'VIEW' | 'INDEX' | 'COLUMN',
647
+ ...params: string[]
648
+ ): Promise<SQLAdapter.RunResult> {
649
+ if (type === 'COLUMN') {
650
+ return await this.query(
651
+ `ALTER TABLE ${this.quote(params[0])}` +
652
+ ` DROP COLUMN ${this.quote(params[1])}`,
653
+ ).run()
654
+ }
655
+ return await this.query(
656
+ `DROP ${type} IF EXISTS ${this.quote(params[0])}`,
657
+ ).run()
658
+ }
659
+ async rename(
660
+ type: 'TABLE' | 'COLUMN',
661
+ ...params: string[]
662
+ ): Promise<SQLAdapter.RunResult> {
663
+ if (type === 'TABLE') {
664
+ return await this.query(
665
+ `ALTER TABLE ${this.quote(params[0])}` +
666
+ ` RENAME TO ${this.quote(params[1])}`,
667
+ ).run()
668
+ }
669
+ return await this.query(
670
+ `ALTER TABLE ${this.quote(params[0])}` +
671
+ ` RENAME COLUMN ${this.quote(params[1])}` +
672
+ ` TO ${this.quote(params[2])}`,
673
+ ).run()
674
+ }
675
+ async createIndex(
676
+ name: string,
677
+ table: string,
678
+ cols: string[],
679
+ unique = false,
680
+ ): Promise<SQLAdapter.RunResult> {
681
+ return await this.query(
682
+ `CREATE ${unique ? 'UNIQUE ' : ''}INDEX ${this.quote(name)}` +
683
+ ` ON ${this.quote(table)}` +
684
+ ` (${cols.map(c => this.quote(c)).join(', ')})`,
685
+ ).run()
686
+ }
687
+ async createView(name: string, sql: string): Promise<SQLAdapter.RunResult> {
688
+ await this.drop('VIEW', name)
689
+ return this.query(`CREATE VIEW ${this.quote(name)} AS ${sql}`).run()
690
+ }
691
+ async createTable(
692
+ table: string,
693
+ defs: string[],
694
+ ifNotExists = false,
695
+ ): Promise<SQLAdapter.RunResult> {
696
+ return await this.query(
697
+ `CREATE TABLE ${ifNotExists ? 'IF NOT EXISTS ' : ''}` +
698
+ `${this.quote(table)} (\n${defs.join(',\n')}\n)`,
699
+ ).run()
700
+ }
701
+ async copyTableData(
702
+ from: string,
703
+ to: string,
704
+ cols: string[],
705
+ ): Promise<SQLAdapter.RunResult> {
706
+ const cSql = cols.map(c => this.quote(c)).join(', ')
707
+ return await this.query(
708
+ `INSERT INTO ${this.quote(to)} (${cSql})` +
709
+ ` SELECT ${cSql} FROM ${this.quote(from)}`,
710
+ ).run()
711
+ }
712
+
713
+ protected async preSync(_tx: SQLAdapter): Promise<void> {}
714
+ protected async postSync(_tx: SQLAdapter): Promise<void> {}
715
+ /**
716
+ * Patterns recognised when reading a default back *out* of the database.
717
+ * Matched loosely (parens stripped, uppercased, `includes`), so a prefix
718
+ * fragment is a perfectly good entry here.
719
+ */
720
+ readonly dateNowDefaults: string[] = []
721
+
722
+ /**
723
+ * The complete SQL expression emitted *into* DDL for a `%dateNow%` default.
724
+ *
725
+ * Deliberately separate from `dateNowDefaults`. Conflating the two — emitting
726
+ * `dateNowDefaults[0]` — is what produced `DEFAULT (EXTRACT(EPOCH FROM)` on
727
+ * Postgres and `DEFAULT (UNIX_TIMESTAMP)` on MySQL: a prefix is fine to match
728
+ * against and fatal to emit. SQLite only escaped because its match pattern
729
+ * happened to be a complete expression.
730
+ */
731
+ readonly dateNowExpression: string = ''
732
+
733
+ /**
734
+ * The same pair as `dateNowDefaults` / `dateNowExpression`, for `%uuid%`.
735
+ *
736
+ * Both halves are required, and the read-back half is the one that matters:
737
+ * without it the database reports `gen_random_uuid()` where the schema says
738
+ * `%uuid%`, the two never compare equal, and the column is rebuilt on every
739
+ * sync forever. That failure has happened twice in this codebase already,
740
+ * which is why these are separate fields rather than one clever pattern.
741
+ */
742
+ readonly uuidDefaults: string[] = []
743
+ readonly uuidExpression: string = ''
744
+
745
+ isDateNowDefault(def: string): boolean {
746
+ return this.matchesMarker(def, '%dateNow%', this.dateNowDefaults)
747
+ }
748
+
749
+ isUuidDefault(def: string): boolean {
750
+ return this.matchesMarker(def, '%uuid%', this.uuidDefaults)
751
+ }
752
+
753
+ /**
754
+ * Loose match: parens stripped, uppercased, substring. A prefix fragment is a
755
+ * perfectly good entry in the pattern lists — and is fatal to *emit*, which
756
+ * is the whole reason the emitted expression is a separate field.
757
+ */
758
+ private matchesMarker(
759
+ def: string,
760
+ marker: string,
761
+ patterns: string[],
762
+ ): boolean {
763
+ if (def === marker) return true
764
+ const norm = def.replace(/[()]/g, '').trim().toUpperCase()
765
+ return patterns.some(pattern => {
766
+ const normPattern = pattern.replace(/[()]/g, '').trim().toUpperCase()
767
+ return norm === normPattern || norm.includes(normPattern)
768
+ })
769
+ }
770
+
771
+ /**
772
+ * A `CHECK (col IN (…))` clause restricting a column to a set of values.
773
+ *
774
+ * Takes the column name because a CHECK has to name it, which is why
775
+ * `colDef` grew an optional `column` argument. Values bind nowhere — this is
776
+ * DDL — so they are quoted the same way `formatDefault` quotes a string
777
+ * default, by doubling the single quote.
778
+ */
779
+ /**
780
+ * The declared width of a **sized** text column, or `undefined`.
781
+ *
782
+ * The guard is the entire point, and it is not defensive coding — it is a
783
+ * measured result. MySQL reports `character_maximum_length = 65535` for an
784
+ * unsized `TEXT` column, where Postgres reports `null`. Take the number
785
+ * unconditionally and every `Field.Text()` column reads back as
786
+ * `length: 65535`, the schema says nothing, the two never agree, and MySQL
787
+ * rebuilds the table on every sync forever — the exact failure that kept
788
+ * `length` out of the diff until it could be checked against real servers.
789
+ *
790
+ * So: a width counts only when the dialect also calls the column a *sized*
791
+ * text type. `varchar` and `char`, not `text`.
792
+ */
793
+ protected sizedTextLength(
794
+ declaredType: string,
795
+ reported: unknown,
796
+ ): number | undefined {
797
+ const type = declaredType.toLowerCase()
798
+ const sized =
799
+ type.includes('varchar') ||
800
+ type.includes('character varying') ||
801
+ /(^|\W)char(\W|\(|$)/.test(type)
802
+ if (!sized) return undefined
803
+ const n = Number(reported)
804
+ return Number.isInteger(n) && n > 0 ? n : undefined
805
+ }
806
+
807
+ protected enumClause(column: string, values: string[]): string {
808
+ const list = values
809
+ .map(v => `'${String(v).replaceAll("'", "''")}'`)
810
+ .join(', ')
811
+ return ` CHECK (${this.quote(column)} IN (${list}))`
812
+ }
813
+
814
+ protected parseDefault(def: any): any {
815
+ if (def === null || def === undefined) return def
816
+ const isStr = typeof def === 'string'
817
+ if (isStr && def.toUpperCase() === 'NULL') return null
818
+ // `def.trim() !== ''` first, because `Number('')` is `0` and `Number(' ')`
819
+ // is `0` — so an empty-string default came back as the *number* zero. The
820
+ // schema then said `''`, the database said `0`, and the column was rebuilt
821
+ // on every single sync, forever.
822
+ //
823
+ // Unreachable until now only by accident: MySQL rejects a default on TEXT,
824
+ // which is what `value('string', '')` emitted, so the one shape that
825
+ // triggers it could not be created. `Field.Varchar(n, '')` can.
826
+ if (isStr && def.trim() !== '' && !Number.isNaN(Number(def)))
827
+ return Number(def)
828
+ if (isStr && this.isDateNowDefault(def)) return '%dateNow%'
829
+ if (isStr && this.isUuidDefault(def)) return '%uuid%'
830
+ return def
831
+ }
832
+
833
+ async syncSchema(
834
+ constraints: SyncTypes.DBConstraints,
835
+ tsIndexes: SyncTypes.DBIndexes,
836
+ schemaPath: string,
837
+ layout: import('../sync/load').SchemaLayout = 'file',
838
+ ): Promise<void> {
839
+ const { SyncEngine } = await import('../sync/engine')
840
+ await SyncEngine.run(this, constraints, tsIndexes, schemaPath, layout)
841
+ }
842
+
843
+ async close() {
844
+ await (this.sql as any)?.close()
845
+ }
846
+ async [Symbol.asyncDispose]() {
847
+ await this.close()
848
+ }
849
+ query(sqlText: string) {
850
+ return new DatabaseStatement(this, sqlText)
851
+ }
852
+
853
+ protected buildFilterSort(
854
+ options: SQLAdapter.FilterSortOptions,
855
+ validCols: Set<string>,
856
+ ) {
857
+ const whereParams: unknown[] = []
858
+ const whereClauses = Object.entries(options.filters || {})
859
+ .filter(
860
+ ([col, val]) =>
861
+ validCols.has(col) && val !== undefined && val !== null && val !== '',
862
+ )
863
+ .map(([col, val]) => {
864
+ whereParams.push(`%${val}%`)
865
+ return `${this.quote(col)} LIKE ?`
866
+ })
867
+
868
+ const whereSql = whereClauses.length
869
+ ? ` WHERE ${whereClauses.join(' AND ')}`
870
+ : ''
871
+ // Only DESC or ASC ever reaches the string: `sortOrder` arrives off a
872
+ // query parameter, so anything else collapses to ASC rather than being
873
+ // interpolated.
874
+ const direction =
875
+ options.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'
876
+ const orderSql =
877
+ options.sortBy && validCols.has(options.sortBy)
878
+ ? ` ORDER BY ${this.quote(options.sortBy)} ${direction}`
879
+ : ''
880
+
881
+ return { whereSql, orderSql, whereParams }
882
+ }
883
+
884
+ protected formatDefault(
885
+ def: unknown,
886
+ boolTrue: string,
887
+ boolFalse: string,
888
+ ): string {
889
+ if (def === undefined) return ''
890
+ if (def === null || def === 'NULL') return ' DEFAULT NULL'
891
+ if (typeof def === 'boolean')
892
+ return ` DEFAULT ${def ? boolTrue : boolFalse}`
893
+ if (typeof def === 'number' || typeof def === 'bigint')
894
+ return ` DEFAULT ${def}`
895
+ if (typeof def === 'string' && def === '%dateNow%') {
896
+ // Fail loudly rather than emit `DEFAULT ()` or silently drop the
897
+ // default: a missing timestamp default is a schema defect that would
898
+ // otherwise surface much later, as a NOT NULL violation at insert time.
899
+ if (!this.dateNowExpression) {
900
+ throws(`${this.driver} adapter defines no dateNowExpression`)
901
+ }
902
+ return ` DEFAULT (${this.dateNowExpression})`
903
+ }
904
+ if (typeof def === 'string' && def === '%uuid%') {
905
+ // Same reasoning as `%dateNow%` above, and the same failure if silent:
906
+ // a UUID primary key with no default is a NOT NULL violation on the
907
+ // first insert rather than at sync time.
908
+ if (!this.uuidExpression) {
909
+ throws(`${this.driver} adapter defines no uuidExpression`)
910
+ }
911
+ return ` DEFAULT (${this.uuidExpression})`
912
+ }
913
+ return ` DEFAULT '${String(def).replaceAll("'", "''")}'`
914
+ }
915
+
916
+ protected async cleanupBackups(
917
+ backupDir: string,
918
+ baseName: string,
919
+ ext: string,
920
+ keepCount: number,
921
+ ): Promise<number> {
922
+ if (keepCount <= 0) return 0
923
+ const files = Array.from(new Bun.Glob('*').scanSync({ cwd: backupDir }))
924
+ const old = files
925
+ .filter(f => f.startsWith(`${baseName}.`) && f.endsWith(ext))
926
+ .map(f => ({ name: f, time: Number(f.split('.')[1]) || 0 }))
927
+ .sort((a, b) => b.time - a.time)
928
+ .slice(keepCount)
929
+ await Promise.all(old.map(b => Bun.file(`${backupDir}/${b.name}`).delete()))
930
+ return old.length
931
+ }
932
+
933
+ protected async spawnBackup(
934
+ tool: string,
935
+ cmdBuilder: (fullPath: string) => string[],
936
+ ext: string,
937
+ keepCount: number,
938
+ baseName: string,
939
+ envOverride?: Record<string, string>,
940
+ ): Promise<SQLAdapter.BackupResult | null> {
941
+ const backupDir = `${Bakery.dataDir}/backups`
942
+ const backupName = `${baseName}.${Date.now()}${ext}`
943
+ const fullPath = `${backupDir}/${backupName}`
944
+ await Bun.write(`${backupDir}/.keep`, '')
945
+
946
+ if (
947
+ !Try.return(
948
+ () =>
949
+ Bun.spawnSync({ cmd: [tool, '--version'], stdout: 'ignore' })
950
+ .exitCode === 0,
951
+ false,
952
+ )
953
+ ) {
954
+ if (
955
+ !new Logger('db-backup').confirm(
956
+ `${tool} utility not found. Continue without backup?`,
957
+ )
958
+ )
959
+ throw new Error(`Aborted: ${tool} missing.`)
960
+ return null
961
+ }
962
+
963
+ const dump = Bun.spawnSync({
964
+ cmd: cmdBuilder(fullPath),
965
+ stdout: 'ignore',
966
+ stderr: 'pipe',
967
+ env: { ...process.env, ...(envOverride || {}) },
968
+ })
969
+ if (!dump.success)
970
+ throw new Error(
971
+ dump.stderr.toString().trim() ||
972
+ `${tool} failed (exit ${dump.exitCode})`,
973
+ )
974
+
975
+ const cleaned = await this.cleanupBackups(
976
+ backupDir,
977
+ baseName,
978
+ ext,
979
+ keepCount,
980
+ )
981
+ return { file: backupName, cleanupCount: cleaned }
982
+ }
983
+
984
+ async importCSV(
985
+ table: string,
986
+ csvContent: string,
987
+ ): Promise<SQLAdapter.RunResult> {
988
+ const lines = parseCSVRows(csvContent)
989
+ if (lines.length < 2) throw new Error('No rows found')
990
+
991
+ const rawHeaders = lines[0]
992
+ const headers = rawHeaders.map(h => h.trim())
993
+
994
+ const schema = await this.getSchema()
995
+ const tableInfo = schema.find(
996
+ t => t.name === table || Case.camel(t.name) === Case.camel(table),
997
+ )
998
+ const typeMap = new Map<string, string>() // column name -> type
999
+ if (tableInfo) {
1000
+ for (const col of tableInfo.columns) {
1001
+ typeMap.set(Case.camel(col.name), col.type.toLowerCase())
1002
+ typeMap.set(col.name.toLowerCase(), col.type.toLowerCase())
1003
+ }
1004
+ }
1005
+
1006
+ const records = lines.slice(1).map(cols => {
1007
+ return headers.reduce(
1008
+ (acc, h, i) => {
1009
+ const type =
1010
+ typeMap.get(Case.camel(h)) || typeMap.get(h.toLowerCase())
1011
+ acc[h] = parseCSVValue(cols[i], type)
1012
+ return acc
1013
+ },
1014
+ {} as Record<string, any>,
1015
+ )
1016
+ })
1017
+
1018
+ return await this.insert(table, records)
1019
+ }
1020
+ }
1021
+
1022
+ function parseCSVValueWithType(val: string, type: string): any {
1023
+ if (type.includes('int') || type.includes('serial')) {
1024
+ const parsed = parseInt(val, 10)
1025
+ return Number.isNaN(parsed) ? val : parsed
1026
+ }
1027
+ if (
1028
+ type.includes('real') ||
1029
+ type.includes('double') ||
1030
+ type.includes('float') ||
1031
+ type.includes('number') ||
1032
+ type.includes('numeric')
1033
+ ) {
1034
+ const parsed = parseFloat(val)
1035
+ return Number.isNaN(parsed) ? val : parsed
1036
+ }
1037
+ if (type.includes('bool')) {
1038
+ return val === 'true' || val === '1' || val === 't'
1039
+ }
1040
+ return val
1041
+ }
1042
+
1043
+ function parseCSVValueFallback(val: string): any {
1044
+ if (!Number.isNaN(Number(val)) && val !== '') {
1045
+ return Number(val)
1046
+ }
1047
+ const lowerVal = val.toLowerCase()
1048
+ if (lowerVal === 'true' || lowerVal === 'false') {
1049
+ return lowerVal === 'true'
1050
+ }
1051
+ if (lowerVal === 'null') {
1052
+ return null
1053
+ }
1054
+ return val
1055
+ }
1056
+
1057
+ function parseCSVValue(val: any, type?: string): any {
1058
+ if (val === undefined || val === null) {
1059
+ return null
1060
+ }
1061
+ const trimmed = val.trim()
1062
+ if (trimmed === '') {
1063
+ return null
1064
+ }
1065
+ if (type) {
1066
+ return parseCSVValueWithType(trimmed, type)
1067
+ }
1068
+ return parseCSVValueFallback(trimmed)
1069
+ }
1070
+
1071
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: csv
1072
+ function parseCSVRows(csv: string): string[][] {
1073
+ const result: string[][] = []
1074
+ let row: string[] = []
1075
+ let field = ''
1076
+ let inQuotes = false
1077
+ for (let i = 0; i < csv.length; i++) {
1078
+ const c = csv[i],
1079
+ next = csv[i + 1]
1080
+ if (inQuotes) {
1081
+ if (c === '"' && next === '"') {
1082
+ field += '"'
1083
+ i++
1084
+ } else if (c === '"') inQuotes = false
1085
+ else field += c
1086
+ } else {
1087
+ if (c === '"') inQuotes = true
1088
+ else if (c === ',') {
1089
+ row.push(field)
1090
+ field = ''
1091
+ } else if (c === '\n' || c === '\r') {
1092
+ row.push(field)
1093
+ field = ''
1094
+ if (row.length > 0 && !(row.length === 1 && row[0] === ''))
1095
+ result.push(row)
1096
+ row = []
1097
+ if (c === '\r' && next === '\n') i++
1098
+ } else field += c
1099
+ }
1100
+ }
1101
+ if (field !== '' || row.length > 0) {
1102
+ row.push(field)
1103
+ if (row.length > 0 && !(row.length === 1 && row[0] === '')) result.push(row)
1104
+ }
1105
+ return result
1106
+ }
1107
+
1108
+ export class DatabaseStatement {
1109
+ constructor(
1110
+ private readonly connection: SQLAdapter,
1111
+ private readonly sql: string,
1112
+ ) {}
1113
+ all(...params: unknown[]) {
1114
+ return this.connection.execute.all(this.sql, params)
1115
+ }
1116
+ get(...params: any[]) {
1117
+ return this.connection.execute.get(this.sql, params)
1118
+ }
1119
+ run(...params: any[]): Promise<SQLAdapter.RunResult> | SQLAdapter.RunResult {
1120
+ return this.connection.execute.run(this.sql, params)
1121
+ }
1122
+ values(...params: any[]) {
1123
+ return this.connection.execute.values(this.sql, params)
1124
+ }
1125
+ iterate(...params: any[]) {
1126
+ return this.connection.execute.iterate(this.sql, params)
1127
+ }
1128
+ }