@pikku/migrator-sql 0.12.2 → 0.12.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @pikku/migrator-sql
2
2
 
3
+ ## 0.12.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 7262b4c: `db generate` now creates a referenced table before the one referencing it
8
+
9
+ ## 0.12.3
10
+
11
+ ### Patch Changes
12
+
13
+ - b2e038b: Rename `@pikku/sql-migrator` to `@pikku/migrator-sql`, so a future migrator for
14
+ another store sorts beside it rather than under a second prefix. The package has
15
+ never been published under either name, so nothing depends on the old one.
16
+
3
17
  ## 0.12.2
4
18
 
5
19
  ### Patch Changes
@@ -10,4 +10,4 @@
10
10
  */
11
11
  export { migrate, baselineMigrations, pendingMigrations, MigrationDriftError, MIGRATION_TRACKING_TABLE, type MigrationExecutor, type MigrateResult, type AppliedMigration, } from './sql-migrator.js';
12
12
  export { assertSnakeCaseIdentifiers, findCamelCaseIdentifiers, stripSqlComments, CamelCaseIdentifierError, type CamelCaseIdentifier, } from './migration-identifiers.js';
13
- export { splitStatements, bareTableName, tableCreationSql, } from './schema-sql.js';
13
+ export { splitStatements, bareTableName, tableCreationSql, tablesInSourceOrder, } from './schema-sql.js';
package/dist/src/index.js CHANGED
@@ -10,4 +10,4 @@
10
10
  */
11
11
  export { migrate, baselineMigrations, pendingMigrations, MigrationDriftError, MIGRATION_TRACKING_TABLE, } from './sql-migrator.js';
12
12
  export { assertSnakeCaseIdentifiers, findCamelCaseIdentifiers, stripSqlComments, CamelCaseIdentifierError, } from './migration-identifiers.js';
13
- export { splitStatements, bareTableName, tableCreationSql, } from './schema-sql.js';
13
+ export { splitStatements, bareTableName, tableCreationSql, tablesInSourceOrder, } from './schema-sql.js';
@@ -41,3 +41,14 @@ export declare function bareTableName(name: string): string;
41
41
  * expected to fall back rather than emit nothing.
42
42
  */
43
43
  export declare function tableCreationSql(sql: string, table: string): string[];
44
+ /**
45
+ * `tables`, in the order `sql` creates them.
46
+ *
47
+ * A diff hands its missing tables back in whatever order it walked them, which
48
+ * is usually alphabetical and is never the order they can be created in: a
49
+ * table that references another has to come after it, and `channel_subscriptions`
50
+ * sorts before `channels`. The source's own SQL already has a workable order —
51
+ * it was applied in it — so that is the order used, with anything the source
52
+ * does not visibly create left at the end for the caller to render its own way.
53
+ */
54
+ export declare function tablesInSourceOrder(sql: string, tables: string[]): string[];
@@ -133,3 +133,27 @@ export function tableCreationSql(sql, table) {
133
133
  // and their presence says the table came from somewhere this cannot read.
134
134
  return creates ? statements : [];
135
135
  }
136
+ /**
137
+ * `tables`, in the order `sql` creates them.
138
+ *
139
+ * A diff hands its missing tables back in whatever order it walked them, which
140
+ * is usually alphabetical and is never the order they can be created in: a
141
+ * table that references another has to come after it, and `channel_subscriptions`
142
+ * sorts before `channels`. The source's own SQL already has a workable order —
143
+ * it was applied in it — so that is the order used, with anything the source
144
+ * does not visibly create left at the end for the caller to render its own way.
145
+ */
146
+ export function tablesInSourceOrder(sql, tables) {
147
+ const position = new Map();
148
+ let index = 0;
149
+ for (const statement of splitStatements(sql)) {
150
+ const create = CREATE_TABLE.exec(statement);
151
+ if (create) {
152
+ const name = bareTableName(create[1]);
153
+ if (!position.has(name))
154
+ position.set(name, index++);
155
+ }
156
+ }
157
+ const rank = (table) => position.get(bareTableName(table)) ?? Number.MAX_SAFE_INTEGER;
158
+ return [...tables].sort((a, b) => rank(a) - rank(b));
159
+ }
@@ -0,0 +1 @@
1
+ {"root":["../src/index.ts","../src/migration-identifiers.ts","../src/schema-sql.ts","../src/sql-migrator.ts","../src/postgres/index.ts","../src/postgres/postgres-migrator.ts","../src/sqlite/index.ts","../src/sqlite/sqlite-migrator.ts","../src/sqlite/sqlite-runtime-bun.ts","../src/sqlite/sqlite-runtime-node.ts","../src/sqlite/sqlite-runtime.ts"],"version":"6.0.3"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/migrator-sql",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
4
4
  "description": "The SQL migration applier shared by the Pikku CLI and the standalone runtime",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -30,4 +30,4 @@
30
30
  "./sqlite": "./dist/src/sqlite/index.js",
31
31
  "./postgres": "./dist/src/postgres/index.js"
32
32
  }
