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/realtime.js
CHANGED
|
@@ -24,7 +24,20 @@
|
|
|
24
24
|
* (Neon HTTP, Vercel Postgres over fetch) cannot hold such a connection, so
|
|
25
25
|
* `$listen` will surface a clear error rather than hang. `$notify` works
|
|
26
26
|
* everywhere, it's a single round-trip `SELECT pg_notify(...)`.
|
|
27
|
+
*
|
|
28
|
+
* Connection loss:
|
|
29
|
+
*
|
|
30
|
+
* A subscription's connection is held indefinitely with no query in flight,
|
|
31
|
+
* so a database restart, failover or `pg_terminate_backend` reaches it only
|
|
32
|
+
* as an `'error'` event. That event used to have no listener, which exits the
|
|
33
|
+
* process. It is now guarded (connection-guard.ts), and the subscription
|
|
34
|
+
* RECONNECTS by default: the dead connection is destroyed, a fresh one is
|
|
35
|
+
* checked out with exponential backoff, and `LISTEN` is re-issued. Postgres
|
|
36
|
+
* does not queue notifications for a listener that is not connected, so
|
|
37
|
+
* anything NOTIFYed during the gap is gone for good; `onReconnect` is the
|
|
38
|
+
* caller's cue to resynchronise from the source of truth.
|
|
27
39
|
*/
|
|
40
|
+
import { guardCheckout } from './connection-guard.js';
|
|
28
41
|
import { ConnectionError, ValidationError, wrapPgError } from './errors.js';
|
|
29
42
|
// ---------------------------------------------------------------------------
|
|
30
43
|
// Identifier validation
|
|
@@ -58,17 +71,12 @@ export function validateChannel(channel) {
|
|
|
58
71
|
'(letters, digits, underscores; cannot start with a digit)');
|
|
59
72
|
}
|
|
60
73
|
}
|
|
74
|
+
const DEFAULT_RECONNECT = { initialDelayMs: 100, maxDelayMs: 30_000 };
|
|
61
75
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* @param pool the pg-compatible pool to check a long-lived client out of
|
|
65
|
-
* @param channel channel name, MUST already be validated by the caller
|
|
66
|
-
* @param quotedChannel the channel run through quoteIdent (interpolated into SQL)
|
|
67
|
-
* @param handler called with each notification's payload
|
|
68
|
-
* @param onClosed invoked when the subscription releases, so the client can
|
|
69
|
-
* drop it from its active-subscription registry
|
|
76
|
+
* Check out a dedicated connection, wire the handler and run `LISTEN`. Throws
|
|
77
|
+
* (typed) on failure, having given the connection back.
|
|
70
78
|
*/
|
|
71
|
-
|
|
79
|
+
async function openListenConnection(pool, channel, quotedChannel, handler, onLost) {
|
|
72
80
|
let client;
|
|
73
81
|
try {
|
|
74
82
|
client = (await pool.connect());
|
|
@@ -81,11 +89,12 @@ export async function createSubscription(pool, channel, quotedChannel, handler,
|
|
|
81
89
|
// forever waiting for messages that can never arrive, so fail loudly now and
|
|
82
90
|
// give the connection straight back.
|
|
83
91
|
if (typeof client.on !== 'function') {
|
|
84
|
-
client.release
|
|
92
|
+
client.release();
|
|
85
93
|
throw new ConnectionError('$listen requires a persistent connection that can push notifications. ' +
|
|
86
94
|
'The configured pool returned a client with no event support (stateless HTTP drivers ' +
|
|
87
95
|
'like Neon HTTP / Vercel Postgres cannot LISTEN). Use a TCP pg.Pool for LISTEN/NOTIFY.');
|
|
88
96
|
}
|
|
97
|
+
const checkout = guardCheckout(client, onLost);
|
|
89
98
|
const onNotification = (msg) => {
|
|
90
99
|
// pg delivers ALL notifications for the connection to every listener; a
|
|
91
100
|
// dedicated connection only ever LISTENs on one channel, but guard anyway.
|
|
@@ -99,43 +108,139 @@ export async function createSubscription(pool, channel, quotedChannel, handler,
|
|
|
99
108
|
}
|
|
100
109
|
catch (err) {
|
|
101
110
|
client.removeListener?.('notification', onNotification);
|
|
102
|
-
|
|
111
|
+
checkout.release(err instanceof Error ? err : true);
|
|
103
112
|
throw wrapPgError(err);
|
|
104
113
|
}
|
|
114
|
+
return { client, checkout, onNotification };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Acquire a dedicated connection, run `LISTEN "channel"`, and wire the handler.
|
|
118
|
+
* A failure here is thrown to the `$listen` caller; only a connection lost
|
|
119
|
+
* AFTER the subscription is established is retried.
|
|
120
|
+
*
|
|
121
|
+
* @param pool the pg-compatible pool to check a long-lived client out of
|
|
122
|
+
* @param channel channel name, MUST already be validated by the caller
|
|
123
|
+
* @param quotedChannel the channel run through quoteIdent (interpolated into SQL)
|
|
124
|
+
* @param handler called with each notification's payload
|
|
125
|
+
* @param onClosed invoked when the subscription ends, so the client can
|
|
126
|
+
* drop it from its active-subscription registry
|
|
127
|
+
* @param options reconnect policy and loss/reconnect callbacks
|
|
128
|
+
*/
|
|
129
|
+
export async function createSubscription(pool, channel, quotedChannel, handler, onClosed, options = {}) {
|
|
130
|
+
const backoff = options.reconnect === false
|
|
131
|
+
? undefined
|
|
132
|
+
: { ...DEFAULT_RECONNECT, ...(typeof options.reconnect === 'object' ? options.reconnect : {}) };
|
|
133
|
+
const reportError = options.onError ??
|
|
134
|
+
((err) => {
|
|
135
|
+
console.error(`[turbine] $listen "${channel}": ${err.message}${backoff && !closed ? ', reconnecting' : ''}`);
|
|
136
|
+
});
|
|
105
137
|
let closed = false;
|
|
138
|
+
let current;
|
|
139
|
+
let timer;
|
|
140
|
+
let delay = backoff?.initialDelayMs ?? 0;
|
|
141
|
+
/** Let go of the held connection. A lost one is destroyed, never pooled. */
|
|
142
|
+
const dropCurrent = (destroy) => {
|
|
143
|
+
const conn = current;
|
|
144
|
+
if (!conn)
|
|
145
|
+
return;
|
|
146
|
+
current = undefined;
|
|
147
|
+
conn.client.removeListener?.('notification', conn.onNotification);
|
|
148
|
+
conn.checkout.release(destroy);
|
|
149
|
+
};
|
|
150
|
+
const scheduleReconnect = () => {
|
|
151
|
+
if (closed || !backoff)
|
|
152
|
+
return;
|
|
153
|
+
timer = setTimeout(() => {
|
|
154
|
+
timer = undefined;
|
|
155
|
+
void attemptReconnect();
|
|
156
|
+
}, delay);
|
|
157
|
+
// A process with nothing else to do should not stay alive just to retry.
|
|
158
|
+
timer.unref?.();
|
|
159
|
+
delay = Math.min(delay * 2, backoff.maxDelayMs);
|
|
160
|
+
};
|
|
161
|
+
const onLost = (err) => {
|
|
162
|
+
if (closed || !current)
|
|
163
|
+
return;
|
|
164
|
+
dropCurrent(err);
|
|
165
|
+
const lost = wrapPgError(err);
|
|
166
|
+
reportError(lost instanceof Error ? lost : err);
|
|
167
|
+
if (!backoff) {
|
|
168
|
+
closed = true;
|
|
169
|
+
onClosed(sub);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
delay = backoff.initialDelayMs;
|
|
173
|
+
scheduleReconnect();
|
|
174
|
+
};
|
|
175
|
+
const attemptReconnect = async () => {
|
|
176
|
+
if (closed)
|
|
177
|
+
return;
|
|
178
|
+
let conn;
|
|
179
|
+
try {
|
|
180
|
+
conn = await openListenConnection(pool, channel, quotedChannel, handler, onLost);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
if (closed)
|
|
184
|
+
return;
|
|
185
|
+
reportError(err instanceof Error ? err : new Error(String(err)));
|
|
186
|
+
scheduleReconnect();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (closed) {
|
|
190
|
+
// unsubscribe() or disconnect() ran while this attempt was in flight.
|
|
191
|
+
conn.client.removeListener?.('notification', conn.onNotification);
|
|
192
|
+
conn.checkout.release(true);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
current = conn;
|
|
196
|
+
if (backoff)
|
|
197
|
+
delay = backoff.initialDelayMs;
|
|
198
|
+
options.onReconnect?.();
|
|
199
|
+
};
|
|
200
|
+
current = await openListenConnection(pool, channel, quotedChannel, handler, onLost);
|
|
201
|
+
const stop = () => {
|
|
202
|
+
closed = true;
|
|
203
|
+
if (timer !== undefined) {
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
timer = undefined;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
106
208
|
const sub = {
|
|
107
209
|
channel,
|
|
108
210
|
async unsubscribe() {
|
|
109
211
|
if (closed)
|
|
110
212
|
return;
|
|
111
|
-
|
|
213
|
+
stop();
|
|
214
|
+
const conn = current;
|
|
215
|
+
if (!conn) {
|
|
216
|
+
// Between connections: nothing is LISTENing, so there is nothing to undo.
|
|
217
|
+
onClosed(sub);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
112
220
|
try {
|
|
113
|
-
await client.query(`UNLISTEN ${quotedChannel}`);
|
|
221
|
+
await conn.client.query(`UNLISTEN ${quotedChannel}`);
|
|
114
222
|
}
|
|
115
223
|
catch (err) {
|
|
116
224
|
// Best-effort: the connection may already be dead. Still detach +
|
|
117
225
|
// release below so we don't leak the pool slot.
|
|
118
|
-
|
|
119
|
-
client.release?.();
|
|
226
|
+
dropCurrent(true);
|
|
120
227
|
onClosed(sub);
|
|
121
228
|
throw wrapPgError(err);
|
|
122
229
|
}
|
|
123
|
-
|
|
124
|
-
client.release?.();
|
|
230
|
+
dropCurrent(undefined);
|
|
125
231
|
onClosed(sub);
|
|
126
232
|
},
|
|
127
233
|
_forceRelease() {
|
|
128
234
|
if (closed)
|
|
129
235
|
return;
|
|
130
|
-
|
|
131
|
-
client.removeListener?.('notification', onNotification);
|
|
236
|
+
stop();
|
|
132
237
|
// Destroy the connection (release(true)) rather than return it to the pool:
|
|
133
238
|
// we skip UNLISTEN here (the pool is being torn down), so a recycled
|
|
134
239
|
// connection would otherwise carry a stale LISTEN registration. Destroying
|
|
135
240
|
// it guarantees no pooled backend keeps receiving NOTIFY traffic. Matters
|
|
136
241
|
// most for external/serverless pools, where disconnect() is a no-op and the
|
|
137
242
|
// pool outlives this client.
|
|
138
|
-
|
|
243
|
+
dropCurrent(true);
|
|
139
244
|
onClosed(sub);
|
|
140
245
|
},
|
|
141
246
|
};
|
package/dist/schema-sql.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import pg from 'pg';
|
|
8
8
|
import { DESTRUCTIVE_KIND_LABEL, scanDestructiveSql } from './cli/destructive.js';
|
|
9
|
+
import { guardConnection } from './connection-guard.js';
|
|
9
10
|
import { postgresDialect } from './dialect.js';
|
|
10
11
|
import { UnsupportedFeatureError, ValidationError } from './errors.js';
|
|
11
12
|
import { pgConfActionToReferential, stripCheckWrapper } from './introspect.js';
|
|
@@ -597,6 +598,9 @@ export async function schemaDiff(schema, connectionString, options = {}) {
|
|
|
597
598
|
// `pgSchema` because `schema` is already the SchemaDef in this function.
|
|
598
599
|
const pgSchema = options.schema ?? 'public';
|
|
599
600
|
const client = new pg.Client({ connectionString });
|
|
601
|
+
// A bare client with no 'error' listener exits the process if the server
|
|
602
|
+
// drops it mid-diff; guarded, the pending query rejects instead.
|
|
603
|
+
guardConnection(client);
|
|
600
604
|
await client.connect();
|
|
601
605
|
try {
|
|
602
606
|
// Get existing tables in the target schema
|
|
@@ -1616,6 +1620,8 @@ export async function schemaPush(schema, connectionString, options = {}) {
|
|
|
1616
1620
|
}
|
|
1617
1621
|
// Execute all statements in a transaction
|
|
1618
1622
|
const client = new pg.Client({ connectionString });
|
|
1623
|
+
// Same as schemaDiff: a dropped connection must reject the push, not exit.
|
|
1624
|
+
guardConnection(client);
|
|
1619
1625
|
await client.connect();
|
|
1620
1626
|
try {
|
|
1621
1627
|
await client.query('BEGIN');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turbine-orm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.0",
|
|
4
4
|
"description": "Postgres-native TypeScript ORM, runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"//exports": "Each subpath declares its types PER CONDITION. A single shared top-level \"types\" resolves to the ESM declarations for `require` too, which is TS1479 (\"is an ES module ... cannot be require()d\") for any CJS consumer on moduleResolution node16/nodenext. The require condition points at dist/cjs, which ships its own {\"type\":\"commonjs\"} package.json, so those declarations are CJS declarations. Gated in CI by publint + @arethetypeswrong/cli + a real .cts consumer typecheck (see the package-types job in ci.yml).",
|
|
@@ -176,7 +176,9 @@
|
|
|
176
176
|
"check:package-types": "node scripts/check-no-pg-types.mjs",
|
|
177
177
|
"//check:edge": "The edge-runtime gate for turbine-orm/serverless: bundles dist/serverless.js (the ESM build, which is what edge bundlers resolve) with esbuild under the edge-light/worker/browser conditions and fails on any pg-family import, any Node builtin but node:async_hooks, or any other package. Every other gate here runs on Node, which is how a static `import pg from 'pg'` in a shared module broke every edge build while CI stayed green. A CONTROL bundle under Node conditions must reach pg first, or the scan refuses to pass. Reads dist/, so it runs after `npm run build`: in prepublishOnly, and in the build job of ci.yml and the test job of release.yml.",
|
|
178
178
|
"check:edge": "node scripts/check-edge-bundle.mjs",
|
|
179
|
-
"
|
|
179
|
+
"//check:pg-floor": "Installs the packed tarball next to EXACTLY the floor of dependencies.pg and imports every published entry point under both import and require. CI and dev machines only ever install the newest pg in range, so the floor is otherwise never exercised: a ^8.13.1 range shipped code that read pg.utils at load time, which pg first exports in 8.15.0, and 6 of the 8 entry points failed to import on 8.13 and 8.14. Packs dist/ itself when given no tarball, so it runs after `npm run build`. Called from prepublishOnly and from the pack-smoke job of both ci.yml and release.yml.",
|
|
180
|
+
"check:pg-floor": "node scripts/check-pg-floor.mjs",
|
|
181
|
+
"prepublishOnly": "npm run build && npm run typecheck && npm run lint && npm run test:unit && npm run test:coverage:cli && npm run check:error-codes && npm run check:error-prefix && npm run check:changelog && npm run check:cycles && npm run check:skip-gates && npm run check:private-terms && npm run check:package && npm run check:package-types && npm run check:edge && npm run check:pg-floor && npm run size && npm run check:release-tests",
|
|
180
182
|
"prepack": "node scripts/strip-prepare.mjs",
|
|
181
183
|
"postpack": "node scripts/restore-prepare.mjs",
|
|
182
184
|
"size": "size-limit",
|
|
@@ -199,7 +201,7 @@
|
|
|
199
201
|
},
|
|
200
202
|
"//dependencies": "EXACTLY ONE runtime dependency, `pg`. `@types/pg` sat here until 0.70.0 because the published declarations named `pg.Pool` / `pg.PoolClient` / `pg.QueryResult`, which makes a types-only package a hard requirement for every consumer compiling under strict (the v0.28.1 regression). The declaration surface is now pg-free (see src/pg-types.ts) and the `consumer-types` CI job proves it against the real tarball, so the types package is a devDependency. The order is one-way: clear the surface FIRST, then move the dependency.",
|
|
201
203
|
"dependencies": {
|
|
202
|
-
"pg": "^8.
|
|
204
|
+
"pg": "^8.15.0"
|
|
203
205
|
},
|
|
204
206
|
"devDependencies": {
|
|
205
207
|
"@arethetypeswrong/cli": "^0.18.2",
|