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
@@ -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[rel.to];
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
- * Walks the whole args tree: `where`, `orderBy` (object AND array form),
809
- * `cursor`, `distinct`, boolean combinators, relation filters, and each `with`
810
- * level against that relation's target table. `select` / `omit` are NOT
811
- * refused: they return values, and those values are redacted on the way out.
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
- * Every depth check FAILS CLOSED. Returning quietly at the cap (what this used
814
- * to do) meant padding a payload with, for example, eleven nested `NOT`
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
- const assertWithinDepth = (depth) => {
822
- if (depth <= PII_GUARD_MAX_DEPTH)
823
- return;
824
- throw new ValidationError(`[turbine] Query is nested more than ${PII_GUARD_MAX_DEPTH} levels deep, which is past the point where ` +
825
- `Studio can prove it does not filter or sort on a PII-tagged and redacted column, so it is refused. ` +
826
- `Flatten the query, or restart Studio with --show-pii.`);
827
- };
828
- const refuse = (table, column) => {
829
- throw new ValidationError(`[turbine] Column "${column}" on "${table.name}" is PII-tagged and redacted, so it cannot be used ` +
830
- `in a where, orderBy, cursor, or distinct: filtering, sorting, paging, or de-duplicating on a hidden ` +
831
- `value reveals it. Restart Studio with --show-pii to query it.`);
832
- };
833
- const visitClause = (node, table, depth) => {
834
- assertWithinDepth(depth);
835
- if (!table || node === null || typeof node !== 'object')
836
- return;
837
- // `orderBy` accepts a Prisma-style array of single-key objects, and so does
838
- // a `NOT` list. Element order carries no nesting, so the depth is unchanged.
839
- if (Array.isArray(node)) {
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[tableName];
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
- await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
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
- await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
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[table]) {
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[tableName];
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.errorMessagesSafe = (config.errorMessages ?? 'safe') === 'safe';
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
- if (this.replicaPools.length === 0) {
1270
- return this.primaryTableQI(name);
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
- return executePipeline(this.pool, queries, options);
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
- return new TypedSqlQuery(this.pool, sql, params, this.logging);
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)) {