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

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.
@@ -45,8 +45,38 @@ interface CreatePoolOptions extends Partial<PoolConfig> {
45
45
  }
46
46
  /**
47
47
  * Create a Pool with custom type handlers and optional event listeners.
48
+ *
49
+ * `connectionString` may be `undefined` when the caller needs pg to rely on
50
+ * explicit `host`/`port`/`user`/... fields from `options` instead — notably
51
+ * the bracketed-IPv6 workaround in {@link poolConfigFromUrl}, where passing
52
+ * the connection string would cause `pg-connection-string` to re-inject the
53
+ * bracketed host that breaks `getaddrinfo`.
54
+ */
55
+ export declare function createPool(connectionString: string | undefined, options?: CreatePoolOptions): Pool;
56
+ /**
57
+ * Build a pg {@link PoolConfig} from a cleaned connection URL.
58
+ *
59
+ * For most URLs this just returns `{ connectionString }` and pg does its
60
+ * normal parsing. But for URLs whose hostname is a bracketed IPv6 literal
61
+ * (e.g. `postgresql://user@[::1]:5432/db`, as produced by
62
+ * {@link normalizeConnectionUrl}), we expand the URL into explicit
63
+ * `host`/`port`/`user`/`password`/`database` fields with a **bare** IPv6
64
+ * host — no brackets.
65
+ *
66
+ * This works around a `pg-connection-string` quirk: its parser sets
67
+ * `config.host` to the WHATWG `URL.hostname`, which keeps the surrounding
68
+ * `[...]` for IPv6 literals. That bracketed value is then passed verbatim to
69
+ * `getaddrinfo`, which rejects it with `ENOTFOUND`. Since
70
+ * `pg`'s connection-parameters module does
71
+ * `Object.assign({}, config, parse(connectionString))`, any `host` we pass
72
+ * alongside `connectionString` gets clobbered — so we drop `connectionString`
73
+ * entirely on this path and hand pg the parsed fields directly.
74
+ *
75
+ * Remaining query parameters (e.g. `application_name`, `options`,
76
+ * `connect_timeout`) are forwarded as top-level config keys, mirroring how
77
+ * `pg-connection-string` would normally surface them.
48
78
  */
49
- export declare function createPool(connectionString: string, options?: CreatePoolOptions): Pool;
79
+ export declare function poolConfigFromUrl(cleanedUrl: string): PoolConfig;
50
80
  /**
51
81
  * End a pool and wait for all client sockets to fully close.
52
82
  *
@@ -182,11 +182,17 @@ export async function connectWithRetry(opts) {
182
182
  }
183
183
  /**
184
184
  * Create a Pool with custom type handlers and optional event listeners.
185
+ *
186
+ * `connectionString` may be `undefined` when the caller needs pg to rely on
187
+ * explicit `host`/`port`/`user`/... fields from `options` instead — notably
188
+ * the bracketed-IPv6 workaround in {@link poolConfigFromUrl}, where passing
189
+ * the connection string would cause `pg-connection-string` to re-inject the
190
+ * bracketed host that breaks `getaddrinfo`.
185
191
  */
