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
package/dist/dialect.d.ts CHANGED
@@ -220,6 +220,51 @@ export interface LimitOffsetInput {
220
220
  /** Whether the outer SELECT already carries an ORDER BY (so none must be injected). */
221
221
  hasOrderBy: boolean;
222
222
  }
223
+ /**
224
+ * Inputs for {@link Dialect.buildPartitionLimit}, the per-correlation-key row
225
+ * bound the batched relation loader pushes into its follow-up query.
226
+ *
227
+ * The follow-up is one flat `WHERE fk = ANY($1)` over EVERY parent's children,
228
+ * so a trailing `LIMIT n` would cap the TOTAL rather than the per-parent count.
229
+ * A window function is the portable-in-shape way to say "at most n rows per
230
+ * key": rank inside the partition, then keep the ranks at or below the bound.
231
+ *
232
+ * The loader hands over the compiled child SELECT and the pieces it cannot
233
+ * spell engine-independently; the dialect owns the SQL text (and its aliases),
234
+ * exactly like {@link buildLimitOffset} and {@link buildRelationSubquery}.
235
+ */
236
+ export interface PartitionLimitInput {
237
+ /** The compiled child SELECT, to be wrapped as a derived table. */
238
+ innerSql: string;
239
+ /** RAW (unquoted) correlation column to partition by. */
240
+ partitionColumn: string;
241
+ /**
242
+ * The window's (and the outer result's) ordering, as RAW column names.
243
+ *
244
+ * NEVER EMPTY, and never a partial order: the loader only asks for this
245
+ * rewrite when the relation's `orderBy` covers a NOT NULL unique key of the
246
+ * target table, so no two rows of a partition can tie. That precondition is
247
+ * the whole licence for the rewrite. The join plan takes its `limit` with a
248
+ * per-parent `ORDER BY … LIMIT n` and this takes it with a rank over one flat
249
+ * result, and where ties exist the two are each free to keep different rows,
250
+ * which was measured happening (see `partitionOrderBy` in
251
+ * query/batched-loader.ts). Anything short of a total order therefore keeps
252
+ * the client-side slice instead of arriving here.
253
+ */
254
+ orderBy: readonly {
255
+ column: string;
256
+ direction: 'ASC' | 'DESC';
257
+ nulls?: 'FIRST' | 'LAST';
258
+ }[];
259
+ /** SQL-ready placeholder for the bound (`$3`, `?`, …). */
260
+ limitPlaceholder: string;
261
+ /**
262
+ * RAW name of the rank column the wrapper adds. It is part of the projection
263
+ * (`SELECT <outer>.*`), so the CALLER must remove it from each raw row before
264
+ * parsing, or it would surface as an extra field on every child entity.
265
+ */
266
+ rankColumn: string;
267
+ }
223
268
  /**
224
269
  * Everything an engine needs to OVERRIDE nested-relation subquery generation, for
225
270
  * dialects whose JSON-aggregation shape is fundamentally different from PostgreSQL's
@@ -345,6 +390,37 @@ export interface Dialect {
345
390
  readonly supportsUpsertUpdateWhere?: boolean;
346
391
  /** Whether this dialect/engine supports pgvector distance ops (KNN / distance WHERE). */
347
392
  readonly supportsVector: boolean;
393
+ /**
394
+ * Whether this dialect/engine can actually answer JSON CONTAINMENT, i.e. the
395
+ * pathless `{ contains }` and `{ equals }` JSON filters that compile through
396
+ * {@link buildJsonContains}.
397
+ *
398
+ * A capability flag rather than a per-operand hook, because the answer turned
399
+ * out not to vary by operand. SQLite emulates containment as
400
+ * `EXISTS (SELECT 1 FROM json_each(col) WHERE value = $1)` while the param is
401
+ * bound as JSON TEXT, and `json_each.value` yields the DECODED SQL value, so
402
+ * the comparison is between two different encodings and never holds. Measured
403
+ * against a real in-process SQLite, every operand type returned zero rows:
404
+ *
405
+ * stored filter PG SQLite
406
+ * ["gold"] contains: 'gold' match none ('gold' = '"gold"')
407
+ * [1] contains: 1 match none (INTEGER 1 = TEXT '1')
408
+ * {"a":1,"t":"gold"} contains: { a: 1 } match none (no structural walk)
409
+ *
410
+ * So the feature has never worked on that engine at all, and refusing it
411
+ * removes nothing that functioned. Fewer-rows-with-no-error is the exact
412
+ * degrade the capability contract exists to convert into a refusal.
413
+ *
414
+ * Repairing it rather than refusing it is possible and deliberately not done
415
+ * here: `json_each` exposes a `type` column ('text' / 'integer' / 'real' /
416
+ * 'true' / 'false' / 'null'), so a faithful scalar test is
417
+ * `type = $1 AND value IS $2`. That is a param-COUNT change on a path whose
418
+ * build and collect sides must stay in lockstep, which is a different change
419
+ * from this one. Until then the flag is honest and the error names the
420
+ * alternative that is exact today (`{ path: [...], equals }`, which compiles
421
+ * to `json_extract`).
422
+ */
423
+ readonly supportsJsonContains: boolean;
348
424
  /**
349
425
  * Whether this dialect/engine supports the PostgreSQL full-text `search`
350
426
  * filter (`to_tsvector(...) @@ to_tsquery(...)`). Optional: absent is treated
@@ -454,6 +530,28 @@ export interface Dialect {
454
530
  readonly supportsILike: boolean;
455
531
  /** Build a case-insensitive LIKE equivalent. */
