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
@@ -17,6 +17,7 @@ const introspect_js_1 = require("../introspect.js");
17
17
  const index_js_1 = require("../query/index.js");
18
18
  const utils_js_1 = require("../query/utils.js");
19
19
  const schema_js_1 = require("../schema.js");
20
+ const compile_query_js_1 = require("./compile-query.js");
20
21
  const error_catalog_js_1 = require("./error-catalog.js");
21
22
  const migrate_js_1 = require("./migrate.js");
22
23
  const pii_predicate_guard_js_1 = require("./pii-predicate-guard.js");
@@ -170,6 +171,27 @@ const TOOLS = [
170
171
  additionalProperties: false,
171
172
  },
172
173
  },
174
+ {
175
+ name: 'compile_query',
176
+ description: 'Compile a Turbine read query to the EXACT SQL it would send, WITHOUT running it: no statement is executed and no row is read, so this is safe to call on production and safe to call in a loop. Use it before you write the query into code. Pass `table`, an `operation` (findMany / findUnique / findFirst / count / aggregate / groupBy, default findMany) and `args`, the same object you would pass to that method. Returns the SQL, the bound parameters, how many STATEMENTS the query costs at execution (a `with` clause can be one join or one follow-up per relation), which relation-load strategy it takes, whether it is bounded by a LIMIT or reads the whole table, the relation depth, and warnings such as a relation whose correlation column has no index. A query that FAILS to compile is a successful answer, not an error: an unknown column (TURBINE_E003), an unknown relation (TURBINE_E005) or the empty-where guard comes back as `ok: false` with the code, the message and how to fix it. READ OPERATIONS ONLY, deliberately: this server has no write surface and compiling one would be the first. A where or orderBy on a PII-tagged or secret-named column is refused, exactly as in explain_query.',
177
+ inputSchema: {
178
+ type: 'object',
179
+ properties: {
180
+ table: { type: 'string', description: 'Table name (must exist in the introspected schema).' },
181
+ operation: {
182
+ type: 'string',
183
+ enum: [...compile_query_js_1.COMPILE_OPERATIONS],
184
+ description: 'Which read method to compile. Defaults to findMany.',
185
+ },
186
+ args: {
187
+ type: 'object',
188
+ description: 'The query args, exactly as the method takes them: where / orderBy / with / select / omit / limit / offset / take / cursor / distinct / relationLoadStrategy for the find methods, and by / having / _count / _sum / _avg / _min / _max for aggregate and groupBy. Field and relation names are validated against the live schema.',
189
+ },
190
+ },
191
+ required: ['table'],
192
+ additionalProperties: false,
193
+ },
194
+ },
173
195
  {
174
196
  name: 'sample_rows',
175
197
  description: 'Read up to 50 rows from a validated table. PII-tagged and secret-named columns are never fetched; the reply lists exactly what was hidden and where the PII tags came from.',
@@ -396,6 +418,9 @@ async function callTool(params, ctx) {
396
418
  case 'explain_query':
397
419
  result = await explainQuery(ctx, args);
398
420
  break;
421
+ case 'compile_query':
422
+ result = await compileQuery(ctx, args);
423
+ break;
399
424
  case 'sample_rows':
400
425
  result = await sampleRows(ctx, requiredString(args, 'table'), optionalLimit(args.limit));
401
426
  break;
@@ -749,6 +774,27 @@ async function explainQuery(ctx, args) {
749
774
  * column can be shown to be safe, so every where/orderBy is refused rather than
750
775
  * assumed harmless.
751
776
  */
777
+ /**
778
+ * Why this column may not appear in a predicate, or null when it may.
779
+ *
780
+ * The two reasons are the two `sample_rows` already refuses to FETCH
781
+ * (`classifyHiddenColumns`), and they are deliberately the same set: a column
782
+ * whose bytes are too sensitive to sample is too sensitive to binary-search out
783
+ * of the planner. A column absent from the table is not judged here, the builder
784
+ * rejects it by name a moment later.
785
+ *
786
+ * ONE definition, shared by `explain_query` and `compile_query`. It was inline
787
+ * in the former, and lifting it out is the same move that made the WALK shared:
788
+ * two tools that hide different sets of columns are two perimeters, and the
789
+ * weaker one is the one an attacker uses.
790
+ */
791
+ function hiddenColumnReason(owner, column) {
792
+ if (owner.columns.some((col) => col.name === column && col.pii === true))
793
+ return 'is PII-tagged';
794
+ if (SECRET_NAME_PATTERN.test(column))
795
+ return 'has a secret-looking name';
796
+ return null;
797
+ }
752
798
  function assertNoPiiPredicates(args, table, metadata, piiTags) {
753
799
  const hasPredicate = args.where !== undefined || args.orderBy !== undefined;
754
800
  if (tagsUnreadable(piiTags) && hasPredicate) {
@@ -758,22 +804,7 @@ function assertNoPiiPredicates(args, table, metadata, piiTags) {
758
804
  }
759
805
  (0, pii_predicate_guard_js_1.assertNoPiiPredicates)(args, table, {
760
806
  metadata,
761
- /**
762
- * Why this column may not appear in a predicate, or null when it may.
763
- *
764
- * The two reasons are the two `sample_rows` already refuses to FETCH
765
- * (`classifyHiddenColumns`), and they are deliberately the same set: a
766
- * column whose bytes are too sensitive to sample is too sensitive to
767
- * binary-search out of the planner. A column absent from the table is not
768
- * judged here, the builder rejects it by name a moment later.
769
- */
770
- hiddenReason: (owner, column) => {
771
- if (owner.columns.some((col) => col.name === column && col.pii === true))
772
- return 'is PII-tagged';
773
- if (SECRET_NAME_PATTERN.test(column))
774
- return 'has a secret-looking name';
775
- return null;
776
- },
807
+ hiddenReason: hiddenColumnReason,
777
808
  refuseColumn: (owner, column, why) => {
778
809
  throw jsonRpcError(-32602, `Column "${column}" on "${owner.name}" ${why}, so it cannot be used in a where or orderBy here: ` +
779
810
  `EXPLAIN reports the planner's row estimate, and the estimate for a predicate on a hidden value ` +
@@ -790,6 +821,157 @@ function assertNoPiiPredicates(args, table, metadata, piiTags) {
790
821
  },
791
822
  });
792
823
  }
824
+ // ---------------------------------------------------------------------------
825
+ // compile_query, the build half of the ORM with the execute half removed
826
+ // ---------------------------------------------------------------------------
827
+ /**
828
+ * Compile a read query to SQL and describe it, WITHOUT running it.
829
+ *
830
+ * WHAT THIS TOOL DOES AND DOES NOT TOUCH. It reads the CATALOG, once, in its
831
+ * own `BEGIN READ ONLY` transaction, because it cannot validate a column name
832
+ * against a schema it has not seen. That read finishes and the connection goes
833
+ * back to the pool BEFORE anything is compiled: the compile itself is handed
834
+ * `SEALED_POOL` (see `cli/compile-query.ts`), whose every method throws, so the
835
+ * compiled statement has no live connection to escape down and the "compiles,
836
+ * never executes" claim is a property of the code rather than of this comment.
837
+ * That split is also why the compile is not inside `withReadOnly`: a build walk
838
+ * on a deep `with` clause has no business holding one of a max:2 pool's
839
+ * connections while an agent waits.
840
+ *
841
+ * WHY THE PII GUARD RUNS HERE AT ALL, given this tool returns no row and no row
842
+ * ESTIMATE, and so is not the extraction oracle `explain_query` is. Two
843
+ * reasons, and the first is the load-bearing one. A tool that will compile
844
+ * `WHERE "api_key" LIKE $1` is a tool that authors the probe for the agent to
845
+ * run somewhere this server does not control; refusing to write it keeps one
846
+ * rule ("do not build queries that interrogate a hidden value") rather than two
847
+ * that differ by which tool you asked. And second, an unguarded compile is a
848
+ * cheap way to discover which SPELLINGS of a predicate against a hidden column
849
+ * the builder accepts, which is reconnaissance for the tool that does leak. The
850
+ * guard is the same shared walker, with the same `hiddenColumnReason` policy
851
+ * `explain_query` uses, so the two cannot drift apart.
852
+ */
853
+ async function compileQuery(ctx, args) {
854
+ // Same explicit rejection explain_query makes: an agent that reaches for a
855
+ // raw-SQL argument should be told the surface does not exist, not told that
856
+ // `table` is missing.
857
+ if ('sql' in args) {
858
+ throw jsonRpcError(-32602, 'compile_query never accepts SQL; it PRODUCES it. Pass table + operation + args (the object you would ' +
859
+ 'hand to findMany / findUnique / findFirst / count / aggregate / groupBy).');
860
+ }
861
+ const tableName = requiredString(args, 'table');
862
+ const operation = parseCompileOperation(args.operation);
863
+ const queryArgs = parseCompileArgs(args.args);
864
+ const { metadata, piiTags } = await withReadOnly(ctx, (client) => loadSchemaMetadata(client, ctx.options));
865
+ const table = requireTable(metadata, tableName);
866
+ assertCompileHidesNothing(queryArgs, operation, table, metadata, piiTags);
867
+ let report;
868
+ try {
869
+ report = (0, compile_query_js_1.compileQueryPlan)({ metadata, table, operation, args: queryArgs });
870
+ }
871
+ catch (err) {
872
+ // Not a TurbineError (compileQueryPlan turns those into an `ok: false`
873
+ // answer): a malformed args shape the builder rejected some other way.
874
+ throw jsonRpcError(-32602, errorMessage(err));
875
+ }
876
+ return {
877
+ schema: ctx.options.schema,
878
+ ...report,
879
+ executed: false,
880
+ executedNote: 'Nothing was executed. This tool compiles the statement and returns it; the only database access it makes ' +
881
+ 'is the read-only catalog read that resolves table, column and relation names.',
882
+ };
883
+ }
884
+ /** The requested operation, defaulted to findMany and checked against the closed set. */
885
+ function parseCompileOperation(value) {
886
+ if (value === undefined || value === null)
887
+ return 'findMany';
888
+ if (typeof value !== 'string' || !compile_query_js_1.COMPILE_OPERATIONS.includes(value)) {
889
+ throw jsonRpcError(-32602, `operation must be one of ${compile_query_js_1.COMPILE_OPERATIONS.join(', ')}. Write operations are not compiled by this ` +
890
+ 'server: it has no write surface at all, which is stronger than having one that refuses to run.');
891
+ }
892
+ return value;
893
+ }
894
+ /**
895
+ * The query args, passed through whole.
896
+ *
897
+ * Deliberately NOT an allowlist of keys the way `parseExplainFindManyArgs` is.
898
+ * The point of this tool is that the agent compiles the object it is about to
899
+ * paste into its code, so a key this parser dropped would compile a DIFFERENT
900
+ * query than the one that ships, silently, which is worse than any error. Every
901
+ * key is therefore judged by the two things that already judge them correctly:
902
+ * the PII guard, which fails closed on a shape it does not recognize, and the
903
+ * builder, which throws E003 by name for an unknown one. The two privilege
904
+ * options that would matter (`includePii`, `skipGlobalFilters`) are unlocked by
905
+ * a symbol sentinel that `JSON.parse` cannot produce, so neither is reachable
906
+ * over this wire.
907
+ */
908
+ function parseCompileArgs(value) {
909
+ if (value === undefined || value === null)
910
+ return {};
911
+ if (!isObject(value))
912
+ throw jsonRpcError(-32602, 'args must be an object');
913
+ return value;
914
+ }
915
+ /**
916
+ * Refuse a compile that names a hidden column anywhere it could name one.
917
+ *
918
+ * TWO WALKS, because the arg surface has two shapes. The findMany-shaped ops go
919
+ * through the shared `cli/pii-predicate-guard.ts` walker exactly as
920
+ * `explain_query` does, relation-aware and fail-closed. `aggregate` / `groupBy`
921
+ * hand it their `where` only, and everything else through
922
+ * `collectAggregateColumnNames`: the shared walker's `visitLevel` fails closed
923
+ * on any object-valued key outside its findMany vocabulary, so handing it a
924
+ * `by` array or a `_min` block would refuse EVERY aggregate rather than check
925
+ * one, and teaching it those shapes means a second module that has to stay in
926
+ * step with the aggregate compiler. The harvest is blunter and errs the safe
927
+ * way; see its own doc comment.
928
+ */
929
+ function assertCompileHidesNothing(args, operation, table, metadata, piiTags) {
930
+ if (tagsUnreadable(piiTags) && (0, compile_query_js_1.carriesColumnNamingArg)(args)) {
931
+ throw jsonRpcError(-32602, `PII tags could not be read from ${piiTags.path} (${piiTags.reason}), so compile_query cannot prove this ` +
932
+ `query does not filter, sort or group on a PII column. Re-run \`turbine generate\`, or compile the query ` +
933
+ `without where/orderBy/cursor/distinct/by/having and the aggregate blocks.`);
934
+ }
935
+ const aggregateShaped = (0, compile_query_js_1.isAggregateShaped)(operation);
936
+ const predicateArgs = aggregateShaped ? { where: args.where } : args;
937
+ (0, pii_predicate_guard_js_1.assertNoPiiPredicates)(predicateArgs, table, {
938
+ metadata,
939
+ hiddenReason: hiddenColumnReason,
940
+ refuseColumn: (owner, column, why) => {
941
+ throw jsonRpcError(-32602, `Column "${column}" on "${owner.name}" ${why}, so compile_query will not build a statement that filters, ` +
942
+ `sorts, pages or groups on it. This server does not author queries that interrogate a hidden value, ` +
943
+ `wherever that statement would eventually run. Use a visible column.`);
944
+ },
945
+ refuseDepth: (maxDepth) => {
946
+ throw jsonRpcError(-32602, `Query is nested more than ${maxDepth} levels deep, past the point where the PII guard can prove it does ` +
947
+ `not name a hidden column, so it is refused. Flatten the query.`);
948
+ },
949
+ refuseShape: (owner, key) => {
950
+ throw jsonRpcError(-32602, `The PII guard does not recognize "${key}" in a query on "${owner.name}", so it cannot prove the query ` +
951
+ `does not name a hidden column, and refuses it rather than guessing. Remove it and compile without it.`);
952
+ },
953
+ });
954
+ if (!aggregateShaped)
955
+ return;
956
+ let names;
957
+ try {
958
+ names = (0, compile_query_js_1.collectAggregateColumnNames)(args);
959
+ }
960
+ catch (err) {
961
+ // The harvest throws only on its depth cap, which is the same fail-closed
962
+ // posture the shared walker takes, reported the same way.
963
+ throw jsonRpcError(-32602, errorMessage(err));
964
+ }
965
+ for (const name of names) {
966
+ const column = (0, utils_js_1.ownLookup)(table.columnMap, name) ?? name;
967
+ const why = hiddenColumnReason(table, column);
968
+ if (!why)
969
+ continue;
970
+ throw jsonRpcError(-32602, `Column "${column}" on "${table.name}" ${why}, so it cannot be used as a group key, an aggregate target, a ` +
971
+ `HAVING term or an ordering in compile_query. _count over the table is unaffected; group and aggregate on ` +
972
+ `a visible column instead.`);
973
+ }
974
+ }
793
975
  /**
794
976
  * Extract the allowed findMany subset for explain_query (no `with` / raw SQL).
795
977
  * Returns a plain object cast at the buildFindMany call site, same pattern as Studio.
@@ -26,7 +26,7 @@ import { type ErrorMessageMode } from './errors.js';
26
26
  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
- import { type DeferredQuery, type GlobalFilters, type QueryEventListener, QueryInterface, type QueryInterfaceOptions, type RelationLoadStrategy, type TemporalInfinityReading } from './query/index.js';
29
+ import { type DeferredQuery, type GlobalFilters, type JsonEncoding, type QueryEventListener, QueryInterface, type QueryInterfaceOptions, type RelationLoadStrategy, type TemporalInfinityReading } from './query/index.js';
30
30
  import { type NotificationHandler, type Subscription } from './realtime.js';
31
31
  import type { SchemaMetadata } from './schema.js';
32
32
  import { TypedSqlQuery } from './typed-sql.js';
@@ -367,19 +367,31 @@ export interface TurbineConfig {
367
367
  /**
368
368
  * How nested-relation subqueries encode each row's JSON.
369
369
  *
370
- * - `'object'` (default), `json_agg(json_build_object('key', v, …))`. Every
371
- * key name is repeated in every nested object of every row.
370
+ * - `'object'`, `json_agg(json_build_object('key', v, …))`. Every key name
371
+ * is repeated in every nested object of every row.
372
372
  * - `'positional'`, `json_agg(json_build_array(v, …))`. Turbine knows the
373
373
  * column order at build time, so it emits a key-less array and maps
374
374
  * positions back to keys client-side. Same information, a fraction of the
375
- * bytes on wide/deeply-nested `with` trees. Parsed output is byte-identical
376
- * to `'object'`.
377
- *
378
- * Postgres-only in v1: setting `'positional'` on a non-Postgres engine throws
379
- * `UnsupportedFeatureError` (E017) when a `with` clause is present. Default:
380
- * `'object'` (today's behavior, byte-unchanged).
375
+ * bytes on wide/deeply-nested `with` trees. Parsed output is
376
+ * byte-identical to `'object'`.
377
+ *
378
+ * DEFAULTS BY ENGINE: `'positional'` on PostgreSQL, `'object'` on SQLite,
379
+ * MySQL, SQL Server and PowDB. Positional is PostgreSQL-only, and setting it
380
+ * on another engine throws `UnsupportedFeatureError` (E017) as soon as a
381
+ * `with` clause is present, so the default cannot be one value everywhere.
382
+ *
383
+ * Why positional is the PostgreSQL default. `json_build_object` makes the
384
+ * SERVER build the key names for every relation row, which is the whole
385
+ * nested-read cost: measured on a 50-parent / ~10-child-per-parent read
386
+ * against local PostgreSQL 17, server time 0.685 ms → 0.350 ms and 152 KB →
387
+ * 100 KB on the wire, for rows that compare byte-identical.
388
+ *
389
+ * Reasons to set `'object'` (per query via `findMany({ jsonEncoding })`, or
390
+ * here for a whole client): a readable statement in a query log, and
391
+ * `relationLoadStrategy: 'flatten'`, which is refused while positional is
392
+ * active because a flattened relation emits no JSON to encode.
381
393
  */
382
- jsonEncoding?: 'object' | 'positional';
394
+ jsonEncoding?: JsonEncoding;
383
395
  /**
384
396
  * Controls how `NotFoundError` (and other where-aware errors) format their
385
397
  * messages.
@@ -532,6 +544,24 @@ export interface TransactionOptions {
532
544
  */
533
545
  sessionContext?: Record<string, string | number | boolean>;
534
546
  }
547
+ /**
548
+ * The read-only `QueryInterface` operations that a read-replica setup may route
549
+ * to a replica pool. Every other method (all writes, plus internals) stays on
550
+ * the primary. Kept as a Set so the routing proxy's `get` trap is O(1).
551
+ */
552
+ /**
553
+ * Operations that may be served by a read replica.
554
+ *
555
+ * This is a hand-maintained list of names, so it drifts the moment a read
556
+ * method is added to `QueryInterface` and not added here. The failure is
557
+ * SILENT and directional: the missing operation keeps working, it just runs on
558
+ * the PRIMARY, so no test fails and no user sees an error, only a busier
559
+ * primary. `findManyStreamBatches` was added and missed exactly this way.
560
+ * `src/test/read-operations-drift.test.ts` closes the loop mechanically.
561
+ *
562
+ * Exported for that test only.
563
+ */
564
+ export declare const READ_OPERATIONS: ReadonlySet<string>;
535
565
  /**
536
566
  * A transaction-scoped client that provides the same table accessor API as TurbineClient.
537
567
  * All queries run on a dedicated connection within a BEGIN/COMMIT block.
@@ -781,6 +811,11 @@ export declare class TurbineClient {
781
811
  * query-string boundary: a connection string with an unencoded `?` inside the
782
812
  * password is not parseable by pg either, so there is no shape this handles
783
813
  * differently from the driver.
814
+ *
815
+ * A twin of this lives in `src/connection-url.ts`, which `turbine doctor`
816
+ * uses for `statement_timeout`. Unifying them is the obvious refactor and it
817
+ * is deliberately NOT done; the reason (a c8 merge artifact that costs almost
818
+ * all of the coverage gate's headroom) is written up over there.
784
819
  */
785
820
  private static mergeConnectionStringOptions;
786
821
  /**
@@ -26,7 +26,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
26
26
  return (mod && mod.__esModule) ? mod : { "default": mod };
27
27
  };
28
28
  Object.defineProperty(exports, "__esModule", { value: true });
29
- exports.TurbineClient = exports.TransactionClient = void 0;
29
+ exports.TurbineClient = exports.TransactionClient = exports.READ_OPERATIONS = void 0;
30
30
  exports.withRetry = withRetry;
31
31
  const pg_1 = __importDefault(require("pg"));
32
32
  const dialect_js_1 = require("./dialect.js");
@@ -414,7 +414,19 @@ const GUC_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/;
414
414
  * to a replica pool. Every other method (all writes, plus internals) stays on
415
415
  * the primary. Kept as a Set so the routing proxy's `get` trap is O(1).
416
416
  */
417
- const READ_OPERATIONS = new Set([
417
+ /**
418
+ * Operations that may be served by a read replica.
419
+ *
420
+ * This is a hand-maintained list of names, so it drifts the moment a read
421
+ * method is added to `QueryInterface` and not added here. The failure is
422
+ * SILENT and directional: the missing operation keeps working, it just runs on
423
+ * the PRIMARY, so no test fails and no user sees an error, only a busier
424
+ * primary. `findManyStreamBatches` was added and missed exactly this way.
425
+ * `src/test/read-operations-drift.test.ts` closes the loop mechanically.
426
+ *
427
+ * Exported for that test only.
428
+ */
429
+ exports.READ_OPERATIONS = new Set([
418
430
  'findMany',
419
431
  'findFirst',
420
432
  'findUnique',
@@ -424,6 +436,7 @@ const READ_OPERATIONS = new Set([
424
436
  'aggregate',
425
437
  'groupBy',
426
438
  'findManyStream',
439
+ 'findManyStreamBatches',
427
440
  ]);
428
441
  /**
429
442
  * Internal marker on the config object that tells the `TurbineClient`
@@ -1154,6 +1167,11 @@ class TurbineClient {
1154
1167
  * query-string boundary: a connection string with an unencoded `?` inside the
1155
1168
  * password is not parseable by pg either, so there is no shape this handles
1156
1169
  * differently from the driver.
1170
+ *
1171
+ * A twin of this lives in `src/connection-url.ts`, which `turbine doctor`
1172
+ * uses for `statement_timeout`. Unifying them is the obvious refactor and it
1173
+ * is deliberately NOT done; the reason (a c8 merge artifact that costs almost
1174
+ * all of the coverage gate's headroom) is written up over there.
1157
1175
  */
1158
1176
  static mergeConnectionStringOptions(connectionString, setting) {
1159
1177
  const q = connectionString.indexOf('?');
@@ -1442,7 +1460,7 @@ class TurbineClient {
1442
1460
  const client = this;
1443
1461
  return new Proxy(primaryQI, {
1444
1462
  get(target, prop, receiver) {
1445
- if (typeof prop === 'string' && READ_OPERATIONS.has(prop)) {
1463
+ if (typeof prop === 'string' && exports.READ_OPERATIONS.has(prop)) {
1446
1464
  // Pick the replica at CALL time so round-robin advances per operation.
1447
1465
  return (...args) => {
1448
1466
  const replicaQI = client.nextReplicaTableQI(name);
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Connection-string inspection: pooler detection, and connection-time GUCs.
3
+ *
4
+ * A pure leaf. It imports nothing (not even `pg`), so every consumer, the CLI,
5
+ * the statistics collectors, and the tests, reads the same rules without
6
+ * dragging a driver in.
7
+ *
8
+ * ## Why this file exists
9
+ *
10
+ * A **transaction-pooling** proxy (PgBouncer, a Neon `-pooler` endpoint,
11
+ * Supabase's pooler) does not give a client its own server backend. It
12
+ * multiplexes many clients onto a few shared backends and hands a backend back
13
+ * to the pool at the end of each TRANSACTION, without running `DISCARD ALL`.
14
+ * Two consequences drive everything below:
15
+ *
16
+ * 1. A session-level `SET` issued outside an explicit transaction attaches to
17
+ * whichever shared backend served that statement, and stays there for the
18
+ * next client that gets it. A `SET statement_timeout` meant to bound one
19
+ * tool's own reads becomes a setting imposed on an application's queries.
20
+ * This is a production-incident class, not a theoretical one.
21
+ * 2. Session-scoped catalogs (`pg_prepared_statements` above all) describe
22
+ * whichever backend answered, which through a pooler is not "your" session
23
+ * in any useful sense.
24
+ *
25
+ * So there are two jobs here. {@link withStatementTimeoutOption} removes the
26
+ * need for the `SET` in (1) by moving the GUC into the connection's startup
27
+ * parameters, the same mechanism `TurbineClient` uses for `plan_cache_mode`.
28
+ * {@link detectPooler} lets a command that depends on session semantics refuse
29
+ * the endpoint outright rather than degrade silently.
30
+ *
31
+ * NEITHER is a substitute for the other. The connection parameter is what makes
32
+ * the collectors safe for any caller; the refusal is what keeps a diagnostic
33
+ * command from reporting confidently about a connection it cannot reason about.
34
+ */
35
+ /** Host and port pulled out of a connection string, `null` when not stated. */
36
+ export interface ConnectionTarget {
37
+ /** Lower-cased hostname, or `null` for a unix socket / unparseable string. */
38
+ host: string | null;
39
+ /** Port number, or `null` when the string does not name one. */
40
+ port: number | null;
41
+ }
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
+ export declare function parseConnectionTarget(connectionString: string): ConnectionTarget;
51
+ /**
52
+ * Hostname tokens that name a pooler.
53
+ *
54
+ * Matched as whole dot/dash/underscore-delimited TOKENS (with an optional
55
+ * trailing instance number), never as substrings of the connection string.
56
+ * Substring matching is what makes this class of check untrustworthy: a
57
+ * database named `poolers`, a role named `pooler_admin`, or a host called
58
+ * `spooler.internal` all contain the letters and none of them is a pooler, and
59
+ * a detector that cries wolf gets disabled by the first person it blocks.
60
+ */
61
+ export declare const POOLER_HOST_TOKENS: readonly string[];
62
+ /**
63
+ * Ports that name a pooler on their own.
64
+ *
65
+ * 6543 is the transaction-pooling port of the hosted poolers people point a CLI
66
+ * at. 6432 is PgBouncer's own documented default `listen_port`, so anyone who
67
+ * ran PgBouncer without changing it is here.
68
+ *
69
+ * The counter-argument to 6432 is that it refuses someone running plain
70
+ * Postgres on a non-standard port. That is true and it is the right trade,
71
+ * because the two errors do not cost the same: a false positive costs one
72
+ * `--allow-pooler` flag on a command the user is running interactively and
73
+ * reading the output of, while a false negative is silently the exact hazard
74
+ * this gate exists to prevent. When a detector's errors are asymmetric, tune it
75
+ * toward the cheap one.
76
+ */
77
+ export declare const POOLER_PORTS: readonly number[];
78
+ /** Which rule fired, for a message that can say exactly what it saw. */
79
+ export type PoolerSignal = 'host' | 'port';
80
+ export interface PoolerDetection {
81
+ /** True when the connection string looks like a transaction-pooling endpoint. */
82
+ pooled: boolean;
83
+ /** The rule that fired, or `null` when nothing did. */
84
+ signal: PoolerSignal | null;
85
+ host: string | null;
86
+ port: number | null;
87
+ /** The token that matched, for a `host` signal. */
88
+ matchedToken: string | null;
89
+ /**
90
+ * The direct hostname, when it is DERIVABLE rather than guessed: only the
91
+ * in-label `-pooler` / `-pgbouncer` suffix form (Neon's
92
+ * `ep-x-pooler.<region>.aws.neon.tech`) yields one. When the token is a whole
93
+ * label the direct endpoint is a different name entirely (Supabase's pooler
94
+ * is `<region>.pooler.supabase.com` while its direct host is
95
+ * `db.<ref>.supabase.co`), so this stays `null` rather than inventing a
96
+ * hostname that does not resolve.
97
+ */
98
+ directHost: string | null;
99
+ }
100
+ /**
101
+ * Whether a connection string points at a transaction-pooling proxy.
102
+ *
103
+ * Deliberately conservative: it answers from the endpoint's SHAPE (hostname
104
+ * tokens, port), because there is no way to ask a pooler what it is without
105
+ * connecting through it, and the whole point is to decide before connecting.
106
+ * A false negative leaves the caller where it already was; a false positive
107
+ * blocks a legitimate database, so precision wins and the caller is expected to
108
+ * offer an override.
109
+ */
110
+ export declare function detectPooler(connectionString: string): PoolerDetection;
111
+ export interface PoolerRefusalOptions {
112
+ /** The command being refused, e.g. `turbine doctor`. */
113
+ command: string;
114
+ /** The flag that overrides the refusal, e.g. `--allow-pooler`. */
115
+ allowFlag: string;
116
+ }
117
+ /**
118
+ * The lines a command prints when it refuses a pooler endpoint.
119
+ *
120
+ * Plain text, no colour and no `console` call, so it is assertable in a unit
121
+ * test and reusable by any command that grows the same gate.
122
+ */
123
+ export declare function poolerRefusalMessage(detection: PoolerDetection, options: PoolerRefusalOptions): string[];
124
+ /** A pg pool/client config, narrowed to the two fields this helper touches. */
125
+ export interface ConnectionOptionsConfig {
126
+ connectionString: string;
127
+ /** libpq `options` startup parameter. */
128
+ options?: string;
129
+ }
130
+ /**
131
+ * `config` with `statement_timeout` moved into the connection's **startup
132
+ * parameters** (`options=-c statement_timeout=<ms>`) instead of a `SET`.
133
+ *
134
+ * PostgreSQL applies the `options` startup parameter as the backend starts the
135
+ * session, so the bound is in force for the connection's very first statement
136
+ * and for its whole life, with no extra round trip and nothing to reset. The
137
+ * alternative, `SET statement_timeout = <ms>` on a fresh connection, is what
138
+ * this exists to remove: outside an explicit transaction it is exactly the
139
+ * session-state write that a transaction pooler leaves on a shared backend.
140
+ *
141
+ * `TurbineClient` uses the same mechanism for `plan_cache_mode`; see the note
142
+ * there for why a `pool.on('connect')` `SET` is not the alternative it looks
143
+ * like (it races the caller's first query through pg's deprecated same-client
144
+ * queueing).
145
+ *
146
+ * Nothing already set is discarded, in either place pg reads `options` from.
147
+ * pg's `ConnectionParameters` lets a value parsed out of the connection string
148
+ * OVERRIDE the explicit `options` field, so when the URL already carries
149
+ * `?options=...` the GUC is appended THERE; the explicit field itself falls back
150
+ * to `process.env.PGOPTIONS` only while unset, so setting it blind would drop a
151
+ * deployment's `PGOPTIONS`. Both are read first and appended to.
152
+ *
153
+ * THE INJECTION BOUNDARY: a GUC value cannot be a bind parameter, so the
154
+ * emitted text necessarily contains a literal. `statementTimeoutMs` is
155
+ * therefore narrowed to a non-negative safe INTEGER and rendered from the
156
+ * narrowed number; anything else (a float, a negative, `NaN`, a string that
157
+ * coerced) returns the config untouched rather than reaching the wire, because
158
+ * a value that can carry a space can carry a second `-c`.
159
+ */
160
+ export declare function withStatementTimeoutOption(config: ConnectionOptionsConfig, statementTimeoutMs: number): ConnectionOptionsConfig;