turbine-orm 0.65.0 → 0.66.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 +34 -32
- package/dist/adapters/cockroachdb.js +21 -3
- package/dist/adapters/index.d.ts +15 -0
- package/dist/adapters/yugabytedb.js +20 -3
- package/dist/cjs/adapters/cockroachdb.js +21 -3
- package/dist/cjs/adapters/index.d.ts +15 -0
- package/dist/cjs/adapters/yugabytedb.js +20 -3
- package/dist/cjs/cli/destructive.d.ts +18 -4
- package/dist/cjs/cli/destructive.js +230 -122
- package/dist/cjs/cli/index.d.ts +21 -4
- package/dist/cjs/cli/index.js +119 -22
- package/dist/cjs/cli/mcp.d.ts +28 -8
- package/dist/cjs/cli/mcp.js +170 -127
- package/dist/cjs/cli/migrate.d.ts +134 -13
- package/dist/cjs/cli/migrate.js +349 -241
- package/dist/cjs/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cjs/cli/pii-predicate-guard.js +390 -0
- package/dist/cjs/cli/prisma-resolve.js +75 -4
- package/dist/cjs/cli/prisma-schema.d.ts +17 -1
- package/dist/cjs/cli/prisma-schema.js +83 -17
- package/dist/cjs/cli/sql-statements.d.ts +125 -0
- package/dist/cjs/cli/sql-statements.js +378 -0
- package/dist/cjs/cli/studio.js +49 -118
- package/dist/cjs/cli/ui.d.ts +1 -1
- package/dist/cjs/client.d.ts +43 -0
- package/dist/cjs/client.js +125 -6
- package/dist/cjs/dialect.d.ts +123 -0
- package/dist/cjs/dialect.js +33 -0
- package/dist/cjs/errors.d.ts +74 -1
- package/dist/cjs/errors.js +239 -25
- package/dist/cjs/index-advisor.d.ts +33 -1
- package/dist/cjs/index-advisor.js +32 -1
- package/dist/cjs/introspect.d.ts +48 -0
- package/dist/cjs/introspect.js +222 -91
- package/dist/cjs/mssql.js +43 -1
- package/dist/cjs/mysql.d.ts +5 -2
- package/dist/cjs/mysql.js +202 -17
- package/dist/cjs/nested-write.js +6 -1
- package/dist/cjs/pipeline-submittable.js +17 -3
- package/dist/cjs/pipeline.js +75 -9
- package/dist/cjs/powdb.d.ts +23 -0
- package/dist/cjs/powdb.js +33 -1
- package/dist/cjs/powql.d.ts +61 -9
- package/dist/cjs/powql.js +186 -49
- package/dist/cjs/prisma-compat.js +160 -41
- package/dist/cjs/query/aggregates.d.ts +1 -1
- package/dist/cjs/query/aggregates.js +80 -18
- package/dist/cjs/query/batched-loader.d.ts +10 -0
- package/dist/cjs/query/batched-loader.js +268 -7
- package/dist/cjs/query/builder.d.ts +73 -0
- package/dist/cjs/query/builder.js +225 -28
- package/dist/cjs/query/filters.d.ts +162 -0
- package/dist/cjs/query/filters.js +250 -1
- package/dist/cjs/query/relations.d.ts +10 -10
- package/dist/cjs/query/relations.js +93 -12
- package/dist/cjs/query/types.d.ts +14 -1
- package/dist/cjs/query/utils.d.ts +146 -2
- package/dist/cjs/query/utils.js +210 -4
- package/dist/cjs/query/warn-registry.d.ts +10 -0
- package/dist/cjs/query/warn-registry.js +10 -0
- package/dist/cjs/query/where-compile.d.ts +30 -0
- package/dist/cjs/query/where-compile.js +41 -0
- package/dist/cjs/query/where.d.ts +128 -13
- package/dist/cjs/query/where.js +215 -77
- package/dist/cjs/query/writes.d.ts +1 -1
- package/dist/cjs/query/writes.js +39 -15
- package/dist/cjs/schema-builder.d.ts +2 -1
- package/dist/cjs/schema-sql.d.ts +94 -4
- package/dist/cjs/schema-sql.js +506 -30
- package/dist/cjs/schema.d.ts +3 -1
- package/dist/cjs/sqlite.d.ts +6 -0
- package/dist/cjs/sqlite.js +151 -10
- package/dist/cjs/typed-sql.d.ts +29 -1
- package/dist/cjs/typed-sql.js +30 -12
- package/dist/cli/destructive.d.ts +18 -4
- package/dist/cli/destructive.js +229 -121
- package/dist/cli/index.d.ts +21 -4
- package/dist/cli/index.js +120 -24
- package/dist/cli/mcp.d.ts +28 -8
- package/dist/cli/mcp.js +172 -129
- package/dist/cli/migrate.d.ts +134 -13
- package/dist/cli/migrate.js +347 -238
- package/dist/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cli/pii-predicate-guard.js +386 -0
- package/dist/cli/prisma-resolve.js +75 -4
- package/dist/cli/prisma-schema.d.ts +17 -1
- package/dist/cli/prisma-schema.js +83 -17
- package/dist/cli/sql-statements.d.ts +125 -0
- package/dist/cli/sql-statements.js +373 -0
- package/dist/cli/studio.js +49 -118
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +43 -0
- package/dist/client.js +126 -7
- package/dist/dialect.d.ts +123 -0
- package/dist/dialect.js +33 -0
- package/dist/errors.d.ts +74 -1
- package/dist/errors.js +228 -19
- package/dist/index-advisor.d.ts +33 -1
- package/dist/index-advisor.js +31 -1
- package/dist/introspect.d.ts +48 -0
- package/dist/introspect.js +221 -91
- package/dist/mssql.js +44 -2
- package/dist/mysql.d.ts +5 -2
- package/dist/mysql.js +203 -18
- package/dist/nested-write.js +7 -2
- package/dist/pipeline-submittable.js +18 -4
- package/dist/pipeline.js +76 -10
- package/dist/powdb.d.ts +23 -0
- package/dist/powdb.js +33 -2
- package/dist/powql.d.ts +61 -9
- package/dist/powql.js +187 -50
- package/dist/prisma-compat.js +160 -41
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +82 -20
- package/dist/query/batched-loader.d.ts +10 -0
- package/dist/query/batched-loader.js +270 -9
- package/dist/query/builder.d.ts +73 -0
- package/dist/query/builder.js +226 -30
- package/dist/query/filters.d.ts +162 -0
- package/dist/query/filters.js +246 -1
- package/dist/query/relations.d.ts +10 -10
- package/dist/query/relations.js +94 -14
- package/dist/query/types.d.ts +14 -1
- package/dist/query/utils.d.ts +146 -2
- package/dist/query/utils.js +204 -3
- package/dist/query/warn-registry.d.ts +10 -0
- package/dist/query/warn-registry.js +10 -0
- package/dist/query/where-compile.d.ts +30 -0
- package/dist/query/where-compile.js +40 -1
- package/dist/query/where.d.ts +128 -13
- package/dist/query/where.js +216 -80
- package/dist/query/writes.d.ts +1 -1
- package/dist/query/writes.js +40 -16
- package/dist/schema-builder.d.ts +2 -1
- package/dist/schema-sql.d.ts +94 -4
- package/dist/schema-sql.js +505 -30
- package/dist/schema.d.ts +3 -1
- package/dist/sqlite.d.ts +6 -0
- package/dist/sqlite.js +151 -10
- package/dist/typed-sql.d.ts +29 -1
- package/dist/typed-sql.js +30 -12
- package/package.json +6 -4
package/dist/cli/studio.js
CHANGED
|
@@ -49,6 +49,7 @@ import { QueryInterface, quoteIdent } from '../query/index.js';
|
|
|
49
49
|
// `ownLookup` is not re-exported from the query barrel, so it is imported from
|
|
50
50
|
// its defining leaf module rather than duplicated here.
|
|
51
51
|
import { ownLookup, registerUtcTemporalParsers } from '../query/utils.js';
|
|
52
|
+
import { assertNoPiiPredicates as assertNoPiiPredicatesShared } from './pii-predicate-guard.js';
|
|
52
53
|
import { applyPiiTags, loadPiiTags } from './pii-tags.js';
|
|
53
54
|
import { callerKey, checkRateLimit } from './rate-limit.js';
|
|
54
55
|
import { createDemoContext } from './studio-demo.js';
|
|
@@ -478,7 +479,7 @@ export function relationLinksForTable(table, metadata, showPii) {
|
|
|
478
479
|
const referencedBy = [];
|
|
479
480
|
const seenFkColumns = new Set();
|
|
480
481
|
for (const [name, rel] of Object.entries(table.relations)) {
|
|
481
|
-
const target = metadata.tables
|
|
482
|
+
const target = ownLookup(metadata.tables, rel.to);
|
|
482
483
|
if (!target)
|
|
483
484
|
continue;
|
|
484
485
|
if (rel.type === 'belongsTo') {
|
|
@@ -780,18 +781,6 @@ function parseTableFilters(raw, table, redactedPii) {
|
|
|
780
781
|
// ---------------------------------------------------------------------------
|
|
781
782
|
// API: /api/builder: Turbine ORM findMany spec runner
|
|
782
783
|
// ---------------------------------------------------------------------------
|
|
783
|
-
/** Relation-filter wrappers whose body is a clause against the relation's target. */
|
|
784
|
-
const RELATION_FILTER_WRAPPERS = ['some', 'none', 'every', 'is', 'isNot'];
|
|
785
|
-
/**
|
|
786
|
-
* Recursion bound for the PII guard walk.
|
|
787
|
-
*
|
|
788
|
-
* This number is NOT the security boundary: reaching it REFUSES the request
|
|
789
|
-
* (see `assertWithinDepth`). It only bounds the walk on a pathological payload.
|
|
790
|
-
* It sits well above the query builder's own depth-10 relation cap
|
|
791
|
-
* (`CircularRelationError`) and far above any hand-composed boolean nesting, so
|
|
792
|
-
* nothing the builder would accept is refused here for depth alone.
|
|
793
|
-
*/
|
|
794
|
-
const PII_GUARD_MAX_DEPTH = 32;
|
|
795
784
|
/**
|
|
796
785
|
* Refuse a builder query that FILTERS, SORTS, PAGES, or DE-DUPLICATES on a
|
|
797
786
|
* redacted PII column.
|
|
@@ -805,111 +794,39 @@ const PII_GUARD_MAX_DEPTH = 32;
|
|
|
805
794
|
* already refuses its own equivalents (`parseTableFilters`); the builder route
|
|
806
795
|
* has to refuse all of them too.
|
|
807
796
|
*
|
|
808
|
-
*
|
|
809
|
-
* `
|
|
810
|
-
*
|
|
811
|
-
*
|
|
797
|
+
* The WALK lives in `cli/pii-predicate-guard.ts`, shared with the MCP server's
|
|
798
|
+
* `explain_query`, which asks the same question of the same arg tree. It used to
|
|
799
|
+
* be a second hand-written copy here, and the two drifted exactly the way copies
|
|
800
|
+
* do: a hole opened in both and could only have been closed in one. What stays
|
|
801
|
+
* here is the part that is genuinely Studio's: WHICH columns are hidden (a
|
|
802
|
+
* code-first `pii` tag, unless `--show-pii`), and what a refusal looks like (a
|
|
803
|
+
* `ValidationError`, which `/api/builder` renders as a 400).
|
|
812
804
|
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
815
|
-
* wrappers walked the guard off the end of its own recursion and then handed
|
|
816
|
-
* the untouched predicate to the builder.
|
|
805
|
+
* `select` / `omit` are NOT refused: they return values, and those values are
|
|
806
|
+
* redacted on the way out.
|
|
817
807
|
*/
|
|
818
808
|
function assertNoPiiPredicates(args, tableName, metadata, showPii) {
|
|
819
809
|
if (showPii)
|
|
820
810
|
return;
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
`
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
for (const item of node)
|
|
841
|
-
visitClause(item, table, depth);
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
for (const [key, value] of Object.entries(node)) {
|
|
845
|
-
if (key === 'AND' || key === 'OR' || key === 'NOT') {
|
|
846
|
-
visitClause(value, table, depth + 1);
|
|
847
|
-
continue;
|
|
848
|
-
}
|
|
849
|
-
const relation = Object.hasOwn(table.relations, key) ? table.relations[key] : undefined;
|
|
850
|
-
if (relation) {
|
|
851
|
-
visitRelationValue(value, metadata.tables[relation.to], depth + 1);
|
|
852
|
-
continue;
|
|
853
|
-
}
|
|
854
|
-
const column = ownLookup(table.columnMap, key) ?? key;
|
|
855
|
-
if (isRedactedColumn(table, column, showPii))
|
|
856
|
-
refuse(table, column);
|
|
857
|
-
}
|
|
858
|
-
};
|
|
859
|
-
/**
|
|
860
|
-
* A relation predicate arrives in one of two shapes, and BOTH resolve against
|
|
861
|
-
* the relation's target table: bare (`{ user: { email: {...} } }`) or wrapped
|
|
862
|
-
* in a cardinality / to-one operator (`{ user: { is: { email: {...} } } }`,
|
|
863
|
-
* likewise `some` / `none` / `every` / `isNot`). Handing the wrapper straight
|
|
864
|
-
* to `visitClause` walked its keys as if `is` were a column of the target, so
|
|
865
|
-
* the inner clause was never visited and a PII predicate slipped through.
|
|
866
|
-
* Descend into every wrapper member AND into the value itself.
|
|
867
|
-
*/
|
|
868
|
-
const visitRelationValue = (value, target, depth) => {
|
|
869
|
-
assertWithinDepth(depth);
|
|
870
|
-
if (!target || value === null || typeof value !== 'object')
|
|
871
|
-
return;
|
|
872
|
-
const node = value;
|
|
873
|
-
for (const wrapper of RELATION_FILTER_WRAPPERS) {
|
|
874
|
-
if (Object.hasOwn(node, wrapper))
|
|
875
|
-
visitClause(node[wrapper], target, depth + 1);
|
|
876
|
-
}
|
|
877
|
-
visitClause(node, target, depth);
|
|
878
|
-
};
|
|
879
|
-
/** Field-name lists (`distinct`) name columns directly rather than in a clause. */
|
|
880
|
-
const visitFieldList = (value, table) => {
|
|
881
|
-
if (!Array.isArray(value))
|
|
882
|
-
return;
|
|
883
|
-
for (const field of value) {
|
|
884
|
-
if (typeof field !== 'string')
|
|
885
|
-
continue;
|
|
886
|
-
const column = ownLookup(table.columnMap, field) ?? field;
|
|
887
|
-
if (isRedactedColumn(table, column, showPii))
|
|
888
|
-
refuse(table, column);
|
|
889
|
-
}
|
|
890
|
-
};
|
|
891
|
-
const visitLevel = (level, table, depth) => {
|
|
892
|
-
assertWithinDepth(depth);
|
|
893
|
-
if (!table)
|
|
894
|
-
return;
|
|
895
|
-
visitClause(level.where, table, depth);
|
|
896
|
-
visitClause(level.orderBy, table, depth);
|
|
897
|
-
// `cursor` is a flat `{ field: value }` seek key that the builder turns into
|
|
898
|
-
// a WHERE range comparison against the sort key, so it reads exactly like a
|
|
899
|
-
// where on the same column.
|
|
900
|
-
visitClause(level.cursor, table, depth);
|
|
901
|
-
visitFieldList(level.distinct, table);
|
|
902
|
-
const withClause = level.with;
|
|
903
|
-
if (!withClause || typeof withClause !== 'object')
|
|
904
|
-
return;
|
|
905
|
-
for (const [relName, spec] of Object.entries(withClause)) {
|
|
906
|
-
const relation = Object.hasOwn(table.relations, relName) ? table.relations[relName] : undefined;
|
|
907
|
-
if (!relation || spec === true || spec === null || typeof spec !== 'object')
|
|
908
|
-
continue;
|
|
909
|
-
visitLevel(spec, metadata.tables[relation.to], depth + 1);
|
|
910
|
-
}
|
|
911
|
-
};
|
|
912
|
-
visitLevel(args, metadata.tables[tableName], 0);
|
|
811
|
+
assertNoPiiPredicatesShared(args, ownLookup(metadata.tables, tableName), {
|
|
812
|
+
metadata,
|
|
813
|
+
hiddenReason: (table, column) => (isRedactedColumn(table, column, showPii) ? 'is PII-tagged and redacted' : null),
|
|
814
|
+
refuseColumn: (table, column, reason) => {
|
|
815
|
+
throw new ValidationError(`[turbine] Column "${column}" on "${table.name}" ${reason}, so it cannot be used ` +
|
|
816
|
+
`in a where, orderBy, cursor, or distinct: filtering, sorting, paging, or de-duplicating on a hidden ` +
|
|
817
|
+
`value reveals it. Restart Studio with --show-pii to query it.`);
|
|
818
|
+
},
|
|
819
|
+
refuseDepth: (maxDepth) => {
|
|
820
|
+
throw new ValidationError(`[turbine] Query is nested more than ${maxDepth} levels deep, which is past the point where ` +
|
|
821
|
+
`Studio can prove it does not filter or sort on a PII-tagged and redacted column, so it is refused. ` +
|
|
822
|
+
`Flatten the query, or restart Studio with --show-pii.`);
|
|
823
|
+
},
|
|
824
|
+
refuseShape: (table, key) => {
|
|
825
|
+
throw new ValidationError(`[turbine] Studio does not recognize "${key}" in a query on "${table.name}", so it cannot prove the ` +
|
|
826
|
+
`query does not filter or sort on a PII-tagged and redacted column, and refuses it rather than ` +
|
|
827
|
+
`guessing. Remove it, or restart Studio with --show-pii.`);
|
|
828
|
+
},
|
|
829
|
+
});
|
|
913
830
|
}
|
|
914
831
|
export async function apiBuilder(req, res, ctx) {
|
|
915
832
|
const body = await readJsonBody(req);
|
|
@@ -940,7 +857,7 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
940
857
|
// here (the user named the columns, and the values are redacted on the way
|
|
941
858
|
// out); only the empty default projection is refused, with a reason instead
|
|
942
859
|
// of a syntax error.
|
|
943
|
-
const target = ctx.metadata.tables
|
|
860
|
+
const target = ownLookup(ctx.metadata.tables, tableName);
|
|
944
861
|
if (target && !ctx.showPii && args.select === undefined && target.columns.every((c) => c.pii === true)) {
|
|
945
862
|
sendJson(res, 400, {
|
|
946
863
|
error: `Every column of "${tableName}" is PII-tagged, so the default projection is empty. Name the columns you ` +
|
|
@@ -974,7 +891,17 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
974
891
|
// the connection's search_path. Pin it to the configured --schema so the
|
|
975
892
|
// Query tab reads the same schema as the Data tab (set_config is
|
|
976
893
|
// transaction-local and fully parameterized). Demo has no schemas.
|
|
977
|
-
|
|
894
|
+
//
|
|
895
|
+
// The VALUE is bound as a parameter, but Postgres parses the contents of
|
|
896
|
+
// search_path as an identifier LIST, so a name that needs quoting has to
|
|
897
|
+
// carry its own. Passing `My.Schema` raw makes Postgres read it as a
|
|
898
|
+
// two-part token that matches nothing: `current_schemas(false)` came back
|
|
899
|
+
// `{}` and the very next query died with `relation "widgets" does not
|
|
900
|
+
// exist` (measured on PG 16). A schema whose name merely differs in CASE
|
|
901
|
+
// is worse than that, because it silently folds to a DIFFERENT existing
|
|
902
|
+
// schema instead of failing. quoteIdent applies exactly the doubling rule
|
|
903
|
+
// the GUC parser expects.
|
|
904
|
+
await client.query(`SELECT set_config('search_path', $1, true)`, [quoteIdent(ctx.options.schema)]);
|
|
978
905
|
}
|
|
979
906
|
const started = Date.now();
|
|
980
907
|
const result = await client.query(deferred.sql, deferred.params);
|
|
@@ -1173,7 +1100,11 @@ export async function apiRowWrite(req, res, ctx, op) {
|
|
|
1173
1100
|
await client.query('BEGIN');
|
|
1174
1101
|
if (!ctx.demo) {
|
|
1175
1102
|
await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
|
|
1176
|
-
|
|
1103
|
+
// Quoted for the same reason as the read path above: the bound value is
|
|
1104
|
+
// parsed as an identifier list, so an unquoted mixed-case or dotted schema
|
|
1105
|
+
// name pins search_path to nothing. On THIS path that would mean a write
|
|
1106
|
+
// aimed at a non-default schema resolving somewhere else entirely.
|
|
1107
|
+
await client.query(`SELECT set_config('search_path', $1, true)`, [quoteIdent(ctx.options.schema)]);
|
|
1177
1108
|
}
|
|
1178
1109
|
const returnedRows = [];
|
|
1179
1110
|
let rowCount = 0;
|
|
@@ -1323,7 +1254,7 @@ export async function apiCreateSavedQuery(req, res, ctx) {
|
|
|
1323
1254
|
const body = await readJsonBody(req);
|
|
1324
1255
|
const table = typeof body?.table === 'string' ? body.table : '';
|
|
1325
1256
|
const name = typeof body?.name === 'string' ? body.name.trim() : '';
|
|
1326
|
-
if (!table || !ctx.metadata.tables
|
|
1257
|
+
if (!table || !ownLookup(ctx.metadata.tables, table)) {
|
|
1327
1258
|
sendJson(res, 400, { error: unknownTableMessage(table, ctx) });
|
|
1328
1259
|
return;
|
|
1329
1260
|
}
|
|
@@ -1432,7 +1363,7 @@ function redactFlatRow(row, piiKeys) {
|
|
|
1432
1363
|
* arrive as parsed json objects keyed by camelCase field names).
|
|
1433
1364
|
*/
|
|
1434
1365
|
function redactBuilderRows(rows, tableName, withClause, metadata) {
|
|
1435
|
-
const table = metadata.tables
|
|
1366
|
+
const table = ownLookup(metadata.tables, tableName);
|
|
1436
1367
|
if (!table)
|
|
1437
1368
|
return rows;
|
|
1438
1369
|
const piiKeys = piiKeysForTable(table);
|
package/dist/cli/ui.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export declare const symbols: {
|
|
|
33
33
|
readonly info: "i" | "ℹ";
|
|
34
34
|
readonly warning: "⚠" | "!";
|
|
35
35
|
readonly dot: "." | "∙";
|
|
36
|
-
readonly line: "
|
|
36
|
+
readonly line: "-" | "─";
|
|
37
37
|
readonly vertLine: "|" | "│";
|
|
38
38
|
readonly topLeft: "+" | "╭";
|
|
39
39
|
readonly topRight: "+" | "╮";
|
package/dist/client.d.ts
CHANGED
|
@@ -724,6 +724,15 @@ export declare class TurbineClient {
|
|
|
724
724
|
private readonly queryListeners;
|
|
725
725
|
private queryOptions;
|
|
726
726
|
private readonly errorMessagesSafe;
|
|
727
|
+
/**
|
|
728
|
+
* THIS client's error-message mode. Not the process default: see
|
|
729
|
+
* {@link registerClientErrorMessageMode}. Used to scope every operation
|
|
730
|
+
* issued through this client, but only once two clients in the process have
|
|
731
|
+
* asked for different modes.
|
|
732
|
+
*/
|
|
733
|
+
private readonly errorMessageMode;
|
|
734
|
+
/** Per-table mode-scoping accessors, built lazily (see {@link table}). */
|
|
735
|
+
private readonly errorScopedTableCache;
|
|
727
736
|
/** Whether `$on('query')` events carry real params (see `logQueryParams`). */
|
|
728
737
|
private readonly queryParamsVisible;
|
|
729
738
|
/** True when Turbine created the pool and is responsible for tearing it down */
|
|
@@ -913,6 +922,39 @@ export declare class TurbineClient {
|
|
|
913
922
|
* With no replicas the original single-pool instance is returned directly.
|
|
914
923
|
*/
|
|
915
924
|
table<T extends object = Record<string, unknown>>(name: string): QueryInterface<T>;
|
|
925
|
+
/** Get (and cache) the replica-routing accessor for a table. */
|
|
926
|
+
private routingAccessor;
|
|
927
|
+
/**
|
|
928
|
+
* Wrap a table accessor so every operation called through it runs with THIS
|
|
929
|
+
* client's `errorMessages` mode in force, whatever another client set as the
|
|
930
|
+
* process default.
|
|
931
|
+
*
|
|
932
|
+
* A proxy rather than a wrapper object: the QueryInterface mutates its own
|
|
933
|
+
* instance state during a query, so methods must run with the real instance
|
|
934
|
+
* as `this` (the same reason {@link createRoutingAccessor} is built this
|
|
935
|
+
* way). Method wrappers are memoized per property so a hot loop allocates one
|
|
936
|
+
* closure per operation NAME, not per call.
|
|
937
|
+
*
|
|
938
|
+
* The divergence test lives in the wrapper body, evaluated when the operation
|
|
939
|
+
* is CALLED. That placement is the point: an accessor minted before a second
|
|
940
|
+
* client diverges still scopes correctly once it does, and while no client has
|
|
941
|
+
* diverged the module default is already this client's mode, so the wrapper
|
|
942
|
+
* calls straight through and no AsyncLocalStorage scope is established at all.
|
|
943
|
+
*/
|
|
944
|
+
private createErrorModeAccessor;
|
|
945
|
+
/**
|
|
946
|
+
* Run `fn` with this client's `errorMessages` mode in force, or directly when
|
|
947
|
+
* no other client in the process has diverged from it (the common case, which
|
|
948
|
+
* costs nothing). Used by the entry points that do not go through
|
|
949
|
+
* {@link table}: transactions, raw SQL, `pipeline`, and the typed-SQL builder.
|
|
950
|
+
*
|
|
951
|
+
* The typed-SQL builder is the one that cannot be covered by wrapping the
|
|
952
|
+
* call, because it is lazy: `sql` hands this function to `TypedSqlQuery` so
|
|
953
|
+
* the scope reaches the EXECUTION rather than the template. This docstring
|
|
954
|
+
* listed it as covered before 0.66 and it was not, which is the reason the
|
|
955
|
+
* mechanism is spelled out here instead of just named.
|
|
956
|
+
*/
|
|
957
|
+
private withErrorMode;
|
|
916
958
|
/** Get (and cache) the primary-pool-bound QueryInterface for a table. */
|
|
917
959
|
private primaryTableQI;
|
|
918
960
|
/**
|
|
@@ -1065,6 +1107,7 @@ export declare class TurbineClient {
|
|
|
1065
1107
|
* ```
|
|
1066
1108
|
*/
|
|
1067
1109
|
$transaction<T extends readonly DeferredQuery<unknown>[]>(queries: readonly [...T]): Promise<PipelineResults<T>>;
|
|
1110
|
+
private runTransaction;
|
|
1068
1111
|
/**
|
|
1069
1112
|
* Execute a batch of {@link DeferredQuery} objects atomically inside one
|
|
1070
1113
|
* transaction. Backs the `$transaction([...])` array overload. Reuses the raw
|
package/dist/client.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import pg from 'pg';
|
|
25
25
|
import { postgresDialect } from './dialect.js';
|
|
26
|
-
import { ConnectionError, setErrorMessageMode, TimeoutError, UnsupportedFeatureError, ValidationError, wrapPgError, } from './errors.js';
|
|
26
|
+
import { ConnectionError, errorMessageModesDiverged, registerClientErrorMessageMode, runWithErrorMessageMode, setErrorMessageMode, TimeoutError, UnsupportedFeatureError, ValidationError, wrapPgError, } from './errors.js';
|
|
27
27
|
import { ObserveEngine } from './observe.js';
|
|
28
28
|
import { executePipeline, pipelineSupported } from './pipeline.js';
|
|
29
29
|
import { QueryInterface, } from './query/index.js';
|
|
@@ -640,6 +640,15 @@ export class TurbineClient {
|
|
|
640
640
|
queryListeners = new Set();
|
|
641
641
|
queryOptions;
|
|
642
642
|
errorMessagesSafe;
|
|
643
|
+
/**
|
|
644
|
+
* THIS client's error-message mode. Not the process default: see
|
|
645
|
+
* {@link registerClientErrorMessageMode}. Used to scope every operation
|
|
646
|
+
* issued through this client, but only once two clients in the process have
|
|
647
|
+
* asked for different modes.
|
|
648
|
+
*/
|
|
649
|
+
errorMessageMode;
|
|
650
|
+
/** Per-table mode-scoping accessors, built lazily (see {@link table}). */
|
|
651
|
+
errorScopedTableCache = new Map();
|
|
643
652
|
/** Whether `$on('query')` events carry real params (see `logQueryParams`). */
|
|
644
653
|
queryParamsVisible;
|
|
645
654
|
/** True when Turbine created the pool and is responsible for tearing it down */
|
|
@@ -677,6 +686,7 @@ export class TurbineClient {
|
|
|
677
686
|
this.logging = parent.logging;
|
|
678
687
|
this.dialect = parent.dialect;
|
|
679
688
|
this.errorMessagesSafe = parent.errorMessagesSafe;
|
|
689
|
+
this.errorMessageMode = parent.errorMessageMode;
|
|
680
690
|
this.queryParamsVisible = parent.queryParamsVisible;
|
|
681
691
|
this.queryOptions = parent.queryOptions;
|
|
682
692
|
this.middlewares = parent.middlewares; // shared reference: $use on parent flows through
|
|
@@ -830,7 +840,8 @@ export class TurbineClient {
|
|
|
830
840
|
this.schema = schema;
|
|
831
841
|
// Respect env var kill switch
|
|
832
842
|
const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
|
|
833
|
-
this.
|
|
843
|
+
this.errorMessageMode = config.errorMessages ?? 'safe';
|
|
844
|
+
this.errorMessagesSafe = this.errorMessageMode === 'safe';
|
|
834
845
|
// Query-event param visibility. One derived boolean, so the two config
|
|
835
846
|
// spellings can never disagree: `logQueryParams` wins when set, otherwise
|
|
836
847
|
// `errorMessages` keeps deciding exactly as it always has.
|
|
@@ -878,6 +889,16 @@ export class TurbineClient {
|
|
|
878
889
|
};
|
|
879
890
|
// Apply NotFoundError message redaction mode (default: safe, values are
|
|
880
891
|
// stripped from messages to avoid leaking PII into error logs).
|
|
892
|
+
//
|
|
893
|
+
// The process-wide default keeps being set here, unchanged, so a directly
|
|
894
|
+
// constructed error and any single-client process behave exactly as before.
|
|
895
|
+
// What is new is the REGISTRATION: a second client asking for a different
|
|
896
|
+
// mode makes both of them scope their own operations, instead of the last
|
|
897
|
+
// constructor silently deciding for everyone (see
|
|
898
|
+
// registerClientErrorMessageMode). Registration uses the client's effective
|
|
899
|
+
// mode, defaulted, because two clients only agree if their EFFECTIVE modes
|
|
900
|
+
// agree, and an omitted `errorMessages` is an effective 'safe'.
|
|
901
|
+
registerClientErrorMessageMode(this.errorMessageMode);
|
|
881
902
|
if (config.errorMessages) {
|
|
882
903
|
setErrorMessageMode(config.errorMessages);
|
|
883
904
|
}
|
|
@@ -1219,6 +1240,11 @@ export class TurbineClient {
|
|
|
1219
1240
|
this.routingProxyCache.clear();
|
|
1220
1241
|
for (const cache of this.replicaTableCaches)
|
|
1221
1242
|
cache.clear();
|
|
1243
|
+
// The errorMessages accessors too: each proxy holds its target by closure,
|
|
1244
|
+
// so a stale one keeps handing back the pre-$use QueryInterface. Every
|
|
1245
|
+
// cache `table()` reads through has to be listed here, and this one is the
|
|
1246
|
+
// OUTERMOST, so leaving it out silently defeats all three above.
|
|
1247
|
+
this.errorScopedTableCache.clear();
|
|
1222
1248
|
}
|
|
1223
1249
|
// -------------------------------------------------------------------------
|
|
1224
1250
|
// Event emitter, subscribe to query lifecycle events
|
|
@@ -1266,9 +1292,31 @@ export class TurbineClient {
|
|
|
1266
1292
|
* With no replicas the original single-pool instance is returned directly.
|
|
1267
1293
|
*/
|
|
1268
1294
|
table(name) {
|
|
1269
|
-
|
|
1270
|
-
|
|
1295
|
+
const base = this.replicaPools.length === 0 ? this.primaryTableQI(name) : this.routingAccessor(name);
|
|
1296
|
+
// The errorMessages accessor is built ALWAYS, not only once two clients have
|
|
1297
|
+
// diverged. It used to be conditional here, and the condition was wrong in
|
|
1298
|
+
// the one direction that leaks: divergence is a property of the PROCESS over
|
|
1299
|
+
// time, while a table accessor is handed out once and kept. An application
|
|
1300
|
+
// that does `const users = db.users` at module load, then constructs a
|
|
1301
|
+
// second client with `errorMessages: 'verbose'` later (an analytics client,
|
|
1302
|
+
// a per-suite test harness), held a bare unscoped reference forever, so the
|
|
1303
|
+
// safe client's `findUniqueOrThrow` misses started rendering row values.
|
|
1304
|
+
// Building the proxy unconditionally is what makes the mode a property of
|
|
1305
|
+
// the client rather than of the construction order.
|
|
1306
|
+
//
|
|
1307
|
+
// The divergence check did not disappear, it moved INSIDE the wrapper, so it
|
|
1308
|
+
// is asked when the operation runs rather than when the accessor is minted.
|
|
1309
|
+
// While every client agrees the wrapper still establishes no scope and the
|
|
1310
|
+
// query path still pays nothing but one proxy trap.
|
|
1311
|
+
let scoped = this.errorScopedTableCache.get(name);
|
|
1312
|
+
if (!scoped) {
|
|
1313
|
+
scoped = this.createErrorModeAccessor(base);
|
|
1314
|
+
this.errorScopedTableCache.set(name, scoped);
|
|
1271
1315
|
}
|
|
1316
|
+
return scoped;
|
|
1317
|
+
}
|
|
1318
|
+
/** Get (and cache) the replica-routing accessor for a table. */
|
|
1319
|
+
routingAccessor(name) {
|
|
1272
1320
|
let proxy = this.routingProxyCache.get(name);
|
|
1273
1321
|
if (!proxy) {
|
|
1274
1322
|
proxy = this.createRoutingAccessor(name);
|
|
@@ -1276,6 +1324,58 @@ export class TurbineClient {
|
|
|
1276
1324
|
}
|
|
1277
1325
|
return proxy;
|
|
1278
1326
|
}
|
|
1327
|
+
/**
|
|
1328
|
+
* Wrap a table accessor so every operation called through it runs with THIS
|
|
1329
|
+
* client's `errorMessages` mode in force, whatever another client set as the
|
|
1330
|
+
* process default.
|
|
1331
|
+
*
|
|
1332
|
+
* A proxy rather than a wrapper object: the QueryInterface mutates its own
|
|
1333
|
+
* instance state during a query, so methods must run with the real instance
|
|
1334
|
+
* as `this` (the same reason {@link createRoutingAccessor} is built this
|
|
1335
|
+
* way). Method wrappers are memoized per property so a hot loop allocates one
|
|
1336
|
+
* closure per operation NAME, not per call.
|
|
1337
|
+
*
|
|
1338
|
+
* The divergence test lives in the wrapper body, evaluated when the operation
|
|
1339
|
+
* is CALLED. That placement is the point: an accessor minted before a second
|
|
1340
|
+
* client diverges still scopes correctly once it does, and while no client has
|
|
1341
|
+
* diverged the module default is already this client's mode, so the wrapper
|
|
1342
|
+
* calls straight through and no AsyncLocalStorage scope is established at all.
|
|
1343
|
+
*/
|
|
1344
|
+
createErrorModeAccessor(target) {
|
|
1345
|
+
const mode = this.errorMessageMode;
|
|
1346
|
+
const wrapped = new Map();
|
|
1347
|
+
return new Proxy(target, {
|
|
1348
|
+
get(t, prop, receiver) {
|
|
1349
|
+
const value = Reflect.get(t, prop, receiver);
|
|
1350
|
+
if (typeof value !== 'function' || typeof prop !== 'string')
|
|
1351
|
+
return value;
|
|
1352
|
+
let fn = wrapped.get(prop);
|
|
1353
|
+
if (!fn) {
|
|
1354
|
+
fn = (...args) => {
|
|
1355
|
+
const call = () => value.apply(t, args);
|
|
1356
|
+
return errorMessageModesDiverged() ? runWithErrorMessageMode(mode, call) : call();
|
|
1357
|
+
};
|
|
1358
|
+
wrapped.set(prop, fn);
|
|
1359
|
+
}
|
|
1360
|
+
return fn;
|
|
1361
|
+
},
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Run `fn` with this client's `errorMessages` mode in force, or directly when
|
|
1366
|
+
* no other client in the process has diverged from it (the common case, which
|
|
1367
|
+
* costs nothing). Used by the entry points that do not go through
|
|
1368
|
+
* {@link table}: transactions, raw SQL, `pipeline`, and the typed-SQL builder.
|
|
1369
|
+
*
|
|
1370
|
+
* The typed-SQL builder is the one that cannot be covered by wrapping the
|
|
1371
|
+
* call, because it is lazy: `sql` hands this function to `TypedSqlQuery` so
|
|
1372
|
+
* the scope reaches the EXECUTION rather than the template. This docstring
|
|
1373
|
+
* listed it as covered before 0.66 and it was not, which is the reason the
|
|
1374
|
+
* mechanism is spelled out here instead of just named.
|
|
1375
|
+
*/
|
|
1376
|
+
withErrorMode(fn) {
|
|
1377
|
+
return errorMessageModesDiverged() ? runWithErrorMessageMode(this.errorMessageMode, fn) : fn();
|
|
1378
|
+
}
|
|
1279
1379
|
/** Get (and cache) the primary-pool-bound QueryInterface for a table. */
|
|
1280
1380
|
primaryTableQI(name) {
|
|
1281
1381
|
let qi = this.tableCache.get(name);
|
|
@@ -1393,7 +1493,14 @@ export class TurbineClient {
|
|
|
1393
1493
|
if (this.logging) {
|
|
1394
1494
|
console.log(`[turbine] Pipeline: ${queries.length} queries, ${queries.map((q) => q.tag).join(', ')}`);
|
|
1395
1495
|
}
|
|
1396
|
-
|
|
1496
|
+
// Scoped like every other entry point. A pipeline builds NotFoundErrors (a
|
|
1497
|
+
// `buildFindUniqueOrThrow` slot) and wraps driver errors into
|
|
1498
|
+
// `PipelineError.results[i]`, and none of that went through a mode scope
|
|
1499
|
+
// before, so a safe client's miss inside a pipeline rendered the caller's
|
|
1500
|
+
// where VALUES as soon as any verbose client existed in the process. The
|
|
1501
|
+
// scope is established around the await, so every continuation of the batch
|
|
1502
|
+
// resolves this client's mode.
|
|
1503
|
+
return this.withErrorMode(() => executePipeline(this.pool, queries, options));
|
|
1397
1504
|
}
|
|
1398
1505
|
/**
|
|
1399
1506
|
* Check whether the underlying pool supports the real pipeline protocol.
|
|
@@ -1434,7 +1541,7 @@ export class TurbineClient {
|
|
|
1434
1541
|
return result.rows;
|
|
1435
1542
|
}
|
|
1436
1543
|
catch (err) {
|
|
1437
|
-
throw wrapPgError(err);
|
|
1544
|
+
throw this.withErrorMode(() => wrapPgError(err));
|
|
1438
1545
|
}
|
|
1439
1546
|
}
|
|
1440
1547
|
/**
|
|
@@ -1469,7 +1576,13 @@ export class TurbineClient {
|
|
|
1469
1576
|
*/
|
|
1470
1577
|
sql(strings, ...values) {
|
|
1471
1578
|
const { sql, params } = buildTypedSql(strings, values, this.dialect);
|
|
1472
|
-
|
|
1579
|
+
// The scope has to be handed to the BUILDER, not wrapped around this call.
|
|
1580
|
+
// `TypedSqlQuery` is lazy (it runs on `await` / `.one()` / `.scalar()`), so
|
|
1581
|
+
// a scope opened here would close before any row was fetched. The
|
|
1582
|
+
// `withErrorMode` docstring already claimed to cover the typed-SQL builder
|
|
1583
|
+
// and did not, which left two raw-SQL entry points disagreeing about the
|
|
1584
|
+
// same statement, since the adjacent `raw` tag WAS scoped.
|
|
1585
|
+
return new TypedSqlQuery(this.pool, sql, params, this.logging, (fn) => this.withErrorMode(fn));
|
|
1473
1586
|
}
|
|
1474
1587
|
// -------------------------------------------------------------------------
|
|
1475
1588
|
// Transaction support (raw, legacy)
|
|
@@ -1524,6 +1637,12 @@ export class TurbineClient {
|
|
|
1524
1637
|
}
|
|
1525
1638
|
}
|
|
1526
1639
|
async $transaction(fnOrQueries, options) {
|
|
1640
|
+
// Scope the WHOLE call, so the transaction-scoped QueryInterfaces built
|
|
1641
|
+
// inside TransactionClient.table() inherit this client's mode without
|
|
1642
|
+
// needing their own accessor.
|
|
1643
|
+
return this.withErrorMode(() => this.runTransaction(fnOrQueries, options));
|
|
1644
|
+
}
|
|
1645
|
+
async runTransaction(fnOrQueries, options) {
|
|
1527
1646
|
// Batch overload: an array of DeferredQuery objects runs atomically inside
|
|
1528
1647
|
// one BEGIN…COMMIT, reusing the raw transaction machinery below.
|
|
1529
1648
|
if (Array.isArray(fnOrQueries)) {
|