@bakery-framework/orm 2.0.0-alpha.9 → 2.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -9
- package/package.json +3 -3
- package/src/adapters/base.ts +181 -62
- package/src/adapters/mysql.ts +29 -17
- package/src/adapters/observe.ts +11 -11
- package/src/adapters/pgsql.ts +54 -39
- package/src/adapters/registry.ts +13 -13
- package/src/adapters/sqlite.ts +175 -44
- package/src/adapters.ts +4 -4
- package/src/backup.ts +1 -1
- package/src/connection.ts +1 -1
- package/src/define.ts +22 -22
- package/src/field.ts +35 -35
- package/src/globals.d.ts +1 -1
- package/src/index.ts +4 -4
- package/src/orm/index.ts +2 -2
- package/src/orm/mutation.ts +36 -28
- package/src/orm/query.ts +92 -89
- package/src/pool.ts +5 -5
- package/src/schema-registry.ts +3 -3
- package/src/schema-util.ts +48 -17
- package/src/sync/builder.ts +32 -32
- package/src/sync/diff.ts +12 -12
- package/src/sync/engine.ts +19 -19
- package/src/sync/execute.ts +16 -16
- package/src/sync/history.ts +5 -5
- package/src/sync/index.ts +12 -12
- package/src/sync/ledger.ts +23 -23
- package/src/sync/load.ts +16 -16
- package/src/sync/plan.ts +1 -2
- package/src/sync/rename.ts +1 -1
- package/src/sync/rollback.ts +3 -3
- package/src/sync/types.ts +20 -4
- package/src/sync/view-sql.ts +9 -9
- package/templates/schema.example.ts +4 -4
package/src/adapters/sqlite.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { Bakery } from '@bakery-framework/core/core/bakery'
|
|
3
|
+
import { Logger, messageLogger } from '@bakery-framework/core/logger'
|
|
3
4
|
import { Case, Try } from '@bakery-framework/core/utils'
|
|
4
5
|
import { SQL } from 'bun'
|
|
5
6
|
import type * as SyncTypes from '../sync/types'
|
|
6
7
|
import { createExecutor, SQLAdapter } from './base'
|
|
7
8
|
|
|
9
|
+
// Convention 4: logging is data, declared in a table rather than formatted at
|
|
10
|
+
// the call site.
|
|
11
|
+
const MESSAGES = messageLogger(new Logger('database'), {
|
|
12
|
+
JOURNAL_WAL_REFUSED:
|
|
13
|
+
'W SQLite refused WAL for %y{file}%* (answered %y{answer}%*): running the %y{mode}%* journal instead. Expected on a network path; on a local disk it costs every write.',
|
|
14
|
+
PRAGMA_FAILED:
|
|
15
|
+
'W A SQLite performance pragma was rejected: %r{error}%*. The database works; it is not tuned.',
|
|
16
|
+
} as const)
|
|
17
|
+
|
|
8
18
|
export class SQLiteAdapter extends SQLAdapter {
|
|
9
19
|
// SQLite's standard identifier quote. The base class defaults to MySQL's
|
|
10
20
|
// backtick, which SQLite only tolerates as a compatibility extension.
|
|
@@ -12,12 +22,26 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
12
22
|
|
|
13
23
|
protected readonly sql: SQL
|
|
14
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Resolves when the performance pragmas have finished, or failed loudly.
|
|
27
|
+
*
|
|
28
|
+
* They are deliberately not awaited by every query the way `ready` is:
|
|
29
|
+
* tuning is not correctness, and making the first statement of every process
|
|
30
|
+
* wait on six round trips to gain nothing is the wrong trade. But `close()`
|
|
31
|
+
* has to wait, because a handle closed underneath an in-flight pragma
|
|
32
|
+
* rejects with `Connection closed`, and that is indistinguishable at the
|
|
33
|
+
* catch from a filesystem genuinely refusing one. A short-lived adapter (a
|
|
34
|
+
* test, a one-shot script) would otherwise warn on every close about a
|
|
35
|
+
* failure that never happened.
|
|
36
|
+
*/
|
|
37
|
+
protected readonly tuned: Promise<unknown> = Promise.resolve()
|
|
38
|
+
|
|
15
39
|
/**
|
|
16
40
|
* Resolves once `foreign_keys` is on, and every query awaits it.
|
|
17
41
|
*
|
|
18
42
|
* SQLite defaults the pragma OFF and it is per-connection, so without this a
|
|
19
43
|
* FOREIGN KEY is stored, reported by `PRAGMA foreign_key_list`, shown in the
|
|
20
|
-
* dashboard
|
|
44
|
+
* dashboard, and enforces nothing.
|
|
21
45
|
*
|
|
22
46
|
* Deliberately *not* in the performance pragma chain above it. That chain is
|
|
23
47
|
* fire-and-forget by design (its own comment calls the statements
|
|
@@ -124,7 +148,7 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
124
148
|
filename,
|
|
125
149
|
typeof connectionTarget === 'string' ? connectionTarget : undefined,
|
|
126
150
|
)
|
|
127
|
-
// `sql` is supplied when wrapping an existing connection
|
|
151
|
+
// `sql` is supplied when wrapping an existing connection: notably once per
|
|
128
152
|
// transaction. Setup below belongs only to a connection we open ourselves;
|
|
129
153
|
// re-running it per transaction meant a mkdirSync plus six unawaited
|
|
130
154
|
// PRAGMAs
|
|
@@ -143,7 +167,7 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
143
167
|
(filename === ':memory:'
|
|
144
168
|
? new SQL('sqlite://:memory:')
|
|
145
169
|
: new SQL(filename, { adapter: 'sqlite' }))
|
|
146
|
-
// Every owned connection, `:memory:` included
|
|
170
|
+
// Every owned connection, `:memory:` included, the perf pragmas below
|
|
147
171
|
// skip in-memory databases, but correctness does not get to.
|
|
148
172
|
this.ready = ownsConnection
|
|
149
173
|
? this.sql.unsafe('PRAGMA foreign_keys = ON;')
|
|
@@ -151,18 +175,105 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
151
175
|
|
|
152
176
|
if (ownsConnection && filename !== ':memory:') {
|
|
153
177
|
const cacheSize = import.meta.env.THREAD_WORKER ? -1000 : -10000
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
.
|
|
178
|
+
this.tuned = this.sql
|
|
179
|
+
.unsafe('PRAGMA journal_mode = WAL;')
|
|
180
|
+
.then((rows: unknown) => this.confirmWAL(rows, filename))
|
|
157
181
|
.then(() => this.sql.unsafe('PRAGMA synchronous = NORMAL;'))
|
|
158
182
|
.then(() => this.sql.unsafe('PRAGMA temp_store = memory;'))
|
|
159
183
|
.then(() => this.sql.unsafe(`PRAGMA cache_size = ${cacheSize};`))
|
|
160
184
|
.then(() => this.sql.unsafe('PRAGMA busy_timeout = 5000;'))
|
|
161
185
|
.then(() => this.sql.unsafe('PRAGMA mmap_size = 0;'))
|
|
162
|
-
|
|
186
|
+
// Not `catch(() => {})`. Every performance pragma failing silently is
|
|
187
|
+
// how a database ends up running at a fraction of its speed with
|
|
188
|
+
// nothing anywhere saying so, and convention 3 bans the bare form for
|
|
189
|
+
// exactly this. These are tuning, not correctness (`foreign_keys`
|
|
190
|
+
// above is awaited and is allowed to reject), so a failure is a
|
|
191
|
+
// warning rather than a throw.
|
|
192
|
+
.catch((error: Error) => {
|
|
193
|
+
MESSAGES.PRAGMA_FAILED({ error: error.message })
|
|
194
|
+
})
|
|
163
195
|
}
|
|
164
196
|
}
|
|
165
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Waits for the pragma chain before closing the handle. See `tuned`.
|
|
200
|
+
*/
|
|
201
|
+
override async close(): Promise<void> {
|
|
202
|
+
await Try.catch(this.tuned)
|
|
203
|
+
await super.close()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* **WAL first, DELETE only where WAL is actually refused.**
|
|
208
|
+
*
|
|
209
|
+
* The journal mode used to be `platform === 'win32' ? 'DELETE' : 'WAL'`, a
|
|
210
|
+
* rule with no recorded reason. The hypothesis behind it was that a Windows
|
|
211
|
+
* network path cannot host WAL, which is true (WAL needs a shared `-shm`
|
|
212
|
+
* mapping that SMB and WebDAV do not provide), but the rule applied to every
|
|
213
|
+
* Windows install, local disks included, where WAL works.
|
|
214
|
+
*
|
|
215
|
+
* Measured on this machine, four interleaved rounds against a CPU-bound
|
|
216
|
+
* control that stayed flat at 29-34 ms:
|
|
217
|
+
*
|
|
218
|
+
* single autocommit write DELETE 3629 us WAL 36 us 100x
|
|
219
|
+
* 100-row transaction DELETE 3.91 ms WAL 0.13 ms 29x
|
|
220
|
+
*
|
|
221
|
+
* An attempt and a check replaces the rule, which is what makes it safe to
|
|
222
|
+
* change without knowing why it was there. SQLite refuses WAL in two
|
|
223
|
+
* observable ways and both are handled: it returns the *unchanged* mode in
|
|
224
|
+
* the pragma's own result row (an in-memory database answers `memory`), or
|
|
225
|
+
* it throws. Both shapes were reproduced before this was written, so the
|
|
226
|
+
* fallback rests on a measurement rather than on the documentation.
|
|
227
|
+
*
|
|
228
|
+
* The same logic runs on the cache database in
|
|
229
|
+
* `@bakery-framework/core/cache/shared-db`. It is deliberately not shared:
|
|
230
|
+
* that site is synchronous `bun:sqlite` and this one is an async
|
|
231
|
+
* `Bun.SQL`, so the control flow has nothing in common and only the
|
|
232
|
+
* predicate would move: at the cost of a new published export subpath on a
|
|
233
|
+
* surface that was closed on purpose before 2.0.0.
|
|
234
|
+
*/
|
|
235
|
+
private async confirmWAL(rows: unknown, filename: string): Promise<void> {
|
|
236
|
+
const first = Array.isArray(rows) ? rows[0] : undefined
|
|
237
|
+
const answer = String(
|
|
238
|
+
(first as { journal_mode?: unknown } | undefined)?.journal_mode ?? '',
|
|
239
|
+
)
|
|
240
|
+
if (answer.toLowerCase() === 'wal') return
|
|
241
|
+
|
|
242
|
+
const fell = await this.sql.unsafe('PRAGMA journal_mode = DELETE;')
|
|
243
|
+
const mode = Array.isArray(fell)
|
|
244
|
+
? String((fell[0] as { journal_mode?: unknown })?.journal_mode ?? 'unknown')
|
|
245
|
+
: 'unknown'
|
|
246
|
+
MESSAGES.JOURNAL_WAL_REFUSED({
|
|
247
|
+
file: filename,
|
|
248
|
+
answer: answer || '(no row)',
|
|
249
|
+
mode,
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* `PRAGMA schema_version`, which is a counter SQLite bumps on every schema
|
|
255
|
+
* change and leaves alone for every row change.
|
|
256
|
+
*
|
|
257
|
+
* Measured on a 50-table database: the full introspection the explorer runs
|
|
258
|
+
* per write is **250 statements and 8.28 ms**, and this is **10.4 us** - 795x.
|
|
259
|
+
* The semantics were checked rather than taken from the documentation: the
|
|
260
|
+
* counter advances on `CREATE TABLE`, on `ALTER TABLE ... ADD COLUMN` and on
|
|
261
|
+
* `DROP TABLE`, and does not move for an `INSERT`.
|
|
262
|
+
*
|
|
263
|
+
* Prefixed with the driver and the file so two different SQLite databases
|
|
264
|
+
* cannot collide on the same small integer. A fresh database starts near
|
|
265
|
+
* zero, so "version 3" is a value many of them hold at once.
|
|
266
|
+
*/
|
|
267
|
+
override async schemaFingerprint(): Promise<string | null> {
|
|
268
|
+
const row = (await this.query('PRAGMA schema_version').get()) as
|
|
269
|
+
| { schema_version?: number }
|
|
270
|
+
| null
|
|
271
|
+
| undefined
|
|
272
|
+
const version = row?.schema_version
|
|
273
|
+
if (typeof version !== 'number') return null
|
|
274
|
+
return `sqlite:${this.filename ?? ''}:${version}`
|
|
275
|
+
}
|
|
276
|
+
|
|
166
277
|
private static resolveFilename(rawValue?: string | null): string {
|
|
167
278
|
const envVal = process.env.DATABASE_URL || process.env.SQLITE_PATH
|
|
168
279
|
// `Bakery.dataDir`, not a literal: the default database file has to follow
|
|
@@ -212,7 +323,7 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
212
323
|
)
|
|
213
324
|
|
|
214
325
|
// `PRAGMA table_info('${table}')` interpolated the table name into a
|
|
215
|
-
// string literal
|
|
326
|
+
// string literal, a second SQL writer on a public adapter method, outside
|
|
216
327
|
// the `qId`/`qRef`/`safeColumn` guards convention 8 makes the only ones.
|
|
217
328
|
// The `pragma_table_info` table-valued function takes the same argument as a
|
|
218
329
|
// bound parameter, so there is nothing left to quote; MySQL and Postgres
|
|
@@ -236,8 +347,7 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
236
347
|
bigint: 'BIGINT',
|
|
237
348
|
json: 'JSON',
|
|
238
349
|
}[d.type as string] || 'TEXT'
|
|
239
|
-
// SQLite has no VARCHAR of its own
|
|
240
|
-
// but it stores the *declared* type verbatim and hands it back through
|
|
350
|
+
// SQLite has no VARCHAR of its own (every text column is TEXT affinity), // but it stores the *declared* type verbatim and hands it back through
|
|
241
351
|
// `pragma table_info`. Emitting the width is therefore free here and is
|
|
242
352
|
// what lets one schema round-trip on all three dialects.
|
|
243
353
|
let out =
|
|
@@ -282,7 +392,9 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
282
392
|
return new SQLiteAdapter(this.filename, sql as SQL)
|
|
283
393
|
}
|
|
284
394
|
|
|
285
|
-
async getSchema(
|
|
395
|
+
async getSchema(
|
|
396
|
+
options?: SQLAdapter.SchemaOptions,
|
|
397
|
+
): Promise<SQLAdapter.TableDetails[]> {
|
|
286
398
|
const res = (await this.query(
|
|
287
399
|
'SELECT name FROM sqlite_master' +
|
|
288
400
|
" WHERE type='table' AND name NOT LIKE 'sqlite_%'",
|
|
@@ -292,30 +404,31 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
292
404
|
// 20-table schema cost 20 sequential round-trip waves. They share nothing,
|
|
293
405
|
// so the whole fan-out is one wave now.
|
|
294
406
|
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
// change across every consumer — recorded rather than done here.
|
|
407
|
+
// The `COUNT(*)` is a full scan on this dialect and on Postgres, so it is
|
|
408
|
+
// opt-in: only a schema *listing* displays the number, and `getSchema()`
|
|
409
|
+
// also sits on the explorer's write path, where a count nobody shows cost
|
|
410
|
+
// a scan of every table per write.
|
|
300
411
|
return await Promise.all(
|
|
301
412
|
res.map(async t => {
|
|
302
413
|
const tableName = this.quote(t.name)
|
|
303
414
|
|
|
304
415
|
const [countRes, cols, idxs] = (await Promise.all([
|
|
305
|
-
|
|
416
|
+
options?.rowCounts
|
|
417
|
+
? this.query(`SELECT COUNT(*) as count FROM ${tableName}`).get()
|
|
418
|
+
: null,
|
|
306
419
|
this.query(`PRAGMA table_info(${tableName})`).all(),
|
|
307
420
|
this.query(`PRAGMA index_list(${tableName})`).all(),
|
|
308
|
-
])) as [SQLAdapter.CountRow, any[], any[]]
|
|
421
|
+
])) as [SQLAdapter.CountRow | null, any[], any[]]
|
|
309
422
|
|
|
310
423
|
return {
|
|
311
424
|
name: t.name,
|
|
312
|
-
rowCount: countRes?.count || 0,
|
|
425
|
+
rowCount: options?.rowCounts ? countRes?.count || 0 : null,
|
|
313
426
|
columns: cols.map(c => ({
|
|
314
427
|
name: c.name,
|
|
315
428
|
type: c.type,
|
|
316
429
|
notnull: c.notnull === 1,
|
|
317
430
|
// `pk` is the column's **1-based position within the primary key**,
|
|
318
|
-
// not a boolean
|
|
431
|
+
// not a boolean: `PRAGMA table_info` reports 0 for "not part of the
|
|
319
432
|
// key", 1 for the first key column, 2 for the second. So `=== 1`
|
|
320
433
|
// reported a composite `PRIMARY KEY (a, b)` as a single-column key on
|
|
321
434
|
// `a`, silently, and only on SQLite: MySQL reads `column_key = 'PRI'`
|
|
@@ -348,17 +461,23 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
348
461
|
|
|
349
462
|
const { page, pageSize } = options
|
|
350
463
|
|
|
464
|
+
// See `TableDataOptions.knownTotal`: the count is 97% of a page's cost,
|
|
465
|
+
// and a caller that has already counted can say so.
|
|
466
|
+
const reuse = SQLAdapter.usableTotal(options.knownTotal)
|
|
467
|
+
|
|
351
468
|
const [countRes, rows] = (await Promise.all([
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
469
|
+
reuse
|
|
470
|
+
? Promise.resolve(null)
|
|
471
|
+
: this.query(`SELECT COUNT(*) as count FROM ${tname}${whereSql}`).get(
|
|
472
|
+
...whereParams,
|
|
473
|
+
),
|
|
355
474
|
this.query(
|
|
356
475
|
`SELECT rowid AS rowid, * FROM ${tname}` +
|
|
357
476
|
`${whereSql}${orderSql} LIMIT ? OFFSET ?`,
|
|
358
477
|
).all(...whereParams, pageSize, (page - 1) * pageSize),
|
|
359
|
-
])) as [SQLAdapter.CountRow, any[]]
|
|
478
|
+
])) as [SQLAdapter.CountRow | null, any[]]
|
|
360
479
|
|
|
361
|
-
const totalRows = countRes?.count || 0
|
|
480
|
+
const totalRows = reuse ? options.knownTotal! : countRes?.count || 0
|
|
362
481
|
return {
|
|
363
482
|
rows,
|
|
364
483
|
totalRows,
|
|
@@ -446,7 +565,7 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
446
565
|
}
|
|
447
566
|
|
|
448
567
|
/**
|
|
449
|
-
* SQLite has `UNION ALL` but neither `INTERSECT ALL` nor `EXCEPT ALL
|
|
568
|
+
* SQLite has `UNION ALL` but neither `INTERSECT ALL` nor `EXCEPT ALL`: the
|
|
450
569
|
* `ALL` modifier is only accepted after `UNION`.
|
|
451
570
|
*/
|
|
452
571
|
override get supportsSetOperationAll(): boolean {
|
|
@@ -466,9 +585,13 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
466
585
|
const tName = Case.camel(table.name)
|
|
467
586
|
dbConstraints[tName] = {} as SyncTypes.TableConstraints
|
|
468
587
|
|
|
588
|
+
// Bound, not interpolated: the same conversion `hasCol` above
|
|
589
|
+
// records. This one survived it: `getConstraints` runs over every table
|
|
590
|
+
// the database reports, so a name carrying an apostrophe closed the
|
|
591
|
+
// literal and the rest of the statement went with it.
|
|
469
592
|
const cols = (await this.query(
|
|
470
|
-
|
|
471
|
-
).all()) as any[]
|
|
593
|
+
'SELECT * FROM pragma_table_info(?)',
|
|
594
|
+
).all(table.name)) as any[]
|
|
472
595
|
|
|
473
596
|
if (table.type === 'view') {
|
|
474
597
|
const match = table.sql.match(/AS\s+(.*)/is)
|
|
@@ -505,18 +628,26 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
505
628
|
).all()) as any[]
|
|
506
629
|
return Object.fromEntries(
|
|
507
630
|
await Promise.all(
|
|
508
|
-
indexes.map(async idx =>
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
631
|
+
indexes.map(async idx => {
|
|
632
|
+
// Bound for the same reason as `getConstraints` above: an index
|
|
633
|
+
// name comes from `sqlite_master`, not from this codebase.
|
|
634
|
+
const raw = (
|
|
635
|
+
(await this.query('SELECT * FROM pragma_index_info(?)').all(
|
|
636
|
+
idx.name,
|
|
637
|
+
)) as any[]
|
|
638
|
+
).map(c => String(c.name))
|
|
639
|
+
return [
|
|
640
|
+
Case.camel(idx.name),
|
|
641
|
+
{
|
|
642
|
+
type: idx.sql.toUpperCase().includes('UNIQUE')
|
|
643
|
+
? 'unique'
|
|
644
|
+
: 'index',
|
|
645
|
+
table: Case.camel(idx.tbl_name),
|
|
646
|
+
cols: raw.map(Case.camel),
|
|
647
|
+
rawCols: raw,
|
|
648
|
+
},
|
|
649
|
+
]
|
|
650
|
+
}),
|
|
520
651
|
),
|
|
521
652
|
)
|
|
522
653
|
}
|
|
@@ -536,7 +667,7 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
536
667
|
/**
|
|
537
668
|
* SQLite has no UUID function, so the canonical 8-4-4-4-12 form is assembled
|
|
538
669
|
* from `randomblob(16)`. Version and variant nibbles are **not** forced, so
|
|
539
|
-
* this is a random 128-bit value in UUID shape rather than a conforming v4
|
|
670
|
+
* this is a random 128-bit value in UUID shape rather than a conforming v4:
|
|
540
671
|
* unique, but do not hand it to something that validates the version field.
|
|
541
672
|
*
|
|
542
673
|
* SQLite stores the default expression verbatim and hands it back the same
|
|
@@ -559,7 +690,7 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
559
690
|
|
|
560
691
|
// SQLite has no catalog column for width; it stores the *declared* type
|
|
561
692
|
// verbatim and hands it back through `pragma table_info`, so the number is
|
|
562
|
-
// in the type string
|
|
693
|
+
// in the type string, 'VARCHAR(64)'. That is also why emitting the width
|
|
563
694
|
// is worth doing on a dialect with no real VARCHAR: it is what lets one
|
|
564
695
|
// schema round-trip on all three.
|
|
565
696
|
const declared = String(col.type || '')
|
|
@@ -587,7 +718,7 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
587
718
|
}
|
|
588
719
|
|
|
589
720
|
// `protected override`, not `private`: narrowing a base member's visibility
|
|
590
|
-
// is TS2415, and it made SQLiteAdapter fail to satisfy SQLAdapter
|
|
721
|
+
// is TS2415, and it made SQLiteAdapter fail to satisfy SQLAdapter, so the
|
|
591
722
|
// one adapter with real test coverage did not typecheck against the contract
|
|
592
723
|
// the two untested ones share.
|
|
593
724
|
protected override parseDefault(def: any): any {
|
|
@@ -600,7 +731,7 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
600
731
|
// sqlite_master hands back the literal exactly as formatDefault wrote it,
|
|
601
732
|
// so the quote it doubled per SQL rules is still doubled. Stripping the
|
|
602
733
|
// delimiters without collapsing it leaves `it''s fine` where the schema
|
|
603
|
-
// says `it's fine
|
|
734
|
+
// says `it's fine`: drift diffColumnMismatch can never resolve, and a
|
|
604
735
|
// table rebuild on every db:sync as a result.
|
|
605
736
|
return unquoted.replaceAll(quote + quote, quote)
|
|
606
737
|
}
|
package/src/adapters.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { registerAdapter, resolveAdapter } from './adapters/registry'
|
|
|
6
6
|
/**
|
|
7
7
|
* Does this target name a SQLite file rather than a server?
|
|
8
8
|
*
|
|
9
|
-
* Deliberately greedy
|
|
9
|
+
* Deliberately greedy (anything with a path separator lands here), because it
|
|
10
10
|
* is consulted only *after* every registered scheme has had its say. Before
|
|
11
11
|
* the registry that ordering was implicit and this heuristic ran second by
|
|
12
12
|
* luck; now `protocols` is checked first by contract, so an adapter that
|
|
@@ -27,7 +27,7 @@ function isSQLite(val: string) {
|
|
|
27
27
|
/**
|
|
28
28
|
* The three built-in adapters, registered at module load.
|
|
29
29
|
*
|
|
30
|
-
* Registering is cheap
|
|
30
|
+
* Registering is cheap (three object literals), because `open` is what pulls
|
|
31
31
|
* the driver in. An app on SQLite never loads `mysql.ts` or `pgsql.ts`, which
|
|
32
32
|
* was true of the `switch` this replaced and had to stay true of the registry.
|
|
33
33
|
*/
|
|
@@ -64,8 +64,8 @@ registerAdapter({
|
|
|
64
64
|
export async function createDbAdapter() {
|
|
65
65
|
const url = process.env.DB_URL || process.env.DATABASE_URL || ''
|
|
66
66
|
const { poolOptionsFromEnv } = await import('./pool')
|
|
67
|
-
// Read once and handed to every adapter. SQLite ignores it
|
|
68
|
-
// handle has no pool to size
|
|
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
69
|
// so the option goes to all of them and each takes what it needs.
|
|
70
70
|
const pool = poolOptionsFromEnv()
|
|
71
71
|
return await resolveAdapter(url).open(url || undefined, pool)
|
package/src/backup.ts
CHANGED
|
@@ -12,7 +12,7 @@ const MESSAGES = messageLogger(new Logger('db-backup'), {
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Returns whether a backup file was actually written. Callers about to run a
|
|
15
|
-
* destructive migration must check this
|
|
15
|
+
* destructive migration must check this: a thrown error, a `:memory:` database,
|
|
16
16
|
* or a missing `pg_dump`/`mysqldump` all previously looked identical to success.
|
|
17
17
|
*/
|
|
18
18
|
export async function backupDatabase(adapter?: any): Promise<boolean> {
|
package/src/connection.ts
CHANGED
|
@@ -8,7 +8,7 @@ let testDb: any = null
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Test seam. Unit tests that only need a stub adapter should call this instead
|
|
11
|
-
* of `mock.module('./connection', …)
|
|
11
|
+
* of `mock.module('./connection', …)`: Bun's module mocks are process-global
|
|
12
12
|
* and are never restored, so one mocking test file silently breaks every later
|
|
13
13
|
* test that needs a real connection.
|
|
14
14
|
*/
|
package/src/define.ts
CHANGED
|
@@ -8,15 +8,15 @@ import type {
|
|
|
8
8
|
* Prototype: tables as values.
|
|
9
9
|
*
|
|
10
10
|
* Today a schema is one `constraints` object plus a hand-written `DBSchema`
|
|
11
|
-
* type, and everything that references a table
|
|
12
|
-
* columns
|
|
11
|
+
* type, and everything that references a table (indexes, foreign keys, query
|
|
12
|
+
* columns) does so by string. Typos surface at sync time, or not at all.
|
|
13
13
|
*
|
|
14
14
|
* Here a table is a value carrying its own name and columns, so `indexes.ts`
|
|
15
15
|
* and `foreign.ts` can import it, rename-symbol works across files, and an
|
|
16
16
|
* identifier reaching SQL is one the framework constructed rather than one a
|
|
17
17
|
* caller typed.
|
|
18
18
|
*
|
|
19
|
-
* The column descriptors are unchanged
|
|
19
|
+
* The column descriptors are unchanged: the `Field` builders and the
|
|
20
20
|
* `ExtractTableTypes` mapping are reused as-is, so this is a restructuring of
|
|
21
21
|
* how tables are *declared*, not of how their types are computed. That is what
|
|
22
22
|
* keeps `DB.table('users')` autocompleting exactly as it does now: the derived
|
|
@@ -31,7 +31,7 @@ export type ColumnMap = Record<string, unknown>
|
|
|
31
31
|
* from, and the columns themselves.
|
|
32
32
|
*
|
|
33
33
|
* **Was `TableDef`, and the rename is the point.** `schema-util.ts` exports a
|
|
34
|
-
* `TableDef<T, N, O>` describing a *column
|
|
34
|
+
* `TableDef<T, N, O>` describing a *column*, and that is the one every
|
|
35
35
|
* internal call site, every test and every schema file means. Both were public,
|
|
36
36
|
* and the root barrel re-exported *this* one, so `import type { TableDef } from
|
|
37
37
|
* '@bakery-framework/orm'` handed you a table where you asked for a column. Nothing
|
|
@@ -43,7 +43,7 @@ export interface TableRef<
|
|
|
43
43
|
N extends string = string,
|
|
44
44
|
C extends ColumnMap = ColumnMap,
|
|
45
45
|
> {
|
|
46
|
-
/** The name columns qualify against
|
|
46
|
+
/** The name columns qualify against: the alias, when aliased. */
|
|
47
47
|
readonly __table: N
|
|
48
48
|
/** The real table to read FROM. Differs from `__table` only for an alias. */
|
|
49
49
|
readonly __source: string
|
|
@@ -52,7 +52,7 @@ export interface TableRef<
|
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* A reference to one column, carrying its table. This is the value that
|
|
55
|
-
* replaces the `'users.id'` string
|
|
55
|
+
* replaces the `'users.id'` string: `qId` receives structured data rather
|
|
56
56
|
* than text that has to be validated before it can be trusted.
|
|
57
57
|
*/
|
|
58
58
|
export interface TableColumn<
|
|
@@ -84,7 +84,7 @@ export function table<N extends string, C extends ColumnMap>(
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
|
-
* Declare a view
|
|
87
|
+
* Declare a view: a stored `SELECT` the database treats as a table.
|
|
88
88
|
*
|
|
89
89
|
* export const activeUsers = view(
|
|
90
90
|
* 'active_users',
|
|
@@ -94,15 +94,15 @@ export function table<N extends string, C extends ColumnMap>(
|
|
|
94
94
|
*
|
|
95
95
|
* The columns are the shape the `SELECT` returns. They are declared rather than
|
|
96
96
|
* inferred because nothing here parses SQL, and they are what gives the view a
|
|
97
|
-
* row type
|
|
97
|
+
* row type: reading from it is typed exactly like reading a table.
|
|
98
98
|
*
|
|
99
99
|
* **Writes are rejected at compile time.** `InferViews` collects these names and
|
|
100
100
|
* `Mutation.Tables` excludes them, so `DB.Insert.into('active_users')` does not
|
|
101
101
|
* typecheck. A view is a `SELECT`; the database would refuse the write anyway,
|
|
102
102
|
* and refusing it earlier is strictly better.
|
|
103
103
|
*
|
|
104
|
-
* `db:sync` emits `CREATE VIEW`, diffs the body as
|
|
105
|
-
* and recreates the view when it changes
|
|
104
|
+
* `db:sync` emits `CREATE VIEW`, diffs the body as normalized text, and drops
|
|
105
|
+
* and recreates the view when it changes: views hold no data, so recreating is
|
|
106
106
|
* free and there is no migration to plan.
|
|
107
107
|
*
|
|
108
108
|
* Previously declarable only in the older `DBInfo` layout, or here by writing
|
|
@@ -122,7 +122,7 @@ export function view<N extends string, C extends ColumnMap>(
|
|
|
122
122
|
* export const activeUsers = view('active_users', users, 'SELECT * FROM users WHERE active = 1')
|
|
123
123
|
*
|
|
124
124
|
* The filtered-view case, which is the common one: the shape is the source
|
|
125
|
-
* table's, so restating it is duplication that nothing checks
|
|
125
|
+
* table's, so restating it is duplication that nothing checks. Declare a
|
|
126
126
|
* column the `SELECT` does not return and you find out at query time.
|
|
127
127
|
*
|
|
128
128
|
* The source is a **value**, not a type argument, and that is forced rather
|
|
@@ -130,7 +130,7 @@ export function view<N extends string, C extends ColumnMap>(
|
|
|
130
130
|
* inferring the *remaining* type parameters as soon as one is supplied
|
|
131
131
|
* explicitly, so `N` would fall back to `string` and the view's name would stop
|
|
132
132
|
* being a literal. `__table` is what `InferConstraints` keys the schema map on,
|
|
133
|
-
* so that name degrading takes `InferViews` with it
|
|
133
|
+
* so that name degrading takes `InferViews` with it, and since
|
|
134
134
|
* `Mutation.Tables` now excludes views, `Exclude<…, string>` is `never` and
|
|
135
135
|
* *every* mutation stops compiling. Passing the table keeps both inferred.
|
|
136
136
|
*
|
|
@@ -158,7 +158,7 @@ export function view<N extends string, C extends ColumnMap>(
|
|
|
158
158
|
*
|
|
159
159
|
* This is what `db:sync --choose=db` writes into `orm/views.ts`, and it is the
|
|
160
160
|
* honest shape for a view: **a view has no column DDL.** `CREATE VIEW x AS
|
|
161
|
-
* SELECT …` declares no types, and the sync engine only ever reads the body
|
|
161
|
+
* SELECT …` declares no types, and the sync engine only ever reads the body.
|
|
162
162
|
* `createView(name, sql)` takes nothing else, and the diff compares the two
|
|
163
163
|
* bodies as text. So a view's columns exist purely to give it a row type, and
|
|
164
164
|
* writing `Field.Varchar(64)` there would imply a width the database neither
|
|
@@ -166,12 +166,12 @@ export function view<N extends string, C extends ColumnMap>(
|
|
|
166
166
|
*
|
|
167
167
|
* **Both type arguments are given, and that is forced.** TypeScript stops
|
|
168
168
|
* inferring the remaining type parameters as soon as one is supplied, so
|
|
169
|
-
* `view<ActiveUsersView>(name, body)` would leave `N` as `string
|
|
169
|
+
* `view<ActiveUsersView>(name, body)` would leave `N` as `string`, and `N` is
|
|
170
170
|
* what `TablesOf` re-keys the schema map on, so the whole map collapses to an
|
|
171
171
|
* index signature and every mutation stops compiling. Naming both keeps it a
|
|
172
172
|
* literal. In generated code the repetition costs nothing.
|
|
173
173
|
*
|
|
174
|
-
* Column references still work
|
|
174
|
+
* Column references still work: `activeUsers.id`: even though the keys are
|
|
175
175
|
* known only to the type. See the implementation.
|
|
176
176
|
*/
|
|
177
177
|
export function view<N extends string, T>(
|
|
@@ -203,8 +203,8 @@ export function view(
|
|
|
203
203
|
/**
|
|
204
204
|
* A row interface, as the descriptor map `ExtractTableTypes` reads.
|
|
205
205
|
*
|
|
206
|
-
* One `{ type: T[K] }` per property
|
|
207
|
-
* `type` carries the row type
|
|
206
|
+
* One `{ type: T[K] }` per property (which is all a descriptor needs now that
|
|
207
|
+
* `type` carries the row type) plus the `_view` marker that makes
|
|
208
208
|
* `ExtractViews` classify it as a view.
|
|
209
209
|
*/
|
|
210
210
|
type ViewColumns<T> = { [K in keyof T]-?: { type: T[K] } } & { _view: string }
|
|
@@ -255,7 +255,7 @@ function viewImpl<N extends string, C extends ColumnMap>(
|
|
|
255
255
|
// It has to be in `__columns`'s *declared type* too, not only at runtime:
|
|
256
256
|
// `ExtractViews` is what `InferViews` reads and what `Mutation.Tables`
|
|
257
257
|
// excludes, so erasing `_view` from the type left writes to a view
|
|
258
|
-
// compiling
|
|
258
|
+
// compiling: the exact thing declaring one is supposed to prevent.
|
|
259
259
|
// `ExtractTableTypes` filters the key out of the row type separately.
|
|
260
260
|
return Object.assign(table(name, columns), {
|
|
261
261
|
__columns: { _view: body, ...columns },
|
|
@@ -308,7 +308,7 @@ export type InferConstraints<M> = {
|
|
|
308
308
|
: never
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
-
/** Row types
|
|
311
|
+
/** Row types: the derived replacement for a hand-written `DBSchema`. */
|
|
312
312
|
export type InferSchema<M> = {
|
|
313
313
|
[N in keyof InferConstraints<M>]: ExtractTableTypes<InferConstraints<M>, N>
|
|
314
314
|
}
|
|
@@ -332,7 +332,7 @@ export function collectConstraints(module: Record<string, unknown>) {
|
|
|
332
332
|
|
|
333
333
|
// Skip aliases. An alias is a query-time view of an existing table, and
|
|
334
334
|
// collecting one would tell the sync engine to CREATE a table named after
|
|
335
|
-
// it
|
|
335
|
+
// it, so `alias(users, 'author')` would try to build an `author` table.
|
|
336
336
|
if (def.__source !== def.__table) continue
|
|
337
337
|
|
|
338
338
|
constraints[def.__table] = def.__columns
|
|
@@ -347,8 +347,8 @@ export function collectConstraints(module: Record<string, unknown>) {
|
|
|
347
347
|
* export type ActiveUsersView = RowOf<typeof activeUsers>
|
|
348
348
|
* // ^ { id: number; name: string }
|
|
349
349
|
*
|
|
350
|
-
* TypeScript cannot mint a *named* interface from a value
|
|
351
|
-
* written somewhere
|
|
350
|
+
* TypeScript cannot mint a *named* interface from a value (a name has to be
|
|
351
|
+
* written somewhere), so this is the one line that does it, and it stays
|
|
352
352
|
* correct when the declaration changes because it is derived rather than
|
|
353
353
|
* copied. A hand-written `interface ActiveUsersView` would be a second source
|
|
354
354
|
* of truth that nothing checks against the first.
|