@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.
- package/LICENSE +19 -0
- package/README.md +88 -0
- package/package.json +59 -0
- package/src/adapters/base.ts +1128 -0
- package/src/adapters/mysql.ts +619 -0
- package/src/adapters/observe.ts +261 -0
- package/src/adapters/pgsql.ts +611 -0
- package/src/adapters/registry.ts +204 -0
- package/src/adapters/sqlite.ts +588 -0
- package/src/adapters.ts +72 -0
- package/src/backup.ts +37 -0
- package/src/connection.ts +69 -0
- package/src/define.ts +380 -0
- package/src/field.ts +595 -0
- package/src/globals.d.ts +22 -0
- package/src/index.ts +63 -0
- package/src/orm/index.ts +24 -0
- package/src/orm/mutation.ts +692 -0
- package/src/orm/query.ts +1680 -0
- package/src/pool.ts +83 -0
- package/src/schema-registry.ts +75 -0
- package/src/schema-util.ts +467 -0
- package/src/sync/builder.ts +618 -0
- package/src/sync/engine.ts +399 -0
- package/src/sync/helpers.ts +1119 -0
- package/src/sync/history.ts +169 -0
- package/src/sync/index.ts +113 -0
- package/src/sync/ledger.ts +335 -0
- package/src/sync/load.ts +368 -0
- package/src/sync/rollback.ts +200 -0
- package/src/sync/types.ts +101 -0
- package/src/sync/view-sql.ts +160 -0
- package/templates/schema.example.ts +94 -0
|
@@ -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
|
+
}
|