186
192
  export function createPool(connectionString, options) {
187
193
  const { onConnect, onError, onAcquire, onRemove, ...config } = options ?? {};
188
194
  const pool = new Pool({
189
- connectionString,
195
+ ...(connectionString ? { connectionString } : {}),
190
196
  max: DEFAULT_POOL_MAX,
191
197
  connectionTimeoutMillis: DEFAULT_CONNECTION_TIMEOUT_MS,
192
198
  ...config,
@@ -242,6 +248,51 @@ export function createPool(connectionString, options) {
242
248
  pool.on("remove", onRemove);
243
249
  return pool;
244
250
  }
251
+ /**
252
+ * Build a pg {@link PoolConfig} from a cleaned connection URL.
253
+ *
254
+ * For most URLs this just returns `{ connectionString }` and pg does its
255
+ * normal parsing. But for URLs whose hostname is a bracketed IPv6 literal
256
+ * (e.g. `postgresql://user@[::1]:5432/db`, as produced by
257
+ * {@link normalizeConnectionUrl}), we expand the URL into explicit
258
+ * `host`/`port`/`user`/`password`/`database` fields with a **bare** IPv6
259
+ * host — no brackets.
260
+ *
261
+ * This works around a `pg-connection-string` quirk: its parser sets
262
+ * `config.host` to the WHATWG `URL.hostname`, which keeps the surrounding
263
+ * `[...]` for IPv6 literals. That bracketed value is then passed verbatim to
264
+ * `getaddrinfo`, which rejects it with `ENOTFOUND`. Since
265
+ * `pg`'s connection-parameters module does
266
+ * `Object.assign({}, config, parse(connectionString))`, any `host` we pass
267
+ * alongside `connectionString` gets clobbered — so we drop `connectionString`
268
+ * entirely on this path and hand pg the parsed fields directly.
269
+ *
270
+ * Remaining query parameters (e.g. `application_name`, `options`,
271
+ * `connect_timeout`) are forwarded as top-level config keys, mirroring how
272
+ * `pg-connection-string` would normally surface them.
273
+ */
274
+ export function poolConfigFromUrl(cleanedUrl) {
275
+ const urlObj = new URL(cleanedUrl);
276
+ if (!urlObj.hostname.startsWith("[")) {
277
+ return { connectionString: cleanedUrl };
278
+ }
279
+ const config = {
280
+ host: urlObj.hostname.slice(1, -1),
281
+ };
282
+ if (urlObj.port)
283
+ config.port = Number(urlObj.port);
284
+ if (urlObj.username)
285
+ config.user = decodeURIComponent(urlObj.username);
286
+ if (urlObj.password)
287
+ config.password = decodeURIComponent(urlObj.password);
288
+ if (urlObj.pathname.length > 1) {
289
+ config.database = decodeURIComponent(urlObj.pathname.slice(1));
290
+ }
291
+ for (const [key, value] of urlObj.searchParams) {
292
+ config[key] = value;
293
+ }
294
+ return config;
295
+ }
245
296
  /**
246
297
  * End a pool and wait for all client sockets to fully close.
247
298
  *
@@ -269,7 +320,11 @@ export async function createManagedPool(url, options) {
269
320
  // Non-IPv6 hosts are returned unchanged.
270
321
  const normalizedUrl = normalizeConnectionUrl(url);
271
322
  const sslConfig = await parseSslConfig(normalizedUrl, options?.label ?? "target");
272
- const pool = createPool(sslConfig.cleanedUrl, {
323
+ // Expand bracketed-IPv6 URLs into explicit pg fields so the brackets never
324
+ // reach `getaddrinfo` — see `poolConfigFromUrl` for the full rationale.
325
+ const connectionConfig = poolConfigFromUrl(sslConfig.cleanedUrl);
326
+ const pool = createPool(connectionConfig.connectionString, {
327
+ ...connectionConfig,
273
328
  ...(sslConfig.ssl !== undefined ? { ssl: sslConfig.ssl } : {}),
274
329
  onError: (err) => {
275
330
  if (err.code !== "57P01") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supabase/pg-delta",
3
- "version": "1.0.0-alpha.15",
3
+ "version": "1.0.0-alpha.16",
4
4
  "description": "PostgreSQL migrations made easy",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  connectWithRetry,
4
4
  isRetryableConnectError,
5
+ poolConfigFromUrl,
5
6
  } from "./postgres-config.ts";
6
7
 
7
8
  function makeError(message: string, code?: string): Error {
@@ -239,3 +240,97 @@ describe("connectWithRetry", () => {
239
240
  expect(attempts).toBe(1);
240
241
  });
241
242
  });
243
+
244
+ describe("poolConfigFromUrl", () => {
245
+ describe("non-IPv6 URLs pass through as connectionString", () => {
246
+ test("DNS hostname", () => {
247
+ const url = "postgresql://user:pass@db.example.com:5432/mydb";
248
+ expect(poolConfigFromUrl(url)).toEqual({ connectionString: url });
249
+ });
250
+
251
+ test("IPv4 host", () => {
252
+ const url = "postgresql://user:pass@127.0.0.1:5432/mydb";
253
+ expect(poolConfigFromUrl(url)).toEqual({ connectionString: url });
254
+ });
255
+
256
+ test("DNS hostname with query params", () => {
257
+ const url =
258
+ "postgresql://user:pass@db.example.com:5432/mydb?application_name=test";
259
+ expect(poolConfigFromUrl(url)).toEqual({ connectionString: url });
260
+ });
261
+ });
262
+
263
+ describe("bracketed IPv6 URLs expand to explicit fields with no brackets", () => {
264
+ test("full 8-group IPv6 — host has no brackets and no connectionString", () => {
265
+ const url =
266
+ "postgresql://user:pass@[2600:1f16:1cd0:3340:f92e:f4cb:7a52:10a1]:5432/mydb";
267
+ const config = poolConfigFromUrl(url);
268
+ expect(config.connectionString).toBeUndefined();
269
+ expect(config.host).toBe("2600:1f16:1cd0:3340:f92e:f4cb:7a52:10a1");
270
+ expect(config.port).toBe(5432);
271
+ expect(config.user).toBe("user");
272
+ expect(config.password).toBe("pass");
273
+ expect(config.database).toBe("mydb");
274
+ });
275
+
276
+ test("compressed ::1 form", () => {
277
+ const url = "postgresql://user:pass@[::1]:5432/mydb";
278
+ const config = poolConfigFromUrl(url);
279
+ expect(config.host).toBe("::1");
280
+ expect(config.port).toBe(5432);
281
+ });
282
+
283
+ test("host bracket strip survives percent-decoded username/password", () => {
284
+ const url = "postgresql://user:p%40ss%2Fword@[::1]:5432/mydb";
285
+ const config = poolConfigFromUrl(url);
286
+ expect(config.host).toBe("::1");
287
+ expect(config.user).toBe("user");
288
+ expect(config.password).toBe("p@ss/word");
289
+ });
290
+
291
+ test("works without port", () => {
292
+ const url = "postgresql://user:pass@[::1]/mydb";
293
+ const config = poolConfigFromUrl(url);
294
+ expect(config.host).toBe("::1");
295
+ expect(config.port).toBeUndefined();
296
+ });
297
+
298
+ test("works without database (pathname='/')", () => {
299
+ const url = "postgresql://user:pass@[::1]:5432/";
300
+ const config = poolConfigFromUrl(url);
301
+ expect(config.host).toBe("::1");
302
+ expect(config.database).toBeUndefined();
303
+ });
304
+
305
+ test("works without userinfo", () => {
306
+ const url = "postgresql://[::1]:5432/mydb";
307
+ const config = poolConfigFromUrl(url);
308
+ expect(config.host).toBe("::1");
309
+ expect(config.user).toBeUndefined();
310
+ expect(config.password).toBeUndefined();
311
+ });
312
+
313
+ test("query params are forwarded as top-level config keys", () => {
314
+ const url =
315
+ "postgresql://user:pass@[::1]:5432/mydb?application_name=pgdelta&connect_timeout=5";
316
+ const config = poolConfigFromUrl(url) as unknown as Record<
317
+ string,
318
+ unknown
319
+ >;
320
+ expect(config.application_name).toBe("pgdelta");
321
+ expect(config.connect_timeout).toBe("5");
322
+ expect(config.host).toBe("::1");
323
+ });
324
+
325
+ test("IPv4-mapped IPv6 is stripped of brackets (WHATWG canonicalisation is fine)", () => {
326
+ // WHATWG URL canonicalises `::ffff:192.0.2.1` to `::ffff:c000:201`;
327
+ // either form resolves to the same IPv6 address, and the point of this
328
+ // test is purely that no brackets escape to pg.
329
+ const url = "postgresql://user:pass@[::ffff:192.0.2.1]:5432/mydb";
330
+ const config = poolConfigFromUrl(url);
331
+ expect(config.host).not.toContain("[");
332
+ expect(config.host).not.toContain("]");
333
+ expect(config.host).toBe("::ffff:c000:201");
334
+ });
335
+ });
336
+ });
@@ -225,14 +225,20 @@ interface CreatePoolOptions extends Partial<PoolConfig> {
225
225
 
226
226
  /**
227
227
  * Create a Pool with custom type handlers and optional event listeners.
228
+ *
229
+ * `connectionString` may be `undefined` when the caller needs pg to rely on
230
+ * explicit `host`/`port`/`user`/... fields from `options` instead — notably
231
+ * the bracketed-IPv6 workaround in {@link poolConfigFromUrl}, where passing
232
+ * the connection string would cause `pg-connection-string` to re-inject the
233
+ * bracketed host that breaks `getaddrinfo`.
228
234
  */
229
235
  export function createPool(
230
- connectionString: string,
236
+ connectionString: string | undefined,
231
237
  options?: CreatePoolOptions,
232
238
  ): Pool {
233
239
  const { onConnect, onError, onAcquire, onRemove, ...config } = options ?? {};
234
240
  const pool = new Pool({
235
- connectionString,
241
+ ...(connectionString ? { connectionString } : {}),
236
242
  max: DEFAULT_POOL_MAX,
237
243
  connectionTimeoutMillis: DEFAULT_CONNECTION_TIMEOUT_MS,
238
244
  ...config,
@@ -301,6 +307,50 @@ export function createPool(
301
307
  return pool;
302
308
  }
303
309
 
310
+ /**
311
+ * Build a pg {@link PoolConfig} from a cleaned connection URL.
312
+ *
313
+ * For most URLs this just returns `{ connectionString }` and pg does its
314
+ * normal parsing. But for URLs whose hostname is a bracketed IPv6 literal
315
+ * (e.g. `postgresql://user@[::1]:5432/db`, as produced by
316
+ * {@link normalizeConnectionUrl}), we expand the URL into explicit
317
+ * `host`/`port`/`user`/`password`/`database` fields with a **bare** IPv6
318
+ * host — no brackets.
319
+ *
320
+ * This works around a `pg-connection-string` quirk: its parser sets
321
+ * `config.host` to the WHATWG `URL.hostname`, which keeps the surrounding
322
+ * `[...]` for IPv6 literals. That bracketed value is then passed verbatim to
323
+ * `getaddrinfo`, which rejects it with `ENOTFOUND`. Since
324
+ * `pg`'s connection-parameters module does
325
+ * `Object.assign({}, config, parse(connectionString))`, any `host` we pass
326
+ * alongside `connectionString` gets clobbered — so we drop `connectionString`
327
+ * entirely on this path and hand pg the parsed fields directly.
328
+ *
329
+ * Remaining query parameters (e.g. `application_name`, `options`,
330
+ * `connect_timeout`) are forwarded as top-level config keys, mirroring how
331
+ * `pg-connection-string` would normally surface them.
332
+ */
333
+ export function poolConfigFromUrl(cleanedUrl: string): PoolConfig {
334
+ const urlObj = new URL(cleanedUrl);
335
+ if (!urlObj.hostname.startsWith("[")) {
336
+ return { connectionString: cleanedUrl };
337
+ }
338
+
339
+ const config: Record<string, unknown> = {
340
+ host: urlObj.hostname.slice(1, -1),
341
+ };
342
+ if (urlObj.port) config.port = Number(urlObj.port);
343
+ if (urlObj.username) config.user = decodeURIComponent(urlObj.username);
344
+ if (urlObj.password) config.password = decodeURIComponent(urlObj.password);
345
+ if (urlObj.pathname.length > 1) {
346
+ config.database = decodeURIComponent(urlObj.pathname.slice(1));
347
+ }
348
+ for (const [key, value] of urlObj.searchParams) {
349
+ config[key] = value;
350
+ }
351
+ return config as PoolConfig;
352
+ }
353
+
304
354
  /**
305
355
  * End a pool and wait for all client sockets to fully close.
306
356
  *
@@ -334,7 +384,11 @@ export async function createManagedPool(
334
384
  normalizedUrl,
335
385
  options?.label ?? "target",
336
386
  );
337
- const pool = createPool(sslConfig.cleanedUrl, {
387
+ // Expand bracketed-IPv6 URLs into explicit pg fields so the brackets never
388
+ // reach `getaddrinfo` — see `poolConfigFromUrl` for the full rationale.
389
+ const connectionConfig = poolConfigFromUrl(sslConfig.cleanedUrl);
390
+ const pool = createPool(connectionConfig.connectionString, {
391
+ ...connectionConfig,
338
392
  ...(sslConfig.ssl !== undefined ? { ssl: sslConfig.ssl } : {}),
339
393
  onError: (err: Error & { code?: string }) => {
340
394
  if (err.code !== "57P01") {