@pikku/migrator-sql 0.12.2
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 +47 -0
- package/LICENSE +21 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.js +13 -0
- package/dist/src/migration-identifiers.d.ts +58 -0
- package/dist/src/migration-identifiers.js +262 -0
- package/dist/src/postgres/index.d.ts +1 -0
- package/dist/src/postgres/index.js +1 -0
- package/dist/src/postgres/postgres-migrator.d.ts +27 -0
- package/dist/src/postgres/postgres-migrator.js +49 -0
- package/dist/src/schema-sql.d.ts +43 -0
- package/dist/src/schema-sql.js +135 -0
- package/dist/src/sql-migrator.d.ts +71 -0
- package/dist/src/sql-migrator.js +147 -0
- package/dist/src/sqlite/index.d.ts +3 -0
- package/dist/src/sqlite/index.js +2 -0
- package/dist/src/sqlite/sqlite-migrator.d.ts +11 -0
- package/dist/src/sqlite/sqlite-migrator.js +41 -0
- package/dist/src/sqlite/sqlite-runtime-bun.d.ts +2 -0
- package/dist/src/sqlite/sqlite-runtime-bun.js +62 -0
- package/dist/src/sqlite/sqlite-runtime-node.d.ts +2 -0
- package/dist/src/sqlite/sqlite-runtime-node.js +78 -0
- package/dist/src/sqlite/sqlite-runtime.d.ts +20 -0
- package/dist/src/sqlite/sqlite-runtime.js +13 -0
- package/package.json +33 -0
- package/run-tests.sh +33 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# @pikku/migrator-sql
|
|
2
|
+
|
|
3
|
+
## 0.12.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- f970f8f: Rename `@pikku/db-migrator` to `@pikku/migrator-sql`.
|
|
8
|
+
|
|
9
|
+
It applies `.sql` files and keeps their bookkeeping; it is not a database
|
|
10
|
+
service, and `db-` read as though it were one. Nothing was ever published under
|
|
11
|
+
the old name, so there is no alias to keep.
|
|
12
|
+
|
|
13
|
+
## 0.12.1
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- a057bec: Extract the SQL migration applier into `@pikku/migrator-sql`, so the CLI is no longer the only thing that can run one.
|
|
18
|
+
|
|
19
|
+
A shipped standalone bundle has to apply the same migrations to the same database as `pikku db migrate`, from a machine with no checkout. That only works if both agree on the bookkeeping table, the file hash and the file order — a second implementation that differs in any of the three reads every migration the other applied as drifted and refuses to go on.
|
|
20
|
+
|
|
21
|
+
Nothing about the CLI's behaviour changes; the algorithm, the `sql_migrations` table and the drift error moved intact.
|
|
22
|
+
|
|
23
|
+
- a057bec: Give a standalone bundle a command line.
|
|
24
|
+
|
|
25
|
+
An operator holding a standalone artifact on a machine had one thing they could
|
|
26
|
+
do with it: start it. Applying the migrations it needs meant a checkout of the
|
|
27
|
+
project and a second copy of the CLI on a production box, and answering "which
|
|
28
|
+
build is this" meant asking whoever ran the deploy.
|
|
29
|
+
|
|
30
|
+
The bundle now takes a command. `serve` remains the default, so an existing
|
|
31
|
+
`node bundle.js` is unchanged. `version` prints the version the project declared
|
|
32
|
+
at build time. `db migrate` and `db status` apply and report the migrations that
|
|
33
|
+
now ship beside the bundle under `db/<engine>/` — the same path Fabric's build
|
|
34
|
+
container stages them to, so the two producers of an artifact cannot disagree
|
|
35
|
+
about where the SQL lives. `backup <path>` writes a consistent copy of a SQLite
|
|
36
|
+
database with `VACUUM INTO`; on postgres it refuses and names `pg_dump`, which
|
|
37
|
+
is the tool for it.
|
|
38
|
+
|
|
39
|
+
Both engines are supported: a postgres build migrates over the connection it
|
|
40
|
+
already opens. `PostgresMigrationClient` grew an optional `begin`, because a
|
|
41
|
+
pooled client is free to answer `BEGIN`, the migration and `COMMIT` on three
|
|
42
|
+
different connections — which leaves a failed migration half applied with
|
|
43
|
+
nothing to roll back.
|
|
44
|
+
|
|
45
|
+
There is deliberately no way to invoke an RPC. A running server already answers
|
|
46
|
+
them with auth, sessions and middleware applied; an in-process invoke would
|
|
47
|
+
answer them with none of that.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 - present Yasser Fadl and Pikku contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,13 @@
|
|
|
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 { migrate, baselineMigrations, pendingMigrations, MigrationDriftError, MIGRATION_TRACKING_TABLE, type MigrationExecutor, type MigrateResult, type AppliedMigration, } from './sql-migrator.js';
|
|
12
|
+
export { assertSnakeCaseIdentifiers, findCamelCaseIdentifiers, stripSqlComments, CamelCaseIdentifierError, type CamelCaseIdentifier, } from './migration-identifiers.js';
|
|
13
|
+
export { splitStatements, bareTableName, tableCreationSql, } from './schema-sql.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
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 { migrate, baselineMigrations, pendingMigrations, MigrationDriftError, MIGRATION_TRACKING_TABLE, } from './sql-migrator.js';
|
|
12
|
+
export { assertSnakeCaseIdentifiers, findCamelCaseIdentifiers, stripSqlComments, CamelCaseIdentifierError, } from './migration-identifiers.js';
|
|
13
|
+
export { splitStatements, bareTableName, tableCreationSql, } from './schema-sql.js';
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
/** A camelCase identifier a migration declares, and what it should have said. */
|
|
20
|
+
export interface CamelCaseIdentifier {
|
|
21
|
+
file: string;
|
|
22
|
+
table: string;
|
|
23
|
+
/** `null` when the table name itself is the offender. */
|
|
24
|
+
column: string | null;
|
|
25
|
+
suggestion: string;
|
|
26
|
+
}
|
|
27
|
+
export declare class CamelCaseIdentifierError extends Error {
|
|
28
|
+
readonly offenders: CamelCaseIdentifier[];
|
|
29
|
+
constructor(offenders: CamelCaseIdentifier[]);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Strip comments so a word in prose can never be read as an identifier.
|
|
33
|
+
*
|
|
34
|
+
* `splitStatements` already skips over comments when it looks for a boundary,
|
|
35
|
+
* but it hands back the statement with them still in it, and `-- the userId
|
|
36
|
+
* column` would otherwise be flagged. Quoting rules are honoured for the same
|
|
37
|
+
* reason they are there: `'a -- b'` is a string, not a comment.
|
|
38
|
+
*/
|
|
39
|
+
export declare function stripSqlComments(sql: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Every camelCase identifier one migration file declares.
|
|
42
|
+
*
|
|
43
|
+
* Only declarations are read — a `CREATE TABLE` column list and an `ALTER TABLE
|
|
44
|
+
* … ADD COLUMN`. Everywhere else an identifier merely refers to something that
|
|
45
|
+
* was declared somewhere, and reporting those would name the same mistake once
|
|
46
|
+
* per reference while adding nothing to the fix.
|
|
47
|
+
*/
|
|
48
|
+
export declare function findCamelCaseIdentifiers(file: string, sql: string): CamelCaseIdentifier[];
|
|
49
|
+
/**
|
|
50
|
+
* Bail unless every migration on disk is snake_case throughout.
|
|
51
|
+
*
|
|
52
|
+
* Reports all of them at once: a camelCase column is rarely alone, and a
|
|
53
|
+
* one-at-a-time failure turns a single edit into one migrate run per column.
|
|
54
|
+
*/
|
|
55
|
+
export declare function assertSnakeCaseIdentifiers(migrations: Array<{
|
|
56
|
+
name: string;
|
|
57
|
+
sql: string;
|
|
58
|
+
}>): void;
|
|
@@ -0,0 +1,262 @@
|
|
|
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
|
+
import { splitStatements } from './schema-sql.js';
|
|
20
|
+
export class CamelCaseIdentifierError extends Error {
|
|
21
|
+
offenders;
|
|
22
|
+
constructor(offenders) {
|
|
23
|
+
const lines = offenders.map((o) => ` ${o.file} ${o.column === null ? o.table : `${o.table}.${o.column}`} → ${o.suggestion}`);
|
|
24
|
+
super(`[PKU-DB-CAMEL] Migrations declare camelCase identifiers.\n\n` +
|
|
25
|
+
`Pikku's Kysely runs with CamelCasePlugin, which maps camelCase in TypeScript\n` +
|
|
26
|
+
`to snake_case in SQL. A camelCase column half-works: \`.selectAll()\` emits\n` +
|
|
27
|
+
`\`SELECT *\` so the table reads fine, but naming the column compiles it to\n` +
|
|
28
|
+
`snake_case and the database answers \`no such column\`.\n\n` +
|
|
29
|
+
`${lines.join('\n')}\n\n` +
|
|
30
|
+
`Fix the column definition in the migration file itself and replay your dev\n` +
|
|
31
|
+
`database from scratch. Do not write a RENAME COLUMN migration — that leaves\n` +
|
|
32
|
+
`the banned identifier in a SQL file forever.`);
|
|
33
|
+
this.offenders = offenders;
|
|
34
|
+
this.name = 'CamelCaseIdentifierError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Strip comments so a word in prose can never be read as an identifier.
|
|
39
|
+
*
|
|
40
|
+
* `splitStatements` already skips over comments when it looks for a boundary,
|
|
41
|
+
* but it hands back the statement with them still in it, and `-- the userId
|
|
42
|
+
* column` would otherwise be flagged. Quoting rules are honoured for the same
|
|
43
|
+
* reason they are there: `'a -- b'` is a string, not a comment.
|
|
44
|
+
*/
|
|
45
|
+
export function stripSqlComments(sql) {
|
|
46
|
+
let out = '';
|
|
47
|
+
let i = 0;
|
|
48
|
+
while (i < sql.length) {
|
|
49
|
+
const ch = sql[i];
|
|
50
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
51
|
+
const start = i;
|
|
52
|
+
i++;
|
|
53
|
+
while (i < sql.length) {
|
|
54
|
+
if (sql[i] === '\\' && ch === "'") {
|
|
55
|
+
i += 2;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (sql[i] === ch) {
|
|
59
|
+
if (sql[i + 1] === ch) {
|
|
60
|
+
i += 2;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
i++;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
i++;
|
|
67
|
+
}
|
|
68
|
+
out += sql.slice(start, i);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (ch === '-' && sql[i + 1] === '-') {
|
|
72
|
+
const end = sql.indexOf('\n', i);
|
|
73
|
+
i = end === -1 ? sql.length : end;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (ch === '/' && sql[i + 1] === '*') {
|
|
77
|
+
const end = sql.indexOf('*/', i + 2);
|
|
78
|
+
i = end === -1 ? sql.length : end + 2;
|
|
79
|
+
// A block comment can span lines, and removing it outright would join two
|
|
80
|
+
// statements onto one line. A space keeps the tokens either side apart.
|
|
81
|
+
out += ' ';
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
out += ch;
|
|
85
|
+
i++;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
const IDENTIFIER = String.raw `"[^"]*"|\`[^\`]*\`|\[[^\]]*\]|[A-Za-z_][A-Za-z_0-9$]*`;
|
|
90
|
+
const CREATE_TABLE = new RegExp(String.raw `^CREATE\s+(?:TEMP(?:ORARY)?\s+|UNLOGGED\s+)*TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\S+?)\s*\(`, 'i');
|
|
91
|
+
const ALTER_TABLE = new RegExp(String.raw `^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(\S+)`, 'i');
|
|
92
|
+
const ADD_COLUMN = new RegExp(String.raw `^\s*ADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(${IDENTIFIER})`, 'i');
|
|
93
|
+
const COLUMN_NAME = new RegExp(String.raw `^\s*(${IDENTIFIER})`);
|
|
94
|
+
/**
|
|
95
|
+
* The words that open a table-level constraint rather than a column.
|
|
96
|
+
*
|
|
97
|
+
* Only consulted for an unquoted first token: `"check"` in quotes is a column
|
|
98
|
+
* named check, however unwise, and skipping it would let `"checkedAt"`'s
|
|
99
|
+
* neighbours hide behind it.
|
|
100
|
+
*/
|
|
101
|
+
const CONSTRAINT_KEYWORDS = new Set([
|
|
102
|
+
'constraint',
|
|
103
|
+
'primary',
|
|
104
|
+
'foreign',
|
|
105
|
+
'unique',
|
|
106
|
+
'check',
|
|
107
|
+
'exclude',
|
|
108
|
+
'index',
|
|
109
|
+
'key',
|
|
110
|
+
'like',
|
|
111
|
+
'period',
|
|
112
|
+
'fulltext',
|
|
113
|
+
'spatial',
|
|
114
|
+
]);
|
|
115
|
+
/** Drop the quoting a dialect happens to use, leaving the identifier itself. */
|
|
116
|
+
const unquote = (name) => /^["`[]/.test(name) ? name.slice(1, -1) : name;
|
|
117
|
+
const isCamelCase = (name) => /[a-z][A-Z]/.test(name);
|
|
118
|
+
const toSnakeCase = (name) => name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
119
|
+
/**
|
|
120
|
+
* Walk `sql` from `from` and return the offsets just inside and just outside
|
|
121
|
+
* the parenthesised group that starts there, or `null` if it never closes.
|
|
122
|
+
*
|
|
123
|
+
* Quote-aware, because a `)` inside `DEFAULT ')'` closes nothing.
|
|
124
|
+
*/
|
|
125
|
+
function parenSpan(sql, from) {
|
|
126
|
+
let depth = 0;
|
|
127
|
+
let i = from;
|
|
128
|
+
while (i < sql.length) {
|
|
129
|
+
const ch = sql[i];
|
|
130
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
131
|
+
i++;
|
|
132
|
+
while (i < sql.length && sql[i] !== ch)
|
|
133
|
+
i++;
|
|
134
|
+
i++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (ch === '(') {
|
|
138
|
+
depth++;
|
|
139
|
+
if (depth === 1)
|
|
140
|
+
from = i + 1;
|
|
141
|
+
}
|
|
142
|
+
else if (ch === ')') {
|
|
143
|
+
depth--;
|
|
144
|
+
if (depth === 0)
|
|
145
|
+
return { start: from, end: i };
|
|
146
|
+
}
|
|
147
|
+
i++;
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Split a definition list on the commas that separate its items.
|
|
153
|
+
*
|
|
154
|
+
* The commas inside `NUMERIC(10,2)`, a `CHECK (x IN ('a','b'))` or a compound
|
|
155
|
+
* `PRIMARY KEY (a, b)` belong to those and not to the list, so only depth zero
|
|
156
|
+
* counts.
|
|
157
|
+
*/
|
|
158
|
+
function splitTopLevel(list) {
|
|
159
|
+
const items = [];
|
|
160
|
+
let depth = 0;
|
|
161
|
+
let start = 0;
|
|
162
|
+
let i = 0;
|
|
163
|
+
while (i < list.length) {
|
|
164
|
+
const ch = list[i];
|
|
165
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
166
|
+
i++;
|
|
167
|
+
while (i < list.length && list[i] !== ch)
|
|
168
|
+
i++;
|
|
169
|
+
i++;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (ch === '(')
|
|
173
|
+
depth++;
|
|
174
|
+
else if (ch === ')')
|
|
175
|
+
depth--;
|
|
176
|
+
else if (ch === ',' && depth === 0) {
|
|
177
|
+
items.push(list.slice(start, i));
|
|
178
|
+
start = i + 1;
|
|
179
|
+
}
|
|
180
|
+
i++;
|
|
181
|
+
}
|
|
182
|
+
items.push(list.slice(start));
|
|
183
|
+
return items.filter((item) => item.trim().length > 0);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Every camelCase identifier one migration file declares.
|
|
187
|
+
*
|
|
188
|
+
* Only declarations are read — a `CREATE TABLE` column list and an `ALTER TABLE
|
|
189
|
+
* … ADD COLUMN`. Everywhere else an identifier merely refers to something that
|
|
190
|
+
* was declared somewhere, and reporting those would name the same mistake once
|
|
191
|
+
* per reference while adding nothing to the fix.
|
|
192
|
+
*/
|
|
193
|
+
export function findCamelCaseIdentifiers(file, sql) {
|
|
194
|
+
const offenders = [];
|
|
195
|
+
for (const statement of splitStatements(stripSqlComments(sql))) {
|
|
196
|
+
const create = CREATE_TABLE.exec(statement);
|
|
197
|
+
if (create) {
|
|
198
|
+
const table = unquote(create[1].split('.').pop());
|
|
199
|
+
if (isCamelCase(table)) {
|
|
200
|
+
offenders.push({
|
|
201
|
+
file,
|
|
202
|
+
table,
|
|
203
|
+
column: null,
|
|
204
|
+
suggestion: toSnakeCase(table),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const span = parenSpan(statement, create.index + create[0].length - 1);
|
|
208
|
+
if (!span)
|
|
209
|
+
continue;
|
|
210
|
+
for (const item of splitTopLevel(statement.slice(span.start, span.end))) {
|
|
211
|
+
const name = COLUMN_NAME.exec(item)?.[1];
|
|
212
|
+
if (!name)
|
|
213
|
+
continue;
|
|
214
|
+
if (CONSTRAINT_KEYWORDS.has(name.toLowerCase()))
|
|
215
|
+
continue;
|
|
216
|
+
const column = unquote(name);
|
|
217
|
+
if (isCamelCase(column)) {
|
|
218
|
+
offenders.push({
|
|
219
|
+
file,
|
|
220
|
+
table,
|
|
221
|
+
column,
|
|
222
|
+
suggestion: toSnakeCase(column),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const alter = ALTER_TABLE.exec(statement);
|
|
229
|
+
if (!alter)
|
|
230
|
+
continue;
|
|
231
|
+
const table = unquote(alter[1].split('.').pop());
|
|
232
|
+
// Postgres lets one ALTER carry several actions, and only the ADDs declare.
|
|
233
|
+
for (const action of splitTopLevel(statement.slice(alter[0].length).replace(/;\s*$/, ''))) {
|
|
234
|
+
const name = ADD_COLUMN.exec(action)?.[1];
|
|
235
|
+
if (!name)
|
|
236
|
+
continue;
|
|
237
|
+
if (CONSTRAINT_KEYWORDS.has(name.toLowerCase()))
|
|
238
|
+
continue;
|
|
239
|
+
const column = unquote(name);
|
|
240
|
+
if (isCamelCase(column)) {
|
|
241
|
+
offenders.push({
|
|
242
|
+
file,
|
|
243
|
+
table,
|
|
244
|
+
column,
|
|
245
|
+
suggestion: toSnakeCase(column),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return offenders;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Bail unless every migration on disk is snake_case throughout.
|
|
254
|
+
*
|
|
255
|
+
* Reports all of them at once: a camelCase column is rarely alone, and a
|
|
256
|
+
* one-at-a-time failure turns a single edit into one migrate run per column.
|
|
257
|
+
*/
|
|
258
|
+
export function assertSnakeCaseIdentifiers(migrations) {
|
|
259
|
+
const offenders = migrations.flatMap(({ name, sql }) => findCamelCaseIdentifiers(name, sql));
|
|
260
|
+
if (offenders.length > 0)
|
|
261
|
+
throw new CamelCaseIdentifierError(offenders);
|
|
262
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PostgresMigrationExecutor, type PostgresMigrationClient, } from './postgres-migrator.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PostgresMigrationExecutor, } from './postgres-migrator.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { MigrationExecutor, AppliedMigration } from '../sql-migrator.js';
|
|
2
|
+
export interface PostgresMigrationClient {
|
|
3
|
+
query<T = unknown>(sql: string, params?: unknown[]): Promise<{
|
|
4
|
+
rows: T[];
|
|
5
|
+
}>;
|
|
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
|
+
export declare class PostgresMigrationExecutor implements MigrationExecutor {
|
|
20
|
+
private readonly client;
|
|
21
|
+
constructor(client: PostgresMigrationClient);
|
|
22
|
+
ensureTrackingTable(): Promise<void>;
|
|
23
|
+
getApplied(): Promise<AppliedMigration[]>;
|
|
24
|
+
recordMigration(name: string, hash: string): Promise<void>;
|
|
25
|
+
runMigration(sql: string, name: string, hash: string): Promise<void>;
|
|
26
|
+
private applyOn;
|
|
27
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { MIGRATION_TRACKING_TABLE as TRACKING_TABLE } from '../sql-migrator.js';
|
|
2
|
+
export class PostgresMigrationExecutor {
|
|
3
|
+
client;
|
|
4
|
+
constructor(client) {
|
|
5
|
+
this.client = client;
|
|
6
|
+
}
|
|
7
|
+
async ensureTrackingTable() {
|
|
8
|
+
await this.client.query(`
|
|
9
|
+
CREATE TABLE IF NOT EXISTS ${TRACKING_TABLE} (
|
|
10
|
+
name TEXT PRIMARY KEY,
|
|
11
|
+
hash TEXT NOT NULL,
|
|
12
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
13
|
+
)
|
|
14
|
+
`);
|
|
15
|
+
}
|
|
16
|
+
async getApplied() {
|
|
17
|
+
const { rows } = await this.client.query(`SELECT name, hash, applied_at FROM ${TRACKING_TABLE} ORDER BY name`);
|
|
18
|
+
return rows;
|
|
19
|
+
}
|
|
20
|
+
async recordMigration(name, hash) {
|
|
21
|
+
await this.client.query(`INSERT INTO ${TRACKING_TABLE} (name, hash) VALUES ($1, $2)`, [name, hash]);
|
|
22
|
+
}
|
|
23
|
+
async runMigration(sql, name, hash) {
|
|
24
|
+
if (typeof this.client.begin === 'function') {
|
|
25
|
+
await this.client.begin(async (tx) => {
|
|
26
|
+
await this.applyOn(tx, sql, name, hash);
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
await this.client.query('BEGIN');
|
|
31
|
+
try {
|
|
32
|
+
await this.applyOn(this.client, sql, name, hash);
|
|
33
|
+
await this.client.query('COMMIT');
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
await this.client.query('ROLLBACK');
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async applyOn(client, sql, name, hash) {
|
|
41
|
+
if (typeof client.exec === 'function') {
|
|
42
|
+
await client.exec(sql);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
await client.query(sql);
|
|
46
|
+
}
|
|
47
|
+
await client.query(`INSERT INTO ${TRACKING_TABLE} (name, hash) VALUES ($1, $2)`, [name, hash]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a schema source's own SQL back out, one table at a time.
|
|
3
|
+
*
|
|
4
|
+
* A source hands `db generate` both what must exist (`tables`) and what creates
|
|
5
|
+
* it (`sql`). The column map is a comparison surface — it answers "is this
|
|
6
|
+
* table there, and does it have these columns" — and it is deliberately lossy:
|
|
7
|
+
* primary keys, foreign keys, uniqueness, check constraints and indexes are all
|
|
8
|
+
* absent from it. So the moment the generator has to *create* something, the
|
|
9
|
+
* only honest source is the SQL, and this is what pulls the relevant part of it
|
|
10
|
+
* out.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Split a SQL script into its top-level statements, semicolons included.
|
|
14
|
+
*
|
|
15
|
+
* A naive `split(';')` is wrong in a way that only shows up later: a semicolon
|
|
16
|
+
* inside a string literal, a quoted identifier or a comment is not a statement
|
|
17
|
+
* boundary, and cutting there yields two fragments that are each valid-looking
|
|
18
|
+
* and neither of which does what the original did.
|
|
19
|
+
*/
|
|
20
|
+
export declare function splitStatements(sql: string): string[];
|
|
21
|
+
/**
|
|
22
|
+
* Reduce a written table name to the form two sources can be compared on.
|
|
23
|
+
*
|
|
24
|
+
* The same table is `two_factor` to one writer, `"two_factor"` to Kysely and
|
|
25
|
+
* `public.two_factor` to Postgres introspection. Dropping the schema qualifier,
|
|
26
|
+
* the quoting and the case is the only shape all three agree on.
|
|
27
|
+
*/
|
|
28
|
+
export declare function bareTableName(name: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Every statement in `sql` that builds `table`, in the order the source wrote
|
|
31
|
+
* them.
|
|
32
|
+
*
|
|
33
|
+
* The `CREATE TABLE` and its indexes, plus any `ALTER TABLE` the source uses to
|
|
34
|
+
* hang a constraint on it afterwards — which together are what the column list
|
|
35
|
+
* cannot express. Source order is preserved because it is load-bearing: an
|
|
36
|
+
* index cannot precede its table, and a table cannot precede one it references.
|
|
37
|
+
*
|
|
38
|
+
* An empty result means the source's SQL does not visibly create the table.
|
|
39
|
+
* That is a real answer rather than a failure — a source may create a table
|
|
40
|
+
* from something other than a literal `CREATE TABLE` — and the caller is
|
|
41
|
+
* expected to fall back rather than emit nothing.
|
|
42
|
+
*/
|
|
43
|
+
export declare function tableCreationSql(sql: string, table: string): string[];
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a schema source's own SQL back out, one table at a time.
|
|
3
|
+
*
|
|
4
|
+
* A source hands `db generate` both what must exist (`tables`) and what creates
|
|
5
|
+
* it (`sql`). The column map is a comparison surface — it answers "is this
|
|
6
|
+
* table there, and does it have these columns" — and it is deliberately lossy:
|
|
7
|
+
* primary keys, foreign keys, uniqueness, check constraints and indexes are all
|
|
8
|
+
* absent from it. So the moment the generator has to *create* something, the
|
|
9
|
+
* only honest source is the SQL, and this is what pulls the relevant part of it
|
|
10
|
+
* out.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Split a SQL script into its top-level statements, semicolons included.
|
|
14
|
+
*
|
|
15
|
+
* A naive `split(';')` is wrong in a way that only shows up later: a semicolon
|
|
16
|
+
* inside a string literal, a quoted identifier or a comment is not a statement
|
|
17
|
+
* boundary, and cutting there yields two fragments that are each valid-looking
|
|
18
|
+
* and neither of which does what the original did.
|
|
19
|
+
*/
|
|
20
|
+
export function splitStatements(sql) {
|
|
21
|
+
const statements = [];
|
|
22
|
+
let start = 0;
|
|
23
|
+
let i = 0;
|
|
24
|
+
const closeQuote = (quote) => {
|
|
25
|
+
i++;
|
|
26
|
+
while (i < sql.length) {
|
|
27
|
+
if (sql[i] === '\\' && quote === "'") {
|
|
28
|
+
i += 2;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (sql[i] === quote) {
|
|
32
|
+
// A doubled quote is an escaped one, not the end of the literal.
|
|
33
|
+
if (sql[i + 1] === quote) {
|
|
34
|
+
i += 2;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
i++;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
while (i < sql.length) {
|
|
44
|
+
const ch = sql[i];
|
|
45
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
46
|
+
closeQuote(ch);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (ch === '-' && sql[i + 1] === '-') {
|
|
50
|
+
const end = sql.indexOf('\n', i);
|
|
51
|
+
i = end === -1 ? sql.length : end + 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (ch === '/' && sql[i + 1] === '*') {
|
|
55
|
+
const end = sql.indexOf('*/', i + 2);
|
|
56
|
+
i = end === -1 ? sql.length : end + 2;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
// Postgres dollar quoting: everything between `$tag$` and its twin is a
|
|
60
|
+
// literal, and a function body written that way is full of semicolons.
|
|
61
|
+
if (ch === '$') {
|
|
62
|
+
const tag = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i));
|
|
63
|
+
if (tag) {
|
|
64
|
+
const end = sql.indexOf(tag[0], i + tag[0].length);
|
|
65
|
+
i = end === -1 ? sql.length : end + tag[0].length;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (ch === ';') {
|
|
70
|
+
const statement = sql.slice(start, i + 1).trim();
|
|
71
|
+
if (statement.length > 1)
|
|
72
|
+
statements.push(statement);
|
|
73
|
+
start = i + 1;
|
|
74
|
+
}
|
|
75
|
+
i++;
|
|
76
|
+
}
|
|
77
|
+
// A script whose last statement has no trailing semicolon still ran it.
|
|
78
|
+
const tail = sql.slice(start).trim();
|
|
79
|
+
if (tail.length > 0)
|
|
80
|
+
statements.push(tail);
|
|
81
|
+
return statements;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Reduce a written table name to the form two sources can be compared on.
|
|
85
|
+
*
|
|
86
|
+
* The same table is `two_factor` to one writer, `"two_factor"` to Kysely and
|
|
87
|
+
* `public.two_factor` to Postgres introspection. Dropping the schema qualifier,
|
|
88
|
+
* the quoting and the case is the only shape all three agree on.
|
|
89
|
+
*/
|
|
90
|
+
export function bareTableName(name) {
|
|
91
|
+
const last = name.split('.').pop() ?? name;
|
|
92
|
+
return last.replace(/^["'`[]|["'`\]]$/g, '').toLowerCase();
|
|
93
|
+
}
|
|
94
|
+
const CREATE_TABLE = /^CREATE\s+(?:TEMP(?:ORARY)?\s+|UNLOGGED\s+)*TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([^\s(]+)/i;
|
|
95
|
+
const CREATE_INDEX = /^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?\S+\s+ON\s+(?:ONLY\s+)?([^\s(]+)/i;
|
|
96
|
+
const ALTER_TABLE = /^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?([^\s(]+)/i;
|
|
97
|
+
/**
|
|
98
|
+
* Every statement in `sql` that builds `table`, in the order the source wrote
|
|
99
|
+
* them.
|
|
100
|
+
*
|
|
101
|
+
* The `CREATE TABLE` and its indexes, plus any `ALTER TABLE` the source uses to
|
|
102
|
+
* hang a constraint on it afterwards — which together are what the column list
|
|
103
|
+
* cannot express. Source order is preserved because it is load-bearing: an
|
|
104
|
+
* index cannot precede its table, and a table cannot precede one it references.
|
|
105
|
+
*
|
|
106
|
+
* An empty result means the source's SQL does not visibly create the table.
|
|
107
|
+
* That is a real answer rather than a failure — a source may create a table
|
|
108
|
+
* from something other than a literal `CREATE TABLE` — and the caller is
|
|
109
|
+
* expected to fall back rather than emit nothing.
|
|
110
|
+
*/
|
|
111
|
+
export function tableCreationSql(sql, table) {
|
|
112
|
+
const wanted = bareTableName(table);
|
|
113
|
+
const statements = [];
|
|
114
|
+
let creates = false;
|
|
115
|
+
for (const statement of splitStatements(sql)) {
|
|
116
|
+
const create = CREATE_TABLE.exec(statement);
|
|
117
|
+
if (create && bareTableName(create[1]) === wanted) {
|
|
118
|
+
creates = true;
|
|
119
|
+
statements.push(statement);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const index = CREATE_INDEX.exec(statement);
|
|
123
|
+
if (index && bareTableName(index[1]) === wanted) {
|
|
124
|
+
statements.push(statement);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const alter = ALTER_TABLE.exec(statement);
|
|
128
|
+
if (alter && bareTableName(alter[1]) === wanted) {
|
|
129
|
+
statements.push(statement);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Indexes and alters without the table they belong to would fail on their own,
|
|
133
|
+
// and their presence says the table came from somewhere this cannot read.
|
|
134
|
+
return creates ? statements : [];
|
|
135
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The migrator's own bookkeeping table, which belongs to no dialect and to no
|
|
3
|
+
* project's schema.
|
|
4
|
+
*
|
|
5
|
+
* Every introspector hides it for that reason. Leaving it visible on one
|
|
6
|
+
* dialect and not the other is not cosmetic: a schema source exported from a
|
|
7
|
+
* database that has been migrated would publish `sql_migrations` as one of its
|
|
8
|
+
* own tables, and the consumer — which also has it — would then read the source
|
|
9
|
+
* as partially covered and emit column deltas instead of the source's own SQL,
|
|
10
|
+
* silently dropping its primary keys, indexes and constraints.
|
|
11
|
+
*/
|
|
12
|
+
export declare const MIGRATION_TRACKING_TABLE = "sql_migrations";
|
|
13
|
+
export declare class MigrationDriftError extends Error {
|
|
14
|
+
readonly file: string;
|
|
15
|
+
readonly recordedHash: string;
|
|
16
|
+
readonly currentHash: string | null;
|
|
17
|
+
readonly appliedAt: string;
|
|
18
|
+
constructor(file: string, recordedHash: string, currentHash: string | null, appliedAt: string, migrationsDir: string);
|
|
19
|
+
}
|
|
20
|
+
export interface MigrateResult {
|
|
21
|
+
applied: string[];
|
|
22
|
+
skipped: string[];
|
|
23
|
+
}
|
|
24
|
+
export interface AppliedMigration {
|
|
25
|
+
name: string;
|
|
26
|
+
hash: string;
|
|
27
|
+
applied_at: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Provider-agnostic migration executor. Implement this for each DB dialect.
|
|
31
|
+
* Each method maps to a single DB operation; all file I/O and hashing lives
|
|
32
|
+
* in the shared `migrate()` function above.
|
|
33
|
+
*/
|
|
34
|
+
export interface MigrationExecutor {
|
|
35
|
+
ensureTrackingTable(): Promise<void>;
|
|
36
|
+
getApplied(): Promise<AppliedMigration[]>;
|
|
37
|
+
runMigration(sql: string, name: string, hash: string): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Record a migration as applied without running its SQL.
|
|
40
|
+
*
|
|
41
|
+
* For a database that already contains what the migration describes, because
|
|
42
|
+
* something created those tables before anyone wrote them down. Only ever
|
|
43
|
+
* called once the caller has confirmed that is actually true — recording a
|
|
44
|
+
* migration whose tables are absent leaves a database permanently behind with
|
|
45
|
+
* no pending migration to reveal it.
|
|
46
|
+
*/
|
|
47
|
+
recordMigration(name: string, hash: string): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
export declare function migrate(executor: MigrationExecutor, migrationsDir: string): Promise<MigrateResult>;
|
|
50
|
+
/**
|
|
51
|
+
* Record every pending migration as applied, without running any of it.
|
|
52
|
+
*
|
|
53
|
+
* The escape hatch for a database that already has the tables a migration
|
|
54
|
+
* creates — the shape you get when a runtime bootstrapped its own schema at
|
|
55
|
+
* boot and the migration writing it down was authored afterwards. Running that
|
|
56
|
+
* migration would fail on every existing deployment; skipping it forever would
|
|
57
|
+
* leave the history lying. Recording it says what is true.
|
|
58
|
+
*
|
|
59
|
+
* Deliberately unconditional here. Whether the database really does match is a
|
|
60
|
+
* question about schemas, not migration files, so the caller answers it first
|
|
61
|
+
* and this only runs once it has.
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* The migrations on disk that the database has not recorded.
|
|
65
|
+
*
|
|
66
|
+
* Deliberately not derived by the caller: "pending" has to mean the same set
|
|
67
|
+
* `migrate` is about to apply, and that is filename order over `*.sql` minus
|
|
68
|
+
* what is recorded — not whatever a directory listing happens to return.
|
|
69
|
+
*/
|
|
70
|
+
export declare function pendingMigrations(migrationsDir: string, applied: AppliedMigration[]): string[];
|
|
71
|
+
export declare function baselineMigrations(executor: MigrationExecutor, migrationsDir: string): Promise<string[]>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { assertSnakeCaseIdentifiers } from './migration-identifiers.js';
|
|
5
|
+
/**
|
|
6
|
+
* The migrator's own bookkeeping table, which belongs to no dialect and to no
|
|
7
|
+
* project's schema.
|
|
8
|
+
*
|
|
9
|
+
* Every introspector hides it for that reason. Leaving it visible on one
|
|
10
|
+
* dialect and not the other is not cosmetic: a schema source exported from a
|
|
11
|
+
* database that has been migrated would publish `sql_migrations` as one of its
|
|
12
|
+
* own tables, and the consumer — which also has it — would then read the source
|
|
13
|
+
* as partially covered and emit column deltas instead of the source's own SQL,
|
|
14
|
+
* silently dropping its primary keys, indexes and constraints.
|
|
15
|
+
*/
|
|
16
|
+
export const MIGRATION_TRACKING_TABLE = 'sql_migrations';
|
|
17
|
+
export class MigrationDriftError extends Error {
|
|
18
|
+
file;
|
|
19
|
+
recordedHash;
|
|
20
|
+
currentHash;
|
|
21
|
+
appliedAt;
|
|
22
|
+
constructor(file, recordedHash, currentHash, appliedAt, migrationsDir) {
|
|
23
|
+
const onDisk = currentHash === null
|
|
24
|
+
? 'file missing on disk'
|
|
25
|
+
: `sha256:${currentHash.slice(0, 8)}…`;
|
|
26
|
+
super(`[PKU-DB-DRIFT] ${migrationsDir}/${file}\n\n` +
|
|
27
|
+
`Migration content has changed since it was applied.\n` +
|
|
28
|
+
` recorded: sha256:${recordedHash.slice(0, 8)}… applied ${appliedAt}\n` +
|
|
29
|
+
` on disk: ${onDisk}\n\n` +
|
|
30
|
+
`If this edit was intentional, write a new forward migration to revert the change.\n` +
|
|
31
|
+
`Production migrations are immutable.`);
|
|
32
|
+
this.file = file;
|
|
33
|
+
this.recordedHash = recordedHash;
|
|
34
|
+
this.currentHash = currentHash;
|
|
35
|
+
this.appliedAt = appliedAt;
|
|
36
|
+
this.name = 'MigrationDriftError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function sha256(bytes) {
|
|
40
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Apply pending migrations from `migrationsDir/*.sql` using the supplied
|
|
44
|
+
* executor. Hashes raw file bytes on apply; subsequent runs re-hash and bail
|
|
45
|
+
* with `MigrationDriftError` if any applied file has changed on disk.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* The migrations on disk, or none.
|
|
49
|
+
*
|
|
50
|
+
* A project that has never generated a migration has no directory to read, and
|
|
51
|
+
* that is the ordinary first-run state rather than a failure — it is precisely
|
|
52
|
+
* the project `db generate` exists to serve.
|
|
53
|
+
*/
|
|
54
|
+
const migrationFiles = (migrationsDir) => existsSync(migrationsDir)
|
|
55
|
+
? readdirSync(migrationsDir)
|
|
56
|
+
.filter((f) => f.endsWith('.sql'))
|
|
57
|
+
.sort()
|
|
58
|
+
: [];
|
|
59
|
+
/**
|
|
60
|
+
* Re-hash every applied migration and bail if one has changed on disk.
|
|
61
|
+
*
|
|
62
|
+
* Applies to baselining as much as to migrating: recording a file as applied
|
|
63
|
+
* only means anything if the file is still the one that was applied.
|
|
64
|
+
*/
|
|
65
|
+
function assertNoDrift(applied, migrationsDir) {
|
|
66
|
+
for (const row of applied) {
|
|
67
|
+
let currentHash = null;
|
|
68
|
+
try {
|
|
69
|
+
currentHash = sha256(readFileSync(join(migrationsDir, row.name)));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
currentHash = null;
|
|
73
|
+
}
|
|
74
|
+
if (currentHash !== row.hash) {
|
|
75
|
+
throw new MigrationDriftError(row.name, row.hash, currentHash, row.applied_at, migrationsDir);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Every migration on disk, read once.
|
|
81
|
+
*
|
|
82
|
+
* The identifier check reads all of them and not just the pending ones, so that
|
|
83
|
+
* whether a migration is rejected depends on the files alone. A camelCase
|
|
84
|
+
* column that had already been applied somewhere would otherwise pass on that
|
|
85
|
+
* machine and fail on a fresh checkout, which is the opposite of deterministic.
|
|
86
|
+
*/
|
|
87
|
+
const readMigrations = (migrationsDir) => migrationFiles(migrationsDir).map((name) => ({
|
|
88
|
+
name,
|
|
89
|
+
sql: readFileSync(join(migrationsDir, name), 'utf8'),
|
|
90
|
+
}));
|
|
91
|
+
export async function migrate(executor, migrationsDir) {
|
|
92
|
+
assertSnakeCaseIdentifiers(readMigrations(migrationsDir));
|
|
93
|
+
await executor.ensureTrackingTable();
|
|
94
|
+
const applied = await executor.getApplied();
|
|
95
|
+
assertNoDrift(applied, migrationsDir);
|
|
96
|
+
const appliedNames = new Set(applied.map((r) => r.name));
|
|
97
|
+
const result = { applied: [], skipped: [] };
|
|
98
|
+
for (const name of migrationFiles(migrationsDir)) {
|
|
99
|
+
if (appliedNames.has(name)) {
|
|
100
|
+
result.skipped.push(name);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const raw = readFileSync(join(migrationsDir, name));
|
|
104
|
+
const hash = sha256(raw);
|
|
105
|
+
await executor.runMigration(raw.toString('utf8'), name, hash);
|
|
106
|
+
result.applied.push(name);
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Record every pending migration as applied, without running any of it.
|
|
112
|
+
*
|
|
113
|
+
* The escape hatch for a database that already has the tables a migration
|
|
114
|
+
* creates — the shape you get when a runtime bootstrapped its own schema at
|
|
115
|
+
* boot and the migration writing it down was authored afterwards. Running that
|
|
116
|
+
* migration would fail on every existing deployment; skipping it forever would
|
|
117
|
+
* leave the history lying. Recording it says what is true.
|
|
118
|
+
*
|
|
119
|
+
* Deliberately unconditional here. Whether the database really does match is a
|
|
120
|
+
* question about schemas, not migration files, so the caller answers it first
|
|
121
|
+
* and this only runs once it has.
|
|
122
|
+
*/
|
|
123
|
+
/**
|
|
124
|
+
* The migrations on disk that the database has not recorded.
|
|
125
|
+
*
|
|
126
|
+
* Deliberately not derived by the caller: "pending" has to mean the same set
|
|
127
|
+
* `migrate` is about to apply, and that is filename order over `*.sql` minus
|
|
128
|
+
* what is recorded — not whatever a directory listing happens to return.
|
|
129
|
+
*/
|
|
130
|
+
export function pendingMigrations(migrationsDir, applied) {
|
|
131
|
+
const appliedNames = new Set(applied.map((row) => row.name));
|
|
132
|
+
return migrationFiles(migrationsDir).filter((name) => !appliedNames.has(name));
|
|
133
|
+
}
|
|
134
|
+
export async function baselineMigrations(executor, migrationsDir) {
|
|
135
|
+
await executor.ensureTrackingTable();
|
|
136
|
+
const applied = await executor.getApplied();
|
|
137
|
+
assertNoDrift(applied, migrationsDir);
|
|
138
|
+
const appliedNames = new Set(applied.map((r) => r.name));
|
|
139
|
+
const recorded = [];
|
|
140
|
+
for (const name of migrationFiles(migrationsDir)) {
|
|
141
|
+
if (appliedNames.has(name))
|
|
142
|
+
continue;
|
|
143
|
+
await executor.recordMigration(name, sha256(readFileSync(join(migrationsDir, name))));
|
|
144
|
+
recorded.push(name);
|
|
145
|
+
}
|
|
146
|
+
return recorded;
|
|
147
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { MigrationExecutor, AppliedMigration } from '../sql-migrator.js';
|
|
2
|
+
import type { SyncSqliteDatabase } from './sqlite-runtime.js';
|
|
3
|
+
export declare class SqliteMigrationExecutor implements MigrationExecutor {
|
|
4
|
+
private readonly db;
|
|
5
|
+
constructor(db: SyncSqliteDatabase);
|
|
6
|
+
ensureTrackingTable(): Promise<void>;
|
|
7
|
+
getApplied(): Promise<AppliedMigration[]>;
|
|
8
|
+
recordMigration(name: string, hash: string): Promise<void>;
|
|
9
|
+
runMigration(sql: string, name: string, hash: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare function dropTrackingTable(db: SyncSqliteDatabase): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { MIGRATION_TRACKING_TABLE as TRACKING_TABLE } from '../sql-migrator.js';
|
|
2
|
+
export class SqliteMigrationExecutor {
|
|
3
|
+
db;
|
|
4
|
+
constructor(db) {
|
|
5
|
+
this.db = db;
|
|
6
|
+
}
|
|
7
|
+
async ensureTrackingTable() {
|
|
8
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS ${TRACKING_TABLE} (
|
|
9
|
+
name TEXT PRIMARY KEY,
|
|
10
|
+
hash TEXT NOT NULL,
|
|
11
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
12
|
+
)`);
|
|
13
|
+
}
|
|
14
|
+
async getApplied() {
|
|
15
|
+
return this.db
|
|
16
|
+
.prepare(`SELECT name, hash, applied_at FROM ${TRACKING_TABLE} ORDER BY name`)
|
|
17
|
+
.all();
|
|
18
|
+
}
|
|
19
|
+
async recordMigration(name, hash) {
|
|
20
|
+
this.db
|
|
21
|
+
.prepare(`INSERT INTO ${TRACKING_TABLE} (name, hash) VALUES (?, ?)`)
|
|
22
|
+
.run(name, hash);
|
|
23
|
+
}
|
|
24
|
+
async runMigration(sql, name, hash) {
|
|
25
|
+
this.db.exec('BEGIN');
|
|
26
|
+
try {
|
|
27
|
+
this.db.exec(sql);
|
|
28
|
+
this.db
|
|
29
|
+
.prepare(`INSERT INTO ${TRACKING_TABLE} (name, hash) VALUES (?, ?)`)
|
|
30
|
+
.run(name, hash);
|
|
31
|
+
this.db.exec('COMMIT');
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
this.db.exec('ROLLBACK');
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function dropTrackingTable(db) {
|
|
40
|
+
db.exec(`DROP TABLE IF EXISTS ${TRACKING_TABLE}`);
|
|
41
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
class BunSqliteStatement {
|
|
3
|
+
stmt;
|
|
4
|
+
reader;
|
|
5
|
+
constructor(stmt, reader) {
|
|
6
|
+
this.stmt = stmt;
|
|
7
|
+
this.reader = reader;
|
|
8
|
+
}
|
|
9
|
+
all(...parameters) {
|
|
10
|
+
return this.stmt.all(...parameters);
|
|
11
|
+
}
|
|
12
|
+
get(...parameters) {
|
|
13
|
+
return (this.stmt.get(...parameters) ?? null);
|
|
14
|
+
}
|
|
15
|
+
iterate(...parameters) {
|
|
16
|
+
return this.stmt.iterate(...parameters);
|
|
17
|
+
}
|
|
18
|
+
run(...parameters) {
|
|
19
|
+
const result = this.stmt.run(...parameters);
|
|
20
|
+
return {
|
|
21
|
+
changes: result.changes,
|
|
22
|
+
lastInsertRowid: result.lastInsertRowid,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
class BunSqliteDatabase {
|
|
27
|
+
db;
|
|
28
|
+
constructor(db) {
|
|
29
|
+
this.db = db;
|
|
30
|
+
}
|
|
31
|
+
exec(sql) {
|
|
32
|
+
// bun:sqlite throws "no valid SQL statement" on comment-only/empty input
|
|
33
|
+
// (e.g. a placeholder dev-seed.sql); node:sqlite silently no-ops. Match node's
|
|
34
|
+
// tolerance by skipping when nothing executable remains after stripping
|
|
35
|
+
// comments. The original `sql` is still exec'd verbatim when non-empty.
|
|
36
|
+
const executable = sql
|
|
37
|
+
.replace(/--[^\n]*/g, '')
|
|
38
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
39
|
+
.trim();
|
|
40
|
+
if (executable.length === 0)
|
|
41
|
+
return;
|
|
42
|
+
this.db.exec(sql);
|
|
43
|
+
}
|
|
44
|
+
prepare(sql) {
|
|
45
|
+
return new BunSqliteStatement(this.db.prepare(sql), isReaderSql(sql));
|
|
46
|
+
}
|
|
47
|
+
close() {
|
|
48
|
+
this.db.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function isReaderSql(sql) {
|
|
52
|
+
const normalized = sql.trimStart().toUpperCase();
|
|
53
|
+
return (normalized.startsWith('SELECT') ||
|
|
54
|
+
normalized.startsWith('WITH') ||
|
|
55
|
+
normalized.startsWith('PRAGMA') ||
|
|
56
|
+
normalized.startsWith('EXPLAIN'));
|
|
57
|
+
}
|
|
58
|
+
export const bunSqliteRuntime = {
|
|
59
|
+
open(filename) {
|
|
60
|
+
return new BunSqliteDatabase(new Database(filename));
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
class NodeSqliteStatement {
|
|
2
|
+
stmt;
|
|
3
|
+
reader;
|
|
4
|
+
constructor(stmt, sql) {
|
|
5
|
+
this.stmt = stmt;
|
|
6
|
+
// node:sqlite StatementSync does not have a .reader property
|
|
7
|
+
// (that's a better-sqlite3 API). Fall back to SQL inspection when absent.
|
|
8
|
+
if (stmt.reader !== undefined) {
|
|
9
|
+
this.reader = Boolean(stmt.reader);
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
const upper = sql.trimStart().toUpperCase();
|
|
13
|
+
this.reader =
|
|
14
|
+
upper.startsWith('SELECT') ||
|
|
15
|
+
upper.startsWith('WITH') ||
|
|
16
|
+
upper.startsWith('PRAGMA') ||
|
|
17
|
+
upper.startsWith('EXPLAIN') ||
|
|
18
|
+
upper.startsWith('VALUES') ||
|
|
19
|
+
/\bRETURNING\b/.test(upper);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
all(...parameters) {
|
|
23
|
+
return this.stmt.all(...parameters);
|
|
24
|
+
}
|
|
25
|
+
get(...parameters) {
|
|
26
|
+
return this.stmt.get(...parameters) ?? null;
|
|
27
|
+
}
|
|
28
|
+
iterate(...parameters) {
|
|
29
|
+
return this.stmt.iterate(...parameters);
|
|
30
|
+
}
|
|
31
|
+
run(...parameters) {
|
|
32
|
+
const result = this.stmt.run(...parameters);
|
|
33
|
+
return {
|
|
34
|
+
changes: result.changes,
|
|
35
|
+
lastInsertRowid: result.lastInsertRowid,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
class NodeSqliteDatabase {
|
|
40
|
+
db;
|
|
41
|
+
constructor(db) {
|
|
42
|
+
this.db = db;
|
|
43
|
+
}
|
|
44
|
+
exec(sql) {
|
|
45
|
+
this.db.exec(sql);
|
|
46
|
+
}
|
|
47
|
+
prepare(sql) {
|
|
48
|
+
return new NodeSqliteStatement(this.db.prepare(sql), sql);
|
|
49
|
+
}
|
|
50
|
+
close() {
|
|
51
|
+
this.db.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function importNodeSqlite() {
|
|
55
|
+
const dynamicImport = new Function('return import("node:sqlite")');
|
|
56
|
+
try {
|
|
57
|
+
return await dynamicImport();
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
// node ships node:sqlite unflagged only from 24. The raw failure is
|
|
61
|
+
// ERR_UNKNOWN_BUILTIN_MODULE, which reads like a broken install rather than
|
|
62
|
+
// a runtime that is simply too old — so say which it is and how to get past it.
|
|
63
|
+
if (error?.code === 'ERR_UNKNOWN_BUILTIN_MODULE') {
|
|
64
|
+
throw new Error(`This needs node:sqlite, which Node ${process.versions.node} does not provide ` +
|
|
65
|
+
`(it is unflagged from Node 24). Either upgrade Node, or run the CLI on bun, ` +
|
|
66
|
+
`which has it: \`bunx --bun pikku <command>\`.`, { cause: error });
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function createNodeSqliteRuntime() {
|
|
72
|
+
const { DatabaseSync } = await importNodeSqlite();
|
|
73
|
+
return {
|
|
74
|
+
open(filename) {
|
|
75
|
+
return new NodeSqliteDatabase(new DatabaseSync(filename));
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface SyncSqliteChanges {
|
|
2
|
+
changes: number | bigint;
|
|
3
|
+
lastInsertRowid: number | bigint;
|
|
4
|
+
}
|
|
5
|
+
export interface SyncSqliteStatement {
|
|
6
|
+
reader: boolean;
|
|
7
|
+
all(...parameters: unknown[]): unknown[];
|
|
8
|
+
get(...parameters: unknown[]): unknown | null;
|
|
9
|
+
iterate(...parameters: unknown[]): IterableIterator<unknown>;
|
|
10
|
+
run(...parameters: unknown[]): SyncSqliteChanges;
|
|
11
|
+
}
|
|
12
|
+
export interface SyncSqliteDatabase {
|
|
13
|
+
exec(sql: string): void;
|
|
14
|
+
prepare(sql: string): SyncSqliteStatement;
|
|
15
|
+
close(): void;
|
|
16
|
+
}
|
|
17
|
+
export interface SqliteRuntime {
|
|
18
|
+
open(filename: string): SyncSqliteDatabase;
|
|
19
|
+
}
|
|
20
|
+
export declare function loadSqliteRuntime(): Promise<SqliteRuntime>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
let runtimePromise;
|
|
2
|
+
export async function loadSqliteRuntime() {
|
|
3
|
+
runtimePromise ??= (async () => {
|
|
4
|
+
const isBunRuntime = typeof globalThis.Bun !== 'undefined';
|
|
5
|
+
if (isBunRuntime) {
|
|
6
|
+
const { bunSqliteRuntime } = await import('./sqlite-runtime-bun.js');
|
|
7
|
+
return bunSqliteRuntime;
|
|
8
|
+
}
|
|
9
|
+
const { createNodeSqliteRuntime } = await import('./sqlite-runtime-node.js');
|
|
10
|
+
return createNodeSqliteRuntime();
|
|
11
|
+
})();
|
|
12
|
+
return runtimePromise;
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pikku/migrator-sql",
|
|
3
|
+
"version": "0.12.2",
|
|
4
|
+
"description": "The SQL migration applier shared by the Pikku CLI and the standalone runtime",
|
|
5
|
+
"author": "yasser.fadl@gmail.com",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"module": "dist/src/index.js",
|
|
8
|
+
"main": "dist/src/index.js",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"tsc": "tsc",
|
|
12
|
+
"ncu": "npx npm-check-updates",
|
|
13
|
+
"build": "tsc -b",
|
|
14
|
+
"test": "bash run-tests.sh",
|
|
15
|
+
"test:watch": "bash run-tests.sh --watch",
|
|
16
|
+
"test:coverage": "bash run-tests.sh --coverage",
|
|
17
|
+
"prepublishOnly": "yarn build"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22.10"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/bun": "^1.4.0",
|
|
24
|
+
"@types/node": "^22",
|
|
25
|
+
"tsx": "^4.23.12",
|
|
26
|
+
"typescript": "^6.0.3"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./dist/src/index.js",
|
|
30
|
+
"./sqlite": "./dist/src/sqlite/index.js",
|
|
31
|
+
"./postgres": "./dist/src/postgres/index.js"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/run-tests.sh
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# Parse command line arguments
|
|
4
|
+
WATCH_MODE=false
|
|
5
|
+
COVERAGE_MODE=false
|
|
6
|
+
|
|
7
|
+
for arg in "$@"
|
|
8
|
+
do
|
|
9
|
+
case $arg in
|
|
10
|
+
--watch)
|
|
11
|
+
WATCH_MODE=true
|
|
12
|
+
shift
|
|
13
|
+
;;
|
|
14
|
+
--coverage)
|
|
15
|
+
COVERAGE_MODE=true
|
|
16
|
+
shift
|
|
17
|
+
;;
|
|
18
|
+
esac
|
|
19
|
+
done
|
|
20
|
+
|
|
21
|
+
# Build the command
|
|
22
|
+
CMD="node --import tsx --test --test-force-exit src/**/*.test.ts"
|
|
23
|
+
|
|
24
|
+
if [ "$WATCH_MODE" = true ]; then
|
|
25
|
+
CMD="$CMD --watch"
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
if [ "$COVERAGE_MODE" = true ]; then
|
|
29
|
+
CMD="$CMD --experimental-test-coverage"
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
# Execute the command
|
|
33
|
+
eval $CMD
|