@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 +2 -2
- package/src/adapters/base.ts +113 -2
- package/src/adapters/missing-database.ts +42 -0
- package/src/adapters/mysql.ts +10 -0
- package/src/adapters/pgsql.ts +10 -0
- package/src/adapters/sqlite.ts +8 -1
- package/src/adapters.ts +1 -0
- package/src/orm/query.ts +2 -4
- package/src/schema-util.ts +8 -1
- package/src/sync/engine.ts +1 -0
- package/src/sync/index.ts +67 -2
- package/src/sync/load.ts +10 -0
- package/src/sync/plan.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakery-framework/orm",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
57
|
+
"@bakery-framework/core": "^2.1.1"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/adapters/base.ts
CHANGED
|
@@ -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
|
-
|
|
280
|
-
|
|
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
|
+
}
|
package/src/adapters/mysql.ts
CHANGED
|
@@ -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[]> {
|
package/src/adapters/pgsql.ts
CHANGED
|
@@ -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[]> {
|
package/src/adapters/sqlite.ts
CHANGED
|
@@ -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
|
|
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
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
|
-
|
|
1083
|
-
|
|
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)) {
|
package/src/schema-util.ts
CHANGED
|
@@ -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
|
package/src/sync/engine.ts
CHANGED
|
@@ -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 {
|
|
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(', '),
|