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/cjs/mysql.js CHANGED
@@ -124,10 +124,58 @@ function toNamedBinding(values) {
124
124
  // ---------------------------------------------------------------------------
125
125
  // Result shaping + error translation
126
126
  // ---------------------------------------------------------------------------
127
+ /**
128
+ * mysql2's `MYSQL_TYPE_TINY`. A result field with this type and a display width
129
+ * of 1 IS the `BOOLEAN` / `TINYINT(1)` column; see {@link MYSQL_BOOLEAN_TYPE}.
130
+ */
131
+ const MYSQL_FIELD_TYPE_TINY = 1;
132
+ /**
133
+ * Turn `TINYINT(1)` cells back into real booleans, in place.
134
+ *
135
+ * MySQL stores a boolean as 1/0 and mysql2 returns the integer. Turbine still
136
+ * generates `ok: boolean` for the column ({@link mysqlTypeToTs}), so
137
+ * `row.ok === true` was false, `JSON.stringify(row)` emitted `1`, and a value
138
+ * the ORM itself wrote as `true` came back as a number.
139
+ *
140
+ * Done from the RESULT FIELDS rather than from mysql2's `typeCast` option on
141
+ * purpose. `typeCast` lives in {@link MYSQL_DRIVER_FLAGS}, which only applies
142
+ * to pools TURBINE builds; an injected mysql2 pool (a supported, documented
143
+ * entry point) would keep returning numbers at the top level while the join
144
+ * strategy's `jsonWireRule` returned booleans, i.e. fixing it there would have
145
+ * created exactly the strategy-dependent split this is meant to close. The
146
+ * field metadata is on every result from every pool.
147
+ *
148
+ * An expression column is never touched: `ok + 0` comes back as
149
+ * MYSQL_TYPE_LONGLONG with a different width, and only an exact
150
+ * (TINY, width 1) field is coerced.
151
+ */
152
+ function coerceBooleanColumns(rows, fields) {
153
+ if (!Array.isArray(fields) || rows.length === 0)
154
+ return;
155
+ const names = [];
156
+ for (const f of fields) {
157
+ if (!f || f.columnType !== MYSQL_FIELD_TYPE_TINY || f.columnLength !== 1)
158
+ continue;
159
+ if (typeof f.name !== 'string')
160
+ continue;
161
+ names.push(f.name);
162
+ }
163
+ if (names.length === 0)
164
+ return;
165
+ for (const row of rows) {
166
+ for (const name of names) {
167
+ const v = row[name];
168
+ if (v === 1 || v === 0)
169
+ row[name] = v === 1;
170
+ }
171
+ }
172
+ }
127
173
  /** Shape a mysql2 `[result]` into a `pg.QueryResult`-like object. */
128
- function shapeResult(result) {
174
+ function shapeResult(result, fields) {
129
175
  if (Array.isArray(result)) {
130
- return { rows: result, rowCount: result.length };
176
+ const rows = result;
177
+ coerceBooleanColumns(rows, fields);
178
+ return { rows, rowCount: rows.length };
131
179
  }
132
180
  // Write statement → ResultSetHeader (no rows). Expose insertId/lastID for the
133
181
  // `reselect` strategy to re-fetch the auto-increment row.
@@ -164,6 +212,19 @@ function augmentMysqlError(err) {
164
212
  const parts = key.split('.');
165
213
  target.constraint = parts.length > 1 ? parts[parts.length - 1] : key;
166
214
  }
215
+ // THE conflicting row value is in that message, between the quotes after
216
+ // "Duplicate entry". MySQL has no `detail` field, so 'safe' mode had
217
+ // nothing to redact and returned this error verbatim as `.cause`, putting
218
+ // the value into every log line that renders the cause chain. Flag it so
219
+ // the message is withheld in 'safe' mode (see markValueBearingMessage);
220
+ // the constraint NAME survives on `target.constraint` and in the typed
221
+ // UniqueConstraintError's own message, so nothing diagnostic is lost.
222
+ //
223
+ // Deliberately only 1062: the other MySQL codes handled here report
224
+ // column/constraint NAMES only ("Column 'x' cannot be null", "Check
225
+ // constraint 'c' is violated", the FK constraint definition), so flagging
226
+ // them would withhold a message that never carried a value.
227
+ (0, errors_js_1.markValueBearingMessage)(err);
167
228
  return err;
168
229
  }
169
230
  // ER_ROW_IS_REFERENCED_2 / ER_NO_REFERENCED_ROW_2 → foreign key violation
@@ -212,8 +273,8 @@ function normalizeQueryArgs(arg, values) {
212
273
  async function execOne(runner, sql, params) {
213
274
  const binding = toNamedBinding(params);
214
275
  try {
215
- const [result] = binding ? await runner.execute(sql, binding) : await runner.query(sql);
216
- return shapeResult(result);
276
+ const [result, fields] = binding ? await runner.execute(sql, binding) : await runner.query(sql);
277
+ return shapeResult(result, fields);
217
278
  }
218
279
  catch (err) {
219
280
  throw augmentMysqlError(err);
@@ -290,7 +351,11 @@ function mysqlTypeToTs(dialectType, nullable, columnType) {
290
351
  const t = dialectType.toLowerCase();
291
352
  const full = (columnType ?? '').toLowerCase();
292
353
  let base;
293
- if (t === 'tinyint' && full === 'tinyint(1)')
354
+ // `tinyint(1)` can arrive either way: as (DATA_TYPE, COLUMN_TYPE) from the
355
+ // introspector's first pass, or as a single dialect type, because that is the
356
+ // spelling the introspector RECORDS for a boolean column (see the note there)
357
+ // and the `typeToTypeScript` dialect hook passes only the one string.
358
+ if (t === MYSQL_BOOLEAN_TYPE || (t === 'tinyint' && full === MYSQL_BOOLEAN_TYPE))
294
359
  base = 'boolean';
295
360
  else if (/^(tinyint|smallint|mediumint|int|integer|bigint)$/.test(t))
296
361
  base = 'number';
@@ -310,6 +375,63 @@ function mysqlTypeToTs(dialectType, nullable, columnType) {
310
375
  base = 'unknown';
311
376
  return nullable ? `${base} | null` : base;
312
377
  }
378
+ /**
379
+ * The dialect-type spelling Turbine records for a MySQL boolean column.
380
+ *
381
+ * MySQL has no boolean type: `BOOLEAN` is an alias for `TINYINT(1)`, and
382
+ * `information_schema` reports DATA_TYPE `tinyint` for it, which is the same
383
+ * DATA_TYPE a genuine one-byte integer column has. The display width in
384
+ * COLUMN_TYPE is the ONLY thing that separates them, so the introspector keeps
385
+ * the full spelling for this one type rather than the bare DATA_TYPE. Anything
386
+ * reading a dialect type has to accept both spellings; the two places that
387
+ * matter are {@link mysqlTypeToTs} and {@link mysqlDialect.jsonWireRule}.
388
+ */
389
+ const MYSQL_BOOLEAN_TYPE = 'tinyint(1)';
390
+ /**
391
+ * Rewrite every boolean column's recorded dialect type to
392
+ * {@link MYSQL_BOOLEAN_TYPE}, returning a schema that is safe to key
393
+ * {@link mysqlDialect.jsonWireRule} on.
394
+ *
395
+ * WHY. Only the join strategy consults the recorded type, so a column whose
396
+ * type is spelled some other way reads as a boolean at the top level (the shim
397
+ * decides that from the RESULT FIELDS, which every pool has) and as 1/0 through
398
+ * a `with` join, which is the strategy-dependent split the boolean rule exists
399
+ * to close. Two schemas spell it differently and neither is wrong:
400
+ *
401
+ * - a metadata file generated before Turbine started keeping the display width
402
+ * records the bare DATA_TYPE `tinyint` (with `tsType: 'boolean'`, which is
403
+ * what identifies it), so `turbine generate` is NOT required to get the fix;
404
+ * - a code-first `defineSchema` column records the Postgres spelling
405
+ * `boolean` / `bool`, which the DDL generator already emits as `TINYINT(1)`.
406
+ *
407
+ * A genuine one-byte integer column is never touched: it carries
408
+ * `tsType: 'number'`, and the `tsType` is the gate. Copy-on-write, because the
409
+ * argument is usually a shared exported constant and may also be handed to
410
+ * another engine's factory; an already-correct schema is returned unchanged and
411
+ * pays nothing.
412
+ */
413
+ function normalizeBooleanDialectTypes(schema) {
414
+ const needsFix = (col) => {
415
+ if (!/^boolean\b/.test(col.tsType))
416
+ return false;
417
+ const recorded = (col.dialectType ?? col.pgType ?? '').toLowerCase();
418
+ return recorded === 'tinyint' || recorded === 'bool' || recorded === 'boolean';
419
+ };
420
+ let tables;
421
+ for (const [name, table] of Object.entries(schema.tables)) {
422
+ if (!table.columns.some(needsFix))
423
+ continue;
424
+ const columns = table.columns.map((col) => needsFix(col) ? { ...col, dialectType: MYSQL_BOOLEAN_TYPE, pgType: MYSQL_BOOLEAN_TYPE } : col);
425
+ if (!tables)
426
+ tables = { ...schema.tables };
427
+ tables[name] = {
428
+ ...table,
429
+ columns,
430
+ pgTypes: Object.fromEntries(columns.map((c) => [c.name, c.pgType])),
431
+ };
432
+ }
433
+ return tables ? { ...schema, tables } : schema;
434
+ }
313
435
  /** Is a MySQL declared type a date/time type (so values coerce back to `Date`)? */
314
436
  function isMysqlDateType(dialectType) {
315
437
  const t = dialectType.toLowerCase();
@@ -477,6 +599,52 @@ exports.mysqlDialect = {
477
599
  if (t === 'decimal' || t === 'numeric') {
478
600
  return { sql: (ref) => `CAST(${ref} AS CHAR)`, decode: (value) => value };
479
601
  }
602
+ // A boolean is 1/0 on the wire, and the driver shim turns that back into
603
+ // true/false on every direct read (see coerceBooleanColumns). JSON_OBJECT
604
+ // renders the same column as a JSON number and has no column metadata to
605
+ // consult, so without this the join strategy alone would keep returning
606
+ // 1/0: the same strategy-dependent type flip the json rule below exists to
607
+ // prevent. Carried as text because the decode half only runs on a string
608
+ // cell, and a plain CAST is enough here (unlike SQLite, MySQL enforces the
609
+ // column type, so the value is always an integer and `Number` reproduces
610
+ // the driver's own reading of anything that is not 0 or 1).
611
+ if (t === MYSQL_BOOLEAN_TYPE) {
612
+ return {
613
+ sql: (ref) => `CAST(${ref} AS CHAR)`,
614
+ decode: (value) => {
615
+ if (value === '1')
616
+ return true;
617
+ if (value === '0')
618
+ return false;
619
+ if (typeof value !== 'string' || !/^-?\d+$/.test(value))
620
+ return value;
621
+ return Number(value);
622
+ },
623
+ };
624
+ }
625
+ // A `json` column gets NO rule, and that is the fix rather than the absence
626
+ // of one. It used to read differently depending on the relation strategy:
627
+ // the driver handed back text while JSON_OBJECT, which knows the column is
628
+ // JSON, embedded it as a real nested value, so parsing the row yielded an
629
+ // object. Under the default `'auto'` the strategy is decided per query by
630
+ // row counts and index coverage, so adding a `limit` to
631
+ // `findMany({ with: { org: true } })` flipped `org.meta` from a string to
632
+ // an object with no other change.
633
+ //
634
+ // A `CAST(… AS CHAR)` carrier closes that, but only for a pool Turbine
635
+ // built: the text half of it came from a `typeCast` override in
636
+ // MYSQL_DRIVER_FLAGS, and an injected mysql2 pool (a documented entry
637
+ // point) never sees those flags, so it read the column as an object at the
638
+ // top level and as a string through the join. That trades one split for
639
+ // another. So the agreement is built on what BOTH pool shapes already do:
640
+ // mysql2 parses a `json` column by default, JSON_OBJECT embeds it parsed,
641
+ // and with the `typeCast` override gone every read path lands on the same
642
+ // parsed value. It is also what `pg` returns for json/jsonb, so this is
643
+ // Postgres parity rather than a MySQL convention.
644
+ //
645
+ // Contrast SQLite and SQL Server, which return TEXT for the same schema:
646
+ // neither has a JSON column type, so the value there really is a string in
647
+ // a text column and there is nothing to parse it back from.
480
648
  // Binary columns are worse than lossy: JSON_OBJECT emits MySQL's internal
481
649
  // `base64:type15:…` marker string, so the caller got that text instead of
482
650
  // bytes. Carry hex and rebuild the buffer.
@@ -711,11 +879,14 @@ async function introspectMysqlWith(exec, schema, options = {}) {
711
879
  }
712
880
  }
713
881
  const maxLen = c.CHARACTER_MAXIMUM_LENGTH != null ? num(c.CHARACTER_MAXIMUM_LENGTH) : undefined;
882
+ // `tinyint(1)` is the one type whose DATA_TYPE is not enough to identify it
883
+ // (see MYSQL_BOOLEAN_TYPE); everything else keeps the bare DATA_TYPE.
884
+ const recordedType = c.COLUMN_TYPE.toLowerCase() === MYSQL_BOOLEAN_TYPE ? MYSQL_BOOLEAN_TYPE : dataType;
714
885
  const col = {
715
886
  name: c.COLUMN_NAME,
716
887
  field: (0, schema_js_1.snakeToCamel)(c.COLUMN_NAME),
717
- dialectType: dataType,
718
- pgType: dataType,
888
+ dialectType: recordedType,
889
+ pgType: recordedType,
719
890
  tsType: mysqlTypeToTs(dataType, nullable, c.COLUMN_TYPE),
720
891
  nullable,
721
892
  hasDefault: c.COLUMN_DEFAULT !== null || /auto_increment/i.test(c.EXTRA),
@@ -881,12 +1052,18 @@ const MYSQL_DRIVER_FLAGS = {
881
1052
  // DATETIME/TIMESTAMP → JS Date interpreted as UTC (matches Postgres).
882
1053
  dateStrings: false,
883
1054
  timezone: 'Z',
884
- // Force JSON columns to raw strings so the nested-relation parser
885
- // (parseNestedRow) always takes its well-tested JSON.parse path, instead of
886
- // mysql2's auto-parsed objects or a Buffer. Top-level JSON columns therefore
887
- // come back as strings (consistent with the SQLite engine; parse them yourself).
888
- // biome-ignore lint/suspicious/noExplicitAny: mysql2 typeCast field shape (has .type and .string()).
889
- typeCast: (field, next) => (field.type === 'JSON' ? field.string() : next()),
1055
+ //
1056
+ // NOTE: deliberately NO `typeCast` JSON override here. There used to be one,
1057
+ // pinning a `json` column to raw text so the nested-relation parser always
1058
+ // took its JSON.parse path. It could only ever apply to pools TURBINE builds,
1059
+ // and pool injection is a documented entry point, so it did not decide how a
1060
+ // `json` column reads; it decided that the two pool shapes read it
1061
+ // DIFFERENTLY (owned: string, injected: object). Every flag above is
1062
+ // idempotent in that sense (a placeholder style, an integer policy, a time
1063
+ // zone) and none of them changes a value's TYPE. Letting mysql2 parse a
1064
+ // `json` column is what both pool shapes do by default, and it is also what
1065
+ // `pg` does with json/jsonb, so the parsed value is the one answer that is
1066
+ // reachable from every entry point. See `jsonWireRule` for the other half.
890
1067
  };
891
1068
  /** Parse a `mysql://user:pass@host:port/db` connection string into mysql2 config. */
892
1069
  function parseMysqlConfig(connectionString) {
@@ -906,7 +1083,8 @@ function parseMysqlConfig(connectionString) {
906
1083
  return config;
907
1084
  }
908
1085
  catch {
909
- throw new errors_js_1.ConnectionError(`[turbine] Invalid MySQL connection string: "${connectionString}"`);
1086
+ // Never echo the value, see malformedConnectionStringMessage.
1087
+ throw new errors_js_1.ConnectionError((0, errors_js_1.malformedConnectionStringMessage)('MySQL', 'mysql://user:password@localhost:3306/app'));
910
1088
  }
911
1089
  }
912
1090
  /**
@@ -962,8 +1140,11 @@ function isMysql2Pool(x) {
962
1140
  * `disconnect()` is a no-op, advanced config like SSL lives here).
963
1141
  *
964
1142
  * When Turbine builds the pool (string/config), it pins the correct mysql2 flags
965
- * (named placeholders, bignum, UTC dates, JSON-as-string), probes `SELECT VERSION()`
966
- * to reject MySQL < 8.0 / MariaDB, and `disconnect()` closes the pool it created.
1143
+ * (named placeholders, bignum, UTC dates), probes `SELECT VERSION()` to reject
1144
+ * MySQL < 8.0 / MariaDB, and `disconnect()` closes the pool it created. None of
1145
+ * those flags changes a column's TYPE, so an injected pool reads every value
1146
+ * the same way (see {@link MYSQL_DRIVER_FLAGS}); `namedPlaceholders: true` is
1147
+ * the one an injected pool must set for itself.
967
1148
  *
968
1149
  * @example
969
1150
  * ```ts
@@ -1018,7 +1199,11 @@ async function turbineMysql(target, schema, options = {}) {
1018
1199
  pool,
1019
1200
  dialect: exports.mysqlDialect,
1020
1201
  preparedStatements: false,
1021
- }, schema);
1202
+ },
1203
+ // The join strategy is the only read path that consults a recorded dialect
1204
+ // type, so a schema that spells a boolean any other way would read 1/0
1205
+ // there and `true`/`false` everywhere else.
1206
+ normalizeBooleanDialectTypes(schema));
1022
1207
  if (owns) {
1023
1208
  // Turbine built this pool, so disconnect()/end() must close it. External
1024
1209
  // pools (injection) stay the caller's responsibility (disconnect() no-op),
@@ -318,8 +318,13 @@ function belongsToCorrelationWhere(ctx, rel, parentRow, parentTable) {
318
318
  */
319
319
  function scopeWhereToParent(target, correlation) {
320
320
  for (const key of Object.keys(correlation)) {
321
+ // Branded as Turbine's own: this `AND` has a FIXED arity of two, chosen
322
+ // here rather than reachable from a request body, so it must not cost the
323
+ // statement its server-side prepared name the way a caller-written
324
+ // combinator array does. Same reason the global-filter merge and the
325
+ // batched loader's correlation merge are branded.
321
326
  if (Object.hasOwn(target, key))
322
- return { AND: [target, correlation] };
327
+ return (0, utils_js_1.markInternalCombinator)({ AND: [target, correlation] });
323
328
  }
324
329
  return { ...target, ...correlation };
325
330
  }
@@ -172,7 +172,8 @@ async function runPipelined(client, queries, options = {}) {
172
172
  return;
173
173
  }
174
174
  if (!transactional && pipelineError) {
175
- // In non-transactional mode, attach partial results to the error
175
+ // In non-transactional mode, report every slot: what each query returned
176
+ // or the error it failed with.
176
177
  const partialResults = [];
177
178
  for (let i = 0; i < queries.length; i++) {
178
179
  const qErr = queryErrors[i];
@@ -189,8 +190,21 @@ async function runPipelined(client, queries, options = {}) {
189
190
  }
190
191
  }
191
192
  }
192
- pipelineError.results = partialResults;
193
- reject(pipelineError);
193
+ // A REAL PipelineError, not the first driver error with a `results`
194
+ // property bolted onto it. The old shape happened to satisfy
195
+ // `err.results`, which is what the docs show callers reading, while
196
+ // leaving `err instanceof PipelineError` and `err.code ===
197
+ // 'TURBINE_E014'` permanently false: E014 was defined, documented, and
198
+ // never constructed. The driver error survives as `.cause` and as the
199
+ // first `error` slot, and `failedIndex` / `failedTag` (recorded on it by
200
+ // `onErrorMessage`) move onto the typed error's own fields.
201
+ const first = pipelineError;
202
+ reject(new errors_js_1.PipelineError({
203
+ results: partialResults,
204
+ failedIndex: first.failedIndex,
205
+ failedTag: first.failedTag,
206
+ cause: pipelineError,
207
+ }));
194
208
  return;
195
209
  }
196
210
  // All succeeded, transform results
@@ -34,10 +34,21 @@ const pipeline_submittable_js_1 = require("./pipeline-submittable.js");
34
34
  */
35
35
  async function runSequential(client, queries, options = {}) {
36
36
  const { transactional = true } = options;
37
+ if (!transactional)
38
+ return runIndependent(client, queries);
37
39
  try {
38
- if (transactional) {
40
+ // Transaction control is wrapped too. It used to be bare, so a connection
41
+ // that died between checkout and BEGIN (or at COMMIT) surfaced the raw
42
+ // driver error while every statement between them surfaced a typed
43
+ // TurbineError, and a caller branching on `err.code` saw neither
44
+ // `TURBINE_E004` nor a retryable flag for the one failure that is most
45
+ // worth retrying.
46
+ try {
39
47
  await client.query('BEGIN');
40
48
  }
49
+ catch (err) {
50
+ throw (0, errors_js_1.wrapPgError)(err);
51
+ }
41
52
  const results = [];
42
53
  for (const q of queries) {
43
54
  let raw;
@@ -49,23 +60,78 @@ async function runSequential(client, queries, options = {}) {
49
60
  }
50
61
  results.push(q.transform(raw));
51
62
  }
52
- if (transactional) {
63
+ try {
53
64
  await client.query('COMMIT');
54
65
  }
66
+ catch (err) {
67
+ throw (0, errors_js_1.wrapPgError)(err);
68
+ }
55
69
  return results;
56
70
  }
57
71
  catch (err) {
58
- if (transactional) {
59
- try {
60
- await client.query('ROLLBACK');
61
- }
62
- catch {
63
- // Best-effort rollback
64
- }
72
+ try {
73
+ await client.query('ROLLBACK');
74
+ }
75
+ catch {
76
+ // Best-effort rollback
65
77
  }
66
78
  throw err;
67
79
  }
68
80
  }
81
+ /**
82
+ * Sequential fallback for `{ transactional: false }`: each query is
83
+ * INDEPENDENT, which is what that option promises and what the real pipeline
84
+ * path already did. A failing query records its slot and the batch keeps going
85
+ * (there is no transaction to poison), and if anything failed the whole batch
86
+ * rejects with a `PipelineError` carrying every slot.
87
+ *
88
+ * It used to `throw wrapPgError(err)` on the first failure instead. That both
89
+ * abandoned the remaining queries, contradicting the option's own docstring,
90
+ * and rejected with the raw driver error, so `err instanceof PipelineError` and
91
+ * `err.code === 'TURBINE_E014'` were permanently false on the one path that
92
+ * documents them, and `err.results` did not exist at all. The first failure is
93
+ * still reachable, as `.cause` and as the first `error` slot.
94
+ */
95
+ async function runIndependent(client, queries) {
96
+ const slots = [];
97
+ let firstError;
98
+ let failedIndex;
99
+ let failedTag;
100
+ const fail = (index, error) => {
101
+ slots.push({ status: 'error', error });
102
+ if (firstError !== undefined)
103
+ return;
104
+ firstError = error;
105
+ failedIndex = index;
106
+ failedTag = queries[index]?.tag;
107
+ };
108
+ for (let i = 0; i < queries.length; i++) {
109
+ const q = queries[i];
110
+ let raw;
111
+ try {
112
+ raw = await client.query(q.sql, q.params);
113
+ }
114
+ catch (err) {
115
+ const wrapped = (0, errors_js_1.wrapPgError)(err);
116
+ fail(i, wrapped instanceof Error ? wrapped : new Error(String(wrapped)));
117
+ continue;
118
+ }
119
+ // A transform throw is a slot failure too, not a batch abort: that is what
120
+ // the real pipeline path's `finalize` does with the same situation. It is
121
+ // deliberately NOT run through wrapPgError, since it never came from the
122
+ // driver.
123
+ try {
124
+ slots.push({ status: 'ok', value: q.transform(raw) });
125
+ }
126
+ catch (err) {
127
+ fail(i, err instanceof Error ? err : new Error(String(err)));
128
+ }
129
+ }
130
+ if (firstError === undefined) {
131
+ return slots.map((slot) => slot.value);
132
+ }
133
+ throw new errors_js_1.PipelineError({ results: slots, failedIndex, failedTag, cause: firstError });
134
+ }
69
135
  // ---------------------------------------------------------------------------
70
136
  // Pipeline executor (public)
71
137
  // ---------------------------------------------------------------------------
@@ -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/cjs/powdb.js CHANGED
@@ -99,6 +99,7 @@ exports.isJsonColumn = isJsonColumn;
99
99
  exports.powqlColumnType = powqlColumnType;
100
100
  exports.isPowdbDatetimeColumn = isPowdbDatetimeColumn;
101
101
  exports.quotePowqlIdent = quotePowqlIdent;
102
+ exports.quotePowqlDotted = quotePowqlDotted;
102
103
  exports.deriveDesiredLinks = deriveDesiredLinks;
103
104
  exports.powdbLinkStatement = powdbLinkStatement;
104
105
  exports.powqlSchemaDDL = powqlSchemaDDL;
@@ -223,7 +224,8 @@ function parsePowdbUrl(connectionString) {
223
224
  u = new URL(connectionString);
224
225
  }
225
226
  catch {
226
- throw new errors_js_1.ConnectionError(`[turbine] Invalid PowDB connection string: "${connectionString}"`);
227
+ // Never echo the value, see malformedConnectionStringMessage.
228
+ throw new errors_js_1.ConnectionError((0, errors_js_1.malformedConnectionStringMessage)('PowDB', 'powdb://user:password@127.0.0.1:5433/app'));
227
229
  }
228
230
  if (u.protocol !== 'powdb:') {
229
231
  throw new errors_js_1.ConnectionError(`[turbine] PowDB connection string must use the powdb:// scheme (got "${u.protocol}//…").`);
@@ -646,6 +648,36 @@ function quotePowqlIdent(name) {
646
648
  }
647
649
  return exports.POWQL_KEYWORDS.has(name) || !POWQL_BARE_IDENT.test(name) ? `\`${name}\`` : name;
648
650
  }
651
+ /**
652
+ * The DOTTED-position spelling of {@link quotePowqlIdent}: quote a name that
653
+ * falls outside the bare-identifier grammar, and only that.
654
+ *
655
+ * A dotted reference (`.col` in a filter, projection, `order`, `group`, or an
656
+ * `upsert on`) bypasses keyword lookup, so `.order` parses on every engine
657
+ * version and stays bare here, which is the ≤0.9 compatibility decision
658
+ * {@link quotePowqlIdent} documents and which this must not undo.
659
+ *
660
+ * What it does NOT excuse is interpolating the name RAW, which is what these
661
+ * sites used to do. Keyword-ness is a parsing question; a name outside
662
+ * `POWQL_BARE_IDENT` is a statement-integrity one, and that name is the only
663
+ * thing that can carry PowQL syntax into a statement whose values are all bound
664
+ * as `$N` params. Reaching it needs a hostile column name (an introspected
665
+ * database, a generator, a migration authored elsewhere) since names come from
666
+ * schema metadata, but "the names are trusted" is not the invariant the rest of
667
+ * this engine is written to. So: bare when the grammar allows it (byte-identical
668
+ * output for every ordinary and every keyword name), quoted when it does not,
669
+ * where the bare form was a parse error anyway. Verified against the engine that
670
+ * a quoted dotted reference parses everywhere the bare one does and yields the
671
+ * same result-column name.
672
+ */
673
+ function quotePowqlDotted(name) {
674
+ if (POWQL_BARE_IDENT.test(name))
675
+ return name;
676
+ if (name.includes('`')) {
677
+ throw new errors_js_1.ValidationError(`[turbine] Identifier "${name}" contains a backtick, which PowQL cannot represent.`);
678
+ }
679
+ return `\`${name}\``;
680
+ }
649
681
  /**
650
682
  * Derive the entity links Turbine would declare from a schema's relations. One
651
683
  * link per single-column hasMany / hasOne / belongsTo relation, owned by the
@@ -70,14 +70,28 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
70
70
  /** Resolve a camelCase field name (or raw snake) to its column metadata. */
71
71
  private column;
72
72
  /**
73
- * PowQL column reference for a field. Unqualified it is a dotted field
74
- * reference (`.snake_name`), which bypasses keyword lookup. When an `alias`
75
- * is supplied (the F2 join path) it is qualified (`alias.snake_name`) and the
76
- * column name is backtick-quoted if it is a reserved word (a qualified
77
- * `p.order` does NOT bypass keyword lookup, unlike the dotted `.order`).
73
+ * PowQL column reference for a field: a dotted field reference
74
+ * (`.snake_name`), or `alias.snake_name` when an `alias` is supplied (the F2
75
+ * join path).
78
76
  */
79
77
  private ref;
80
- /** Render a raw column name as a PowQL reference, qualified with `alias` when given. */
78
+ /**
79
+ * Render a raw column name as a PowQL reference, qualified with `alias` when
80
+ * given.
81
+ *
82
+ * BOTH branches quote now; they just use different rules, because the two
83
+ * positions have different grammars. A QUALIFIED `p.col` does not bypass
84
+ * keyword lookup, so it needs the full {@link quotePowqlIdent}. An
85
+ * UNQUALIFIED `.col` does bypass it, so it uses {@link quotePowqlDotted},
86
+ * which quotes only a name outside the bare-identifier grammar and leaves
87
+ * keywords bare, preserving the ≤0.9 compatibility decision documented on
88
+ * `quotePowqlIdent` while still keeping a column NAME from carrying syntax
89
+ * into the statement. This branch used to interpolate the name RAW, the one
90
+ * identifier site in the engine with no boundary at all.
91
+ *
92
+ * Output is byte-identical for every name the grammar accepts bare, keywords
93
+ * included, so no existing schema sees a different statement.
94
+ */
81
95
  private colRefName;
82
96
  /**
83
97
  * Push a value into the param array and return its `$N` placeholder. When the
@@ -316,7 +330,39 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
316
330
  * for a misspelling that is not there. Same message as the SQL engines.
317
331
  */
318
332
  private projectionColumn;
319
- private projectedColumns;
333
+ /**
334
+ * The projected column list, plus the PK columns that are in it ONLY because
335
+ * this method put them there.
336
+ *
337
+ * PowDB needs the PK in the FETCH even when the caller excluded it: `upsert`
338
+ * reselects by PK, the m2m loader keys its target map on it (`targetByPk`),
339
+ * and the join path correlates through it. That force-add is right and stays.
340
+ * What was missing is the other half, taking it back off the ENTITY, so
341
+ * `select: { name: true }` returned `{ id, name }` and `omit: { id: true }`
342
+ * returned the column the caller asked to hide. The SQL engines return
343
+ * neither, so this was a cross-engine divergence on a documented-as-shared
344
+ * surface, and it was UNIFORM within PowDB: all five paths leaked (top-level
345
+ * find, batched loader, native join, nested projection, link path). The link
346
+ * path was the only one that even tried, and its strip could never fire: it
347
+ * decided "did the caller ask for the PK" by testing the column list AFTER
348
+ * the force-add, which by construction always contains it. So there was one
349
+ * strip in the code, dead since it shipped, and no query anywhere got the
350
+ * projection it asked for.
351
+ *
352
+ * `forcedPk` is what the strip needs, and it is deliberately NARROW: only the
353
+ * `select` / `omit` force-adds are listed. A PII-tagged PK kept through the
354
+ * DEFAULT projection is NOT, because that one is a deliberate, documented
355
+ * decision the SQL engines make identically (a row that cannot address itself
356
+ * is worse than a key that leaks its own value).
357
+ */
358
+ private projectionPlan;
359
+ /**
360
+ * Take the internally-forced PK columns back off the entities, so what the
361
+ * caller receives matches the `select` / `omit` they wrote. Called only after
362
+ * every consumer of the key (relation stitching, reselect) has finished with
363
+ * it. A no-op, and free, when nothing was forced.
364
+ */
365
+ private stripForcedPk;
320
366
  /**
321
367
  * The snake_case names of this table's PII-tagged columns. Empty for a table
322
368
  * with no `pii: true` column, so untagged tables keep their prior projection.
@@ -337,7 +383,7 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
337
383
  * client-side strip of last resort, not defense-in-depth, for those paths; we
338
384
  * do NOT reverse-engineer an undocumented projection form. The upsert path is
339
385
  * different: it has no `returning` and reselects by PK through the read
340
- * projection ({@link projectedColumns}), which already omits PII, so PII never
386
+ * projection ({@link projectionPlan}), which already omits PII, so PII never
341
387
  * crosses the wire there. If a future spec revision lets `returning` take a
342
388
  * projection, switch the write paths to emit the non-PII list and this strip
343
389
  * becomes a no-op like {@link parseWriteRow} on the SQL engines.
@@ -641,7 +687,13 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
641
687
  * DECLARED link that verifiably matches (`findMatchingLink`).
642
688
  */
643
689
  private planLinkPathRelation;
644
- /** The flat `l<i>_<col>: t0.<linkName>.<col>` projection fields for one link plan. */
690
+ /**
691
+ * The flat `l<i>_<col>: t0.<linkName>.<col>` projection fields for one link
692
+ * plan. Both path segments and the synthetic result key go through
693
+ * `quotePowqlIdent`, the same identifier boundary every other emission site
694
+ * uses; verified against the engine that a quoted link hop and a quoted alias
695
+ * parse exactly like their bare forms and yield the same result-column names.
696
+ */
645
697
  private linkPathFields;
646
698
  /**
647
699
  * Reconstruct each link-path relation's child entity from its flat hop fields