456
532
  buildInsensitiveLike(column: string, paramRef: string): string;
533
+ /**
534
+ * Escape the LIKE metacharacters of this engine in a bound `contains` /
535
+ * `startsWith` / `endsWith` / `stringContains` operand. Optional: when a
536
+ * dialect omits it the shared {@link escapeLike} is used, which escapes the
537
+ * SQL-standard set (`\`, `%`, `_`) and pairs with the `ESCAPE '\'` clause the
538
+ * builders always emit.
539
+ *
540
+ * It exists because the standard set is not the whole set everywhere:
541
+ * T-SQL's `LIKE` also treats `[` as opening a character CLASS, so
542
+ * `{ contains: '[draft]' }` becomes "contains any one of d, r, a, f, t" on
543
+ * SQL Server, a silently over-broad predicate on an engine where every other
544
+ * dialect agrees the value is a literal. That is not injection (the operand
545
+ * is still bound), but it is a wrong answer, and the difference is a property
546
+ * of the ENGINE's pattern grammar, not of the SQL text, which is why it is a
547
+ * dialect hook rather than a branch in the where builder.
548
+ *
549
+ * Whatever a dialect returns MUST be escaped for the same `ESCAPE '\'`
550
+ * clause, i.e. a backslash prefix, and MUST be identical on the SQL-build and
551
+ * the cache-hit param-collect paths (both call this one hook, so they cannot
552
+ * drift).
553
+ */
554
+ escapeLikePattern?(value: string): string;
457
555
  /** JSON operator support level for this dialect. */
458
556
  readonly jsonPathSupport: 'native' | 'function' | 'limited';
459
557
  /** Build a JSON containment check. */
@@ -601,6 +699,31 @@ export interface Dialect {
601
699
  * param-push-ordering contract the override must honor.
602
700
  */
603
701
  buildRelationSubquery?(ctx: RelationSubqueryContext): string;
702
+ /**
703
+ * Bound a batched relation follow-up to at most N rows per correlation key,
704
+ * by wrapping the compiled child SELECT in a `ROW_NUMBER() OVER (PARTITION
705
+ * BY …)` filter. See {@link PartitionLimitInput} for why a trailing `LIMIT`
706
+ * cannot express this.
707
+ *
708
+ * OPTIONAL, and absent means "no pushdown": the batched loader then fetches
709
+ * every matching child and applies the per-relation `limit` client-side, the
710
+ * behaviour every engine had before this hook existed. That fallback is
711
+ * correct but unbounded in bytes over the wire (200 parents x ~505 children
712
+ * with `limit: 3` measured 101,000 rows fetched to keep 600, +52.9 MB peak
713
+ * heap against +0.5 MB for the join plan), which is why PostgreSQL
714
+ * implements it. Only PostgreSQL does in this release; the other engines keep
715
+ * the client-side slice, so their emitted SQL is byte-identical to before.
716
+ *
717
+ * The client-side slice is NOT removed when a dialect implements this: it
718
+ * stays as the belt-and-braces bound, and is a no-op once the engine has
719
+ * already limited each partition. It is also still the whole mechanism for
720
+ * any relation whose `orderBy` does not TOTALLY order the target table, on
721
+ * every engine including this one: a rewrite that reaches here has to return
722
+ * the same rows the join plan's per-parent `ORDER BY … LIMIT n` returns, and
723
+ * with ties present neither plan's choice is forced. See
724
+ * {@link PartitionLimitInput.orderBy}.
725
+ */
726
+ buildPartitionLimit?(input: PartitionLimitInput): string;
604
727
  }
