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.
- package/README.md +4 -4
- package/dist/checkout.d.ts +53 -0
- package/dist/checkout.js +78 -0
- package/dist/cjs/checkout.d.ts +53 -0
- package/dist/cjs/checkout.js +82 -0
- package/dist/cjs/cli/index.js +4 -0
- package/dist/cjs/cli/mcp.js +4 -0
- package/dist/cjs/cli/migrate.js +6 -0
- package/dist/cjs/cli/observe.js +8 -0
- package/dist/cjs/cli/studio.js +13 -1
- package/dist/cjs/client.d.ts +12 -2
- package/dist/cjs/client.js +61 -75
- package/dist/cjs/connection-guard.d.ts +120 -0
- package/dist/cjs/connection-guard.js +191 -0
- package/dist/cjs/errors.d.ts +26 -0
- package/dist/cjs/errors.js +85 -1
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/nested-write.d.ts +12 -2
- package/dist/cjs/nested-write.js +4 -10
- package/dist/cjs/pipeline.js +12 -7
- package/dist/cjs/plan-flip-probe.js +4 -0
- package/dist/cjs/powdb-shared.d.ts +22 -2
- package/dist/cjs/powdb-shared.js +27 -2
- package/dist/cjs/powdb.js +36 -37
- package/dist/cjs/powql.d.ts +51 -6
- package/dist/cjs/powql.js +199 -45
- package/dist/cjs/prisma-compat.js +28 -4
- package/dist/cjs/query/builder.d.ts +44 -24
- package/dist/cjs/query/builder.js +125 -66
- package/dist/cjs/query/deferred.d.ts +9 -0
- package/dist/cjs/query/option-surface.js +12 -0
- package/dist/cjs/query/types.d.ts +68 -4
- package/dist/cjs/query/writes.d.ts +39 -9
- package/dist/cjs/query/writes.js +72 -34
- package/dist/cjs/realtime.d.ts +46 -2
- package/dist/cjs/realtime.js +125 -20
- package/dist/cjs/schema-sql.js +6 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/mcp.js +4 -0
- package/dist/cli/migrate.js +6 -0
- package/dist/cli/observe.js +8 -0
- package/dist/cli/studio.js +13 -1
- package/dist/client.d.ts +12 -2
- package/dist/client.js +62 -76
- package/dist/connection-guard.d.ts +120 -0
- package/dist/connection-guard.js +183 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.js +83 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/nested-write.d.ts +12 -2
- package/dist/nested-write.js +4 -10
- package/dist/pipeline.js +13 -8
- package/dist/plan-flip-probe.js +4 -0
- package/dist/powdb-shared.d.ts +22 -2
- package/dist/powdb-shared.js +25 -2
- package/dist/powdb.js +23 -24
- package/dist/powql.d.ts +51 -6
- package/dist/powql.js +200 -46
- package/dist/prisma-compat.js +28 -4
- package/dist/query/builder.d.ts +44 -24
- package/dist/query/builder.js +126 -67
- package/dist/query/deferred.d.ts +9 -0
- package/dist/query/option-surface.js +12 -0
- package/dist/query/types.d.ts +68 -4
- package/dist/query/writes.d.ts +39 -9
- package/dist/query/writes.js +71 -34
- package/dist/realtime.d.ts +46 -2
- package/dist/realtime.js +125 -20
- package/dist/schema-sql.js +6 -0
- package/package.json +5 -3
package/dist/cjs/client.js
CHANGED
|
@@ -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
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
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
|
-
|
|
1735
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
1879
|
-
//
|
|
1880
|
-
|
|
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
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
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
|
-
|
|
2094
|
-
|
|
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
|
+
}
|
package/dist/cjs/errors.d.ts
CHANGED
|
@@ -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
|
*
|