@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,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
|
+
}
|
package/src/adapters.ts
ADDED
|
@@ -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
|
+
}
|