turbine-orm 0.70.0 → 0.71.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 (46) hide show
  1. package/README.md +164 -1041
  2. package/dist/cjs/cli/compile-query.d.ts +198 -0
  3. package/dist/cjs/cli/compile-query.js +529 -0
  4. package/dist/cjs/cli/index.d.ts +25 -1
  5. package/dist/cjs/cli/index.js +49 -1
  6. package/dist/cjs/cli/mcp.js +198 -16
  7. package/dist/cjs/client.d.ts +45 -10
  8. package/dist/cjs/client.js +21 -3
  9. package/dist/cjs/connection-url.d.ts +160 -0
  10. package/dist/cjs/connection-url.js +296 -0
  11. package/dist/cjs/index-stats.d.ts +4 -1
  12. package/dist/cjs/index-stats.js +27 -11
  13. package/dist/cjs/index.d.ts +1 -1
  14. package/dist/cjs/plan-flip-probe.js +17 -1
  15. package/dist/cjs/powql.d.ts +1 -0
  16. package/dist/cjs/powql.js +9 -0
  17. package/dist/cjs/query/builder.d.ts +133 -2
  18. package/dist/cjs/query/builder.js +288 -64
  19. package/dist/cjs/query/deferred.d.ts +12 -6
  20. package/dist/cjs/query/index.d.ts +1 -1
  21. package/dist/cjs/query/option-surface.js +6 -0
  22. package/dist/cjs/query/types.d.ts +47 -0
  23. package/dist/cjs/query/where.d.ts +11 -2
  24. package/dist/cli/compile-query.d.ts +198 -0
  25. package/dist/cli/compile-query.js +522 -0
  26. package/dist/cli/index.d.ts +25 -1
  27. package/dist/cli/index.js +48 -1
  28. package/dist/cli/mcp.js +198 -16
  29. package/dist/client.d.ts +45 -10
  30. package/dist/client.js +19 -1
  31. package/dist/connection-url.d.ts +160 -0
  32. package/dist/connection-url.js +289 -0
  33. package/dist/index-stats.d.ts +4 -1
  34. package/dist/index-stats.js +27 -11
  35. package/dist/index.d.ts +1 -1
  36. package/dist/plan-flip-probe.js +17 -1
  37. package/dist/powql.d.ts +1 -0
  38. package/dist/powql.js +9 -0
  39. package/dist/query/builder.d.ts +133 -2
  40. package/dist/query/builder.js +288 -64
  41. package/dist/query/deferred.d.ts +12 -6
  42. package/dist/query/index.d.ts +1 -1
  43. package/dist/query/option-surface.js +6 -0
  44. package/dist/query/types.d.ts +47 -0
  45. package/dist/query/where.d.ts +11 -2
  46. package/package.json +8 -6
