@supabase/pg-delta 1.0.0-alpha.13 → 1.0.0-alpha.14
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/dist/core/connection-url.d.ts +32 -0
- package/dist/core/connection-url.js +77 -0
- package/dist/core/integrations/supabase.js +1 -0
- package/dist/core/objects/procedure/procedure.diff.js +8 -0
- package/dist/core/objects/table/changes/table.alter.js +4 -1
- package/dist/core/objects/table/table.diff.js +7 -2
- package/dist/core/postgres-config.d.ts +27 -0
- package/dist/core/postgres-config.js +99 -7
- package/package.json +2 -1
- package/src/core/connection-url.test.ts +142 -0
- package/src/core/connection-url.ts +82 -0
- package/src/core/integrations/supabase.ts +1 -0
- package/src/core/objects/procedure/procedure.diff.test.ts +25 -0
- package/src/core/objects/procedure/procedure.diff.ts +12 -0
- package/src/core/objects/table/changes/table.alter.test.ts +14 -0
- package/src/core/objects/table/changes/table.alter.ts +4 -1
- package/src/core/objects/table/table.diff.test.ts +55 -0
- package/src/core/objects/table/table.diff.ts +10 -2
- package/src/core/postgres-config.test.ts +241 -0
- package/src/core/postgres-config.ts +127 -16
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection URL normalization for pg-delta.
|
|
3
|
+
*
|
|
4
|
+
* Auto-normalizes percent-encoded IPv6 hosts in PostgreSQL connection URLs.
|
|
5
|
+
* A URL like `postgresql://user:pass@2406%3Ada18%3A...%3Ab3c9:5432/db`
|
|
6
|
+
* becomes `postgresql://user:pass@[2406:da18:...:b3c9]:5432/db` before it
|
|
7
|
+
* reaches `pg-connection-string` / `pg.Pool`, so DNS resolution sees the
|
|
8
|
+
* address in its canonical bracketed form.
|
|
9
|
+
*
|
|
10
|
+
* Non-IPv6 hosts (IPv4, DNS names, already-bracketed IPv6, partial fragments
|
|
11
|
+
* that just happen to contain `%3A`) are returned verbatim.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Return true if `value` is a valid IPv6 literal in any canonical form:
|
|
15
|
+
* full 8-group, `::` compression, or IPv4-mapped (`::ffff:1.2.3.4`).
|
|
16
|
+
* RFC 4007 zone identifiers (`fe80::1%eth0`) are accepted.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isIPv6(value: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Normalize a PostgreSQL connection URL so IPv6 hosts reach pg in the
|
|
21
|
+
* canonical bracketed form.
|
|
22
|
+
*
|
|
23
|
+
* If the URL's hostname contains a percent-encoded colon AND the decoded
|
|
24
|
+
* hostname is a valid IPv6 literal, the hostname is decoded and wrapped in
|
|
25
|
+
* `[...]`. All other fields (scheme, userinfo, port, path, query, fragment)
|
|
26
|
+
* are preserved byte-for-byte from the input.
|
|
27
|
+
*
|
|
28
|
+
* Any URL whose decoded hostname does not validate as IPv6 is returned
|
|
29
|
+
* verbatim, so a malformed input will surface its usual downstream error
|
|
30
|
+
* instead of being silently rewritten.
|
|
31
|
+
*/
|
|
32
|
+
export declare function normalizeConnectionUrl(url: string): string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection URL normalization for pg-delta.
|
|
3
|
+
*
|
|
4
|
+
* Auto-normalizes percent-encoded IPv6 hosts in PostgreSQL connection URLs.
|
|
5
|
+
* A URL like `postgresql://user:pass@2406%3Ada18%3A...%3Ab3c9:5432/db`
|
|
6
|
+
* becomes `postgresql://user:pass@[2406:da18:...:b3c9]:5432/db` before it
|
|
7
|
+
* reaches `pg-connection-string` / `pg.Pool`, so DNS resolution sees the
|
|
8
|
+
* address in its canonical bracketed form.
|
|
9
|
+
*
|
|
10
|
+
* Non-IPv6 hosts (IPv4, DNS names, already-bracketed IPv6, partial fragments
|
|
11
|
+
* that just happen to contain `%3A`) are returned verbatim.
|
|
12
|
+
*/
|
|
13
|
+
// IPv6 detection regex vendored from ip-regex (Sindre Sorhus, MIT).
|
|
14
|
+
// https://github.com/sindresorhus/ip-regex
|
|
15
|
+
const v4 = "(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}";
|
|
16
|
+
const v6seg = "[a-fA-F\\d]{1,4}";
|
|
17
|
+
const v6 = `
|
|
18
|
+
(?:
|
|
19
|
+
(?:${v6seg}:){7}(?:${v6seg}|:)|
|
|
20
|
+
(?:${v6seg}:){6}(?:${v4}|:${v6seg}|:)|
|
|
21
|
+
(?:${v6seg}:){5}(?::${v4}|(?::${v6seg}){1,2}|:)|
|
|
22
|
+
(?:${v6seg}:){4}(?:(?::${v6seg}){0,1}:${v4}|(?::${v6seg}){1,3}|:)|
|
|
23
|
+
(?:${v6seg}:){3}(?:(?::${v6seg}){0,2}:${v4}|(?::${v6seg}){1,4}|:)|
|
|
24
|
+
(?:${v6seg}:){2}(?:(?::${v6seg}){0,3}:${v4}|(?::${v6seg}){1,5}|:)|
|
|
25
|
+
(?:${v6seg}:){1}(?:(?::${v6seg}){0,4}:${v4}|(?::${v6seg}){1,6}|:)|
|
|
26
|
+
(?::(?:(?::${v6seg}){0,5}:${v4}|(?::${v6seg}){1,7}|:))
|
|
27
|
+
)(?:%[0-9a-zA-Z]{1,})?
|
|
28
|
+
`
|
|
29
|
+
.replace(/\s*\/\/.*$/gm, "")
|
|
30
|
+
.replace(/\n/g, "")
|
|
31
|
+
.trim();
|
|
32
|
+
const V6_EXACT = new RegExp(`^${v6}$`);
|
|
33
|
+
/**
|
|
34
|
+
* Return true if `value` is a valid IPv6 literal in any canonical form:
|
|
35
|
+
* full 8-group, `::` compression, or IPv4-mapped (`::ffff:1.2.3.4`).
|
|
36
|
+
* RFC 4007 zone identifiers (`fe80::1%eth0`) are accepted.
|
|
37
|
+
*/
|
|
38
|
+
export function isIPv6(value) {
|
|
39
|
+
return typeof value === "string" && V6_EXACT.test(value);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Normalize a PostgreSQL connection URL so IPv6 hosts reach pg in the
|
|
43
|
+
* canonical bracketed form.
|
|
44
|
+
*
|
|
45
|
+
* If the URL's hostname contains a percent-encoded colon AND the decoded
|
|
46
|
+
* hostname is a valid IPv6 literal, the hostname is decoded and wrapped in
|
|
47
|
+
* `[...]`. All other fields (scheme, userinfo, port, path, query, fragment)
|
|
48
|
+
* are preserved byte-for-byte from the input.
|
|
49
|
+
*
|
|
50
|
+
* Any URL whose decoded hostname does not validate as IPv6 is returned
|
|
51
|
+
* verbatim, so a malformed input will surface its usual downstream error
|
|
52
|
+
* instead of being silently rewritten.
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeConnectionUrl(url) {
|
|
55
|
+
const urlObj = new URL(url);
|
|
56
|
+
// Cheap pre-filter: only look closer if the hostname contains a
|
|
57
|
+
// percent-encoded colon. Anything else is left entirely untouched.
|
|
58
|
+
if (!/%3[aA]/.test(urlObj.hostname))
|
|
59
|
+
return url;
|
|
60
|
+
const decodedHost = decodeURIComponent(urlObj.hostname);
|
|
61
|
+
// Authoritative validation: only normalize when the decoded string is a
|
|
62
|
+
// real IPv6 literal. Rejects partial fragments, random hostnames that
|
|
63
|
+
// happen to contain `%3A`, and any malformed input.
|
|
64
|
+
if (!isIPv6(decodedHost))
|
|
65
|
+
return url;
|
|
66
|
+
// Preserve username/password/port/path/search/hash exactly as they appear
|
|
67
|
+
// in the WHATWG URL model (these are returned already percent-encoded).
|
|
68
|
+
const scheme = `${urlObj.protocol}//`;
|
|
69
|
+
const auth = urlObj.username
|
|
70
|
+
? urlObj.password
|
|
71
|
+
? `${urlObj.username}:${urlObj.password}@`
|
|
72
|
+
: `${urlObj.username}@`
|
|
73
|
+
: "";
|
|
74
|
+
const port = urlObj.port ? `:${urlObj.port}` : "";
|
|
75
|
+
const tail = `${urlObj.pathname}${urlObj.search}${urlObj.hash}`;
|
|
76
|
+
return `${scheme}${auth}[${decodedHost}]${port}${tail}`;
|
|
77
|
+
}
|
|
@@ -99,6 +99,14 @@ export function diffProcedures(ctx, main, branch) {
|
|
|
99
99
|
if (nonAlterablePropsChanged) {
|
|
100
100
|
// Replace the entire procedure
|
|
101
101
|
changes.push(new CreateProcedure({ procedure: branchProcedure, orReplace: true }));
|
|
102
|
+
if (mainProcedure.comment !== branchProcedure.comment) {
|
|
103
|
+
if (branchProcedure.comment === null) {
|
|
104
|
+
changes.push(new DropCommentOnProcedure({ procedure: mainProcedure }));
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
changes.push(new CreateCommentOnProcedure({ procedure: branchProcedure }));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
102
110
|
}
|
|
103
111
|
else {
|
|
104
112
|
// Only alterable properties changed - check each one
|
|
@@ -462,13 +462,16 @@ export class AlterTableAlterColumnSetDefault extends AlterTableChange {
|
|
|
462
462
|
}
|
|
463
463
|
serialize(_options) {
|
|
464
464
|
const set = this.column.is_generated ? "SET EXPRESSION AS" : "SET DEFAULT";
|
|
465
|
+
const value = this.column.is_generated
|
|
466
|
+
? `(${this.column.default ?? "NULL"})`
|
|
467
|
+
: (this.column.default ?? "NULL");
|
|
465
468
|
return [
|
|
466
469
|
"ALTER TABLE",
|
|
467
470
|
`${this.table.schema}.${this.table.name}`,
|
|
468
471
|
"ALTER COLUMN",
|
|
469
472
|
this.column.name,
|
|
470
473
|
set,
|
|
471
|
-
|
|
474
|
+
value,
|
|
472
475
|
].join(" ");
|
|
473
476
|
}
|
|
474
477
|
}
|
|
@@ -486,9 +486,14 @@ export function diffTables(ctx, main, branch) {
|
|
|
486
486
|
// Set new default value
|
|
487
487
|
const isGeneratedColumn = branchCol.is_generated;
|
|
488
488
|
const isPostgresLowerThan17 = ctx.version < 170000;
|
|
489
|
-
|
|
489
|
+
const generatedStatusChanged = mainCol.is_generated !== branchCol.is_generated;
|
|
490
|
+
if (isGeneratedColumn &&
|
|
491
|
+
(isPostgresLowerThan17 || generatedStatusChanged)) {
|
|
490
492
|
// For generated columns in < PostgreSQL 17, we need to drop and recreate
|
|
491
|
-
// instead of using SET EXPRESSION AS for computed columns
|
|
493
|
+
// instead of using SET EXPRESSION AS for computed columns. We also
|
|
494
|
+
// need to recreate the column when switching between regular and
|
|
495
|
+
// generated states because SET EXPRESSION only applies to existing
|
|
496
|
+
// generated columns.
|
|
492
497
|
// cf: https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=5d06e99a3
|
|
493
498
|
// cf: https://www.postgresql.org/docs/release/17.0/
|
|
494
499
|
// > Allow ALTER TABLE to change a column's generation expression
|
|
@@ -3,6 +3,33 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { ClientBase, PoolClient, PoolConfig } from "pg";
|
|
5
5
|
import { Pool } from "pg";
|
|
6
|
+
/**
|
|
7
|
+
* Return true when `err` represents a transient connect failure that makes
|
|
8
|
+
* sense to retry with backoff (e.g. refused connections, DNS blips, our own
|
|
9
|
+
* eager-connect timeout wrapper). Returns false for permanent failures such
|
|
10
|
+
* as authentication errors, TLS negotiation errors, and `ENOTFOUND`.
|
|
11
|
+
*
|
|
12
|
+
* Unknown errors are treated as retryable on purpose: transient-by-default
|
|
13
|
+
* is safer here because a duplicated retry is strictly cheaper than a spurious
|
|
14
|
+
* hard failure during catalog extraction.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isRetryableConnectError(err: unknown): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Retry an async `connect` operation with bounded exponential backoff.
|
|
19
|
+
* Stops immediately on a non-retryable error. On exhausted attempts, throws
|
|
20
|
+
* the last observed error.
|
|
21
|
+
*
|
|
22
|
+
* Exposed for testing — production call sites always go through
|
|
23
|
+
* {@link createManagedPool}.
|
|
24
|
+
*/
|
|
25
|
+
export declare function connectWithRetry<T>(opts: {
|
|
26
|
+
connect: (attempt: number) => Promise<T>;
|
|
27
|
+
isRetryable?: (err: unknown) => boolean;
|
|
28
|
+
maxAttempts?: number;
|
|
29
|
+
baseBackoffMs?: number;
|
|
30
|
+
maxBackoffMs?: number;
|
|
31
|
+
sleep?: (ms: number) => Promise<void>;
|
|
32
|
+
}): Promise<T>;
|
|
6
33
|
/**
|
|
7
34
|
* Options for creating a Pool with event listeners.
|
|
8
35
|
*/
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* PostgreSQL connection configuration with custom type handlers.
|
|
3
3
|
*/
|
|
4
4
|
import { escapeIdentifier, Pool, types } from "pg";
|
|
5
|
+
import { normalizeConnectionUrl } from "./connection-url.js";
|
|
5
6
|
import { parseSslConfig } from "./plan/ssl-config.js";
|
|
6
7
|
// ============================================================================
|
|
7
8
|
// Array Parser
|
|
@@ -96,6 +97,89 @@ types.setTypeParser(1016, (val) => parseArray(val, parseIntElement)); // int8[]
|
|
|
96
97
|
const DEFAULT_POOL_MAX = Number(process.env.PGDELTA_POOL_MAX) || 5;
|
|
97
98
|
const DEFAULT_CONNECTION_TIMEOUT_MS = Number(process.env.PGDELTA_CONNECTION_TIMEOUT_MS) || 3_000;
|
|
98
99
|
const DEFAULT_CONNECT_TIMEOUT_MS = Number(process.env.PGDELTA_CONNECT_TIMEOUT_MS) || 2_500;
|
|
100
|
+
const DEFAULT_CONNECT_MAX_ATTEMPTS = Number(process.env.PGDELTA_CONNECT_MAX_ATTEMPTS) || 3;
|
|
101
|
+
const DEFAULT_CONNECT_BASE_BACKOFF_MS = Number(process.env.PGDELTA_CONNECT_BASE_BACKOFF_MS) || 250;
|
|
102
|
+
const DEFAULT_CONNECT_MAX_BACKOFF_MS = Number(process.env.PGDELTA_CONNECT_MAX_BACKOFF_MS) || 1_000;
|
|
103
|
+
// PostgreSQL auth-class SQLSTATE codes: not retryable.
|
|
104
|
+
const NON_RETRYABLE_PG_CODES = new Set([
|
|
105
|
+
"28000", // invalid_authorization_specification
|
|
106
|
+
"28P01", // invalid_password
|
|
107
|
+
"28P02", // pgdelta: alias reserved here to future-proof against new auth codes
|
|
108
|
+
]);
|
|
109
|
+
// Non-retryable TLS/SSL markers. The `pg` driver surfaces TLS failures as
|
|
110
|
+
// either plain Node `Error` instances with a code on `ERR_TLS_*` or error
|
|
111
|
+
// messages that include well-known cert/TLS terminology; we match both
|
|
112
|
+
// because node-pg normalises some of these.
|
|
113
|
+
const TLS_MESSAGE_MARKERS = [
|
|
114
|
+
"self-signed certificate",
|
|
115
|
+
"self signed certificate",
|
|
116
|
+
"unable to verify the first certificate",
|
|
117
|
+
"certificate has expired",
|
|
118
|
+
"tls",
|
|
119
|
+
"ssl",
|
|
120
|
+
];
|
|
121
|
+
/**
|
|
122
|
+
* Return true when `err` represents a transient connect failure that makes
|
|
123
|
+
* sense to retry with backoff (e.g. refused connections, DNS blips, our own
|
|
124
|
+
* eager-connect timeout wrapper). Returns false for permanent failures such
|
|
125
|
+
* as authentication errors, TLS negotiation errors, and `ENOTFOUND`.
|
|
126
|
+
*
|
|
127
|
+
* Unknown errors are treated as retryable on purpose: transient-by-default
|
|
128
|
+
* is safer here because a duplicated retry is strictly cheaper than a spurious
|
|
129
|
+
* hard failure during catalog extraction.
|
|
130
|
+
*/
|
|
131
|
+
export function isRetryableConnectError(err) {
|
|
132
|
+
if (!(err instanceof Error))
|
|
133
|
+
return true;
|
|
134
|
+
const code = err.code;
|
|
135
|
+
if (code && NON_RETRYABLE_PG_CODES.has(code))
|
|
136
|
+
return false;
|
|
137
|
+
if (code === "ENOTFOUND")
|
|
138
|
+
return false;
|
|
139
|
+
if (code && typeof code === "string" && code.startsWith("ERR_TLS")) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
const message = err.message?.toLowerCase() ?? "";
|
|
143
|
+
// Our own eager-connect timeout wrapper is retryable (flaky network).
|
|
144
|
+
if (message.includes("timed out after"))
|
|
145
|
+
return true;
|
|
146
|
+
for (const marker of TLS_MESSAGE_MARKERS) {
|
|
147
|
+
if (message.includes(marker))
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Retry an async `connect` operation with bounded exponential backoff.
|
|
154
|
+
* Stops immediately on a non-retryable error. On exhausted attempts, throws
|
|
155
|
+
* the last observed error.
|
|
156
|
+
*
|
|
157
|
+
* Exposed for testing — production call sites always go through
|
|
158
|
+
* {@link createManagedPool}.
|
|
159
|
+
*/
|
|
160
|
+
export async function connectWithRetry(opts) {
|
|
161
|
+
const maxAttempts = opts.maxAttempts ?? DEFAULT_CONNECT_MAX_ATTEMPTS;
|
|
162
|
+
const baseBackoffMs = opts.baseBackoffMs ?? DEFAULT_CONNECT_BASE_BACKOFF_MS;
|
|
163
|
+
const maxBackoffMs = opts.maxBackoffMs ?? DEFAULT_CONNECT_MAX_BACKOFF_MS;
|
|
164
|
+
const isRetryable = opts.isRetryable ?? isRetryableConnectError;
|
|
165
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
166
|
+
let lastError;
|
|
167
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
168
|
+
try {
|
|
169
|
+
return await opts.connect(attempt);
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
lastError = err;
|
|
173
|
+
if (attempt >= maxAttempts || !isRetryable(err)) {
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
const backoff = Math.min(baseBackoffMs * 2 ** (attempt - 1), maxBackoffMs);
|
|
177
|
+
await sleep(backoff);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Unreachable: loop either returns or throws.
|
|
181
|
+
throw lastError;
|
|
182
|
+
}
|
|
99
183
|
/**
|
|
100
184
|
* Create a Pool with custom type handlers and optional event listeners.
|
|
101
185
|
*/
|
|
@@ -180,7 +264,11 @@ export function createPool(connectionString, options) {
|
|
|
180
264
|
* to close (via {@link endPool}).
|
|
181
265
|
*/
|
|
182
266
|
export async function createManagedPool(url, options) {
|
|
183
|
-
|
|
267
|
+
// Normalize percent-encoded IPv6 hosts (e.g. `2406%3A...%3Ab3c9`) into the
|
|
268
|
+
// canonical bracketed form before the URL reaches `parseSslConfig` or pg.
|
|
269
|
+
// Non-IPv6 hosts are returned unchanged.
|
|
270
|
+
const normalizedUrl = normalizeConnectionUrl(url);
|
|
271
|
+
const sslConfig = await parseSslConfig(normalizedUrl, options?.label ?? "target");
|
|
184
272
|
const pool = createPool(sslConfig.cleanedUrl, {
|
|
185
273
|
...(sslConfig.ssl !== undefined ? { ssl: sslConfig.ssl } : {}),
|
|
186
274
|
onError: (err) => {
|
|
@@ -197,15 +285,19 @@ export async function createManagedPool(url, options) {
|
|
|
197
285
|
});
|
|
198
286
|
// Eagerly validate connectivity so SSL/auth failures surface immediately
|
|
199
287
|
// instead of hanging on the first real query. node-pg's connectionTimeoutMillis
|
|
200
|
-
// is not reliably enforced under Bun when SSL negotiation hangs.
|
|
288
|
+
// is not reliably enforced under Bun when SSL negotiation hangs. Transient
|
|
289
|
+
// failures (refused connections, flaky DNS, our own timeout wrapper) are
|
|
290
|
+
// retried with bounded exponential backoff; auth/TLS/ENOTFOUND fail fast.
|
|
201
291
|
const label = options?.label ?? "target";
|
|
202
292
|
const timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS;
|
|
203
293
|
try {
|
|
204
|
-
const client = await
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
294
|
+
const client = await connectWithRetry({
|
|
295
|
+
connect: () => Promise.race([
|
|
296
|
+
pool.connect(),
|
|
297
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Connection to ${label} database timed out after ${timeoutMs}ms. ` +
|
|
298
|
+
`The server may require SSL, use an invalid certificate, or be unreachable.`)), timeoutMs)),
|
|
299
|
+
]),
|
|
300
|
+
});
|
|
209
301
|
client.release();
|
|
210
302
|
}
|
|
211
303
|
catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supabase/pg-delta",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.14",
|
|
4
4
|
"description": "PostgreSQL migrations made easy",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"format-and-lint": "biome check . --error-on-warnings",
|
|
73
73
|
"knip": "knip",
|
|
74
74
|
"pgdelta": "bun src/cli/bin/cli.ts",
|
|
75
|
+
"sync-base-images": "bun scripts/sync-supabase-base-images.ts",
|
|
75
76
|
"test": "bun scripts/run-tests.ts",
|
|
76
77
|
"test:unit": "bun run test src/",
|
|
77
78
|
"test:integration": "bun run test tests/",
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isIPv6, normalizeConnectionUrl } from "./connection-url.ts";
|
|
3
|
+
|
|
4
|
+
describe("isIPv6", () => {
|
|
5
|
+
describe("accepted", () => {
|
|
6
|
+
const accepted = [
|
|
7
|
+
"::",
|
|
8
|
+
"::1",
|
|
9
|
+
"1::",
|
|
10
|
+
"1:2:3:4:5:6:7:8",
|
|
11
|
+
"2406:da18:243:740f:abda:9a5c:a92d:b3c9",
|
|
12
|
+
"::ffff:192.0.2.1",
|
|
13
|
+
"fe80::AbCd",
|
|
14
|
+
"fe80::1%eth0",
|
|
15
|
+
];
|
|
16
|
+
for (const value of accepted) {
|
|
17
|
+
test(`accepts "${value}"`, () => {
|
|
18
|
+
expect(isIPv6(value)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("rejected", () => {
|
|
24
|
+
const rejected = [
|
|
25
|
+
"",
|
|
26
|
+
"2406:da18:243:740f", // only 4 groups
|
|
27
|
+
"1:2:3:4:5:6:7:8:9", // 9 groups
|
|
28
|
+
"1::2::3", // double compression
|
|
29
|
+
"gggg::1", // invalid hex
|
|
30
|
+
"1.2.3.4", // pure IPv4
|
|
31
|
+
"[::1]", // bracketed
|
|
32
|
+
"localhost",
|
|
33
|
+
"example.com",
|
|
34
|
+
":::", // malformed
|
|
35
|
+
];
|
|
36
|
+
for (const value of rejected) {
|
|
37
|
+
test(`rejects ${JSON.stringify(value)}`, () => {
|
|
38
|
+
expect(isIPv6(value)).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("normalizeConnectionUrl", () => {
|
|
45
|
+
describe("normalizes percent-encoded IPv6 hosts", () => {
|
|
46
|
+
test("full 8-group IPv6 becomes bracketed", () => {
|
|
47
|
+
const input =
|
|
48
|
+
"postgresql://user:pass@2406%3Ada18%3A243%3A740f%3Aabda%3A9a5c%3Aa92d%3Ab3c9:5432/db";
|
|
49
|
+
expect(normalizeConnectionUrl(input)).toBe(
|
|
50
|
+
"postgresql://user:pass@[2406:da18:243:740f:abda:9a5c:a92d:b3c9]:5432/db",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("compressed ::1 form", () => {
|
|
55
|
+
const input = "postgresql://user:pass@%3A%3A1:5432/db";
|
|
56
|
+
expect(normalizeConnectionUrl(input)).toBe(
|
|
57
|
+
"postgresql://user:pass@[::1]:5432/db",
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("IPv4-mapped ::ffff:192.0.2.1", () => {
|
|
62
|
+
const input = "postgresql://user:pass@%3A%3Affff%3A192.0.2.1:5432/db";
|
|
63
|
+
expect(normalizeConnectionUrl(input)).toBe(
|
|
64
|
+
"postgresql://user:pass@[::ffff:192.0.2.1]:5432/db",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("mixed-case percent triples (%3a and %3A)", () => {
|
|
69
|
+
const input =
|
|
70
|
+
"postgresql://user:pass@2406%3ada18%3A243%3a740f%3Aabda%3A9a5c%3Aa92d%3Ab3c9:5432/db";
|
|
71
|
+
expect(normalizeConnectionUrl(input)).toBe(
|
|
72
|
+
"postgresql://user:pass@[2406:da18:243:740f:abda:9a5c:a92d:b3c9]:5432/db",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("preserves URL-encoded password and query string", () => {
|
|
77
|
+
const input =
|
|
78
|
+
"postgresql://user:p%40ss%2Fword@%3A%3A1:5432/db?sslmode=require&application_name=pgdelta";
|
|
79
|
+
expect(normalizeConnectionUrl(input)).toBe(
|
|
80
|
+
"postgresql://user:p%40ss%2Fword@[::1]:5432/db?sslmode=require&application_name=pgdelta",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("preserves fragment", () => {
|
|
85
|
+
const input = "postgresql://user:pass@%3A%3A1:5432/db#frag";
|
|
86
|
+
expect(normalizeConnectionUrl(input)).toBe(
|
|
87
|
+
"postgresql://user:pass@[::1]:5432/db#frag",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("works without a port", () => {
|
|
92
|
+
const input = "postgresql://user:pass@%3A%3A1/db";
|
|
93
|
+
expect(normalizeConnectionUrl(input)).toBe(
|
|
94
|
+
"postgresql://user:pass@[::1]/db",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("works without userinfo", () => {
|
|
99
|
+
const input = "postgresql://%3A%3A1:5432/db";
|
|
100
|
+
expect(normalizeConnectionUrl(input)).toBe("postgresql://[::1]:5432/db");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("works with username only (no password)", () => {
|
|
104
|
+
const input = "postgresql://user@%3A%3A1:5432/db";
|
|
105
|
+
expect(normalizeConnectionUrl(input)).toBe(
|
|
106
|
+
"postgresql://user@[::1]:5432/db",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("leaves URL unchanged (guardrail)", () => {
|
|
112
|
+
test("already-bracketed IPv6", () => {
|
|
113
|
+
const input = "postgresql://user:pass@[::1]:5432/db";
|
|
114
|
+
expect(normalizeConnectionUrl(input)).toBe(input);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("IPv4 host", () => {
|
|
118
|
+
const input = "postgresql://user:pass@127.0.0.1:5432/db";
|
|
119
|
+
expect(normalizeConnectionUrl(input)).toBe(input);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("DNS hostname", () => {
|
|
123
|
+
const input = "postgresql://user:pass@db.example.com:5432/db";
|
|
124
|
+
expect(normalizeConnectionUrl(input)).toBe(input);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("percent-encoded colons that do not decode to a valid IPv6 (4 groups only)", () => {
|
|
128
|
+
const input = "postgresql://user:pass@2406%3Ada18%3A243%3A740f:5432/db";
|
|
129
|
+
expect(normalizeConnectionUrl(input)).toBe(input);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("non-colon percent-encoded character in hostname", () => {
|
|
133
|
+
const input = "postgresql://user:pass@host%2Dname:5432/db";
|
|
134
|
+
expect(normalizeConnectionUrl(input)).toBe(input);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("garbage host `%3A%3Azzz` decodes to `::zzz`, not valid IPv6", () => {
|
|
138
|
+
const input = "postgresql://user:pass@%3A%3Azzz:5432/db";
|
|
139
|
+
expect(normalizeConnectionUrl(input)).toBe(input);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection URL normalization for pg-delta.
|
|
3
|
+
*
|
|
4
|
+
* Auto-normalizes percent-encoded IPv6 hosts in PostgreSQL connection URLs.
|
|
5
|
+
* A URL like `postgresql://user:pass@2406%3Ada18%3A...%3Ab3c9:5432/db`
|
|
6
|
+
* becomes `postgresql://user:pass@[2406:da18:...:b3c9]:5432/db` before it
|
|
7
|
+
* reaches `pg-connection-string` / `pg.Pool`, so DNS resolution sees the
|
|
8
|
+
* address in its canonical bracketed form.
|
|
9
|
+
*
|
|
10
|
+
* Non-IPv6 hosts (IPv4, DNS names, already-bracketed IPv6, partial fragments
|
|
11
|
+
* that just happen to contain `%3A`) are returned verbatim.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// IPv6 detection regex vendored from ip-regex (Sindre Sorhus, MIT).
|
|
15
|
+
// https://github.com/sindresorhus/ip-regex
|
|
16
|
+
const v4 =
|
|
17
|
+
"(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}";
|
|
18
|
+
const v6seg = "[a-fA-F\\d]{1,4}";
|
|
19
|
+
const v6 = `
|
|
20
|
+
(?:
|
|
21
|
+
(?:${v6seg}:){7}(?:${v6seg}|:)|
|
|
22
|
+
(?:${v6seg}:){6}(?:${v4}|:${v6seg}|:)|
|
|
23
|
+
(?:${v6seg}:){5}(?::${v4}|(?::${v6seg}){1,2}|:)|
|
|
24
|
+
(?:${v6seg}:){4}(?:(?::${v6seg}){0,1}:${v4}|(?::${v6seg}){1,3}|:)|
|
|
25
|
+
(?:${v6seg}:){3}(?:(?::${v6seg}){0,2}:${v4}|(?::${v6seg}){1,4}|:)|
|
|
26
|
+
(?:${v6seg}:){2}(?:(?::${v6seg}){0,3}:${v4}|(?::${v6seg}){1,5}|:)|
|
|
27
|
+
(?:${v6seg}:){1}(?:(?::${v6seg}){0,4}:${v4}|(?::${v6seg}){1,6}|:)|
|
|
28
|
+
(?::(?:(?::${v6seg}){0,5}:${v4}|(?::${v6seg}){1,7}|:))
|
|
29
|
+
)(?:%[0-9a-zA-Z]{1,})?
|
|
30
|
+
`
|
|
31
|
+
.replace(/\s*\/\/.*$/gm, "")
|
|
32
|
+
.replace(/\n/g, "")
|
|
33
|
+
.trim();
|
|
34
|
+
|
|
35
|
+
const V6_EXACT = new RegExp(`^${v6}$`);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Return true if `value` is a valid IPv6 literal in any canonical form:
|
|
39
|
+
* full 8-group, `::` compression, or IPv4-mapped (`::ffff:1.2.3.4`).
|
|
40
|
+
* RFC 4007 zone identifiers (`fe80::1%eth0`) are accepted.
|
|
41
|
+
*/
|
|
42
|
+
export function isIPv6(value: string): boolean {
|
|
43
|
+
return typeof value === "string" && V6_EXACT.test(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalize a PostgreSQL connection URL so IPv6 hosts reach pg in the
|
|
48
|
+
* canonical bracketed form.
|
|
49
|
+
*
|
|
50
|
+
* If the URL's hostname contains a percent-encoded colon AND the decoded
|
|
51
|
+
* hostname is a valid IPv6 literal, the hostname is decoded and wrapped in
|
|
52
|
+
* `[...]`. All other fields (scheme, userinfo, port, path, query, fragment)
|
|
53
|
+
* are preserved byte-for-byte from the input.
|
|
54
|
+
*
|
|
55
|
+
* Any URL whose decoded hostname does not validate as IPv6 is returned
|
|
56
|
+
* verbatim, so a malformed input will surface its usual downstream error
|
|
57
|
+
* instead of being silently rewritten.
|
|
58
|
+
*/
|
|
59
|
+
export function normalizeConnectionUrl(url: string): string {
|
|
60
|
+
const urlObj = new URL(url);
|
|
61
|
+
// Cheap pre-filter: only look closer if the hostname contains a
|
|
62
|
+
// percent-encoded colon. Anything else is left entirely untouched.
|
|
63
|
+
if (!/%3[aA]/.test(urlObj.hostname)) return url;
|
|
64
|
+
|
|
65
|
+
const decodedHost = decodeURIComponent(urlObj.hostname);
|
|
66
|
+
// Authoritative validation: only normalize when the decoded string is a
|
|
67
|
+
// real IPv6 literal. Rejects partial fragments, random hostnames that
|
|
68
|
+
// happen to contain `%3A`, and any malformed input.
|
|
69
|
+
if (!isIPv6(decodedHost)) return url;
|
|
70
|
+
|
|
71
|
+
// Preserve username/password/port/path/search/hash exactly as they appear
|
|
72
|
+
// in the WHATWG URL model (these are returned already percent-encoded).
|
|
73
|
+
const scheme = `${urlObj.protocol}//`;
|
|
74
|
+
const auth = urlObj.username
|
|
75
|
+
? urlObj.password
|
|
76
|
+
? `${urlObj.username}:${urlObj.password}@`
|
|
77
|
+
: `${urlObj.username}@`
|
|
78
|
+
: "";
|
|
79
|
+
const port = urlObj.port ? `:${urlObj.port}` : "";
|
|
80
|
+
const tail = `${urlObj.pathname}${urlObj.search}${urlObj.hash}`;
|
|
81
|
+
return `${scheme}${auth}[${decodedHost}]${port}${tail}`;
|
|
82
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
AlterProcedureSetStrictness,
|
|
10
10
|
AlterProcedureSetVolatility,
|
|
11
11
|
} from "./changes/procedure.alter.ts";
|
|
12
|
+
import { CreateCommentOnProcedure } from "./changes/procedure.comment.ts";
|
|
12
13
|
import { CreateProcedure } from "./changes/procedure.create.ts";
|
|
13
14
|
import { DropProcedure } from "./changes/procedure.drop.ts";
|
|
14
15
|
import { diffProcedures } from "./procedure.diff.ts";
|
|
@@ -158,4 +159,28 @@ describe.concurrent("procedure.diff", () => {
|
|
|
158
159
|
expect(changes).toHaveLength(1);
|
|
159
160
|
expect(changes[0]).toBeInstanceOf(CreateProcedure);
|
|
160
161
|
});
|
|
162
|
+
|
|
163
|
+
test("create or replace also emits a procedure comment when the comment changes", () => {
|
|
164
|
+
const main = new Procedure(base);
|
|
165
|
+
const branch = new Procedure({
|
|
166
|
+
...base,
|
|
167
|
+
definition:
|
|
168
|
+
"CREATE FUNCTION public.fn1() RETURNS int4 LANGUAGE sql AS $$SELECT 42::int4$$",
|
|
169
|
+
source_code: "SELECT 42::int4",
|
|
170
|
+
comment: "updated comment",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const changes = diffProcedures(
|
|
174
|
+
testContext,
|
|
175
|
+
{ [main.stableId]: main },
|
|
176
|
+
{ [branch.stableId]: branch },
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(changes.some((change) => change instanceof CreateProcedure)).toBe(
|
|
180
|
+
true,
|
|
181
|
+
);
|
|
182
|
+
expect(
|
|
183
|
+
changes.some((change) => change instanceof CreateCommentOnProcedure),
|
|
184
|
+
).toBe(true);
|
|
185
|
+
});
|
|
161
186
|
});
|
|
@@ -169,6 +169,18 @@ export function diffProcedures(
|
|
|
169
169
|
changes.push(
|
|
170
170
|
new CreateProcedure({ procedure: branchProcedure, orReplace: true }),
|
|
171
171
|
);
|
|
172
|
+
|
|
173
|
+
if (mainProcedure.comment !== branchProcedure.comment) {
|
|
174
|
+
if (branchProcedure.comment === null) {
|
|
175
|
+
changes.push(
|
|
176
|
+
new DropCommentOnProcedure({ procedure: mainProcedure }),
|
|
177
|
+
);
|
|
178
|
+
} else {
|
|
179
|
+
changes.push(
|
|
180
|
+
new CreateCommentOnProcedure({ procedure: branchProcedure }),
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
172
184
|
} else {
|
|
173
185
|
// Only alterable properties changed - check each one
|
|
174
186
|
|
|
@@ -492,6 +492,20 @@ describe.concurrent("table", () => {
|
|
|
492
492
|
"ALTER TABLE public.test_table ALTER COLUMN a SET DEFAULT 0",
|
|
493
493
|
);
|
|
494
494
|
|
|
495
|
+
const changeSetGeneratedExpression = new AlterTableAlterColumnSetDefault({
|
|
496
|
+
table: withCols,
|
|
497
|
+
column: {
|
|
498
|
+
...colText,
|
|
499
|
+
name: "computed_name",
|
|
500
|
+
is_generated: true,
|
|
501
|
+
default: "lower((b))",
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
await assertValidSql(changeSetGeneratedExpression.serialize());
|
|
505
|
+
expect(changeSetGeneratedExpression.serialize()).toBe(
|
|
506
|
+
"ALTER TABLE public.test_table ALTER COLUMN computed_name SET EXPRESSION AS (lower((b)))",
|
|
507
|
+
);
|
|
508
|
+
|
|
495
509
|
const changeDropDefault = new AlterTableAlterColumnDropDefault({
|
|
496
510
|
table: withCols,
|
|
497
511
|
column: { ...colInt, default: null },
|
|
@@ -644,6 +644,9 @@ export class AlterTableAlterColumnSetDefault extends AlterTableChange {
|
|
|
644
644
|
|
|
645
645
|
serialize(_options?: SerializeOptions): string {
|
|
646
646
|
const set = this.column.is_generated ? "SET EXPRESSION AS" : "SET DEFAULT";
|
|
647
|
+
const value = this.column.is_generated
|
|
648
|
+
? `(${this.column.default ?? "NULL"})`
|
|
649
|
+
: (this.column.default ?? "NULL");
|
|
647
650
|
|
|
648
651
|
return [
|
|
649
652
|
"ALTER TABLE",
|
|
@@ -651,7 +654,7 @@ export class AlterTableAlterColumnSetDefault extends AlterTableChange {
|
|
|
651
654
|
"ALTER COLUMN",
|
|
652
655
|
this.column.name,
|
|
653
656
|
set,
|
|
654
|
-
|
|
657
|
+
value,
|
|
655
658
|
].join(" ");
|
|
656
659
|
}
|
|
657
660
|
}
|
|
@@ -835,6 +835,61 @@ describe.concurrent("table.diff", () => {
|
|
|
835
835
|
).toBe(true);
|
|
836
836
|
});
|
|
837
837
|
|
|
838
|
+
test("postgres 17+ recreates a column when switching from regular to generated", () => {
|
|
839
|
+
const pg17Context = {
|
|
840
|
+
...testContext,
|
|
841
|
+
version: 170000,
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
const regularColumn = {
|
|
845
|
+
name: "confirmed_at",
|
|
846
|
+
position: 1,
|
|
847
|
+
data_type: "timestamp with time zone",
|
|
848
|
+
data_type_str: "timestamp with time zone",
|
|
849
|
+
is_custom_type: false,
|
|
850
|
+
custom_type_type: null,
|
|
851
|
+
custom_type_category: null,
|
|
852
|
+
custom_type_schema: null,
|
|
853
|
+
custom_type_name: null,
|
|
854
|
+
not_null: false,
|
|
855
|
+
is_identity: false,
|
|
856
|
+
is_identity_always: false,
|
|
857
|
+
is_generated: false,
|
|
858
|
+
collation: null,
|
|
859
|
+
default: null,
|
|
860
|
+
comment: null,
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
const generatedColumn = {
|
|
864
|
+
...regularColumn,
|
|
865
|
+
is_generated: true,
|
|
866
|
+
default: "LEAST(email_confirmed_at, phone_confirmed_at)",
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
const mainTable = new Table({
|
|
870
|
+
...base,
|
|
871
|
+
name: "auth_users_like",
|
|
872
|
+
columns: [regularColumn],
|
|
873
|
+
});
|
|
874
|
+
const branchTable = new Table({
|
|
875
|
+
...base,
|
|
876
|
+
name: "auth_users_like",
|
|
877
|
+
columns: [generatedColumn],
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
const changes = diffTables(
|
|
881
|
+
pg17Context,
|
|
882
|
+
{ [mainTable.stableId]: mainTable },
|
|
883
|
+
{ [branchTable.stableId]: branchTable },
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
expect(changes.some((c) => c instanceof AlterTableDropColumn)).toBe(true);
|
|
887
|
+
expect(changes.some((c) => c instanceof AlterTableAddColumn)).toBe(true);
|
|
888
|
+
expect(
|
|
889
|
+
changes.some((c) => c instanceof AlterTableAlterColumnSetDefault),
|
|
890
|
+
).toBe(false);
|
|
891
|
+
});
|
|
892
|
+
|
|
838
893
|
test("created table with privileges emits grant changes", () => {
|
|
839
894
|
const t = new Table({
|
|
840
895
|
...base,
|
|
@@ -745,10 +745,18 @@ export function diffTables(
|
|
|
745
745
|
// Set new default value
|
|
746
746
|
const isGeneratedColumn = branchCol.is_generated;
|
|
747
747
|
const isPostgresLowerThan17 = ctx.version < 170000;
|
|
748
|
+
const generatedStatusChanged =
|
|
749
|
+
mainCol.is_generated !== branchCol.is_generated;
|
|
748
750
|
|
|
749
|
-
if (
|
|
751
|
+
if (
|
|
752
|
+
isGeneratedColumn &&
|
|
753
|
+
(isPostgresLowerThan17 || generatedStatusChanged)
|
|
754
|
+
) {
|
|
750
755
|
// For generated columns in < PostgreSQL 17, we need to drop and recreate
|
|
751
|
-
// instead of using SET EXPRESSION AS for computed columns
|
|
756
|
+
// instead of using SET EXPRESSION AS for computed columns. We also
|
|
757
|
+
// need to recreate the column when switching between regular and
|
|
758
|
+
// generated states because SET EXPRESSION only applies to existing
|
|
759
|
+
// generated columns.
|
|
752
760
|
// cf: https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=5d06e99a3
|
|
753
761
|
// cf: https://www.postgresql.org/docs/release/17.0/
|
|
754
762
|
// > Allow ALTER TABLE to change a column's generation expression
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
connectWithRetry,
|
|
4
|
+
isRetryableConnectError,
|
|
5
|
+
} from "./postgres-config.ts";
|
|
6
|
+
|
|
7
|
+
function makeError(message: string, code?: string): Error {
|
|
8
|
+
const err = new Error(message) as Error & { code?: string };
|
|
9
|
+
if (code !== undefined) err.code = code;
|
|
10
|
+
return err;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("isRetryableConnectError", () => {
|
|
14
|
+
describe("non-retryable", () => {
|
|
15
|
+
test("PG auth code 28P01", () => {
|
|
16
|
+
expect(
|
|
17
|
+
isRetryableConnectError(makeError("password auth failed", "28P01")),
|
|
18
|
+
).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("PG auth code 28000", () => {
|
|
22
|
+
expect(
|
|
23
|
+
isRetryableConnectError(
|
|
24
|
+
makeError("invalid authorization specification", "28000"),
|
|
25
|
+
),
|
|
26
|
+
).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("ENOTFOUND (permanent DNS failure)", () => {
|
|
30
|
+
expect(
|
|
31
|
+
isRetryableConnectError(
|
|
32
|
+
makeError("getaddrinfo ENOTFOUND bogus.host", "ENOTFOUND"),
|
|
33
|
+
),
|
|
34
|
+
).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("TLS error via code (ERR_TLS_*)", () => {
|
|
38
|
+
expect(
|
|
39
|
+
isRetryableConnectError(
|
|
40
|
+
makeError("TLS failure", "ERR_TLS_CERT_ALTNAME_INVALID"),
|
|
41
|
+
),
|
|
42
|
+
).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("TLS error via message marker", () => {
|
|
46
|
+
expect(
|
|
47
|
+
isRetryableConnectError(
|
|
48
|
+
makeError("self-signed certificate in certificate chain"),
|
|
49
|
+
),
|
|
50
|
+
).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("SSL error via message marker", () => {
|
|
54
|
+
expect(
|
|
55
|
+
isRetryableConnectError(
|
|
56
|
+
makeError("SSL connection has been closed unexpectedly"),
|
|
57
|
+
),
|
|
58
|
+
).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("retryable", () => {
|
|
63
|
+
test("ECONNRESET", () => {
|
|
64
|
+
expect(
|
|
65
|
+
isRetryableConnectError(makeError("socket hang up", "ECONNRESET")),
|
|
66
|
+
).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("ECONNREFUSED", () => {
|
|
70
|
+
expect(
|
|
71
|
+
isRetryableConnectError(
|
|
72
|
+
makeError("connect ECONNREFUSED", "ECONNREFUSED"),
|
|
73
|
+
),
|
|
74
|
+
).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("ETIMEDOUT", () => {
|
|
78
|
+
expect(
|
|
79
|
+
isRetryableConnectError(makeError("connect ETIMEDOUT", "ETIMEDOUT")),
|
|
80
|
+
).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("EAI_AGAIN (transient DNS)", () => {
|
|
84
|
+
expect(
|
|
85
|
+
isRetryableConnectError(
|
|
86
|
+
makeError("getaddrinfo EAI_AGAIN db.host", "EAI_AGAIN"),
|
|
87
|
+
),
|
|
88
|
+
).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("our own eager-connect timeout wrapper", () => {
|
|
92
|
+
expect(
|
|
93
|
+
isRetryableConnectError(
|
|
94
|
+
new Error(
|
|
95
|
+
"Connection to target database timed out after 2500ms. " +
|
|
96
|
+
"The server may require SSL, use an invalid certificate, or be unreachable.",
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("unknown generic Error is transient-by-default", () => {
|
|
103
|
+
expect(isRetryableConnectError(new Error("something weird"))).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("non-Error values are transient-by-default", () => {
|
|
107
|
+
expect(isRetryableConnectError("string error")).toBe(true);
|
|
108
|
+
expect(isRetryableConnectError({ reason: "x" })).toBe(true);
|
|
109
|
+
expect(isRetryableConnectError(undefined)).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("connectWithRetry", () => {
|
|
115
|
+
const noSleep = async (_ms: number) => {
|
|
116
|
+
// no-op sleep to keep tests fast
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
test("resolves on first attempt without retrying", async () => {
|
|
120
|
+
let attempts = 0;
|
|
121
|
+
const result = await connectWithRetry({
|
|
122
|
+
connect: async () => {
|
|
123
|
+
attempts++;
|
|
124
|
+
return "ok" as const;
|
|
125
|
+
},
|
|
126
|
+
sleep: noSleep,
|
|
127
|
+
});
|
|
128
|
+
expect(result).toBe("ok");
|
|
129
|
+
expect(attempts).toBe(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("retries a retryable error until success", async () => {
|
|
133
|
+
let attempts = 0;
|
|
134
|
+
const result = await connectWithRetry({
|
|
135
|
+
connect: async () => {
|
|
136
|
+
attempts++;
|
|
137
|
+
if (attempts < 3) {
|
|
138
|
+
throw makeError("connect ECONNREFUSED", "ECONNREFUSED");
|
|
139
|
+
}
|
|
140
|
+
return "ok" as const;
|
|
141
|
+
},
|
|
142
|
+
maxAttempts: 5,
|
|
143
|
+
sleep: noSleep,
|
|
144
|
+
});
|
|
145
|
+
expect(result).toBe("ok");
|
|
146
|
+
expect(attempts).toBe(3);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("honours maxAttempts and throws the last error", async () => {
|
|
150
|
+
let attempts = 0;
|
|
151
|
+
const boom = makeError("connect ECONNREFUSED", "ECONNREFUSED");
|
|
152
|
+
await expect(
|
|
153
|
+
connectWithRetry({
|
|
154
|
+
connect: async () => {
|
|
155
|
+
attempts++;
|
|
156
|
+
throw boom;
|
|
157
|
+
},
|
|
158
|
+
maxAttempts: 4,
|
|
159
|
+
sleep: noSleep,
|
|
160
|
+
}),
|
|
161
|
+
).rejects.toBe(boom);
|
|
162
|
+
expect(attempts).toBe(4);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("stops immediately on a non-retryable error", async () => {
|
|
166
|
+
let attempts = 0;
|
|
167
|
+
const authError = makeError("password authentication failed", "28P01");
|
|
168
|
+
await expect(
|
|
169
|
+
connectWithRetry({
|
|
170
|
+
connect: async () => {
|
|
171
|
+
attempts++;
|
|
172
|
+
throw authError;
|
|
173
|
+
},
|
|
174
|
+
maxAttempts: 10,
|
|
175
|
+
sleep: noSleep,
|
|
176
|
+
}),
|
|
177
|
+
).rejects.toBe(authError);
|
|
178
|
+
expect(attempts).toBe(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("uses exponential backoff: 250ms, 500ms (3 attempts)", async () => {
|
|
182
|
+
const delays: number[] = [];
|
|
183
|
+
let attempts = 0;
|
|
184
|
+
await expect(
|
|
185
|
+
connectWithRetry({
|
|
186
|
+
connect: async () => {
|
|
187
|
+
attempts++;
|
|
188
|
+
throw makeError("connect ECONNREFUSED", "ECONNREFUSED");
|
|
189
|
+
},
|
|
190
|
+
maxAttempts: 3,
|
|
191
|
+
baseBackoffMs: 250,
|
|
192
|
+
maxBackoffMs: 1000,
|
|
193
|
+
sleep: async (ms) => {
|
|
194
|
+
delays.push(ms);
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
).rejects.toBeDefined();
|
|
198
|
+
expect(attempts).toBe(3);
|
|
199
|
+
// Sleep is invoked once after attempt 1 and once after attempt 2;
|
|
200
|
+
// the final failure throws without sleeping.
|
|
201
|
+
expect(delays).toEqual([250, 500]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("caps backoff at maxBackoffMs", async () => {
|
|
205
|
+
const delays: number[] = [];
|
|
206
|
+
await expect(
|
|
207
|
+
connectWithRetry({
|
|
208
|
+
connect: async () => {
|
|
209
|
+
throw makeError("connect ECONNREFUSED", "ECONNREFUSED");
|
|
210
|
+
},
|
|
211
|
+
maxAttempts: 5,
|
|
212
|
+
baseBackoffMs: 250,
|
|
213
|
+
maxBackoffMs: 600,
|
|
214
|
+
sleep: async (ms) => {
|
|
215
|
+
delays.push(ms);
|
|
216
|
+
},
|
|
217
|
+
}),
|
|
218
|
+
).rejects.toBeDefined();
|
|
219
|
+
// Uncapped would be [250, 500, 1000, 2000]; the 1000/2000 values are
|
|
220
|
+
// both capped to 600.
|
|
221
|
+
expect(delays).toEqual([250, 500, 600, 600]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("injected isRetryable overrides the default predicate", async () => {
|
|
225
|
+
let attempts = 0;
|
|
226
|
+
const err = new Error("custom transient");
|
|
227
|
+
const neverRetry = () => false;
|
|
228
|
+
await expect(
|
|
229
|
+
connectWithRetry({
|
|
230
|
+
connect: async () => {
|
|
231
|
+
attempts++;
|
|
232
|
+
throw err;
|
|
233
|
+
},
|
|
234
|
+
maxAttempts: 5,
|
|
235
|
+
isRetryable: neverRetry,
|
|
236
|
+
sleep: noSleep,
|
|
237
|
+
}),
|
|
238
|
+
).rejects.toBe(err);
|
|
239
|
+
expect(attempts).toBe(1);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { ClientBase, PoolClient, PoolConfig } from "pg";
|
|
6
6
|
import { escapeIdentifier, Pool, types } from "pg";
|
|
7
|
+
import { normalizeConnectionUrl } from "./connection-url.ts";
|
|
7
8
|
import { parseSslConfig } from "./plan/ssl-config.ts";
|
|
8
9
|
|
|
9
10
|
// ============================================================================
|
|
@@ -109,6 +110,104 @@ const DEFAULT_CONNECTION_TIMEOUT_MS =
|
|
|
109
110
|
Number(process.env.PGDELTA_CONNECTION_TIMEOUT_MS) || 3_000;
|
|
110
111
|
const DEFAULT_CONNECT_TIMEOUT_MS =
|
|
111
112
|
Number(process.env.PGDELTA_CONNECT_TIMEOUT_MS) || 2_500;
|
|
113
|
+
const DEFAULT_CONNECT_MAX_ATTEMPTS =
|
|
114
|
+
Number(process.env.PGDELTA_CONNECT_MAX_ATTEMPTS) || 3;
|
|
115
|
+
const DEFAULT_CONNECT_BASE_BACKOFF_MS =
|
|
116
|
+
Number(process.env.PGDELTA_CONNECT_BASE_BACKOFF_MS) || 250;
|
|
117
|
+
const DEFAULT_CONNECT_MAX_BACKOFF_MS =
|
|
118
|
+
Number(process.env.PGDELTA_CONNECT_MAX_BACKOFF_MS) || 1_000;
|
|
119
|
+
|
|
120
|
+
// PostgreSQL auth-class SQLSTATE codes: not retryable.
|
|
121
|
+
const NON_RETRYABLE_PG_CODES = new Set([
|
|
122
|
+
"28000", // invalid_authorization_specification
|
|
123
|
+
"28P01", // invalid_password
|
|
124
|
+
"28P02", // pgdelta: alias reserved here to future-proof against new auth codes
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
// Non-retryable TLS/SSL markers. The `pg` driver surfaces TLS failures as
|
|
128
|
+
// either plain Node `Error` instances with a code on `ERR_TLS_*` or error
|
|
129
|
+
// messages that include well-known cert/TLS terminology; we match both
|
|
130
|
+
// because node-pg normalises some of these.
|
|
131
|
+
const TLS_MESSAGE_MARKERS = [
|
|
132
|
+
"self-signed certificate",
|
|
133
|
+
"self signed certificate",
|
|
134
|
+
"unable to verify the first certificate",
|
|
135
|
+
"certificate has expired",
|
|
136
|
+
"tls",
|
|
137
|
+
"ssl",
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Return true when `err` represents a transient connect failure that makes
|
|
142
|
+
* sense to retry with backoff (e.g. refused connections, DNS blips, our own
|
|
143
|
+
* eager-connect timeout wrapper). Returns false for permanent failures such
|
|
144
|
+
* as authentication errors, TLS negotiation errors, and `ENOTFOUND`.
|
|
145
|
+
*
|
|
146
|
+
* Unknown errors are treated as retryable on purpose: transient-by-default
|
|
147
|
+
* is safer here because a duplicated retry is strictly cheaper than a spurious
|
|
148
|
+
* hard failure during catalog extraction.
|
|
149
|
+
*/
|
|
150
|
+
export function isRetryableConnectError(err: unknown): boolean {
|
|
151
|
+
if (!(err instanceof Error)) return true;
|
|
152
|
+
const code = (err as NodeJS.ErrnoException & { code?: string }).code;
|
|
153
|
+
|
|
154
|
+
if (code && NON_RETRYABLE_PG_CODES.has(code)) return false;
|
|
155
|
+
if (code === "ENOTFOUND") return false;
|
|
156
|
+
if (code && typeof code === "string" && code.startsWith("ERR_TLS")) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const message = err.message?.toLowerCase() ?? "";
|
|
161
|
+
// Our own eager-connect timeout wrapper is retryable (flaky network).
|
|
162
|
+
if (message.includes("timed out after")) return true;
|
|
163
|
+
for (const marker of TLS_MESSAGE_MARKERS) {
|
|
164
|
+
if (message.includes(marker)) return false;
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Retry an async `connect` operation with bounded exponential backoff.
|
|
171
|
+
* Stops immediately on a non-retryable error. On exhausted attempts, throws
|
|
172
|
+
* the last observed error.
|
|
173
|
+
*
|
|
174
|
+
* Exposed for testing — production call sites always go through
|
|
175
|
+
* {@link createManagedPool}.
|
|
176
|
+
*/
|
|
177
|
+
export async function connectWithRetry<T>(opts: {
|
|
178
|
+
connect: (attempt: number) => Promise<T>;
|
|
179
|
+
isRetryable?: (err: unknown) => boolean;
|
|
180
|
+
maxAttempts?: number;
|
|
181
|
+
baseBackoffMs?: number;
|
|
182
|
+
maxBackoffMs?: number;
|
|
183
|
+
sleep?: (ms: number) => Promise<void>;
|
|
184
|
+
}): Promise<T> {
|
|
185
|
+
const maxAttempts = opts.maxAttempts ?? DEFAULT_CONNECT_MAX_ATTEMPTS;
|
|
186
|
+
const baseBackoffMs = opts.baseBackoffMs ?? DEFAULT_CONNECT_BASE_BACKOFF_MS;
|
|
187
|
+
const maxBackoffMs = opts.maxBackoffMs ?? DEFAULT_CONNECT_MAX_BACKOFF_MS;
|
|
188
|
+
const isRetryable = opts.isRetryable ?? isRetryableConnectError;
|
|
189
|
+
const sleep =
|
|
190
|
+
opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
|
|
191
|
+
|
|
192
|
+
let lastError: unknown;
|
|
193
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
194
|
+
try {
|
|
195
|
+
return await opts.connect(attempt);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
lastError = err;
|
|
198
|
+
if (attempt >= maxAttempts || !isRetryable(err)) {
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
const backoff = Math.min(
|
|
202
|
+
baseBackoffMs * 2 ** (attempt - 1),
|
|
203
|
+
maxBackoffMs,
|
|
204
|
+
);
|
|
205
|
+
await sleep(backoff);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// Unreachable: loop either returns or throws.
|
|
209
|
+
throw lastError;
|
|
210
|
+
}
|
|
112
211
|
|
|
113
212
|
/**
|
|
114
213
|
* Options for creating a Pool with event listeners.
|
|
@@ -227,7 +326,14 @@ export async function createManagedPool(
|
|
|
227
326
|
url: string,
|
|
228
327
|
options?: { role?: string; label?: "source" | "target" },
|
|
229
328
|
): Promise<{ pool: Pool; close: () => Promise<void> }> {
|
|
230
|
-
|
|
329
|
+
// Normalize percent-encoded IPv6 hosts (e.g. `2406%3A...%3Ab3c9`) into the
|
|
330
|
+
// canonical bracketed form before the URL reaches `parseSslConfig` or pg.
|
|
331
|
+
// Non-IPv6 hosts are returned unchanged.
|
|
332
|
+
const normalizedUrl = normalizeConnectionUrl(url);
|
|
333
|
+
const sslConfig = await parseSslConfig(
|
|
334
|
+
normalizedUrl,
|
|
335
|
+
options?.label ?? "target",
|
|
336
|
+
);
|
|
231
337
|
const pool = createPool(sslConfig.cleanedUrl, {
|
|
232
338
|
...(sslConfig.ssl !== undefined ? { ssl: sslConfig.ssl } : {}),
|
|
233
339
|
onError: (err: Error & { code?: string }) => {
|
|
@@ -245,25 +351,30 @@ export async function createManagedPool(
|
|
|
245
351
|
|
|
246
352
|
// Eagerly validate connectivity so SSL/auth failures surface immediately
|
|
247
353
|
// instead of hanging on the first real query. node-pg's connectionTimeoutMillis
|
|
248
|
-
// is not reliably enforced under Bun when SSL negotiation hangs.
|
|
354
|
+
// is not reliably enforced under Bun when SSL negotiation hangs. Transient
|
|
355
|
+
// failures (refused connections, flaky DNS, our own timeout wrapper) are
|
|
356
|
+
// retried with bounded exponential backoff; auth/TLS/ENOTFOUND fail fast.
|
|
249
357
|
const label = options?.label ?? "target";
|
|
250
358
|
const timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS;
|
|
251
359
|
try {
|
|
252
|
-
const client = await
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
() =>
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
360
|
+
const client = await connectWithRetry({
|
|
361
|
+
connect: () =>
|
|
362
|
+
Promise.race([
|
|
363
|
+
pool.connect(),
|
|
364
|
+
new Promise<never>((_, reject) =>
|
|
365
|
+
setTimeout(
|
|
366
|
+
() =>
|
|
367
|
+
reject(
|
|
368
|
+
new Error(
|
|
369
|
+
`Connection to ${label} database timed out after ${timeoutMs}ms. ` +
|
|
370
|
+
`The server may require SSL, use an invalid certificate, or be unreachable.`,
|
|
371
|
+
),
|
|
372
|
+
),
|
|
373
|
+
timeoutMs,
|
|
262
374
|
),
|
|
263
|
-
|
|
264
|
-
),
|
|
265
|
-
|
|
266
|
-
]);
|
|
375
|
+
),
|
|
376
|
+
]),
|
|
377
|
+
});
|
|
267
378
|
client.release();
|
|
268
379
|
} catch (err) {
|
|
269
380
|
await pool.end().catch(() => {});
|