@onreza/sqlx-js 0.5.0 → 0.7.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 +99 -30
- package/ROADMAP.md +2 -2
- package/dist/bin/sqlx-js-diagnostics.d.ts +2 -0
- package/dist/bin/sqlx-js-diagnostics.js +96 -0
- package/dist/bin/sqlx-js.js +291 -92
- package/dist/src/cache.d.ts +1 -1
- package/dist/src/cache.js +1 -1
- package/dist/src/codegen.js +27 -14
- package/dist/src/commands/init.js +97 -3
- package/dist/src/commands/migrate.d.ts +2 -101
- package/dist/src/commands/migrate.js +6 -546
- package/dist/src/commands/prepare.d.ts +10 -2
- package/dist/src/commands/prepare.js +201 -33
- package/dist/src/commands/schema.js +1 -1
- package/dist/src/commands/watch.d.ts +14 -6
- package/dist/src/commands/watch.js +184 -21
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +8 -0
- package/dist/src/function-cache.d.ts +2 -0
- package/dist/src/function-cache.js +6 -3
- package/dist/src/index.d.ts +17 -1
- package/dist/src/index.js +3 -0
- package/dist/src/migration-core.d.ts +157 -0
- package/dist/src/migration-core.js +578 -0
- package/dist/src/pg/schema.d.ts +1 -0
- package/dist/src/pg/schema.js +42 -31
- package/dist/src/pg/wire.d.ts +1 -0
- package/dist/src/pg/wire.js +6 -0
- package/dist/src/postgres-runtime.d.ts +11 -1
- package/dist/src/postgres-runtime.js +22 -5
- package/dist/src/runtime.d.ts +5 -2
- package/dist/src/runtime.js +34 -13
- package/dist/src/scan/scanner.d.ts +1 -1
- package/dist/src/scan/scanner.js +84 -17
- package/dist/src/type-inspection.d.ts +1 -0
- package/dist/src/type-inspection.js +20 -0
- package/package.json +6 -2
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { decodeText } from "./pg/wire.js";
|
|
5
|
+
export const SQUASH_PREFIX = "-- sqlx-js-squash:";
|
|
6
|
+
export const DEFAULT_MIGRATE_LOCK_KEY = 18750938867203960n;
|
|
7
|
+
const FILE_RE = /^(\d+)_(.+)\.up\.sql$/;
|
|
8
|
+
const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
|
9
|
+
const MIGRATIONS_TABLE = "_sqlx_js_migrations";
|
|
10
|
+
export function readMigrations(dir) {
|
|
11
|
+
if (!existsSync(dir))
|
|
12
|
+
return [];
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const file of readdirSync(dir).sort()) {
|
|
15
|
+
const match = FILE_RE.exec(file);
|
|
16
|
+
if (!match)
|
|
17
|
+
continue;
|
|
18
|
+
const version = parseInt(match[1], 10);
|
|
19
|
+
const name = match[2];
|
|
20
|
+
if (!SAFE_NAME_RE.test(name)) {
|
|
21
|
+
throw new Error(`sqlx-js.migrate: unsafe migration filename ${file} — name must match /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/`);
|
|
22
|
+
}
|
|
23
|
+
const upPath = join(dir, file);
|
|
24
|
+
const downPath = join(dir, `${match[1]}_${name}.down.sql`);
|
|
25
|
+
const upSql = readFileSync(upPath, "utf8");
|
|
26
|
+
out.push({
|
|
27
|
+
version,
|
|
28
|
+
name,
|
|
29
|
+
upPath,
|
|
30
|
+
downPath: existsSync(downPath) ? downPath : null,
|
|
31
|
+
upSql,
|
|
32
|
+
upHash: createHash("sha256").update(upSql).digest("hex"),
|
|
33
|
+
squash: parseSquashMetadata(upSql),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
export function parseSquashMetadata(sql) {
|
|
39
|
+
const line = sql.split(/\r?\n/).find((value) => value.startsWith(SQUASH_PREFIX));
|
|
40
|
+
if (!line)
|
|
41
|
+
return null;
|
|
42
|
+
const raw = line.slice(SQUASH_PREFIX.length).trim();
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(raw);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
throw new Error(`sqlx-js.migrate: invalid squash metadata JSON: ${error.message}`);
|
|
49
|
+
}
|
|
50
|
+
if (!parsed || typeof parsed !== "object")
|
|
51
|
+
throw new Error("sqlx-js.migrate: invalid squash metadata");
|
|
52
|
+
const value = parsed;
|
|
53
|
+
if (value.format !== 1 || !Array.isArray(value.replaces) || value.replaces.length === 0) {
|
|
54
|
+
throw new Error("sqlx-js.migrate: invalid squash metadata");
|
|
55
|
+
}
|
|
56
|
+
const replaces = [];
|
|
57
|
+
for (const replacement of value.replaces) {
|
|
58
|
+
if (!replacement || typeof replacement !== "object") {
|
|
59
|
+
throw new Error("sqlx-js.migrate: invalid squash replacement metadata");
|
|
60
|
+
}
|
|
61
|
+
const item = replacement;
|
|
62
|
+
if (typeof item.version !== "number" || !Number.isSafeInteger(item.version) || item.version <= 0) {
|
|
63
|
+
throw new Error("sqlx-js.migrate: invalid squash replacement version");
|
|
64
|
+
}
|
|
65
|
+
if (typeof item.name !== "string" || !SAFE_NAME_RE.test(item.name)) {
|
|
66
|
+
throw new Error("sqlx-js.migrate: invalid squash replacement name");
|
|
67
|
+
}
|
|
68
|
+
if (typeof item.upHash !== "string" || !/^[a-f0-9]{64}$/.test(item.upHash)) {
|
|
69
|
+
throw new Error("sqlx-js.migrate: invalid squash replacement hash");
|
|
70
|
+
}
|
|
71
|
+
replaces.push({ version: item.version, name: item.name, upHash: item.upHash });
|
|
72
|
+
}
|
|
73
|
+
return { format: 1, replaces };
|
|
74
|
+
}
|
|
75
|
+
function quoteIdent(ident) {
|
|
76
|
+
return `"${ident.replace(/"/g, '""')}"`;
|
|
77
|
+
}
|
|
78
|
+
async function findMigrationStore(client) {
|
|
79
|
+
const result = await client.simpleQuery(`
|
|
80
|
+
SELECT n.nspname, cls.relname
|
|
81
|
+
FROM pg_class cls
|
|
82
|
+
JOIN pg_namespace n ON n.oid = cls.relnamespace
|
|
83
|
+
WHERE cls.oid = to_regclass('${MIGRATIONS_TABLE}')
|
|
84
|
+
`);
|
|
85
|
+
const row = result.rows[0];
|
|
86
|
+
if (!row)
|
|
87
|
+
return null;
|
|
88
|
+
const schema = decodeText(row[0]);
|
|
89
|
+
const table = decodeText(row[1]);
|
|
90
|
+
if (!schema || !table)
|
|
91
|
+
throw new Error(`sqlx-js.migrate: failed to resolve ${MIGRATIONS_TABLE} identifier`);
|
|
92
|
+
return { table: `${quoteIdent(schema)}.${quoteIdent(table)}` };
|
|
93
|
+
}
|
|
94
|
+
async function resolveMigrationStore(client) {
|
|
95
|
+
const store = await findMigrationStore(client);
|
|
96
|
+
if (!store)
|
|
97
|
+
throw new Error(`sqlx-js.migrate: failed to resolve ${MIGRATIONS_TABLE} in current search_path`);
|
|
98
|
+
return store;
|
|
99
|
+
}
|
|
100
|
+
export async function ensureTable(client) {
|
|
101
|
+
const existing = await findMigrationStore(client);
|
|
102
|
+
if (existing)
|
|
103
|
+
return existing;
|
|
104
|
+
await client.simpleQuery(`
|
|
105
|
+
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
|
|
106
|
+
version BIGINT PRIMARY KEY,
|
|
107
|
+
name TEXT NOT NULL,
|
|
108
|
+
up_hash TEXT NOT NULL,
|
|
109
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
110
|
+
)
|
|
111
|
+
`);
|
|
112
|
+
return resolveMigrationStore(client);
|
|
113
|
+
}
|
|
114
|
+
export async function listApplied(client, store) {
|
|
115
|
+
const resolved = store ?? await resolveMigrationStore(client);
|
|
116
|
+
const result = await client.simpleQuery(`SELECT version, name, up_hash FROM ${resolved.table} ORDER BY version`);
|
|
117
|
+
const out = new Map();
|
|
118
|
+
for (const row of result.rows) {
|
|
119
|
+
out.set(Number(decodeText(row[0])), { name: decodeText(row[1]), hash: decodeText(row[2]) });
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
function appliedSquashSupersededVersions(all, applied, onEvent) {
|
|
124
|
+
const superseded = new Set();
|
|
125
|
+
const byVersion = new Map(all.map((migration) => [migration.version, migration]));
|
|
126
|
+
const visitedSquashes = new Set();
|
|
127
|
+
const visitReplacements = (migration) => {
|
|
128
|
+
if (!migration.squash || visitedSquashes.has(migration.version))
|
|
129
|
+
return true;
|
|
130
|
+
visitedSquashes.add(migration.version);
|
|
131
|
+
for (const replacement of migration.squash.replaces) {
|
|
132
|
+
superseded.add(replacement.version);
|
|
133
|
+
const current = byVersion.get(replacement.version);
|
|
134
|
+
if (!current?.squash || current.version >= migration.version)
|
|
135
|
+
continue;
|
|
136
|
+
if (current.name !== replacement.name || current.upHash !== replacement.upHash) {
|
|
137
|
+
onEvent?.({ kind: "tampered", version: replacement.version, name: replacement.name, applied: replacement.upHash, current: current.upHash });
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
if (!visitReplacements(current))
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
};
|
|
145
|
+
for (const migration of all) {
|
|
146
|
+
if (!migration.squash)
|
|
147
|
+
continue;
|
|
148
|
+
const current = applied.get(migration.version);
|
|
149
|
+
if (!current)
|
|
150
|
+
continue;
|
|
151
|
+
if (current.hash !== migration.upHash) {
|
|
152
|
+
onEvent?.({ kind: "tampered", version: migration.version, name: migration.name, applied: current.hash, current: migration.upHash });
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
if (!visitReplacements(migration))
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
return superseded;
|
|
159
|
+
}
|
|
160
|
+
function squashCoveredVersions(all) {
|
|
161
|
+
const covered = new Set();
|
|
162
|
+
for (const migration of all) {
|
|
163
|
+
if (!migration.squash)
|
|
164
|
+
continue;
|
|
165
|
+
for (const replacement of migration.squash.replaces) {
|
|
166
|
+
if (replacement.version >= migration.version) {
|
|
167
|
+
throw new Error(`sqlx-js.migrate: squash replacement ${replacement.version}_${replacement.name} must be older than ${migration.version}_${migration.name}`);
|
|
168
|
+
}
|
|
169
|
+
covered.add(replacement.version);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return covered;
|
|
173
|
+
}
|
|
174
|
+
export function effectiveSquashReplacements(all) {
|
|
175
|
+
const covered = squashCoveredVersions(all);
|
|
176
|
+
return all
|
|
177
|
+
.filter((migration) => !covered.has(migration.version))
|
|
178
|
+
.map((migration) => ({ version: migration.version, name: migration.name, upHash: migration.upHash }));
|
|
179
|
+
}
|
|
180
|
+
function preflightSquashMigrations(all, applied, superseded, onEvent) {
|
|
181
|
+
const byVersion = new Map(all.map((migration) => [migration.version, migration]));
|
|
182
|
+
for (const migration of all) {
|
|
183
|
+
if (!migration.squash)
|
|
184
|
+
continue;
|
|
185
|
+
let present = 0;
|
|
186
|
+
for (const replacement of migration.squash.replaces) {
|
|
187
|
+
if (replacement.version >= migration.version) {
|
|
188
|
+
onEvent?.({
|
|
189
|
+
kind: "failed",
|
|
190
|
+
version: migration.version,
|
|
191
|
+
name: migration.name,
|
|
192
|
+
error: `squash replacement ${replacement.version}_${replacement.name} must be older than ${migration.version}_${migration.name}`,
|
|
193
|
+
});
|
|
194
|
+
return "failed";
|
|
195
|
+
}
|
|
196
|
+
const current = byVersion.get(replacement.version);
|
|
197
|
+
if (current &&
|
|
198
|
+
!superseded.has(replacement.version) &&
|
|
199
|
+
(current.name !== replacement.name || current.upHash !== replacement.upHash)) {
|
|
200
|
+
onEvent?.({
|
|
201
|
+
kind: "tampered",
|
|
202
|
+
version: replacement.version,
|
|
203
|
+
name: replacement.name,
|
|
204
|
+
applied: replacement.upHash,
|
|
205
|
+
current: current.upHash,
|
|
206
|
+
});
|
|
207
|
+
return "tampered";
|
|
208
|
+
}
|
|
209
|
+
const currentApplied = applied.get(replacement.version);
|
|
210
|
+
if (!currentApplied)
|
|
211
|
+
continue;
|
|
212
|
+
present++;
|
|
213
|
+
if (currentApplied.hash !== replacement.upHash || currentApplied.name !== replacement.name) {
|
|
214
|
+
onEvent?.({
|
|
215
|
+
kind: "tampered",
|
|
216
|
+
version: replacement.version,
|
|
217
|
+
name: replacement.name,
|
|
218
|
+
applied: currentApplied.hash,
|
|
219
|
+
current: replacement.upHash,
|
|
220
|
+
});
|
|
221
|
+
return "tampered";
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (present > 0 && present !== migration.squash.replaces.length) {
|
|
225
|
+
onEvent?.({
|
|
226
|
+
kind: "failed",
|
|
227
|
+
version: migration.version,
|
|
228
|
+
name: migration.name,
|
|
229
|
+
error: `squash migration replaces ${migration.squash.replaces.length} migration(s), but only ${present} matching row(s) are applied`,
|
|
230
|
+
});
|
|
231
|
+
return "failed";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return "ok";
|
|
235
|
+
}
|
|
236
|
+
function planSquashAdoption(migration, applied, onEvent) {
|
|
237
|
+
if (!migration.squash)
|
|
238
|
+
return { kind: "none" };
|
|
239
|
+
let present = 0;
|
|
240
|
+
for (const replacement of migration.squash.replaces) {
|
|
241
|
+
if (replacement.version >= migration.version) {
|
|
242
|
+
onEvent?.({
|
|
243
|
+
kind: "failed",
|
|
244
|
+
version: migration.version,
|
|
245
|
+
name: migration.name,
|
|
246
|
+
error: `squash replacement ${replacement.version}_${replacement.name} must be older than ${migration.version}_${migration.name}`,
|
|
247
|
+
});
|
|
248
|
+
return { kind: "failed" };
|
|
249
|
+
}
|
|
250
|
+
const current = applied.get(replacement.version);
|
|
251
|
+
if (!current)
|
|
252
|
+
continue;
|
|
253
|
+
present++;
|
|
254
|
+
if (current.hash !== replacement.upHash || current.name !== replacement.name) {
|
|
255
|
+
onEvent?.({
|
|
256
|
+
kind: "tampered",
|
|
257
|
+
version: replacement.version,
|
|
258
|
+
name: replacement.name,
|
|
259
|
+
applied: current.hash,
|
|
260
|
+
current: replacement.upHash,
|
|
261
|
+
});
|
|
262
|
+
return { kind: "tampered" };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (present === 0)
|
|
266
|
+
return { kind: "none" };
|
|
267
|
+
if (present !== migration.squash.replaces.length) {
|
|
268
|
+
onEvent?.({
|
|
269
|
+
kind: "failed",
|
|
270
|
+
version: migration.version,
|
|
271
|
+
name: migration.name,
|
|
272
|
+
error: `squash migration replaces ${migration.squash.replaces.length} migration(s), but only ${present} matching row(s) are applied`,
|
|
273
|
+
});
|
|
274
|
+
return { kind: "failed" };
|
|
275
|
+
}
|
|
276
|
+
return { kind: "adopt", replaced: migration.squash.replaces.length };
|
|
277
|
+
}
|
|
278
|
+
export function buildMigrationPlan(all, applied, onEvent) {
|
|
279
|
+
const plannedApplied = new Map(applied);
|
|
280
|
+
const superseded = appliedSquashSupersededVersions(all, plannedApplied, onEvent);
|
|
281
|
+
if (!superseded)
|
|
282
|
+
return { kind: "tampered" };
|
|
283
|
+
const preflight = preflightSquashMigrations(all, plannedApplied, superseded, onEvent);
|
|
284
|
+
if (preflight !== "ok")
|
|
285
|
+
return { kind: preflight };
|
|
286
|
+
const steps = [];
|
|
287
|
+
for (const migration of all) {
|
|
288
|
+
if (superseded.has(migration.version))
|
|
289
|
+
continue;
|
|
290
|
+
const current = plannedApplied.get(migration.version);
|
|
291
|
+
if (current) {
|
|
292
|
+
if (current.hash !== migration.upHash) {
|
|
293
|
+
onEvent?.({
|
|
294
|
+
kind: "tampered",
|
|
295
|
+
version: migration.version,
|
|
296
|
+
name: migration.name,
|
|
297
|
+
applied: current.hash,
|
|
298
|
+
current: migration.upHash,
|
|
299
|
+
});
|
|
300
|
+
return { kind: "tampered" };
|
|
301
|
+
}
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const adoption = planSquashAdoption(migration, plannedApplied, onEvent);
|
|
305
|
+
if (adoption.kind === "adopt") {
|
|
306
|
+
steps.push({ kind: "adopt", migration, replaced: adoption.replaced });
|
|
307
|
+
for (const replacement of migration.squash.replaces)
|
|
308
|
+
plannedApplied.delete(replacement.version);
|
|
309
|
+
plannedApplied.set(migration.version, { name: migration.name, hash: migration.upHash });
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (adoption.kind === "tampered" || adoption.kind === "failed")
|
|
313
|
+
return { kind: adoption.kind };
|
|
314
|
+
steps.push({ kind: "apply", migration });
|
|
315
|
+
plannedApplied.set(migration.version, { name: migration.name, hash: migration.upHash });
|
|
316
|
+
}
|
|
317
|
+
return { kind: "ok", steps };
|
|
318
|
+
}
|
|
319
|
+
function publicPlanItem(step) {
|
|
320
|
+
if (step.kind === "apply") {
|
|
321
|
+
return { kind: "apply", version: step.migration.version, name: step.migration.name };
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
kind: "adopt",
|
|
325
|
+
version: step.migration.version,
|
|
326
|
+
name: step.migration.name,
|
|
327
|
+
replaced: step.replaced,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
export async function resetMigrationSession(client) {
|
|
331
|
+
await client.simpleQuery("RESET ALL");
|
|
332
|
+
}
|
|
333
|
+
async function executeSquashAdoption(client, store, migration, applied, replaced, onEvent) {
|
|
334
|
+
if (!migration.squash)
|
|
335
|
+
return "failed";
|
|
336
|
+
let committed = false;
|
|
337
|
+
await client.simpleQuery("BEGIN");
|
|
338
|
+
try {
|
|
339
|
+
for (const replacement of migration.squash.replaces) {
|
|
340
|
+
await client.execParamsText(`DELETE FROM ${store.table} WHERE version = $1`, [String(replacement.version)]);
|
|
341
|
+
}
|
|
342
|
+
await client.execParamsText(`INSERT INTO ${store.table} (version, name, up_hash) VALUES ($1, $2, $3)`, [String(migration.version), migration.name, migration.upHash]);
|
|
343
|
+
await client.simpleQuery("COMMIT");
|
|
344
|
+
committed = true;
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
let rollbackError;
|
|
348
|
+
if (!committed) {
|
|
349
|
+
try {
|
|
350
|
+
await client.simpleQuery("ROLLBACK");
|
|
351
|
+
}
|
|
352
|
+
catch (rollback) {
|
|
353
|
+
rollbackError = rollback.message;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const message = rollbackError
|
|
357
|
+
? `${error.message} (rollback also failed: ${rollbackError})`
|
|
358
|
+
: error.message;
|
|
359
|
+
onEvent?.({ kind: "failed", version: migration.version, name: migration.name, error: message });
|
|
360
|
+
return "failed";
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
await resetMigrationSession(client);
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
onEvent?.({
|
|
367
|
+
kind: "failed",
|
|
368
|
+
version: migration.version,
|
|
369
|
+
name: migration.name,
|
|
370
|
+
error: `failed to reset migration session: ${error.message}`,
|
|
371
|
+
});
|
|
372
|
+
return "failed";
|
|
373
|
+
}
|
|
374
|
+
for (const replacement of migration.squash.replaces)
|
|
375
|
+
applied.delete(replacement.version);
|
|
376
|
+
applied.set(migration.version, { name: migration.name, hash: migration.upHash });
|
|
377
|
+
onEvent?.({ kind: "adopted", version: migration.version, name: migration.name, replaced });
|
|
378
|
+
return "done";
|
|
379
|
+
}
|
|
380
|
+
export async function planPending(client, migrationsDir, onEvent) {
|
|
381
|
+
const store = await findMigrationStore(client);
|
|
382
|
+
const applied = store ? await listApplied(client, store) : new Map();
|
|
383
|
+
const all = readMigrations(migrationsDir);
|
|
384
|
+
const counts = { pending: 0, adoptable: 0, tampered: 0, failed: 0 };
|
|
385
|
+
const plan = buildMigrationPlan(all, applied, onEvent);
|
|
386
|
+
if (plan.kind === "tampered")
|
|
387
|
+
return { ...counts, tampered: 1, steps: [] };
|
|
388
|
+
if (plan.kind === "failed")
|
|
389
|
+
return { ...counts, failed: 1, steps: [] };
|
|
390
|
+
const steps = plan.steps.map(publicPlanItem);
|
|
391
|
+
for (const step of steps) {
|
|
392
|
+
if (step.kind === "apply") {
|
|
393
|
+
counts.pending++;
|
|
394
|
+
onEvent?.({ kind: "pending", version: step.version, name: step.name });
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
counts.adoptable++;
|
|
398
|
+
onEvent?.({ kind: "adoptable", version: step.version, name: step.name, replaced: step.replaced });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return { ...counts, steps };
|
|
402
|
+
}
|
|
403
|
+
export async function inspectMigrationPlan(client, migrationsDir) {
|
|
404
|
+
const diagnostics = [];
|
|
405
|
+
const result = await planPending(client, migrationsDir, (event) => {
|
|
406
|
+
if (event.kind === "tampered" || event.kind === "failed")
|
|
407
|
+
diagnostics.push(event);
|
|
408
|
+
});
|
|
409
|
+
return { ok: result.tampered === 0 && result.failed === 0, ...result, diagnostics };
|
|
410
|
+
}
|
|
411
|
+
export async function inspectMigrations(client, migrationsDir) {
|
|
412
|
+
const store = await findMigrationStore(client);
|
|
413
|
+
const applied = store ? await listApplied(client, store) : new Map();
|
|
414
|
+
const all = readMigrations(migrationsDir);
|
|
415
|
+
const validation = [];
|
|
416
|
+
const plan = buildMigrationPlan(all, applied, (event) => validation.push(event));
|
|
417
|
+
const planned = new Map();
|
|
418
|
+
if (plan.kind === "ok") {
|
|
419
|
+
for (const step of plan.steps)
|
|
420
|
+
planned.set(step.migration.version, step);
|
|
421
|
+
}
|
|
422
|
+
const validationByVersion = new Map(validation.map((event) => [event.version, event]));
|
|
423
|
+
const superseded = appliedSquashSupersededVersions(all, applied) ?? new Set();
|
|
424
|
+
const summary = {
|
|
425
|
+
applied: 0,
|
|
426
|
+
pending: 0,
|
|
427
|
+
adoptable: 0,
|
|
428
|
+
superseded: 0,
|
|
429
|
+
tampered: 0,
|
|
430
|
+
failed: 0,
|
|
431
|
+
};
|
|
432
|
+
const items = all.map((migration) => {
|
|
433
|
+
const validationEvent = validationByVersion.get(migration.version);
|
|
434
|
+
if (validationEvent?.kind === "tampered") {
|
|
435
|
+
summary.tampered++;
|
|
436
|
+
return {
|
|
437
|
+
version: migration.version,
|
|
438
|
+
name: migration.name,
|
|
439
|
+
status: "tampered",
|
|
440
|
+
detail: `hash mismatch: applied ${validationEvent.applied.slice(0, 16)} vs current ${validationEvent.current.slice(0, 16)}`,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (validationEvent?.kind === "failed") {
|
|
444
|
+
summary.failed++;
|
|
445
|
+
return { version: migration.version, name: migration.name, status: "failed", detail: validationEvent.error };
|
|
446
|
+
}
|
|
447
|
+
if (superseded.has(migration.version)) {
|
|
448
|
+
summary.superseded++;
|
|
449
|
+
return { version: migration.version, name: migration.name, status: "superseded" };
|
|
450
|
+
}
|
|
451
|
+
const current = applied.get(migration.version);
|
|
452
|
+
if (current) {
|
|
453
|
+
if (current.hash !== migration.upHash) {
|
|
454
|
+
summary.tampered++;
|
|
455
|
+
return {
|
|
456
|
+
version: migration.version,
|
|
457
|
+
name: migration.name,
|
|
458
|
+
status: "tampered",
|
|
459
|
+
detail: `hash mismatch: applied ${current.hash.slice(0, 16)} vs current ${migration.upHash.slice(0, 16)}`,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
summary.applied++;
|
|
463
|
+
return { version: migration.version, name: migration.name, status: "applied" };
|
|
464
|
+
}
|
|
465
|
+
const plannedStep = planned.get(migration.version);
|
|
466
|
+
if (plannedStep?.kind === "adopt") {
|
|
467
|
+
summary.adoptable++;
|
|
468
|
+
return {
|
|
469
|
+
version: migration.version,
|
|
470
|
+
name: migration.name,
|
|
471
|
+
status: "adoptable",
|
|
472
|
+
detail: `${plannedStep.replaced} replaced`,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
summary.pending++;
|
|
476
|
+
return { version: migration.version, name: migration.name, status: "pending" };
|
|
477
|
+
});
|
|
478
|
+
return { historyTable: store?.table ?? null, summary, items };
|
|
479
|
+
}
|
|
480
|
+
export async function applyPending(client, migrationsDir, onEvent) {
|
|
481
|
+
const store = await ensureTable(client);
|
|
482
|
+
const applied = await listApplied(client, store);
|
|
483
|
+
const all = readMigrations(migrationsDir);
|
|
484
|
+
const counts = { applied: 0, tampered: 0, failed: 0 };
|
|
485
|
+
const plan = buildMigrationPlan(all, applied, onEvent);
|
|
486
|
+
if (plan.kind === "tampered")
|
|
487
|
+
return { ...counts, tampered: 1 };
|
|
488
|
+
if (plan.kind === "failed")
|
|
489
|
+
return { ...counts, failed: 1 };
|
|
490
|
+
for (const step of plan.steps) {
|
|
491
|
+
const migration = step.migration;
|
|
492
|
+
if (step.kind === "adopt") {
|
|
493
|
+
const adoption = await executeSquashAdoption(client, store, migration, applied, step.replaced, onEvent);
|
|
494
|
+
if (adoption === "failed")
|
|
495
|
+
return { ...counts, failed: counts.failed + 1 };
|
|
496
|
+
counts.applied++;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
let committed = false;
|
|
500
|
+
await client.simpleQuery("BEGIN");
|
|
501
|
+
try {
|
|
502
|
+
await client.simpleQuery(migration.upSql);
|
|
503
|
+
await client.execParamsText(`INSERT INTO ${store.table} (version, name, up_hash) VALUES ($1, $2, $3)`, [String(migration.version), migration.name, migration.upHash]);
|
|
504
|
+
await client.simpleQuery("COMMIT");
|
|
505
|
+
committed = true;
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
let rollbackError;
|
|
509
|
+
if (!committed) {
|
|
510
|
+
try {
|
|
511
|
+
await client.simpleQuery("ROLLBACK");
|
|
512
|
+
}
|
|
513
|
+
catch (rollback) {
|
|
514
|
+
rollbackError = rollback.message;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
counts.failed++;
|
|
518
|
+
const message = rollbackError
|
|
519
|
+
? `${error.message} (rollback also failed: ${rollbackError})`
|
|
520
|
+
: error.message;
|
|
521
|
+
onEvent?.({ kind: "failed", version: migration.version, name: migration.name, error: message });
|
|
522
|
+
return counts;
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
await resetMigrationSession(client);
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
counts.failed++;
|
|
529
|
+
onEvent?.({
|
|
530
|
+
kind: "failed",
|
|
531
|
+
version: migration.version,
|
|
532
|
+
name: migration.name,
|
|
533
|
+
error: `failed to reset migration session: ${error.message}`,
|
|
534
|
+
});
|
|
535
|
+
return counts;
|
|
536
|
+
}
|
|
537
|
+
counts.applied++;
|
|
538
|
+
applied.set(migration.version, { name: migration.name, hash: migration.upHash });
|
|
539
|
+
onEvent?.({ kind: "applied", version: migration.version, name: migration.name });
|
|
540
|
+
}
|
|
541
|
+
return counts;
|
|
542
|
+
}
|
|
543
|
+
function lockKeyToString(lockKey) {
|
|
544
|
+
if (typeof lockKey === "bigint")
|
|
545
|
+
return lockKey.toString();
|
|
546
|
+
if (!Number.isSafeInteger(lockKey)) {
|
|
547
|
+
throw new Error(`sqlx-js.migrate: lockKey must be a safe integer or bigint, got ${lockKey}`);
|
|
548
|
+
}
|
|
549
|
+
return BigInt(lockKey).toString();
|
|
550
|
+
}
|
|
551
|
+
export async function acquireMigrateLock(client, lockKey = DEFAULT_MIGRATE_LOCK_KEY, timeoutMs) {
|
|
552
|
+
if (timeoutMs !== undefined && !Number.isFinite(timeoutMs)) {
|
|
553
|
+
throw new Error(`sqlx-js.migrate: lockTimeoutMs must be a finite number, got ${timeoutMs}`);
|
|
554
|
+
}
|
|
555
|
+
const key = lockKeyToString(lockKey);
|
|
556
|
+
if (timeoutMs === undefined || timeoutMs <= 0) {
|
|
557
|
+
await client.simpleQuery(`SELECT pg_advisory_lock(${key})`);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const start = Date.now();
|
|
561
|
+
let delay = 50;
|
|
562
|
+
while (true) {
|
|
563
|
+
const result = await client.simpleQuery(`SELECT pg_try_advisory_lock(${key})`);
|
|
564
|
+
if (decodeText(result.rows[0]?.[0] ?? null) === "t")
|
|
565
|
+
return;
|
|
566
|
+
const elapsed = Date.now() - start;
|
|
567
|
+
if (elapsed >= timeoutMs) {
|
|
568
|
+
throw new Error(`sqlx-js.migrate: failed to acquire advisory lock ${key} within ${timeoutMs}ms`);
|
|
569
|
+
}
|
|
570
|
+
const remaining = timeoutMs - elapsed;
|
|
571
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(delay, remaining)));
|
|
572
|
+
delay = Math.min(delay * 2, 2000);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
export async function releaseMigrateLock(client, lockKey = DEFAULT_MIGRATE_LOCK_KEY) {
|
|
576
|
+
const key = lockKeyToString(lockKey);
|
|
577
|
+
await client.simpleQuery(`SELECT pg_advisory_unlock(${key})`);
|
|
578
|
+
}
|
package/dist/src/pg/schema.d.ts
CHANGED
|
@@ -68,6 +68,7 @@ export declare class SchemaCache {
|
|
|
68
68
|
name: string;
|
|
69
69
|
} | undefined;
|
|
70
70
|
loadTableNamesByOid(oids: number[]): Promise<void>;
|
|
71
|
+
private recordTable;
|
|
71
72
|
columnNameByAttno(tableOid: number, attno: number): string | undefined;
|
|
72
73
|
loadColumnsForTables(tableOids: number[]): Promise<void>;
|
|
73
74
|
columnsOf(tableOid: number): Map<string, ColumnInfo> | undefined;
|
package/dist/src/pg/schema.js
CHANGED
|
@@ -49,34 +49,38 @@ export class SchemaCache {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
async loadTableNames(names) {
|
|
52
|
-
const
|
|
52
|
+
const explicit = [];
|
|
53
|
+
const visible = [];
|
|
53
54
|
for (const n of names) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
if (n.schema) {
|
|
56
|
+
if (!this.nameToOid.has(`${n.schema}.${n.name}`))
|
|
57
|
+
explicit.push({ schema: n.schema, name: n.name });
|
|
58
|
+
}
|
|
59
|
+
else if (!this.nameToOid.has(unqualifiedKey(n.name))) {
|
|
60
|
+
visible.push(n.name);
|
|
61
|
+
}
|
|
58
62
|
}
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
63
|
+
if (explicit.length > 0) {
|
|
64
|
+
const where = explicit.map((n) => `(n.nspname = ${quote(n.schema)} AND c.relname = ${quote(n.name)})`).join(" OR ");
|
|
65
|
+
const sql = `SELECT c.oid::int8, n.nspname, c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE ${where}`;
|
|
66
|
+
const result = await this.client.simpleQueryAll(sql);
|
|
67
|
+
for (const row of result.rows)
|
|
68
|
+
this.recordTable(row);
|
|
69
|
+
}
|
|
70
|
+
if (visible.length > 0) {
|
|
71
|
+
const requested = [...new Set(visible)].map(quote).join(",");
|
|
72
|
+
const sql = `SELECT c.oid::int8, n.nspname, c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relname IN (${requested}) AND pg_table_is_visible(c.oid)`;
|
|
73
|
+
const result = await this.client.simpleQueryAll(sql);
|
|
74
|
+
for (const row of result.rows) {
|
|
75
|
+
this.recordTable(row, true);
|
|
76
|
+
}
|
|
73
77
|
}
|
|
74
78
|
}
|
|
75
79
|
isNotNull(tableOid, attno) {
|
|
76
80
|
return this.byOidNum.get(key(tableOid, attno))?.notNull;
|
|
77
81
|
}
|
|
78
82
|
resolveTable(schema, name) {
|
|
79
|
-
const k = `${schema
|
|
83
|
+
const k = schema ? `${schema}.${name}` : unqualifiedKey(name);
|
|
80
84
|
const arr = this.nameToOid.get(k);
|
|
81
85
|
return arr?.[0];
|
|
82
86
|
}
|
|
@@ -89,17 +93,21 @@ export class SchemaCache {
|
|
|
89
93
|
return;
|
|
90
94
|
const sql = `SELECT c.oid::int8, n.nspname, c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid IN (${need.join(",")})`;
|
|
91
95
|
const r = await this.client.simpleQueryAll(sql);
|
|
92
|
-
for (const row of r.rows)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
for (const row of r.rows)
|
|
97
|
+
this.recordTable(row);
|
|
98
|
+
}
|
|
99
|
+
recordTable(row, visible = false) {
|
|
100
|
+
const oid = Number(decodeText(row[0]));
|
|
101
|
+
const schema = decodeText(row[1]);
|
|
102
|
+
const name = decodeText(row[2]);
|
|
103
|
+
const key = `${schema}.${name}`;
|
|
104
|
+
const arr = this.nameToOid.get(key) ?? [];
|
|
105
|
+
if (!arr.includes(oid))
|
|
106
|
+
arr.push(oid);
|
|
107
|
+
this.nameToOid.set(key, arr);
|
|
108
|
+
if (visible)
|
|
109
|
+
this.nameToOid.set(unqualifiedKey(name), [oid]);
|
|
110
|
+
this.oidToName.set(oid, { schema, name });
|
|
103
111
|
}
|
|
104
112
|
columnNameByAttno(tableOid, attno) {
|
|
105
113
|
return this.byOidNum.get(key(tableOid, attno))?.name;
|
|
@@ -269,6 +277,9 @@ export class SchemaCache {
|
|
|
269
277
|
function key(oid, attno) {
|
|
270
278
|
return `${oid}/${attno}`;
|
|
271
279
|
}
|
|
280
|
+
function unqualifiedKey(name) {
|
|
281
|
+
return `\0${name}`;
|
|
282
|
+
}
|
|
272
283
|
function quote(s) {
|
|
273
284
|
return `'${s.replace(/'/g, "''")}'`;
|
|
274
285
|
}
|