turbine-orm 0.79.1 → 0.80.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.
Files changed (71) hide show
  1. package/README.md +4 -4
  2. package/dist/checkout.d.ts +53 -0
  3. package/dist/checkout.js +78 -0
  4. package/dist/cjs/checkout.d.ts +53 -0
  5. package/dist/cjs/checkout.js +82 -0
  6. package/dist/cjs/cli/index.js +4 -0
  7. package/dist/cjs/cli/mcp.js +4 -0
  8. package/dist/cjs/cli/migrate.js +6 -0
  9. package/dist/cjs/cli/observe.js +8 -0
  10. package/dist/cjs/cli/studio.js +13 -1
  11. package/dist/cjs/client.d.ts +12 -2
  12. package/dist/cjs/client.js +61 -75
  13. package/dist/cjs/connection-guard.d.ts +120 -0
  14. package/dist/cjs/connection-guard.js +191 -0
  15. package/dist/cjs/errors.d.ts +26 -0
  16. package/dist/cjs/errors.js +85 -1
  17. package/dist/cjs/index.d.ts +1 -1
  18. package/dist/cjs/nested-write.d.ts +12 -2
  19. package/dist/cjs/nested-write.js +4 -10
  20. package/dist/cjs/pipeline.js +12 -7
  21. package/dist/cjs/plan-flip-probe.js +4 -0
  22. package/dist/cjs/powdb-shared.d.ts +22 -2
  23. package/dist/cjs/powdb-shared.js +27 -2
  24. package/dist/cjs/powdb.js +36 -37
  25. package/dist/cjs/powql.d.ts +51 -6
  26. package/dist/cjs/powql.js +199 -45
  27. package/dist/cjs/prisma-compat.js +28 -4
  28. package/dist/cjs/query/builder.d.ts +44 -24
  29. package/dist/cjs/query/builder.js +125 -66
  30. package/dist/cjs/query/deferred.d.ts +9 -0
  31. package/dist/cjs/query/option-surface.js +12 -0
  32. package/dist/cjs/query/types.d.ts +68 -4
  33. package/dist/cjs/query/writes.d.ts +39 -9
  34. package/dist/cjs/query/writes.js +72 -34
  35. package/dist/cjs/realtime.d.ts +46 -2
  36. package/dist/cjs/realtime.js +125 -20
  37. package/dist/cjs/schema-sql.js +6 -0
  38. package/dist/cli/index.js +4 -0
  39. package/dist/cli/mcp.js +4 -0
  40. package/dist/cli/migrate.js +6 -0
  41. package/dist/cli/observe.js +8 -0
  42. package/dist/cli/studio.js +13 -1
  43. package/dist/client.d.ts +12 -2
  44. package/dist/client.js +62 -76
  45. package/dist/connection-guard.d.ts +120 -0
  46. package/dist/connection-guard.js +183 -0
  47. package/dist/errors.d.ts +26 -0
  48. package/dist/errors.js +83 -1
  49. package/dist/index.d.ts +1 -1
  50. package/dist/index.js +1 -1
  51. package/dist/nested-write.d.ts +12 -2
  52. package/dist/nested-write.js +4 -10
  53. package/dist/pipeline.js +13 -8
  54. package/dist/plan-flip-probe.js +4 -0
  55. package/dist/powdb-shared.d.ts +22 -2
  56. package/dist/powdb-shared.js +25 -2
  57. package/dist/powdb.js +23 -24
  58. package/dist/powql.d.ts +51 -6
  59. package/dist/powql.js +200 -46
  60. package/dist/prisma-compat.js +28 -4
  61. package/dist/query/builder.d.ts +44 -24
  62. package/dist/query/builder.js +126 -67
  63. package/dist/query/deferred.d.ts +9 -0
  64. package/dist/query/option-surface.js +12 -0
  65. package/dist/query/types.d.ts +68 -4
  66. package/dist/query/writes.d.ts +39 -9
  67. package/dist/query/writes.js +71 -34
  68. package/dist/realtime.d.ts +46 -2
  69. package/dist/realtime.js +125 -20
  70. package/dist/schema-sql.js +6 -0
  71. package/package.json +5 -3
@@ -29,6 +29,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
29
29
  exports.TurbineClient = exports.TransactionClient = exports.READ_OPERATIONS = void 0;
30
30
  exports.withRetry = withRetry;
31
31
  const _pg_1 = __importDefault(require("pg"));
