@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 +5 -3
- package/src/adapters/base.ts +137 -4
- package/src/adapters/live-types.ts +308 -0
- package/src/adapters/mysql.ts +75 -0
- package/src/adapters/pgsql.ts +124 -5
- package/src/adapters/sqlite.ts +39 -3
- package/src/array-value.ts +48 -0
- package/src/field.ts +98 -0
- package/src/migrate/check.ts +108 -0
- package/src/migrate/files.ts +111 -0
- package/src/migrate/index.ts +168 -0
- package/src/migrate/runner.ts +208 -0
- package/src/migrate/script.ts +228 -0
- package/src/orm/query.ts +30 -0
- package/src/schema-util.ts +8 -0
- package/src/sessions.ts +309 -0
- package/src/sync/index.ts +66 -4
- package/src/sync/ledger.ts +11 -5
- package/src/sync/rollback.ts +6 -0
- package/src/sync/types.ts +4 -0
package/src/adapters/pgsql.ts
CHANGED
|
@@ -1,8 +1,41 @@
|
|
|
1
1
|
import { Case, Try } from '@bakery-framework/core/utils'
|
|
2
2
|
import { SQL } from 'bun'
|
|
3
|
+
import { encodePgArray, isArrayValue } from '../array-value'
|
|
3
4
|
import { type PoolOptions, withPoolOptions } from '../pool'
|
|
4
5
|
import type * as SyncTypes from '../sync/types'
|
|
5
6
|
import { createExecutor, isOpenConnection, SQLAdapter } from './base'
|
|
7
|
+
import { pgTypeProblem } from './live-types'
|
|
8
|
+
import type { ScriptDialect } from '../migrate/script'
|
|
9
|
+
/** `pg_advisory_lock` key for migration runs: "bakerymg" in ASCII, 0x62616b6572796d67. */
|
|
10
|
+
const MIGRATION_LOCK_KEY = '7089065371914300775'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Tables, partitioned tables, views, materialized views and foreign tables of
|
|
14
|
+
* the current schema, a row per column. `array_agg(…::text)` because an
|
|
15
|
+
* enum's labels are of type `name`, and Bun returns an array of an unfamiliar
|
|
16
|
+
* type as its raw `{…}` string.
|
|
17
|
+
*/
|
|
18
|
+
const PG_LIVE_COLUMNS = `
|
|
19
|
+
SELECT c.relname AS table_name,
|
|
20
|
+
c.relkind AS kind,
|
|
21
|
+
a.attname AS column_name,
|
|
22
|
+
CASE WHEN t.typtype = 'd'
|
|
23
|
+
THEN format_type(t.typbasetype, t.typtypmod)
|
|
24
|
+
ELSE format_type(a.atttypid, a.atttypmod) END AS type,
|
|
25
|
+
NOT a.attnotnull AS nullable,
|
|
26
|
+
COALESCE(a.attnum = ANY (pk.conkey), false) AS is_primary,
|
|
27
|
+
CASE WHEN t.typtype = 'e' THEN (
|
|
28
|
+
SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder)
|
|
29
|
+
FROM pg_enum e WHERE e.enumtypid = t.oid
|
|
30
|
+
) END AS enum_values
|
|
31
|
+
FROM pg_class c
|
|
32
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
33
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
34
|
+
JOIN pg_type t ON t.oid = a.atttypid
|
|
35
|
+
LEFT JOIN pg_constraint pk ON pk.conrelid = c.oid AND pk.contype = 'p'
|
|
36
|
+
WHERE n.nspname = current_schema()
|
|
37
|
+
AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
|
|
38
|
+
ORDER BY c.relname, a.attnum`
|
|
6
39
|
|
|
7
40
|
interface PGSQLParserState {
|
|
8
41
|
inSingleQuote: boolean
|
|
@@ -16,6 +49,55 @@ export class PGAdapter extends SQLAdapter {
|
|
|
16
49
|
protected readonly sql: SQL
|
|
17
50
|
override readonly quoteChar: string = '"'
|
|
18
51
|
|
|
52
|
+
override get scriptDialect(): ScriptDialect {
|
|
53
|
+
return 'pgsql'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A session advisory lock. `pg_advisory_lock` waits for the other run to
|
|
58
|
+
* release it, so a second deploy blocks and then finds nothing pending. The
|
|
59
|
+
* key is the ASCII of "bakerymg".
|
|
60
|
+
*/
|
|
61
|
+
override async takeMigrationLock(): Promise<() => Promise<void>> {
|
|
62
|
+
await this.sql.unsafe(`SELECT pg_advisory_lock(${MIGRATION_LOCK_KEY})`)
|
|
63
|
+
return async () => {
|
|
64
|
+
await this.sql.unsafe(`SELECT pg_advisory_unlock(${MIGRATION_LOCK_KEY})`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* From `pg_catalog`, which shows every relation to every role, where
|
|
70
|
+
* `information_schema` shows only what the role holds a privilege on. A
|
|
71
|
+
* domain is resolved to its base type; an enum brings its labels.
|
|
72
|
+
*/
|
|
73
|
+
override async liveColumns(): Promise<SQLAdapter.LiveColumn[]> {
|
|
74
|
+
const rows = (await this.sql.unsafe(PG_LIVE_COLUMNS)) as {
|
|
75
|
+
table_name: string
|
|
76
|
+
kind: string
|
|
77
|
+
column_name: string
|
|
78
|
+
type: string
|
|
79
|
+
nullable: boolean
|
|
80
|
+
is_primary: boolean
|
|
81
|
+
enum_values: string[] | null
|
|
82
|
+
}[]
|
|
83
|
+
return rows.map(row => ({
|
|
84
|
+
table: row.table_name,
|
|
85
|
+
column: row.column_name,
|
|
86
|
+
type: row.type.toLowerCase(),
|
|
87
|
+
nullable: Boolean(row.nullable),
|
|
88
|
+
primary: Boolean(row.is_primary),
|
|
89
|
+
view: row.kind === 'v' || row.kind === 'm',
|
|
90
|
+
...(row.enum_values ? { enumValues: row.enum_values } : {}),
|
|
91
|
+
}))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
override columnTypeProblem(
|
|
95
|
+
declared: SyncTypes.ColumnConstraint,
|
|
96
|
+
live: SQLAdapter.LiveColumn,
|
|
97
|
+
): string | null {
|
|
98
|
+
return pgTypeProblem(declared, live)
|
|
99
|
+
}
|
|
100
|
+
|
|
19
101
|
constructor(connectionTarget?: string | URL | SQL, pool: PoolOptions = {}) {
|
|
20
102
|
const target =
|
|
21
103
|
typeof connectionTarget === 'string' || connectionTarget instanceof URL
|
|
@@ -130,12 +212,42 @@ export class PGAdapter extends SQLAdapter {
|
|
|
130
212
|
return result
|
|
131
213
|
}
|
|
132
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Plain arrays where Bun returned typed ones. Measured: an `int4[]` column
|
|
217
|
+
* comes back as an `Int32Array` (and `float4[]` as a `Float32Array`) once
|
|
218
|
+
* the query has a bound parameter, and as an ordinary array when it has
|
|
219
|
+
* none, so the same column changed type with the WHERE clause, and
|
|
220
|
+
* `JSON.stringify` wrote an Int32Array as `{"0":1,"1":2}`. A `Uint8Array`
|
|
221
|
+
* is left alone: that is a `bytea`, read as a Buffer.
|
|
222
|
+
*/
|
|
223
|
+
private static plainArrays(rows: unknown): unknown {
|
|
224
|
+
if (!Array.isArray(rows)) return rows
|
|
225
|
+
for (const row of rows) {
|
|
226
|
+
if (!row || typeof row !== 'object') continue
|
|
227
|
+
for (const key in row) {
|
|
228
|
+
const value = (row as Record<string, unknown>)[key]
|
|
229
|
+
if (
|
|
230
|
+
ArrayBuffer.isView(value) &&
|
|
231
|
+
!(value instanceof Uint8Array) &&
|
|
232
|
+
!(value instanceof DataView)
|
|
233
|
+
) {
|
|
234
|
+
;(row as Record<string, unknown>)[key] = Array.from(
|
|
235
|
+
value as unknown as ArrayLike<unknown>,
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return rows
|
|
241
|
+
}
|
|
242
|
+
|
|
133
243
|
readonly execute: SQLAdapter.Executor = createExecutor(
|
|
134
244
|
async (sqlText: string, params: unknown[] = []) =>
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
245
|
+
PGAdapter.plainArrays(
|
|
246
|
+
await this.sql.unsafe(
|
|
247
|
+
PGAdapter.normalizePostgresSQL(sqlText, params),
|
|
248
|
+
params,
|
|
249
|
+
),
|
|
250
|
+
) as any,
|
|
139
251
|
async (
|
|
140
252
|
sqlText: string,
|
|
141
253
|
params: unknown[] = [],
|
|
@@ -175,7 +287,14 @@ export class PGAdapter extends SQLAdapter {
|
|
|
175
287
|
return { lastInsertRowid, changes }
|
|
176
288
|
},
|
|
177
289
|
this.driver,
|
|
178
|
-
{
|
|
290
|
+
{
|
|
291
|
+
explain: error => this.explainError(error),
|
|
292
|
+
// A `DB.array()` value as the array literal Postgres casts from text.
|
|
293
|
+
bind: params =>
|
|
294
|
+
params.map(param =>
|
|
295
|
+
isArrayValue(param) ? encodePgArray(param.values) : param,
|
|
296
|
+
),
|
|
297
|
+
},
|
|
179
298
|
)
|
|
180
299
|
|
|
181
300
|
async hasCol(table: string, column: string): Promise<boolean> {
|
package/src/adapters/sqlite.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { Case, Try } from '@bakery-framework/core/utils'
|
|
|
6
6
|
import { SQL } from 'bun'
|
|
7
7
|
import type * as SyncTypes from '../sync/types'
|
|
8
8
|
import { createExecutor, SQLAdapter } from './base'
|
|
9
|
+
import { sqliteTypeProblem } from './live-types'
|
|
9
10
|
|
|
10
11
|
// Convention 4: logging is data, declared in a table rather than formatted at
|
|
11
12
|
// the call site.
|
|
@@ -579,6 +580,41 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
579
580
|
return false
|
|
580
581
|
}
|
|
581
582
|
|
|
583
|
+
/** `sqlite_master` and `pragma_table_info`, SQLite's whole catalog. */
|
|
584
|
+
override async liveColumns(): Promise<SQLAdapter.LiveColumn[]> {
|
|
585
|
+
const rows = (await this.sql.unsafe(
|
|
586
|
+
`SELECT m.name AS table_name, m.type AS kind, p.name AS column_name,
|
|
587
|
+
p.type AS type, p."notnull" AS not_null, p.pk AS pk
|
|
588
|
+
FROM sqlite_master m JOIN pragma_table_info(m.name) p
|
|
589
|
+
WHERE m.type IN ('table', 'view') AND m.name NOT LIKE 'sqlite_%'
|
|
590
|
+
ORDER BY m.name, p.cid`,
|
|
591
|
+
)) as {
|
|
592
|
+
table_name: string
|
|
593
|
+
kind: string
|
|
594
|
+
column_name: string
|
|
595
|
+
type: string | null
|
|
596
|
+
not_null: number
|
|
597
|
+
pk: number
|
|
598
|
+
}[]
|
|
599
|
+
return rows.map(row => ({
|
|
600
|
+
table: row.table_name,
|
|
601
|
+
column: row.column_name,
|
|
602
|
+
type: (row.type ?? '').toLowerCase(),
|
|
603
|
+
// An INTEGER PRIMARY KEY is the rowid and cannot hold NULL, whatever
|
|
604
|
+
// `notnull` says.
|
|
605
|
+
nullable: !Number(row.not_null) && !Number(row.pk),
|
|
606
|
+
primary: Number(row.pk) > 0,
|
|
607
|
+
view: row.kind === 'view',
|
|
608
|
+
}))
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
override columnTypeProblem(
|
|
612
|
+
declared: SyncTypes.ColumnConstraint,
|
|
613
|
+
live: SQLAdapter.LiveColumn,
|
|
614
|
+
): string | null {
|
|
615
|
+
return sqliteTypeProblem(declared, live)
|
|
616
|
+
}
|
|
617
|
+
|
|
582
618
|
async getConstraints(): Promise<SyncTypes.DBConstraints> {
|
|
583
619
|
const tables = (await this.query(
|
|
584
620
|
'SELECT sql,name,type FROM sqlite_master' +
|
|
@@ -596,9 +632,9 @@ export class SQLiteAdapter extends SQLAdapter {
|
|
|
596
632
|
// records. This one survived it: `getConstraints` runs over every table
|
|
597
633
|
// the database reports, so a name carrying an apostrophe closed the
|
|
598
634
|
// literal and the rest of the statement went with it.
|
|
599
|
-
const cols = (await this.query(
|
|
600
|
-
|
|
601
|
-
)
|
|
635
|
+
const cols = (await this.query('SELECT * FROM pragma_table_info(?)').all(
|
|
636
|
+
table.name,
|
|
637
|
+
)) as any[]
|
|
602
638
|
|
|
603
639
|
if (table.type === 'view') {
|
|
604
640
|
const match = table.sql.match(/AS\s+(.*)/is)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Postgres array, bound as one parameter: what `DB.array([...])` makes.
|
|
3
|
+
*
|
|
4
|
+
* Needed because Bun cannot bind a JavaScript array to a Postgres array
|
|
5
|
+
* parameter. Measured: into `text[]` it fails with `22P02 malformed array
|
|
6
|
+
* literal: "x,y"`, into `int4[]` with `08P01 insufficient data left in
|
|
7
|
+
* message`, explicit `::int4[]` cast or not, and the same in `@>`, `&&` and
|
|
8
|
+
* `= ANY(?)`. An array literal sent as text binds in all of those places with
|
|
9
|
+
* no cast, for text, int4, int8, jsonb and uuid elements alike.
|
|
10
|
+
*
|
|
11
|
+
* A wrapper rather than encoding every JavaScript array: a `jsonb` column
|
|
12
|
+
* handed a plain array stores a JSON array today, and an array literal sent
|
|
13
|
+
* there would arrive as a malformed JSON string instead. Only the query can
|
|
14
|
+
* say which one it means, since the query layer carries no column types.
|
|
15
|
+
*/
|
|
16
|
+
export class ArrayValue<T = unknown> {
|
|
17
|
+
constructor(readonly values: readonly T[]) {}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isArrayValue(value: unknown): value is ArrayValue {
|
|
21
|
+
return value instanceof ArrayValue
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The text of a Postgres array literal for `values`: `{"a","b c",NULL}`.
|
|
26
|
+
*
|
|
27
|
+
* Every element is double-quoted, with backslashes and double quotes escaped,
|
|
28
|
+
* so a comma, a brace or a space inside one is only text. NULL is bare.
|
|
29
|
+
* Numbers and booleans are quoted too, which Postgres reads the same as bare
|
|
30
|
+
* when it casts the literal to the column's element type. A nested array is a
|
|
31
|
+
* nested literal, a Date its ISO string, and any other object its JSON, which
|
|
32
|
+
* is how a `jsonb[]` element is written.
|
|
33
|
+
*/
|
|
34
|
+
export function encodePgArray(values: readonly unknown[]): string {
|
|
35
|
+
return `{${values.map(encodeElement).join(',')}}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function encodeElement(value: unknown): string {
|
|
39
|
+
if (value === null || value === undefined) return 'NULL'
|
|
40
|
+
if (Array.isArray(value)) return encodePgArray(value)
|
|
41
|
+
const text =
|
|
42
|
+
value instanceof Date
|
|
43
|
+
? value.toISOString()
|
|
44
|
+
: typeof value === 'object'
|
|
45
|
+
? JSON.stringify(value)
|
|
46
|
+
: String(value)
|
|
47
|
+
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
|
48
|
+
}
|
package/src/field.ts
CHANGED
|
@@ -35,6 +35,35 @@ function column<T, N extends boolean = false, O extends boolean = false>(
|
|
|
35
35
|
/** `T` when the column is NOT NULL, `T | null` when it is nullable. */
|
|
36
36
|
type Nullable<T, N extends boolean> = N extends true ? T | null : T
|
|
37
37
|
|
|
38
|
+
/** The element types `Field.Array` takes. See there for why these and not `uuid`. */
|
|
39
|
+
type ArrayElement =
|
|
40
|
+
| 'text'
|
|
41
|
+
| 'varchar'
|
|
42
|
+
| 'integer'
|
|
43
|
+
| 'smallint'
|
|
44
|
+
| 'bigint'
|
|
45
|
+
| 'numeric'
|
|
46
|
+
| 'real'
|
|
47
|
+
| 'double precision'
|
|
48
|
+
| 'boolean'
|
|
49
|
+
| 'timestamptz'
|
|
50
|
+
| 'jsonb'
|
|
51
|
+
|
|
52
|
+
/** What Bun returns for one element of an array of `E`. */
|
|
53
|
+
type ElementOf<E extends ArrayElement> = E extends
|
|
54
|
+
| 'integer'
|
|
55
|
+
| 'smallint'
|
|
56
|
+
| 'real'
|
|
57
|
+
| 'double precision'
|
|
58
|
+
? number
|
|
59
|
+
: E extends 'boolean'
|
|
60
|
+
? boolean
|
|
61
|
+
: E extends 'timestamptz'
|
|
62
|
+
? Date
|
|
63
|
+
: E extends 'jsonb'
|
|
64
|
+
? unknown
|
|
65
|
+
: string
|
|
66
|
+
|
|
38
67
|
/**
|
|
39
68
|
* Optional on insert when there is a default, or the column is nullable.
|
|
40
69
|
*
|
|
@@ -295,6 +324,75 @@ export const Field = {
|
|
|
295
324
|
(nullable: true): TableDef<unknown, true, true>
|
|
296
325
|
},
|
|
297
326
|
|
|
327
|
+
/**
|
|
328
|
+
* A column whose SQL type the migration files give, for a type this
|
|
329
|
+
* vocabulary has no spelling for: `numeric(5,2)`, `timestamptz`, a `bigint`
|
|
330
|
+
* key, `text[]`. **Migrations mode only**: classic `db:sync` refuses a schema
|
|
331
|
+
* that uses it, since it cannot create or compare a type it does not know.
|
|
332
|
+
*
|
|
333
|
+
* The type argument is what the driver hands back, and it is the caller's
|
|
334
|
+
* to get right. Measured with Bun on Postgres: `numeric` and `bigint` come
|
|
335
|
+
* back as strings, `timestamptz` and `date` as a `Date`, `uuid` as a string.
|
|
336
|
+
* `db:sync` checks the SQL type against the database, not the type
|
|
337
|
+
* argument against the SQL type.
|
|
338
|
+
*
|
|
339
|
+
* weight: Field.Sql<string>('numeric(5,2)'),
|
|
340
|
+
* closedAt: Field.Sql<Date>('timestamptz', { nullable: true }),
|
|
341
|
+
* createdAt: Field.Sql<Date>('timestamptz', { optional: true }),
|
|
342
|
+
*
|
|
343
|
+
* `optional` says the database fills the column when an insert leaves it
|
|
344
|
+
* out (a `DEFAULT`, an identity), so inserts may omit it.
|
|
345
|
+
*/
|
|
346
|
+
Sql: ((
|
|
347
|
+
sqlType: string,
|
|
348
|
+
options: { nullable?: true; optional?: true } = {},
|
|
349
|
+
) => {
|
|
350
|
+
const def: Record<string, unknown> = { type: 'sql', sqlType }
|
|
351
|
+
if (options.nullable) def.nullable = true
|
|
352
|
+
return def
|
|
353
|
+
}) as {
|
|
354
|
+
<T>(sqlType: string): TableDef<T, false, false>
|
|
355
|
+
<T>(sqlType: string, options: { optional: true }): TableDef<T, false, true>
|
|
356
|
+
<T>(
|
|
357
|
+
sqlType: string,
|
|
358
|
+
options: { nullable: true; optional?: true },
|
|
359
|
+
): TableDef<T | null, true, true>
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* A Postgres array column, typed by its element: `Field.Array('text')` is
|
|
364
|
+
* `text[]` and `string[]`. `Field.Sql` with the element's type, so it
|
|
365
|
+
* belongs to migrations mode too, and `db:sync` checks it as `text[]`.
|
|
366
|
+
*
|
|
367
|
+
* The element types are the ones whose arrays Bun returns as the type
|
|
368
|
+
* says, measured: `bigint` and `numeric` elements come back as strings,
|
|
369
|
+
* `timestamptz` ones as a `Date`. `uuid` is not offered, because Bun hands
|
|
370
|
+
* back a `uuid[]` as its raw `{…}` string; store such ids as `text[]`.
|
|
371
|
+
*
|
|
372
|
+
* Write one with `DB.array([...])`; a plain array cannot be bound. And keep
|
|
373
|
+
* an `integer[]` free of NULL elements (a CHECK does it): once a query has a
|
|
374
|
+
* parameter, Bun's driver throws on reading one, as it does on any 2-D
|
|
375
|
+
* array, inside the driver where nothing here can catch it first.
|
|
376
|
+
*/
|
|
377
|
+
Array: (<E extends ArrayElement>(
|
|
378
|
+
element: E,
|
|
379
|
+
options: { nullable?: true; optional?: true } = {},
|
|
380
|
+
) => {
|
|
381
|
+
const def: Record<string, unknown> = { type: 'sql', sqlType: `${element}[]` }
|
|
382
|
+
if (options.nullable) def.nullable = true
|
|
383
|
+
return def
|
|
384
|
+
}) as {
|
|
385
|
+
<E extends ArrayElement>(element: E): TableDef<ElementOf<E>[], false, false>
|
|
386
|
+
<E extends ArrayElement>(
|
|
387
|
+
element: E,
|
|
388
|
+
options: { optional: true },
|
|
389
|
+
): TableDef<ElementOf<E>[], false, true>
|
|
390
|
+
<E extends ArrayElement>(
|
|
391
|
+
element: E,
|
|
392
|
+
options: { nullable: true; optional?: true },
|
|
393
|
+
): TableDef<ElementOf<E>[] | null, true, true>
|
|
394
|
+
},
|
|
395
|
+
|
|
298
396
|
/** True/false: `BOOLEAN` on Postgres, `TINYINT(1)` on MySQL. */
|
|
299
397
|
Bool: <D extends boolean | null | undefined = undefined>(d?: D) =>
|
|
300
398
|
column<
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Case } from '@bakery-framework/core/utils'
|
|
2
|
+
import type { SQLAdapter } from '../adapters/base'
|
|
3
|
+
import type * as SyncTypes from '../sync/types'
|
|
4
|
+
|
|
5
|
+
/** One way the declared tables and the database disagree. */
|
|
6
|
+
export interface SchemaMismatch {
|
|
7
|
+
/** The table as declared. */
|
|
8
|
+
table: string
|
|
9
|
+
/** The column as declared, when the mismatch is a column's. */
|
|
10
|
+
column?: string
|
|
11
|
+
problem: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compare the declared tables with the database, column by column, reading
|
|
16
|
+
* the catalog and nothing else: what `db:sync` does in migrations mode, where
|
|
17
|
+
* the migration files are the schema and the declarations only type queries.
|
|
18
|
+
*
|
|
19
|
+
* It asks one question of each declared column: would a query typed by this
|
|
20
|
+
* declaration be right about the column? So a table or column the database
|
|
21
|
+
* lacks, a type the declaration does not accept (each adapter's rules, see
|
|
22
|
+
* `adapters/live-types.ts`), a disagreement about NULL, and a primary key in
|
|
23
|
+
* the wrong place are mismatches. A table or column the database has and the
|
|
24
|
+
* declarations leave out is not: queries simply never name it.
|
|
25
|
+
*
|
|
26
|
+
* A view's columns are not held to their nullability, since catalogs report
|
|
27
|
+
* every view column as nullable whatever the query beneath it can return.
|
|
28
|
+
*/
|
|
29
|
+
export async function checkSchema(
|
|
30
|
+
adapter: SQLAdapter,
|
|
31
|
+
declared: SyncTypes.DBConstraints,
|
|
32
|
+
): Promise<SchemaMismatch[]> {
|
|
33
|
+
const live = new Map<string, Map<string, SQLAdapter.LiveColumn>>()
|
|
34
|
+
for (const column of await adapter.liveColumns()) {
|
|
35
|
+
let columns = live.get(column.table)
|
|
36
|
+
if (!columns) live.set(column.table, (columns = new Map()))
|
|
37
|
+
columns.set(column.column, column)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const mismatches: SchemaMismatch[] = []
|
|
41
|
+
for (const [table, constraints] of Object.entries(declared)) {
|
|
42
|
+
const sqlTable = Case.snake(table)
|
|
43
|
+
const columns = live.get(sqlTable)
|
|
44
|
+
if (!columns) {
|
|
45
|
+
mismatches.push({
|
|
46
|
+
table,
|
|
47
|
+
problem: `${sqlTable} is declared, and the database has no table or view by that name.`,
|
|
48
|
+
})
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const [column, def] of Object.entries(constraints)) {
|
|
53
|
+
// `_view`, `_oldTable` and `_transform` sit beside the columns.
|
|
54
|
+
if (column.startsWith('_') || !def || typeof def !== 'object') continue
|
|
55
|
+
const constraint = def as SyncTypes.ColumnConstraint
|
|
56
|
+
const sqlColumn = Case.snake(column)
|
|
57
|
+
const where = `${sqlTable}.${sqlColumn}`
|
|
58
|
+
const liveColumn = columns.get(sqlColumn)
|
|
59
|
+
if (!liveColumn) {
|
|
60
|
+
mismatches.push({
|
|
61
|
+
table,
|
|
62
|
+
column,
|
|
63
|
+
problem: `${where} is declared, and the table has no such column.`,
|
|
64
|
+
})
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const typeProblem = adapter.columnTypeProblem(constraint, liveColumn)
|
|
69
|
+
if (typeProblem) {
|
|
70
|
+
mismatches.push({ table, column, problem: `${where}: ${typeProblem}.` })
|
|
71
|
+
}
|
|
72
|
+
if (liveColumn.view) continue
|
|
73
|
+
|
|
74
|
+
const nullable = Boolean(constraint.nullable) && !constraint.primary
|
|
75
|
+
if (nullable !== liveColumn.nullable) {
|
|
76
|
+
mismatches.push({
|
|
77
|
+
table,
|
|
78
|
+
column,
|
|
79
|
+
problem: nullable
|
|
80
|
+
? `${where}: declared nullable, and the database has it NOT NULL.`
|
|
81
|
+
: `${where}: declared NOT NULL, and the database allows NULL.`,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
if (constraint.primary && !liveColumn.primary) {
|
|
85
|
+
mismatches.push({
|
|
86
|
+
table,
|
|
87
|
+
column,
|
|
88
|
+
problem: `${where}: declared the primary key, and it is not the database's.`,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return mismatches
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The `Field.Sql` columns of a schema, as `table.column`, for classic sync to refuse. */
|
|
97
|
+
export function sqlColumnsIn(declared: SyncTypes.DBConstraints): string[] {
|
|
98
|
+
const found: string[] = []
|
|
99
|
+
for (const [table, constraints] of Object.entries(declared)) {
|
|
100
|
+
for (const [column, def] of Object.entries(constraints)) {
|
|
101
|
+
if (column.startsWith('_') || !def || typeof def !== 'object') continue
|
|
102
|
+
if ((def as SyncTypes.ColumnConstraint).type === 'sql') {
|
|
103
|
+
found.push(`${table}.${column}`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return found
|
|
108
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A migration file: SQL, named for the order it runs in, run once.
|
|
6
|
+
*
|
|
7
|
+
* `0001_rooms.sql`, `0002_campus_check.sql`: a number, an underscore, a name.
|
|
8
|
+
* Files run in the order of their numbers (so `9_x` comes before `10_y`), and
|
|
9
|
+
* by name where two share a number, which is what two branches that each
|
|
10
|
+
* added `0007_…` produce when they merge.
|
|
11
|
+
*/
|
|
12
|
+
export interface MigrationFile {
|
|
13
|
+
/** The file name, which is also the file's key in the ledger. */
|
|
14
|
+
name: string
|
|
15
|
+
/** The number before the underscore, as a bigint so no prefix can overflow. */
|
|
16
|
+
order: bigint
|
|
17
|
+
path: string
|
|
18
|
+
/** The text with a byte-order mark and CRLF line endings removed. */
|
|
19
|
+
text: string
|
|
20
|
+
/** SHA-256 of `text`, hex. What the ledger compares on every run. */
|
|
21
|
+
checksum: string
|
|
22
|
+
/**
|
|
23
|
+
* False when the file's first line is `-- bakery:no-transaction`, for a
|
|
24
|
+
* statement no transaction can hold (`CREATE INDEX CONCURRENTLY`). Such a
|
|
25
|
+
* file may hold only that one statement.
|
|
26
|
+
*/
|
|
27
|
+
transaction: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `<digits>_<name>.sql`. */
|
|
31
|
+
const MIGRATION_NAME = /^(\d+)_[A-Za-z0-9._-]+\.sql$/
|
|
32
|
+
|
|
33
|
+
const NO_TRANSACTION = /^--\s*bakery:\s*no-transaction\s*$/i
|
|
34
|
+
|
|
35
|
+
export type ReadResult =
|
|
36
|
+
| { ok: true; files: MigrationFile[] }
|
|
37
|
+
| { ok: false; problems: string[] }
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every migration in `dir`, in the order they run.
|
|
41
|
+
*
|
|
42
|
+
* A `.sql` file whose name does not fit is refused rather than skipped: a
|
|
43
|
+
* misnamed migration that is silently ignored is a schema change that never
|
|
44
|
+
* happens, discovered in production.
|
|
45
|
+
*/
|
|
46
|
+
export async function readMigrations(dir: string): Promise<ReadResult> {
|
|
47
|
+
let names: string[]
|
|
48
|
+
try {
|
|
49
|
+
names = await readdir(dir)
|
|
50
|
+
} catch (error: any) {
|
|
51
|
+
if (error?.code === 'ENOENT') {
|
|
52
|
+
return { ok: false, problems: [`${dir} does not exist.`] }
|
|
53
|
+
}
|
|
54
|
+
throw error
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const problems: string[] = []
|
|
58
|
+
const files: MigrationFile[] = []
|
|
59
|
+
for (const name of names) {
|
|
60
|
+
if (!name.toLowerCase().endsWith('.sql')) continue
|
|
61
|
+
const match = MIGRATION_NAME.exec(name)
|
|
62
|
+
if (!match) {
|
|
63
|
+
problems.push(
|
|
64
|
+
`${name} is not named <number>_<name>.sql, so it has no place in the order.`,
|
|
65
|
+
)
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
const path = join(dir, name)
|
|
69
|
+
const text = normalize(await Bun.file(path).text())
|
|
70
|
+
files.push({
|
|
71
|
+
name,
|
|
72
|
+
order: BigInt(match[1]!),
|
|
73
|
+
path,
|
|
74
|
+
text,
|
|
75
|
+
checksum: checksumOf(text),
|
|
76
|
+
transaction: !NO_TRANSACTION.test(firstLine(text)),
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (problems.length) return { ok: false, problems }
|
|
81
|
+
files.sort(compareMigrations)
|
|
82
|
+
return { ok: true, files }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Run order: by number, then by name. */
|
|
86
|
+
export function compareMigrations(
|
|
87
|
+
a: Pick<MigrationFile, 'order' | 'name'>,
|
|
88
|
+
b: Pick<MigrationFile, 'order' | 'name'>,
|
|
89
|
+
): number {
|
|
90
|
+
if (a.order !== b.order) return a.order < b.order ? -1 : 1
|
|
91
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The text the checksum is taken over. A byte-order mark and CRLF line endings
|
|
96
|
+
* are an editor's or a checkout's, not the migration's: a repository cloned on
|
|
97
|
+
* Windows with `core.autocrlf` must not make every applied file look edited.
|
|
98
|
+
*/
|
|
99
|
+
export function normalize(text: string): string {
|
|
100
|
+
return text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function checksumOf(text: string): string {
|
|
104
|
+
return new Bun.CryptoHasher('sha256').update(text).digest('hex')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function firstLine(text: string): string {
|
|
108
|
+
const trimmed = text.trimStart()
|
|
109
|
+
const end = trimmed.indexOf('\n')
|
|
110
|
+
return (end === -1 ? trimmed : trimmed.slice(0, end)).trim()
|
|
111
|
+
}
|