@@ -0,0 +1,296 @@
1
+ "use strict";
2
+ /**
3
+ * Connection-string inspection: pooler detection, and connection-time GUCs.
4
+ *
5
+ * A pure leaf. It imports nothing (not even `pg`), so every consumer, the CLI,
6
+ * the statistics collectors, and the tests, reads the same rules without
7
+ * dragging a driver in.
8
+ *
9
+ * ## Why this file exists
10
+ *
11
+ * A **transaction-pooling** proxy (PgBouncer, a Neon `-pooler` endpoint,
12
+ * Supabase's pooler) does not give a client its own server backend. It
13
+ * multiplexes many clients onto a few shared backends and hands a backend back
14
+ * to the pool at the end of each TRANSACTION, without running `DISCARD ALL`.
15
+ * Two consequences drive everything below:
16
+ *
17
+ * 1. A session-level `SET` issued outside an explicit transaction attaches to
18
+ * whichever shared backend served that statement, and stays there for the
19
+ * next client that gets it. A `SET statement_timeout` meant to bound one
20
+ * tool's own reads becomes a setting imposed on an application's queries.
21
+ * This is a production-incident class, not a theoretical one.
22
+ * 2. Session-scoped catalogs (`pg_prepared_statements` above all) describe
23
+ * whichever backend answered, which through a pooler is not "your" session
24
+ * in any useful sense.
25
+ *
26
+ * So there are two jobs here. {@link withStatementTimeoutOption} removes the
27
+ * need for the `SET` in (1) by moving the GUC into the connection's startup
28
+ * parameters, the same mechanism `TurbineClient` uses for `plan_cache_mode`.
29
+ * {@link detectPooler} lets a command that depends on session semantics refuse
30
+ * the endpoint outright rather than degrade silently.
31
+ *
32
+ * NEITHER is a substitute for the other. The connection parameter is what makes
33
+ * the collectors safe for any caller; the refusal is what keeps a diagnostic
34
+ * command from reporting confidently about a connection it cannot reason about.
35
+ */
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.POOLER_PORTS = exports.POOLER_HOST_TOKENS = void 0;
38
+ exports.parseConnectionTarget = parseConnectionTarget;
39
+ exports.detectPooler = detectPooler;
40
+ exports.poolerRefusalMessage = poolerRefusalMessage;
41
+ exports.withStatementTimeoutOption = withStatementTimeoutOption;
42
+ /**
43
+ * Host and port from a connection string, in either shape libpq accepts.
44
+ *
45
+ * URL form (`postgres://user:pw@host:5432/db`) goes through `URL`; the
46
+ * key/value DSN form (`host=db.example.com port=6543 dbname=app`) falls back to
47
+ * a scan. An unparseable string yields `{ host: null, port: null }`, which every
48
+ * caller here treats as "no evidence", never as "safe".
49
+ */
50
+ function parseConnectionTarget(connectionString) {
51
+ const trimmed = connectionString.trim();
52
+ if (trimmed === '')
53
+ return { host: null, port: null };
54
+ try {
55
+ const url = new URL(trimmed);
56
+ // A `postgresql:///db?host=/var/run/postgresql` unix socket parses with an
57
+ // empty hostname. Report it as absent rather than as the empty string.
58
+ const host = url.hostname === '' ? null : url.hostname.toLowerCase();
59
+ const port = url.port === '' ? null : Number.parseInt(url.port, 10);
60
+ return { host, port: port !== null && Number.isInteger(port) ? port : null };
61
+ }
62
+ catch {
63
+ // Not a URL: try the key/value DSN form. Unquoted values only, which is
64
+ // what a host or a port is in practice.
65
+ const hostMatch = /(?:^|\s)host\s*=\s*([^\s'"]+)/i.exec(trimmed);
66
+ const portMatch = /(?:^|\s)port\s*=\s*(\d+)/i.exec(trimmed);
67
+ const host = hostMatch?.[1]?.toLowerCase() ?? null;
68
+ const port = portMatch?.[1] !== undefined ? Number.parseInt(portMatch[1], 10) : null;
69
+ return { host: host === '' ? null : host, port: port !== null && Number.isInteger(port) ? port : null };
70
+ }
71
+ }
72
+ // ---------------------------------------------------------------------------
73
+ // Pooler detection
74
+ // ---------------------------------------------------------------------------
75
+ /**
76
+ * Hostname tokens that name a pooler.
77
+ *
78
+ * Matched as whole dot/dash/underscore-delimited TOKENS (with an optional
79
+ * trailing instance number), never as substrings of the connection string.
80
+ * Substring matching is what makes this class of check untrustworthy: a
81
+ * database named `poolers`, a role named `pooler_admin`, or a host called
82
+ * `spooler.internal` all contain the letters and none of them is a pooler, and
83
+ * a detector that cries wolf gets disabled by the first person it blocks.
84
+ */
85
+ exports.POOLER_HOST_TOKENS = Object.freeze(['pooler', 'pgbouncer']);
86
+ /**
87
+ * Ports that name a pooler on their own.
88
+ *
89
+ * 6543 is the transaction-pooling port of the hosted poolers people point a CLI
90
+ * at. 6432 is PgBouncer's own documented default `listen_port`, so anyone who
91
+ * ran PgBouncer without changing it is here.
92
+ *
93
+ * The counter-argument to 6432 is that it refuses someone running plain
94
+ * Postgres on a non-standard port. That is true and it is the right trade,
95
+ * because the two errors do not cost the same: a false positive costs one
96
+ * `--allow-pooler` flag on a command the user is running interactively and
97
+ * reading the output of, while a false negative is silently the exact hazard
98
+ * this gate exists to prevent. When a detector's errors are asymmetric, tune it
99
+ * toward the cheap one.
100
+ */
101
+ exports.POOLER_PORTS = Object.freeze([6543, 6432]);
102
+ /** The "nothing detected" result, so callers never build one by hand. */
103
+ function noPooler(target) {
104
+ return { pooled: false, signal: null, host: target.host, port: target.port, matchedToken: null, directHost: null };
105
+ }
106
+ /**
107
+ * Whether a connection string points at a transaction-pooling proxy.
108
+ *
109
+ * Deliberately conservative: it answers from the endpoint's SHAPE (hostname
110
+ * tokens, port), because there is no way to ask a pooler what it is without
111
+ * connecting through it, and the whole point is to decide before connecting.
112
+ * A false negative leaves the caller where it already was; a false positive
113
+ * blocks a legitimate database, so precision wins and the caller is expected to
114
+ * offer an override.
115
+ */
116
+ function detectPooler(connectionString) {
117
+ const target = parseConnectionTarget(connectionString);
118
+ if (target.host !== null) {
119
+ for (const label of target.host.split('.')) {
120
+ for (const token of label.split(/[-_]/)) {
121
+ // `pooler`, `pgbouncer`, and numbered instances of either (`pooler2`).
122
+ const base = /^([a-z]+)\d*$/.exec(token)?.[1] ?? token;
123
+ if (exports.POOLER_HOST_TOKENS.includes(base)) {
124
+ return {
125
+ pooled: true,
126
+ signal: 'host',
127
+ host: target.host,
128
+ port: target.port,
129
+ matchedToken: token,
130
+ directHost: deriveDirectHost(target.host, token),
131
+ };
132
+ }
133
+ }
134
+ }
135
+ }
136
+ if (target.port !== null && exports.POOLER_PORTS.includes(target.port)) {
137
+ return {
138
+ pooled: true,
139
+ signal: 'port',
140
+ host: target.host,
141
+ port: target.port,
142
+ matchedToken: null,
143
+ directHost: null,
144
+ };
145
+ }
146
+ return noPooler(target);
147
+ }
148
+ /**
149
+ * `host` with an in-label `-<token>` pooler suffix removed, or `null`.
150
+ *
151
+ * Only the suffix form is derivable, see {@link PoolerDetection.directHost}.
152
+ */
153
+ function deriveDirectHost(host, token) {
154
+ const labels = host.split('.');
155
+ let changed = false;
156
+ const rewritten = labels.map((label) => {
157
+ if (changed)
158
+ return label;
159
+ // The token must be a trailing segment of a MULTI-segment label, so
160
+ // `pooler.supabase.com` (a whole label) is not rewritten.
161
+ const suffix = `-${token}`;
162
+ if (label.length > suffix.length && label.endsWith(suffix)) {
163
+ changed = true;
164
+ return label.slice(0, -suffix.length);
165
+ }
166
+ return label;
167
+ });
168
+ return changed ? rewritten.join('.') : null;
169
+ }
170
+ /**
171
+ * The lines a command prints when it refuses a pooler endpoint.
172
+ *
173
+ * Plain text, no colour and no `console` call, so it is assertable in a unit
174
+ * test and reusable by any command that grows the same gate.
175
+ */
176
+ function poolerRefusalMessage(detection, options) {
177
+ const what = detection.signal === 'port'
178
+ ? `port ${detection.port} is a transaction-pooling port`
179
+ : `the hostname contains "${detection.matchedToken}"`;
180
+ const where = detection.host ?? '(host not stated)';
181
+ const lines = [
182
+ `${options.command} refuses to run through a connection pooler.`,
183
+ '',
184
+ ` Endpoint: ${where}${detection.port === null ? '' : `:${detection.port}`}`,
185
+ ` Detected: ${what}`,
186
+ '',
187
+ 'A transaction pooler (PgBouncer, a Neon "-pooler" endpoint, Supabase\'s pooler) does not',
188
+ 'give a client its own server backend. It multiplexes many clients onto a few shared',
189
+ 'backends and reuses one as soon as a transaction ends, so:',
190
+ '',
191
+ ' 1. Session state is not private. Anything this command sets on the session can be left',
192
+ ' behind for another client, and whatever an earlier client left behind can be in',
193
+ ' force for this one. That is how a "read-only guardrail" becomes an outage.',
194
+ ' 2. Session-scoped views describe the wrong session. pg_prepared_statements, which this',
195
+ ' report tells you to read to confirm a cached plan, belongs to whichever backend',
196
+ ' answered, not to your application.',
197
+ ' 3. The read bounds may not apply. This command asks for its statement_timeout as a',
198
+ ' connection parameter, and a pooler is free to drop or reject one.',
199
+ '',
200
+ 'Use the DIRECT (non-pooled) endpoint for the same database instead.',
201
+ ];
202
+ if (detection.directHost !== null) {
203
+ lines.push('', ` ${detection.host}`, ` -> ${detection.directHost}`);
204
+ lines.push('', 'That is the same connection string with the pooler marker removed from the host.');
205
+ }
206
+ else {
207
+ lines.push('', 'On a managed provider that is the "direct" or "session" connection string in your', 'dashboard, not the pooled one. Self-hosted, it is the Postgres host itself rather', 'than the proxy in front of it.');
208
+ }
209
+ lines.push('', `If you are certain this endpoint is not a transaction pooler, re-run with ${options.allowFlag}.`);
210
+ return lines;
211
+ }
212
+ /**
213
+ * `config` with `statement_timeout` moved into the connection's **startup
214
+ * parameters** (`options=-c statement_timeout=<ms>`) instead of a `SET`.
215
+ *
216
+ * PostgreSQL applies the `options` startup parameter as the backend starts the
217
+ * session, so the bound is in force for the connection's very first statement
218
+ * and for its whole life, with no extra round trip and nothing to reset. The
219
+ * alternative, `SET statement_timeout = <ms>` on a fresh connection, is what
220
+ * this exists to remove: outside an explicit transaction it is exactly the
221
+ * session-state write that a transaction pooler leaves on a shared backend.
222
+ *
223
+ * `TurbineClient` uses the same mechanism for `plan_cache_mode`; see the note
224
+ * there for why a `pool.on('connect')` `SET` is not the alternative it looks
225
+ * like (it races the caller's first query through pg's deprecated same-client
226
+ * queueing).
227
+ *
228
+ * Nothing already set is discarded, in either place pg reads `options` from.
229
+ * pg's `ConnectionParameters` lets a value parsed out of the connection string
230
+ * OVERRIDE the explicit `options` field, so when the URL already carries
231
+ * `?options=...` the GUC is appended THERE; the explicit field itself falls back
232
+ * to `process.env.PGOPTIONS` only while unset, so setting it blind would drop a
233
+ * deployment's `PGOPTIONS`. Both are read first and appended to.
234
+ *
235
+ * THE INJECTION BOUNDARY: a GUC value cannot be a bind parameter, so the
236
+ * emitted text necessarily contains a literal. `statementTimeoutMs` is
237
+ * therefore narrowed to a non-negative safe INTEGER and rendered from the
238
+ * narrowed number; anything else (a float, a negative, `NaN`, a string that
239
+ * coerced) returns the config untouched rather than reaching the wire, because
240
+ * a value that can carry a space can carry a second `-c`.
241
+ */
242
+ function withStatementTimeoutOption(config, statementTimeoutMs) {
243
+ if (!Number.isSafeInteger(statementTimeoutMs) || statementTimeoutMs < 0)
244
+ return config;
245
+ // No unit suffix: statement_timeout's default unit IS milliseconds, which is
246
+ // what `SET statement_timeout = <n>` meant before this replaced it.
247
+ const setting = `-c statement_timeout=${statementTimeoutMs}`;
248
+ const merged = mergeConnectionStringOptions(config.connectionString, setting);
249
+ if (merged !== null)
250
+ return { ...config, connectionString: merged };
251
+ const existing = config.options || (typeof process !== 'undefined' ? process.env?.PGOPTIONS : undefined);
252
+ return { ...config, options: existing ? `${existing} ${setting}` : setting };
253
+ }
254
+ /**
255
+ * `connectionString` with `setting` appended to an existing `options` query
256
+ * parameter, or `null` when it carries none (in which case the caller uses the
257
+ * `options` config field, which the connection string does not override).
258
+ *
259
+ * Only the query string is rewritten, never the userinfo or host, so a
260
+ * percent-encoded password cannot be mangled by a round trip through `URL`. The
261
+ * split is on the first `?`, which is also where pg's own parser puts the
262
+ * query-string boundary: a connection string with an unencoded `?` inside the
263
+ * password is not parseable by pg either, so there is no shape this handles
264
+ * differently from the driver.
265
+ *
266
+ * DELIBERATELY DUPLICATED with `TurbineClient.mergeConnectionStringOptions`,
267
+ * which needs the identical merge for `plan_cache_mode`. Collapsing the two
268
+ * onto this copy is the obviously correct refactor and it was tried; it is
269
+ * reverted, and the reason is worth writing down because the next person will
270
+ * try it too.
271
+ *
272
+ * Exporting it and importing it from `client.ts` adds an import edge from a
273
+ * module that ~100 test processes load. In each of those processes this file's
274
+ * top level runs and its functions do not, so c8 merges ~100 top-level-only
275
+ * entries against the one full entry from `pooler-guard.test.ts`, and the file
276
+ * reports 40% in the merged report while measuring 100% in isolation. Real
277
+ * coverage is unchanged either way, but the aggregate gate moved 75.48% ->
278
+ * 75.03% against a 75% floor, i.e. the refactor spent almost all the headroom
279
+ * on a reporting artifact.
280
+ *
281
+ * So: two copies, both small, both pure, neither reachable from the other. If
282
+ * you unify them, re-measure `npm run test:coverage` as a whole and not just
283
+ * this file, and raise the floor's headroom first. Keep this module
284
+ * import-free regardless.
285
+ */
286
+ function mergeConnectionStringOptions(connectionString, setting) {
287
+ const q = connectionString.indexOf('?');
288
+ if (q === -1)
289
+ return null;
290
+ const params = new URLSearchParams(connectionString.slice(q + 1));
291
+ const existing = params.get('options');
292
+ if (existing === null)
293
+ return null;
294
+ params.set('options', `${existing} ${setting}`);
295
+ return connectionString.slice(0, q + 1) + params.toString();
296
+ }
@@ -12,7 +12,10 @@
12
12
  *
13
13
  * DESIGN: the file splits into two halves.
14
14
  * - The COLLECTOR half (`collectStatsSnapshot`) reads pg catalogs. It uses a
15
- * single one-connection pool, sets a statement_timeout, and treats every
15
+ * single one-connection pool, bounds every read with a CONNECTION-TIME
16
+ * statement_timeout rather than a session-level `SET` (see
17
+ * `withStatementTimeoutOption` in connection-url.ts for why that
18
+ * distinction is a safety property and not a style choice), and treats every
16
19
  * catalog read as INDIVIDUALLY OPTIONAL: a read that fails (privileges,
17
20
  * CockroachDB/YugabyteDB catalog gaps, missing view) degrades that one
18
21
  * signal and records a notice rather than aborting the whole snapshot.
@@ -13,7 +13,10 @@
13
13
  *
14
14
  * DESIGN: the file splits into two halves.
15
15
  * - The COLLECTOR half (`collectStatsSnapshot`) reads pg catalogs. It uses a
16
- * single one-connection pool, sets a statement_timeout, and treats every
16
+ * single one-connection pool, bounds every read with a CONNECTION-TIME
17
+ * statement_timeout rather than a session-level `SET` (see
18
+ * `withStatementTimeoutOption` in connection-url.ts for why that
19
+ * distinction is a safety property and not a style choice), and treats every
17
20
  * catalog read as INDIVIDUALLY OPTIONAL: a read that fails (privileges,
18
21
  * CockroachDB/YugabyteDB catalog gaps, missing view) degrades that one
19
22
  * signal and records a notice rather than aborting the whole snapshot.
@@ -71,6 +74,7 @@ exports.auditDoctorIndexes = auditDoctorIndexes;
71
74
  exports.isSnapshotUsable = isSnapshotUsable;
72
75
  exports.collectStatsSnapshot = collectStatsSnapshot;
73
76
  exports.collectTableHeat = collectTableHeat;
77
+ const connection_url_js_1 = require("./connection-url.js");
74
78
  const index_advisor_js_1 = require("./index-advisor.js");
75
79
  // ---------------------------------------------------------------------------
76
80
  // Thresholds (exported + printed so a user can see exactly why a tier was chosen)
@@ -563,7 +567,20 @@ async function collectStatsSnapshot(options) {
563
567
  const notices = [];
564
568
  const snapshot = emptyStatsSnapshot(notices);
565
569
  const { Pool } = (await Promise.resolve().then(() => __importStar(require('pg')))).default;
566
- const pool = new Pool({ connectionString: options.connectionString, max: 1 });
570
+ // statement_timeout travels as a CONNECTION PARAMETER, never as a `SET`. A
571
+ // bare `SET` on a fresh connection is a session-state write, and through a
572
+ // transaction-pooling proxy it attaches to a shared server backend that is
573
+ // then handed to somebody else's queries. The startup parameter is applied by
574
+ // the backend as it starts the session, so it bounds this collector's very
575
+ // first read, costs no round trip, and cannot outlive the connection. Nothing
576
+ // in the caller's `?options=` or `PGOPTIONS` is discarded; see
577
+ // withStatementTimeoutOption. (`turbine doctor` also refuses a pooler
578
+ // endpoint outright, but this collector is callable on its own, so it does
579
+ // not lean on that.)
580
+ const pool = new Pool({
581
+ ...(0, connection_url_js_1.withStatementTimeoutOption)({ connectionString: options.connectionString }, timeout),
582
+ max: 1,
583
+ });
567
584
  const run = async (label, text, values) => {
568
585
  try {
569
586
  const res = await pool.query(text, values);
@@ -575,8 +592,6 @@ async function collectStatsSnapshot(options) {
575
592
  }
576
593
  };
577
594
  try {
578
- // statement_timeout is best-effort; if it fails the reads still run.
579
- await run('statement_timeout', `SET statement_timeout = ${Number(timeout)}`);
580
595
  // --- stats_reset / age --------------------------------------------------
581
596
  const resetRows = await run('pg_stat_database.stats_reset', `SELECT stats_reset FROM pg_stat_database WHERE datname = current_database()`);
582
597
  if (resetRows && resetRows.length > 0) {
@@ -765,14 +780,15 @@ async function collectTableHeat(options) {
765
780
  return result;
766
781
  }
767
782
  const { Pool } = (await Promise.resolve().then(() => __importStar(require('pg')))).default;
768
- const pool = new Pool({ connectionString: options.connectionString, max: 1 });
783
+ // As in collectStatsSnapshot: the bound is a connection parameter, not a
784
+ // `SET`. This collector points at whatever `--metrics-url` names, which is
785
+ // frequently a shared observability database, so leaving session state on a
786
+ // pooled backend there is if anything the worse version of the same bug.
787
+ const pool = new Pool({
788
+ ...(0, connection_url_js_1.withStatementTimeoutOption)({ connectionString: options.connectionString }, timeout),
789
+ max: 1,
790
+ });
769
791
  try {
770
- try {
771
- await pool.query(`SET statement_timeout = ${Number(timeout)}`);
772
- }
773
- catch {
774
- /* best-effort */
775
- }
776
792
  const exists = await pool
777
793
  .query(`SELECT to_regclass('_turbine_metrics')::text AS reg`)
778
794
  .then((r) => r.rows[0]?.reg ?? null)
@@ -44,7 +44,7 @@ export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type Neste
44
44
  export { HttpJsonSink, type HttpJsonSinkOptions, type MetricsFlushBatch, type MetricsFlushRow, type ObserveConfig, type ObserveHandle, type ObserveSink, PgMetricsSink, type PgMetricsSinkOptions, } from './observe.js';
45
45
  export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
46
46
  export { fingerprintPrismaSchema } from './prisma-schema-fingerprint.js';
47
- export { type AggregateArgs, type AggregateResult, type ArrayFilter, AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_COUNT_BATCH_MIN_PARENT_ROWS, AUTO_JOIN_PENALTY_MS_PER_ROW, AUTO_TO_ONE_JOIN_MAX_ROWS, AUTO_TO_ONE_JOIN_ROWS_MAX, AUTO_TO_ONE_JOIN_ROWS_MIN, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByAggregateSpec, type GroupByArgs, type GroupByDistinctOn, type GroupByResult, type HavingClause, type JsonFilter, type JsonPathAggregateTarget, type JsonPathGroupKey, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderByObject, type OrderDirection, type PrivilegeOption, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type RelationLoadStrategy, type RelationPickBy, type RelationPickOrderBy, type SelectResult, type SkipGlobalFilters, type TemporalInfinityReading, type TextSearchFilter, type TypedWithClause, UNSAFE, type Unsafe, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithOrderByObject, type WithResult, } from './query/index.js';
47
+ export { type AggregateArgs, type AggregateResult, type ArrayFilter, AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_COUNT_BATCH_MIN_PARENT_ROWS, AUTO_JOIN_PENALTY_MS_PER_ROW, AUTO_TO_ONE_JOIN_MAX_ROWS, AUTO_TO_ONE_JOIN_ROWS_MAX, AUTO_TO_ONE_JOIN_ROWS_MIN, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByAggregateSpec, type GroupByArgs, type GroupByDistinctOn, type GroupByResult, type HavingClause, type JsonEncoding, type JsonFilter, type JsonPathAggregateTarget, type JsonPathGroupKey, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderByObject, type OrderDirection, type PrivilegeOption, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type RelationLoadStrategy, type RelationPickBy, type RelationPickOrderBy, type SelectResult, type SkipGlobalFilters, type TemporalInfinityReading, type TextSearchFilter, type TypedWithClause, UNSAFE, type Unsafe, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithOrderByObject, type WithResult, } from './query/index.js';
48
48
  export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
49
49
  export type { CheckMetadata, ColumnMetadata, IndexMetadata, PrismaCompatMap, PrismaModelMap, PrismaRelationMap, PrismaSchemaSource, ReferentialAction, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
50
50
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, withDbFieldNames, } from './schema.js';
@@ -264,11 +264,27 @@ async function probePlanFlips(options) {
264
264
  const client = new Client({ connectionString: options.connectionString });
265
265
  try {
266
266
  await client.connect();
267
- await client.query(`SET statement_timeout = ${Number(options.statementTimeoutMs ?? 5000)}`);
268
267
  // READ ONLY is belt-and-braces: EXPLAIN without ANALYZE cannot write, and the
269
268
  // transaction is rolled back regardless. It costs nothing and makes the
270
269
  // read-only intent checkable from a server-side log.
271
270
  await client.query('BEGIN READ ONLY');
271
+ // statement_timeout is set INSIDE the transaction and transaction-locally.
272
+ // It used to be a session-level `SET` on the fresh connection, one statement
273
+ // earlier, and a dedicated `Client` does not make that safe: through a
274
+ // transaction-pooling proxy a bare `SET` outside a transaction attaches to a
275
+ // SHARED server backend, which the pooler then hands to another client
276
+ // without a DISCARD. Every statement this pass runs is inside this
277
+ // transaction, so a transaction-local bound covers exactly the same ground
278
+ // and provably cannot outlive the ROLLBACK below.
279
+ //
280
+ // `set_config(name, value, is_local => true)` rather than `SET LOCAL`:
281
+ // Postgres rejects a bind parameter in `SET LOCAL` (`SET LOCAL x = $1` is a
282
+ // syntax error), so this is the parameterizable form of the same thing. The
283
+ // value carries no unit because statement_timeout's default unit is
284
+ // milliseconds, which is what the previous statement meant.
285
+ await client.query(`SELECT set_config('statement_timeout', $1, true)`, [
286
+ String(Number(options.statementTimeoutMs ?? 5000)),
287
+ ]);
272
288
  result.available = true;
273
289
  for (let i = 0; i < targets.length; i++) {
274
290
  const finding = targets[i];
@@ -821,6 +821,7 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
821
821
  /** Resolve a groupBy order direction, refusing `nulls: 'first'` (E017); `nulls: 'last'` is a no-op. */
822
822
  private groupOrderDir;
823
823
  findManyStream(): AsyncGenerator<T>;
824
+ findManyStreamBatches(): AsyncGenerator<T[]>;
824
825
  /** Reselect a single row by its single-column primary key value. */
825
826
  private reselectByPk;
826
827
  /**
package/dist/cjs/powql.js CHANGED
@@ -3178,6 +3178,15 @@ class PowqlInterface {
3178
3178
  async *findManyStream() {
3179
3179
  throw new errors_js_1.UnsupportedFeatureError('cursor streaming (findManyStream)', 'PowDB', 'PowDB has no server-side cursor; page with findMany({ limit, offset }) instead');
3180
3180
  }
3181
+ // The batch-yielding sibling needs its OWN stub. `PowqlInterface` is a
3182
+ // parallel implementation of QueryInterface's surface rather than a subclass,
3183
+ // so a method that exists on one and not the other is not a type error here:
3184
+ // it is `undefined` at runtime, and the caller gets `TypeError: not a
3185
+ // function` instead of the typed E017 that tells them what to do instead.
3186
+ // biome-ignore lint/correctness/useYield: intentionally throws before yielding, PowDB has no server cursor.
3187
+ async *findManyStreamBatches() {
3188
+ throw new errors_js_1.UnsupportedFeatureError('cursor streaming (findManyStreamBatches)', 'PowDB', 'PowDB has no server-side cursor; page with findMany({ limit, offset }) instead');
3189
+ }
3181
3190
  // -------------------------------------------------------------------------
3182
3191
  // Reselect helper (upsert only, PowDB's upsert has no `returning`)
3183
3192
  // -------------------------------------------------------------------------
@@ -266,8 +266,31 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
266
266
  * is derived. See {@link autoToOneThreshold}.
267
267
  */
268
268
  private readonly autoRoundTripMs;
269
- /** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
269
+ /**
270
+ * The CLIENT-level nested-relation JSON encoding: what a query that names no
271
+ * `jsonEncoding` of its own gets. `'positional'` on PostgreSQL, `'object'`
272
+ * everywhere else (see {@link defaultJsonEncoding}).
273
+ *
274
+ * Read through {@link QueryInterface.currentJsonEncoding}, never directly, so
275
+ * a per-query override cannot be missed by one reader.
276
+ */
270
277
  private readonly jsonEncoding;
278
+ /**
279
+ * The encoding the query BEING BUILT resolved to, i.e. its own
280
+ * `jsonEncoding` or {@link QueryInterface.jsonEncoding}.
281
+ *
282
+ * Reassigned per `build*` call and exposed on the {@link BuilderCtx} as a live
283
+ * getter, exactly like {@link QueryInterface.currentSkip} and for the same
284
+ * reason: relations.ts reads the encoding from four places deep inside the
285
+ * SELECT walk, and threading it through every one of them as a parameter
286
+ * would be four chances to forget it.
287
+ *
288
+ * Safe because a build is SYNCHRONOUS from the assignment to the last read:
289
+ * `buildFindMany` / `buildFindUnique` return a fully-formed DeferredQuery
290
+ * whose parser closure already captured the shapes, so nothing reads this
291
+ * field after the build returns and no two builds can interleave on it.
292
+ */
293
+ private currentJsonEncoding;
271
294
  /**
272
295
  * `parseRow` decode plans, keyed by table plus the exact column list. Bounded
273
296
  * like the SQL template cache and for the same reason: the shapes come from
@@ -497,6 +520,24 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
497
520
  * the per-query arg wins, then the client-level default (off).
498
521
  */
499
522
  private resolveStableOrder;
523
+ /**
524
+ * Resolve one query's relation JSON encoding and PIN it for the build, so
525
+ * every reader inside relations.ts sees the same answer.
526
+ *
527
+ * Called at the TOP of each entry point that can emit or decode relation JSON
528
+ * (`buildFindMany`, `buildFindUnique`, `makeStreamRowParser`), before the
529
+ * cache key is assembled and before the flatten plan is consulted, because
530
+ * both of those depend on the answer.
531
+ *
532
+ * An unrecognized value THROWS (E003) rather than falling back. A per-query
533
+ * option that is silently ignored when misspelled is the exact failure this
534
+ * package has been bitten by before (see query/option-surface.ts), and here it
535
+ * would be invisible: the wrong encoding still returns correct rows, just
536
+ * without the saving the caller asked for, or with the flatten plan they were
537
+ * trying to re-enable still refused. Thrown before the SQL cache is consulted
538
+ * so a warm template can never serve a call the cold path would refuse.
539
+ */
540
+ private resolveJsonEncoding;
500
541
  /**
501
542
  * Fill a PK-ascending `orderBy` into every to-many `with` relation that has no
502
543
  * explicit one, recursing into nested `with`. Returns a CLONED clause (user
@@ -999,10 +1040,87 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
999
1040
  */
1000
1041
  private measureWithDepth;
1001
1042
  buildFindMany<W extends TypedWithClause<R> = {}, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined>(args?: FindManyArgs<T, R, W, S, O>): DeferredQuery<QueryResult<T, R, W, S, O>[]>;
1043
+ /**
1044
+ * Build the one row parser a whole drain uses.
1045
+ *
1046
+ * Shared by {@link findManyStream} and {@link findManyStreamBatches} so the
1047
+ * two can never disagree about a row's SHAPE while agreeing about its
1048
+ * contents: the flatten plan, the positional/object relation decode and the
1049
+ * PII projection are all decided here, once, before a statement is issued.
1050
+ *
1051
+ * The plan is a pure function of the schema, the `with` shape and
1052
+ * `includePii`, never of `limit`, so the batch-size override the speculative
1053
+ * fetch applies cannot change it and the parser always matches the emitted
1054
+ * SQL. Reading the raw `includePii` sentinel with `=== true` here would have
1055
+ * quietly planned a no-PII parser over a with-PII statement, which is why it
1056
+ * goes through `resolveUnsafeFlag` on every path, `with` clause or not.
1057
+ */
1058
+ private makeStreamRowParser;
1059
+ /**
1060
+ * Everything a stream does to the DATABASE, and nothing it does to a row.
1061
+ *
1062
+ * {@link findManyStream} and {@link findManyStreamBatches} are the same drain
1063
+ * handed out at two granularities, so the statement they issue, when the
1064
+ * cursor opens, and how the connection is released all live here once. Only
1065
+ * the yielding differs, which is the entire point of having both.
1066
+ *
1067
+ * Parsing deliberately stays in the callers: they parse at different
1068
+ * granularities, and the per-row method must keep parsing LAZILY, one row at
1069
+ * a time, exactly as it always has. Hoisting the parse in here would make a
1070
+ * consumer that breaks after the first row pay for the rest of its batch.
1071
+ *
1072
+ * `action` is the tag query events carry. It is a parameter rather than a
1073
+ * constant so each public method reports its own name instead of the name of
1074
+ * whichever one happens to be implemented over the other.
1075
+ *
1076
+ * An EMPTY batch is never yielded: the dialect's cursor loop breaks on a
1077
+ * zero-row FETCH, and the speculative path below yields nothing at all when
1078
+ * the result set is empty. Callers may therefore treat a yielded batch as
1079
+ * non-empty.
1080
+ */
1081
+ private streamRaw;
1082
+ /**
1083
+ * Stream rows from a findMany query using PostgreSQL cursors, one BATCH of
1084
+ * rows at a time.
1085
+ *
1086
+ * The same drain {@link findManyStream} performs, the same statements, the
1087
+ * same cursor, the same rows in the same order, handed out as arrays instead
1088
+ * of one row at a time. That is worth having because the per-row form costs
1089
+ * one promise resolution and one microtask turn PER ROW: measured over 50,000
1090
+ * rows on a local PostgreSQL, per-row yielding costs ~7 ms (~140 ns/row) that
1091
+ * batch yielding does not, which is roughly half of the streaming overhead
1092
+ * over a hand-written cursor loop. Nothing else about the two paths differs,
1093
+ * so the entire saving is the yielding.
1094
+ *
1095
+ * Prefer this whenever the consumer can work on an array; keep
1096
+ * {@link findManyStream} when a row at a time is what the code actually wants,
1097
+ * since flattening a batch by hand costs exactly what it saves.
1098
+ *
1099
+ * A yielded batch is never empty, and its length is NOT a contract: it is at
1100
+ * most `batchSize`, the final batch is usually shorter, and a result set that
1101
+ * fits within one batch arrives as a single array from the speculative fetch
1102
+ * with no cursor involved. Do not use batch boundaries to infer anything
1103
+ * about the data.
1104
+ *
1105
+ * Every other streaming behaviour is shared and documented on
1106
+ * {@link findManyStream}: the speculative fast path, the cursor path and its
1107
+ * cleanup, the snapshot semantics, and early `break`.
1108
+ *
1109
+ * @example
1110
+ * ```ts
1111
+ * for await (const batch of db.users.findManyStreamBatches({ where: { orgId: 1 }, batchSize: 500 })) {
1112
+ * await sink.writeAll(batch);
1113
+ * }
1114
+ * ```
1115
+ */
1116
+ findManyStreamBatches<W extends TypedWithClause<R> = {}, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined>(args?: FindManyStreamArgs<T, R, W, S, O>): AsyncGenerator<QueryResult<T, R, W, S, O>[], void, undefined>;
1002
1117
  /**
1003
1118
  * Stream rows from a findMany query using PostgreSQL cursors.
1004
1119
  * Returns an AsyncIterable that yields individual rows, fetching in batches internally.
1005
1120
  *
1121
+ * See {@link findManyStreamBatches} for the same drain yielded a batch at a
1122
+ * time, which is measurably cheaper when the consumer can take an array.
1123
+ *
1006
1124
  * **Speculative fast-path:** Before opening a cursor, issues a single
1007
1125
  * `SELECT ... LIMIT batchSize+1`. If the result fits within `batchSize`,
1008
1126
  * all rows are yielded immediately with zero cursor overhead (no BEGIN /
@@ -1118,7 +1236,11 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
1118
1236
  * strategy, silently and byte-identically):
1119
1237
  * - the resolved strategy is not `'flatten'`;
1120
1238
  * - `jsonEncoding: 'positional'` (a flattened relation emits no JSON at all,
1121
- * so the two encodings are not composed in this version);
1239
+ * so the two are not composed in this version). NOTE that this is the
1240
+ * PostgreSQL DEFAULT, so on Postgres `'flatten'` engages only when the
1241
+ * caller ALSO passes `jsonEncoding: 'object'`. The fallback is silent and
1242
+ * byte-identical, and `warnFlattenBlocked` names the encoding and the
1243
+ * escape hatch in dev;
1122
1244
  * - the dialect owns relation-subquery generation
1123
1245
  * (`dialect.buildRelationSubquery`, i.e. SQL Server's `FOR JSON PATH`);
1124
1246
  * - `distinct` (the `DISTINCT ON` rewrite re-orders in an outer wrapper, and
@@ -1255,6 +1377,15 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
1255
1377
  * Build the decode plan for one exact column list, and remember it as this
1256
1378
  * table's most recent shape so the next row of the same result set hits the
1257
1379
  * fast path in {@link parseRow}.
1380
+ *
1381
+ * The shape key's delimiter is NUL because it is the one byte a Postgres
1382
+ * identifier cannot contain, even quoted, so no table or column name can
1383
+ * forge a collision. It MUST be written as the six-character escape, never
1384
+ * as a literal NUL byte: a raw NUL makes byte-oriented tools classify this
1385
+ * file as binary, and `grep` then reports ZERO matches for a term that is
1386
+ * present rather than saying it declined to look. This is the largest file
1387
+ * in the repo, and that silent empty result has already sent more than one
1388
+ * search down the wrong path.
1258
1389
  */
1259
1390
  private buildRowDecodePlan;
1260
1391
  /**