@bakery-framework/orm 2.1.2 → 2.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/orm",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "Bakery database layer: adapters, query builder, schema sync and backup.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -36,13 +36,15 @@
36
36
  "./sync/ledger": "./src/sync/ledger.ts",
37
37
  "./sync/history": "./src/sync/history.ts",
38
38
  "./sync/rollback": "./src/sync/rollback.ts",
39
+ "./migrate": "./src/migrate/index.ts",
39
40
  "./templates/*": "./templates/*",
40
41
  "./package.json": "./package.json",
41
42
  "./schema-registry": "./src/schema-registry.ts",
42
43
  "./schema-util": "./src/schema-util.ts",
43
44
  "./connection": "./src/connection.ts",
44
45
  "./pool": "./src/pool.ts",
45
- "./backup": "./src/backup.ts"
46
+ "./backup": "./src/backup.ts",
47
+ "./sessions": "./src/sessions.ts"
46
48
  },
47
49
  "files": [
48
50
  "src",
@@ -54,6 +56,6 @@
54
56
  "bun": ">=1.4.0"
55
57
  },
56
58
  "dependencies": {
57
- "@bakery-framework/core": "^2.1.2"
59
+ "@bakery-framework/core": "^2.2.0"
58
60
  }
59
61
  }
@@ -3,6 +3,8 @@ import { Logger } from '@bakery-framework/core/logger'
3
3
  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
+ import { isArrayValue } from '../array-value'
7
+ import type { ScriptDialect } from '../migrate/script'
6
8
  import type * as SyncTypes from '../sync/types'
7
9
  import { DatabaseMissingError, isMissingDatabase } from './missing-database'
8
10
  import { observe, observeIterate } from './observe'
@@ -20,6 +22,27 @@ export namespace SQLAdapter {
20
22
  lastInsertRowid: number | bigint | null
21
23
  changes: number
22
24
  }
25
+ /**
26
+ * A column as the database's catalog describes it, for the check that
27
+ * migrations mode runs in place of a sync (`migrate/check.ts`).
28
+ */
29
+ export interface LiveColumn {
30
+ /** As the database spells it. */
31
+ table: string
32
+ column: string
33
+ /** In the dialect's own spelling, lower-cased: `character varying(64)`, `int`. */
34
+ type: string
35
+ nullable: boolean
36
+ primary: boolean
37
+ /** A view's column: catalogs report every one of them nullable. */
38
+ view: boolean
39
+ /** A native enum's labels, in order, where the dialect has native enums. */
40
+ enumValues?: string[]
41
+ }
42
+ /** The one call `executeScript` makes on a driver handle. */
43
+ export interface ScriptHandle {
44
+ unsafe(text: string): Promise<unknown>
45
+ }
23
46
  export interface BackupResult {
24
47
  file: string
25
48
  cleanupCount?: number
@@ -298,6 +321,22 @@ function explained<A extends unknown[], R>(
298
321
  }
299
322
  }
300
323
 
