@supabase/pg-delta 1.0.0-alpha.13 → 1.0.0-alpha.15

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.
Files changed (37) hide show
  1. package/README.md +7 -1
  2. package/dist/core/catalog.diff.js +7 -1
  3. package/dist/core/connection-url.d.ts +32 -0
  4. package/dist/core/connection-url.js +77 -0
  5. package/dist/core/expand-replace-dependencies.d.ts +8 -2
  6. package/dist/core/expand-replace-dependencies.js +24 -10
  7. package/dist/core/integrations/supabase.js +1 -0
  8. package/dist/core/objects/procedure/procedure.diff.js +8 -0
  9. package/dist/core/objects/sequence/sequence.diff.js +14 -6
  10. package/dist/core/objects/table/changes/table.alter.js +4 -1
  11. package/dist/core/objects/table/changes/table.drop.d.ts +12 -0
  12. package/dist/core/objects/table/changes/table.drop.js +20 -3
  13. package/dist/core/objects/table/table.diff.js +7 -2
  14. package/dist/core/post-diff-cycle-breaking.d.ts +22 -0
  15. package/dist/core/post-diff-cycle-breaking.js +143 -0
  16. package/dist/core/postgres-config.d.ts +27 -0
  17. package/dist/core/postgres-config.js +99 -7
  18. package/package.json +2 -1
  19. package/src/core/catalog.diff.ts +7 -1
  20. package/src/core/connection-url.test.ts +142 -0
  21. package/src/core/connection-url.ts +82 -0
  22. package/src/core/expand-replace-dependencies.test.ts +247 -8
  23. package/src/core/expand-replace-dependencies.ts +33 -5
  24. package/src/core/integrations/supabase.ts +1 -0
  25. package/src/core/objects/procedure/procedure.diff.test.ts +25 -0
  26. package/src/core/objects/procedure/procedure.diff.ts +12 -0
  27. package/src/core/objects/sequence/sequence.diff.test.ts +110 -8
  28. package/src/core/objects/sequence/sequence.diff.ts +16 -6
  29. package/src/core/objects/table/changes/table.alter.test.ts +14 -0
  30. package/src/core/objects/table/changes/table.alter.ts +4 -1
  31. package/src/core/objects/table/changes/table.drop.ts +27 -4
  32. package/src/core/objects/table/table.diff.test.ts +55 -0
  33. package/src/core/objects/table/table.diff.ts +10 -2
  34. package/src/core/post-diff-cycle-breaking.test.ts +317 -0
  35. package/src/core/post-diff-cycle-breaking.ts +236 -0
  36. package/src/core/postgres-config.test.ts +241 -0
  37. package/src/core/postgres-config.ts +127 -16
@@ -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
- const sslConfig = await parseSslConfig(url, options?.label ?? "target");
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 Promise.race([
253
- pool.connect(),
254
- new Promise<never>((_, reject) =>
255
- setTimeout(
256
- () =>
257
- reject(
258
- new Error(
259
- `Connection to ${label} database timed out after ${timeoutMs}ms. ` +
260
- `The server may require SSL, use an invalid certificate, or be unreachable.`,
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
- timeoutMs,
264
- ),
265
- ),
266
- ]);
375
+ ),
376
+ ]),
377
+ });
267
378
  client.release();
268
379
  } catch (err) {
269
380
  await pool.end().catch(() => {});