@onreza/sqlx-js 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -22
- package/ROADMAP.md +2 -3
- package/dist/bin/sqlx-js.js +94 -11
- package/dist/src/codegen.js +0 -2
- package/dist/src/commands/init.d.ts +5 -0
- package/dist/src/commands/init.js +57 -0
- package/dist/src/commands/migrate.d.ts +190 -3
- package/dist/src/commands/migrate.js +1383 -46
- package/dist/src/commands/prepare.d.ts +16 -2
- package/dist/src/commands/prepare.js +81 -23
- package/dist/src/commands/schema.js +4 -0
- package/dist/src/commands/watch.js +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/pg/narrow.js +8 -0
- package/dist/src/pg/schema.d.ts +17 -1
- package/dist/src/pg/schema.js +48 -2
- package/dist/src/pg/wire.d.ts +14 -1
- package/dist/src/pg/wire.js +81 -3
- package/dist/src/postgres-runtime.d.ts +9 -2
- package/dist/src/postgres-runtime.js +20 -5
- package/dist/src/runtime.d.ts +12 -0
- package/dist/src/runtime.js +83 -3
- package/dist/src/scan/scanner.js +1 -1
- package/package.json +6 -9
- package/dist/src/bun-runtime.d.ts +0 -7
- package/dist/src/bun-runtime.js +0 -45
- package/dist/src/bun.d.ts +0 -22
- package/dist/src/bun.js +0 -9
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
6
|
import { PgClient, parseDatabaseUrl, decodeText } from "../pg/wire.js";
|
|
7
|
+
import { openSession, prepareOnce } from "./prepare.js";
|
|
8
|
+
import { introspectConnected, schemaSnapshotEqual, } from "../schema-snapshot.js";
|
|
9
|
+
const SQUASH_PREFIX = "-- sqlx-js-squash:";
|
|
5
10
|
export const DEFAULT_MIGRATE_LOCK_KEY = 18750938867203960n;
|
|
6
11
|
const FILE_RE = /^(\d+)_(.+)\.up\.sql$/;
|
|
12
|
+
const DOWN_FILE_RE = /^(\d+)_(.+)\.down\.sql$/;
|
|
7
13
|
const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
|
14
|
+
const MIGRATIONS_TABLE = "_sqlx_js_migrations";
|
|
8
15
|
function readMigrations(dir) {
|
|
9
16
|
if (!existsSync(dir))
|
|
10
17
|
return [];
|
|
@@ -28,58 +35,829 @@ function readMigrations(dir) {
|
|
|
28
35
|
downPath: existsSync(downPath) ? downPath : null,
|
|
29
36
|
upSql,
|
|
30
37
|
upHash: createHash("sha256").update(upSql).digest("hex"),
|
|
38
|
+
squash: parseSquashMetadata(upSql),
|
|
31
39
|
});
|
|
32
40
|
}
|
|
33
41
|
return out;
|
|
34
42
|
}
|
|
43
|
+
function parseSquashMetadata(sql) {
|
|
44
|
+
const line = sql.split(/\r?\n/).find((l) => l.startsWith(SQUASH_PREFIX));
|
|
45
|
+
if (!line)
|
|
46
|
+
return null;
|
|
47
|
+
const raw = line.slice(SQUASH_PREFIX.length).trim();
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(raw);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
throw new Error(`sqlx-js.migrate: invalid squash metadata JSON: ${e.message}`);
|
|
54
|
+
}
|
|
55
|
+
if (!parsed || typeof parsed !== "object") {
|
|
56
|
+
throw new Error("sqlx-js.migrate: invalid squash metadata");
|
|
57
|
+
}
|
|
58
|
+
const obj = parsed;
|
|
59
|
+
if (obj.format !== 1 || !Array.isArray(obj.replaces) || obj.replaces.length === 0) {
|
|
60
|
+
throw new Error("sqlx-js.migrate: invalid squash metadata");
|
|
61
|
+
}
|
|
62
|
+
const replaces = [];
|
|
63
|
+
for (const r of obj.replaces) {
|
|
64
|
+
if (!r || typeof r !== "object")
|
|
65
|
+
throw new Error("sqlx-js.migrate: invalid squash replacement metadata");
|
|
66
|
+
const item = r;
|
|
67
|
+
if (typeof item.version !== "number" || !Number.isSafeInteger(item.version) || item.version <= 0) {
|
|
68
|
+
throw new Error("sqlx-js.migrate: invalid squash replacement version");
|
|
69
|
+
}
|
|
70
|
+
if (typeof item.name !== "string" || !SAFE_NAME_RE.test(item.name)) {
|
|
71
|
+
throw new Error("sqlx-js.migrate: invalid squash replacement name");
|
|
72
|
+
}
|
|
73
|
+
if (typeof item.upHash !== "string" || !/^[a-f0-9]{64}$/.test(item.upHash)) {
|
|
74
|
+
throw new Error("sqlx-js.migrate: invalid squash replacement hash");
|
|
75
|
+
}
|
|
76
|
+
replaces.push({ version: item.version, name: item.name, upHash: item.upHash });
|
|
77
|
+
}
|
|
78
|
+
return { format: 1, replaces };
|
|
79
|
+
}
|
|
80
|
+
function quoteIdent(ident) {
|
|
81
|
+
return `"${ident.replace(/"/g, '""')}"`;
|
|
82
|
+
}
|
|
83
|
+
function databaseUrlWithDatabase(databaseUrl, database) {
|
|
84
|
+
const url = new URL(databaseUrl);
|
|
85
|
+
url.pathname = `/${database}`;
|
|
86
|
+
return url.toString();
|
|
87
|
+
}
|
|
88
|
+
function maintenanceDatabaseUrl(databaseUrl) {
|
|
89
|
+
return databaseUrlWithDatabase(databaseUrl, "postgres");
|
|
90
|
+
}
|
|
91
|
+
function generatedShadowDatabaseName() {
|
|
92
|
+
return `sqlx_js_shadow_${process.pid}_${Date.now().toString(36)}_${randomBytes(4).toString("hex")}`;
|
|
93
|
+
}
|
|
94
|
+
async function findMigrationStore(c) {
|
|
95
|
+
const r = await c.simpleQuery(`
|
|
96
|
+
SELECT n.nspname, cls.relname
|
|
97
|
+
FROM pg_class cls
|
|
98
|
+
JOIN pg_namespace n ON n.oid = cls.relnamespace
|
|
99
|
+
WHERE cls.oid = to_regclass('${MIGRATIONS_TABLE}')
|
|
100
|
+
`);
|
|
101
|
+
const row = r.rows[0];
|
|
102
|
+
if (!row)
|
|
103
|
+
return null;
|
|
104
|
+
const schema = decodeText(row[0]);
|
|
105
|
+
const table = decodeText(row[1]);
|
|
106
|
+
if (!schema || !table) {
|
|
107
|
+
throw new Error(`sqlx-js.migrate: failed to resolve ${MIGRATIONS_TABLE} identifier`);
|
|
108
|
+
}
|
|
109
|
+
return { table: `${quoteIdent(schema)}.${quoteIdent(table)}` };
|
|
110
|
+
}
|
|
111
|
+
async function resolveMigrationStore(c) {
|
|
112
|
+
const store = await findMigrationStore(c);
|
|
113
|
+
if (!store) {
|
|
114
|
+
throw new Error(`sqlx-js.migrate: failed to resolve ${MIGRATIONS_TABLE} in current search_path`);
|
|
115
|
+
}
|
|
116
|
+
return store;
|
|
117
|
+
}
|
|
35
118
|
export async function ensureTable(c) {
|
|
119
|
+
const existing = await findMigrationStore(c);
|
|
120
|
+
if (existing)
|
|
121
|
+
return existing;
|
|
36
122
|
await c.simpleQuery(`
|
|
37
|
-
CREATE TABLE IF NOT EXISTS
|
|
123
|
+
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
|
|
38
124
|
version BIGINT PRIMARY KEY,
|
|
39
125
|
name TEXT NOT NULL,
|
|
40
126
|
up_hash TEXT NOT NULL,
|
|
41
127
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
42
128
|
)
|
|
43
129
|
`);
|
|
130
|
+
return resolveMigrationStore(c);
|
|
44
131
|
}
|
|
45
|
-
export async function listApplied(c) {
|
|
46
|
-
const
|
|
132
|
+
export async function listApplied(c, store) {
|
|
133
|
+
const s = store ?? await resolveMigrationStore(c);
|
|
134
|
+
const r = await c.simpleQuery(`SELECT version, name, up_hash FROM ${s.table} ORDER BY version`);
|
|
47
135
|
const out = new Map();
|
|
48
136
|
for (const row of r.rows) {
|
|
49
137
|
out.set(Number(decodeText(row[0])), { name: decodeText(row[1]), hash: decodeText(row[2]) });
|
|
50
138
|
}
|
|
51
139
|
return out;
|
|
52
140
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
const
|
|
141
|
+
function appliedSquashSupersededVersions(all, applied, onEvent) {
|
|
142
|
+
const superseded = new Set();
|
|
143
|
+
const byVersion = new Map(all.map((m) => [m.version, m]));
|
|
144
|
+
const visitedSquashes = new Set();
|
|
145
|
+
const visitReplacements = (m) => {
|
|
146
|
+
if (!m.squash || visitedSquashes.has(m.version))
|
|
147
|
+
return true;
|
|
148
|
+
visitedSquashes.add(m.version);
|
|
149
|
+
for (const r of m.squash.replaces) {
|
|
150
|
+
superseded.add(r.version);
|
|
151
|
+
const current = byVersion.get(r.version);
|
|
152
|
+
if (!current?.squash || current.version >= m.version)
|
|
153
|
+
continue;
|
|
154
|
+
if (current.name !== r.name || current.upHash !== r.upHash) {
|
|
155
|
+
onEvent?.({ kind: "tampered", version: r.version, name: r.name, applied: r.upHash, current: current.upHash });
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
if (!visitReplacements(current))
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
return true;
|
|
162
|
+
};
|
|
58
163
|
for (const m of all) {
|
|
164
|
+
if (!m.squash)
|
|
165
|
+
continue;
|
|
59
166
|
const a = applied.get(m.version);
|
|
167
|
+
if (!a)
|
|
168
|
+
continue;
|
|
169
|
+
if (a.hash !== m.upHash) {
|
|
170
|
+
onEvent?.({ kind: "tampered", version: m.version, name: m.name, applied: a.hash, current: m.upHash });
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
if (!visitReplacements(m))
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
return superseded;
|
|
177
|
+
}
|
|
178
|
+
function squashCoveredVersions(all) {
|
|
179
|
+
const covered = new Set();
|
|
180
|
+
for (const m of all) {
|
|
181
|
+
if (!m.squash)
|
|
182
|
+
continue;
|
|
183
|
+
for (const r of m.squash.replaces) {
|
|
184
|
+
if (r.version >= m.version) {
|
|
185
|
+
throw new Error(`sqlx-js.migrate: squash replacement ${r.version}_${r.name} must be older than ${m.version}_${m.name}`);
|
|
186
|
+
}
|
|
187
|
+
covered.add(r.version);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return covered;
|
|
191
|
+
}
|
|
192
|
+
function migrationCheckIssue(code, message, details = {}) {
|
|
193
|
+
return { severity: "error", code, message, ...details };
|
|
194
|
+
}
|
|
195
|
+
function parseMigrationVersion(raw) {
|
|
196
|
+
const version = Number(raw);
|
|
197
|
+
if (!Number.isSafeInteger(version) || version <= 0)
|
|
198
|
+
return null;
|
|
199
|
+
return version;
|
|
200
|
+
}
|
|
201
|
+
export function checkMigrationFiles(migrationsDir) {
|
|
202
|
+
const issues = [];
|
|
203
|
+
const upByVersion = new Map();
|
|
204
|
+
const downFiles = [];
|
|
205
|
+
let migrations = 0;
|
|
206
|
+
if (existsSync(migrationsDir)) {
|
|
207
|
+
for (const file of readdirSync(migrationsDir).sort()) {
|
|
208
|
+
const up = FILE_RE.exec(file);
|
|
209
|
+
const down = DOWN_FILE_RE.exec(file);
|
|
210
|
+
if (!up && !down) {
|
|
211
|
+
if (file.endsWith(".up.sql") || file.endsWith(".down.sql")) {
|
|
212
|
+
issues.push(migrationCheckIssue("invalid-file-name", `migration file ${file} must be named <version>_<name>.up.sql or <version>_<name>.down.sql`, { file }));
|
|
213
|
+
}
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const match = up ?? down;
|
|
217
|
+
const version = parseMigrationVersion(match[1]);
|
|
218
|
+
const name = match[2];
|
|
219
|
+
const stem = `${match[1]}_${name}`;
|
|
220
|
+
if (version === null) {
|
|
221
|
+
issues.push(migrationCheckIssue("invalid-version", `migration file ${file} has an invalid version`, { file }));
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (!SAFE_NAME_RE.test(name)) {
|
|
225
|
+
issues.push(migrationCheckIssue("invalid-file-name", `migration file ${file} has an unsafe name`, { file, version, name }));
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (down) {
|
|
229
|
+
downFiles.push({ file, stem, version, name });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
migrations++;
|
|
233
|
+
const upSql = readFileSync(join(migrationsDir, file), "utf8");
|
|
234
|
+
let squash = null;
|
|
235
|
+
try {
|
|
236
|
+
squash = parseSquashMetadata(upSql);
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
issues.push(migrationCheckIssue("invalid-squash-metadata", `${file}: ${e.message}`, { file, version, name }));
|
|
240
|
+
}
|
|
241
|
+
const upHash = createHash("sha256").update(upSql).digest("hex");
|
|
242
|
+
const existing = upByVersion.get(version);
|
|
243
|
+
if (existing) {
|
|
244
|
+
issues.push(migrationCheckIssue("duplicate-version", `migration version ${version} is used by both ${existing.file} and ${file}`, { file, version, name }));
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
upByVersion.set(version, { file, stem, name, upHash, squash });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
for (const down of downFiles) {
|
|
251
|
+
const up = upByVersion.get(down.version);
|
|
252
|
+
if (!up || up.stem !== down.stem) {
|
|
253
|
+
issues.push(migrationCheckIssue("orphan-down", `down migration ${down.file} does not have a matching up migration`, { file: down.file, version: down.version, name: down.name }));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
for (const [version, migration] of upByVersion) {
|
|
257
|
+
if (!migration.squash)
|
|
258
|
+
continue;
|
|
259
|
+
for (const r of migration.squash.replaces) {
|
|
260
|
+
if (r.version >= version) {
|
|
261
|
+
issues.push(migrationCheckIssue("invalid-squash-replacement", `squash replacement ${r.version}_${r.name} must be older than ${version}_${migration.name}`, { file: migration.file, version, name: migration.name }));
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const current = upByVersion.get(r.version);
|
|
265
|
+
if (current && (current.name !== r.name || current.upHash !== r.upHash)) {
|
|
266
|
+
issues.push(migrationCheckIssue("tampered-squash-replacement", `squash replacement ${r.version}_${r.name} does not match current migration file ${current.file}`, { file: current.file, version: r.version, name: r.name }));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
ok: issues.length === 0,
|
|
272
|
+
migrations,
|
|
273
|
+
archives: listMigrationArchives(migrationsDir).length,
|
|
274
|
+
issues,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function diffByKey(before, after, key) {
|
|
278
|
+
const beforeMap = new Map(before.map((item) => [key(item), JSON.stringify(item)]));
|
|
279
|
+
const afterMap = new Map(after.map((item) => [key(item), JSON.stringify(item)]));
|
|
280
|
+
const added = [];
|
|
281
|
+
const removed = [];
|
|
282
|
+
const changed = [];
|
|
283
|
+
for (const k of afterMap.keys()) {
|
|
284
|
+
if (!beforeMap.has(k))
|
|
285
|
+
added.push(k);
|
|
286
|
+
else if (beforeMap.get(k) !== afterMap.get(k))
|
|
287
|
+
changed.push(k);
|
|
288
|
+
}
|
|
289
|
+
for (const k of beforeMap.keys()) {
|
|
290
|
+
if (!afterMap.has(k))
|
|
291
|
+
removed.push(k);
|
|
292
|
+
}
|
|
293
|
+
return { added: added.sort(), removed: removed.sort(), changed: changed.sort() };
|
|
294
|
+
}
|
|
295
|
+
function schemaTypeKey(type) {
|
|
296
|
+
return `${type.kind}:${type.schema}.${type.name}`;
|
|
297
|
+
}
|
|
298
|
+
function schemaFunctionKey(fn) {
|
|
299
|
+
return `${fn.schema}.${fn.name}(${fn.identityArguments})`;
|
|
300
|
+
}
|
|
301
|
+
function schemaDiffSummary(before, after) {
|
|
302
|
+
return {
|
|
303
|
+
relations: diffByKey(before.relations, after.relations, (r) => `${r.schema}.${r.name}`),
|
|
304
|
+
types: diffByKey(before.types, after.types, schemaTypeKey),
|
|
305
|
+
functions: diffByKey(before.functions, after.functions, schemaFunctionKey),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
async function listUserSchemas(c) {
|
|
309
|
+
const r = await c.simpleQuery(`
|
|
310
|
+
SELECT nspname
|
|
311
|
+
FROM pg_namespace
|
|
312
|
+
WHERE nspname <> 'information_schema'
|
|
313
|
+
AND nspname NOT LIKE 'pg\\_%' ESCAPE '\\'
|
|
314
|
+
ORDER BY nspname
|
|
315
|
+
`);
|
|
316
|
+
return r.rows.map((row) => decodeText(row[0])).filter((schema) => schema.length > 0);
|
|
317
|
+
}
|
|
318
|
+
async function isolateShadowSchemaState(c) {
|
|
319
|
+
for (const schema of await listUserSchemas(c)) {
|
|
320
|
+
await c.simpleQuery(`DROP SCHEMA IF EXISTS ${quoteIdent(schema)} CASCADE`);
|
|
321
|
+
}
|
|
322
|
+
await c.simpleQuery("CREATE SCHEMA IF NOT EXISTS public");
|
|
323
|
+
await resetMigrationSession(c);
|
|
324
|
+
}
|
|
325
|
+
async function useExplicitShadowDatabase(databaseUrl) {
|
|
326
|
+
const c = new PgClient(parseDatabaseUrl(databaseUrl));
|
|
327
|
+
await c.connect();
|
|
328
|
+
try {
|
|
329
|
+
await isolateShadowSchemaState(c);
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
await c.end();
|
|
333
|
+
}
|
|
334
|
+
return { databaseUrl, cleanup: async () => { } };
|
|
335
|
+
}
|
|
336
|
+
async function createDisposableShadowDatabase(databaseUrl, shadowAdminUrl, log = console.log) {
|
|
337
|
+
if (!databaseUrl) {
|
|
338
|
+
throw new Error("DATABASE_URL is required to create an automatic shadow database (or pass --shadow-url)");
|
|
339
|
+
}
|
|
340
|
+
const name = generatedShadowDatabaseName();
|
|
341
|
+
const adminUrl = shadowAdminUrl ?? maintenanceDatabaseUrl(databaseUrl);
|
|
342
|
+
const shadowUrl = databaseUrlWithDatabase(databaseUrl, name);
|
|
343
|
+
const owner = parseDatabaseUrl(databaseUrl).user;
|
|
344
|
+
const admin = new PgClient(parseDatabaseUrl(adminUrl));
|
|
345
|
+
await admin.connect();
|
|
346
|
+
try {
|
|
347
|
+
await admin.simpleQuery(`CREATE DATABASE ${quoteIdent(name)} OWNER ${quoteIdent(owner)}`);
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
throw new Error(`sqlx-js.migrate: failed to create shadow database ${name}: ${err.message}. ` +
|
|
351
|
+
"Grant CREATEDB, pass --shadow-admin-url, or pass --shadow-url.");
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
await admin.end();
|
|
355
|
+
}
|
|
356
|
+
log(`shadow: created ${name}`);
|
|
357
|
+
let dropped = false;
|
|
358
|
+
return {
|
|
359
|
+
databaseUrl: shadowUrl,
|
|
360
|
+
cleanup: async () => {
|
|
361
|
+
if (dropped)
|
|
362
|
+
return;
|
|
363
|
+
dropped = true;
|
|
364
|
+
const dropAdmin = new PgClient(parseDatabaseUrl(adminUrl));
|
|
365
|
+
await dropAdmin.connect();
|
|
366
|
+
try {
|
|
367
|
+
await dropAdmin.simpleQuery(`DROP DATABASE IF EXISTS ${quoteIdent(name)}`);
|
|
368
|
+
log(`shadow: dropped ${name}`);
|
|
369
|
+
}
|
|
370
|
+
finally {
|
|
371
|
+
await dropAdmin.end();
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
async function withWorkflowShadowDatabase(opts, fn) {
|
|
377
|
+
const handle = opts.shadowUrl
|
|
378
|
+
? await useExplicitShadowDatabase(opts.shadowUrl)
|
|
379
|
+
: await createDisposableShadowDatabase(opts.databaseUrl, opts.shadowAdminUrl);
|
|
380
|
+
let fnError;
|
|
381
|
+
try {
|
|
382
|
+
return await fn(handle.databaseUrl);
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
fnError = err;
|
|
386
|
+
throw err;
|
|
387
|
+
}
|
|
388
|
+
finally {
|
|
389
|
+
try {
|
|
390
|
+
await handle.cleanup();
|
|
391
|
+
}
|
|
392
|
+
catch (cleanupErr) {
|
|
393
|
+
if (fnError)
|
|
394
|
+
console.warn(`shadow: cleanup failed after command error: ${cleanupErr.message}`);
|
|
395
|
+
else
|
|
396
|
+
throw cleanupErr;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
async function withDryRunShadowDatabase(opts, fn, log) {
|
|
401
|
+
if (opts.shadowUrl)
|
|
402
|
+
return fn(opts.shadowUrl);
|
|
403
|
+
const handle = await createDisposableShadowDatabase(opts.databaseUrl, opts.shadowAdminUrl, log);
|
|
404
|
+
let fnError;
|
|
405
|
+
try {
|
|
406
|
+
return await fn(handle.databaseUrl);
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
fnError = err;
|
|
410
|
+
throw err;
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
try {
|
|
414
|
+
await handle.cleanup();
|
|
415
|
+
}
|
|
416
|
+
catch (cleanupErr) {
|
|
417
|
+
if (fnError)
|
|
418
|
+
console.warn(`shadow: cleanup failed after command error: ${cleanupErr.message}`);
|
|
419
|
+
else
|
|
420
|
+
throw cleanupErr;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
async function applyMigrationsForWorkflow(databaseUrl, migrationsDir, lockKey, lockTimeoutMs) {
|
|
425
|
+
const c = new PgClient(parseDatabaseUrl(databaseUrl));
|
|
426
|
+
await c.connect();
|
|
427
|
+
let locked = false;
|
|
428
|
+
try {
|
|
429
|
+
await acquireMigrateLock(c, lockKey ?? DEFAULT_MIGRATE_LOCK_KEY, lockTimeoutMs);
|
|
430
|
+
locked = true;
|
|
431
|
+
const result = await applyPending(c, migrationsDir, (e) => {
|
|
432
|
+
if (e.kind === "applied")
|
|
433
|
+
console.log(`shadow: applied ${String(e.version).padStart(4, "0")}_${e.name}`);
|
|
434
|
+
else if (e.kind === "adopted")
|
|
435
|
+
console.log(`shadow: adopted ${String(e.version).padStart(4, "0")}_${e.name} (${e.replaced} replaced)`);
|
|
436
|
+
else if (e.kind === "tampered") {
|
|
437
|
+
throw new Error(`sqlx-js shadow: ${e.version}_${e.name} hash mismatch (applied ${e.applied.slice(0, 16)} vs current ${e.current.slice(0, 16)})`);
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
throw new Error(`sqlx-js shadow: ${e.version}_${e.name} failed — ${e.error}`);
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
if (result.applied === 0 && result.tampered === 0 && result.failed === 0)
|
|
444
|
+
console.log("shadow: migrations up-to-date");
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
if (locked) {
|
|
448
|
+
try {
|
|
449
|
+
await releaseMigrateLock(c, lockKey ?? DEFAULT_MIGRATE_LOCK_KEY);
|
|
450
|
+
}
|
|
451
|
+
catch (e) {
|
|
452
|
+
console.warn(`shadow: failed to release advisory lock: ${e.message}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
await c.end();
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function latestMigrationIsSquash(migrationsDir) {
|
|
459
|
+
const all = readMigrations(migrationsDir);
|
|
460
|
+
return all[all.length - 1]?.squash != null;
|
|
461
|
+
}
|
|
462
|
+
async function validateLatestDownForWorkflow(databaseUrl, migrationsDir) {
|
|
463
|
+
const c = new PgClient(parseDatabaseUrl(databaseUrl));
|
|
464
|
+
await c.connect();
|
|
465
|
+
try {
|
|
466
|
+
const outcome = await checkLastDownMigration(c, migrationsDir);
|
|
467
|
+
if (outcome.kind === "noop") {
|
|
468
|
+
console.log("shadow: no migrations to validate down");
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (outcome.kind === "passed") {
|
|
472
|
+
console.log(`shadow: latest down restores schema (${String(outcome.version).padStart(4, "0")}_${outcome.name})`);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (outcome.kind === "no-down" && latestMigrationIsSquash(migrationsDir)) {
|
|
476
|
+
console.log(`shadow: latest migration has no down (expected for squash baseline ${String(outcome.version).padStart(4, "0")}_${outcome.name})`);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (outcome.kind === "no-down") {
|
|
480
|
+
throw new Error(`latest migration ${String(outcome.version).padStart(4, "0")}_${outcome.name} has no .down.sql`);
|
|
481
|
+
}
|
|
482
|
+
if (outcome.kind === "schema-mismatch") {
|
|
483
|
+
throw new Error(`latest migration ${String(outcome.version).padStart(4, "0")}_${outcome.name} down did not restore schema`);
|
|
484
|
+
}
|
|
485
|
+
throw new Error(`latest down validation failed during ${outcome.phase}: ${outcome.error}`);
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
await c.end();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async function prepareWorkflowArtifacts(opts, databaseUrl) {
|
|
492
|
+
const prepareOpts = {
|
|
493
|
+
root: opts.root,
|
|
494
|
+
databaseUrl,
|
|
495
|
+
cacheDir: opts.cacheDir,
|
|
496
|
+
dtsPath: opts.dtsPath,
|
|
497
|
+
check: false,
|
|
498
|
+
prune: opts.prune,
|
|
499
|
+
};
|
|
500
|
+
const session = await openSession(prepareOpts);
|
|
501
|
+
try {
|
|
502
|
+
const r = await prepareOnce(prepareOpts, session);
|
|
503
|
+
if (r.failures > 0) {
|
|
504
|
+
console.error(`\n${r.failures} query/queries failed to prepare`);
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
console.log(`\nprepared ${r.entries} unique query/queries → ${opts.dtsPath}`);
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
finally {
|
|
511
|
+
await session.client.end();
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
async function prepareInTemporaryArtifacts(opts, databaseUrl) {
|
|
515
|
+
const tmp = mkdtempSync(join(tmpdir(), "sqlx-js-verify-"));
|
|
516
|
+
const cacheDir = join(tmp, "cache");
|
|
517
|
+
const dtsPath = join(tmp, "sqlx-js-env.d.ts");
|
|
518
|
+
const prepareOpts = {
|
|
519
|
+
root: opts.root,
|
|
520
|
+
databaseUrl,
|
|
521
|
+
cacheDir,
|
|
522
|
+
dtsPath,
|
|
523
|
+
check: false,
|
|
524
|
+
prune: true,
|
|
525
|
+
};
|
|
526
|
+
const session = await openSession(prepareOpts);
|
|
527
|
+
try {
|
|
528
|
+
const r = await prepareOnce(prepareOpts, session);
|
|
529
|
+
if (r.failures > 0) {
|
|
530
|
+
console.error(`\n${r.failures} query/queries failed to prepare`);
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
console.log(`shadow: prepared ${r.entries} unique query/queries`);
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
await session.client.end();
|
|
538
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function effectiveSquashReplacements(all) {
|
|
542
|
+
const covered = squashCoveredVersions(all);
|
|
543
|
+
return all
|
|
544
|
+
.filter((m) => !covered.has(m.version))
|
|
545
|
+
.map((m) => ({ version: m.version, name: m.name, upHash: m.upHash }));
|
|
546
|
+
}
|
|
547
|
+
function preflightSquashMigrations(all, applied, superseded, onEvent) {
|
|
548
|
+
const byVersion = new Map(all.map((m) => [m.version, m]));
|
|
549
|
+
for (const m of all) {
|
|
550
|
+
if (!m.squash)
|
|
551
|
+
continue;
|
|
552
|
+
let present = 0;
|
|
553
|
+
for (const r of m.squash.replaces) {
|
|
554
|
+
if (r.version >= m.version) {
|
|
555
|
+
onEvent?.({
|
|
556
|
+
kind: "failed",
|
|
557
|
+
version: m.version,
|
|
558
|
+
name: m.name,
|
|
559
|
+
error: `squash replacement ${r.version}_${r.name} must be older than ${m.version}_${m.name}`,
|
|
560
|
+
});
|
|
561
|
+
return "failed";
|
|
562
|
+
}
|
|
563
|
+
const current = byVersion.get(r.version);
|
|
564
|
+
if (current && !superseded.has(r.version) && (current.name !== r.name || current.upHash !== r.upHash)) {
|
|
565
|
+
onEvent?.({ kind: "tampered", version: r.version, name: r.name, applied: r.upHash, current: current.upHash });
|
|
566
|
+
return "tampered";
|
|
567
|
+
}
|
|
568
|
+
const a = applied.get(r.version);
|
|
569
|
+
if (!a)
|
|
570
|
+
continue;
|
|
571
|
+
present++;
|
|
572
|
+
if (a.hash !== r.upHash || a.name !== r.name) {
|
|
573
|
+
onEvent?.({ kind: "tampered", version: r.version, name: r.name, applied: a.hash, current: r.upHash });
|
|
574
|
+
return "tampered";
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (present > 0 && present !== m.squash.replaces.length) {
|
|
578
|
+
onEvent?.({
|
|
579
|
+
kind: "failed",
|
|
580
|
+
version: m.version,
|
|
581
|
+
name: m.name,
|
|
582
|
+
error: `squash migration replaces ${m.squash.replaces.length} migration(s), but only ${present} matching row(s) are applied`,
|
|
583
|
+
});
|
|
584
|
+
return "failed";
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return "ok";
|
|
588
|
+
}
|
|
589
|
+
function planSquashAdoption(m, applied, onEvent) {
|
|
590
|
+
if (!m.squash)
|
|
591
|
+
return { kind: "none" };
|
|
592
|
+
let present = 0;
|
|
593
|
+
for (const r of m.squash.replaces) {
|
|
594
|
+
if (r.version >= m.version) {
|
|
595
|
+
onEvent?.({
|
|
596
|
+
kind: "failed",
|
|
597
|
+
version: m.version,
|
|
598
|
+
name: m.name,
|
|
599
|
+
error: `squash replacement ${r.version}_${r.name} must be older than ${m.version}_${m.name}`,
|
|
600
|
+
});
|
|
601
|
+
return { kind: "failed" };
|
|
602
|
+
}
|
|
603
|
+
const a = applied.get(r.version);
|
|
604
|
+
if (!a)
|
|
605
|
+
continue;
|
|
606
|
+
present++;
|
|
607
|
+
if (a.hash !== r.upHash || a.name !== r.name) {
|
|
608
|
+
onEvent?.({ kind: "tampered", version: r.version, name: r.name, applied: a.hash, current: r.upHash });
|
|
609
|
+
return { kind: "tampered" };
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (present === 0)
|
|
613
|
+
return { kind: "none" };
|
|
614
|
+
if (present !== m.squash.replaces.length) {
|
|
615
|
+
onEvent?.({
|
|
616
|
+
kind: "failed",
|
|
617
|
+
version: m.version,
|
|
618
|
+
name: m.name,
|
|
619
|
+
error: `squash migration replaces ${m.squash.replaces.length} migration(s), but only ${present} matching row(s) are applied`,
|
|
620
|
+
});
|
|
621
|
+
return { kind: "failed" };
|
|
622
|
+
}
|
|
623
|
+
return { kind: "adopt", replaced: m.squash.replaces.length };
|
|
624
|
+
}
|
|
625
|
+
function buildMigrationPlan(all, applied, onEvent) {
|
|
626
|
+
const plannedApplied = new Map(applied);
|
|
627
|
+
const superseded = appliedSquashSupersededVersions(all, plannedApplied, onEvent);
|
|
628
|
+
if (!superseded)
|
|
629
|
+
return { kind: "tampered" };
|
|
630
|
+
const preflight = preflightSquashMigrations(all, plannedApplied, superseded, onEvent);
|
|
631
|
+
if (preflight !== "ok")
|
|
632
|
+
return { kind: preflight };
|
|
633
|
+
const steps = [];
|
|
634
|
+
for (const m of all) {
|
|
635
|
+
if (superseded.has(m.version))
|
|
636
|
+
continue;
|
|
637
|
+
const a = plannedApplied.get(m.version);
|
|
60
638
|
if (a) {
|
|
61
639
|
if (a.hash !== m.upHash) {
|
|
62
|
-
counts.tampered++;
|
|
63
640
|
onEvent?.({ kind: "tampered", version: m.version, name: m.name, applied: a.hash, current: m.upHash });
|
|
641
|
+
return { kind: "tampered" };
|
|
642
|
+
}
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
const adoption = planSquashAdoption(m, plannedApplied, onEvent);
|
|
646
|
+
if (adoption.kind === "adopt") {
|
|
647
|
+
steps.push({ kind: "adopt", migration: m, replaced: adoption.replaced });
|
|
648
|
+
for (const r of m.squash.replaces)
|
|
649
|
+
plannedApplied.delete(r.version);
|
|
650
|
+
plannedApplied.set(m.version, { name: m.name, hash: m.upHash });
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
if (adoption.kind === "tampered" || adoption.kind === "failed")
|
|
654
|
+
return { kind: adoption.kind };
|
|
655
|
+
steps.push({ kind: "apply", migration: m });
|
|
656
|
+
plannedApplied.set(m.version, { name: m.name, hash: m.upHash });
|
|
657
|
+
}
|
|
658
|
+
return { kind: "ok", steps };
|
|
659
|
+
}
|
|
660
|
+
function publicPlanItem(step) {
|
|
661
|
+
if (step.kind === "apply") {
|
|
662
|
+
return { kind: "apply", version: step.migration.version, name: step.migration.name };
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
kind: "adopt",
|
|
666
|
+
version: step.migration.version,
|
|
667
|
+
name: step.migration.name,
|
|
668
|
+
replaced: step.replaced,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
async function resetMigrationSession(c) {
|
|
672
|
+
await c.simpleQuery("RESET ALL");
|
|
673
|
+
}
|
|
674
|
+
async function executeSquashAdoption(c, store, m, applied, replaced, onEvent) {
|
|
675
|
+
if (!m.squash)
|
|
676
|
+
return "failed";
|
|
677
|
+
let committed = false;
|
|
678
|
+
await c.simpleQuery("BEGIN");
|
|
679
|
+
try {
|
|
680
|
+
for (const r of m.squash.replaces) {
|
|
681
|
+
await c.execParamsText(`DELETE FROM ${store.table} WHERE version = $1`, [String(r.version)]);
|
|
682
|
+
}
|
|
683
|
+
await c.execParamsText(`INSERT INTO ${store.table} (version, name, up_hash) VALUES ($1, $2, $3)`, [String(m.version), m.name, m.upHash]);
|
|
684
|
+
await c.simpleQuery("COMMIT");
|
|
685
|
+
committed = true;
|
|
686
|
+
}
|
|
687
|
+
catch (err) {
|
|
688
|
+
let rollbackErr;
|
|
689
|
+
if (!committed) {
|
|
690
|
+
try {
|
|
691
|
+
await c.simpleQuery("ROLLBACK");
|
|
692
|
+
}
|
|
693
|
+
catch (rb) {
|
|
694
|
+
rollbackErr = rb.message;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
const message = rollbackErr
|
|
698
|
+
? `${err.message} (rollback also failed: ${rollbackErr})`
|
|
699
|
+
: err.message;
|
|
700
|
+
onEvent?.({ kind: "failed", version: m.version, name: m.name, error: message });
|
|
701
|
+
return "failed";
|
|
702
|
+
}
|
|
703
|
+
try {
|
|
704
|
+
await resetMigrationSession(c);
|
|
705
|
+
}
|
|
706
|
+
catch (err) {
|
|
707
|
+
onEvent?.({ kind: "failed", version: m.version, name: m.name, error: `failed to reset migration session: ${err.message}` });
|
|
708
|
+
return "failed";
|
|
709
|
+
}
|
|
710
|
+
for (const r of m.squash.replaces)
|
|
711
|
+
applied.delete(r.version);
|
|
712
|
+
applied.set(m.version, { name: m.name, hash: m.upHash });
|
|
713
|
+
onEvent?.({ kind: "adopted", version: m.version, name: m.name, replaced });
|
|
714
|
+
return "done";
|
|
715
|
+
}
|
|
716
|
+
export async function planPending(c, migrationsDir, onEvent) {
|
|
717
|
+
const store = await findMigrationStore(c);
|
|
718
|
+
const applied = store ? await listApplied(c, store) : new Map();
|
|
719
|
+
const all = readMigrations(migrationsDir);
|
|
720
|
+
const counts = { pending: 0, adoptable: 0, tampered: 0, failed: 0 };
|
|
721
|
+
const plan = buildMigrationPlan(all, applied, onEvent);
|
|
722
|
+
if (plan.kind === "tampered") {
|
|
723
|
+
counts.tampered++;
|
|
724
|
+
return { ...counts, steps: [] };
|
|
725
|
+
}
|
|
726
|
+
if (plan.kind === "failed") {
|
|
727
|
+
counts.failed++;
|
|
728
|
+
return { ...counts, steps: [] };
|
|
729
|
+
}
|
|
730
|
+
const steps = plan.steps.map(publicPlanItem);
|
|
731
|
+
for (const step of steps) {
|
|
732
|
+
if (step.kind === "apply") {
|
|
733
|
+
counts.pending++;
|
|
734
|
+
onEvent?.({ kind: "pending", version: step.version, name: step.name });
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
counts.adoptable++;
|
|
738
|
+
onEvent?.({ kind: "adoptable", version: step.version, name: step.name, replaced: step.replaced });
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return { ...counts, steps };
|
|
742
|
+
}
|
|
743
|
+
export async function inspectMigrationPlan(c, migrationsDir) {
|
|
744
|
+
const diagnostics = [];
|
|
745
|
+
const result = await planPending(c, migrationsDir, (e) => {
|
|
746
|
+
if (e.kind === "tampered" || e.kind === "failed")
|
|
747
|
+
diagnostics.push(e);
|
|
748
|
+
});
|
|
749
|
+
return {
|
|
750
|
+
ok: result.tampered === 0 && result.failed === 0,
|
|
751
|
+
...result,
|
|
752
|
+
diagnostics,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
export async function inspectMigrations(c, migrationsDir) {
|
|
756
|
+
const store = await findMigrationStore(c);
|
|
757
|
+
const applied = store ? await listApplied(c, store) : new Map();
|
|
758
|
+
const all = readMigrations(migrationsDir);
|
|
759
|
+
const validation = [];
|
|
760
|
+
const plan = buildMigrationPlan(all, applied, (e) => validation.push(e));
|
|
761
|
+
const planned = new Map();
|
|
762
|
+
if (plan.kind === "ok") {
|
|
763
|
+
for (const step of plan.steps)
|
|
764
|
+
planned.set(step.migration.version, step);
|
|
765
|
+
}
|
|
766
|
+
const validationByVersion = new Map(validation.map((e) => [e.version, e]));
|
|
767
|
+
const superseded = appliedSquashSupersededVersions(all, applied) ?? new Set();
|
|
768
|
+
const summary = {
|
|
769
|
+
applied: 0,
|
|
770
|
+
pending: 0,
|
|
771
|
+
adoptable: 0,
|
|
772
|
+
superseded: 0,
|
|
773
|
+
tampered: 0,
|
|
774
|
+
failed: 0,
|
|
775
|
+
};
|
|
776
|
+
const items = all.map((m) => {
|
|
777
|
+
const validationEvent = validationByVersion.get(m.version);
|
|
778
|
+
if (validationEvent?.kind === "tampered") {
|
|
779
|
+
summary.tampered++;
|
|
780
|
+
return {
|
|
781
|
+
version: m.version,
|
|
782
|
+
name: m.name,
|
|
783
|
+
status: "tampered",
|
|
784
|
+
detail: `hash mismatch: applied ${validationEvent.applied.slice(0, 16)} vs current ${validationEvent.current.slice(0, 16)}`,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
if (validationEvent?.kind === "failed") {
|
|
788
|
+
summary.failed++;
|
|
789
|
+
return { version: m.version, name: m.name, status: "failed", detail: validationEvent.error };
|
|
790
|
+
}
|
|
791
|
+
if (superseded.has(m.version)) {
|
|
792
|
+
summary.superseded++;
|
|
793
|
+
return { version: m.version, name: m.name, status: "superseded" };
|
|
794
|
+
}
|
|
795
|
+
const a = applied.get(m.version);
|
|
796
|
+
if (a) {
|
|
797
|
+
if (a.hash !== m.upHash) {
|
|
798
|
+
summary.tampered++;
|
|
799
|
+
return {
|
|
800
|
+
version: m.version,
|
|
801
|
+
name: m.name,
|
|
802
|
+
status: "tampered",
|
|
803
|
+
detail: `hash mismatch: applied ${a.hash.slice(0, 16)} vs current ${m.upHash.slice(0, 16)}`,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
summary.applied++;
|
|
807
|
+
return { version: m.version, name: m.name, status: "applied" };
|
|
808
|
+
}
|
|
809
|
+
const plannedStep = planned.get(m.version);
|
|
810
|
+
if (plannedStep?.kind === "adopt") {
|
|
811
|
+
summary.adoptable++;
|
|
812
|
+
return { version: m.version, name: m.name, status: "adoptable", detail: `${plannedStep.replaced} replaced` };
|
|
813
|
+
}
|
|
814
|
+
summary.pending++;
|
|
815
|
+
return { version: m.version, name: m.name, status: "pending" };
|
|
816
|
+
});
|
|
817
|
+
return { historyTable: store?.table ?? null, summary, items };
|
|
818
|
+
}
|
|
819
|
+
export async function applyPending(c, migrationsDir, onEvent) {
|
|
820
|
+
const store = await ensureTable(c);
|
|
821
|
+
const applied = await listApplied(c, store);
|
|
822
|
+
const all = readMigrations(migrationsDir);
|
|
823
|
+
const counts = { applied: 0, tampered: 0, failed: 0 };
|
|
824
|
+
const plan = buildMigrationPlan(all, applied, onEvent);
|
|
825
|
+
if (plan.kind === "tampered") {
|
|
826
|
+
counts.tampered++;
|
|
827
|
+
return counts;
|
|
828
|
+
}
|
|
829
|
+
if (plan.kind === "failed") {
|
|
830
|
+
counts.failed++;
|
|
831
|
+
return counts;
|
|
832
|
+
}
|
|
833
|
+
for (const step of plan.steps) {
|
|
834
|
+
const m = step.migration;
|
|
835
|
+
if (step.kind === "adopt") {
|
|
836
|
+
const adoption = await executeSquashAdoption(c, store, m, applied, step.replaced, onEvent);
|
|
837
|
+
if (adoption === "failed") {
|
|
838
|
+
counts.failed++;
|
|
64
839
|
return counts;
|
|
65
840
|
}
|
|
841
|
+
counts.applied++;
|
|
66
842
|
continue;
|
|
67
843
|
}
|
|
844
|
+
let committed = false;
|
|
68
845
|
await c.simpleQuery("BEGIN");
|
|
69
846
|
try {
|
|
70
847
|
await c.simpleQuery(m.upSql);
|
|
71
|
-
await c.execParamsText(
|
|
848
|
+
await c.execParamsText(`INSERT INTO ${store.table} (version, name, up_hash) VALUES ($1, $2, $3)`, [String(m.version), m.name, m.upHash]);
|
|
72
849
|
await c.simpleQuery("COMMIT");
|
|
73
|
-
|
|
74
|
-
onEvent?.({ kind: "applied", version: m.version, name: m.name });
|
|
850
|
+
committed = true;
|
|
75
851
|
}
|
|
76
852
|
catch (err) {
|
|
77
853
|
let rollbackErr;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
854
|
+
if (!committed) {
|
|
855
|
+
try {
|
|
856
|
+
await c.simpleQuery("ROLLBACK");
|
|
857
|
+
}
|
|
858
|
+
catch (rb) {
|
|
859
|
+
rollbackErr = rb.message;
|
|
860
|
+
}
|
|
83
861
|
}
|
|
84
862
|
counts.failed++;
|
|
85
863
|
const message = rollbackErr
|
|
@@ -88,6 +866,17 @@ export async function applyPending(c, migrationsDir, onEvent) {
|
|
|
88
866
|
onEvent?.({ kind: "failed", version: m.version, name: m.name, error: message });
|
|
89
867
|
return counts;
|
|
90
868
|
}
|
|
869
|
+
try {
|
|
870
|
+
await resetMigrationSession(c);
|
|
871
|
+
}
|
|
872
|
+
catch (err) {
|
|
873
|
+
counts.failed++;
|
|
874
|
+
onEvent?.({ kind: "failed", version: m.version, name: m.name, error: `failed to reset migration session: ${err.message}` });
|
|
875
|
+
return counts;
|
|
876
|
+
}
|
|
877
|
+
counts.applied++;
|
|
878
|
+
applied.set(m.version, { name: m.name, hash: m.upHash });
|
|
879
|
+
onEvent?.({ kind: "applied", version: m.version, name: m.name });
|
|
91
880
|
}
|
|
92
881
|
return counts;
|
|
93
882
|
}
|
|
@@ -128,7 +917,275 @@ export async function releaseMigrateLock(c, lockKey = DEFAULT_MIGRATE_LOCK_KEY)
|
|
|
128
917
|
const key = lockKeyToString(lockKey);
|
|
129
918
|
await c.simpleQuery(`SELECT pg_advisory_unlock(${key})`);
|
|
130
919
|
}
|
|
920
|
+
function safeMigrationName(name) {
|
|
921
|
+
const safe = name.replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/^[^a-zA-Z0-9]+/, "");
|
|
922
|
+
if (!safe)
|
|
923
|
+
throw new Error(`sqlx-js.migrate: invalid migration name "${name}"`);
|
|
924
|
+
return safe;
|
|
925
|
+
}
|
|
926
|
+
function renderSquashMigration(replaces, schemaSql) {
|
|
927
|
+
const metadata = { format: 1, replaces };
|
|
928
|
+
return [
|
|
929
|
+
`${SQUASH_PREFIX} ${JSON.stringify(metadata)}`,
|
|
930
|
+
"-- generated by sqlx-js migrate squash",
|
|
931
|
+
"-- This baseline can be applied to an empty database or adopted by a database",
|
|
932
|
+
"-- that has all replaced migrations recorded with matching hashes.",
|
|
933
|
+
"",
|
|
934
|
+
schemaSql.trimEnd(),
|
|
935
|
+
"",
|
|
936
|
+
].join("\n");
|
|
937
|
+
}
|
|
938
|
+
export function sanitizePgDumpSchema(sql) {
|
|
939
|
+
const out = [];
|
|
940
|
+
let state = { dollarQuote: null, blockComment: false, singleQuote: null };
|
|
941
|
+
for (const line of sql.split(/\r?\n/)) {
|
|
942
|
+
if (!state.dollarQuote && !state.blockComment && !state.singleQuote && line.trimStart().startsWith("\\"))
|
|
943
|
+
continue;
|
|
944
|
+
out.push(line);
|
|
945
|
+
state = scanPgDumpLine(line, state);
|
|
946
|
+
}
|
|
947
|
+
return out.join("\n").trimEnd() + "\n";
|
|
948
|
+
}
|
|
949
|
+
function matchDollarQuote(line, index) {
|
|
950
|
+
const m = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/.exec(line.slice(index));
|
|
951
|
+
return m?.[0] ?? null;
|
|
952
|
+
}
|
|
953
|
+
function scanPgDumpLine(line, current) {
|
|
954
|
+
const state = { ...current };
|
|
955
|
+
let i = 0;
|
|
956
|
+
while (i < line.length) {
|
|
957
|
+
if (state.singleQuote) {
|
|
958
|
+
while (i < line.length) {
|
|
959
|
+
if (state.singleQuote === "escape" && line[i] === "\\") {
|
|
960
|
+
i += 2;
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
if (line[i] !== "'") {
|
|
964
|
+
i++;
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
if (line[i + 1] === "'") {
|
|
968
|
+
i += 2;
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
i++;
|
|
972
|
+
state.singleQuote = null;
|
|
973
|
+
break;
|
|
974
|
+
}
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
if (state.dollarQuote) {
|
|
978
|
+
const end = line.indexOf(state.dollarQuote, i);
|
|
979
|
+
if (end === -1)
|
|
980
|
+
return state;
|
|
981
|
+
i = end + state.dollarQuote.length;
|
|
982
|
+
state.dollarQuote = null;
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
if (state.blockComment) {
|
|
986
|
+
const end = line.indexOf("*/", i);
|
|
987
|
+
if (end === -1)
|
|
988
|
+
return state;
|
|
989
|
+
i = end + 2;
|
|
990
|
+
state.blockComment = false;
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
const ch = line[i];
|
|
994
|
+
const next = line[i + 1];
|
|
995
|
+
if (ch === "-" && next === "-")
|
|
996
|
+
break;
|
|
997
|
+
if (ch === "/" && next === "*") {
|
|
998
|
+
state.blockComment = true;
|
|
999
|
+
i += 2;
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
if (ch === "'") {
|
|
1003
|
+
const escapeString = i > 0 && (line[i - 1] === "E" || line[i - 1] === "e");
|
|
1004
|
+
state.singleQuote = escapeString ? "escape" : "standard";
|
|
1005
|
+
i++;
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
if (ch === "$") {
|
|
1009
|
+
const tag = matchDollarQuote(line, i);
|
|
1010
|
+
if (tag) {
|
|
1011
|
+
state.dollarQuote = tag;
|
|
1012
|
+
i += tag.length;
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
i++;
|
|
1017
|
+
}
|
|
1018
|
+
return state;
|
|
1019
|
+
}
|
|
1020
|
+
function archiveMigrations(migrationsDir, archiveName, migrations) {
|
|
1021
|
+
const archiveDir = join(migrationsDir, ".archive", archiveName);
|
|
1022
|
+
if (existsSync(archiveDir)) {
|
|
1023
|
+
throw new Error(`sqlx-js.migrate: archive already exists: ${archiveDir}`);
|
|
1024
|
+
}
|
|
1025
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
1026
|
+
for (const m of migrations) {
|
|
1027
|
+
renameSync(m.upPath, join(archiveDir, basename(m.upPath)));
|
|
1028
|
+
if (m.downPath)
|
|
1029
|
+
renameSync(m.downPath, join(archiveDir, basename(m.downPath)));
|
|
1030
|
+
}
|
|
1031
|
+
return archiveDir;
|
|
1032
|
+
}
|
|
1033
|
+
function validateArchiveName(name) {
|
|
1034
|
+
if (!SAFE_NAME_RE.test(name)) {
|
|
1035
|
+
throw new Error(`sqlx-js.migrate: unsafe archive name ${name}`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
function isMigrationSqlFile(file) {
|
|
1039
|
+
const up = FILE_RE.exec(file);
|
|
1040
|
+
const down = DOWN_FILE_RE.exec(file);
|
|
1041
|
+
const m = up ?? down;
|
|
1042
|
+
return !!m && SAFE_NAME_RE.test(m[2]);
|
|
1043
|
+
}
|
|
1044
|
+
export function listMigrationArchives(migrationsDir) {
|
|
1045
|
+
const archiveRoot = join(migrationsDir, ".archive");
|
|
1046
|
+
if (!existsSync(archiveRoot))
|
|
1047
|
+
return [];
|
|
1048
|
+
return readdirSync(archiveRoot, { withFileTypes: true })
|
|
1049
|
+
.filter((entry) => entry.isDirectory() && SAFE_NAME_RE.test(entry.name))
|
|
1050
|
+
.map((entry) => {
|
|
1051
|
+
const path = join(archiveRoot, entry.name);
|
|
1052
|
+
const files = readdirSync(path)
|
|
1053
|
+
.filter((file) => isMigrationSqlFile(file))
|
|
1054
|
+
.sort();
|
|
1055
|
+
return { name: entry.name, path, files };
|
|
1056
|
+
})
|
|
1057
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
1058
|
+
}
|
|
1059
|
+
export function restoreMigrationArchive(migrationsDir, archiveName, opts = {}) {
|
|
1060
|
+
validateArchiveName(archiveName);
|
|
1061
|
+
const archiveDir = join(migrationsDir, ".archive", archiveName);
|
|
1062
|
+
if (!existsSync(archiveDir)) {
|
|
1063
|
+
throw new Error(`sqlx-js.migrate: archive not found: ${archiveName}`);
|
|
1064
|
+
}
|
|
1065
|
+
const files = readdirSync(archiveDir)
|
|
1066
|
+
.filter((file) => isMigrationSqlFile(file))
|
|
1067
|
+
.sort();
|
|
1068
|
+
if (files.length === 0)
|
|
1069
|
+
throw new Error(`sqlx-js.migrate: archive is empty: ${archiveName}`);
|
|
1070
|
+
const conflicts = files.filter((file) => existsSync(join(migrationsDir, file)));
|
|
1071
|
+
if (conflicts.length > 0 && !opts.force) {
|
|
1072
|
+
throw new Error(`sqlx-js.migrate: restore would overwrite existing migration file(s): ${conflicts.join(", ")}`);
|
|
1073
|
+
}
|
|
1074
|
+
for (const file of files) {
|
|
1075
|
+
renameSync(join(archiveDir, file), join(migrationsDir, file));
|
|
1076
|
+
}
|
|
1077
|
+
return { archiveName, restored: files };
|
|
1078
|
+
}
|
|
1079
|
+
export function createSquashMigration(opts) {
|
|
1080
|
+
if (!existsSync(opts.migrationsDir))
|
|
1081
|
+
mkdirSync(opts.migrationsDir, { recursive: true });
|
|
1082
|
+
const existing = readMigrations(opts.migrationsDir);
|
|
1083
|
+
if (existing.length === 0)
|
|
1084
|
+
throw new Error("sqlx-js.migrate: no migrations to squash");
|
|
1085
|
+
if (opts.schemaSql.trim() === "")
|
|
1086
|
+
throw new Error("sqlx-js.migrate: squash schema SQL is empty");
|
|
1087
|
+
const safe = safeMigrationName(opts.name);
|
|
1088
|
+
const nextVersion = (existing[existing.length - 1]?.version ?? 0) + 1;
|
|
1089
|
+
const padded = String(nextVersion).padStart(4, "0");
|
|
1090
|
+
const upPath = join(opts.migrationsDir, `${padded}_${safe}.up.sql`);
|
|
1091
|
+
if (existsSync(upPath))
|
|
1092
|
+
throw new Error(`sqlx-js.migrate: migration already exists: ${upPath}`);
|
|
1093
|
+
const replaces = effectiveSquashReplacements(existing);
|
|
1094
|
+
writeFileSync(upPath, renderSquashMigration(replaces, opts.schemaSql));
|
|
1095
|
+
let archiveDir;
|
|
1096
|
+
if (opts.replace) {
|
|
1097
|
+
archiveDir = archiveMigrations(opts.migrationsDir, `${padded}_${safe}`, existing);
|
|
1098
|
+
}
|
|
1099
|
+
return { version: nextVersion, name: safe, upPath, replaced: replaces.length, archiveDir };
|
|
1100
|
+
}
|
|
1101
|
+
function pgDumpEnv(databaseUrl) {
|
|
1102
|
+
const cfg = parseDatabaseUrl(databaseUrl);
|
|
1103
|
+
const env = { ...process.env };
|
|
1104
|
+
env.PGHOST = cfg.host;
|
|
1105
|
+
env.PGPORT = String(cfg.port);
|
|
1106
|
+
env.PGUSER = cfg.user;
|
|
1107
|
+
env.PGDATABASE = cfg.database;
|
|
1108
|
+
if (cfg.password)
|
|
1109
|
+
env.PGPASSWORD = cfg.password;
|
|
1110
|
+
else
|
|
1111
|
+
delete env.PGPASSWORD;
|
|
1112
|
+
if (cfg.sslmode)
|
|
1113
|
+
env.PGSSLMODE = cfg.sslmode;
|
|
1114
|
+
else
|
|
1115
|
+
delete env.PGSSLMODE;
|
|
1116
|
+
if (cfg.applicationName)
|
|
1117
|
+
env.PGAPPNAME = cfg.applicationName;
|
|
1118
|
+
else
|
|
1119
|
+
delete env.PGAPPNAME;
|
|
1120
|
+
if (cfg.connectTimeoutMs)
|
|
1121
|
+
env.PGCONNECT_TIMEOUT = String(cfg.connectTimeoutMs / 1000);
|
|
1122
|
+
else
|
|
1123
|
+
delete env.PGCONNECT_TIMEOUT;
|
|
1124
|
+
delete env.PGSERVICE;
|
|
1125
|
+
delete env.PGSERVICEFILE;
|
|
1126
|
+
delete env.PGPASSFILE;
|
|
1127
|
+
return env;
|
|
1128
|
+
}
|
|
1129
|
+
export function dumpSchema(databaseUrl, pgDumpPath = "pg_dump") {
|
|
1130
|
+
const tmp = mkdtempSync(join(tmpdir(), "sqlx-js-pgdump-"));
|
|
1131
|
+
const outPath = join(tmp, "schema.sql");
|
|
1132
|
+
const args = [
|
|
1133
|
+
"--schema-only",
|
|
1134
|
+
"--no-owner",
|
|
1135
|
+
"--no-privileges",
|
|
1136
|
+
"--exclude-table=*._sqlx_js_migrations",
|
|
1137
|
+
"--exclude-table=_sqlx_js_migrations",
|
|
1138
|
+
"--exclude-table=public._sqlx_js_migrations",
|
|
1139
|
+
`--file=${outPath}`,
|
|
1140
|
+
"-w",
|
|
1141
|
+
];
|
|
1142
|
+
try {
|
|
1143
|
+
const r = spawnSync(pgDumpPath, args, {
|
|
1144
|
+
encoding: "utf8",
|
|
1145
|
+
env: pgDumpEnv(databaseUrl),
|
|
1146
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
1147
|
+
});
|
|
1148
|
+
if (r.error) {
|
|
1149
|
+
throw new Error(`sqlx-js.migrate: failed to run ${pgDumpPath}: ${r.error.message}`);
|
|
1150
|
+
}
|
|
1151
|
+
if (r.status !== 0) {
|
|
1152
|
+
throw new Error(`sqlx-js.migrate: ${pgDumpPath} failed: ${r.stderr.trim() || `exit ${r.status}`}`);
|
|
1153
|
+
}
|
|
1154
|
+
const schema = sanitizePgDumpSchema(readFileSync(outPath, "utf8"));
|
|
1155
|
+
if (schema.trim() === "")
|
|
1156
|
+
throw new Error("sqlx-js.migrate: pg_dump returned empty schema");
|
|
1157
|
+
return schema;
|
|
1158
|
+
}
|
|
1159
|
+
finally {
|
|
1160
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
export async function migrateSquash(opts) {
|
|
1164
|
+
let schemaSql = "";
|
|
1165
|
+
await withWorkflowShadowDatabase({
|
|
1166
|
+
databaseUrl: opts.databaseUrl ?? "",
|
|
1167
|
+
shadowUrl: opts.shadowUrl,
|
|
1168
|
+
shadowAdminUrl: opts.shadowAdminUrl,
|
|
1169
|
+
}, async (shadowDatabaseUrl) => {
|
|
1170
|
+
await applyMigrationsForWorkflow(shadowDatabaseUrl, opts.migrationsDir, opts.lockKey, opts.lockTimeoutMs);
|
|
1171
|
+
schemaSql = dumpSchema(shadowDatabaseUrl, opts.pgDumpPath);
|
|
1172
|
+
});
|
|
1173
|
+
const result = createSquashMigration({
|
|
1174
|
+
migrationsDir: opts.migrationsDir,
|
|
1175
|
+
name: opts.name,
|
|
1176
|
+
schemaSql,
|
|
1177
|
+
replace: opts.replace,
|
|
1178
|
+
});
|
|
1179
|
+
console.log(`created ${result.upPath}`);
|
|
1180
|
+
console.log(`squash: replaced ${result.replaced} migration(s) with ${String(result.version).padStart(4, "0")}_${result.name}`);
|
|
1181
|
+
if (result.archiveDir)
|
|
1182
|
+
console.log(`squash: archived replaced migrations in ${result.archiveDir}`);
|
|
1183
|
+
}
|
|
131
1184
|
export async function migrateRun(opts) {
|
|
1185
|
+
if (opts.json && !opts.dryRun) {
|
|
1186
|
+
console.error("--json for migrate run requires --dry-run");
|
|
1187
|
+
process.exit(2);
|
|
1188
|
+
}
|
|
132
1189
|
const cfg = parseDatabaseUrl(opts.databaseUrl);
|
|
133
1190
|
const c = new PgClient(cfg);
|
|
134
1191
|
await c.connect();
|
|
@@ -138,20 +1195,54 @@ export async function migrateRun(opts) {
|
|
|
138
1195
|
try {
|
|
139
1196
|
await acquireMigrateLock(c, lockKey, opts.lockTimeoutMs);
|
|
140
1197
|
locked = true;
|
|
141
|
-
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
console.error(` current: ${e.current.slice(0, 16)}…`);
|
|
148
|
-
exitCode = 1;
|
|
1198
|
+
if (opts.dryRun) {
|
|
1199
|
+
if (opts.json) {
|
|
1200
|
+
const result = await inspectMigrationPlan(c, opts.migrationsDir);
|
|
1201
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1202
|
+
if (!result.ok)
|
|
1203
|
+
exitCode = 1;
|
|
149
1204
|
}
|
|
150
1205
|
else {
|
|
151
|
-
|
|
152
|
-
|
|
1206
|
+
const result = await planPending(c, opts.migrationsDir, (e) => {
|
|
1207
|
+
if (e.kind === "pending")
|
|
1208
|
+
console.log(`would apply ${e.version}_${e.name}`);
|
|
1209
|
+
else if (e.kind === "adoptable")
|
|
1210
|
+
console.log(`would adopt ${e.version}_${e.name} (${e.replaced} replaced)`);
|
|
1211
|
+
else if (e.kind === "tampered") {
|
|
1212
|
+
console.error(`migration ${e.version}_${e.name} was tampered with (hash mismatch)`);
|
|
1213
|
+
console.error(` applied: ${e.applied.slice(0, 16)}…`);
|
|
1214
|
+
console.error(` current: ${e.current.slice(0, 16)}…`);
|
|
1215
|
+
exitCode = 1;
|
|
1216
|
+
}
|
|
1217
|
+
else {
|
|
1218
|
+
console.error(`planning ${e.version}_${e.name}…\n ✗ ${e.error}`);
|
|
1219
|
+
exitCode = 1;
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
if (exitCode === 0 && result.steps.length === 0)
|
|
1223
|
+
console.log("migrations up-to-date");
|
|
1224
|
+
else if (exitCode === 0)
|
|
1225
|
+
console.log(`dry-run: ${result.steps.length} pending action(s)`);
|
|
153
1226
|
}
|
|
154
|
-
}
|
|
1227
|
+
}
|
|
1228
|
+
else {
|
|
1229
|
+
await applyPending(c, opts.migrationsDir, (e) => {
|
|
1230
|
+
if (e.kind === "applied")
|
|
1231
|
+
console.log(`applying ${e.version}_${e.name}…\n ✓ applied`);
|
|
1232
|
+
else if (e.kind === "adopted")
|
|
1233
|
+
console.log(`adopting ${e.version}_${e.name}…\n ✓ replaced ${e.replaced} migration rows`);
|
|
1234
|
+
else if (e.kind === "tampered") {
|
|
1235
|
+
console.error(`migration ${e.version}_${e.name} was tampered with (hash mismatch)`);
|
|
1236
|
+
console.error(` applied: ${e.applied.slice(0, 16)}…`);
|
|
1237
|
+
console.error(` current: ${e.current.slice(0, 16)}…`);
|
|
1238
|
+
exitCode = 1;
|
|
1239
|
+
}
|
|
1240
|
+
else {
|
|
1241
|
+
console.error(`applying ${e.version}_${e.name}…\n ✗ ${e.error}`);
|
|
1242
|
+
exitCode = 1;
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
155
1246
|
}
|
|
156
1247
|
finally {
|
|
157
1248
|
if (locked) {
|
|
@@ -171,20 +1262,82 @@ export async function migrateInfo(opts) {
|
|
|
171
1262
|
const cfg = parseDatabaseUrl(opts.databaseUrl);
|
|
172
1263
|
const c = new PgClient(cfg);
|
|
173
1264
|
await c.connect();
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
console.log(`
|
|
1265
|
+
try {
|
|
1266
|
+
const info = await inspectMigrations(c, opts.migrationsDir);
|
|
1267
|
+
if (opts.json) {
|
|
1268
|
+
console.log(JSON.stringify(info, null, 2));
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
console.log(`migrations in ${opts.migrationsDir}:`);
|
|
1272
|
+
console.log(`history table: ${info.historyTable ?? "not created"}`);
|
|
1273
|
+
console.log(`summary: ${info.summary.applied} applied, ${info.summary.pending} pending, ` +
|
|
1274
|
+
`${info.summary.adoptable} adoptable, ${info.summary.superseded} superseded, ` +
|
|
1275
|
+
`${info.summary.tampered} tampered, ${info.summary.failed} failed`);
|
|
1276
|
+
for (const item of info.items) {
|
|
1277
|
+
const detail = item.detail ? ` (${item.detail})` : "";
|
|
1278
|
+
console.log(` ${String(item.version).padStart(4, "0")}_${item.name}: ${item.status}${detail}`);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
finally {
|
|
1282
|
+
await c.end();
|
|
182
1283
|
}
|
|
183
|
-
|
|
1284
|
+
}
|
|
1285
|
+
export function migrateCheck(opts) {
|
|
1286
|
+
const report = checkMigrationFiles(opts.migrationsDir);
|
|
1287
|
+
if (opts.json) {
|
|
1288
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1289
|
+
}
|
|
1290
|
+
else if (report.ok) {
|
|
1291
|
+
console.log(`migration files ok: ${report.migrations} migration(s), ${report.archives} archive(s)`);
|
|
1292
|
+
}
|
|
1293
|
+
else {
|
|
1294
|
+
console.error(`migration check failed: ${report.issues.length} issue(s)`);
|
|
1295
|
+
for (const issue of report.issues) {
|
|
1296
|
+
const file = issue.file ? `${issue.file}: ` : "";
|
|
1297
|
+
console.error(` [${issue.severity}] ${issue.code}: ${file}${issue.message}`);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (!report.ok)
|
|
1301
|
+
process.exit(1);
|
|
1302
|
+
}
|
|
1303
|
+
function assertMigrationCheckOk(migrationsDir) {
|
|
1304
|
+
const report = checkMigrationFiles(migrationsDir);
|
|
1305
|
+
if (report.ok) {
|
|
1306
|
+
console.log(`migration files ok: ${report.migrations} migration(s), ${report.archives} archive(s)`);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
console.error(`migration check failed: ${report.issues.length} issue(s)`);
|
|
1310
|
+
for (const issue of report.issues) {
|
|
1311
|
+
const file = issue.file ? `${issue.file}: ` : "";
|
|
1312
|
+
console.error(` [${issue.severity}] ${issue.code}: ${file}${issue.message}`);
|
|
1313
|
+
}
|
|
1314
|
+
process.exit(1);
|
|
1315
|
+
}
|
|
1316
|
+
export async function migrateDev(opts) {
|
|
1317
|
+
assertMigrationCheckOk(opts.migrationsDir);
|
|
1318
|
+
let ok = true;
|
|
1319
|
+
await withWorkflowShadowDatabase(opts, async (shadowDatabaseUrl) => {
|
|
1320
|
+
await applyMigrationsForWorkflow(shadowDatabaseUrl, opts.migrationsDir, opts.lockKey, opts.lockTimeoutMs);
|
|
1321
|
+
await validateLatestDownForWorkflow(shadowDatabaseUrl, opts.migrationsDir);
|
|
1322
|
+
ok = await prepareWorkflowArtifacts(opts, shadowDatabaseUrl);
|
|
1323
|
+
});
|
|
1324
|
+
if (!ok)
|
|
1325
|
+
process.exit(1);
|
|
1326
|
+
}
|
|
1327
|
+
export async function migrateVerify(opts) {
|
|
1328
|
+
assertMigrationCheckOk(opts.migrationsDir);
|
|
1329
|
+
let ok = true;
|
|
1330
|
+
await withWorkflowShadowDatabase(opts, async (shadowDatabaseUrl) => {
|
|
1331
|
+
await applyMigrationsForWorkflow(shadowDatabaseUrl, opts.migrationsDir, opts.lockKey, opts.lockTimeoutMs);
|
|
1332
|
+
await validateLatestDownForWorkflow(shadowDatabaseUrl, opts.migrationsDir);
|
|
1333
|
+
ok = await prepareInTemporaryArtifacts(opts, shadowDatabaseUrl);
|
|
1334
|
+
});
|
|
1335
|
+
if (!ok)
|
|
1336
|
+
process.exit(1);
|
|
184
1337
|
}
|
|
185
1338
|
export async function revertLast(c, migrationsDir) {
|
|
186
|
-
await ensureTable(c);
|
|
187
|
-
const applied = await listApplied(c);
|
|
1339
|
+
const store = await ensureTable(c);
|
|
1340
|
+
const applied = await listApplied(c, store);
|
|
188
1341
|
const all = readMigrations(migrationsDir);
|
|
189
1342
|
let last = null;
|
|
190
1343
|
for (let i = all.length - 1; i >= 0; i--) {
|
|
@@ -201,7 +1354,7 @@ export async function revertLast(c, migrationsDir) {
|
|
|
201
1354
|
await c.simpleQuery("BEGIN");
|
|
202
1355
|
try {
|
|
203
1356
|
await c.simpleQuery(downSql);
|
|
204
|
-
await c.execParamsText(
|
|
1357
|
+
await c.execParamsText(`DELETE FROM ${store.table} WHERE version = $1`, [String(last.version)]);
|
|
205
1358
|
await c.simpleQuery("COMMIT");
|
|
206
1359
|
return { kind: "reverted", version: last.version, name: last.name };
|
|
207
1360
|
}
|
|
@@ -219,7 +1372,175 @@ export async function revertLast(c, migrationsDir) {
|
|
|
219
1372
|
return { kind: "failed", version: last.version, name: last.name, error: msg };
|
|
220
1373
|
}
|
|
221
1374
|
}
|
|
1375
|
+
export async function checkLastDownMigration(c, migrationsDir) {
|
|
1376
|
+
const localCheck = checkMigrationFiles(migrationsDir);
|
|
1377
|
+
if (!localCheck.ok) {
|
|
1378
|
+
return {
|
|
1379
|
+
kind: "failed",
|
|
1380
|
+
phase: "validate",
|
|
1381
|
+
error: localCheck.issues.map((i) => `${i.code}: ${i.message}`).join("; "),
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
const all = readMigrations(migrationsDir);
|
|
1385
|
+
const target = all[all.length - 1];
|
|
1386
|
+
if (!target)
|
|
1387
|
+
return { kind: "noop" };
|
|
1388
|
+
if (!target.downPath)
|
|
1389
|
+
return { kind: "no-down", version: target.version, name: target.name };
|
|
1390
|
+
const downSql = readFileSync(target.downPath, "utf8");
|
|
1391
|
+
const validation = [];
|
|
1392
|
+
const prefixPlan = buildMigrationPlan(all.slice(0, -1), new Map(), (e) => {
|
|
1393
|
+
validation.push(e);
|
|
1394
|
+
});
|
|
1395
|
+
if (prefixPlan.kind !== "ok") {
|
|
1396
|
+
return {
|
|
1397
|
+
kind: "failed",
|
|
1398
|
+
version: target.version,
|
|
1399
|
+
name: target.name,
|
|
1400
|
+
phase: "validate",
|
|
1401
|
+
error: validation.map((e) => e.kind === "failed" ? e.error : `${e.version}_${e.name} hash mismatch`).join("; "),
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
let phase = "begin";
|
|
1405
|
+
let outcome;
|
|
1406
|
+
let inTransaction = false;
|
|
1407
|
+
try {
|
|
1408
|
+
await c.simpleQuery("BEGIN");
|
|
1409
|
+
inTransaction = true;
|
|
1410
|
+
phase = "isolate";
|
|
1411
|
+
await isolateShadowSchemaState(c);
|
|
1412
|
+
phase = "previous-up";
|
|
1413
|
+
for (const step of prefixPlan.steps) {
|
|
1414
|
+
if (step.kind === "apply") {
|
|
1415
|
+
await c.simpleQuery(step.migration.upSql);
|
|
1416
|
+
await resetMigrationSession(c);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
phase = "snapshot-before";
|
|
1420
|
+
const before = await introspectConnected(c);
|
|
1421
|
+
phase = "target-up";
|
|
1422
|
+
await c.simpleQuery(target.upSql);
|
|
1423
|
+
await resetMigrationSession(c);
|
|
1424
|
+
phase = "down";
|
|
1425
|
+
await c.simpleQuery(downSql);
|
|
1426
|
+
await resetMigrationSession(c);
|
|
1427
|
+
phase = "snapshot-after";
|
|
1428
|
+
const after = await introspectConnected(c);
|
|
1429
|
+
outcome = schemaSnapshotEqual(before, after)
|
|
1430
|
+
? { kind: "passed", version: target.version, name: target.name }
|
|
1431
|
+
: {
|
|
1432
|
+
kind: "schema-mismatch",
|
|
1433
|
+
version: target.version,
|
|
1434
|
+
name: target.name,
|
|
1435
|
+
diff: schemaDiffSummary(before, after),
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
catch (err) {
|
|
1439
|
+
outcome = {
|
|
1440
|
+
kind: "failed",
|
|
1441
|
+
version: target.version,
|
|
1442
|
+
name: target.name,
|
|
1443
|
+
phase,
|
|
1444
|
+
error: err.message,
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
if (inTransaction) {
|
|
1448
|
+
try {
|
|
1449
|
+
phase = "rollback";
|
|
1450
|
+
await c.simpleQuery("ROLLBACK");
|
|
1451
|
+
}
|
|
1452
|
+
catch (err) {
|
|
1453
|
+
const rollbackError = err.message;
|
|
1454
|
+
if (outcome.kind === "failed") {
|
|
1455
|
+
return { ...outcome, error: `${outcome.error} (rollback also failed: ${rollbackError})` };
|
|
1456
|
+
}
|
|
1457
|
+
return {
|
|
1458
|
+
kind: "failed",
|
|
1459
|
+
version: target.version,
|
|
1460
|
+
name: target.name,
|
|
1461
|
+
phase,
|
|
1462
|
+
error: rollbackError,
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
return outcome;
|
|
1467
|
+
}
|
|
1468
|
+
function migrationLabel(version, name) {
|
|
1469
|
+
return `${String(version).padStart(4, "0")}_${name}`;
|
|
1470
|
+
}
|
|
1471
|
+
function printSchemaObjectDiff(label, diff) {
|
|
1472
|
+
if (diff.added.length > 0)
|
|
1473
|
+
console.error(` ${label} added: ${diff.added.join(", ")}`);
|
|
1474
|
+
if (diff.removed.length > 0)
|
|
1475
|
+
console.error(` ${label} removed: ${diff.removed.join(", ")}`);
|
|
1476
|
+
if (diff.changed.length > 0)
|
|
1477
|
+
console.error(` ${label} changed: ${diff.changed.join(", ")}`);
|
|
1478
|
+
}
|
|
1479
|
+
async function migrateRevertDryRun(opts) {
|
|
1480
|
+
let exitCode = 0;
|
|
1481
|
+
await withDryRunShadowDatabase(opts, async (shadowDatabaseUrl) => {
|
|
1482
|
+
const cfg = parseDatabaseUrl(shadowDatabaseUrl);
|
|
1483
|
+
const c = new PgClient(cfg);
|
|
1484
|
+
await c.connect();
|
|
1485
|
+
const lockKey = opts.lockKey ?? DEFAULT_MIGRATE_LOCK_KEY;
|
|
1486
|
+
let locked = false;
|
|
1487
|
+
try {
|
|
1488
|
+
await acquireMigrateLock(c, lockKey, opts.lockTimeoutMs);
|
|
1489
|
+
locked = true;
|
|
1490
|
+
const outcome = await checkLastDownMigration(c, opts.migrationsDir);
|
|
1491
|
+
if (opts.json) {
|
|
1492
|
+
console.log(JSON.stringify(outcome, null, 2));
|
|
1493
|
+
}
|
|
1494
|
+
else if (outcome.kind === "noop") {
|
|
1495
|
+
console.log("revert dry-run: no migrations");
|
|
1496
|
+
}
|
|
1497
|
+
else if (outcome.kind === "no-down") {
|
|
1498
|
+
console.error(`migration ${migrationLabel(outcome.version, outcome.name)} has no .down.sql`);
|
|
1499
|
+
exitCode = 1;
|
|
1500
|
+
}
|
|
1501
|
+
else if (outcome.kind === "passed") {
|
|
1502
|
+
console.log(`revert dry-run: ${migrationLabel(outcome.version, outcome.name)} restores schema`);
|
|
1503
|
+
}
|
|
1504
|
+
else if (outcome.kind === "schema-mismatch") {
|
|
1505
|
+
console.error(`revert dry-run: ${migrationLabel(outcome.version, outcome.name)} down did not restore schema`);
|
|
1506
|
+
printSchemaObjectDiff("relations", outcome.diff.relations);
|
|
1507
|
+
printSchemaObjectDiff("types", outcome.diff.types);
|
|
1508
|
+
printSchemaObjectDiff("functions", outcome.diff.functions);
|
|
1509
|
+
exitCode = 1;
|
|
1510
|
+
}
|
|
1511
|
+
else {
|
|
1512
|
+
const label = outcome.version && outcome.name ? `${migrationLabel(outcome.version, outcome.name)} ` : "";
|
|
1513
|
+
console.error(`revert dry-run: ${label}failed during ${outcome.phase}`);
|
|
1514
|
+
console.error(` ✗ ${outcome.error}`);
|
|
1515
|
+
exitCode = 1;
|
|
1516
|
+
}
|
|
1517
|
+
if (opts.json && outcome.kind !== "noop" && outcome.kind !== "passed")
|
|
1518
|
+
exitCode = 1;
|
|
1519
|
+
}
|
|
1520
|
+
finally {
|
|
1521
|
+
if (locked) {
|
|
1522
|
+
try {
|
|
1523
|
+
await releaseMigrateLock(c, lockKey);
|
|
1524
|
+
}
|
|
1525
|
+
catch (e) {
|
|
1526
|
+
console.warn(`sqlx-js.migrate: failed to release advisory lock: ${e.message}`);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
await c.end();
|
|
1530
|
+
}
|
|
1531
|
+
}, opts.json ? () => { } : console.log);
|
|
1532
|
+
if (exitCode !== 0)
|
|
1533
|
+
process.exit(exitCode);
|
|
1534
|
+
}
|
|
222
1535
|
export async function migrateRevert(opts) {
|
|
1536
|
+
if (opts.json && !opts.dryRun) {
|
|
1537
|
+
console.error("--json for migrate revert requires --dry-run");
|
|
1538
|
+
process.exit(2);
|
|
1539
|
+
}
|
|
1540
|
+
if (opts.dryRun) {
|
|
1541
|
+
await migrateRevertDryRun(opts);
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
223
1544
|
const cfg = parseDatabaseUrl(opts.databaseUrl);
|
|
224
1545
|
const c = new PgClient(cfg);
|
|
225
1546
|
await c.connect();
|
|
@@ -261,14 +1582,30 @@ export async function migrateRevert(opts) {
|
|
|
261
1582
|
if (exitCode !== 0)
|
|
262
1583
|
process.exit(exitCode);
|
|
263
1584
|
}
|
|
1585
|
+
export function migrateArchiveList(opts) {
|
|
1586
|
+
const archives = listMigrationArchives(opts.migrationsDir);
|
|
1587
|
+
if (archives.length === 0) {
|
|
1588
|
+
console.log("no migration archives");
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
for (const archive of archives) {
|
|
1592
|
+
console.log(`${archive.name}: ${archive.files.length} file(s)`);
|
|
1593
|
+
for (const file of archive.files)
|
|
1594
|
+
console.log(` ${file}`);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
export function migrateArchiveRestore(opts) {
|
|
1598
|
+
const result = restoreMigrationArchive(opts.migrationsDir, opts.name, { force: opts.force });
|
|
1599
|
+
console.log(`restored ${result.restored.length} file(s) from ${result.archiveName}`);
|
|
1600
|
+
for (const file of result.restored)
|
|
1601
|
+
console.log(` ${file}`);
|
|
1602
|
+
}
|
|
264
1603
|
export function migrateAdd(opts) {
|
|
265
1604
|
if (!existsSync(opts.migrationsDir))
|
|
266
1605
|
mkdirSync(opts.migrationsDir, { recursive: true });
|
|
267
1606
|
const existing = readMigrations(opts.migrationsDir);
|
|
268
1607
|
const nextVersion = (existing[existing.length - 1]?.version ?? 0) + 1;
|
|
269
|
-
const safe = opts.name
|
|
270
|
-
if (!safe)
|
|
271
|
-
throw new Error(`sqlx-js.migrate: invalid migration name "${opts.name}"`);
|
|
1608
|
+
const safe = safeMigrationName(opts.name);
|
|
272
1609
|
const padded = String(nextVersion).padStart(4, "0");
|
|
273
1610
|
const upFname = `${padded}_${safe}.up.sql`;
|
|
274
1611
|
const downFname = `${padded}_${safe}.down.sql`;
|