32
+ const checkout_js_1 = require("./checkout.js");
33
+ const connection_guard_js_1 = require("./connection-guard.js");
32
34
  const connection_url_js_1 = require("./connection-url.js");
33
35
  const dialect_js_1 = require("./dialect.js");
34
36
  const errors_js_1 = require("./errors.js");
@@ -363,29 +365,6 @@ const ISOLATION_LEVELS = Object.assign(Object.create(null), {
363
365
  * loops, and a loop keyed on it would never fire for the commit-time conflicts
364
366
  * that are the main reason to run SERIALIZABLE at all.
365
367
  */
366
- /**
367
- * Check out a pooled connection, translating a driver failure into a typed
368
- * Turbine error.
369
- *
370
- * `pool.connect()` is where the first-run failures actually land: wrong
371
- * password (SQLSTATE 28P01), no such database (3D000), nothing listening
372
- * (ECONNREFUSED), an unverifiable TLS certificate. Unwrapped, every one of
373
- * those left `$transaction`, `transaction()` and `connect()` as a raw pg
374
- * `DatabaseError` carrying a SQLSTATE in `.code`, the same property Turbine
375
- * puts `TURBINE_E0NN` in, so the single error a new user is most likely to see
376
- * was the one error the typed-error contract did not cover.
377
- *
378
- * Query paths need no equivalent: `pool.query()` opens the connection itself
379
- * and rejects with the connect error, which the query boundary already wraps.
380
- */
381
- async function acquireConnection(pool) {
382
- try {
383
- return await pool.connect();
384
- }
385
- catch (err) {
386
- throw (0, errors_js_1.wrapPgError)(err);
387
- }
388
- }
389
368
  async function runTxControl(client, sql) {
390
369
  try {
391
370
  await client.query(sql);
@@ -982,6 +961,11 @@ class TurbineClient {
982
961
  ownPool.on('error', (err) => {
983
962
  console.error('[turbine] Unexpected pool error:', err.message);
984
963
  });
964
+ (0, connection_guard_js_1.absorbCheckedOutErrors)(ownPool);
965
+ // After a freeze (a serverless function between invocations), wait one
966
+ // loop turn before reusing a long-idle connection, so the pool has read
967
+ // any close the server sent meanwhile. See connection-guard.ts.
968
+ (0, connection_guard_js_1.settleLongIdleCheckouts)(ownPool);
985
969
  this.pool = ownPool;
986
970
  this.ownsPool = true;
987
971
  if (this.logging) {
@@ -1005,6 +989,8 @@ class TurbineClient {
1005
989
  replicaPool.on('error', (err) => {
1006
990
  console.error('[turbine] Unexpected replica pool error:', err.message);
1007
991
  });
992
+ (0, connection_guard_js_1.absorbCheckedOutErrors)(replicaPool);
993
+ (0, connection_guard_js_1.settleLongIdleCheckouts)(replicaPool);
1008
994
  this.replicaPools.push(replicaPool);
1009
995
  this.ownedReplicaPools.push(replicaPool);
1010
996
  }
@@ -1708,19 +1694,15 @@ class TurbineClient {
1708
1694
  * ```
1709
1695
  */
1710
1696
  async transaction(fn) {
1711
- const client = await acquireConnection(this.pool);
1712
- /**
1713
- * Only true once BEGIN has actually succeeded. If BEGIN itself throws
1714
- * (e.g. a single-writer engine's transaction gate times out or rejects a
1715
- * re-entrant begin), issuing a "best-effort" ROLLBACK would be a stray
1716
- * statement from a context that never opened a transaction, on a driver
1717
- * with one shared engine handle (PowDB embedded) it would roll back a
1718
- * DIFFERENT caller's open transaction.
1719
- */
1720
- let began = false;
1697
+ // BEGIN runs inside openCheckout, which sends it once more on a fresh
1698
+ // connection when the first one turns out to be dead. A BEGIN that fails
1699
+ // for good throws from there with the connection already released, so the
1700
+ // catch below only ever sees a transaction that began: its ROLLBACK can
1701
+ // never be a stray statement from a context that opened none, which on a
1702
+ // driver with one shared engine handle (PowDB embedded) would roll back a
1703
+ // DIFFERENT caller's open transaction.
1704
+ const { client, checkout } = await (0, checkout_js_1.openCheckout)(this.pool, (c) => runTxControl(c, this.dialect.beginStatement()));
1721
1705
  try {
1722
- await runTxControl(client, this.dialect.beginStatement());
1723
- began = true;
1724
1706
  // Engine seam: single-writer engines scope their transaction re-entrancy
1725
1707
  // marker to the callback's async subtree (see
1726
1708
  // PgCompatPoolClient.wrapTransactionCallback). Absent everywhere else.
@@ -1731,18 +1713,16 @@ class TurbineClient {
1731
1713
  return result;
1732
1714
  }
1733
1715
  catch (err) {
1734
- if (began) {
1735
- try {
1736
- await client.query(this.dialect.rollbackStatement());
1737
- }
1738
- catch {
1739
- // Best-effort rollback, the connection may have died mid-query.
1740
- }
1716
+ try {
1717
+ await client.query(this.dialect.rollbackStatement());
1741
1718
  }
1742
- throw err;
1719
+ catch {
1720
+ // Best-effort rollback, the connection may have died mid-query.
1721
+ }
1722
+ throw (0, errors_js_1.explainConnectionLoss)(err, checkout.lostWith);
1743
1723
  }
1744
1724
  finally {
1745
- client.release();
1725
+ checkout.release();
1746
1726
  }
1747
1727
  }
1748
1728
  async $transaction(fnOrQueries, options) {
@@ -1761,7 +1741,13 @@ class TurbineClient {
1761
1741
  // Resolve the isolation level BEFORE taking a pool slot: a bad argument is
1762
1742
  // the caller's bug and should not cost a connection to discover.
1763
1743
  const isolationSql = resolveIsolationLevel(options?.isolationLevel);
1764
- const client = await acquireConnection(this.pool);
1744
+ // BEGIN with optional isolation level, the dialect owns the keyword and
1745
+ // BEGIN+isolation composition (Postgres appends ` ISOLATION LEVEL …`). It
1746
+ // runs inside openCheckout, which sends it once more on a fresh connection
1747
+ // when the first one turns out to be dead, and which releases the
1748
+ // connection itself when BEGIN fails for good. So everything below runs in
1749
+ // a transaction that began.
1750
+ const { client, checkout } = await (0, checkout_js_1.openCheckout)(this.pool, (c) => runTxControl(c, this.dialect.beginStatement(isolationSql)));
1765
1751
  const timeout = options?.timeout;
1766
1752
  /**
1767
1753
  * Track whether the connection has already been released so the finally
@@ -1774,27 +1760,14 @@ class TurbineClient {
1774
1760
  return;
1775
1761
  released = true;
1776
1762
  try {
1777
- client.release(err);
1763
+ checkout.release(err);
1778
1764
  }
1779
1765
  catch {
1780
1766
  // pg may throw if the client is already released, swallow.
1781
1767
  }
1782
1768
  };
1783
1769
  let timedOut = false;
1784
- /**
1785
- * Only true once BEGIN has actually succeeded. If BEGIN itself throws -
1786
- * e.g. a single-writer engine's transaction gate times out in its FIFO
1787
- * queue or rejects a re-entrant begin (PowDB, E002/E017), this context
1788
- * never opened a transaction, so the catch below must NOT issue its
1789
- * best-effort ROLLBACK: on a driver with one shared engine handle that
1790
- * stray ROLLBACK would tear down a DIFFERENT caller's open transaction.
1791
- */
1792
- let began = false;
1793
1770
  try {
1794
- // BEGIN with optional isolation level, the dialect owns the keyword and
1795
- // BEGIN+isolation composition (Postgres appends ` ISOLATION LEVEL …`).
1796
- await runTxControl(client, this.dialect.beginStatement(isolationSql));
1797
- began = true;
1798
1771
  // Apply transaction-local session context (RLS / multi-tenant GUCs).
1799
1772
  // Order matters: BEGIN -> isolation level (above) -> set_config loop ->
1800
1773
  // user fn. Any error here propagates to the catch below and rolls back
@@ -1875,11 +1848,9 @@ class TurbineClient {
1875
1848
  // If the timeout fired we already destroyed the connection, issuing a
1876
1849
  // ROLLBACK on a released client would throw "Client has already been
1877
1850
  // released". Skip the rollback in that case (the backend rolled back
1878
- // when its socket was closed). Likewise skip it when BEGIN never
1879
- // succeeded (`began` false), there is no transaction to roll back and
1880
- // the stray statement could hit another caller's transaction on a
1881
- // shared-handle engine.
1882
- if (began && !timedOut && !released) {
1851
+ // when its socket was closed). A BEGIN that failed never gets here, see
1852
+ // openCheckout above, so there is always a transaction to roll back.
1853
+ if (!timedOut && !released) {
1883
1854
  try {
1884
1855
  await client.query(this.dialect.rollbackStatement());
1885
1856
  }
@@ -1890,7 +1861,7 @@ class TurbineClient {
1890
1861
  if (this.logging) {
1891
1862
  console.log('[turbine] Transaction rolled back');
1892
1863
  }
1893
- throw err;
1864
+ throw (0, errors_js_1.explainConnectionLoss)(err, checkout.lostWith);
1894
1865
  }
1895
1866
  finally {
1896
1867
  releaseOnce();
@@ -2001,17 +1972,27 @@ class TurbineClient {
2001
1972
  * cannot do this, `$listen` throws a `ConnectionError` rather than hang.
2002
1973
  * `$notify` works on every driver.
2003
1974
  *
1975
+ * **Connection loss:** if the subscription's connection dies (a restart,
1976
+ * failover, compute suspend, `pg_terminate_backend`), it reconnects with
1977
+ * exponential backoff and re-issues `LISTEN`; `options.reconnect: false`
1978
+ * ends it instead. Postgres does not hold notifications for a disconnected
1979
+ * listener, so anything sent during the gap is lost: use
1980
+ * `options.onReconnect` to resynchronise. `options.onError` receives the loss
1981
+ * and each failed attempt (default: one `console.error` line each).
1982
+ *
2004
1983
  * @example
2005
1984
  * ```ts
2006
1985
  * const sub = await db.$listen('order_created', (payload) => {
2007
1986
  * const order = JSON.parse(payload);
2008
1987
  * console.log('new order', order.id);
1988
+ * }, {
1989
+ * onReconnect: () => refreshOrdersFromDatabase(),
2009
1990
  * });
2010
1991
  * // ...later
2011
1992
  * await sub.unsubscribe();
2012
1993
  * ```
2013
1994
  */
2014
- async $listen(channel, handler) {
1995
+ async $listen(channel, handler, options) {
2015
1996
  if (!this.dialect.supportsListenNotify) {
2016
1997
  throw new errors_js_1.UnsupportedFeatureError('$listen (LISTEN/NOTIFY realtime)', this.dialect.name, 'Realtime pub/sub requires PostgreSQL.');
2017
1998
  }
@@ -2022,7 +2003,7 @@ class TurbineClient {
2022
2003
  }
2023
2004
  const sub = await (0, realtime_js_1.createSubscription)(this.pool, channel, quoted, handler, (closed) => {
2024
2005
  this.activeSubscriptions.delete(closed);
2025
- });
2006
+ }, options);
2026
2007
  this.activeSubscriptions.add(sub);
2027
2008
  return sub;
2028
2009
  }
@@ -2083,15 +2064,20 @@ class TurbineClient {
2083
2064
  * Throws if the connection fails.
2084
2065
  */
2085
2066
  async connect() {
2086
- const client = await acquireConnection(this.pool);
2087
- try {
2088
- await client.query('SELECT 1');
2089
- if (this.logging) {
2090
- console.log('[turbine] Connection verified');
2067
+ // A connection the server closed while idle says nothing about whether
2068
+ // the database is reachable, so the check runs on a fresh one when the
2069
+ // first turns out to be dead (see openCheckout).
2070
+ const { checkout } = await (0, checkout_js_1.openCheckout)(this.pool, async (client) => {
2071
+ try {
2072
+ await client.query('SELECT 1');
2091
2073
  }
2092
- }
2093
- finally {
2094
- client.release();
2074
+ catch (err) {
2075
+ throw (0, errors_js_1.wrapPgError)(err);
2076
+ }
2077
+ });
2078
+ checkout.release();
2079
+ if (this.logging) {
2080
+ console.log('[turbine] Connection verified');
2095
2081
  }
2096
2082
  }
2097
2083
  /**
@@ -0,0 +1,120 @@
1
+ /**
2
+ * turbine-orm, connection error guard
3
+ *
4
+ * A pg client emits `'error'` when its socket dies: a database restart, a
5
+ * failover, a serverless compute suspend, `pg_terminate_backend`. pg-pool keeps
6
+ * a listener on every IDLE client and removes it at checkout, so for as long as
7
+ * a client is checked out nobody is listening unless the borrower is, and an
8
+ * `'error'` event with no listener is thrown by Node's EventEmitter. The
9
+ * process exits. Every place Turbine holds a connection across an await
10
+ * (`$transaction`, `transaction()`, nested writes, cursor streams, pipelines,
11
+ * `$listen`, Studio and MCP requests) was one database restart away from taking
12
+ * the application down with it.
13
+ *
14
+ * The fix is a listener for exactly the checkout window: attached right after
15
+ * `pool.connect()` resolves (a promise continuation, so no socket event can
16
+ * slip in before it), removed right after `release()`, which is where pg-pool
17
+ * re-attaches its own. The listener does not need to reject anything itself:
18
+ * pg already fails the in-flight query and every queued one, and any later
19
+ * query on the dead client rejects with "not queryable", so the pending call
20
+ * always rejects on its own. What was missing was only the listener that stops
21
+ * the event from being fatal. It also records the first error, for two reasons:
22
+ * `release()` passes it on so the pool destroys the connection instead of
23
+ * lending it out again, and callers can report the ORIGINAL cause (say, 57P01
24
+ * `terminating connection due to administrator command`) rather than the
25
+ * follow-on "Client has encountered a connection error and is not queryable".
26
+ *
27
+ * ZERO imports, same reason as connection-url.ts: `query/`, `cli/` and
28
+ * client.ts all check connections out, and a leaf is the only place all three
29
+ * can share without a new edge in the import graph.
30
+ */
31
+ /** Anything checked out of a pool: a `release()` plus, on pg, the emitter surface. */
32
+ interface Releasable {
33
+ release(err?: Error | boolean): void;
34
+ }
35
+ export interface ConnectionGuard {
36
+ /** The first error the connection emitted while guarded, if it emitted one. */
37
+ readonly lostWith: Error | undefined;
38
+ /** Remove the listener. Idempotent. For clients Turbine owns outright (a bare `pg.Client`). */
39
+ detach(): void;
40
+ }
41
+ export interface CheckoutGuard extends ConnectionGuard {
42
+ /**
43
+ * Hand the client back to its pool and remove the listener. Idempotent. A
44
+ * connection that errored while checked out is released WITH that error, so
45
+ * pg-pool destroys it rather than returning it to the idle set; an explicit
46
+ * `err` argument takes precedence.
47
+ */
48
+ release(err?: Error | boolean): void;
49
+ }
50
+ /**
51
+ * Keep every connection a pool opens from ever emitting an unheard `'error'`.
52
+ * For pools Turbine creates itself, where it can: pg-pool listens on IDLE
53
+ * clients only, so a checkout made outside Turbine's own guarded paths
54
+ * (`db.pool.connect()` in application code, a CLI server's request handler) is
55
+ * otherwise one database restart from exiting the process. The listener
56
+ * absorbs nothing that matters: the borrower's pending query still rejects,
57
+ * and pg-pool still evicts a client that failed. A pool with no `connect`
58
+ * event (an engine shim) is left alone.
59
+ */
60
+ export declare function absorbCheckedOutErrors(pool: unknown): void;
61
+ /**
62
+ * Guard a connection for as long as the caller holds it. Use {@link guardCheckout}
63
+ * for a pooled checkout; this form is for a client whose whole lifetime the
64
+ * caller owns (`new pg.Client()` in a CLI command or `schemaPush`).
65
+ *
66
+ * `onLost` runs once, on the first error, for a holder that has no query in
67
+ * flight to learn about the loss from (a LISTEN connection waiting for
68
+ * notifications).
69
+ */
70
+ export declare function guardConnection(client: unknown, onLost?: (err: Error) => void): ConnectionGuard;
71
+ /** Guard a pooled checkout from `pool.connect()` until its `release()`. */
72
+ export declare function guardCheckout(client: Releasable, onLost?: (err: Error) => void): CheckoutGuard;
73
+ /**
74
+ * Resolve after the event loop has run at least one full poll phase.
75
+ *
76
+ * Two `setImmediate` hops, not one: a continuation that starts in the poll
77
+ * phase would reach its first hop in the SAME iteration's check phase, before
78
+ * any socket event that arrived meanwhile has been read. The second hop is
79
+ * queued for the next iteration, which polls first. `setTimeout` stands in on
80
+ * runtimes without `setImmediate` (edge), where one timer turn also polls.
81
+ */
82
+ export declare function settleEventLoop(): Promise<void>;
83
+ /**
84
+ * How long a pool must have gone without a connection coming back before the
85
+ * next checkout waits for {@link settleEventLoop}. Long enough that a pool in
86
+ * steady use never pays it; short enough to cover a serverless freeze between
87
+ * invocations, which is where dead idle connections come from.
88
+ */
89
+ export declare const LONG_IDLE_SETTLE_MS = 1000;
90
+ /**
91
+ * Stop a pool from lending out a connection the server has already closed.
92
+ *
93
+ * pg-pool evicts an idle connection when its socket reports the close, which
94
+ * needs the event loop to read the socket. When the loop has NOT run (a
95
+ * serverless function frozen between invocations, a long synchronous block),
96
+ * a restart, failover, pooler recycle or compute suspend in that window leaves
97
+ * every idle connection dead but still in the idle list. The first checkout
98
+ * after the thaw runs before the loop polls, so it gets one, and so does every
99
+ * concurrent caller: measured on PostgreSQL 17 with the loop blocked across a
100
+ * `pg_terminate_backend` of all five idle connections, five concurrent queries
101
+ * all failed with 57P01. Awake, the same terminate costs nothing, because the
102
+ * closes are read as they arrive.
103
+ *
104
+ * So a checkout that would reuse a connection idle for at least
105
+ * {@link LONG_IDLE_SETTLE_MS} first waits for one poll phase. Every close that
106
+ * arrived during the freeze is read then, pg-pool evicts those connections,
107
+ * and the checkout gets a live one or opens a fresh one. No round trip: a
108
+ * `SELECT 1` validation would cost one on every such checkout, and the loop
109
+ * turn is what actually carries the information. A pool in steady use returns
110
+ * a connection far more often than once a second and never pays it.
111
+ *
112
+ * Idle age is taken from the last clean `'release'`: pg-pool reuses the most
113
+ * recently returned connection first, so that is the age of the one the next
114
+ * checkout gets. Wrapping `connect` covers `pool.query` too, which checks out
115
+ * through `this.connect`. Pools without pg-pool's `'release'` event (engine
116
+ * shims, HTTP pools) are left alone. For pools Turbine creates only: a
117
+ * caller's own pool is not Turbine's to patch.
118
+ */
119
+ export declare function settleLongIdleCheckouts(pool: unknown, idleMs?: number): void;
120
+ export {};
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm, connection error guard
4
+ *
5
+ * A pg client emits `'error'` when its socket dies: a database restart, a
6
+ * failover, a serverless compute suspend, `pg_terminate_backend`. pg-pool keeps
7
+ * a listener on every IDLE client and removes it at checkout, so for as long as
8
+ * a client is checked out nobody is listening unless the borrower is, and an
9
+ * `'error'` event with no listener is thrown by Node's EventEmitter. The
10
+ * process exits. Every place Turbine holds a connection across an await
11
+ * (`$transaction`, `transaction()`, nested writes, cursor streams, pipelines,
12
+ * `$listen`, Studio and MCP requests) was one database restart away from taking
13
+ * the application down with it.
14
+ *
15
+ * The fix is a listener for exactly the checkout window: attached right after
16
+ * `pool.connect()` resolves (a promise continuation, so no socket event can
17
+ * slip in before it), removed right after `release()`, which is where pg-pool
18
+ * re-attaches its own. The listener does not need to reject anything itself:
19
+ * pg already fails the in-flight query and every queued one, and any later
20
+ * query on the dead client rejects with "not queryable", so the pending call
21
+ * always rejects on its own. What was missing was only the listener that stops
22
+ * the event from being fatal. It also records the first error, for two reasons:
23
+ * `release()` passes it on so the pool destroys the connection instead of
24
+ * lending it out again, and callers can report the ORIGINAL cause (say, 57P01
25
+ * `terminating connection due to administrator command`) rather than the
26
+ * follow-on "Client has encountered a connection error and is not queryable".
27
+ *
28
+ * ZERO imports, same reason as connection-url.ts: `query/`, `cli/` and
29
+ * client.ts all check connections out, and a leaf is the only place all three
30
+ * can share without a new edge in the import graph.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.LONG_IDLE_SETTLE_MS = void 0;
34
+ exports.absorbCheckedOutErrors = absorbCheckedOutErrors;
35
+ exports.guardConnection = guardConnection;
36
+ exports.guardCheckout = guardCheckout;
37
+ exports.settleEventLoop = settleEventLoop;
38
+ exports.settleLongIdleCheckouts = settleLongIdleCheckouts;
39
+ /**
40
+ * Keep every connection a pool opens from ever emitting an unheard `'error'`.
41
+ * For pools Turbine creates itself, where it can: pg-pool listens on IDLE
42
+ * clients only, so a checkout made outside Turbine's own guarded paths
43
+ * (`db.pool.connect()` in application code, a CLI server's request handler) is
44
+ * otherwise one database restart from exiting the process. The listener
45
+ * absorbs nothing that matters: the borrower's pending query still rejects,
46
+ * and pg-pool still evicts a client that failed. A pool with no `connect`
47
+ * event (an engine shim) is left alone.
48
+ */
49
+ function absorbCheckedOutErrors(pool) {
50
+ const emitter = pool;
51
+ if (typeof emitter.on !== 'function')
52
+ return;
53
+ emitter.on('connect', (client) => {
54
+ client.on?.('error', () => { });
55
+ });
56
+ }
57
+ /**
58
+ * Guard a connection for as long as the caller holds it. Use {@link guardCheckout}
59
+ * for a pooled checkout; this form is for a client whose whole lifetime the
60
+ * caller owns (`new pg.Client()` in a CLI command or `schemaPush`).
61
+ *
62
+ * `onLost` runs once, on the first error, for a holder that has no query in
63
+ * flight to learn about the loss from (a LISTEN connection waiting for
64
+ * notifications).
65
+ */
66
+ function guardConnection(client, onLost) {
67
+ const emitter = client;
68
+ let lostWith;
69
+ let attached = false;
70
+ const onError = (err) => {
71
+ if (lostWith)
72
+ return;
73
+ lostWith = err;
74
+ onLost?.(err);
75
+ };
76
+ if (typeof emitter.on === 'function') {
77
+ emitter.on('error', onError);
78
+ attached = true;
79
+ }
80
+ return {
81
+ get lostWith() {
82
+ return lostWith;
83
+ },
84
+ detach() {
85
+ if (!attached)
86
+ return;
87
+ attached = false;
88
+ emitter.removeListener?.('error', onError);
89
+ },
90
+ };
91
+ }
92
+ /** Guard a pooled checkout from `pool.connect()` until its `release()`. */
93
+ function guardCheckout(client, onLost) {
94
+ const guard = guardConnection(client, onLost);
95
+ let released = false;
96
+ return {
97
+ get lostWith() {
98
+ return guard.lostWith;
99
+ },
100
+ detach: guard.detach,
101
+ release(err) {
102
+ if (released)
103
+ return;
104
+ released = true;
105
+ try {
106
+ client.release(err ?? guard.lostWith);
107
+ }
108
+ finally {
109
+ // After release, not before: pg-pool re-attaches its idle listener
110
+ // inside release(), so detaching second leaves no window with none.
111
+ // A connection that already failed keeps the listener: it is being
112
+ // destroyed, pg emits 'error' a second time when the socket finally
113
+ // ends, and a pool that does not re-listen on release would let that
114
+ // one through. A live connection must shed it, or listeners pile up
115
+ // across checkouts.
116
+ if (!guard.lostWith)
117
+ guard.detach();
118
+ }
119
+ },
120
+ };
121
+ }
122
+ /**
123
+ * Resolve after the event loop has run at least one full poll phase.
124
+ *
125
+ * Two `setImmediate` hops, not one: a continuation that starts in the poll
126
+ * phase would reach its first hop in the SAME iteration's check phase, before
127
+ * any socket event that arrived meanwhile has been read. The second hop is
128
+ * queued for the next iteration, which polls first. `setTimeout` stands in on
129
+ * runtimes without `setImmediate` (edge), where one timer turn also polls.
130
+ */
131
+ function settleEventLoop() {
132
+ const hop = typeof setImmediate === 'function' ? setImmediate : (fn) => setTimeout(fn, 0);
133
+ return new Promise((resolve) => {
134
+ hop(() => hop(resolve));
135
+ });
136
+ }
137
+ /**
138
+ * How long a pool must have gone without a connection coming back before the
139
+ * next checkout waits for {@link settleEventLoop}. Long enough that a pool in
140
+ * steady use never pays it; short enough to cover a serverless freeze between
141
+ * invocations, which is where dead idle connections come from.
142
+ */
143
+ exports.LONG_IDLE_SETTLE_MS = 1000;
144
+ /**
145
+ * Stop a pool from lending out a connection the server has already closed.
146
+ *
147
+ * pg-pool evicts an idle connection when its socket reports the close, which
148
+ * needs the event loop to read the socket. When the loop has NOT run (a
149
+ * serverless function frozen between invocations, a long synchronous block),
150
+ * a restart, failover, pooler recycle or compute suspend in that window leaves
151
+ * every idle connection dead but still in the idle list. The first checkout
152
+ * after the thaw runs before the loop polls, so it gets one, and so does every
153
+ * concurrent caller: measured on PostgreSQL 17 with the loop blocked across a
154
+ * `pg_terminate_backend` of all five idle connections, five concurrent queries
155
+ * all failed with 57P01. Awake, the same terminate costs nothing, because the
156
+ * closes are read as they arrive.
157
+ *
158
+ * So a checkout that would reuse a connection idle for at least
159
+ * {@link LONG_IDLE_SETTLE_MS} first waits for one poll phase. Every close that
160
+ * arrived during the freeze is read then, pg-pool evicts those connections,
161
+ * and the checkout gets a live one or opens a fresh one. No round trip: a
162
+ * `SELECT 1` validation would cost one on every such checkout, and the loop
163
+ * turn is what actually carries the information. A pool in steady use returns
164
+ * a connection far more often than once a second and never pays it.
165
+ *
166
+ * Idle age is taken from the last clean `'release'`: pg-pool reuses the most
167
+ * recently returned connection first, so that is the age of the one the next
168
+ * checkout gets. Wrapping `connect` covers `pool.query` too, which checks out
169
+ * through `this.connect`. Pools without pg-pool's `'release'` event (engine
170
+ * shims, HTTP pools) are left alone. For pools Turbine creates only: a
171
+ * caller's own pool is not Turbine's to patch.
172
+ */
173
+ function settleLongIdleCheckouts(pool, idleMs = exports.LONG_IDLE_SETTLE_MS) {
174
+ const p = pool;
175
+ const connect = p.connect;
176
+ if (typeof p.on !== 'function' || typeof connect !== 'function')
177
+ return;
178
+ let lastReturnedAt = performance.now();
179
+ p.on('release', (err) => {
180
+ if (!err)
181
+ lastReturnedAt = performance.now();
182
+ });
183
+ p.connect = (...args) => {
184
+ if (!((p.idleCount ?? 0) > 0) || performance.now() - lastReturnedAt < idleMs)
185
+ return connect.apply(p, args);
186
+ const settled = settleEventLoop().then(() => connect.apply(p, args));
187
+ // Callback form (pool.query's internal checkout): the callback carries the
188
+ // result, and pg-pool returns nothing in that form either.
189
+ return typeof args[0] === 'function' ? undefined : settled;
190
+ };
191
+ }
@@ -556,6 +556,32 @@ export declare class ReadOnlyError extends TurbineError {
556
556
  reason?: 'snapshot' | 'rbac';
557
557
  });
558
558
  }
559
+ /**
560
+ * Report the error that KILLED a held connection instead of its follow-on.
561
+ *
562
+ * When a connection dies while a transaction callback is between queries, the
563
+ * next query fails with "Client has encountered a connection error and is not
564
+ * queryable", which says nothing about why. The connection guard recorded the
565
+ * real cause (`lostWith`, e.g. 57P01 `terminating connection due to
566
+ * administrator command`), so a not-queryable ConnectionError is swapped for
567
+ * that one. Anything else passes through: an error the caller threw, or a
568
+ * query that was IN FLIGHT when the connection died, which already carries the
569
+ * server's own message.
570
+ */
571
+ export declare function explainConnectionLoss(err: unknown, lostWith: Error | undefined): unknown;
572
+ /**
573
+ * Whether `err` says a connection that was already open is gone, so the same
574
+ * statement on a fresh connection can succeed. Accepts the raw driver error or
575
+ * the {@link ConnectionError} wrapPgError made of it.
576
+ *
577
+ * Narrower than "is a ConnectionError" on purpose. A connection that could not
578
+ * be OPENED (refused, DNS, auth, a connect timeout, 57P03 while the server
579
+ * starts) will not open on an immediate second try either, and retrying it
580
+ * only doubles the wait before the caller hears about it. Nor does it cover
581
+ * `Connection terminated` without "unexpectedly", which is the pool itself
582
+ * shutting down.
583
+ */
584
+ export declare function isStaleConnectionError(err: unknown): boolean;
559
585
  /**
560
586
  * Translate a pg driver error into a typed Turbine error.
561
587
  *