@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,588 @@
1
+ import path from 'node:path'
2
+ import { Bakery } from '@bakery-framework/core/core/bakery'
3
+ import { Case, Try } from '@bakery-framework/core/utils'
4
+ import { SQL } from 'bun'
5
+ import type * as SyncTypes from '../sync/types'
6
+ import { createExecutor, SQLAdapter } from './base'
7
+
8
+ export class SQLiteAdapter extends SQLAdapter {
9
+ // SQLite's standard identifier quote. The base class defaults to MySQL's
10
+ // backtick, which SQLite only tolerates as a compatibility extension.
11
+ override readonly quoteChar: string = '"'
12
+
13
+ protected readonly sql: SQL
14
+
15
+ /**
16
+ * Resolves once `foreign_keys` is on, and every query awaits it.
17
+ *
18
+ * SQLite defaults the pragma OFF and it is per-connection, so without this a
19
+ * FOREIGN KEY is stored, reported by `PRAGMA foreign_key_list`, shown in the
20
+ * dashboard — and enforces nothing.
21
+ *
22
+ * Deliberately *not* in the performance pragma chain above it. That chain is
23
+ * fire-and-forget by design (its own comment calls the statements
24
+ * "unawaited"), which is fine for `cache_size`: applying late costs a little
25
+ * speed. Applying `foreign_keys` late costs a row that was never checked, so
26
+ * this one has to be something callers can wait on. A resolved promise after
27
+ * the first query, so the cost is one microtask.
28
+ */
29
+ private readonly ready: Promise<unknown>
30
+ private static readonly sqliteTypes: [
31
+ string,
32
+ SyncTypes.ColumnConstraint['type'],
33
+ ][] = [
34
+ ['BIGINT', 'bigint'],
35
+ ['JSON', 'json'],
36
+ ['INTEGER', 'integer'],
37
+ ['TEXT', 'string'],
38
+ ['REAL', 'number'],
39
+ ['BLOB', 'buffer'],
40
+ ['NUMERIC', 'number'],
41
+ ['BOOLEAN', 'boolean'],
42
+ ]
43
+ private static readonly sqlKeywords = new Set([
44
+ 'SELECT',
45
+ 'FROM',
46
+ 'WHERE',
47
+ 'JOIN',
48
+ 'LEFT',
49
+ 'RIGHT',
50
+ 'INNER',
51
+ 'OUTER',
52
+ 'ON',
53
+ 'AS',
54
+ 'AND',
55
+ 'OR',
56
+ 'NOT',
57
+ 'NULL',
58
+ 'IS',
59
+ 'IN',
60
+ 'GROUP',
61
+ 'BY',
62
+ 'ORDER',
63
+ 'HAVING',
64
+ 'LIMIT',
65
+ 'OFFSET',
66
+ 'ASC',
67
+ 'DESC',
68
+ 'CREATE',
69
+ 'TABLE',
70
+ 'VIEW',
71
+ 'DROP',
72
+ 'ALTER',
73
+ 'UPDATE',
74
+ 'SET',
75
+ 'INSERT',
76
+ 'INTO',
77
+ 'VALUES',
78
+ 'DELETE',
79
+ 'PRIMARY',
80
+ 'KEY',
81
+ 'FOREIGN',
82
+ 'REFERENCES',
83
+ 'AUTOINCREMENT',
84
+ 'DEFAULT',
85
+ 'UNIQUE',
86
+ 'CHECK',
87
+ 'CONSTRAINT',
88
+ 'CAST',
89
+ 'INTEGER',
90
+ 'TEXT',
91
+ 'REAL',
92
+ 'BLOB',
93
+ 'NUMERIC',
94
+ 'BOOLEAN',
95
+ ])
96
+ /**
97
+ * Normalize a stored view definition so it can be compared against the
98
+ * schema's. Handles both quote styles: views created before the adapter used
99
+ * `"` are still on disk with backticks.
100
+ */
101
+ private static cleanSQLQuotes(sql: string): string {
102
+ return sql.replace(/`([^`]+)`|"([^"]+)"/g, (match, tick, dquote) => {
103
+ const word = tick ?? dquote
104
+ return !SQLiteAdapter.sqlKeywords.has(word.toUpperCase()) &&
105
+ /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(word)
106
+ ? word
107
+ : match
108
+ })
109
+ }
110
+ private static mapSqlToTsType(
111
+ sqlType: string,
112
+ ): SyncTypes.ColumnConstraint['type'] {
113
+ const upperType = (sqlType || '').toUpperCase()
114
+ for (const [sql, ts] of SQLiteAdapter.sqliteTypes) {
115
+ if (upperType.includes(sql)) return ts
116
+ }
117
+ return 'string'
118
+ }
119
+
120
+ constructor(connectionTarget?: string | null, sql?: SQL) {
121
+ const filename = SQLiteAdapter.resolveFilename(connectionTarget)
122
+ super(
123
+ 'sqlite',
124
+ filename,
125
+ typeof connectionTarget === 'string' ? connectionTarget : undefined,
126
+ )
127
+ // `sql` is supplied when wrapping an existing connection — notably once per
128
+ // transaction. Setup below belongs only to a connection we open ourselves;
129
+ // re-running it per transaction meant a mkdirSync plus six unawaited
130
+ // PRAGMAs
131
+ // racing against the transaction body on the same handle.
132
+ const ownsConnection = sql === undefined
133
+
134
+ if (ownsConnection && filename !== ':memory:') {
135
+ const dir = path.dirname(filename)
136
+ Try(() => {
137
+ const { mkdirSync } = require('node:fs')
138
+ mkdirSync(dir, { recursive: true })
139
+ })
140
+ }
141
+ this.sql =
142
+ sql ??
143
+ (filename === ':memory:'
144
+ ? new SQL('sqlite://:memory:')
145
+ : new SQL(filename, { adapter: 'sqlite' }))
146
+ // Every owned connection, `:memory:` included — the perf pragmas below
147
+ // skip in-memory databases, but correctness does not get to.
148
+ this.ready = ownsConnection
149
+ ? this.sql.unsafe('PRAGMA foreign_keys = ON;')
150
+ : Promise.resolve()
151
+
152
+ if (ownsConnection && filename !== ':memory:') {
153
+ const cacheSize = import.meta.env.THREAD_WORKER ? -1000 : -10000
154
+ const journalMode = process.platform === 'win32' ? 'DELETE' : 'WAL'
155
+ this.sql
156
+ .unsafe(`PRAGMA journal_mode = ${journalMode};`)
157
+ .then(() => this.sql.unsafe('PRAGMA synchronous = NORMAL;'))
158
+ .then(() => this.sql.unsafe('PRAGMA temp_store = memory;'))
159
+ .then(() => this.sql.unsafe(`PRAGMA cache_size = ${cacheSize};`))
160
+ .then(() => this.sql.unsafe('PRAGMA busy_timeout = 5000;'))
161
+ .then(() => this.sql.unsafe('PRAGMA mmap_size = 0;'))
162
+ .catch(() => {})
163
+ }
164
+ }
165
+
166
+ private static resolveFilename(rawValue?: string | null): string {
167
+ const envVal = process.env.DATABASE_URL || process.env.SQLITE_PATH
168
+ // `Bakery.dataDir`, not a literal: the default database file has to follow
169
+ // the data directory, and this is the only place that names it.
170
+ const fallback = path.resolve(Bakery.dataDir, 'server.db')
171
+ const value =
172
+ rawValue?.trim() ||
173
+ (typeof envVal === 'string' ? envVal.trim() : undefined)
174
+
175
+ if (!value) return fallback
176
+ if (value === ':memory:' || path.isAbsolute(value)) return value
177
+
178
+ if (value.startsWith('sqlite://'))
179
+ return this.resolveFilename(value.slice('sqlite://'.length))
180
+ if (value.startsWith('sqlite:'))
181
+ return this.resolveFilename(
182
+ value.slice('sqlite:'.length).replace(/^\/+/, ''),
183
+ )
184
+ if (value.startsWith('file://'))
185
+ return Try.return(() => Bun.fileURLToPath(new URL(value)), fallback)
186
+ return path.resolve(process.cwd(), value)
187
+ }
188
+
189
+ readonly execute: SQLAdapter.Executor = createExecutor(
190
+ async (sqlText: string, params: unknown[] = []) => {
191
+ await this.ready
192
+ return (await this.sql.unsafe(sqlText, params)) as SQLAdapter.RowRecord[]
193
+ },
194
+ async (
195
+ sqlText: string,
196
+ params: unknown[] = [],
197
+ ): Promise<SQLAdapter.RunResult> => {
198
+ await this.ready
199
+ const result = (await this.sql.unsafe(sqlText, params)) as any
200
+ return {
201
+ lastInsertRowid:
202
+ result?.lastInsertRowid ??
203
+ result?.insertId ??
204
+ result?.lastInsertId ??
205
+ null,
206
+ changes: Number(
207
+ result?.count ?? result?.affectedRows ?? result?.changedRows ?? 0,
208
+ ),
209
+ }
210
+ },
211
+ this.driver,
212
+ )
213
+
214
+ // `PRAGMA table_info('${table}')` interpolated the table name into a
215
+ // string literal — a second SQL writer on a public adapter method, outside
216
+ // the `qId`/`qRef`/`safeColumn` guards convention 8 makes the only ones.
217
+ // The `pragma_table_info` table-valued function takes the same argument as a
218
+ // bound parameter, so there is nothing left to quote; MySQL and Postgres
219
+ // already bind theirs against `information_schema`.
220
+ async hasCol(table: string, column: string): Promise<boolean> {
221
+ const cols = (await this.query(`SELECT name FROM pragma_table_info(?)`).all(
222
+ table,
223
+ )) as SQLAdapter.NameRow[]
224
+ return cols.some(c => c.name === column)
225
+ }
226
+
227
+ colDef(def: unknown, column?: string): string {
228
+ const d = def as any
229
+ const typeStr =
230
+ {
231
+ integer: 'INTEGER',
232
+ string: 'TEXT',
233
+ number: 'REAL',
234
+ boolean: 'INTEGER',
235
+ buffer: 'BLOB',
236
+ bigint: 'BIGINT',
237
+ json: 'JSON',
238
+ }[d.type as string] || 'TEXT'
239
+ // SQLite has no VARCHAR of its own — every text column is TEXT affinity —
240
+ // but it stores the *declared* type verbatim and hands it back through
241
+ // `pragma table_info`. Emitting the width is therefore free here and is
242
+ // what lets one schema round-trip on all three dialects.
243
+ let out =
244
+ d.type === 'string' && typeof d.length === 'number'
245
+ ? `VARCHAR(${d.length})`
246
+ : typeStr
247
+ if (d.primary) out += ' PRIMARY KEY'
248
+ // SQLite accepts AUTOINCREMENT only on an INTEGER PRIMARY KEY and rejects
249
+ // the whole CREATE TABLE otherwise, so the guard MySQL and Postgres apply
250
+ // for tidiness is load-bearing here.
251
+ if (d.autoIncrement && d.type === 'integer') out += ' AUTOINCREMENT'
252
+ if (!d.nullable && !d.primary) out += ' NOT NULL'
253
+ // The CHECK names the column, which is why colDef takes it. Emitted only
254
+ // when both are known: an ALTER path that has no name yet gets a plain
255
+ // sized column rather than a syntax error.
256
+ const check =
257
+ Array.isArray(d._enum) && d._enum.length && column
258
+ ? this.enumClause(column, d._enum)
259
+ : ''
260
+ return out + this.formatDefault(d.default, '1', '0') + check
261
+ }
262
+
263
+ async backup(keepCount = 10): Promise<SQLAdapter.BackupResult | null> {
264
+ if (
265
+ this.filename === ':memory:' ||
266
+ !this.filename ||
267
+ !(await Bun.file(this.filename).exists())
268
+ )
269
+ return null
270
+ const ext = path.extname(this.filename),
271
+ base = path.basename(this.filename, ext)
272
+ const backupDir = `${path.dirname(this.filename)}/backups`,
273
+ backupName = `${base}.${Date.now()}${ext}`
274
+ await Bun.write(`${backupDir}/${backupName}`, Bun.file(this.filename))
275
+ return {
276
+ file: backupName,
277
+ cleanupCount: await this.cleanupBackups(backupDir, base, ext, keepCount),
278
+ }
279
+ }
280
+
281
+ protected withConnection(sql: unknown): SQLAdapter {
282
+ return new SQLiteAdapter(this.filename, sql as SQL)
283
+ }
284
+
285
+ async getSchema(): Promise<SQLAdapter.TableDetails[]> {
286
+ const res = (await this.query(
287
+ 'SELECT name FROM sqlite_master' +
288
+ " WHERE type='table' AND name NOT LIKE 'sqlite_%'",
289
+ ).all()) as SQLAdapter.NameRow[]
290
+ const tablesWithDetails: SQLAdapter.TableDetails[] = []
291
+ for (const t of res) {
292
+ const tableName = this.quote(t.name)
293
+
294
+ const [countRes, cols, idxs] = (await Promise.all([
295
+ this.query(`SELECT COUNT(*) as count FROM ${tableName}`).get(),
296
+ this.query(`PRAGMA table_info(${tableName})`).all(),
297
+ this.query(`PRAGMA index_list(${tableName})`).all(),
298
+ ])) as [SQLAdapter.CountRow, any[], any[]]
299
+
300
+ tablesWithDetails.push({
301
+ name: t.name,
302
+ rowCount: countRes?.count || 0,
303
+ columns: cols.map(c => ({
304
+ name: c.name,
305
+ type: c.type,
306
+ notnull: c.notnull === 1,
307
+ pk: c.pk === 1,
308
+ })),
309
+ indexes: idxs.map(i => ({ name: i.name, unique: i.unique === 1 })),
310
+ })
311
+ }
312
+ return tablesWithDetails
313
+ }
314
+
315
+ async getData(
316
+ tableName: string,
317
+ options: SQLAdapter.TableDataOptions,
318
+ ): Promise<SQLAdapter.TableDataResult> {
319
+ const tname = this.quote(tableName)
320
+ const cols = (await this.query(
321
+ `PRAGMA table_info(${tname})`,
322
+ ).all()) as SQLAdapter.NameRow[]
323
+ const { whereSql, orderSql, whereParams } = this.buildFilterSort(
324
+ options,
325
+ new Set(cols.map(c => c.name)),
326
+ )
327
+
328
+ const { page, pageSize } = options
329
+
330
+ const [countRes, rows] = (await Promise.all([
331
+ this.query(`SELECT COUNT(*) as count FROM ${tname}${whereSql}`).get(
332
+ ...whereParams,
333
+ ),
334
+ this.query(
335
+ `SELECT rowid AS rowid, * FROM ${tname}` +
336
+ `${whereSql}${orderSql} LIMIT ? OFFSET ?`,
337
+ ).all(...whereParams, pageSize, (page - 1) * pageSize),
338
+ ])) as [SQLAdapter.CountRow, any[]]
339
+
340
+ const totalRows = countRes?.count || 0
341
+ return {
342
+ rows,
343
+ totalRows,
344
+ page: page,
345
+ pageSize: pageSize,
346
+ totalPages: Math.ceil(totalRows / pageSize),
347
+ }
348
+ }
349
+
350
+ async remove(table: string, rowid: unknown): Promise<SQLAdapter.RunResult> {
351
+ return await this.query(
352
+ `DELETE FROM ${this.quote(table)} WHERE rowid = ?`,
353
+ ).run(rowid)
354
+ }
355
+
356
+ async truncate(table: string): Promise<SQLAdapter.RunResult> {
357
+ await this.query(`DELETE FROM ${this.quote(table)}`).run()
358
+ return this.query(`VACUUM`).run()
359
+ }
360
+
361
+ async update(table: string, rowid: unknown, row: SQLAdapter.RowRecord) {
362
+ const keys = Object.keys(row).filter(k => k !== 'rowid')
363
+ return await this.query(
364
+ `UPDATE ${this.quote(table)}
365
+ SET ${keys.map(k => `${this.quote(k)} = ?`).join(', ')}
366
+ WHERE rowid = ?`,
367
+ ).run(...keys.map(k => row[k]), rowid)
368
+ }
369
+
370
+ override async getForeignKeys(): Promise<SyncTypes.DBForeignKeys> {
371
+ const out: SyncTypes.DBForeignKeys = {}
372
+ const tables = await this.getSchema()
373
+ for (const t of tables) {
374
+ // PRAGMA takes an identifier, not a bound parameter.
375
+ const rows = (await this.query(
376
+ `PRAGMA foreign_key_list(${this.quote(t.name)})`,
377
+ ).all()) as any[]
378
+ // One row per column, grouped by `id` for a composite key.
379
+ const byId = new Map<
380
+ number,
381
+ {
382
+ cols: string[]
383
+ refCols: string[]
384
+ refTable: string
385
+ onDelete: SyncTypes.ForeignKeyAction
386
+ onUpdate: SyncTypes.ForeignKeyAction
387
+ }
388
+ >()
389
+ for (const r of rows) {
390
+ const id = Number(r.id ?? 0)
391
+ const g = byId.get(id) ?? {
392
+ cols: [] as string[],
393
+ refCols: [] as string[],
394
+ refTable: String(r.table),
395
+ onDelete: SQLAdapter.normalizeForeignKeyAction(r.on_delete),
396
+ onUpdate: SQLAdapter.normalizeForeignKeyAction(r.on_update),
397
+ }
398
+ g.cols.push(String(r.from))
399
+ g.refCols.push(String(r.to))
400
+ byId.set(id, g)
401
+ }
402
+ for (const g of byId.values()) {
403
+ const fk = {
404
+ table: t.name,
405
+ cols: g.cols,
406
+ refTable: g.refTable,
407
+ refCols: g.refCols,
408
+ onDelete: g.onDelete,
409
+ onUpdate: g.onUpdate,
410
+ }
411
+ out[SQLAdapter.foreignKeyId(fk)] = fk
412
+ }
413
+ }
414
+ return out
415
+ }
416
+
417
+ /**
418
+ * SQLite has no `ALTER TABLE ADD/DROP FOREIGN KEY`: the constraint lives in
419
+ * the table definition, so changing one means rebuilding the table. The
420
+ * planner reads this and schedules a rebuild instead of emitting DDL that
421
+ * would fail.
422
+ */
423
+ override get supportsAlterForeignKey(): boolean {
424
+ return false
425
+ }
426
+
427
+ /**
428
+ * SQLite has `UNION ALL` but neither `INTERSECT ALL` nor `EXCEPT ALL` — the
429
+ * `ALL` modifier is only accepted after `UNION`.
430
+ */
431
+ override get supportsSetOperationAll(): boolean {
432
+ return false
433
+ }
434
+
435
+ async getConstraints(): Promise<SyncTypes.DBConstraints> {
436
+ const tables = (await this.query(
437
+ 'SELECT sql,name,type FROM sqlite_master' +
438
+ " WHERE (type='table' OR type='view')" +
439
+ " AND name NOT LIKE 'sqlite_%'",
440
+ ).all()) as any[]
441
+
442
+ const dbConstraints: SyncTypes.DBConstraints = {}
443
+
444
+ for (const table of tables) {
445
+ const tName = Case.camel(table.name)
446
+ dbConstraints[tName] = {} as SyncTypes.TableConstraints
447
+
448
+ const cols = (await this.query(
449
+ `PRAGMA table_info('${table.name}')`,
450
+ ).all()) as any[]
451
+
452
+ if (table.type === 'view') {
453
+ const match = table.sql.match(/AS\s+(.*)/is)
454
+ if (match)
455
+ dbConstraints[tName]._view = SQLiteAdapter.cleanSQLQuotes(
456
+ match[1].trim(),
457
+ )
458
+
459
+ for (const col of cols) {
460
+ dbConstraints[tName][Case.camel(col.name)] = {
461
+ type: SQLiteAdapter.mapSqlToTsType(col.type),
462
+ nullable: col.notnull === 0n || col.notnull === 0,
463
+ }
464
+ }
465
+ continue
466
+ }
467
+
468
+ for (const col of cols) {
469
+ dbConstraints[tName][Case.camel(col.name)] = this.parseConstraints(
470
+ col,
471
+ table.sql,
472
+ )
473
+ }
474
+ }
475
+
476
+ return dbConstraints
477
+ }
478
+
479
+ async getIndexes(): Promise<SyncTypes.DBIndexes> {
480
+ const indexes = (await this.query(
481
+ 'SELECT name, tbl_name, sql FROM sqlite_master' +
482
+ " WHERE type='index' AND sql IS NOT NULL" +
483
+ " AND name NOT LIKE 'sqlite_autoindex_%'",
484
+ ).all()) as any[]
485
+ return Object.fromEntries(
486
+ await Promise.all(
487
+ indexes.map(async idx => [
488
+ Case.camel(idx.name),
489
+ {
490
+ type: idx.sql.toUpperCase().includes('UNIQUE') ? 'unique' : 'index',
491
+ table: Case.camel(idx.tbl_name),
492
+ cols: (
493
+ (await this.query(
494
+ `PRAGMA index_info('${idx.name}')`,
495
+ ).all()) as any[]
496
+ ).map(c => Case.camel(c.name)),
497
+ },
498
+ ]),
499
+ ),
500
+ )
501
+ }
502
+
503
+ protected override async preSync(tx: SQLAdapter): Promise<void> {
504
+ await tx.query('PRAGMA foreign_keys=OFF').run()
505
+ }
506
+ protected override async postSync(tx: SQLAdapter): Promise<void> {
507
+ await tx.query('PRAGMA foreign_keys=ON').run()
508
+ }
509
+ override readonly dateNowDefaults: string[] = [
510
+ "CAST(strftime('%s', 'now') AS INTEGER)",
511
+ ]
512
+ override readonly dateNowExpression: string =
513
+ "CAST(strftime('%s', 'now') AS INTEGER)"
514
+
515
+ /**
516
+ * SQLite has no UUID function, so the canonical 8-4-4-4-12 form is assembled
517
+ * from `randomblob(16)`. Version and variant nibbles are **not** forced, so
518
+ * this is a random 128-bit value in UUID shape rather than a conforming v4 —
519
+ * unique, but do not hand it to something that validates the version field.
520
+ *
521
+ * SQLite stores the default expression verbatim and hands it back the same
522
+ * way, so the emitted form and the match pattern are the same string.
523
+ */
524
+ override readonly uuidExpression: string =
525
+ "lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-' || " +
526
+ "hex(randomblob(2)) || '-' || hex(randomblob(2)) || '-' || " +
527
+ 'hex(randomblob(6)))'
528
+ override readonly uuidDefaults: string[] = [this.uuidExpression]
529
+
530
+ protected override parseConstraints(
531
+ col: any,
532
+ tableSql = '',
533
+ ): SyncTypes.ColumnConstraint {
534
+ const primary = col.pk > 0
535
+ const cons: SyncTypes.ColumnConstraint = {
536
+ type: SQLiteAdapter.mapSqlToTsType(col.type),
537
+ }
538
+
539
+ // SQLite has no catalog column for width; it stores the *declared* type
540
+ // verbatim and hands it back through `pragma table_info`, so the number is
541
+ // in the type string — 'VARCHAR(64)'. That is also why emitting the width
542
+ // is worth doing on a dialect with no real VARCHAR: it is what lets one
543
+ // schema round-trip on all three.
544
+ const declared = String(col.type || '')
545
+ const length = this.sizedTextLength(
546
+ declared,
547
+ /\((\d+)\)/.exec(declared)?.[1],
548
+ )
549
+ if (length !== undefined) cons.length = length
550
+
551
+ if (primary) cons.primary = true
552
+ if (
553
+ primary &&
554
+ cons.type === 'integer' &&
555
+ tableSql?.toUpperCase().includes('AUTOINCREMENT')
556
+ ) {
557
+ cons.autoIncrement = true
558
+ }
559
+
560
+ if (col.notnull === 0 && !primary) cons.nullable = true
561
+
562
+ const parsedDef = this.parseDefault(col.dflt_value)
563
+ if (parsedDef !== undefined) cons.default = parsedDef
564
+
565
+ return cons
566
+ }
567
+
568
+ // `protected override`, not `private`: narrowing a base member's visibility
569
+ // is TS2415, and it made SQLiteAdapter fail to satisfy SQLAdapter — so the
570
+ // one adapter with real test coverage did not typecheck against the contract
571
+ // the two untested ones share.
572
+ protected override parseDefault(def: any): any {
573
+ if (def === null || def === undefined) return def
574
+ const isStr = typeof def === 'string'
575
+ if (isStr && (def.startsWith("'") || def.startsWith('"'))) {
576
+ const quote: string = def[0]
577
+ const unquoted = def.slice(1, -1)
578
+ if (unquoted === '%dateNow%') return def
579
+ // sqlite_master hands back the literal exactly as formatDefault wrote it,
580
+ // so the quote it doubled per SQL rules is still doubled. Stripping the
581
+ // delimiters without collapsing it leaves `it''s fine` where the schema
582
+ // says `it's fine` — drift diffColumnMismatch can never resolve, and a
583
+ // table rebuild on every db:sync as a result.
584
+ return unquoted.replaceAll(quote + quote, quote)
585
+ }
586
+ return super.parseDefault(def)
587
+ }
588
+ }
@@ -0,0 +1,72 @@
1
+ export * from './adapters/base'
2
+ export * from './adapters/registry'
3
+
4
+ import { registerAdapter, resolveAdapter } from './adapters/registry'
5
+
6
+ /**
7
+ * Does this target name a SQLite file rather than a server?
8
+ *
9
+ * Deliberately greedy — anything with a path separator lands here — because it
10
+ * is consulted only *after* every registered scheme has had its say. Before
11
+ * the registry that ordering was implicit and this heuristic ran second by
12
+ * luck; now `protocols` is checked first by contract, so an adapter that
13
+ * declares `mssql://` gets it even though this would happily have claimed it.
14
+ */
15
+ function isSQLite(val: string) {
16
+ return (
17
+ val === ':memory:' ||
18
+ val.startsWith('sqlite:') ||
19
+ val.startsWith('file:') ||
20
+ /(^|[\\/])[^\\/]+\\.db($|[?#])/i.test(val) ||
21
+ val.endsWith('.db') ||
22
+ val.includes('/') ||
23
+ val.includes('\\')
24
+ )
25
+ }
26
+
27
+ /**
28
+ * The three built-in adapters, registered at module load.
29
+ *
30
+ * Registering is cheap — three object literals — because `open` is what pulls
31
+ * the driver in. An app on SQLite never loads `mysql.ts` or `pgsql.ts`, which
32
+ * was true of the `switch` this replaced and had to stay true of the registry.
33
+ */
34
+ registerAdapter({
35
+ driver: 'sqlite',
36
+ protocols: ['sqlite', 'file'],
37
+ matches: isSQLite,
38
+ async open(target) {
39
+ const { SQLiteAdapter } = await import('./adapters/sqlite')
40
+ return new SQLiteAdapter(target || undefined)
41
+ },
42
+ })
43
+
44
+ registerAdapter({
45
+ driver: 'postgres',
46
+ protocols: ['postgres', 'postgresql'],
47
+ async open(target, pool) {
48
+ const { PGAdapter } = await import('./adapters/pgsql')
49
+ return new PGAdapter(target || undefined, pool)
50
+ },
51
+ })
52
+
53
+ registerAdapter({
54
+ driver: 'mysql',
55
+ // Four spellings, all of which the adapter rewrites to `mysql://` in its
56
+ // constructor: `mysqli`/`mysqlis` are PHP-era, `mysqls` is the TLS form.
57
+ protocols: ['mysql', 'mysqls', 'mysqli', 'mysqlis'],
58
+ async open(target, pool) {
59
+ const { MySQLAdapter } = await import('./adapters/mysql')
60
+ return new MySQLAdapter(target || undefined, pool)
61
+ },
62
+ })
63
+
64
+ export async function createDbAdapter() {
65
+ const url = process.env.DB_URL || process.env.DATABASE_URL || ''
66
+ const { poolOptionsFromEnv } = await import('./pool')
67
+ // Read once and handed to every adapter. SQLite ignores it — a single file
68
+ // handle has no pool to size — but the registry cannot know which does what,
69
+ // so the option goes to all of them and each takes what it needs.
70
+ const pool = poolOptionsFromEnv()
71
+ return await resolveAdapter(url).open(url || undefined, pool)
72
+ }
package/src/backup.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { Bakery } from '@bakery-framework/core/core/bakery'
2
+ import { Logger, messageLogger } from '@bakery-framework/core/logger'
3
+ import { Try } from '@bakery-framework/core/utils'
4
+
5
+ const MESSAGES = messageLogger(new Logger('db-backup'), {
6
+ BACKUP_CREATED: 'I Created database backup: %y{file}%*',
7
+ BACKUP_FAILED: 'E Failed to create database backup: %r{error}%*',
8
+ BACKUP_SKIPPED:
9
+ 'W No database backup was produced (in-memory DB or no dump tool available).',
10
+ BACKUP_CLEANUP: 'I Auto-deleted %c{count}%* old backup(s) to maintain limit.',
11
+ } as const)
12
+
13
+ /**
14
+ * Returns whether a backup file was actually written. Callers about to run a
15
+ * destructive migration must check this — a thrown error, a `:memory:` database,
16
+ * or a missing `pg_dump`/`mysqldump` all previously looked identical to success.
17
+ */
18
+ export async function backupDatabase(adapter?: any): Promise<boolean> {
19
+ const conn = adapter || (await import('./connection')).connection
20
+ const [err, result] = await Try.catch(conn.backup(Bakery.config.backups))
21
+
22
+ if (err) {
23
+ MESSAGES.BACKUP_FAILED({ error: err.message })
24
+ return false
25
+ }
26
+
27
+ if (!result) {
28
+ MESSAGES.BACKUP_SKIPPED()
29
+ return false
30
+ }
31
+
32
+ MESSAGES.BACKUP_CREATED({ file: result.file })
33
+ if (result.cleanupCount && result.cleanupCount > 0) {
34
+ MESSAGES.BACKUP_CLEANUP({ count: result.cleanupCount })
35
+ }
36
+ return true
37
+ }