@pikku/migrator-sql 0.12.2 → 0.12.3

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,13 @@
1
1
  # @pikku/migrator-sql
2
2
 
3
+ ## 0.12.3
4
+
5
+ ### Patch Changes
6
+
7
+ - b2e038b: Rename `@pikku/sql-migrator` to `@pikku/migrator-sql`, so a future migrator for
8
+ another store sorts beside it rather than under a second prefix. The package has
9
+ never been published under either name, so nothing depends on the old one.
10
+
3
11
  ## 0.12.2
4
12
 
5
13
  ### Patch Changes
@@ -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.3",
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,34 @@
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
+ } 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
+ }
@@ -0,0 +1,66 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { splitStatements, tableCreationSql } from './schema-sql.js'
5
+
6
+ test('a semicolon inside a string literal is not a statement boundary', () => {
7
+ const sql = `CREATE TABLE a (id TEXT, note TEXT DEFAULT 'one; two');
8
+ CREATE TABLE b (id TEXT);`
9
+
10
+ assert.deepEqual(splitStatements(sql), [
11
+ `CREATE TABLE a (id TEXT, note TEXT DEFAULT 'one; two');`,
12
+ 'CREATE TABLE b (id TEXT);',
13
+ ])
14
+ })
15
+
16
+ test('a doubled quote inside a literal does not end it', () => {
17
+ const sql = `CREATE TABLE a (note TEXT DEFAULT 'it''s; fine');`
18
+ assert.deepEqual(splitStatements(sql), [sql])
19
+ })
20
+
21
+ test('semicolons in comments and dollar-quoted bodies are ignored', () => {
22
+ const sql = `-- a comment; with a semicolon
23
+ CREATE TABLE a (id TEXT);
24
+ /* another; one */
25
+ CREATE FUNCTION f() RETURNS void AS $$ BEGIN; END; $$ LANGUAGE plpgsql;`
26
+
27
+ assert.deepEqual(splitStatements(sql), [
28
+ `-- a comment; with a semicolon
29
+ CREATE TABLE a (id TEXT);`,
30
+ `/* another; one */
31
+ CREATE FUNCTION f() RETURNS void AS $$ BEGIN; END; $$ LANGUAGE plpgsql;`,
32
+ ])
33
+ })
34
+
35
+ test('a trailing statement with no semicolon still counts', () => {
36
+ assert.deepEqual(splitStatements('CREATE TABLE a (id TEXT)'), [
37
+ 'CREATE TABLE a (id TEXT)',
38
+ ])
39
+ })
40
+
41
+ test("a table's own SQL comes back with its indexes and constraints", () => {
42
+ const sql = `CREATE TABLE "user" ("id" text primary key);
43
+ CREATE TABLE "two_factor" ("id" text primary key, "user_id" text not null references "user" ("id"));
44
+ CREATE UNIQUE INDEX "two_factor_user_id_idx" ON "two_factor" ("user_id");
45
+ CREATE INDEX "user_id_idx" ON "user" ("id");
46
+ ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_secret_check" CHECK (length("id") > 0);`
47
+
48
+ assert.deepEqual(tableCreationSql(sql, 'two_factor'), [
49
+ `CREATE TABLE "two_factor" ("id" text primary key, "user_id" text not null references "user" ("id"));`,
50
+ `CREATE UNIQUE INDEX "two_factor_user_id_idx" ON "two_factor" ("user_id");`,
51
+ `ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_secret_check" CHECK (length("id") > 0);`,
52
+ ])
53
+ })
54
+
55
+ test('quoting, casing and a schema qualifier all name the same table', () => {
56
+ const sql = 'create table if not exists public."Two_Factor" (id text);'
57
+ assert.deepEqual(tableCreationSql(sql, '"two_factor"'), [sql])
58
+ assert.deepEqual(tableCreationSql(sql, 'app.two_factor'), [sql])
59
+ })
60
+
61
+ test('a table the SQL never creates yields nothing to copy', () => {
62
+ const sql = `CREATE TABLE "user" ("id" text primary key);
63
+ CREATE INDEX "orphan_idx" ON "orders" ("id");`
64
+
65
+ assert.deepEqual(tableCreationSql(sql, 'orders'), [])
66
+ })