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.
Files changed (142) hide show
  1. package/README.md +34 -32
  2. package/dist/adapters/cockroachdb.js +21 -3
  3. package/dist/adapters/index.d.ts +15 -0
  4. package/dist/adapters/yugabytedb.js +20 -3
  5. package/dist/cjs/adapters/cockroachdb.js +21 -3
  6. package/dist/cjs/adapters/index.d.ts +15 -0
  7. package/dist/cjs/adapters/yugabytedb.js +20 -3
  8. package/dist/cjs/cli/destructive.d.ts +18 -4
  9. package/dist/cjs/cli/destructive.js +230 -122
  10. package/dist/cjs/cli/index.d.ts +21 -4
  11. package/dist/cjs/cli/index.js +119 -22
  12. package/dist/cjs/cli/mcp.d.ts +28 -8
  13. package/dist/cjs/cli/mcp.js +170 -127
  14. package/dist/cjs/cli/migrate.d.ts +134 -13
  15. package/dist/cjs/cli/migrate.js +349 -241
  16. package/dist/cjs/cli/pii-predicate-guard.d.ts +112 -0
  17. package/dist/cjs/cli/pii-predicate-guard.js +390 -0
  18. package/dist/cjs/cli/prisma-resolve.js +75 -4
  19. package/dist/cjs/cli/prisma-schema.d.ts +17 -1
  20. package/dist/cjs/cli/prisma-schema.js +83 -17
  21. package/dist/cjs/cli/sql-statements.d.ts +125 -0
  22. package/dist/cjs/cli/sql-statements.js +378 -0
  23. package/dist/cjs/cli/studio.js +49 -118
  24. package/dist/cjs/cli/ui.d.ts +1 -1
  25. package/dist/cjs/client.d.ts +43 -0
  26. package/dist/cjs/client.js +125 -6
  27. package/dist/cjs/dialect.d.ts +123 -0
  28. package/dist/cjs/dialect.js +33 -0
  29. package/dist/cjs/errors.d.ts +74 -1
  30. package/dist/cjs/errors.js +239 -25
  31. package/dist/cjs/index-advisor.d.ts +33 -1
  32. package/dist/cjs/index-advisor.js +32 -1
  33. package/dist/cjs/introspect.d.ts +48 -0
  34. package/dist/cjs/introspect.js +222 -91
  35. package/dist/cjs/mssql.js +43 -1
  36. package/dist/cjs/mysql.d.ts +5 -2
  37. package/dist/cjs/mysql.js +202 -17
  38. package/dist/cjs/nested-write.js +6 -1
  39. package/dist/cjs/pipeline-submittable.js +17 -3
  40. package/dist/cjs/pipeline.js +75 -9
  41. package/dist/cjs/powdb.d.ts +23 -0
  42. package/dist/cjs/powdb.js +33 -1
  43. package/dist/cjs/powql.d.ts +61 -9
  44. package/dist/cjs/powql.js +186 -49
  45. package/dist/cjs/prisma-compat.js +160 -41
  46. package/dist/cjs/query/aggregates.d.ts +1 -1
  47. package/dist/cjs/query/aggregates.js +80 -18
  48. package/dist/cjs/query/batched-loader.d.ts +10 -0
  49. package/dist/cjs/query/batched-loader.js +268 -7
  50. package/dist/cjs/query/builder.d.ts +73 -0
  51. package/dist/cjs/query/builder.js +225 -28
  52. package/dist/cjs/query/filters.d.ts +162 -0
  53. package/dist/cjs/query/filters.js +250 -1
  54. package/dist/cjs/query/relations.d.ts +10 -10
  55. package/dist/cjs/query/relations.js +93 -12
  56. package/dist/cjs/query/types.d.ts +14 -1
  57. package/dist/cjs/query/utils.d.ts +146 -2
  58. package/dist/cjs/query/utils.js +210 -4
  59. package/dist/cjs/query/warn-registry.d.ts +10 -0
  60. package/dist/cjs/query/warn-registry.js +10 -0
  61. package/dist/cjs/query/where-compile.d.ts +30 -0
  62. package/dist/cjs/query/where-compile.js +41 -0
  63. package/dist/cjs/query/where.d.ts +128 -13
  64. package/dist/cjs/query/where.js +215 -77
  65. package/dist/cjs/query/writes.d.ts +1 -1
  66. package/dist/cjs/query/writes.js +39 -15
  67. package/dist/cjs/schema-builder.d.ts +2 -1
  68. package/dist/cjs/schema-sql.d.ts +94 -4
  69. package/dist/cjs/schema-sql.js +506 -30
  70. package/dist/cjs/schema.d.ts +3 -1
  71. package/dist/cjs/sqlite.d.ts +6 -0
  72. package/dist/cjs/sqlite.js +151 -10
  73. package/dist/cjs/typed-sql.d.ts +29 -1
  74. package/dist/cjs/typed-sql.js +30 -12
  75. package/dist/cli/destructive.d.ts +18 -4
  76. package/dist/cli/destructive.js +229 -121
  77. package/dist/cli/index.d.ts +21 -4
  78. package/dist/cli/index.js +120 -24
  79. package/dist/cli/mcp.d.ts +28 -8
  80. package/dist/cli/mcp.js +172 -129
  81. package/dist/cli/migrate.d.ts +134 -13
  82. package/dist/cli/migrate.js +347 -238
  83. package/dist/cli/pii-predicate-guard.d.ts +112 -0
  84. package/dist/cli/pii-predicate-guard.js +386 -0
  85. package/dist/cli/prisma-resolve.js +75 -4
  86. package/dist/cli/prisma-schema.d.ts +17 -1
  87. package/dist/cli/prisma-schema.js +83 -17
  88. package/dist/cli/sql-statements.d.ts +125 -0
  89. package/dist/cli/sql-statements.js +373 -0
  90. package/dist/cli/studio.js +49 -118
  91. package/dist/cli/ui.d.ts +1 -1
  92. package/dist/client.d.ts +43 -0
  93. package/dist/client.js +126 -7
  94. package/dist/dialect.d.ts +123 -0
  95. package/dist/dialect.js +33 -0
  96. package/dist/errors.d.ts +74 -1
  97. package/dist/errors.js +228 -19
  98. package/dist/index-advisor.d.ts +33 -1
  99. package/dist/index-advisor.js +31 -1
  100. package/dist/introspect.d.ts +48 -0
  101. package/dist/introspect.js +221 -91
  102. package/dist/mssql.js +44 -2
  103. package/dist/mysql.d.ts +5 -2
  104. package/dist/mysql.js +203 -18
  105. package/dist/nested-write.js +7 -2
  106. package/dist/pipeline-submittable.js +18 -4
  107. package/dist/pipeline.js +76 -10
  108. package/dist/powdb.d.ts +23 -0
  109. package/dist/powdb.js +33 -2
  110. package/dist/powql.d.ts +61 -9
  111. package/dist/powql.js +187 -50
  112. package/dist/prisma-compat.js +160 -41
  113. package/dist/query/aggregates.d.ts +1 -1
  114. package/dist/query/aggregates.js +82 -20
  115. package/dist/query/batched-loader.d.ts +10 -0
  116. package/dist/query/batched-loader.js +270 -9
  117. package/dist/query/builder.d.ts +73 -0
  118. package/dist/query/builder.js +226 -30
  119. package/dist/query/filters.d.ts +162 -0
  120. package/dist/query/filters.js +246 -1
  121. package/dist/query/relations.d.ts +10 -10
  122. package/dist/query/relations.js +94 -14
  123. package/dist/query/types.d.ts +14 -1
  124. package/dist/query/utils.d.ts +146 -2
  125. package/dist/query/utils.js +204 -3
  126. package/dist/query/warn-registry.d.ts +10 -0
  127. package/dist/query/warn-registry.js +10 -0
  128. package/dist/query/where-compile.d.ts +30 -0
  129. package/dist/query/where-compile.js +40 -1
  130. package/dist/query/where.d.ts +128 -13
  131. package/dist/query/where.js +216 -80
  132. package/dist/query/writes.d.ts +1 -1
  133. package/dist/query/writes.js +40 -16
  134. package/dist/schema-builder.d.ts +2 -1
  135. package/dist/schema-sql.d.ts +94 -4
  136. package/dist/schema-sql.js +505 -30
  137. package/dist/schema.d.ts +3 -1
  138. package/dist/sqlite.d.ts +6 -0
  139. package/dist/sqlite.js +151 -10
  140. package/dist/typed-sql.d.ts +29 -1
  141. package/dist/typed-sql.js +30 -12
  142. package/package.json +6 -4