324
+ /**
325
+ * The default `bind`: parameters as they are, refusing a `DB.array()` value,
326
+ * which only the Postgres adapter can encode. Bound as an object it would
327
+ * reach the driver as `[object Object]` or a driver error naming nothing.
328
+ */
329
+ function refuseArrays(driver: SQLAdapter.Driver) {
330
+ return (params: unknown[]): unknown[] => {
331
+ if (params.some(isArrayValue)) {
332
+ throw new Error(
333
+ `DB.array() writes a Postgres array, and the ${driver} adapter has no array type. Store the values as JSON (Field.Json) instead.`,
334
+ )
335
+ }
336
+ return params
337
+ }
338
+ }
339
+
301
340
  export function createExecutor(
302
341
  rawAll: SQLAdapter.Executor['all'],
303
342
  rawRun: SQLAdapter.Executor['run'],
@@ -312,10 +351,28 @@ export function createExecutor(
312
351
  * the paging `iterate` carry it too. See `SQLAdapter.explainError`.
313
352
  */
314
353
  explain?: (error: unknown) => unknown
354
+ /**
355
+ * Turn the query's parameters into what the driver binds. Postgres
356
+ * encodes a `DB.array()` value as an array literal here; the default
357
+ * refuses one, since only Postgres has arrays.
358
+ */
359
+ bind?: (params: unknown[]) => unknown[]
315
360
  } = {},
316
361
  ): SQLAdapter.Executor {
317
- const all = options.explain ? explained(rawAll, options.explain) : rawAll
318
- const run = options.explain ? explained(rawRun, options.explain) : rawRun
362
+ const bind = options.bind ?? refuseArrays(driver)
363
+ const bound = <F extends SQLAdapter.Executor['all'] | SQLAdapter.Executor['run']>(
364
+ fn: F,
365
+ ): F =>
366
+ (async (sqlText: string, params: unknown[] = []) =>
367
+ await fn(sqlText, bind(params))) as F
368
+ const explainedAll = options.explain
369
+ ? explained(rawAll, options.explain)
370
+ : rawAll
371
+ const explainedRun = options.explain
372
+ ? explained(rawRun, options.explain)
373
+ : rawRun
374
+ const all = bound(explainedAll)
375
+ const run = bound(explainedRun)
319
376
  const iterate = options.iterate ?? pagedIterate(all, options.chunkSize)
320
377
  // `get` and `values` call the raw `all` rather than `exec.all`, which is a
321
378
  // behavioral detail worth stating: it is what keeps one executed statement
@@ -459,7 +516,10 @@ export abstract class SQLAdapter {
459
516
  * `undefined` when there is no URL to rewrite. A static for the reason
460
517
  * {@link usableTotal} gives.
461
518
  */
462
- static withDatabase(url: string | undefined, name: string): string | undefined {
519
+ static withDatabase(
520
+ url: string | undefined,
521
+ name: string,
522
+ ): string | undefined {
463
523
  if (!url) return undefined
464
524
  return Try.return(() => {
465
525
  const target = new URL(url)
@@ -572,6 +632,76 @@ export abstract class SQLAdapter {
572
632
  ? await handle.savepoint(run)
573
633
  : await handle.transaction(run)
574
634
  }
635
+
636
+ /**
637
+ * Whether DDL runs inside a transaction here and is undone by a rollback.
638
+ * True on Postgres and SQLite; MySQL commits each DDL statement as it runs,
639
+ * so a migration that fails halfway there leaves its first half applied.
640
+ */
641
+ get transactionalDDL(): boolean {
642
+ return true
643
+ }
644
+
645
+ /**
646
+ * How this dialect's SQL text is lexed, for reading a migration file without
647
+ * running it (`migrate/script.ts`): standard quotes and comments, plus
648
+ * Postgres's dollar quotes or MySQL's backticks and `#` comments.
649
+ */
650
+ get scriptDialect(): ScriptDialect {
651
+ return 'sqlite'
652
+ }
653
+
654
+ /**
655
+ * Run a migration file's text as written: every statement in it, with no
656
+ * parameters and no rewriting.
657
+ *
658
+ * Not `query().run()`, which is built for one statement with placeholders:
659
+ * the Postgres executor rewrites `?` and backticks (it does not know about
660
+ * comments or `$$` bodies, and measured, it turned a backtick inside a quoted
661
+ * default into a double quote after a `-- don't` comment), and appends
662
+ * `RETURNING *` to anything starting with INSERT. With no parameters, Bun
663
+ * sends the text over the simple protocol, which takes several statements
664
+ * at once on all three dialects.
665
+ */
666
+ async executeScript(text: string): Promise<void> {
667
+ await (this.sql as SQLAdapter.ScriptHandle).unsafe(text)
668
+ }
669
+
670
+ /**
671
+ * Take the lock that keeps two migration runs from applying the same file,
672
+ * and return what releases it. A no-op here: SQLite serializes writers on
673
+ * its own. Held for the session, so the runner's connection pool has to be
674
+ * a single connection.
675
+ */
676
+ async takeMigrationLock(): Promise<() => Promise<void>> {
677
+ return async () => {}
678
+ }
679
+
680
+ /**
681
+ * Every column of every table and view in this connection's schema, read
682
+ * from the catalog alone, for the migrations-mode check. Catalog alone,
683
+ * because the check runs as the app's role, which may hold no privilege on
684
+ * some tables at all: on Postgres `information_schema` hid such a table
685
+ * entirely, and hid the primary key of a table the role could only read
686
+ * (measured), while `pg_catalog` showed both.
687
+ */
688
+ async liveColumns(): Promise<SQLAdapter.LiveColumn[]> {
689
+ throw new Error(
690
+ `The ${this.driver} adapter cannot read its catalog for the migrations check.`,
691
+ )
692
+ }
693
+
694
+ /**
695
+ * Why a live column's type does not match its declaration, or `null` when
696
+ * it does. Each adapter states its own dialect's rules: which spellings a
697
+ * `Field` accepts, and how a `Field.Sql` type compares.
698
+ */
699
+ columnTypeProblem(
700
+ _declared: SyncTypes.ColumnConstraint,
701
+ _live: SQLAdapter.LiveColumn,
702
+ ): string | null {
703
+ return null
704
+ }
575
705
  protected abstract parseConstraints(
576
706
  col: unknown,
577
707
  ...params: unknown[]
@@ -1097,8 +1227,11 @@ export abstract class SQLAdapter {
1097
1227
  * `axb`: `%` and `_` are the wildcards, and the filter passed user input
1098
1228
  * through untouched. The escape character itself goes first, or escaping it
1099
1229
  * afterwards would double the ones this method just added.
1230
+ *
1231
+ * Public for SQL written outside the adapter (the session store's search),
1232
+ * which pairs it with {@link likeEscapeClause}: the two are one decision.
1100
1233
  */
1101
- protected escapeLike(value: string): string {
1234
+ escapeLike(value: string): string {
1102
1235
  const e = this.likeEscape
1103
1236
  return value
1104
1237
  .split(e)
@@ -0,0 +1,308 @@
1
+ import type * as SyncTypes from '../sync/types'
2
+ import type { SQLAdapter } from './base'
3
+
4
+ /**
5
+ * Which live column types a declaration accepts, per dialect, for the check
6
+ * migrations mode runs instead of a sync (`migrate/check.ts`).
7
+ *
8
+ * The question each rule answers is "would a query typed by this declaration
9
+ * be right about what this column holds", which is looser than "is this the
10
+ * type classic sync would have created": a declared `Field.Int()` is right
11
+ * about a `smallint`, and a declared `Field.String()` about any `varchar`. A
12
+ * width or a precision the declaration states is held to exactly; one it
13
+ * leaves out is not asked about, the way an unsized `Field.String()` never
14
+ * asked for a column to shrink.
15
+ *
16
+ * Pure functions of the declaration and the catalog's description, so the
17
+ * rules are tested without a server.
18
+ */
19
+
20
+ type Declared = SyncTypes.ColumnConstraint
21
+ type Live = SQLAdapter.LiveColumn
22
+
23
+ /** `declared X, the database has Y`. */
24
+ function mismatch(expected: string, live: Live): string {
25
+ return `declared ${expected}, the database has ${live.type}`
26
+ }
27
+
28
+ /**
29
+ * A native enum whose labels differ from the declared members. Text with a
30
+ * CHECK is accepted unread: no catalog spells a CHECK's expression in a form
31
+ * worth parsing here.
32
+ */
33
+ function enumProblem(declared: Declared, live: Live): string | null {
34
+ if (!declared._enum || !live.enumValues) return null
35
+ const want = [...declared._enum].sort().join(', ')
36
+ const have = [...live.enumValues].sort().join(', ')
37
+ return want === have
38
+ ? null
39
+ : `declared the values ${want}, the database's enum has ${have}`
40
+ }
41
+
42
+ /** The base name and modifier of a type: `numeric(5,2)` is `numeric` and `(5,2)`. */
43
+ function splitModifier(type: string): { base: string; modifier: string } {
44
+ const open = type.indexOf('(')
45
+ if (open === -1) return { base: type, modifier: '' }
46
+ const close = type.indexOf(')', open)
47
+ const modifier = type.slice(open, close + 1).replace(/\s+/g, '')
48
+ // Postgres puts a timestamp's precision in the middle:
49
+ // `timestamp(3) with time zone`.
50
+ const base = `${type.slice(0, open)}${type.slice(close + 1)}`
51
+ .replace(/\s+/g, ' ')
52
+ .trim()
53
+ return { base, modifier }
54
+ }
55
+
56
+ /**
57
+ * Two spellings that mean the same type, where `declared` may leave a width,
58
+ * a precision or an array's element modifier unsaid.
59
+ */
60
+ function sameType(declared: string, live: string): boolean {
61
+ if (declared === live) return true
62
+ const d = splitModifier(declared)
63
+ const l = splitModifier(live)
64
+ return d.base === l.base && (d.modifier === '' || d.modifier === l.modifier)
65
+ }
66
+
67
+ const PG_ALIASES: Record<string, string> = {
68
+ int: 'integer',
69
+ int4: 'integer',
70
+ serial: 'integer',
71
+ serial4: 'integer',
72
+ int2: 'smallint',
73
+ smallserial: 'smallint',
74
+ serial2: 'smallint',
75
+ int8: 'bigint',
76
+ bigserial: 'bigint',
77
+ serial8: 'bigint',
78
+ float4: 'real',
79
+ float8: 'double precision',
80
+ float: 'double precision',
81
+ bool: 'boolean',
82
+ varchar: 'character varying',
83
+ char: 'character',
84
+ bpchar: 'character',
85
+ decimal: 'numeric',
86
+ timestamptz: 'timestamp with time zone',
87
+ timestamp: 'timestamp without time zone',
88
+ timetz: 'time with time zone',
89
+ time: 'time without time zone',
90
+ }
91
+
92
+ /**
93
+ * A Postgres type as a declaration may spell it, in the spelling
94
+ * `format_type()` reports: `timestamptz` is `timestamp with time zone`,
95
+ * `varchar(64)` is `character varying(64)`, `int[]` is `integer[]`.
96
+ */
97
+ export function normalizePgType(spelled: string): string {
98
+ let type = spelled.trim().toLowerCase().replace(/\s+/g, ' ')
99
+ let arrays = ''
100
+ while (type.endsWith('[]')) {
101
+ arrays += '[]'
102
+ type = type.slice(0, -2).trim()
103
+ }
104
+ const { base, modifier } = splitModifier(type)
105
+ const name = PG_ALIASES[base] ?? base
106
+ if (modifier && name.startsWith('timestamp ')) {
107
+ return `timestamp${modifier} ${name.slice('timestamp '.length)}${arrays}`
108
+ }
109
+ if (modifier && name.startsWith('time ')) {
110
+ return `time${modifier} ${name.slice('time '.length)}${arrays}`
111
+ }
112
+ return `${name}${modifier}${arrays}`
113
+ }
114
+
115
+ /** Postgres: `live.type` is `format_type()`, a domain already resolved to its base. */
116
+ export function pgTypeProblem(declared: Declared, live: Live): string | null {
117
+ const type = live.type
118
+ const accepts = (ok: boolean, expected: string) =>
119
+ ok ? null : mismatch(expected, live)
120
+
121
+ switch (declared.type) {
122
+ case 'integer':
123
+ return accepts(
124
+ type === 'integer' || type === 'smallint',
125
+ 'an integer (integer or smallint)',
126
+ )
127
+ case 'bigint':
128
+ return accepts(type === 'bigint', 'bigint')
129
+ case 'number':
130
+ return accepts(
131
+ type === 'double precision' || type === 'real',
132
+ 'a float (double precision or real)',
133
+ )
134
+ case 'boolean':
135
+ return accepts(type === 'boolean', 'boolean')
136
+ case 'json':
137
+ return accepts(type === 'json' || type === 'jsonb', 'json or jsonb')
138
+ case 'buffer':
139
+ return accepts(type === 'bytea', 'bytea')
140
+ case 'sql': {
141
+ const want = normalizePgType(declared.sqlType ?? '')
142
+ return accepts(sameType(want, type), want)
143
+ }
144
+ case 'string': {
145
+ if (declared._enum) {
146
+ const textual = type === 'text' || type.startsWith('character varying')
147
+ if (!textual && !live.enumValues) {
148
+ return mismatch('an enum (text, varchar, or a native enum)', live)
149
+ }
150
+ return enumProblem(declared, live)
151
+ }
152
+ // Field.Uuid is a 36-character string; a native uuid holds the same.
153
+ if (declared.length === 36 && type === 'uuid') return null
154
+ if (declared.length) {
155
+ return accepts(
156
+ type === `character varying(${declared.length})` ||
157
+ type === `character(${declared.length})`,
158
+ `varchar(${declared.length})`,
159
+ )
160
+ }
161
+ return accepts(
162
+ type === 'text' ||
163
+ type === 'uuid' ||
164
+ type === 'citext' ||
165
+ type.startsWith('character varying') ||
166
+ type.startsWith('character('),
167
+ 'text (text, varchar or char)',
168
+ )
169
+ }
170
+ }
171
+ }
172
+
173
+ const MYSQL_ALIASES: Record<string, string> = {
174
+ integer: 'int',
175
+ bool: 'tinyint(1)',
176
+ boolean: 'tinyint(1)',
177
+ numeric: 'decimal',
178
+ dec: 'decimal',
179
+ 'double precision': 'double',
180
+ real: 'double',
181
+ }
182
+
183
+ /** A MySQL type the way `column_type` reports it: no display width on an integer. */
184
+ export function normalizeMysqlType(spelled: string): string {
185
+ const type = spelled.trim().toLowerCase().replace(/\s+/g, ' ')
186
+ if (MYSQL_ALIASES[type]) return MYSQL_ALIASES[type]!
187
+ const { base, modifier } = splitModifier(type)
188
+ const name = MYSQL_ALIASES[base] ?? base
189
+ // `int(11)` is a display width, which MySQL 8 no longer reports: the type
190
+ // is `int`. `tinyint(1)` is the exception everyone relies on to mean bool.
191
+ if (/^(tinyint|smallint|mediumint|int|bigint)( unsigned)?$/.test(name)) {
192
+ return modifier === '(1)' && name === 'tinyint' ? 'tinyint(1)' : name
193
+ }
194
+ return `${name}${modifier}`
195
+ }
196
+
197
+ /** MySQL: `live.type` is `column_type`, lower-cased. */
198
+ export function mysqlTypeProblem(
199
+ declared: Declared,
200
+ live: Live,
201
+ ): string | null {
202
+ const type = normalizeMysqlType(live.type)
203
+ const accepts = (ok: boolean, expected: string) =>
204
+ ok ? null : mismatch(expected, live)
205
+ const integer = /^(tinyint|smallint|mediumint|int)( unsigned)?$/
206
+
207
+ switch (declared.type) {
208
+ case 'integer':
209
+ return accepts(
210
+ integer.test(type),
211
+ 'an integer (int, smallint, mediumint)',
212
+ )
213
+ case 'bigint':
214
+ return accepts(type.startsWith('bigint'), 'bigint')
215
+ case 'number':
216
+ return accepts(
217
+ type === 'double' || type === 'float',
218
+ 'a float (double or float)',
219
+ )
220
+ case 'boolean':
221
+ return accepts(type === 'tinyint(1)', 'tinyint(1)')
222
+ case 'json':
223
+ return accepts(type === 'json', 'json')
224
+ case 'buffer':
225
+ return accepts(
226
+ type.endsWith('blob') || type.startsWith('varbinary'),
227
+ 'a blob',
228
+ )
229
+ case 'sql': {
230
+ const want = normalizeMysqlType(declared.sqlType ?? '')
231
+ return accepts(sameType(want, type), want)
232
+ }
233
+ case 'string': {
234
+ if (declared._enum) {
235
+ if (type.startsWith('enum(')) {
236
+ const labels = [...type.matchAll(/'((?:[^']|'')*)'/g)].map(m =>
237
+ m[1]!.replace(/''/g, "'"),
238
+ )
239
+ return enumProblem(declared, { ...live, enumValues: labels })
240
+ }
241
+ return accepts(
242
+ type.endsWith('text') || type.startsWith('varchar'),
243
+ 'an enum (enum, varchar or text)',
244
+ )
245
+ }
246
+ if (declared.length) {
247
+ return accepts(
248
+ type === `varchar(${declared.length})` ||
249
+ type === `char(${declared.length})`,
250
+ `varchar(${declared.length})`,
251
+ )
252
+ }
253
+ return accepts(
254
+ type.endsWith('text') ||
255
+ type.startsWith('varchar') ||
256
+ type.startsWith('char('),
257
+ 'text (text, varchar or char)',
258
+ )
259
+ }
260
+ }
261
+ }
262
+
263
+ /**
264
+ * SQLite: `live.type` is the declared type text, which SQLite keeps but does
265
+ * not enforce. The rules are its type affinity rules, so a column declared
266
+ * `VARCHAR(10)` that holds text of any length still reads as text.
267
+ */
268
+ export function sqliteTypeProblem(
269
+ declared: Declared,
270
+ live: Live,
271
+ ): string | null {
272
+ const type = live.type
273
+ const accepts = (ok: boolean, expected: string) =>
274
+ ok ? null : mismatch(expected, live)
275
+ const integer = type.includes('int')
276
+ const textual =
277
+ type.includes('char') || type.includes('clob') || type.includes('text')
278
+ const real =
279
+ type.includes('real') || type.includes('floa') || type.includes('doub')
280
+
281
+ switch (declared.type) {
282
+ case 'integer':
283
+ case 'bigint':
284
+ return accepts(integer, 'an integer')
285
+ case 'boolean':
286
+ return accepts(integer || type.includes('bool'), 'an integer or boolean')
287
+ case 'number':
288
+ return accepts(real || type.includes('numeric'), 'a real')
289
+ case 'json':
290
+ return accepts(type.includes('json') || textual, 'json or text')
291
+ case 'buffer':
292
+ return accepts(type === '' || type.includes('blob'), 'a blob')
293
+ case 'sql': {
294
+ const want = (declared.sqlType ?? '').trim().toLowerCase()
295
+ return accepts(sameType(want, type.replace(/\s+/g, ' ')), want)
296
+ }
297
+ case 'string': {
298
+ if (declared.length && /\(\d+\)/.test(type)) {
299
+ return accepts(
300
+ type === `varchar(${declared.length})` ||
301
+ type === `character varying(${declared.length})`,
302
+ `varchar(${declared.length})`,
303
+ )
304
+ }
305
+ return accepts(textual, 'text')
306
+ }
307
+ }
308
+ }
@@ -3,6 +3,22 @@ import { SQL } from 'bun'
3
3
  import { type PoolOptions, withPoolOptions } from '../pool'
4
4
  import type * as SyncTypes from '../sync/types'
5
5
  import { createExecutor, isOpenConnection, SQLAdapter } from './base'
6
+ import { mysqlTypeProblem } from './live-types'
7
+ import type { ScriptDialect } from '../migrate/script'
8
+
9
+ /** The database's tables and views, a row per column, from `information_schema`. */
10
+ const MYSQL_LIVE_COLUMNS = `
11
+ SELECT c.table_name AS table_name,
12
+ t.table_type AS table_type,
13
+ c.column_name AS column_name,
14
+ c.column_type AS type,
15
+ c.is_nullable = 'YES' AS nullable,
16
+ c.column_key = 'PRI' AS is_primary
17
+ FROM information_schema.columns c
18
+ JOIN information_schema.tables t
19
+ ON t.table_schema = c.table_schema AND t.table_name = c.table_name
20
+ WHERE c.table_schema = DATABASE()
21
+ ORDER BY c.table_name, c.ordinal_position`
6
22
 
7
23
  export class MySQLAdapter extends SQLAdapter {
8
24
  protected readonly sql: SQL
@@ -267,6 +283,65 @@ export class MySQLAdapter extends SQLAdapter {
267
283
  return false
268
284
  }
269
285
 
286
+ /** MySQL commits every DDL statement as it runs; a rollback cannot undo one. */
287
+ override get transactionalDDL(): boolean {
288
+ return false
289
+ }
290
+
291
+ override get scriptDialect(): ScriptDialect {
292
+ return 'mysql'
293
+ }
294
+
295
+ /**
296
+ * A named lock, held by the session. `GET_LOCK` waits up to its timeout and
297
+ * answers 1 when it has the lock and 0 when the wait ran out, so 0 has to be
298
+ * refused here rather than taken for success.
299
+ */
300
+ override async takeMigrationLock(): Promise<() => Promise<void>> {
301
+ const rows = (await this.sql.unsafe(
302
+ "SELECT GET_LOCK('bakery_migrations', 600) AS taken",
303
+ )) as { taken: unknown }[]
304
+ if (Number(rows[0]?.taken) !== 1) {
305
+ throw new Error(
306
+ 'Another migration run has held the lock for 10 minutes. Wait for it to finish, then run db:migrate again.',
307
+ )
308
+ }
309
+ return async () => {
310
+ await this.sql.unsafe("SELECT RELEASE_LOCK('bakery_migrations')")
311
+ }
312
+ }
313
+
314
+ /**
315
+ * From `information_schema`, MySQL's only catalog. It lists what the user
316
+ * holds a privilege on, so a table the app's user cannot touch is reported
317
+ * missing, which for a query typed against it is the truth.
318
+ */
319
+ override async liveColumns(): Promise<SQLAdapter.LiveColumn[]> {
320
+ const rows = (await this.sql.unsafe(MYSQL_LIVE_COLUMNS)) as {
321
+ table_name: string
322
+ table_type: string
323
+ column_name: string
324
+ type: string
325
+ nullable: number | boolean
326
+ is_primary: number | boolean
327
+ }[]
328
+ return rows.map(row => ({
329
+ table: row.table_name,
330
+ column: row.column_name,
331
+ type: String(row.type).toLowerCase(),
332
+ nullable: Boolean(Number(row.nullable)),
333
+ primary: Boolean(Number(row.is_primary)),
334
+ view: row.table_type === 'VIEW',
335
+ }))
336
+ }
337
+
338
+ override columnTypeProblem(
339
+ declared: SyncTypes.ColumnConstraint,
340
+ live: SQLAdapter.LiveColumn,
341
+ ): string | null {
342
+ return mysqlTypeProblem(declared, live)
343
+ }
344
+
270
345
  protected withConnection(sql: unknown): SQLAdapter {
271
346
  return new MySQLAdapter(sql as SQL)
272
347
  }