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/realtime.js
CHANGED
|
@@ -25,10 +25,23 @@
|
|
|
25
25
|
* (Neon HTTP, Vercel Postgres over fetch) cannot hold such a connection, so
|
|
26
26
|
* `$listen` will surface a clear error rather than hang. `$notify` works
|
|
27
27
|
* everywhere, it's a single round-trip `SELECT pg_notify(...)`.
|
|
28
|
+
*
|
|
29
|
+
* Connection loss:
|
|
30
|
+
*
|
|
31
|
+
* A subscription's connection is held indefinitely with no query in flight,
|
|
32
|
+
* so a database restart, failover or `pg_terminate_backend` reaches it only
|
|
33
|
+
* as an `'error'` event. That event used to have no listener, which exits the
|
|
34
|
+
* process. It is now guarded (connection-guard.ts), and the subscription
|
|
35
|
+
* RECONNECTS by default: the dead connection is destroyed, a fresh one is
|
|
36
|
+
* checked out with exponential backoff, and `LISTEN` is re-issued. Postgres
|
|
37
|
+
* does not queue notifications for a listener that is not connected, so
|
|
38
|
+
* anything NOTIFYed during the gap is gone for good; `onReconnect` is the
|
|
39
|
+
* caller's cue to resynchronise from the source of truth.
|
|
28
40
|
*/
|
|
29
41
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
42
|
exports.validateChannel = validateChannel;
|
|
31
43
|
exports.createSubscription = createSubscription;
|
|
44
|
+
const connection_guard_js_1 = require("./connection-guard.js");
|
|
32
45
|
const errors_js_1 = require("./errors.js");
|
|
33
46
|
// ---------------------------------------------------------------------------
|
|
34
47
|
// Identifier validation
|
|
@@ -62,17 +75,12 @@ function validateChannel(channel) {
|
|
|
62
75
|
'(letters, digits, underscores; cannot start with a digit)');
|
|
63
76
|
}
|
|
64
77
|
}
|
|
78
|
+
const DEFAULT_RECONNECT = { initialDelayMs: 100, maxDelayMs: 30_000 };
|
|
65
79
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* @param pool the pg-compatible pool to check a long-lived client out of
|
|
69
|
-
* @param channel channel name, MUST already be validated by the caller
|
|
70
|
-
* @param quotedChannel the channel run through quoteIdent (interpolated into SQL)
|
|
71
|
-
* @param handler called with each notification's payload
|
|
72
|
-
* @param onClosed invoked when the subscription releases, so the client can
|
|
73
|
-
* drop it from its active-subscription registry
|
|
80
|
+
* Check out a dedicated connection, wire the handler and run `LISTEN`. Throws
|
|
81
|
+
* (typed) on failure, having given the connection back.
|
|
74
82
|
*/
|
|
75
|
-
async function
|
|
83
|
+
async function openListenConnection(pool, channel, quotedChannel, handler, onLost) {
|
|
76
84
|
let client;
|
|
77
85
|
try {
|
|
78
86
|
client = (await pool.connect());
|
|
@@ -85,11 +93,12 @@ async function createSubscription(pool, channel, quotedChannel, handler, onClose
|
|
|
85
93
|
// forever waiting for messages that can never arrive, so fail loudly now and
|
|
86
94
|
// give the connection straight back.
|
|
87
95
|
if (typeof client.on !== 'function') {
|
|
88
|
-
client.release
|
|
96
|
+
client.release();
|
|
89
97
|
throw new errors_js_1.ConnectionError('$listen requires a persistent connection that can push notifications. ' +
|
|
90
98
|
'The configured pool returned a client with no event support (stateless HTTP drivers ' +
|
|
91
99
|
'like Neon HTTP / Vercel Postgres cannot LISTEN). Use a TCP pg.Pool for LISTEN/NOTIFY.');
|
|
92
100
|
}
|
|
101
|
+
const checkout = (0, connection_guard_js_1.guardCheckout)(client, onLost);
|
|
93
102
|
const onNotification = (msg) => {
|
|
94
103
|
// pg delivers ALL notifications for the connection to every listener; a
|
|
95
104
|
// dedicated connection only ever LISTENs on one channel, but guard anyway.
|
|
@@ -103,43 +112,139 @@ async function createSubscription(pool, channel, quotedChannel, handler, onClose
|
|
|
103
112
|
}
|
|
104
113
|
catch (err) {
|
|
105
114
|
client.removeListener?.('notification', onNotification);
|
|
106
|
-
|
|
115
|
+
checkout.release(err instanceof Error ? err : true);
|
|
107
116
|
throw (0, errors_js_1.wrapPgError)(err);
|
|
108
117
|
}
|
|
118
|
+
return { client, checkout, onNotification };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Acquire a dedicated connection, run `LISTEN "channel"`, and wire the handler.
|
|
122
|
+
* A failure here is thrown to the `$listen` caller; only a connection lost
|
|
123
|
+
* AFTER the subscription is established is retried.
|
|
124
|
+
*
|
|
125
|
+
* @param pool the pg-compatible pool to check a long-lived client out of
|
|
126
|
+
* @param channel channel name, MUST already be validated by the caller
|
|
127
|
+
* @param quotedChannel the channel run through quoteIdent (interpolated into SQL)
|
|
128
|
+
* @param handler called with each notification's payload
|
|
129
|
+
* @param onClosed invoked when the subscription ends, so the client can
|
|
130
|
+
* drop it from its active-subscription registry
|
|
131
|
+
* @param options reconnect policy and loss/reconnect callbacks
|
|
132
|
+
*/
|
|
133
|
+
async function createSubscription(pool, channel, quotedChannel, handler, onClosed, options = {}) {
|
|
134
|
+
const backoff = options.reconnect === false
|
|
135
|
+
? undefined
|
|
136
|
+
: { ...DEFAULT_RECONNECT, ...(typeof options.reconnect === 'object' ? options.reconnect : {}) };
|
|
137
|
+
const reportError = options.onError ??
|
|
138
|
+
((err) => {
|
|
139
|
+
console.error(`[turbine] $listen "${channel}": ${err.message}${backoff && !closed ? ', reconnecting' : ''}`);
|
|
140
|
+
});
|
|
109
141
|
let closed = false;
|
|
142
|
+
let current;
|
|
143
|
+
let timer;
|
|
144
|
+
let delay = backoff?.initialDelayMs ?? 0;
|
|
145
|
+
/** Let go of the held connection. A lost one is destroyed, never pooled. */
|
|
146
|
+
const dropCurrent = (destroy) => {
|
|
147
|
+
const conn = current;
|
|
148
|
+
if (!conn)
|
|
149
|
+
return;
|
|
150
|
+
current = undefined;
|
|
151
|
+
conn.client.removeListener?.('notification', conn.onNotification);
|
|
152
|
+
conn.checkout.release(destroy);
|
|
153
|
+
};
|
|
154
|
+
const scheduleReconnect = () => {
|
|
155
|
+
if (closed || !backoff)
|
|
156
|
+
return;
|
|
157
|
+
timer = setTimeout(() => {
|
|
158
|
+
timer = undefined;
|
|
159
|
+
void attemptReconnect();
|
|
160
|
+
}, delay);
|
|
161
|
+
// A process with nothing else to do should not stay alive just to retry.
|
|
162
|
+
timer.unref?.();
|
|
163
|
+
delay = Math.min(delay * 2, backoff.maxDelayMs);
|
|
164
|
+
};
|
|
165
|
+
const onLost = (err) => {
|
|
166
|
+
if (closed || !current)
|
|
167
|
+
return;
|
|
168
|
+
dropCurrent(err);
|
|
169
|
+
const lost = (0, errors_js_1.wrapPgError)(err);
|
|
170
|
+
reportError(lost instanceof Error ? lost : err);
|
|
171
|
+
if (!backoff) {
|
|
172
|
+
closed = true;
|
|
173
|
+
onClosed(sub);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
delay = backoff.initialDelayMs;
|
|
177
|
+
scheduleReconnect();
|
|
178
|
+
};
|
|
179
|
+
const attemptReconnect = async () => {
|
|
180
|
+
if (closed)
|
|
181
|
+
return;
|
|
182
|
+
let conn;
|
|
183
|
+
try {
|
|
184
|
+
conn = await openListenConnection(pool, channel, quotedChannel, handler, onLost);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
if (closed)
|
|
188
|
+
return;
|
|
189
|
+
reportError(err instanceof Error ? err : new Error(String(err)));
|
|
190
|
+
scheduleReconnect();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (closed) {
|
|
194
|
+
// unsubscribe() or disconnect() ran while this attempt was in flight.
|
|
195
|
+
conn.client.removeListener?.('notification', conn.onNotification);
|
|
196
|
+
conn.checkout.release(true);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
current = conn;
|
|
200
|
+
if (backoff)
|
|
201
|
+
delay = backoff.initialDelayMs;
|
|
202
|
+
options.onReconnect?.();
|
|
203
|
+
};
|
|
204
|
+
current = await openListenConnection(pool, channel, quotedChannel, handler, onLost);
|
|
205
|
+
const stop = () => {
|
|
206
|
+
closed = true;
|
|
207
|
+
if (timer !== undefined) {
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
timer = undefined;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
110
212
|
const sub = {
|
|
111
213
|
channel,
|
|
112
214
|
async unsubscribe() {
|
|
113
215
|
if (closed)
|
|
114
216
|
return;
|
|
115
|
-
|
|
217
|
+
stop();
|
|
218
|
+
const conn = current;
|
|
219
|
+
if (!conn) {
|
|
220
|
+
// Between connections: nothing is LISTENing, so there is nothing to undo.
|
|
221
|
+
onClosed(sub);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
116
224
|
try {
|
|
117
|
-
await client.query(`UNLISTEN ${quotedChannel}`);
|
|
225
|
+
await conn.client.query(`UNLISTEN ${quotedChannel}`);
|
|
118
226
|
}
|
|
119
227
|
catch (err) {
|
|
120
228
|
// Best-effort: the connection may already be dead. Still detach +
|
|
121
229
|
// release below so we don't leak the pool slot.
|
|
122
|
-
|
|
123
|
-
client.release?.();
|
|
230
|
+
dropCurrent(true);
|
|
124
231
|
onClosed(sub);
|
|
125
232
|
throw (0, errors_js_1.wrapPgError)(err);
|
|
126
233
|
}
|
|
127
|
-
|
|
128
|
-
client.release?.();
|
|
234
|
+
dropCurrent(undefined);
|
|
129
235
|
onClosed(sub);
|
|
130
236
|
},
|
|
131
237
|
_forceRelease() {
|
|
132
238
|
if (closed)
|
|
133
239
|
return;
|
|
134
|
-
|
|
135
|
-
client.removeListener?.('notification', onNotification);
|
|
240
|
+
stop();
|
|
136
241
|
// Destroy the connection (release(true)) rather than return it to the pool:
|
|
137
242
|
// we skip UNLISTEN here (the pool is being torn down), so a recycled
|
|
138
243
|
// connection would otherwise carry a stale LISTEN registration. Destroying
|
|
139
244
|
// it guarantees no pooled backend keeps receiving NOTIFY traffic. Matters
|
|
140
245
|
// most for external/serverless pools, where disconnect() is a no-op and the
|
|
141
246
|
// pool outlives this client.
|
|
142
|
-
|
|
247
|
+
dropCurrent(true);
|
|
143
248
|
onClosed(sub);
|
|
144
249
|
},
|
|
145
250
|
};
|
package/dist/cjs/schema-sql.js
CHANGED
|
@@ -25,6 +25,7 @@ exports.schemaPush = schemaPush;
|
|
|
25
25
|
exports.schemaToSQLString = schemaToSQLString;
|
|
26
26
|
const pg_1 = __importDefault(require("pg"));
|
|
27
27
|
const destructive_js_1 = require("./cli/destructive.js");
|
|
28
|
+
const connection_guard_js_1 = require("./connection-guard.js");
|
|
28
29
|
const dialect_js_1 = require("./dialect.js");
|
|
29
30
|
const errors_js_1 = require("./errors.js");
|
|
30
31
|
const introspect_js_1 = require("./introspect.js");
|
|
@@ -616,6 +617,9 @@ async function schemaDiff(schema, connectionString, options = {}) {
|
|
|
616
617
|
// `pgSchema` because `schema` is already the SchemaDef in this function.
|
|
617
618
|
const pgSchema = options.schema ?? 'public';
|
|
618
619
|
const client = new pg_1.default.Client({ connectionString });
|
|
620
|
+
// A bare client with no 'error' listener exits the process if the server
|
|
621
|
+
// drops it mid-diff; guarded, the pending query rejects instead.
|
|
622
|
+
(0, connection_guard_js_1.guardConnection)(client);
|
|
619
623
|
await client.connect();
|
|
620
624
|
try {
|
|
621
625
|
// Get existing tables in the target schema
|
|
@@ -1636,6 +1640,8 @@ async function schemaPush(schema, connectionString, options = {}) {
|
|
|
1636
1640
|
}
|
|
1637
1641
|
// Execute all statements in a transaction
|
|
1638
1642
|
const client = new pg_1.default.Client({ connectionString });
|
|
1643
|
+
// Same as schemaDiff: a dropped connection must reject the push, not exit.
|
|
1644
|
+
(0, connection_guard_js_1.guardConnection)(client);
|
|
1639
1645
|
await client.connect();
|
|
1640
1646
|
try {
|
|
1641
1647
|
await client.query('BEGIN');
|
package/dist/cli/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFi
|
|
|
29
29
|
import { tmpdir } from 'node:os';
|
|
30
30
|
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
31
31
|
import { pathToFileURL } from 'node:url';
|
|
32
|
+
import { guardConnection } from '../connection-guard.js';
|
|
32
33
|
import { detectPooler, parseSearchPathValue, poolerRefusalMessage } from '../connection-url.js';
|
|
33
34
|
import { ValidationError } from '../errors.js';
|
|
34
35
|
import { generate, generatePrismaMap, resolveImportExtension } from '../generate.js';
|
|
@@ -955,6 +956,7 @@ async function probeDatabase(url, schema) {
|
|
|
955
956
|
try {
|
|
956
957
|
const { default: pg } = await import('pg');
|
|
957
958
|
const client = new pg.Client({ connectionString: url });
|
|
959
|
+
guardConnection(client);
|
|
958
960
|
await client.connect();
|
|
959
961
|
let tableCount = 0;
|
|
960
962
|
try {
|
|
@@ -2978,6 +2980,7 @@ async function seedConnectionString(config) {
|
|
|
2978
2980
|
assertPinnableSchema(schema);
|
|
2979
2981
|
const { default: pg } = await import('pg');
|
|
2980
2982
|
const probe = new pg.Client({ connectionString: config.url });
|
|
2983
|
+
guardConnection(probe);
|
|
2981
2984
|
await probe.connect();
|
|
2982
2985
|
let inherited;
|
|
2983
2986
|
try {
|
|
@@ -3049,6 +3052,7 @@ async function runSeedPlan(plan, config) {
|
|
|
3049
3052
|
const url = seedUrl ?? requireUrl(config);
|
|
3050
3053
|
const { default: pg } = await import('pg');
|
|
3051
3054
|
const client = new pg.Client({ connectionString: url });
|
|
3055
|
+
guardConnection(client);
|
|
3052
3056
|
await client.connect();
|
|
3053
3057
|
try {
|
|
3054
3058
|
await client.query(readFileSync(plan.file, 'utf-8'));
|
package/dist/cli/mcp.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { dirname, resolve } from 'node:path';
|
|
4
4
|
import pg from 'pg';
|
|
5
|
+
import { absorbCheckedOutErrors } from '../connection-guard.js';
|
|
5
6
|
import { findMissingRelationIndexes } from '../index-advisor.js';
|
|
6
7
|
import { formatBytes } from '../index-stats.js';
|
|
7
8
|
import { deriveCatalogRelations, indexKeyColumn, parseIndexKeyEntries } from '../introspect.js';
|
|
@@ -289,6 +290,9 @@ export function startMcpServer(options, transport = {}) {
|
|
|
289
290
|
ctx.pool.on?.('error', (err) => {
|
|
290
291
|
process.stderr.write(`[turbine] mcp pool error: ${redactUrl(err.message)}\n`);
|
|
291
292
|
});
|
|
293
|
+
// The same event on a CHECKED-OUT client (a tool call mid-query when the
|
|
294
|
+
// server restarts) has no pool listener to fall back on; see connection-guard.ts.
|
|
295
|
+
absorbCheckedOutErrors(ctx.pool);
|
|
292
296
|
announcePiiTags(options);
|
|
293
297
|
let buffer = '';
|
|
294
298
|
let disposed = false;
|
package/dist/cli/migrate.js
CHANGED
|
@@ -16,6 +16,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
|
|
|
16
16
|
import { join } from 'node:path';
|
|
17
17
|
import pg from 'pg';
|
|
18
18
|
import { postgresql } from '../adapters/index.js';
|
|
19
|
+
import { guardConnection } from '../connection-guard.js';
|
|
19
20
|
import { isPlainSchemaIdentifier, parseSearchPathValue, withSearchPathOption } from '../connection-url.js';
|
|
20
21
|
import { postgresDialect } from '../dialect.js';
|
|
21
22
|
import { MigrationError, ValidationError } from '../errors.js';
|
|
@@ -841,6 +842,9 @@ async function connectMigrationClient(connectionString, schema) {
|
|
|
841
842
|
const inherited = schema === undefined || schema === '' ? [] : await probeConnectionSchemas(connectionString, schema);
|
|
842
843
|
const pinned = connectionStringForSchema(connectionString, schema, inherited);
|
|
843
844
|
const client = new pg.Client({ connectionString: pinned });
|
|
845
|
+
// Held for the whole run: a server restart must fail the migration with a
|
|
846
|
+
// message, not exit through an unheard 'error' event (connection-guard.ts).
|
|
847
|
+
guardConnection(client);
|
|
844
848
|
await client.connect();
|
|
845
849
|
return { client, connectionString: pinned };
|
|
846
850
|
}
|
|
@@ -853,6 +857,7 @@ async function probeConnectionSchemas(connectionString, schema) {
|
|
|
853
857
|
// so in those terms, see assertPinnableSchema.
|
|
854
858
|
assertPinnableSchema(schema);
|
|
855
859
|
const probe = new pg.Client({ connectionString });
|
|
860
|
+
guardConnection(probe);
|
|
856
861
|
await probe.connect();
|
|
857
862
|
try {
|
|
858
863
|
await assertSchemaExists(probe, schema);
|
|
@@ -866,6 +871,7 @@ async function probeConnectionSchemas(connectionString, schema) {
|
|
|
866
871
|
/** Open the second, lock-only connection. Separated so tests can fake it. */
|
|
867
872
|
async function openLockConnection(connectionString) {
|
|
868
873
|
const client = new pg.Client({ connectionString });
|
|
874
|
+
guardConnection(client);
|
|
869
875
|
await client.connect();
|
|
870
876
|
return client;
|
|
871
877
|
}
|
package/dist/cli/observe.js
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
9
|
import { createServer } from 'node:http';
|
|
10
10
|
import pg from 'pg';
|
|
11
|
+
import { absorbCheckedOutErrors } from '../connection-guard.js';
|
|
11
12
|
import { OBSERVE_HTML } from './observe-ui.js';
|
|
12
13
|
import { callerKey, checkRateLimit } from './rate-limit.js';
|
|
14
|
+
import { redactUrl } from './ui.js';
|
|
13
15
|
// ---------------------------------------------------------------------------
|
|
14
16
|
// Main entry point
|
|
15
17
|
// ---------------------------------------------------------------------------
|
|
@@ -19,6 +21,12 @@ export async function startObserve(options) {
|
|
|
19
21
|
max: 2,
|
|
20
22
|
idleTimeoutMillis: 10_000,
|
|
21
23
|
});
|
|
24
|
+
// Idle-client errors surface on the pool, checked-out ones on the client;
|
|
25
|
+
// with no listener either one exits the dashboard (see connection-guard.ts).
|
|
26
|
+
pool.on('error', (err) => {
|
|
27
|
+
console.error(`[turbine] observe pool error: ${redactUrl(err.message)}`);
|
|
28
|
+
});
|
|
29
|
+
absorbCheckedOutErrors(pool);
|
|
22
30
|
const probe = await pool.connect();
|
|
23
31
|
try {
|
|
24
32
|
await probe.query('SELECT 1');
|
package/dist/cli/studio.js
CHANGED
|
@@ -43,6 +43,7 @@ import { createServer } from 'node:http';
|
|
|
43
43
|
import { platform } from 'node:os';
|
|
44
44
|
import { dirname, resolve as pathResolve } from 'node:path';
|
|
45
45
|
import pg from 'pg';
|
|
46
|
+
import { absorbCheckedOutErrors } from '../connection-guard.js';
|
|
46
47
|
import { ValidationError } from '../errors.js';
|
|
47
48
|
import { introspect } from '../introspect.js';
|
|
48
49
|
import { QueryInterface, quoteIdent } from '../query/index.js';
|
|
@@ -58,6 +59,7 @@ import { applyPiiTags, loadPiiTags } from './pii-tags.js';
|
|
|
58
59
|
import { callerKey, checkRateLimit } from './rate-limit.js';
|
|
59
60
|
import { createDemoContext } from './studio-demo.js';
|
|
60
61
|
import { STUDIO_HTML } from './studio-ui.generated.js';
|
|
62
|
+
import { redactUrl } from './ui.js';
|
|
61
63
|
// ---------------------------------------------------------------------------
|
|
62
64
|
// Main entry point
|
|
63
65
|
// ---------------------------------------------------------------------------
|
|
@@ -102,11 +104,21 @@ export async function startStudio(options) {
|
|
|
102
104
|
registerUtcTemporalParsers();
|
|
103
105
|
// pg.Pool satisfies the PgCompatPool contract (same as the external-pool
|
|
104
106
|
// seam in client.ts); the cast keeps one typed pool field for both modes.
|
|
105
|
-
|
|
107
|
+
const pgPool = new pg.Pool({
|
|
106
108
|
connectionString: options.url,
|
|
107
109
|
max: 4, // small pool, single-user tool
|
|
108
110
|
idleTimeoutMillis: 10_000,
|
|
109
111
|
});
|
|
112
|
+
// A connection that dies while idle emits 'error' on the POOL, and one that
|
|
113
|
+
// dies mid-request emits it on the checked-out client. Neither had a
|
|
114
|
+
// listener, so a database restart exited Studio. Now the pending request
|
|
115
|
+
// fails and the server keeps running. The message is redacted because pg
|
|
116
|
+
// can echo the connection string into connection failures.
|
|
117
|
+
pgPool.on('error', (err) => {
|
|
118
|
+
console.error(`[turbine] studio pool error: ${redactUrl(err.message)}`);
|
|
119
|
+
});
|
|
120
|
+
absorbCheckedOutErrors(pgPool);
|
|
121
|
+
pool = pgPool;
|
|
110
122
|
// Verify connectivity before starting the server, fail fast.
|
|
111
123
|
const probe = await pool.connect();
|
|
112
124
|
try {
|
package/dist/client.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { type ObserveConfig, type ObserveHandle } from './observe.js';
|
|
|
27
27
|
import type { PgCompatPool, PgCompatPoolClient } from './pg-types.js';
|
|
28
28
|
import { type PipelineOptions, type PipelineResults } from './pipeline.js';
|
|
29
29
|
import { type DeferredQuery, type GlobalFilters, type JsonEncoding, type QueryEventListener, QueryInterface, type QueryInterfaceOptions, type RelationLoadStrategy, type TemporalInfinityReading } from './query/index.js';
|
|
30
|
-
import { type NotificationHandler, type Subscription } from './realtime.js';
|
|
30
|
+
import { type ListenOptions, type NotificationHandler, type Subscription } from './realtime.js';
|
|
31
31
|
import type { SchemaMetadata } from './schema.js';
|
|
32
32
|
import { TypedSqlQuery } from './typed-sql.js';
|
|
33
33
|
export interface RetryOptions {
|
|
@@ -1206,17 +1206,27 @@ export declare class TurbineClient {
|
|
|
1206
1206
|
* cannot do this, `$listen` throws a `ConnectionError` rather than hang.
|
|
1207
1207
|
* `$notify` works on every driver.
|
|
1208
1208
|
*
|
|
1209
|
+
* **Connection loss:** if the subscription's connection dies (a restart,
|
|
1210
|
+
* failover, compute suspend, `pg_terminate_backend`), it reconnects with
|
|
1211
|
+
* exponential backoff and re-issues `LISTEN`; `options.reconnect: false`
|
|
1212
|
+
* ends it instead. Postgres does not hold notifications for a disconnected
|
|
1213
|
+
* listener, so anything sent during the gap is lost: use
|
|
1214
|
+
* `options.onReconnect` to resynchronise. `options.onError` receives the loss
|
|
1215
|
+
* and each failed attempt (default: one `console.error` line each).
|
|
1216
|
+
*
|
|
1209
1217
|
* @example
|
|
1210
1218
|
* ```ts
|
|
1211
1219
|
* const sub = await db.$listen('order_created', (payload) => {
|
|
1212
1220
|
* const order = JSON.parse(payload);
|
|
1213
1221
|
* console.log('new order', order.id);
|
|
1222
|
+
* }, {
|
|
1223
|
+
* onReconnect: () => refreshOrdersFromDatabase(),
|
|
1214
1224
|
* });
|
|
1215
1225
|
* // ...later
|
|
1216
1226
|
* await sub.unsubscribe();
|
|
1217
1227
|
* ```
|
|
1218
1228
|
*/
|
|
1219
|
-
$listen(channel: string, handler: NotificationHandler): Promise<Subscription>;
|
|
1229
|
+
$listen(channel: string, handler: NotificationHandler, options?: ListenOptions): Promise<Subscription>;
|
|
1220
1230
|
/**
|
|
1221
1231
|
* Send a Postgres NOTIFY on `channel` with an optional payload string.
|
|
1222
1232
|
*
|