@@ -69,6 +69,7 @@ const index_js_1 = require("../query/index.js");
69
69
  // `ownLookup` is not re-exported from the query barrel, so it is imported from
70
70
  // its defining leaf module rather than duplicated here.
71
71
  const utils_js_1 = require("../query/utils.js");
72
+ const pii_predicate_guard_js_1 = require("./pii-predicate-guard.js");
72
73
  const pii_tags_js_1 = require("./pii-tags.js");
73
74
  const rate_limit_js_1 = require("./rate-limit.js");
74
75
  const studio_demo_js_1 = require("./studio-demo.js");
@@ -498,7 +499,7 @@ function relationLinksForTable(table, metadata, showPii) {
498
499
  const referencedBy = [];
499
500
  const seenFkColumns = new Set();
500
501
  for (const [name, rel] of Object.entries(table.relations)) {
501
- const target = metadata.tables[rel.to];
502
+ const target = (0, utils_js_1.ownLookup)(metadata.tables, rel.to);
502
503
  if (!target)
503
504
  continue;
504
505
  if (rel.type === 'belongsTo') {
@@ -800,18 +801,6 @@ function parseTableFilters(raw, table, redactedPii) {
800
801
  // ---------------------------------------------------------------------------
801
802
  // API: /api/builder: Turbine ORM findMany spec runner
802
803
  // ---------------------------------------------------------------------------
803
- /** Relation-filter wrappers whose body is a clause against the relation's target. */
804
- const RELATION_FILTER_WRAPPERS = ['some', 'none', 'every', 'is', 'isNot'];
805
- /**
806
- * Recursion bound for the PII guard walk.
807
- *
808
- * This number is NOT the security boundary: reaching it REFUSES the request
809
- * (see `assertWithinDepth`). It only bounds the walk on a pathological payload.
810
- * It sits well above the query builder's own depth-10 relation cap
811
- * (`CircularRelationError`) and far above any hand-composed boolean nesting, so
812
- * nothing the builder would accept is refused here for depth alone.
813
- */
814
- const PII_GUARD_MAX_DEPTH = 32;
815
804
  /**
816
805
  * Refuse a builder query that FILTERS, SORTS, PAGES, or DE-DUPLICATES on a
817
806
  * redacted PII column.
@@ -825,111 +814,39 @@ const PII_GUARD_MAX_DEPTH = 32;
825
814
  * already refuses its own equivalents (`parseTableFilters`); the builder route
826
815
  * has to refuse all of them too.
827
816
  *
828
- * Walks the whole args tree: `where`, `orderBy` (object AND array form),
829
- * `cursor`, `distinct`, boolean combinators, relation filters, and each `with`
830
- * level against that relation's target table. `select` / `omit` are NOT
831
- * refused: they return values, and those values are redacted on the way out.
817
+ * The WALK lives in `cli/pii-predicate-guard.ts`, shared with the MCP server's
818
+ * `explain_query`, which asks the same question of the same arg tree. It used to
819
+ * be a second hand-written copy here, and the two drifted exactly the way copies
820
+ * do: a hole opened in both and could only have been closed in one. What stays
821
+ * here is the part that is genuinely Studio's: WHICH columns are hidden (a
822
+ * code-first `pii` tag, unless `--show-pii`), and what a refusal looks like (a
823
+ * `ValidationError`, which `/api/builder` renders as a 400).
832
824
  *
833
- * Every depth check FAILS CLOSED. Returning quietly at the cap (what this used
834
- * to do) meant padding a payload with, for example, eleven nested `NOT`
835
- * wrappers walked the guard off the end of its own recursion and then handed
836
- * the untouched predicate to the builder.
825
+ * `select` / `omit` are NOT refused: they return values, and those values are
826
+ * redacted on the way out.
837
827
  */
838
828
  function assertNoPiiPredicates(args, tableName, metadata, showPii) {
839
829
  if (showPii)
840
830
  return;
841
- const assertWithinDepth = (depth) => {
842
- if (depth <= PII_GUARD_MAX_DEPTH)
843
- return;
844
- throw new errors_js_1.ValidationError(`[turbine] Query is nested more than ${PII_GUARD_MAX_DEPTH} levels deep, which is past the point where ` +
845
- `Studio can prove it does not filter or sort on a PII-tagged and redacted column, so it is refused. ` +
846
- `Flatten the query, or restart Studio with --show-pii.`);
847
- };
848
- const refuse = (table, column) => {
849
- throw new errors_js_1.ValidationError(`[turbine] Column "${column}" on "${table.name}" is PII-tagged and redacted, so it cannot be used ` +
850
- `in a where, orderBy, cursor, or distinct: filtering, sorting, paging, or de-duplicating on a hidden ` +
851
- `value reveals it. Restart Studio with --show-pii to query it.`);
852
- };
853
- const visitClause = (node, table, depth) => {
854
- assertWithinDepth(depth);
855
- if (!table || node === null || typeof node !== 'object')
856
- return;
857
- // `orderBy` accepts a Prisma-style array of single-key objects, and so does
858
- // a `NOT` list. Element order carries no nesting, so the depth is unchanged.
859
- if (Array.isArray(node)) {
860
- for (const item of node)
861
- visitClause(item, table, depth);
862
- return;
863
- }
864
- for (const [key, value] of Object.entries(node)) {
865
- if (key === 'AND' || key === 'OR' || key === 'NOT') {
866
- visitClause(value, table, depth + 1);
867
- continue;
868
- }
869
- const relation = Object.hasOwn(table.relations, key) ? table.relations[key] : undefined;
870
- if (relation) {
871
- visitRelationValue(value, metadata.tables[relation.to], depth + 1);
872
- continue;
873
- }
874
- const column = (0, utils_js_1.ownLookup)(table.columnMap, key) ?? key;
875
- if (isRedactedColumn(table, column, showPii))
876
- refuse(table, column);
877
- }
878
- };
879
- /**
880
- * A relation predicate arrives in one of two shapes, and BOTH resolve against
881
- * the relation's target table: bare (`{ user: { email: {...} } }`) or wrapped
882
- * in a cardinality / to-one operator (`{ user: { is: { email: {...} } } }`,
883
- * likewise `some` / `none` / `every` / `isNot`). Handing the wrapper straight
884
- * to `visitClause` walked its keys as if `is` were a column of the target, so
885
- * the inner clause was never visited and a PII predicate slipped through.
886
- * Descend into every wrapper member AND into the value itself.
887
- */
888
- const visitRelationValue = (value, target, depth) => {
889
- assertWithinDepth(depth);
890
- if (!target || value === null || typeof value !== 'object')
891
- return;
892
- const node = value;
893
- for (const wrapper of RELATION_FILTER_WRAPPERS) {
894
- if (Object.hasOwn(node, wrapper))
895
- visitClause(node[wrapper], target, depth + 1);
896
- }
897
- visitClause(node, target, depth);
898
- };
899
- /** Field-name lists (`distinct`) name columns directly rather than in a clause. */
900
- const visitFieldList = (value, table) => {
901
- if (!Array.isArray(value))
902
- return;
903
- for (const field of value) {
904
- if (typeof field !== 'string')
905
- continue;
906
- const column = (0, utils_js_1.ownLookup)(table.columnMap, field) ?? field;
907
- if (isRedactedColumn(table, column, showPii))
908
- refuse(table, column);
909
- }
910
- };
911
- const visitLevel = (level, table, depth) => {
912
- assertWithinDepth(depth);
913
- if (!table)
914
- return;
915
- visitClause(level.where, table, depth);
916
- visitClause(level.orderBy, table, depth);
917
- // `cursor` is a flat `{ field: value }` seek key that the builder turns into
918
- // a WHERE range comparison against the sort key, so it reads exactly like a
919
- // where on the same column.
920
- visitClause(level.cursor, table, depth);
921
- visitFieldList(level.distinct, table);
922
- const withClause = level.with;
923
- if (!withClause || typeof withClause !== 'object')
924
- return;
925
- for (const [relName, spec] of Object.entries(withClause)) {
926
- const relation = Object.hasOwn(table.relations, relName) ? table.relations[relName] : undefined;
927
- if (!relation || spec === true || spec === null || typeof spec !== 'object')
928
- continue;
929
- visitLevel(spec, metadata.tables[relation.to], depth + 1);
930
- }
931
- };
932
- visitLevel(args, metadata.tables[tableName], 0);
831
+ (0, pii_predicate_guard_js_1.assertNoPiiPredicates)(args, (0, utils_js_1.ownLookup)(metadata.tables, tableName), {
832
+ metadata,
833
+ hiddenReason: (table, column) => (isRedactedColumn(table, column, showPii) ? 'is PII-tagged and redacted' : null),
834
+ refuseColumn: (table, column, reason) => {
835
+ throw new errors_js_1.ValidationError(`[turbine] Column "${column}" on "${table.name}" ${reason}, so it cannot be used ` +
836
+ `in a where, orderBy, cursor, or distinct: filtering, sorting, paging, or de-duplicating on a hidden ` +
837
+ `value reveals it. Restart Studio with --show-pii to query it.`);
838
+ },
839
+ refuseDepth: (maxDepth) => {
840
+ throw new errors_js_1.ValidationError(`[turbine] Query is nested more than ${maxDepth} levels deep, which is past the point where ` +
841
+ `Studio can prove it does not filter or sort on a PII-tagged and redacted column, so it is refused. ` +
842
+ `Flatten the query, or restart Studio with --show-pii.`);
843
+ },
844
+ refuseShape: (table, key) => {
845
+ throw new errors_js_1.ValidationError(`[turbine] Studio does not recognize "${key}" in a query on "${table.name}", so it cannot prove the ` +
846
+ `query does not filter or sort on a PII-tagged and redacted column, and refuses it rather than ` +
847
+ `guessing. Remove it, or restart Studio with --show-pii.`);
848
+ },
849
+ });
933
850
  }
934
851
  async function apiBuilder(req, res, ctx) {
935
852
  const body = await readJsonBody(req);
@@ -960,7 +877,7 @@ async function apiBuilder(req, res, ctx) {
960
877
  // here (the user named the columns, and the values are redacted on the way
961
878
  // out); only the empty default projection is refused, with a reason instead
962
879
  // of a syntax error.
963
- const target = ctx.metadata.tables[tableName];
880
+ const target = (0, utils_js_1.ownLookup)(ctx.metadata.tables, tableName);
964
881
  if (target && !ctx.showPii && args.select === undefined && target.columns.every((c) => c.pii === true)) {
965
882
  sendJson(res, 400, {
966
883
  error: `Every column of "${tableName}" is PII-tagged, so the default projection is empty. Name the columns you ` +
@@ -994,7 +911,17 @@ async function apiBuilder(req, res, ctx) {
994
911
  // the connection's search_path. Pin it to the configured --schema so the
995
912
  // Query tab reads the same schema as the Data tab (set_config is
996
913
  // transaction-local and fully parameterized). Demo has no schemas.
997
- await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
914
+ //
915
+ // The VALUE is bound as a parameter, but Postgres parses the contents of
916
+ // search_path as an identifier LIST, so a name that needs quoting has to
917
+ // carry its own. Passing `My.Schema` raw makes Postgres read it as a
918
+ // two-part token that matches nothing: `current_schemas(false)` came back
919
+ // `{}` and the very next query died with `relation "widgets" does not
920
+ // exist` (measured on PG 16). A schema whose name merely differs in CASE
921
+ // is worse than that, because it silently folds to a DIFFERENT existing
922
+ // schema instead of failing. quoteIdent applies exactly the doubling rule
923
+ // the GUC parser expects.
924
+ await client.query(`SELECT set_config('search_path', $1, true)`, [(0, index_js_1.quoteIdent)(ctx.options.schema)]);
998
925
  }
999
926
  const started = Date.now();
1000
927
  const result = await client.query(deferred.sql, deferred.params);
@@ -1193,7 +1120,11 @@ async function apiRowWrite(req, res, ctx, op) {
1193
1120
  await client.query('BEGIN');
1194
1121
  if (!ctx.demo) {
1195
1122
  await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
1196
- await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
1123
+ // Quoted for the same reason as the read path above: the bound value is
1124
+ // parsed as an identifier list, so an unquoted mixed-case or dotted schema
1125
+ // name pins search_path to nothing. On THIS path that would mean a write
1126
+ // aimed at a non-default schema resolving somewhere else entirely.
1127
+ await client.query(`SELECT set_config('search_path', $1, true)`, [(0, index_js_1.quoteIdent)(ctx.options.schema)]);
1197
1128
  }
1198
1129
  const returnedRows = [];
1199
1130
  let rowCount = 0;
@@ -1343,7 +1274,7 @@ async function apiCreateSavedQuery(req, res, ctx) {
1343
1274
  const body = await readJsonBody(req);
1344
1275
  const table = typeof body?.table === 'string' ? body.table : '';
1345
1276
  const name = typeof body?.name === 'string' ? body.name.trim() : '';
1346
- if (!table || !ctx.metadata.tables[table]) {
1277
+ if (!table || !(0, utils_js_1.ownLookup)(ctx.metadata.tables, table)) {
1347
1278
  sendJson(res, 400, { error: unknownTableMessage(table, ctx) });
1348
1279
  return;
1349
1280
  }
@@ -1452,7 +1383,7 @@ function redactFlatRow(row, piiKeys) {
1452
1383
  * arrive as parsed json objects keyed by camelCase field names).
1453
1384
  */
1454
1385
  function redactBuilderRows(rows, tableName, withClause, metadata) {
1455
- const table = metadata.tables[tableName];
1386
+ const table = (0, utils_js_1.ownLookup)(metadata.tables, tableName);
1456
1387
  if (!table)
1457
1388
  return rows;
1458
1389
  const piiKeys = piiKeysForTable(table);
@@ -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: "+" | "╮";
@@ -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
@@ -648,6 +648,15 @@ class TurbineClient {
648
648
  queryListeners = new Set();
649
649
  queryOptions;
650
650
  errorMessagesSafe;
651
+ /**
652
+ * THIS client's error-message mode. Not the process default: see
653
+ * {@link registerClientErrorMessageMode}. Used to scope every operation
654
+ * issued through this client, but only once two clients in the process have
655
+ * asked for different modes.
656
+ */
657
+ errorMessageMode;
658
+ /** Per-table mode-scoping accessors, built lazily (see {@link table}). */
659
+ errorScopedTableCache = new Map();
651
660
  /** Whether `$on('query')` events carry real params (see `logQueryParams`). */
652
661
  queryParamsVisible;
653
662
  /** True when Turbine created the pool and is responsible for tearing it down */
@@ -685,6 +694,7 @@ class TurbineClient {
685
694
  this.logging = parent.logging;
686
695
  this.dialect = parent.dialect;
687
696
  this.errorMessagesSafe = parent.errorMessagesSafe;
697
+ this.errorMessageMode = parent.errorMessageMode;
688
698
  this.queryParamsVisible = parent.queryParamsVisible;
689
699
  this.queryOptions = parent.queryOptions;
690
700
  this.middlewares = parent.middlewares; // shared reference: $use on parent flows through
@@ -838,7 +848,8 @@ class TurbineClient {
838
848
  this.schema = schema;
839
849
  // Respect env var kill switch
840
850
  const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
841
- this.errorMessagesSafe = (config.errorMessages ?? 'safe') === 'safe';
851
+ this.errorMessageMode = config.errorMessages ?? 'safe';
852
+ this.errorMessagesSafe = this.errorMessageMode === 'safe';
842
853
  // Query-event param visibility. One derived boolean, so the two config
843
854
  // spellings can never disagree: `logQueryParams` wins when set, otherwise
844
855
  // `errorMessages` keeps deciding exactly as it always has.
@@ -886,6 +897,16 @@ class TurbineClient {
886
897
  };
887
898
  // Apply NotFoundError message redaction mode (default: safe, values are
888
899
  // stripped from messages to avoid leaking PII into error logs).
900
+ //
901
+ // The process-wide default keeps being set here, unchanged, so a directly
902
+ // constructed error and any single-client process behave exactly as before.
903
+ // What is new is the REGISTRATION: a second client asking for a different
904
+ // mode makes both of them scope their own operations, instead of the last
905
+ // constructor silently deciding for everyone (see
906
+ // registerClientErrorMessageMode). Registration uses the client's effective
907
+ // mode, defaulted, because two clients only agree if their EFFECTIVE modes
908
+ // agree, and an omitted `errorMessages` is an effective 'safe'.
909
+ (0, errors_js_1.registerClientErrorMessageMode)(this.errorMessageMode);
889
910
  if (config.errorMessages) {
890
911
  (0, errors_js_1.setErrorMessageMode)(config.errorMessages);
891
912
  }
@@ -1227,6 +1248,11 @@ class TurbineClient {
1227
1248
  this.routingProxyCache.clear();
1228
1249
  for (const cache of this.replicaTableCaches)
1229
1250
  cache.clear();
1251
+ // The errorMessages accessors too: each proxy holds its target by closure,
1252
+ // so a stale one keeps handing back the pre-$use QueryInterface. Every
1253
+ // cache `table()` reads through has to be listed here, and this one is the
1254
+ // OUTERMOST, so leaving it out silently defeats all three above.
1255
+ this.errorScopedTableCache.clear();
1230
1256
  }
1231
1257
  // -------------------------------------------------------------------------
1232
1258
  // Event emitter, subscribe to query lifecycle events
@@ -1274,9 +1300,31 @@ class TurbineClient {
1274
1300
  * With no replicas the original single-pool instance is returned directly.
1275
1301
  */
1276
1302
  table(name) {
1277
- if (this.replicaPools.length === 0) {
1278
- return this.primaryTableQI(name);
1303
+ const base = this.replicaPools.length === 0 ? this.primaryTableQI(name) : this.routingAccessor(name);
1304
+ // The errorMessages accessor is built ALWAYS, not only once two clients have
1305
+ // diverged. It used to be conditional here, and the condition was wrong in
1306
+ // the one direction that leaks: divergence is a property of the PROCESS over
1307
+ // time, while a table accessor is handed out once and kept. An application
1308
+ // that does `const users = db.users` at module load, then constructs a
1309
+ // second client with `errorMessages: 'verbose'` later (an analytics client,
1310
+ // a per-suite test harness), held a bare unscoped reference forever, so the
1311
+ // safe client's `findUniqueOrThrow` misses started rendering row values.
1312
+ // Building the proxy unconditionally is what makes the mode a property of
1313
+ // the client rather than of the construction order.
1314
+ //
1315
+ // The divergence check did not disappear, it moved INSIDE the wrapper, so it
1316
+ // is asked when the operation runs rather than when the accessor is minted.
1317
+ // While every client agrees the wrapper still establishes no scope and the
1318
+ // query path still pays nothing but one proxy trap.
1319
+ let scoped = this.errorScopedTableCache.get(name);
1320
+ if (!scoped) {
1321
+ scoped = this.createErrorModeAccessor(base);
1322
+ this.errorScopedTableCache.set(name, scoped);
1279
1323
  }
1324
+ return scoped;
1325
+ }
1326
+ /** Get (and cache) the replica-routing accessor for a table. */
1327
+ routingAccessor(name) {
1280
1328
  let proxy = this.routingProxyCache.get(name);
1281
1329
  if (!proxy) {
1282
1330
  proxy = this.createRoutingAccessor(name);
@@ -1284,6 +1332,58 @@ class TurbineClient {
1284
1332
  }
1285
1333
  return proxy;
1286
1334
  }
1335
+ /**
1336
+ * Wrap a table accessor so every operation called through it runs with THIS
1337
+ * client's `errorMessages` mode in force, whatever another client set as the
1338
+ * process default.
1339
+ *
1340
+ * A proxy rather than a wrapper object: the QueryInterface mutates its own
1341
+ * instance state during a query, so methods must run with the real instance
1342
+ * as `this` (the same reason {@link createRoutingAccessor} is built this
1343
+ * way). Method wrappers are memoized per property so a hot loop allocates one
1344
+ * closure per operation NAME, not per call.
1345
+ *
1346
+ * The divergence test lives in the wrapper body, evaluated when the operation
1347
+ * is CALLED. That placement is the point: an accessor minted before a second
1348
+ * client diverges still scopes correctly once it does, and while no client has
1349
+ * diverged the module default is already this client's mode, so the wrapper
1350
+ * calls straight through and no AsyncLocalStorage scope is established at all.
1351
+ */
1352
+ createErrorModeAccessor(target) {
1353
+ const mode = this.errorMessageMode;
1354
+ const wrapped = new Map();
1355
+ return new Proxy(target, {
1356
+ get(t, prop, receiver) {
1357
+ const value = Reflect.get(t, prop, receiver);
1358
+ if (typeof value !== 'function' || typeof prop !== 'string')
1359
+ return value;
1360
+ let fn = wrapped.get(prop);
1361
+ if (!fn) {
1362
+ fn = (...args) => {
1363
+ const call = () => value.apply(t, args);
1364
+ return (0, errors_js_1.errorMessageModesDiverged)() ? (0, errors_js_1.runWithErrorMessageMode)(mode, call) : call();
1365
+ };
1366
+ wrapped.set(prop, fn);
1367
+ }
1368
+ return fn;
1369
+ },
1370
+ });
1371
+ }
1372
+ /**
1373
+ * Run `fn` with this client's `errorMessages` mode in force, or directly when
1374
+ * no other client in the process has diverged from it (the common case, which
1375
+ * costs nothing). Used by the entry points that do not go through
1376
+ * {@link table}: transactions, raw SQL, `pipeline`, and the typed-SQL builder.
1377
+ *
1378
+ * The typed-SQL builder is the one that cannot be covered by wrapping the
1379
+ * call, because it is lazy: `sql` hands this function to `TypedSqlQuery` so
1380
+ * the scope reaches the EXECUTION rather than the template. This docstring
1381
+ * listed it as covered before 0.66 and it was not, which is the reason the
1382
+ * mechanism is spelled out here instead of just named.
1383
+ */
1384
+ withErrorMode(fn) {
1385
+ return (0, errors_js_1.errorMessageModesDiverged)() ? (0, errors_js_1.runWithErrorMessageMode)(this.errorMessageMode, fn) : fn();
1386
+ }
1287
1387
  /** Get (and cache) the primary-pool-bound QueryInterface for a table. */
1288
1388
  primaryTableQI(name) {
1289
1389
  let qi = this.tableCache.get(name);
@@ -1401,7 +1501,14 @@ class TurbineClient {
1401
1501
  if (this.logging) {
1402
1502
  console.log(`[turbine] Pipeline: ${queries.length} queries, ${queries.map((q) => q.tag).join(', ')}`);
1403
1503
  }
1404
- return (0, pipeline_js_1.executePipeline)(this.pool, queries, options);
1504
+ // Scoped like every other entry point. A pipeline builds NotFoundErrors (a
1505
+ // `buildFindUniqueOrThrow` slot) and wraps driver errors into
1506
+ // `PipelineError.results[i]`, and none of that went through a mode scope
1507
+ // before, so a safe client's miss inside a pipeline rendered the caller's
1508
+ // where VALUES as soon as any verbose client existed in the process. The
1509
+ // scope is established around the await, so every continuation of the batch
1510
+ // resolves this client's mode.
1511
+ return this.withErrorMode(() => (0, pipeline_js_1.executePipeline)(this.pool, queries, options));
1405
1512
  }
1406
1513
  /**
1407
1514
  * Check whether the underlying pool supports the real pipeline protocol.
@@ -1442,7 +1549,7 @@ class TurbineClient {
1442
1549
  return result.rows;
1443
1550
  }
1444
1551
  catch (err) {
1445
- throw (0, errors_js_1.wrapPgError)(err);
1552
+ throw this.withErrorMode(() => (0, errors_js_1.wrapPgError)(err));
1446
1553
  }
1447
1554
  }
1448
1555
  /**
@@ -1477,7 +1584,13 @@ class TurbineClient {
1477
1584
  */
1478
1585
  sql(strings, ...values) {
1479
1586
  const { sql, params } = (0, typed_sql_js_1.buildTypedSql)(strings, values, this.dialect);
1480
- return new typed_sql_js_1.TypedSqlQuery(this.pool, sql, params, this.logging);
1587
+ // The scope has to be handed to the BUILDER, not wrapped around this call.
1588
+ // `TypedSqlQuery` is lazy (it runs on `await` / `.one()` / `.scalar()`), so
1589
+ // a scope opened here would close before any row was fetched. The
1590
+ // `withErrorMode` docstring already claimed to cover the typed-SQL builder
1591
+ // and did not, which left two raw-SQL entry points disagreeing about the
1592
+ // same statement, since the adjacent `raw` tag WAS scoped.
1593
+ return new typed_sql_js_1.TypedSqlQuery(this.pool, sql, params, this.logging, (fn) => this.withErrorMode(fn));
1481
1594
  }
1482
1595
  // -------------------------------------------------------------------------
1483
1596
  // Transaction support (raw, legacy)
@@ -1532,6 +1645,12 @@ class TurbineClient {
1532
1645
  }
1533
1646
  }
1534
1647
  async $transaction(fnOrQueries, options) {
1648
+ // Scope the WHOLE call, so the transaction-scoped QueryInterfaces built
1649
+ // inside TransactionClient.table() inherit this client's mode without
1650
+ // needing their own accessor.
1651
+ return this.withErrorMode(() => this.runTransaction(fnOrQueries, options));
1652
+ }
1653
+ async runTransaction(fnOrQueries, options) {
1535
1654
  // Batch overload: an array of DeferredQuery objects runs atomically inside
1536
1655
  // one BEGIN…COMMIT, reusing the raw transaction machinery below.
1537
1656
  if (Array.isArray(fnOrQueries)) {