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.
- package/README.md +164 -1041
- package/dist/cjs/cli/compile-query.d.ts +198 -0
- package/dist/cjs/cli/compile-query.js +529 -0
- package/dist/cjs/cli/index.d.ts +25 -1
- package/dist/cjs/cli/index.js +49 -1
- package/dist/cjs/cli/mcp.js +198 -16
- package/dist/cjs/client.d.ts +45 -10
- package/dist/cjs/client.js +21 -3
- package/dist/cjs/connection-url.d.ts +160 -0
- package/dist/cjs/connection-url.js +296 -0
- package/dist/cjs/index-stats.d.ts +4 -1
- package/dist/cjs/index-stats.js +27 -11
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/plan-flip-probe.js +17 -1
- package/dist/cjs/powql.d.ts +1 -0
- package/dist/cjs/powql.js +9 -0
- package/dist/cjs/query/builder.d.ts +133 -2
- package/dist/cjs/query/builder.js +288 -64
- package/dist/cjs/query/deferred.d.ts +12 -6
- package/dist/cjs/query/index.d.ts +1 -1
- package/dist/cjs/query/option-surface.js +6 -0
- package/dist/cjs/query/types.d.ts +47 -0
- package/dist/cjs/query/where.d.ts +11 -2
- package/dist/cli/compile-query.d.ts +198 -0
- package/dist/cli/compile-query.js +522 -0
- package/dist/cli/index.d.ts +25 -1
- package/dist/cli/index.js +48 -1
- package/dist/cli/mcp.js +198 -16
- package/dist/client.d.ts +45 -10
- package/dist/client.js +19 -1
- package/dist/connection-url.d.ts +160 -0
- package/dist/connection-url.js +289 -0
- package/dist/index-stats.d.ts +4 -1
- package/dist/index-stats.js +27 -11
- package/dist/index.d.ts +1 -1
- package/dist/plan-flip-probe.js +17 -1
- package/dist/powql.d.ts +1 -0
- package/dist/powql.js +9 -0
- package/dist/query/builder.d.ts +133 -2
- package/dist/query/builder.js +288 -64
- package/dist/query/deferred.d.ts +12 -6
- package/dist/query/index.d.ts +1 -1
- package/dist/query/option-surface.js +6 -0
- package/dist/query/types.d.ts +47 -0
- package/dist/query/where.d.ts +11 -2
- package/package.json +8 -6
package/dist/cjs/cli/mcp.js
CHANGED
|
@@ -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.
|
package/dist/cjs/client.d.ts
CHANGED
|
@@ -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'
|
|
371
|
-
*
|
|
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
|
|
376
|
-
* to `'object'`.
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
* `
|
|
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?:
|
|
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
|
/**
|
package/dist/cjs/client.js
CHANGED
|
@@ -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
|
-
|
|
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;
|