33
- }
33
+ }
package/run-tests.sh CHANGED
File without changes
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The SQL migration applier, and nothing that knows where it is running.
3
+ *
4
+ * Two programs apply the same migrations to the same database: `pikku db
5
+ * migrate` from a checkout, and a shipped standalone bundle from a machine that
6
+ * has no checkout. They have to agree on the bookkeeping table, the hash, and
7
+ * the file order, because the second runs against a database the first has
8
+ * already written to — a second implementation that differs in any of the three
9
+ * reports every migration the other applied as drifted.
10
+ */
11
+ export {
12
+ migrate,
13
+ baselineMigrations,
14
+ pendingMigrations,
15
+ MigrationDriftError,
16
+ MIGRATION_TRACKING_TABLE,
17
+ type MigrationExecutor,
18
+ type MigrateResult,
19
+ type AppliedMigration,
20
+ } from './sql-migrator.js'
21
+
22
+ export {
23
+ assertSnakeCaseIdentifiers,
24
+ findCamelCaseIdentifiers,
25
+ stripSqlComments,
26
+ CamelCaseIdentifierError,
27
+ type CamelCaseIdentifier,
28
+ } from './migration-identifiers.js'
29
+
30
+ export {
31
+ splitStatements,
32
+ bareTableName,
33
+ tableCreationSql,
34
+ tablesInSourceOrder,
35
+ } from './schema-sql.js'
@@ -0,0 +1,142 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ assertSnakeCaseIdentifiers,
6
+ CamelCaseIdentifierError,
7
+ findCamelCaseIdentifiers,
8
+ } from './migration-identifiers.js'
9
+
10
+ test('a snake_case migration declares nothing to complain about', () => {
11
+ const sql = `CREATE TABLE animal (
12
+ id TEXT NOT NULL PRIMARY KEY,
13
+ price_cents INTEGER NOT NULL,
14
+ weight NUMERIC(10,2),
15
+ status TEXT CHECK (status IN ('for_sale','sold')) DEFAULT 'for_sale',
16
+ created_at DATE NOT NULL,
17
+ CONSTRAINT animal_price_positive CHECK (price_cents > 0),
18
+ FOREIGN KEY (id) REFERENCES owner (id)
19
+ );
20
+ CREATE INDEX animal_status_idx ON animal (status);`
21
+
22
+ assert.deepEqual(findCamelCaseIdentifiers('0001-animal.sql', sql), [])
23
+ })
24
+
25
+ test('a camelCase column is reported with its snake_case name', () => {
26
+ const sql = 'CREATE TABLE animal (id TEXT PRIMARY KEY, priceCents INTEGER);'
27
+
28
+ assert.deepEqual(findCamelCaseIdentifiers('0001-animal.sql', sql), [
29
+ {
30
+ file: '0001-animal.sql',
31
+ table: 'animal',
32
+ column: 'priceCents',
33
+ suggestion: 'price_cents',
34
+ },
35
+ ])
36
+ })
37
+
38
+ test('the Better Auth schema quoting style passes as written', () => {
39
+ const sql =
40
+ 'create table "user" ("id" text not null primary key, "email_verified" integer not null, "created_at" date not null);'
41
+
42
+ assert.deepEqual(findCamelCaseIdentifiers('0001-better-auth.sql', sql), [])
43
+ })
44
+
45
+ test('quoting a camelCase column does not exempt it', () => {
46
+ const sql = 'create table "user" ("id" text, "emailVerified" integer);'
47
+
48
+ assert.deepEqual(findCamelCaseIdentifiers('0001-better-auth.sql', sql), [
49
+ {
50
+ file: '0001-better-auth.sql',
51
+ table: 'user',
52
+ column: 'emailVerified',
53
+ suggestion: 'email_verified',
54
+ },
55
+ ])
56
+ })
57
+
58
+ test('a camelCase table name is reported on its own', () => {
59
+ const sql = 'CREATE TABLE animalSale (id TEXT PRIMARY KEY);'
60
+
61
+ assert.deepEqual(findCamelCaseIdentifiers('0001-sales.sql', sql), [
62
+ {
63
+ file: '0001-sales.sql',
64
+ table: 'animalSale',
65
+ column: null,
66
+ suggestion: 'animal_sale',
67
+ },
68
+ ])
69
+ })
70
+
71
+ test('camelCase inside comments and string literals is not a declaration', () => {
72
+ const sql = `-- priceCents used to live here; renamed for CamelCasePlugin
73
+ /* the createdAt column is a date,
74
+ not a datetime */
75
+ CREATE TABLE animal (
76
+ id TEXT PRIMARY KEY, -- was animalId
77
+ status TEXT NOT NULL DEFAULT 'forSale'
78
+ );`
79
+
80
+ assert.deepEqual(findCamelCaseIdentifiers('0001-animal.sql', sql), [])
81
+ })
82
+
83
+ test('ALTER TABLE ADD COLUMN is a declaration too', () => {
84
+ const sql = `ALTER TABLE animal ADD COLUMN soldAt DATE;
85
+ ALTER TABLE "animal" ADD priceCents INTEGER NOT NULL DEFAULT 0;
86
+ ALTER TABLE animal ADD COLUMN IF NOT EXISTS ownerId TEXT;`
87
+
88
+ assert.deepEqual(
89
+ findCamelCaseIdentifiers('0002-animal.sql', sql).map((o) => o.column),
90
+ ['soldAt', 'priceCents', 'ownerId']
91
+ )
92
+ })
93
+
94
+ test('an ALTER that adds a constraint declares no column', () => {
95
+ const sql = `ALTER TABLE animal ADD CONSTRAINT animalPriceCheck CHECK (price_cents > 0);
96
+ ALTER TABLE animal ADD PRIMARY KEY (id);
97
+ ALTER TABLE animal RENAME TO beast;`
98
+
99
+ assert.deepEqual(findCamelCaseIdentifiers('0003-animal.sql', sql), [])
100
+ })
101
+
102
+ test('every offender across every file is reported at once', () => {
103
+ const migrations = [
104
+ {
105
+ name: '0001-animal.sql',
106
+ sql: 'CREATE TABLE animal (id TEXT, priceCents INTEGER, soldAt DATE);',
107
+ },
108
+ {
109
+ name: '0002-owner.sql',
110
+ sql: 'ALTER TABLE owner ADD COLUMN displayName TEXT;',
111
+ },
112
+ ]
113
+
114
+ try {
115
+ assertSnakeCaseIdentifiers(migrations)
116
+ assert.fail('expected a CamelCaseIdentifierError')
117
+ } catch (error) {
118
+ assert.ok(error instanceof CamelCaseIdentifierError)
119
+ assert.deepEqual(
120
+ error.offenders.map((o) => `${o.file}:${o.table}.${o.column}`),
121
+ [
122
+ '0001-animal.sql:animal.priceCents',
123
+ '0001-animal.sql:animal.soldAt',
124
+ '0002-owner.sql:owner.displayName',
125
+ ]
126
+ )
127
+ assert.match(error.message, /priceCents {2}→ {2}price_cents/)
128
+ assert.match(error.message, /displayName {2}→ {2}display_name/)
129
+ assert.match(error.message, /replay your dev\ndatabase from scratch/)
130
+ }
131
+ })
132
+
133
+ test('a clean set of migrations passes the assertion', () => {
134
+ assert.doesNotThrow(() =>
135
+ assertSnakeCaseIdentifiers([
136
+ {
137
+ name: '0001-animal.sql',
138
+ sql: 'CREATE TABLE animal (price_cents INT);',
139
+ },
140
+ ])
141
+ )
142
+ })
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Catching camelCase columns at migrate time, before they can half-work.
3
+ *
4
+ * Pikku's Kysely runs with `CamelCasePlugin`, so `priceCents` in TypeScript is
5
+ * `price_cents` in SQL and nothing else. A migration that declares the column
6
+ * *as* `priceCents` is not merely unconventional — it is broken in the one way
7
+ * that hides itself: `.selectAll()` compiles to `SELECT *` and never names an
8
+ * identifier, so the table reads back perfectly, while the first query that
9
+ * names the column (`.select(['animal.priceCents'])`) asks for `price_cents`
10
+ * and gets `no such column`. The usual conclusion is that the plugin is broken,
11
+ * and the usual response is a raw `sql` template or a retreat to `.selectAll()`
12
+ * — both of which keep the real cause alive.
13
+ *
14
+ * There is no exception to escape. Even the Better Auth schema `db generate`
15
+ * writes is snake_case (`email_verified`, `user_id`), because Better Auth is
16
+ * handed the app's own Kysely and its camelCase field names compile the same
17
+ * way everyone else's do.
18
+ */
19
+
20
+ import { splitStatements } from './schema-sql.js'
21
+
22
+ /** A camelCase identifier a migration declares, and what it should have said. */
23
+ export interface CamelCaseIdentifier {
24
+ file: string
25
+ table: string
26
+ /** `null` when the table name itself is the offender. */
27
+ column: string | null
28
+ suggestion: string
29
+ }
30
+
31
+ export class CamelCaseIdentifierError extends Error {
32
+ constructor(public readonly offenders: CamelCaseIdentifier[]) {
33
+ const lines = offenders.map(
34
+ (o) =>
35
+ ` ${o.file} ${o.column === null ? o.table : `${o.table}.${o.column}`} → ${o.suggestion}`
36
+ )
37
+ super(
38
+ `[PKU-DB-CAMEL] Migrations declare camelCase identifiers.\n\n` +
39
+ `Pikku's Kysely runs with CamelCasePlugin, which maps camelCase in TypeScript\n` +
40
+ `to snake_case in SQL. A camelCase column half-works: \`.selectAll()\` emits\n` +
41
+ `\`SELECT *\` so the table reads fine, but naming the column compiles it to\n` +
42
+ `snake_case and the database answers \`no such column\`.\n\n` +
43
+ `${lines.join('\n')}\n\n` +
44
+ `Fix the column definition in the migration file itself and replay your dev\n` +
45
+ `database from scratch. Do not write a RENAME COLUMN migration — that leaves\n` +
46
+ `the banned identifier in a SQL file forever.`
47
+ )
48
+ this.name = 'CamelCaseIdentifierError'
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Strip comments so a word in prose can never be read as an identifier.
54
+ *
55
+ * `splitStatements` already skips over comments when it looks for a boundary,
56
+ * but it hands back the statement with them still in it, and `-- the userId
57
+ * column` would otherwise be flagged. Quoting rules are honoured for the same
58
+ * reason they are there: `'a -- b'` is a string, not a comment.
59
+ */
60
+ export function stripSqlComments(sql: string): string {
61
+ let out = ''
62
+ let i = 0
63
+
64
+ while (i < sql.length) {
65
+ const ch = sql[i]!
66
+
67
+ if (ch === "'" || ch === '"' || ch === '`') {
68
+ const start = i
69
+ i++
70
+ while (i < sql.length) {
71
+ if (sql[i] === '\\' && ch === "'") {
72
+ i += 2
73
+ continue
74
+ }
75
+ if (sql[i] === ch) {
76
+ if (sql[i + 1] === ch) {
77
+ i += 2
78
+ continue
79
+ }
80
+ i++
81
+ break
82
+ }
83
+ i++
84
+ }
85
+ out += sql.slice(start, i)
86
+ continue
87
+ }
88
+
89
+ if (ch === '-' && sql[i + 1] === '-') {
90
+ const end = sql.indexOf('\n', i)
91
+ i = end === -1 ? sql.length : end
92
+ continue
93
+ }
94
+
95
+ if (ch === '/' && sql[i + 1] === '*') {
96
+ const end = sql.indexOf('*/', i + 2)
97
+ i = end === -1 ? sql.length : end + 2
98
+ // A block comment can span lines, and removing it outright would join two
99
+ // statements onto one line. A space keeps the tokens either side apart.
100
+ out += ' '
101
+ continue
102
+ }
103
+
104
+ out += ch
105
+ i++
106
+ }
107
+
108
+ return out
109
+ }
110
+
111
+ const IDENTIFIER = String.raw`"[^"]*"|\`[^\`]*\`|\[[^\]]*\]|[A-Za-z_][A-Za-z_0-9$]*`
112
+
113
+ const CREATE_TABLE = new RegExp(
114
+ String.raw`^CREATE\s+(?:TEMP(?:ORARY)?\s+|UNLOGGED\s+)*TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\S+?)\s*\(`,
115
+ 'i'
116
+ )
117
+
118
+ const ALTER_TABLE = new RegExp(
119
+ String.raw`^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(\S+)`,
120
+ 'i'
121
+ )
122
+
123
+ const ADD_COLUMN = new RegExp(
124
+ String.raw`^\s*ADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(${IDENTIFIER})`,
125
+ 'i'
126
+ )
127
+
128
+ const COLUMN_NAME = new RegExp(String.raw`^\s*(${IDENTIFIER})`)
129
+
130
+ /**
131
+ * The words that open a table-level constraint rather than a column.
132
+ *
133
+ * Only consulted for an unquoted first token: `"check"` in quotes is a column
134
+ * named check, however unwise, and skipping it would let `"checkedAt"`'s
135
+ * neighbours hide behind it.
136
+ */
137
+ const CONSTRAINT_KEYWORDS = new Set([
138
+ 'constraint',
139
+ 'primary',
140
+ 'foreign',
141
+ 'unique',
142
+ 'check',
143
+ 'exclude',
144
+ 'index',
145
+ 'key',
146
+ 'like',
147
+ 'period',
148
+ 'fulltext',
149
+ 'spatial',
150
+ ])
151
+
152
+ /** Drop the quoting a dialect happens to use, leaving the identifier itself. */
153
+ const unquote = (name: string): string =>
154
+ /^["`[]/.test(name) ? name.slice(1, -1) : name
155
+
156
+ const isCamelCase = (name: string): boolean => /[a-z][A-Z]/.test(name)
157
+
158
+ const toSnakeCase = (name: string): string =>
159
+ name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
160
+
161
+ /**
162
+ * Walk `sql` from `from` and return the offsets just inside and just outside
163
+ * the parenthesised group that starts there, or `null` if it never closes.
164
+ *
165
+ * Quote-aware, because a `)` inside `DEFAULT ')'` closes nothing.
166
+ */
167
+ function parenSpan(
168
+ sql: string,
169
+ from: number
170
+ ): { start: number; end: number } | null {
171
+ let depth = 0
172
+ let i = from
173
+
174
+ while (i < sql.length) {
175
+ const ch = sql[i]!
176
+
177
+ if (ch === "'" || ch === '"' || ch === '`') {
178
+ i++
179
+ while (i < sql.length && sql[i] !== ch) i++
180
+ i++
181
+ continue
182
+ }
183
+
184
+ if (ch === '(') {
185
+ depth++
186
+ if (depth === 1) from = i + 1
187
+ } else if (ch === ')') {
188
+ depth--
189
+ if (depth === 0) return { start: from, end: i }
190
+ }
191
+
192
+ i++
193
+ }
194
+
195
+ return null
196
+ }
197
+
198
+ /**
199
+ * Split a definition list on the commas that separate its items.
200
+ *
201
+ * The commas inside `NUMERIC(10,2)`, a `CHECK (x IN ('a','b'))` or a compound
202
+ * `PRIMARY KEY (a, b)` belong to those and not to the list, so only depth zero
203
+ * counts.
204
+ */
205
+ function splitTopLevel(list: string): string[] {
206
+ const items: string[] = []
207
+ let depth = 0
208
+ let start = 0
209
+ let i = 0
210
+
211
+ while (i < list.length) {
212
+ const ch = list[i]!
213
+
214
+ if (ch === "'" || ch === '"' || ch === '`') {
215
+ i++
216
+ while (i < list.length && list[i] !== ch) i++
217
+ i++
218
+ continue
219
+ }
220
+
221
+ if (ch === '(') depth++
222
+ else if (ch === ')') depth--
223
+ else if (ch === ',' && depth === 0) {
224
+ items.push(list.slice(start, i))
225
+ start = i + 1
226
+ }
227
+
228
+ i++
229
+ }
230
+
231
+ items.push(list.slice(start))
232
+ return items.filter((item) => item.trim().length > 0)
233
+ }
234
+
235
+ /**
236
+ * Every camelCase identifier one migration file declares.
237
+ *
238
+ * Only declarations are read — a `CREATE TABLE` column list and an `ALTER TABLE
239
+ * … ADD COLUMN`. Everywhere else an identifier merely refers to something that
240
+ * was declared somewhere, and reporting those would name the same mistake once
241
+ * per reference while adding nothing to the fix.
242
+ */
243
+ export function findCamelCaseIdentifiers(
244
+ file: string,
245
+ sql: string
246
+ ): CamelCaseIdentifier[] {
247
+ const offenders: CamelCaseIdentifier[] = []
248
+
249
+ for (const statement of splitStatements(stripSqlComments(sql))) {
250
+ const create = CREATE_TABLE.exec(statement)
251
+ if (create) {
252
+ const table = unquote(create[1]!.split('.').pop()!)
253
+ if (isCamelCase(table)) {
254
+ offenders.push({
255
+ file,
256
+ table,
257
+ column: null,
258
+ suggestion: toSnakeCase(table),
259
+ })
260
+ }
261
+
262
+ const span = parenSpan(statement, create.index + create[0]!.length - 1)
263
+ if (!span) continue
264
+
265
+ for (const item of splitTopLevel(statement.slice(span.start, span.end))) {
266
+ const name = COLUMN_NAME.exec(item)?.[1]
267
+ if (!name) continue
268
+ if (CONSTRAINT_KEYWORDS.has(name.toLowerCase())) continue
269
+ const column = unquote(name)
270
+ if (isCamelCase(column)) {
271
+ offenders.push({
272
+ file,
273
+ table,
274
+ column,
275
+ suggestion: toSnakeCase(column),
276
+ })
277
+ }
278
+ }
279
+ continue
280
+ }
281
+
282
+ const alter = ALTER_TABLE.exec(statement)
283
+ if (!alter) continue
284
+
285
+ const table = unquote(alter[1]!.split('.').pop()!)
286
+ // Postgres lets one ALTER carry several actions, and only the ADDs declare.
287
+ for (const action of splitTopLevel(
288
+ statement.slice(alter[0]!.length).replace(/;\s*$/, '')
289
+ )) {
290
+ const name = ADD_COLUMN.exec(action)?.[1]
291
+ if (!name) continue
292
+ if (CONSTRAINT_KEYWORDS.has(name.toLowerCase())) continue
293
+ const column = unquote(name)
294
+ if (isCamelCase(column)) {
295
+ offenders.push({
296
+ file,
297
+ table,
298
+ column,
299
+ suggestion: toSnakeCase(column),
300
+ })
301
+ }
302
+ }
303
+ }
304
+
305
+ return offenders
306
+ }
307
+
308
+ /**
309
+ * Bail unless every migration on disk is snake_case throughout.
310
+ *
311
+ * Reports all of them at once: a camelCase column is rarely alone, and a
312
+ * one-at-a-time failure turns a single edit into one migrate run per column.
313
+ */
314
+ export function assertSnakeCaseIdentifiers(
315
+ migrations: Array<{ name: string; sql: string }>
316
+ ): void {
317
+ const offenders = migrations.flatMap(({ name, sql }) =>
318
+ findCamelCaseIdentifiers(name, sql)
319
+ )
320
+ if (offenders.length > 0) throw new CamelCaseIdentifierError(offenders)
321
+ }
@@ -0,0 +1,4 @@
1
+ export {
2
+ PostgresMigrationExecutor,
3
+ type PostgresMigrationClient,
4
+ } from './postgres-migrator.js'
@@ -0,0 +1,81 @@
1
+ import type { MigrationExecutor, AppliedMigration } from '../sql-migrator.js'
2
+ import { MIGRATION_TRACKING_TABLE as TRACKING_TABLE } from '../sql-migrator.js'
3
+
4
+ export interface PostgresMigrationClient {
5
+ query<T = unknown>(sql: string, params?: unknown[]): Promise<{ rows: T[] }>
6
+ exec?(sql: string): Promise<unknown>
7
+ /**
8
+ * Run a migration and its bookkeeping row on one connection.
9
+ *
10
+ * A pooled client is free to answer `BEGIN`, the migration and `COMMIT` on
11
+ * three different connections, which leaves a transaction open on one and the
12
+ * DDL committed outside it on another — a failed migration then stays half
13
+ * applied with nothing to roll back. A client that hands out a connection
14
+ * implements this; one that only ever has a single connection does not need
15
+ * to, and the statement pair below is correct for it.
16
+ */
17
+ begin?<T>(handler: (client: PostgresMigrationClient) => Promise<T>): Promise<T>
18
+ }
19
+
20
+ export class PostgresMigrationExecutor implements MigrationExecutor {
21
+ constructor(private readonly client: PostgresMigrationClient) {}
22
+
23
+ async ensureTrackingTable(): Promise<void> {
24
+ await this.client.query(`
25
+ CREATE TABLE IF NOT EXISTS ${TRACKING_TABLE} (
26
+ name TEXT PRIMARY KEY,
27
+ hash TEXT NOT NULL,
28
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
29
+ )
30
+ `)
31
+ }
32
+
33
+ async getApplied(): Promise<AppliedMigration[]> {
34
+ const { rows } = await this.client.query<AppliedMigration>(
35
+ `SELECT name, hash, applied_at FROM ${TRACKING_TABLE} ORDER BY name`
36
+ )
37
+ return rows
38
+ }
39
+
40
+ async recordMigration(name: string, hash: string): Promise<void> {
41
+ await this.client.query(
42
+ `INSERT INTO ${TRACKING_TABLE} (name, hash) VALUES ($1, $2)`,
43
+ [name, hash]
44
+ )
45
+ }
46
+
47
+ async runMigration(sql: string, name: string, hash: string): Promise<void> {
48
+ if (typeof this.client.begin === 'function') {
49
+ await this.client.begin(async (tx) => {
50
+ await this.applyOn(tx, sql, name, hash)
51
+ })
52
+ return
53
+ }
54
+
55
+ await this.client.query('BEGIN')
56
+ try {
57
+ await this.applyOn(this.client, sql, name, hash)
58
+ await this.client.query('COMMIT')
59
+ } catch (err) {
60
+ await this.client.query('ROLLBACK')
61
+ throw err
62
+ }
63
+ }
64
+
65
+ private async applyOn(
66
+ client: PostgresMigrationClient,
67
+ sql: string,
68
+ name: string,
69
+ hash: string
70
+ ): Promise<void> {
71
+ if (typeof client.exec === 'function') {
72
+ await client.exec(sql)
73
+ } else {
74
+ await client.query(sql)
75
+ }
76
+ await client.query(
77
+ `INSERT INTO ${TRACKING_TABLE} (name, hash) VALUES ($1, $2)`,
78
+ [name, hash]
79
+ )
80
+ }
81
+ }