@bakery-framework/orm 2.0.4 → 2.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/orm",
3
- "version": "2.0.4",
3
+ "version": "2.1.1",
4
4
  "description": "Bakery database layer: adapters, query builder, schema sync and backup.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -54,6 +54,6 @@
54
54
  "bun": ">=1.4.0"
55
55
  },
56
56
  "dependencies": {
57
- "@bakery-framework/core": "^2.0.4"
57
+ "@bakery-framework/core": "^2.1.1"
58
58
  }
59
59
  }
@@ -4,6 +4,7 @@ import type { MapOf } from '@bakery-framework/core/types'
4
4
  import { Case, Try } from '@bakery-framework/core/utils'
5
5
  import { throws } from '@bakery-framework/core/utils/common'
6
6
  import type * as SyncTypes from '../sync/types'
7
+ import { DatabaseMissingError, isMissingDatabase } from './missing-database'
7
8
  import { observe, observeIterate } from './observe'
8
9
  import type { Driver as RegisteredDriver } from './registry'
9
10
 
@@ -181,6 +182,12 @@ export function isOpenConnection(value: unknown): boolean {
181
182
  )
182
183
  }
183
184
 
185
+ /** A database name as a URL path carries it, with the percent-encoding undone. */
186
+ function decodedDatabaseName(raw: string | undefined): string | undefined {
187
+ if (!raw) return undefined
188
+ return Try.return(() => decodeURIComponent(raw), raw)
189
+ }
190
+
184
191
  export function quoteIdentifier(name: string, quoteChar: string): string {
185
192
  // The `includes` guard is not redundant: `replaceAll` walks and rebuilds the
186
193
  // string even when there is nothing to replace, and an identifier containing
@@ -275,16 +282,40 @@ export function pagedIterate(
275
282
  }
276
283
  }
277
284
 
285
+ /**
286
+ * `fn`, with whatever it throws passed through `explain` first.
287
+ */
288
+ function explained<A extends unknown[], R>(
289
+ fn: (...args: A) => R | Promise<R>,
290
+ explain: (error: unknown) => unknown,
291
+ ): (...args: A) => Promise<R> {
292
+ return async (...args: A) => {
293
+ try {
294
+ return await fn(...args)
295
+ } catch (error) {
296
+ throw explain(error)
297
+ }
298
+ }
299
+ }
300
+
278
301
  export function createExecutor(
279
- all: SQLAdapter.Executor['all'],
280
- run: SQLAdapter.Executor['run'],
302
+ rawAll: SQLAdapter.Executor['all'],
303
+ rawRun: SQLAdapter.Executor['run'],
281
304
  driver: SQLAdapter.Driver,
282
305
  options: {
283
306
  /** Replace the paging walker: for a driver that can genuinely stream. */
284
307
  iterate?: SQLAdapter.Executor['iterate']
285
308
  chunkSize?: number
309
+ /**
310
+ * Turn a driver error into one that says what to do, before any caller
311
+ * sees it. Applied to `all` and `run` themselves, so `get`, `values` and
312
+ * the paging `iterate` carry it too. See `SQLAdapter.explainError`.
313
+ */
314
+ explain?: (error: unknown) => unknown
286
315
  } = {},
287
316
  ): SQLAdapter.Executor {
317
+ const all = options.explain ? explained(rawAll, options.explain) : rawAll
318
+ const run = options.explain ? explained(rawRun, options.explain) : rawRun
288
319
  const iterate = options.iterate ?? pagedIterate(all, options.chunkSize)
289
320
  // `get` and `values` call the raw `all` rather than `exec.all`, which is a
290
321
  // behavioral detail worth stating: it is what keeps one executed statement
@@ -345,6 +376,71 @@ export abstract class SQLAdapter {
345
376
  }, undefined)
346
377
  }
347
378
 