605
728
  export interface DialectIntrospector {
606
729
  introspect(options: IntrospectOptions): Promise<SchemaMetadata>;
package/dist/dialect.js CHANGED
@@ -20,6 +20,7 @@ export const postgresDialect = {
20
20
  nullJsonLiteral: 'NULL',
21
21
  aggSupportsInlineOrderBy: true,
22
22
  supportsVector: true,
23
+ supportsJsonContains: true,
23
24
  supportsFullTextSearch: true,
24
25
  supportsArrayColumns: true,
25
26
  supportsListenNotify: true,
@@ -258,6 +259,38 @@ export const postgresDialect = {
258
259
  params: [name, value],
259
260
  };
260
261
  },
262
+ buildPartitionLimit(input) {
263
+ // Two wrapper levels, and both are needed: `ROW_NUMBER()` is a window
264
+ // function, so it cannot appear in the same WHERE that filters on it.
265
+ // Aliases are deliberately not in the `t<n>` family the relation builder
266
+ // allocates, so a future caller that wraps a query carrying those aliases
267
+ // cannot shadow one.
268
+ const inner = this.quoteIdentifier('turbine_pl_src');
269
+ const outer = this.quoteIdentifier('turbine_pl_rank');
270
+ const rank = this.quoteIdentifier(input.rankColumn);
271
+ const order = (ref) => input.orderBy
272
+ .map((o) => `${ref}.${this.quoteIdentifier(o.column)} ${o.direction}${o.nulls ? ` NULLS ${o.nulls}` : ''}`)
273
+ .join(', ');
274
+ // The empty-list branches are defensive only: the loader never asks for
275
+ // this rewrite without a total order (see the note on
276
+ // `PartitionLimitInput.orderBy`). An unordered window numbers each
277
+ // partition arbitrarily, and so does the join plan's ORDER-BY-less
278
+ // per-parent `LIMIT`, but they are different plans over different row sets
279
+ // and were measured choosing differently, so that shape keeps the loader's
280
+ // client-side slice rather than arriving here.
281
+ const windowOrder = input.orderBy.length > 0 ? ` ORDER BY ${order(inner)}` : '';
282
+ // The OUTER ordering is not cosmetic: the loader buckets children in result
283
+ // order, so without re-applying it here the relation array would come back
284
+ // in whatever order the rank filter happened to emit. It agrees with the
285
+ // window's own ordering by construction, since both are this one list and
286
+ // that list admits no ties, so the outer sort cannot pick a different order
287
+ // among the ranked rows than the rank did.
288
+ const outerOrder = input.orderBy.length > 0 ? ` ORDER BY ${order(outer)}` : '';
289
+ return (`SELECT ${outer}.* FROM (SELECT ${inner}.*, ROW_NUMBER() OVER (PARTITION BY ` +
290
+ `${inner}.${this.quoteIdentifier(input.partitionColumn)}${windowOrder}) AS ${rank} ` +
291
+ `FROM (${input.innerSql}) AS ${inner}) AS ${outer} ` +
292
+ `WHERE ${outer}.${rank} <= ${input.limitPlaceholder}${outerOrder}`);
293
+ },
261
294
  async *openStream(connection, sql, params, batchSize, opts) {
262
295
  // Cursors require a single connection inside a transaction: BEGIN → DECLARE
263
296
  // … NO SCROLL CURSOR FOR → FETCH n (loop) → CLOSE → COMMIT; ROLLBACK on
package/dist/errors.d.ts CHANGED
@@ -47,6 +47,40 @@ export declare class TurbineError extends Error {
47
47
  * with `{ errorMessages: 'verbose' }`.
48
48
  */
49
49
  export type ErrorMessageMode = 'safe' | 'verbose';
50
+ /**
51
+ * Register a client's configured mode and report whether per-operation scoping
52
+ * is now required.
53
+ *
54
+ * The gate is DIVERGENCE, not "has a client been built": while every client in
55
+ * the process agrees (the overwhelmingly common case, including every
56
+ * single-client app), the module default is already the right answer for all of
57
+ * them and no scope has to be established, so the query path pays exactly
58
+ * nothing. The moment two clients disagree, every client starts scoping, which
59
+ * is why this is read per call rather than latched per client.
60
+ *
61
+ * @internal Used by TurbineClient; not part of the public surface.
62
+ */
63
+ export declare function registerClientErrorMessageMode(mode: ErrorMessageMode): void;
64
+ /**
65
+ * Whether any two clients in this process have registered different modes, so
66
+ * the module default can no longer speak for all of them.
67
+ *
68
+ * @internal
69
+ */
70
+ export declare function errorMessageModesDiverged(): boolean;
71
+ /**
72
+ * Run `fn` with `mode` in force for everything it does, awaits included.
73
+ *
74
+ * Establishing the scope around the call (rather than threading a mode
75
+ * parameter into every error constructor) is what makes this reachable at all:
76
+ * the mode-sensitive errors are built deep inside the query executor, the write
77
+ * builders and the nested-write engine, none of which are handed the client.
78
+ * The async context is captured when the operation's promise chain is created
79
+ * INSIDE this call, so every continuation of it resolves the same mode.
80
+ *
81
+ * @internal
82
+ */
83
+ export declare function runWithErrorMessageMode<R>(mode: ErrorMessageMode, fn: () => R): R;
50
84
  /**
51
85
  * Set the global NotFoundError message mode. Called from the TurbineClient
52
86
  * constructor when `TurbineConfig.errorMessages` is provided.
@@ -70,7 +104,14 @@ export type ErrorMessageMode = 'safe' | 'verbose';
70
104
  * leak, not as a log-scrubbing boundary.
71
105
  */
72
106
  export declare function setErrorMessageMode(mode: ErrorMessageMode): void;
73
- /** Returns the current NotFoundError message mode. Exported for tests. */
107
+ /**
108
+ * Returns the NotFoundError message mode in effect right now.
109
+ *
110
+ * Called inside an operation issued through a client whose mode differs from
111
+ * another client's in the same process, that is THAT client's mode; anywhere
112
+ * else it is the process default {@link setErrorMessageMode} last set, which is
113
+ * what it has always returned. Exported for tests.
114
+ */
74
115
  export declare function getErrorMessageMode(): ErrorMessageMode;
75
116
  /**
76
117
  * The marker left where a driver `detail` string was removed in 'safe' mode.
@@ -78,6 +119,17 @@ export declare function getErrorMessageMode(): ErrorMessageMode;
78
119
  * assertions can match on it without hardcoding the wording.
79
120
  */
80
121
  export declare const REDACTED_DETAIL = "[redacted by turbine errorMessages:\"safe\"]";
122
+ /**
123
+ * Mark a driver error as carrying row values in its `message` (see
124
+ * {@link VALUE_BEARING_MESSAGE}). Called by the engine error augmenters for the
125
+ * exact driver codes whose message grammar embeds a value; every other code is
126
+ * left alone so nothing is withheld that did not need to be.
127
+ *
128
+ * Non-enumerable so the flag itself never shows up in a serialized error, and
129
+ * best-effort so a frozen or exotic driver error cannot turn a constraint
130
+ * violation into a TypeError.
131
+ */
132
+ export declare function markValueBearingMessage<T>(err: T): T;
81
133
  /**
82
134
  * Render a user-supplied `where` / `connect` target for a "no row found" error
83
135
  * message, honoring the global {@link ErrorMessageMode}. In 'safe' mode (the
@@ -168,6 +220,27 @@ export declare class ConnectionError extends TurbineError {
168
220
  sqlstate?: string;
169
221
  });
170
222
  }
223
+ /**
224
+ * The message for "this engine's connection string could not be parsed".
225
+ *
226
+ * The one rule it exists to enforce: NEVER echo the value. A DSN carries a
227
+ * password, and the trigger for this error is a MALFORMED DSN, which is exactly
228
+ * when someone pastes the failure into a bug report, a CI log, or an error
229
+ * tracker. No redaction written against the URL grammar can be trusted on a
230
+ * string that just failed to parse as a URL, so the only safe amount of it to
231
+ * include is none: not the password, not the host, not a prefix.
232
+ *
233
+ * This mirrors the Postgres path (`assertUsableConnectionString` in client.ts),
234
+ * which reached the same conclusion first and stated the same reason; the
235
+ * engines used to interpolate the raw string instead, so SECURITY.md's claim of
236
+ * redaction "in all CLI error output" was true for one engine out of four.
237
+ * Shared rather than copied so the three engines cannot drift back apart.
238
+ *
239
+ * @param engine the human engine label ("MySQL", "SQL Server", "PowDB").
240
+ * @param example a well-formed connection string for that engine, with a
241
+ * placeholder password. Never derived from the caller's value.
242
+ */
243
+ export declare function malformedConnectionStringMessage(engine: string, example: string): string;
171
244
  /** Thrown when a relation reference is invalid */
172
245
  export declare class RelationError extends TurbineError {
173
246
  constructor(message: string);
package/dist/errors.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
1
2
  /**
2
3
  * turbine-orm, Error types
3
4
  *
@@ -77,6 +78,76 @@ export class TurbineError extends Error {
77
78
  }
78
79
  }
79
80
  let errorMessageMode = 'safe';
81
+ /**
82
+ * The mode in force for the operation currently executing, when a client has
83
+ * established one. See {@link runWithErrorMessageMode}.
84
+ */
85
+ const errorMessageModeScope = new AsyncLocalStorage();
86
+ /** The first mode a TurbineClient registered, used to detect divergence. */
87
+ let firstRegisteredClientMode;
88
+ let clientModesDiverged = false;
89
+ /**
90
+ * The mode that applies right now: the scope a client established for this
91
+ * operation, else the process-wide default.
92
+ *
93
+ * The default is the fallback rather than the authority, which is the whole
94
+ * point. `errorMessageMode` is a module-level `let` that every TurbineClient
95
+ * constructor overwrote, so in a process with a primary client and an analytics
96
+ * or replica client, or a test harness building one client per suite, the LAST
97
+ * client constructed decided the mode for ALL of them, order-dependently. A
98
+ * client asking for `'safe'` could silently be running in `'verbose'`, which is
99
+ * the direction that leaks. (The dual ESM+CJS build makes it worse: the two
100
+ * copies hold separate `let`s.)
101
+ *
102
+ * An error constructed outside any client, directly or via
103
+ * {@link setErrorMessageMode}, still formats by the default, unchanged.
104
+ */
105
+ function currentErrorMessageMode() {
106
+ return errorMessageModeScope.getStore() ?? errorMessageMode;
107
+ }
108
+ /**
109
+ * Register a client's configured mode and report whether per-operation scoping
110
+ * is now required.
111
+ *
112
+ * The gate is DIVERGENCE, not "has a client been built": while every client in
113
+ * the process agrees (the overwhelmingly common case, including every
114
+ * single-client app), the module default is already the right answer for all of
115
+ * them and no scope has to be established, so the query path pays exactly
116
+ * nothing. The moment two clients disagree, every client starts scoping, which
117
+ * is why this is read per call rather than latched per client.
118
+ *
119
+ * @internal Used by TurbineClient; not part of the public surface.
120
+ */
121
+ export function registerClientErrorMessageMode(mode) {
122
+ if (firstRegisteredClientMode === undefined)
123
+ firstRegisteredClientMode = mode;
124
+ else if (firstRegisteredClientMode !== mode)
125
+ clientModesDiverged = true;
126
+ }
127
+ /**
128
+ * Whether any two clients in this process have registered different modes, so
129
+ * the module default can no longer speak for all of them.
130
+ *
131
+ * @internal
132
+ */
133
+ export function errorMessageModesDiverged() {
134
+ return clientModesDiverged;
135
+ }
136
+ /**
137
+ * Run `fn` with `mode` in force for everything it does, awaits included.
138
+ *
139
+ * Establishing the scope around the call (rather than threading a mode
140
+ * parameter into every error constructor) is what makes this reachable at all:
141
+ * the mode-sensitive errors are built deep inside the query executor, the write
142
+ * builders and the nested-write engine, none of which are handed the client.
143
+ * The async context is captured when the operation's promise chain is created
144
+ * INSIDE this call, so every continuation of it resolves the same mode.
145
+ *
146
+ * @internal
147
+ */
148
+ export function runWithErrorMessageMode(mode, fn) {
149
+ return errorMessageModeScope.run(mode, fn);
150
+ }
80
151
  /**
81
152
  * Set the global NotFoundError message mode. Called from the TurbineClient
82
153
  * constructor when `TurbineConfig.errorMessages` is provided.
@@ -102,9 +173,16 @@ let errorMessageMode = 'safe';
102
173
  export function setErrorMessageMode(mode) {
103
174
  errorMessageMode = mode;
104
175
  }
105
- /** Returns the current NotFoundError message mode. Exported for tests. */
176
+ /**
177
+ * Returns the NotFoundError message mode in effect right now.
178
+ *
179
+ * Called inside an operation issued through a client whose mode differs from
180
+ * another client's in the same process, that is THAT client's mode; anywhere
181
+ * else it is the process default {@link setErrorMessageMode} last set, which is
182
+ * what it has always returned. Exported for tests.
183
+ */
106
184
  export function getErrorMessageMode() {
107
- return errorMessageMode;
185
+ return currentErrorMessageMode();
108
186
  }
109
187
  /**
110
188
  * The marker left where a driver `detail` string was removed in 'safe' mode.
@@ -112,6 +190,69 @@ export function getErrorMessageMode() {
112
190
  * assertions can match on it without hardcoding the wording.
113
191
  */
114
192
  export const REDACTED_DETAIL = '[redacted by turbine errorMessages:"safe"]';
193
+ /**
194
+ * Marks a driver error whose `message` (not only its `detail`) embeds row
195
+ * VALUES, so 'safe' mode has to withhold the message text too.
196
+ *
197
+ * Postgres splits this cleanly: `message` carries relation / constraint /
198
+ * column NAMES and `detail` carries the conflicting values, so redacting
199
+ * `detail` was enough. MySQL and SQL Server do not split it at all. mysql2's
200
+ * ER_DUP_ENTRY reads `Duplicate entry 'alice@example.com' for key
201
+ * 'users.email'` and SQL Server's 2627 ends `The duplicate key value is
202
+ * (alice@example.com).`, both on `message`, with no `detail` field anywhere.
203
+ * So `redactCauseForMode` took its "nothing value-bearing to remove" early
204
+ * return and handed back the driver error untouched, and the row value reached
205
+ * every log line, Sentry event and uncaught-rejection dump that renders the
206
+ * cause chain, in the mode whose entire job is to prevent exactly that.
207
+ *
208
+ * A flag rather than a code list, and set by the ENGINE that knows its own
209
+ * message grammar, for two reasons. It keeps Postgres byte-identical (pg never
210
+ * sets it, so its cause is returned exactly as before), and it puts "this
211
+ * engine's message embeds values" next to the code that reads that engine's
212
+ * messages instead of in a table here that would silently rot.
213
+ *
214
+ * `Symbol.for` so the ESM and CJS copies of this module agree on the key: a
215
+ * dual-package consumer can hand an error marked by one build to the other.
216
+ */
217
+ const VALUE_BEARING_MESSAGE = Symbol.for('turbine.error.valueBearingMessage');
218
+ /**
219
+ * Mark a driver error as carrying row values in its `message` (see
220
+ * {@link VALUE_BEARING_MESSAGE}). Called by the engine error augmenters for the
221
+ * exact driver codes whose message grammar embeds a value; every other code is
222
+ * left alone so nothing is withheld that did not need to be.
223
+ *
224
+ * Non-enumerable so the flag itself never shows up in a serialized error, and
225
+ * best-effort so a frozen or exotic driver error cannot turn a constraint
226
+ * violation into a TypeError.
227
+ */
228
+ export function markValueBearingMessage(err) {
229
+ if (!err || typeof err !== 'object')
230
+ return err;
231
+ try {
232
+ Object.defineProperty(err, VALUE_BEARING_MESSAGE, {
233
+ value: true,
234
+ enumerable: false,
235
+ writable: true,
236
+ configurable: true,
237
+ });
238
+ }
239
+ catch {
240
+ /* a frozen driver error keeps its message; the Turbine message is still safe */
241
+ }
242
+ return err;
243
+ }
244
+ /**
245
+ * The replacement for a withheld driver message. The whole message goes, not a
246
+ * pattern-matched part of it: three engines with three grammars and no
247
+ * guarantee a future driver keeps either, so "withhold it" is the only rule
248
+ * that stays true. Nothing diagnostic is lost, the constraint/column/table
249
+ * names are already on the typed Turbine error as structured fields and in its
250
+ * own message, and `errorMessages: 'verbose'` returns the driver text in full.
251
+ */
252
+ function redactedDriverMessage(code) {
253
+ const codePart = typeof code === 'string' && code.length > 0 ? ` (driver code ${code})` : '';
254
+ return `${REDACTED_DETAIL}${codePart}: this engine reports the conflicting row values in the message text, so errorMessages:"safe" withholds it. Use errorMessages:"verbose" to see it.`;
255
+ }
115
256
  /**
116
257
  * Postgres puts the CONFLICTING ROW VALUES in the `detail` field of a
117
258
  * constraint error, and nowhere else: `Key (email)=(alice@example.com) already
@@ -148,17 +289,29 @@ export const REDACTED_DETAIL = '[redacted by turbine errorMessages:"safe"]';
148
289
  *
149
290
  * In 'verbose' mode the cause passes through untouched: that mode's documented
150
291
  * job is full-fidelity debugging.
292
+ *
293
+ * ENGINES WHOSE MESSAGE CARRIES THE VALUE. MySQL and SQL Server do not have a
294
+ * `detail` field at all and put the conflicting value in `message`, so the
295
+ * early return below used to hand the raw driver error straight back (see
296
+ * {@link markValueBearingMessage}). When the engine set that flag the clone
297
+ * also withholds `message`, `sqlMessage` (mysql2's copy, which is the field
298
+ * mysql2 formats `message` FROM, so leaving it would put the value straight
299
+ * back) and the message text embedded in the rendered `stack` string. The stack
300
+ * substitution is an exact replacement of the known message string, never a
301
+ * grammar guess, so the frames survive intact.
151
302
  */
152
303
  function redactCauseForMode(cause) {
153
- if (errorMessageMode === 'verbose')
304
+ if (currentErrorMessageMode() === 'verbose')
154
305
  return cause;
155
306
  if (!cause || typeof cause !== 'object')
156
307
  return cause;
157
308
  const detail = cause.detail;
309
+ const hasDetail = typeof detail === 'string' && detail.length > 0;
310
+ const valueBearingMessage = cause[VALUE_BEARING_MESSAGE] === true;
158
311
  // Nothing value-bearing to remove: return the original object so the common
159
312
  // case (a non-pg cause, or a pg error without a detail) allocates nothing and
160
313
  // keeps object identity with what the driver threw.
161
- if (typeof detail !== 'string' || detail.length === 0)
314
+ if (!hasDetail && !valueBearingMessage)
162
315
  return cause;
163
316
  try {
164
317
  const descriptors = Object.getOwnPropertyDescriptors(cause);
@@ -166,12 +319,36 @@ function redactCauseForMode(cause) {
166
319
  // non-writable `detail` would make the assignment throw in strict mode
167
320
  // (every module here is ESM, so it always would), and losing the cause is
168
321
  // worse than paying for one descriptor literal.
169
- descriptors.detail = {
170
- value: REDACTED_DETAIL,
171
- writable: true,
172
- enumerable: descriptors.detail?.enumerable ?? true,
173
- configurable: true,
174
- };
322
+ if (hasDetail) {
323
+ descriptors.detail = {
324
+ value: REDACTED_DETAIL,
325
+ writable: true,
326
+ enumerable: descriptors.detail?.enumerable ?? true,
327
+ configurable: true,
328
+ };
329
+ }
330
+ // The exact message strings to scrub out of the rendered stack, collected
331
+ // BEFORE the descriptors are overwritten.
332
+ const withheldTexts = [];
333
+ if (valueBearingMessage) {
334
+ const code = cause.code;
335
+ const replacement = redactedDriverMessage(code);
336
+ for (const key of ['message', 'sqlMessage']) {
337
+ const current = cause[key];
338
+ if (typeof current !== 'string' || current.length === 0)
339
+ continue;
340
+ withheldTexts.push(current);
341
+ descriptors[key] = {
342
+ value: replacement,
343
+ writable: true,
344
+ // `message` is non-enumerable on a native Error and mysql2's
345
+ // `sqlMessage` is enumerable; keep whichever the driver chose so the
346
+ // clone serializes with the same key set as the original.
347
+ enumerable: descriptors[key]?.enumerable ?? false,
348
+ configurable: true,
349
+ };
350
+ }
351
+ }
175
352
  // Brand check rather than `instanceof Error`, so a driver error thrown from
176
353
  // another realm (a worker, a bundled duplicate of pg) is still recognized.
177
354
  const isError = Object.prototype.toString.call(cause) === '[object Error]';
@@ -189,7 +366,14 @@ function redactCauseForMode(cause) {
189
366
  // own descriptor rather than having a lie written over it.
190
367
  const originalStack = cause.stack;
191
368
  if (typeof originalStack === 'string') {
192
- descriptors.stack = { value: originalStack, writable: true, enumerable: false, configurable: true };
369
+ // V8 renders the stack as `<name>: <message>\n at …`, so a withheld
370
+ // message is still sitting in it. Substitute the exact strings that were
371
+ // withheld (split/join, so a message repeated in a nested frame goes
372
+ // too); everything else, the frames included, is untouched.
373
+ let stackText = originalStack;
374
+ for (const text of withheldTexts)
375
+ stackText = stackText.split(text).join(REDACTED_DETAIL);
376
+ descriptors.stack = { value: stackText, writable: true, enumerable: false, configurable: true };
193
377
  }
194
378
  else if (descriptors.stack && typeof descriptors.stack.get === 'function') {
195
379
  // An own accessor bound to the ORIGINAL receiver would return undefined
@@ -218,7 +402,7 @@ function redactCauseForMode(cause) {
218
402
  * connect/update failures which historically embedded the raw values.
219
403
  */
220
404
  export function describeTargetForMessage(target) {
221
- if (errorMessageMode === 'verbose') {
405
+ if (currentErrorMessageMode() === 'verbose') {
222
406
  try {
223
407
  return JSON.stringify(target);
224
408
  }
@@ -292,11 +476,11 @@ export class NotFoundError extends TurbineError {
292
476
  let message = input.message;
293
477
  if (!message) {
294
478
  if (operation && table) {
295
- const wherePart = where !== undefined ? ` matching where: ${renderWhereForMessage(where, errorMessageMode)}` : '';
479
+ const wherePart = where !== undefined ? ` matching where: ${renderWhereForMessage(where, currentErrorMessageMode())}` : '';
296
480
  message = `[turbine] ${operation} on "${table}" found no record${wherePart}`;
297
481
  }
298
482
  else if (table) {
299
- const wherePart = where !== undefined ? ` matching where ${renderWhereForMessage(where, errorMessageMode)}` : '';
483
+ const wherePart = where !== undefined ? ` matching where ${renderWhereForMessage(where, currentErrorMessageMode())}` : '';
300
484
  message = `[turbine] No record found in "${table}"${wherePart}`;
301
485
  }
302
486
  else {
@@ -362,6 +546,31 @@ export class ConnectionError extends TurbineError {
362
546
  this.sqlstate = options?.sqlstate;
363
547
  }
364
548
  }
549
+ /**
550
+ * The message for "this engine's connection string could not be parsed".
551
+ *
552
+ * The one rule it exists to enforce: NEVER echo the value. A DSN carries a
553
+ * password, and the trigger for this error is a MALFORMED DSN, which is exactly
554
+ * when someone pastes the failure into a bug report, a CI log, or an error
555
+ * tracker. No redaction written against the URL grammar can be trusted on a
556
+ * string that just failed to parse as a URL, so the only safe amount of it to
557
+ * include is none: not the password, not the host, not a prefix.
558
+ *
559
+ * This mirrors the Postgres path (`assertUsableConnectionString` in client.ts),
560
+ * which reached the same conclusion first and stated the same reason; the
561
+ * engines used to interpolate the raw string instead, so SECURITY.md's claim of
562
+ * redaction "in all CLI error output" was true for one engine out of four.
563
+ * Shared rather than copied so the three engines cannot drift back apart.
564
+ *
565
+ * @param engine the human engine label ("MySQL", "SQL Server", "PowDB").
566
+ * @param example a well-formed connection string for that engine, with a
567
+ * placeholder password. Never derived from the caller's value.
568
+ */
569
+ export function malformedConnectionStringMessage(engine, example) {
570
+ return (`[turbine] The ${engine} connection string could not be parsed as a URL. Expected something like "${example}". ` +
571
+ '(Check for a missing "//", a stray quote copied out of a .env file, or a shell-truncated value.) ' +
572
+ 'The value is not included here because it may contain a password.');
573
+ }
365
574
  /** Thrown when a relation reference is invalid */
366
575
  export class RelationError extends TurbineError {
367
576
  constructor(message) {
@@ -419,7 +628,7 @@ export class UniqueConstraintError extends TurbineError {
419
628
  // values straight back into any log line that prints the error object).
420
629
  // The structured `.columns`/`.constraint`/`.column` fields survive in
421
630
  // both modes, they carry NAMES, never values.
422
- const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
631
+ const detail = currentErrorMessageMode() === 'verbose' ? detailFromCause(cause) : undefined;
423
632
  if (detail)
424
633
  message += `: ${detail}`;
425
634
  }
@@ -449,7 +658,7 @@ export class ForeignKeyError extends TurbineError {
449
658
  // values straight back into any log line that prints the error object).
450
659
  // The structured `.columns`/`.constraint`/`.column` fields survive in
451
660
  // both modes, they carry NAMES, never values.
452
- const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
661
+ const detail = currentErrorMessageMode() === 'verbose' ? detailFromCause(cause) : undefined;
453
662
  if (detail)
454
663
  message += `: ${detail}`;
455
664
  }
@@ -478,7 +687,7 @@ export class NotNullViolationError extends TurbineError {
478
687
  // values straight back into any log line that prints the error object).
479
688
  // The structured `.columns`/`.constraint`/`.column` fields survive in
480
689
  // both modes, they carry NAMES, never values.
481
- const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
690
+ const detail = currentErrorMessageMode() === 'verbose' ? detailFromCause(cause) : undefined;
482
691
  if (detail)
483
692
  message += `: ${detail}`;
484
693
  }
@@ -572,7 +781,7 @@ export class CheckConstraintError extends TurbineError {
572
781
  // values straight back into any log line that prints the error object).
573
782
  // The structured `.columns`/`.constraint`/`.column` fields survive in
574
783
  // both modes, they carry NAMES, never values.
575
- const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
784
+ const detail = currentErrorMessageMode() === 'verbose' ? detailFromCause(cause) : undefined;
576
785
  if (detail)
577
786
  message += `: ${detail}`;
578
787
  }
@@ -600,7 +809,7 @@ export class ExclusionConstraintError extends TurbineError {
600
809
  // values straight back into any log line that prints the error object).
601
810
  // The structured `.columns`/`.constraint`/`.column` fields survive in
602
811
  // both modes, they carry NAMES, never values.
603
- const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
812
+ const detail = currentErrorMessageMode() === 'verbose' ? detailFromCause(cause) : undefined;
604
813
  if (detail)
605
814
  message += `: ${detail}`;
606
815
  }