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/mysql.js CHANGED
@@ -64,7 +64,7 @@
64
64
  */
65
65
  import { TurbineClient } from './client.js';
66
66
  import { postgresDialect, } from './dialect.js';
67
- import { ConnectionError, UnsupportedFeatureError } from './errors.js';
67
+ import { ConnectionError, malformedConnectionStringMessage, markValueBearingMessage, UnsupportedFeatureError, } from './errors.js';
68
68
  import { applyTableFilters, deriveEngineRelations } from './introspect.js';
69
69
  import importOptionalPeer from './optional-peer-import.cjs';
70
70
  import { isDateType, snakeToCamel, } from './schema.js';
@@ -114,10 +114,58 @@ function toNamedBinding(values) {
114
114
  // ---------------------------------------------------------------------------
115
115
  // Result shaping + error translation
116
116
  // ---------------------------------------------------------------------------
117
+ /**
118
+ * mysql2's `MYSQL_TYPE_TINY`. A result field with this type and a display width
119
+ * of 1 IS the `BOOLEAN` / `TINYINT(1)` column; see {@link MYSQL_BOOLEAN_TYPE}.
120
+ */
121
+ const MYSQL_FIELD_TYPE_TINY = 1;
122
+ /**
123
+ * Turn `TINYINT(1)` cells back into real booleans, in place.
124
+ *
125
+ * MySQL stores a boolean as 1/0 and mysql2 returns the integer. Turbine still
126
+ * generates `ok: boolean` for the column ({@link mysqlTypeToTs}), so
127
+ * `row.ok === true` was false, `JSON.stringify(row)` emitted `1`, and a value
128
+ * the ORM itself wrote as `true` came back as a number.
129
+ *
130
+ * Done from the RESULT FIELDS rather than from mysql2's `typeCast` option on
131
+ * purpose. `typeCast` lives in {@link MYSQL_DRIVER_FLAGS}, which only applies
132
+ * to pools TURBINE builds; an injected mysql2 pool (a supported, documented
133
+ * entry point) would keep returning numbers at the top level while the join
134
+ * strategy's `jsonWireRule` returned booleans, i.e. fixing it there would have
135
+ * created exactly the strategy-dependent split this is meant to close. The
136
+ * field metadata is on every result from every pool.
137
+ *
138
+ * An expression column is never touched: `ok + 0` comes back as
139
+ * MYSQL_TYPE_LONGLONG with a different width, and only an exact
140
+ * (TINY, width 1) field is coerced.
141
+ */
142
+ function coerceBooleanColumns(rows, fields) {
143
+ if (!Array.isArray(fields) || rows.length === 0)
144
+ return;
145
+ const names = [];
146
+ for (const f of fields) {
147
+ if (!f || f.columnType !== MYSQL_FIELD_TYPE_TINY || f.columnLength !== 1)
148
+ continue;
149
+ if (typeof f.name !== 'string')
150
+ continue;
151
+ names.push(f.name);
152
+ }
153
+ if (names.length === 0)
154
+ return;
155
+ for (const row of rows) {
156
+ for (const name of names) {
157
+ const v = row[name];
158
+ if (v === 1 || v === 0)
159
+ row[name] = v === 1;
160
+ }
161
+ }
162
+ }
117
163
  /** Shape a mysql2 `[result]` into a `pg.QueryResult`-like object. */
118
- function shapeResult(result) {
164
+ function shapeResult(result, fields) {
119
165
  if (Array.isArray(result)) {
120
- return { rows: result, rowCount: result.length };
166
+ const rows = result;
167
+ coerceBooleanColumns(rows, fields);
168
+ return { rows, rowCount: rows.length };
121
169
  }
122
170
  // Write statement → ResultSetHeader (no rows). Expose insertId/lastID for the
123
171
  // `reselect` strategy to re-fetch the auto-increment row.
@@ -154,6 +202,19 @@ function augmentMysqlError(err) {
154
202
  const parts = key.split('.');
155
203
  target.constraint = parts.length > 1 ? parts[parts.length - 1] : key;
156
204
  }
205
+ // THE conflicting row value is in that message, between the quotes after
206
+ // "Duplicate entry". MySQL has no `detail` field, so 'safe' mode had
207
+ // nothing to redact and returned this error verbatim as `.cause`, putting
208
+ // the value into every log line that renders the cause chain. Flag it so
209
+ // the message is withheld in 'safe' mode (see markValueBearingMessage);
210
+ // the constraint NAME survives on `target.constraint` and in the typed
211
+ // UniqueConstraintError's own message, so nothing diagnostic is lost.
212
+ //
213
+ // Deliberately only 1062: the other MySQL codes handled here report
214
+ // column/constraint NAMES only ("Column 'x' cannot be null", "Check
215
+ // constraint 'c' is violated", the FK constraint definition), so flagging
216
+ // them would withhold a message that never carried a value.
217
+ markValueBearingMessage(err);
157
218
  return err;
158
219
  }
159
220
  // ER_ROW_IS_REFERENCED_2 / ER_NO_REFERENCED_ROW_2 → foreign key violation
@@ -202,8 +263,8 @@ function normalizeQueryArgs(arg, values) {
202
263
  async function execOne(runner, sql, params) {
203
264
  const binding = toNamedBinding(params);
204
265
  try {
205
- const [result] = binding ? await runner.execute(sql, binding) : await runner.query(sql);
206
- return shapeResult(result);
266
+ const [result, fields] = binding ? await runner.execute(sql, binding) : await runner.query(sql);
267
+ return shapeResult(result, fields);
207
268
  }
208
269
  catch (err) {
209
270
  throw augmentMysqlError(err);
@@ -279,7 +340,11 @@ export function mysqlTypeToTs(dialectType, nullable, columnType) {
279
340
  const t = dialectType.toLowerCase();
280
341
  const full = (columnType ?? '').toLowerCase();
281
342
  let base;
282
- if (t === 'tinyint' && full === 'tinyint(1)')
343
+ // `tinyint(1)` can arrive either way: as (DATA_TYPE, COLUMN_TYPE) from the
344
+ // introspector's first pass, or as a single dialect type, because that is the
345
+ // spelling the introspector RECORDS for a boolean column (see the note there)
346
+ // and the `typeToTypeScript` dialect hook passes only the one string.
347
+ if (t === MYSQL_BOOLEAN_TYPE || (t === 'tinyint' && full === MYSQL_BOOLEAN_TYPE))
283
348
  base = 'boolean';
284
349
  else if (/^(tinyint|smallint|mediumint|int|integer|bigint)$/.test(t))
285
350
  base = 'number';
@@ -299,6 +364,63 @@ export function mysqlTypeToTs(dialectType, nullable, columnType) {
299
364
  base = 'unknown';
300
365
  return nullable ? `${base} | null` : base;
301
366
  }
367
+ /**
368
+ * The dialect-type spelling Turbine records for a MySQL boolean column.
369
+ *
370
+ * MySQL has no boolean type: `BOOLEAN` is an alias for `TINYINT(1)`, and
371
+ * `information_schema` reports DATA_TYPE `tinyint` for it, which is the same
372
+ * DATA_TYPE a genuine one-byte integer column has. The display width in
373
+ * COLUMN_TYPE is the ONLY thing that separates them, so the introspector keeps
374
+ * the full spelling for this one type rather than the bare DATA_TYPE. Anything
375
+ * reading a dialect type has to accept both spellings; the two places that
376
+ * matter are {@link mysqlTypeToTs} and {@link mysqlDialect.jsonWireRule}.
377
+ */
378
+ const MYSQL_BOOLEAN_TYPE = 'tinyint(1)';
379
+ /**
380
+ * Rewrite every boolean column's recorded dialect type to
381
+ * {@link MYSQL_BOOLEAN_TYPE}, returning a schema that is safe to key
382
+ * {@link mysqlDialect.jsonWireRule} on.
383
+ *
384
+ * WHY. Only the join strategy consults the recorded type, so a column whose
385
+ * type is spelled some other way reads as a boolean at the top level (the shim
386
+ * decides that from the RESULT FIELDS, which every pool has) and as 1/0 through
387
+ * a `with` join, which is the strategy-dependent split the boolean rule exists
388
+ * to close. Two schemas spell it differently and neither is wrong:
389
+ *
390
+ * - a metadata file generated before Turbine started keeping the display width
391
+ * records the bare DATA_TYPE `tinyint` (with `tsType: 'boolean'`, which is
392
+ * what identifies it), so `turbine generate` is NOT required to get the fix;
393
+ * - a code-first `defineSchema` column records the Postgres spelling
394
+ * `boolean` / `bool`, which the DDL generator already emits as `TINYINT(1)`.
395
+ *
396
+ * A genuine one-byte integer column is never touched: it carries
397
+ * `tsType: 'number'`, and the `tsType` is the gate. Copy-on-write, because the
398
+ * argument is usually a shared exported constant and may also be handed to
399
+ * another engine's factory; an already-correct schema is returned unchanged and
400
+ * pays nothing.
401
+ */
402
+ function normalizeBooleanDialectTypes(schema) {
403
+ const needsFix = (col) => {
404
+ if (!/^boolean\b/.test(col.tsType))
405
+ return false;
406
+ const recorded = (col.dialectType ?? col.pgType ?? '').toLowerCase();
407
+ return recorded === 'tinyint' || recorded === 'bool' || recorded === 'boolean';
408
+ };
409
+ let tables;
410
+ for (const [name, table] of Object.entries(schema.tables)) {
411
+ if (!table.columns.some(needsFix))
412
+ continue;
413
+ const columns = table.columns.map((col) => needsFix(col) ? { ...col, dialectType: MYSQL_BOOLEAN_TYPE, pgType: MYSQL_BOOLEAN_TYPE } : col);
414
+ if (!tables)
415
+ tables = { ...schema.tables };
416
+ tables[name] = {
417
+ ...table,
418
+ columns,
419
+ pgTypes: Object.fromEntries(columns.map((c) => [c.name, c.pgType])),
420
+ };
421
+ }
422
+ return tables ? { ...schema, tables } : schema;
423
+ }
302
424
  /** Is a MySQL declared type a date/time type (so values coerce back to `Date`)? */
303
425
  function isMysqlDateType(dialectType) {
304
426
  const t = dialectType.toLowerCase();
@@ -466,6 +588,52 @@ export const mysqlDialect = {
466
588
  if (t === 'decimal' || t === 'numeric') {
467
589
  return { sql: (ref) => `CAST(${ref} AS CHAR)`, decode: (value) => value };
468
590
  }
591
+ // A boolean is 1/0 on the wire, and the driver shim turns that back into
592
+ // true/false on every direct read (see coerceBooleanColumns). JSON_OBJECT
593
+ // renders the same column as a JSON number and has no column metadata to
594
+ // consult, so without this the join strategy alone would keep returning
595
+ // 1/0: the same strategy-dependent type flip the json rule below exists to
596
+ // prevent. Carried as text because the decode half only runs on a string
597
+ // cell, and a plain CAST is enough here (unlike SQLite, MySQL enforces the
598
+ // column type, so the value is always an integer and `Number` reproduces
599
+ // the driver's own reading of anything that is not 0 or 1).
600
+ if (t === MYSQL_BOOLEAN_TYPE) {
601
+ return {
602
+ sql: (ref) => `CAST(${ref} AS CHAR)`,
603
+ decode: (value) => {
604
+ if (value === '1')
605
+ return true;
606
+ if (value === '0')
607
+ return false;
608
+ if (typeof value !== 'string' || !/^-?\d+$/.test(value))
609
+ return value;
610
+ return Number(value);
611
+ },
612
+ };
613
+ }
614
+ // A `json` column gets NO rule, and that is the fix rather than the absence
615
+ // of one. It used to read differently depending on the relation strategy:
616
+ // the driver handed back text while JSON_OBJECT, which knows the column is
617
+ // JSON, embedded it as a real nested value, so parsing the row yielded an
618
+ // object. Under the default `'auto'` the strategy is decided per query by
619
+ // row counts and index coverage, so adding a `limit` to
620
+ // `findMany({ with: { org: true } })` flipped `org.meta` from a string to
621
+ // an object with no other change.
622
+ //
623
+ // A `CAST(… AS CHAR)` carrier closes that, but only for a pool Turbine
624
+ // built: the text half of it came from a `typeCast` override in
625
+ // MYSQL_DRIVER_FLAGS, and an injected mysql2 pool (a documented entry
626
+ // point) never sees those flags, so it read the column as an object at the
627
+ // top level and as a string through the join. That trades one split for
628
+ // another. So the agreement is built on what BOTH pool shapes already do:
629
+ // mysql2 parses a `json` column by default, JSON_OBJECT embeds it parsed,
630
+ // and with the `typeCast` override gone every read path lands on the same
631
+ // parsed value. It is also what `pg` returns for json/jsonb, so this is
632
+ // Postgres parity rather than a MySQL convention.
633
+ //
634
+ // Contrast SQLite and SQL Server, which return TEXT for the same schema:
635
+ // neither has a JSON column type, so the value there really is a string in
636
+ // a text column and there is nothing to parse it back from.
469
637
  // Binary columns are worse than lossy: JSON_OBJECT emits MySQL's internal
470
638
  // `base64:type15:…` marker string, so the caller got that text instead of
471
639
  // bytes. Carry hex and rebuild the buffer.
@@ -700,11 +868,14 @@ export async function introspectMysqlWith(exec, schema, options = {}) {
700
868
  }
701
869
  }
702
870
  const maxLen = c.CHARACTER_MAXIMUM_LENGTH != null ? num(c.CHARACTER_MAXIMUM_LENGTH) : undefined;
871
+ // `tinyint(1)` is the one type whose DATA_TYPE is not enough to identify it
872
+ // (see MYSQL_BOOLEAN_TYPE); everything else keeps the bare DATA_TYPE.
873
+ const recordedType = c.COLUMN_TYPE.toLowerCase() === MYSQL_BOOLEAN_TYPE ? MYSQL_BOOLEAN_TYPE : dataType;
703
874
  const col = {
704
875
  name: c.COLUMN_NAME,
705
876
  field: snakeToCamel(c.COLUMN_NAME),
706
- dialectType: dataType,
707
- pgType: dataType,
877
+ dialectType: recordedType,
878
+ pgType: recordedType,
708
879
  tsType: mysqlTypeToTs(dataType, nullable, c.COLUMN_TYPE),
709
880
  nullable,
710
881
  hasDefault: c.COLUMN_DEFAULT !== null || /auto_increment/i.test(c.EXTRA),
@@ -870,12 +1041,18 @@ const MYSQL_DRIVER_FLAGS = {
870
1041
  // DATETIME/TIMESTAMP → JS Date interpreted as UTC (matches Postgres).
871
1042
  dateStrings: false,
872
1043
  timezone: 'Z',
873
- // Force JSON columns to raw strings so the nested-relation parser
874
- // (parseNestedRow) always takes its well-tested JSON.parse path, instead of
875
- // mysql2's auto-parsed objects or a Buffer. Top-level JSON columns therefore
876
- // come back as strings (consistent with the SQLite engine; parse them yourself).
877
- // biome-ignore lint/suspicious/noExplicitAny: mysql2 typeCast field shape (has .type and .string()).
878
- typeCast: (field, next) => (field.type === 'JSON' ? field.string() : next()),
1044
+ //
1045
+ // NOTE: deliberately NO `typeCast` JSON override here. There used to be one,
1046
+ // pinning a `json` column to raw text so the nested-relation parser always
1047
+ // took its JSON.parse path. It could only ever apply to pools TURBINE builds,
1048
+ // and pool injection is a documented entry point, so it did not decide how a
1049
+ // `json` column reads; it decided that the two pool shapes read it
1050
+ // DIFFERENTLY (owned: string, injected: object). Every flag above is
1051
+ // idempotent in that sense (a placeholder style, an integer policy, a time
1052
+ // zone) and none of them changes a value's TYPE. Letting mysql2 parse a
1053
+ // `json` column is what both pool shapes do by default, and it is also what
1054
+ // `pg` does with json/jsonb, so the parsed value is the one answer that is
1055
+ // reachable from every entry point. See `jsonWireRule` for the other half.
879
1056
  };
880
1057
  /** Parse a `mysql://user:pass@host:port/db` connection string into mysql2 config. */
881
1058
  function parseMysqlConfig(connectionString) {
@@ -895,7 +1072,8 @@ function parseMysqlConfig(connectionString) {
895
1072
  return config;
896
1073
  }
897
1074
  catch {
898
- throw new ConnectionError(`[turbine] Invalid MySQL connection string: "${connectionString}"`);
1075
+ // Never echo the value, see malformedConnectionStringMessage.
1076
+ throw new ConnectionError(malformedConnectionStringMessage('MySQL', 'mysql://user:password@localhost:3306/app'));
899
1077
  }
900
1078
  }
901
1079
  /**
@@ -951,8 +1129,11 @@ function isMysql2Pool(x) {
951
1129
  * `disconnect()` is a no-op, advanced config like SSL lives here).
952
1130
  *
953
1131
  * When Turbine builds the pool (string/config), it pins the correct mysql2 flags
954
- * (named placeholders, bignum, UTC dates, JSON-as-string), probes `SELECT VERSION()`
955
- * to reject MySQL < 8.0 / MariaDB, and `disconnect()` closes the pool it created.
1132
+ * (named placeholders, bignum, UTC dates), probes `SELECT VERSION()` to reject
1133
+ * MySQL < 8.0 / MariaDB, and `disconnect()` closes the pool it created. None of
1134
+ * those flags changes a column's TYPE, so an injected pool reads every value
1135
+ * the same way (see {@link MYSQL_DRIVER_FLAGS}); `namedPlaceholders: true` is
1136
+ * the one an injected pool must set for itself.
956
1137
  *
957
1138
  * @example
958
1139
  * ```ts
@@ -1007,7 +1188,11 @@ export async function turbineMysql(target, schema, options = {}) {
1007
1188
  pool,
1008
1189
  dialect: mysqlDialect,
1009
1190
  preparedStatements: false,
1010
- }, schema);
1191
+ },
1192
+ // The join strategy is the only read path that consults a recorded dialect
1193
+ // type, so a schema that spells a boolean any other way would read 1/0
1194
+ // there and `true`/`false` everywhere else.
1195
+ normalizeBooleanDialectTypes(schema));
1011
1196
  if (owns) {
1012
1197
  // Turbine built this pool, so disconnect()/end() must close it. External
1013
1198
  // pools (injection) stay the caller's responsibility (disconnect() no-op),
@@ -12,7 +12,7 @@
12
12
  * `NestedWriteContext`.
13
13
  */
14
14
  import { CircularRelationError, describeTargetForMessage, NotFoundError, RelationError, UnsupportedFeatureError, ValidationError, } from './errors.js';
15
- import { resolveColumnName } from './query/utils.js';
15
+ import { markInternalCombinator, resolveColumnName } from './query/utils.js';
16
16
  import { normalizeKeyColumns } from './schema.js';
17
17
  const MAX_DEPTH = 10;
18
18
  const CREATE_ONLY_OPS = new Set(['create', 'connect', 'connectOrCreate']);
@@ -310,8 +310,13 @@ function belongsToCorrelationWhere(ctx, rel, parentRow, parentTable) {
310
310
  */
311
311
  function scopeWhereToParent(target, correlation) {
312
312
  for (const key of Object.keys(correlation)) {
313
+ // Branded as Turbine's own: this `AND` has a FIXED arity of two, chosen
314
+ // here rather than reachable from a request body, so it must not cost the
315
+ // statement its server-side prepared name the way a caller-written
316
+ // combinator array does. Same reason the global-filter merge and the
317
+ // batched loader's correlation merge are branded.
313
318
  if (Object.hasOwn(target, key))
314
- return { AND: [target, correlation] };
319
+ return markInternalCombinator({ AND: [target, correlation] });
315
320
  }
316
321
  return { ...target, ...correlation };
317
322
  }
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import Result from 'pg/lib/result';
20
20
  import { prepareValue } from 'pg/lib/utils';
21
- import { wrapPgError } from './errors.js';
21
+ import { PipelineError, wrapPgError } from './errors.js';
22
22
  // ---------------------------------------------------------------------------
23
23
  // Event names we intercept
24
24
  // ---------------------------------------------------------------------------
@@ -165,7 +165,8 @@ export async function runPipelined(client, queries, options = {}) {
165
165
  return;
166
166
  }
167
167
  if (!transactional && pipelineError) {
168
- // In non-transactional mode, attach partial results to the error
168
+ // In non-transactional mode, report every slot: what each query returned
169
+ // or the error it failed with.
169
170
  const partialResults = [];
170
171
  for (let i = 0; i < queries.length; i++) {
171
172
  const qErr = queryErrors[i];
@@ -182,8 +183,21 @@ export async function runPipelined(client, queries, options = {}) {
182
183
  }
183
184
  }
184
185
  }
185
- pipelineError.results = partialResults;
186
- reject(pipelineError);
186
+ // A REAL PipelineError, not the first driver error with a `results`
187
+ // property bolted onto it. The old shape happened to satisfy
188
+ // `err.results`, which is what the docs show callers reading, while
189
+ // leaving `err instanceof PipelineError` and `err.code ===
190
+ // 'TURBINE_E014'` permanently false: E014 was defined, documented, and
191
+ // never constructed. The driver error survives as `.cause` and as the
192
+ // first `error` slot, and `failedIndex` / `failedTag` (recorded on it by
193
+ // `onErrorMessage`) move onto the typed error's own fields.
194
+ const first = pipelineError;
195
+ reject(new PipelineError({
196
+ results: partialResults,
197
+ failedIndex: first.failedIndex,
198
+ failedTag: first.failedTag,
199
+ cause: pipelineError,
200
+ }));
187
201
  return;
188
202
  }
189
203
  // All succeeded, transform results
package/dist/pipeline.js CHANGED
@@ -18,7 +18,7 @@
18
18
  * Sequential fallback covers HTTP-based drivers (Neon HTTP, Vercel Postgres, Cloudflare
19
19
  * Hyperdrive), mock pools in tests, and any pool that doesn't expose pg internals.
20
20
  */
21
- import { wrapPgError } from './errors.js';
21
+ import { PipelineError, wrapPgError } from './errors.js';
22
22
  import { runPipelined, supportsExtendedPipeline } from './pipeline-submittable.js';
23
23
  /**
24
24
  * Execute queries sequentially on an already-acquired connection.
@@ -30,10 +30,21 @@ import { runPipelined, supportsExtendedPipeline } from './pipeline-submittable.j
30
30
  */
31
31
  async function runSequential(client, queries, options = {}) {
32
32
  const { transactional = true } = options;
33
+ if (!transactional)
34
+ return runIndependent(client, queries);
33
35
  try {
34
- if (transactional) {
36
+ // Transaction control is wrapped too. It used to be bare, so a connection
37
+ // that died between checkout and BEGIN (or at COMMIT) surfaced the raw
38
+ // driver error while every statement between them surfaced a typed
39
+ // TurbineError, and a caller branching on `err.code` saw neither
40
+ // `TURBINE_E004` nor a retryable flag for the one failure that is most
41
+ // worth retrying.
42
+ try {
35
43
  await client.query('BEGIN');
36
44
  }
45
+ catch (err) {
46
+ throw wrapPgError(err);
47
+ }
37
48
  const results = [];
38
49
  for (const q of queries) {
39
50
  let raw;
@@ -45,23 +56,78 @@ async function runSequential(client, queries, options = {}) {
45
56
  }
46
57
  results.push(q.transform(raw));
47
58
  }
48
- if (transactional) {
59
+ try {
49
60
  await client.query('COMMIT');
50
61
  }
62
+ catch (err) {
63
+ throw wrapPgError(err);
64
+ }
51
65
  return results;
52
66
  }
53
67
  catch (err) {
54
- if (transactional) {
55
- try {
56
- await client.query('ROLLBACK');
57
- }
58
- catch {
59
- // Best-effort rollback
60
- }
68
+ try {
69
+ await client.query('ROLLBACK');
70
+ }
71
+ catch {
72
+ // Best-effort rollback
61
73
  }
62
74
  throw err;
63
75
  }
64
76
  }
77
+ /**
78
+ * Sequential fallback for `{ transactional: false }`: each query is
79
+ * INDEPENDENT, which is what that option promises and what the real pipeline
80
+ * path already did. A failing query records its slot and the batch keeps going
81
+ * (there is no transaction to poison), and if anything failed the whole batch
82
+ * rejects with a `PipelineError` carrying every slot.
83
+ *
84
+ * It used to `throw wrapPgError(err)` on the first failure instead. That both
85
+ * abandoned the remaining queries, contradicting the option's own docstring,
86
+ * and rejected with the raw driver error, so `err instanceof PipelineError` and
87
+ * `err.code === 'TURBINE_E014'` were permanently false on the one path that
88
+ * documents them, and `err.results` did not exist at all. The first failure is
89
+ * still reachable, as `.cause` and as the first `error` slot.
90
+ */
91
+ async function runIndependent(client, queries) {
92
+ const slots = [];
93
+ let firstError;
94
+ let failedIndex;
95
+ let failedTag;
96
+ const fail = (index, error) => {
97
+ slots.push({ status: 'error', error });
98
+ if (firstError !== undefined)
99
+ return;
100
+ firstError = error;
101
+ failedIndex = index;
102
+ failedTag = queries[index]?.tag;
103
+ };
104
+ for (let i = 0; i < queries.length; i++) {
105
+ const q = queries[i];
106
+ let raw;
107
+ try {
108
+ raw = await client.query(q.sql, q.params);
109
+ }
110
+ catch (err) {
111
+ const wrapped = wrapPgError(err);
112
+ fail(i, wrapped instanceof Error ? wrapped : new Error(String(wrapped)));
113
+ continue;
114
+ }
115
+ // A transform throw is a slot failure too, not a batch abort: that is what
116
+ // the real pipeline path's `finalize` does with the same situation. It is
117
+ // deliberately NOT run through wrapPgError, since it never came from the
118
+ // driver.
119
+ try {
120
+ slots.push({ status: 'ok', value: q.transform(raw) });
121
+ }
122
+ catch (err) {
123
+ fail(i, err instanceof Error ? err : new Error(String(err)));
124
+ }
125
+ }
126
+ if (firstError === undefined) {
127
+ return slots.map((slot) => slot.value);
128
+ }
129
+ throw new PipelineError({ results: slots, failedIndex, failedTag, cause: firstError });
130
+ }
65
131
  // ---------------------------------------------------------------------------
66
132
  // Pipeline executor (public)
67
133
  // ---------------------------------------------------------------------------
package/dist/powdb.d.ts CHANGED
@@ -478,6 +478,29 @@ export declare const POWQL_KEYWORDS: ReadonlySet<string>;
478
478
  * errors when emitted bare, so quoting is strictly an improvement.
479
479
  */
480
480
  export declare function quotePowqlIdent(name: string): string;
481
+ /**
482
+ * The DOTTED-position spelling of {@link quotePowqlIdent}: quote a name that
483
+ * falls outside the bare-identifier grammar, and only that.
484
+ *
485
+ * A dotted reference (`.col` in a filter, projection, `order`, `group`, or an
486
+ * `upsert on`) bypasses keyword lookup, so `.order` parses on every engine
487
+ * version and stays bare here, which is the ≤0.9 compatibility decision
488
+ * {@link quotePowqlIdent} documents and which this must not undo.
489
+ *
490
+ * What it does NOT excuse is interpolating the name RAW, which is what these
491
+ * sites used to do. Keyword-ness is a parsing question; a name outside
492
+ * `POWQL_BARE_IDENT` is a statement-integrity one, and that name is the only
493
+ * thing that can carry PowQL syntax into a statement whose values are all bound
494
+ * as `$N` params. Reaching it needs a hostile column name (an introspected
495
+ * database, a generator, a migration authored elsewhere) since names come from
496
+ * schema metadata, but "the names are trusted" is not the invariant the rest of
497
+ * this engine is written to. So: bare when the grammar allows it (byte-identical
498
+ * output for every ordinary and every keyword name), quoted when it does not,
499
+ * where the bare form was a parse error anyway. Verified against the engine that
500
+ * a quoted dotted reference parses everywhere the bare one does and yields the
501
+ * same result-column name.
502
+ */
503
+ export declare function quotePowqlDotted(name: string): string;
481
504
  /**
482
505
  * Options for {@link powqlSchemaDDL}. Additive: with no options the DDL is
483
506
  * emitted unconditionally (pure-function callers / tests); pass `capabilities`
package/dist/powdb.js CHANGED
@@ -55,7 +55,7 @@
55
55
  import { AsyncLocalStorage } from 'node:async_hooks';
56
56
  import { TurbineClient, } from './client.js';
57
57
  import { postgresDialect } from './dialect.js';
58
- import { ConnectionError, NotNullViolationError, ReadOnlyError, TimeoutError, UniqueConstraintError, UnsupportedFeatureError, ValidationError, } from './errors.js';
58
+ import { ConnectionError, malformedConnectionStringMessage, NotNullViolationError, ReadOnlyError, TimeoutError, UniqueConstraintError, UnsupportedFeatureError, ValidationError, } from './errors.js';
59
59
  import importOptionalPeer from './optional-peer-import.cjs';
60
60
  import { shouldWarnOnce, WARN_NS } from './query/warn-registry.js';
61
61
  import { normalizeKeyColumns } from './schema.js';
@@ -162,7 +162,8 @@ export function parsePowdbUrl(connectionString) {
162
162
  u = new URL(connectionString);
163
163
  }
164
164
  catch {
165
- throw new ConnectionError(`[turbine] Invalid PowDB connection string: "${connectionString}"`);
165
+ // Never echo the value, see malformedConnectionStringMessage.
166
+ throw new ConnectionError(malformedConnectionStringMessage('PowDB', 'powdb://user:password@127.0.0.1:5433/app'));
166
167
  }
167
168
  if (u.protocol !== 'powdb:') {
168
169
  throw new ConnectionError(`[turbine] PowDB connection string must use the powdb:// scheme (got "${u.protocol}//…").`);
@@ -585,6 +586,36 @@ export function quotePowqlIdent(name) {
585
586
  }
586
587
  return POWQL_KEYWORDS.has(name) || !POWQL_BARE_IDENT.test(name) ? `\`${name}\`` : name;
587
588
  }
589
+ /**
590
+ * The DOTTED-position spelling of {@link quotePowqlIdent}: quote a name that
591
+ * falls outside the bare-identifier grammar, and only that.
592
+ *
593
+ * A dotted reference (`.col` in a filter, projection, `order`, `group`, or an
594
+ * `upsert on`) bypasses keyword lookup, so `.order` parses on every engine
595
+ * version and stays bare here, which is the ≤0.9 compatibility decision
596
+ * {@link quotePowqlIdent} documents and which this must not undo.
597
+ *
598
+ * What it does NOT excuse is interpolating the name RAW, which is what these
599
+ * sites used to do. Keyword-ness is a parsing question; a name outside
600
+ * `POWQL_BARE_IDENT` is a statement-integrity one, and that name is the only
601
+ * thing that can carry PowQL syntax into a statement whose values are all bound
602
+ * as `$N` params. Reaching it needs a hostile column name (an introspected
603
+ * database, a generator, a migration authored elsewhere) since names come from
604
+ * schema metadata, but "the names are trusted" is not the invariant the rest of
605
+ * this engine is written to. So: bare when the grammar allows it (byte-identical
606
+ * output for every ordinary and every keyword name), quoted when it does not,
607
+ * where the bare form was a parse error anyway. Verified against the engine that
608
+ * a quoted dotted reference parses everywhere the bare one does and yields the
609
+ * same result-column name.
610
+ */
611
+ export function quotePowqlDotted(name) {
612
+ if (POWQL_BARE_IDENT.test(name))
613
+ return name;
614
+ if (name.includes('`')) {
615
+ throw new ValidationError(`[turbine] Identifier "${name}" contains a backtick, which PowQL cannot represent.`);
616
+ }
617
+ return `\`${name}\``;
618
+ }
588
619
  /**
589
620
  * Derive the entity links Turbine would declare from a schema's relations. One
590
621
  * link per single-column hasMany / hasOne / belongsTo relation, owned by the