379
+ /**
380
+ * The server half of the connection URL, `host:port`, for messages. Never
381
+ * the URL itself, which carries the password.
382
+ */
383
+ get serverName(): string | undefined {
384
+ if (!this.url) return undefined
385
+ return Try.return(() => new URL(this.url!).host || undefined, undefined)
386
+ }
387
+
388
+ /**
389
+ * The error a query raised, or a {@link DatabaseMissingError} when it means
390
+ * the server has no database by this connection's name. Passed to
391
+ * `createExecutor` by the two server adapters; SQLite creates its file on
392
+ * open and has no such error to explain.
393
+ */
394
+ protected explainError(error: unknown): unknown {
395
+ if (!isMissingDatabase(error)) return error
396
+ const database = decodedDatabaseName(this.databaseName)
397
+ const server = this.serverName
398
+ if (!database || !server) return error
399
+ return new DatabaseMissingError(database, server, { cause: error })
400
+ }
401
+
402
+ /**
403
+ * The same server and credentials, pointed at a database that always
404
+ * exists there: where `CREATE DATABASE` runs. `undefined` for a dialect
405
+ * with no server to ask.
406
+ */
407
+ protected maintenanceUrl(): string | undefined {
408
+ return undefined
409
+ }
410
+
411
+ /** A sibling adapter on another URL, for {@link createDatabase}. */
412
+ protected forUrl(_url: string): SQLAdapter {
413
+ throw new Error(`The ${this.driver} adapter cannot open a second database.`)
414
+ }
415
+
416
+ /**
417
+ * Create the database this connection names, through a second connection
418
+ * to the server's maintenance database, and return its name.
419
+ *
420
+ * Not called by anything that runs on its own: `db:sync` calls it only with
421
+ * `--create-database` or a yes at its prompt, because creating a database
422
+ * needs a privilege an app's runtime role should not hold, and because a
423
+ * typo in `DB_URL` must not quietly become a fresh, empty database.
424
+ */
425
+ async createDatabase(): Promise<string> {
426
+ const database = decodedDatabaseName(this.databaseName)
427
+ const target = this.maintenanceUrl()
428
+ if (!database || !target) {
429
+ throw new Error(
430
+ database
431
+ ? `The ${this.driver} adapter has no server to create a database on.`
432
+ : `DB_URL names no database for the ${this.driver} adapter to create.`,
433
+ )
434
+ }
435
+ const admin = this.forUrl(target)
436
+ try {
437
+ await admin.query(`CREATE DATABASE ${admin.quote(database)}`).run()
438
+ } finally {
439
+ await admin.close()
440
+ }
441
+ return database
442
+ }
443
+
348
444
  /**
349
445
  * Placeholder ceiling for a single statement. See
350
446
  * {@link DEFAULT_MAX_QUERY_PARAMS} for why it is one number and not three.
@@ -357,6 +453,21 @@ export abstract class SQLAdapter {
357
453
  return DEFAULT_MAX_QUERY_PARAMS
358
454
  }
359
455
 
456
+ /**
457
+ * `url` with its database swapped for `name`, and everything else (the
458
+ * credentials, the host, the query string and with it `sslmode`) kept.
459
+ * `undefined` when there is no URL to rewrite. A static for the reason
460
+ * {@link usableTotal} gives.
461
+ */
462
+ static withDatabase(url: string | undefined, name: string): string | undefined {
463
+ if (!url) return undefined
464
+ return Try.return(() => {
465
+ const target = new URL(url)
466
+ target.pathname = `/${encodeURIComponent(name)}`
467
+ return target.toString()
468
+ }, undefined)
469
+ }
470
+
360
471
  /**
361
472
  * Whether a caller-supplied total can be used in place of a `COUNT(*)`.
362
473
  *
@@ -0,0 +1,42 @@
1
+ /**
2
+ * A server that has no database by the name the connection asked for.
3
+ *
4
+ * Recognized by the code the server itself sends, which is stable across
5
+ * driver versions in a way no message is. Measured 2026-10-01 against
6
+ * Postgres 16 and MySQL 8.4:
7
+ *
8
+ * Postgres SQLSTATE 3D000 Bun: errno "3D000" postgres.js: code "3D000"
9
+ * MySQL error 1049 Bun: errno 1049 mysql2: errno 1049
10
+ *
11
+ * postgres.js and mysql2 are not bakery's drivers. They are listed because
12
+ * silid's copy of this ORM runs on them, and one predicate serves both.
13
+ */
14
+ export function isMissingDatabase(error: unknown): boolean {
15
+ const e = error as { errno?: unknown; code?: unknown } | null | undefined
16
+ return e?.errno === '3D000' || e?.code === '3D000' || e?.errno === 1049
17
+ }
18
+
19
+ /**
20
+ * What a query raises when the database `DB_URL` names does not exist, in
21
+ * place of the driver's own error (kept as `cause`).
22
+ *
23
+ * The message names the database, the server (host and port, never the
24
+ * credentials in the URL) and the command that creates it, because the
25
+ * driver's version names only the database, and on a production log that
26
+ * reads as data loss rather than as a typo or a missing step.
27
+ */
28
+ export class DatabaseMissingError extends Error {
29
+ override readonly name = 'DatabaseMissingError'
30
+
31
+ constructor(
32
+ readonly database: string,
33
+ readonly server: string,
34
+ options?: ErrorOptions,
35
+ ) {
36
+ super(
37
+ `Database "${database}" does not exist on ${server}. ` +
38
+ 'Run `bun run db:sync --create-database` to create it, or point DB_URL at one that exists.',
39
+ options,
40
+ )
41
+ }
42
+ }
@@ -56,6 +56,7 @@ export class MySQLAdapter extends SQLAdapter {
56
56
  }
