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
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
- * Acquire a dedicated connection, run `LISTEN "channel"`, and wire the handler.
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
- export async function createSubscription(pool, channel, quotedChannel, handler, onClosed) {
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
- client.release?.();
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
- closed = true;
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
- client.removeListener?.('notification', onNotification);
119
- client.release?.();
226
+ dropCurrent(true);
120
227
  onClosed(sub);
121
228
  throw wrapPgError(err);
122
229
  }
123
- client.removeListener?.('notification', onNotification);
124
- client.release?.();
230
+ dropCurrent(undefined);
125
231
  onClosed(sub);
126
232
  },
127
233
  _forceRelease() {
128
234
  if (closed)
129
235
  return;
130
- closed = true;
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
- client.release?.(true);
243
+ dropCurrent(true);
139
244
  onClosed(sub);
140
245
  },
141
246
  };
@@ -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.79.1",
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
- "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 size && npm run check:release-tests",
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.13.1"
204
+ "pg": "^8.15.0"
203
205
  },
204
206
  "devDependencies": {
205
207
  "@arethetypeswrong/cli": "^0.18.2",