57
57
  },
58
58
  this.driver,
59
+ { explain: error => this.explainError(error) },
59
60
  )
60
61
 
61
62
  async hasCol(table: string, column: string): Promise<boolean> {
@@ -270,6 +271,15 @@ export class MySQLAdapter extends SQLAdapter {
270
271
  return new MySQLAdapter(sql as SQL)
271
272
  }
272
273
 
274
+ /** `mysql`: the system database every MySQL server has, and the one Bun's client falls back to when a URL names none (measured). The query string, and with it `sslmode`, is kept. */
275
+ protected override maintenanceUrl(): string | undefined {
276
+ return SQLAdapter.withDatabase(this.url, 'mysql')
277
+ }
278
+
279
+ protected override forUrl(url: string): SQLAdapter {
280
+ return new MySQLAdapter(url)
281
+ }
282
+
273
283
  async getSchema(
274
284
  options?: SQLAdapter.SchemaOptions,
275
285
  ): Promise<SQLAdapter.TableDetails[]> {
@@ -175,6 +175,7 @@ export class PGAdapter extends SQLAdapter {
175
175
  return { lastInsertRowid, changes }
176
176
  },
177
177
  this.driver,
178
+ { explain: error => this.explainError(error) },
178
179
  )
179
180
 
180
181
  async hasCol(table: string, column: string): Promise<boolean> {
@@ -252,6 +253,15 @@ export class PGAdapter extends SQLAdapter {
252
253
  return new PGAdapter(sql as SQL)
253
254
  }
254
255
 
256
+ /** `postgres`: the database every Postgres cluster is created with. The query string, and with it `sslmode`, is kept. */
257
+ protected override maintenanceUrl(): string | undefined {
258
+ return SQLAdapter.withDatabase(this.url, 'postgres')
259
+ }
260
+
261
+ protected override forUrl(url: string): SQLAdapter {
262
+ return new PGAdapter(url)
263
+ }
264
+
255
265
  async getSchema(
256
266
  options?: SQLAdapter.SchemaOptions,
257
267
  ): Promise<SQLAdapter.TableDetails[]> {
@@ -1,3 +1,4 @@
1
+ import { mkdir } from 'node:fs/promises'
1
2
  import path from 'node:path'
2
3
  import { Bakery } from '@bakery-framework/core/core/bakery'
3
4
  import { Logger, messageLogger } from '@bakery-framework/core/logger'
@@ -381,7 +382,13 @@ export class SQLiteAdapter extends SQLAdapter {
381
382
  base = path.basename(this.filename, ext)
382
383
  const backupDir = `${path.dirname(this.filename)}/backups`,
383
384
  backupName = `${base}.${Date.now()}${ext}`
384
- await Bun.write(`${backupDir}/${backupName}`, Bun.file(this.filename))
385
+ await mkdir(backupDir, { recursive: true })
386
+ // `VACUUM INTO`, not a copy of the file. In WAL mode a commit sits in
387
+ // `-wal` until a checkpoint, and a copy of the main file alone came out
388
+ // with no table in it at all after a CREATE and 50 inserts (see
389
+ // `sqlite-backup.test.ts`). SQLite writes this from a read transaction,
390
+ // so it is consistent and holds everything committed.
391
+ await this.query('VACUUM INTO ?').run(`${backupDir}/${backupName}`)
385
392
  return {
386
393
  file: backupName,
387
394
  cleanupCount: await this.cleanupBackups(backupDir, base, ext, keepCount),
package/src/adapters.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './adapters/base'
2
+ export * from './adapters/missing-database'
2
3
  export * from './adapters/registry'
3
4
 
4
5
  import { registerAdapter, resolveAdapter } from './adapters/registry'
package/src/orm/query.ts CHANGED
@@ -1079,10 +1079,8 @@ export namespace DB {
1079
1079
  return left
1080
1080
  }
1081
1081
  const op = operator.toUpperCase()
1082
- if (op === 'IS NULL' || op === 'IS NOT NULL') {
1083
- return `${left} ${op}`
1084
- }
1085
- // One rule, three callers. See `nullComparison`.
1082
+ // One rule, three callers, `IS NULL` and `IS NOT NULL` included. See
1083
+ // `nullComparison`.
1086
1084
  const nullOp = nullComparison(op, rightArg, isRightColumn)
1087
1085
  if (nullOp) return `${left} ${nullOp}`
1088
1086
  if (op === 'BETWEEN' && Array.isArray(rightArg)) {
@@ -484,14 +484,21 @@ export function old(
484
484
  * function three callers share cannot drift a third time.
485
485
  *
486
486
  * A column reference on the right is a different comparison and is left alone.
487
+ *
488
+ * `IS NULL` and `IS NOT NULL` themselves (`DB.isNull()`, `DB.isNotNull()`)
489
+ * are the third drift, and the reason they are answered here first:
490
+ * `formatClause` handled them before calling this, the two `evalWhere` copies
491
+ * did not, and every `UPDATE` or `DELETE` through either helper emitted
492
+ * `IS NULL NULL`, which no dialect parses.
487
493
  */
488
494
  export function nullComparison(
489
495
  operator: string,
490
496
  right: unknown,
491
497
  isRightColumn?: boolean,
492
498
  ): string | null {
493
- if (right !== null || isRightColumn) return null
494
499
  const op = operator.toUpperCase()
500
+ if (op === 'IS NULL' || op === 'IS NOT NULL') return op
501
+ if (right !== null || isRightColumn) return null
495
502
  if (op === '=') return 'IS NULL'
496
503
  if (op === '!=' || op === '<>') return 'IS NOT NULL'
497
504
  return null
@@ -47,6 +47,7 @@ export const syncMsgs = {
47
47
  DANGER_ZONE: 'W %rDANGER ZONE: Destructive or major changes detected!%*',
48
48
  DROP_TABLES: 'W Tables to drop: %r{tables}%*',
49
49
  RENAME_TABLES: 'I Tables to rename: %y{tables}%*',
50
+ CREATE_TABLES: 'I Tables to create: %g{tables}%*',
50
51
  DROP_COLS: 'W Columns to drop: %r{cols}%*',
51
52
  RENAME_COLS: 'I Columns to rename: %y{cols}%*',
52
53
  ADD_COLS: 'I Columns to add: %g{cols}%*',
package/src/sync/index.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import '@bakery-framework/core/core/init'
2
2
 
3
- import { Logger, messageLogger } from '@bakery-framework/core/logger'
3
+ import {
4
+ isInteractive,
5
+ Logger,
6
+ messageLogger,
7
+ } from '@bakery-framework/core/logger'
8
+ import { Try } from '@bakery-framework/core/utils'
9
+ import type { SQLAdapter } from '../adapters/base'
10
+ import { DatabaseMissingError } from '../adapters/missing-database'
4
11
  import { closeDB, connection, initDB } from '../connection'
5
12
  import { loadSchema, schemaFromConfig } from './load'
6
13
 
@@ -18,6 +25,9 @@ const syncMsgs = {
18
25
  MIGRATE_SCAFFOLDED: 'I Created %y{dir}%*: the generator owns tables.ts.',
19
26
  MIGRATE_RETIRED:
20
27
  'I Converted to the orm/ folder. The previous %yschema.ts%* was moved to %y{to}%*, not deleted.',
28
+ DB_MISSING:
29
+ 'E %rDatabase %y{name}%r does not exist on {server}%*. Run %ybun run db:sync --create-database%* to create it, or point DB_URL at one that exists.',
30
+ DB_CREATED: 'I Created database %g{name}%* on %y{server}%*.',
21
31
  } as const
22
32
 
23
33
  const MESSAGES = messageLogger(logger, syncMsgs)
@@ -85,7 +95,7 @@ export class SyncService {
85
95
  // CLI usage text goes to stdout verbatim: it is program output, not a
86
96
  // log line, so it deliberately bypasses the structured logger.
87
97
  console.log(`
88
- Usage: bun run db:sync [--migrate] [--choose=db|ts] [--dry-run] [--force-sync] [--help]
98
+ Usage: bun run db:sync [--migrate] [--choose=db|ts] [--dry-run] [--force-sync] [--create-database] [--help]
89
99
 
90
100
  Flags:
91
101
  --migrate Adopt an existing database: write the schema from what is
@@ -96,11 +106,59 @@ Flags:
96
106
  --choose=ts Apply schema.ts to the database (TS wins, default)
97
107
  --dry-run Preview planned changes without applying them
98
108
  --force-sync In production, allow destructive changes
109
+ --create-database
110
+ Create the database DB_URL names when the server does
111
+ not have it. Asked in a terminal, needed without one.
99
112
  --no-ledger Diff against live introspection, ignoring the recorded schema
100
113
  --help, -h Show this help message
101
114
  `)
102
115
  }
103
116
 
117
+ /** `--create-database`: make the database `DB_URL` names if it is missing. */
118
+ static createRequested(argv: string[] = process.argv.slice(2)): boolean {
119
+ return argv.includes('--create-database')
120
+ }
121
+
122
+ /**
123
+ * Make sure the database the connection names exists, creating it when
124
+ * that was asked for.
125
+ *
126
+ * `present` and `created` mean the sync can go on; `missing` means it
127
+ * cannot, and `DB_MISSING` has said why. Creating needs `--create-database`
128
+ * or a yes at the prompt, and the prompt is only asked with a terminal: an
129
+ * unattended run (CI, a deploy step, a container) never creates one on its
130
+ * own, since a typo in `DB_URL` would otherwise become a fresh, empty
131
+ * database with the real one untouched beside it.
132
+ *
133
+ * Any other connection failure is thrown as before. SQLite answers
134
+ * `present`, having created its file on open.
135
+ */
136
+ static async ensureDatabase(
137
+ adapter: SQLAdapter,
138
+ {
139
+ argv = process.argv.slice(2),
140
+ interactive = isInteractive(),
141
+ }: { argv?: string[]; interactive?: boolean } = {},
142
+ ): Promise<'present' | 'created' | 'missing'> {
143
+ const [error] = await Try.catch(adapter.query('SELECT 1').get())
144
+ if (!error) return 'present'
145
+ if (!(error instanceof DatabaseMissingError)) throw error
146
+
147
+ const asked =
148
+ SyncService.createRequested(argv) ||
149
+ (interactive &&
150
+ logger.confirm(
151
+ `Database "${error.database}" does not exist on ${error.server}. Create it?`,
152
+ ))
153
+ if (!asked) {
154
+ MESSAGES.DB_MISSING({ name: error.database, server: error.server })
155
+ return 'missing'
156
+ }
157
+ await adapter.createDatabase()
158
+ MESSAGES.DB_CREATED({ name: error.database, server: error.server })
159
+ return 'created'
160
+ }
161
+
104
162
  /** `--migrate`: adopt what is already in the database. */
105
163
  static migrateRequested(argv: string[] = process.argv.slice(2)): boolean {
106
164
  return argv.includes('--migrate')
@@ -192,6 +250,13 @@ Flags:
192
250
  return process.exit(1)
193
251
  }
194
252
 
253
+ // After the schema checks, so a config error never leaves a new, empty
254
+ // database behind, and before anything else asks the database a question.
255
+ if ((await SyncService.ensureDatabase(connection)) === 'missing') {
256
+ await closeDB()
257
+ return process.exit(1)
258
+ }
259
+
195
260
  // `foreign()` used to abort here, because no adapter emitted FOREIGN KEY
196
261
  // DDL and the declaration would have become a plain index: referential
197
262
  // integrity in appearance only. All three adapters now emit and read back
package/src/sync/load.ts CHANGED
@@ -29,11 +29,18 @@
29
29
  * somewhere else.
30
30
  */
31
31
 
32
+ import { Logger, messageLogger } from '@bakery-framework/core/logger'
32
33
  import { Case, fs } from '@bakery-framework/core/utils'
33
34
  import { Try } from '@bakery-framework/core/utils/common'
34
35
  import { collectConstraints } from '../define'
35
36
  import type * as SyncTypes from './types'
36
37
 
38
+ // Convention 4: logging is data, declared in a table.
39
+ const MESSAGES = messageLogger(new Logger('db-sync'), {
40
+ SCHEMA_IMPORT_FAILED:
41
+ 'W %y{file}%* could not be loaded: %r{error}%*. Sync is treating the schema as absent.',
42
+ } as const)
43
+
37
44
  /**
38
45
  * Which shape the generator must write back.
39
46
  *
@@ -147,6 +154,9 @@ async function readSchema(
147
154
  targetPath: string,
148
155
  ): Promise<LoadedSchema> {
149
156
  const [error, module] = await importFresh(entry)
157
+ // Still read as no schema, which `loadSchema` relies on for a broken folder
158
+ // entry, but no longer in silence: this is the only place the cause exists.
159
+ if (error) MESSAGES.SCHEMA_IMPORT_FAILED({ file: entry, error: error.message })
150
160
  if (error || !module) return emptyAt(targetPath)
151
161
 
152
162
  if (layout === 'folder') {
package/src/sync/plan.ts CHANGED
@@ -118,6 +118,15 @@ export function logPlannedChanges(
118
118
  .map(t => `${t.oldName} -> ${t.newName}`)
119
119
  .join(', '),
120
120
  })
121
+ // Counted as a change in `evaluateChanges` from the start and never
122
+ // printed, so a dry run on a fresh database listed its indexes and none of
123
+ // its tables. The tables are created by the last phase of the run, which
124
+ // emits CREATE TABLE IF NOT EXISTS for every table; this set is the ones
125
+ // that phase will actually create.
126
+ if (plan.unmappedTsTables.size)
127
+ MESSAGES.CREATE_TABLES({
128
+ tables: Array.from(plan.unmappedTsTables, t => Case.snake(t)).join(', '),
129
+ })
121
130
  if (plan.columnsToDrop.length)
122
131
  MESSAGES.DROP_COLS({
123
132
  cols: plan.columnsToDrop.map(c => `${c.table}.${c.column}`).join(', '),