turbine-orm 0.50.0 → 0.51.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 (186) hide show
  1. package/README.md +66 -66
  2. package/dist/adapters/cockroachdb.d.ts +5 -5
  3. package/dist/adapters/cockroachdb.js +10 -10
  4. package/dist/adapters/index.d.ts +5 -5
  5. package/dist/adapters/index.js +7 -7
  6. package/dist/adapters/yugabytedb.d.ts +7 -7
  7. package/dist/adapters/yugabytedb.js +10 -10
  8. package/dist/cjs/adapters/cockroachdb.d.ts +5 -5
  9. package/dist/cjs/adapters/cockroachdb.js +10 -10
  10. package/dist/cjs/adapters/index.d.ts +5 -5
  11. package/dist/cjs/adapters/index.js +7 -7
  12. package/dist/cjs/adapters/yugabytedb.d.ts +7 -7
  13. package/dist/cjs/adapters/yugabytedb.js +10 -10
  14. package/dist/cjs/cli/config.d.ts +13 -2
  15. package/dist/cjs/cli/config.js +3 -2
  16. package/dist/cjs/cli/destructive.d.ts +1 -1
  17. package/dist/cjs/cli/destructive.js +1 -1
  18. package/dist/cjs/cli/index.d.ts +10 -10
  19. package/dist/cjs/cli/index.js +49 -45
  20. package/dist/cjs/cli/loader.d.ts +7 -7
  21. package/dist/cjs/cli/loader.js +9 -9
  22. package/dist/cjs/cli/mcp.js +4 -4
  23. package/dist/cjs/cli/migrate.d.ts +5 -5
  24. package/dist/cjs/cli/migrate.js +11 -11
  25. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  26. package/dist/cjs/cli/ui.d.ts +2 -2
  27. package/dist/cjs/cli/ui.js +2 -2
  28. package/dist/cjs/client.d.ts +49 -38
  29. package/dist/cjs/client.js +57 -56
  30. package/dist/cjs/dialect.d.ts +62 -18
  31. package/dist/cjs/dialect.js +40 -2
  32. package/dist/cjs/errors.d.ts +5 -5
  33. package/dist/cjs/errors.js +11 -11
  34. package/dist/cjs/generate.d.ts +6 -6
  35. package/dist/cjs/generate.js +31 -29
  36. package/dist/cjs/index-advisor.d.ts +5 -5
  37. package/dist/cjs/index-advisor.js +0 -0
  38. package/dist/cjs/index.d.ts +1 -1
  39. package/dist/cjs/index.js +7 -7
  40. package/dist/cjs/introspect.d.ts +35 -9
  41. package/dist/cjs/introspect.js +83 -32
  42. package/dist/cjs/mssql.d.ts +11 -11
  43. package/dist/cjs/mssql.js +64 -29
  44. package/dist/cjs/mysql.d.ts +8 -8
  45. package/dist/cjs/mysql.js +61 -23
  46. package/dist/cjs/nested-write.d.ts +21 -2
  47. package/dist/cjs/nested-write.js +51 -14
  48. package/dist/cjs/optional-peer-import.cjs +7 -7
  49. package/dist/cjs/optional-peer-import.d.cts +7 -7
  50. package/dist/cjs/pipeline-submittable.d.ts +2 -2
  51. package/dist/cjs/pipeline-submittable.js +6 -6
  52. package/dist/cjs/pipeline.d.ts +1 -1
  53. package/dist/cjs/pipeline.js +4 -4
  54. package/dist/cjs/powdb-introspect.d.ts +1 -1
  55. package/dist/cjs/powdb-introspect.js +1 -1
  56. package/dist/cjs/powdb.d.ts +28 -28
  57. package/dist/cjs/powdb.js +66 -66
  58. package/dist/cjs/powql.d.ts +27 -27
  59. package/dist/cjs/powql.js +73 -52
  60. package/dist/cjs/query/aggregates.d.ts +1 -1
  61. package/dist/cjs/query/aggregates.js +5 -5
  62. package/dist/cjs/query/batched-loader.d.ts +11 -11
  63. package/dist/cjs/query/batched-loader.js +24 -24
  64. package/dist/cjs/query/builder.d.ts +39 -21
  65. package/dist/cjs/query/builder.js +99 -57
  66. package/dist/cjs/query/compound-unique.d.ts +1 -1
  67. package/dist/cjs/query/compound-unique.js +0 -0
  68. package/dist/cjs/query/deferred.d.ts +12 -6
  69. package/dist/cjs/query/deferred.js +1 -1
  70. package/dist/cjs/query/filters.d.ts +31 -11
  71. package/dist/cjs/query/filters.js +67 -14
  72. package/dist/cjs/query/index.d.ts +1 -1
  73. package/dist/cjs/query/index.js +1 -1
  74. package/dist/cjs/query/relations.d.ts +9 -9
  75. package/dist/cjs/query/relations.js +164 -57
  76. package/dist/cjs/query/types.d.ts +86 -35
  77. package/dist/cjs/query/types.js +1 -1
  78. package/dist/cjs/query/utils.d.ts +27 -10
  79. package/dist/cjs/query/utils.js +86 -14
  80. package/dist/cjs/query/where.d.ts +47 -28
  81. package/dist/cjs/query/where.js +130 -31
  82. package/dist/cjs/query/writes.d.ts +24 -5
  83. package/dist/cjs/query/writes.js +102 -13
  84. package/dist/cjs/realtime.d.ts +7 -7
  85. package/dist/cjs/realtime.js +9 -9
  86. package/dist/cjs/schema-builder.d.ts +18 -7
  87. package/dist/cjs/schema-builder.js +17 -10
  88. package/dist/cjs/schema-metadata.d.ts +3 -3
  89. package/dist/cjs/schema-metadata.js +9 -9
  90. package/dist/cjs/schema-sql.d.ts +9 -9
  91. package/dist/cjs/schema-sql.js +20 -20
  92. package/dist/cjs/schema.d.ts +19 -9
  93. package/dist/cjs/schema.js +6 -6
  94. package/dist/cjs/serverless.d.ts +15 -15
  95. package/dist/cjs/serverless.js +16 -16
  96. package/dist/cjs/sqlite.d.ts +8 -8
  97. package/dist/cjs/sqlite.js +53 -22
  98. package/dist/cjs/typed-sql.d.ts +4 -4
  99. package/dist/cjs/typed-sql.js +5 -5
  100. package/dist/cli/config.d.ts +13 -2
  101. package/dist/cli/config.js +3 -2
  102. package/dist/cli/destructive.d.ts +1 -1
  103. package/dist/cli/destructive.js +1 -1
  104. package/dist/cli/index.d.ts +10 -10
  105. package/dist/cli/index.js +49 -45
  106. package/dist/cli/loader.d.ts +7 -7
  107. package/dist/cli/loader.js +9 -9
  108. package/dist/cli/mcp.js +4 -4
  109. package/dist/cli/migrate.d.ts +5 -5
  110. package/dist/cli/migrate.js +11 -11
  111. package/dist/cli/studio-ui.generated.js +1 -1
  112. package/dist/cli/ui.d.ts +2 -2
  113. package/dist/cli/ui.js +2 -2
  114. package/dist/client.d.ts +49 -38
  115. package/dist/client.js +57 -56
  116. package/dist/dialect.d.ts +62 -18
  117. package/dist/dialect.js +40 -2
  118. package/dist/errors.d.ts +5 -5
  119. package/dist/errors.js +11 -11
  120. package/dist/generate.d.ts +6 -6
  121. package/dist/generate.js +31 -29
  122. package/dist/index-advisor.d.ts +5 -5
  123. package/dist/index-advisor.js +0 -0
  124. package/dist/index.d.ts +1 -1
  125. package/dist/index.js +7 -7
  126. package/dist/introspect.d.ts +35 -9
  127. package/dist/introspect.js +82 -32
  128. package/dist/mssql.d.ts +11 -11
  129. package/dist/mssql.js +64 -29
  130. package/dist/mysql.d.ts +8 -8
  131. package/dist/mysql.js +61 -23
  132. package/dist/nested-write.d.ts +21 -2
  133. package/dist/nested-write.js +51 -14
  134. package/dist/optional-peer-import.cjs +7 -7
  135. package/dist/optional-peer-import.d.cts +7 -7
  136. package/dist/pipeline-submittable.d.ts +2 -2
  137. package/dist/pipeline-submittable.js +6 -6
  138. package/dist/pipeline.d.ts +1 -1
  139. package/dist/pipeline.js +4 -4
  140. package/dist/powdb-introspect.d.ts +1 -1
  141. package/dist/powdb-introspect.js +1 -1
  142. package/dist/powdb.d.ts +28 -28
  143. package/dist/powdb.js +66 -66
  144. package/dist/powql.d.ts +27 -27
  145. package/dist/powql.js +73 -52
  146. package/dist/query/aggregates.d.ts +1 -1
  147. package/dist/query/aggregates.js +5 -5
  148. package/dist/query/batched-loader.d.ts +11 -11
  149. package/dist/query/batched-loader.js +24 -24
  150. package/dist/query/builder.d.ts +39 -21
  151. package/dist/query/builder.js +100 -58
  152. package/dist/query/compound-unique.d.ts +1 -1
  153. package/dist/query/compound-unique.js +0 -0
  154. package/dist/query/deferred.d.ts +12 -6
  155. package/dist/query/deferred.js +1 -1
  156. package/dist/query/filters.d.ts +31 -11
  157. package/dist/query/filters.js +66 -13
  158. package/dist/query/index.d.ts +1 -1
  159. package/dist/query/index.js +1 -1
  160. package/dist/query/relations.d.ts +9 -9
  161. package/dist/query/relations.js +165 -58
  162. package/dist/query/types.d.ts +86 -35
  163. package/dist/query/types.js +1 -1
  164. package/dist/query/utils.d.ts +27 -10
  165. package/dist/query/utils.js +84 -14
  166. package/dist/query/where.d.ts +47 -28
  167. package/dist/query/where.js +129 -32
  168. package/dist/query/writes.d.ts +24 -5
  169. package/dist/query/writes.js +101 -13
  170. package/dist/realtime.d.ts +7 -7
  171. package/dist/realtime.js +9 -9
  172. package/dist/schema-builder.d.ts +18 -7
  173. package/dist/schema-builder.js +17 -10
  174. package/dist/schema-metadata.d.ts +3 -3
  175. package/dist/schema-metadata.js +9 -9
  176. package/dist/schema-sql.d.ts +9 -9
  177. package/dist/schema-sql.js +20 -20
  178. package/dist/schema.d.ts +19 -9
  179. package/dist/schema.js +6 -6
  180. package/dist/serverless.d.ts +15 -15
  181. package/dist/serverless.js +16 -16
  182. package/dist/sqlite.d.ts +8 -8
  183. package/dist/sqlite.js +53 -22
  184. package/dist/typed-sql.d.ts +4 -4
  185. package/dist/typed-sql.js +5 -5
  186. package/package.json +2 -2
package/dist/mssql.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * turbine-orm/mssql — Microsoft SQL Server engine (driver-injected, optional peer)
2
+ * turbine-orm/mssql, Microsoft SQL Server engine (driver-injected, optional peer)
3
3
  *
4
4
  * Binds Turbine to SQL Server 2016+ via the `mssql` driver (which wraps
5
- * `tedious`). `mssql` is **not** a root dependency — it is an **optional peer**:
5
+ * `tedious`). `mssql` is **not** a root dependency, it is an **optional peer**:
6
6
  * `npm i turbine-orm` pulls nothing extra, and only consumers who
7
7
  * `import 'turbine-orm/mssql'` install `mssql` themselves. The factory loads it
8
8
  * through a dynamic `import('mssql')` so importing this module never crashes when
@@ -33,14 +33,14 @@
33
33
  * real JSON instead of being escaped as a string. `INCLUDE_NULL_VALUES`
34
34
  * keeps NULL columns present (matching PostgreSQL `json_build_object`).
35
35
  * 3. **No `LIMIT`.** Paging is `ORDER BY … OFFSET n ROWS FETCH NEXT m ROWS ONLY`,
36
- * which requires an ORDER BY — a stable `ORDER BY (SELECT NULL)` is injected
36
+ * which requires an ORDER BY, a stable `ORDER BY (SELECT NULL)` is injected
37
37
  * when the query has none (`Dialect.buildLimitOffset`).
38
38
  *
39
39
  * ## Named `@pN` placeholders (no positional `?`)
40
40
  *
41
41
  * `mssqlDialect.paramPlaceholder = (i) => '@p' + i`. The driver shim binds via
42
42
  * `request.input('p' + i, value)`, so binding is by NAME and independent of where
43
- * each placeholder lands in the SQL text — exactly the guarantee PostgreSQL's
43
+ * each placeholder lands in the SQL text, exactly the guarantee PostgreSQL's
44
44
  * numbered `$N` gives. (SQL Server is naturally named-param friendly, sidestepping
45
45
  * the positional-`?` mis-bind bug the SQLite/MySQL phases hit.)
46
46
  *
@@ -48,12 +48,12 @@
48
48
  *
49
49
  * - **Single query nested relations preserved** via `FOR JSON PATH` (SQL Server
50
50
  * 2016+). Ordered/limited to-many uses `ORDER BY … OFFSET/FETCH` inside the FOR
51
- * JSON subquery (no inner-subquery rewrite needed — FOR JSON aggregates AFTER
51
+ * JSON subquery (no inner-subquery rewrite needed, FOR JSON aggregates AFTER
52
52
  * the row selection).
53
53
  * - **Result strategy `'output'`:** create/update/delete/upsert return their rows
54
54
  * from the same statement. `createMany` returns the inserted rows via
55
55
  * `OUTPUT INSERTED.*` on the multi-row VALUES insert (≤ 1000 rows / 2100 params
56
- * per statement — exceeding either throws a clear `ValidationError`; chunk
56
+ * per statement, exceeding either throws a clear `ValidationError`; chunk
57
57
  * yourself or use single `create`s).
58
58
  * - **MERGE concurrency caveat:** `MERGE` is the upsert primitive; under high
59
59
  * concurrency a `MERGE` can still race (it is NOT a substitute for a unique
@@ -62,20 +62,20 @@
62
62
  * loser of a race.
63
63
  * - **Unsupported (throw `UnsupportedFeatureError`):** pgvector distance ops,
64
64
  * LISTEN/NOTIFY (`$listen`/`$notify`), RLS `sessionContext` (sp_set_session_context
65
- * exists but is connection-scoped, not transaction-local, so it is not wired —
65
+ * exists but is connection-scoped, not transaction-local, so it is not wired -
66
66
  * throws rather than silently leaking context across pooled connections).
67
67
  * - **Advisory-lock migration locking** is available in principle via
68
68
  * `sp_getapplock`/`sp_releaseapplock` (`supportsAdvisoryLock = true`); the
69
69
  * migrate CLI is still PostgreSQL-only, so this flag documents intent for a
70
70
  * future adapter.
71
- * - **Case-insensitive matching** uses `LOWER(col) LIKE LOWER(ref)` — deterministic
71
+ * - **Case-insensitive matching** uses `LOWER(col) LIKE LOWER(ref)`, deterministic
72
72
  * regardless of the column's collation (note this can defeat an index unless a
73
73
  * computed/persisted `LOWER()` index exists).
74
74
  * - **bignum:** the shim applies the same safe-int policy Turbine uses for Postgres
75
75
  * `int8` (number when it fits in 2^53, decimal string otherwise) WITHOUT mutating
76
76
  * any global driver state. `DECIMAL`/`NUMERIC`/`MONEY` come back as strings;
77
77
  * `BIT` binds/returns booleans.
78
- * - **`DISTINCT ON`** is PostgreSQL-only and is not translated — avoid `distinct`
78
+ * - **`DISTINCT ON`** is PostgreSQL-only and is not translated, avoid `distinct`
79
79
  * on SQL Server.
80
80
  *
81
81
  * ## Example
@@ -112,7 +112,7 @@ function sortedRelEntries(obj) {
112
112
  /**
113
113
  * Coerce an arbitrary JS value into something `mssql` can bind. `BIT` accepts JS
114
114
  * booleans directly; `undefined`/`null` → NULL; `bigint` follows the safe-int
115
- * policy (number when it fits in 2^53, else a decimal string — no precision
115
+ * policy (number when it fits in 2^53, else a decimal string, no precision
116
116
  * loss); `Date`/`Uint8Array` pass through; any remaining object/array → JSON text
117
117
  * (matches how Turbine already pre-serializes JSON filter / `IN`-list params).
118
118
  */
@@ -152,7 +152,7 @@ function shapeResult(result) {
152
152
  * driver returns BIGINT as a string to avoid precision loss, so a BIGINT IDENTITY
153
153
  * `id` would otherwise surface as `'1'` instead of `1`. Values outside the safe
154
154
  * range are left as strings (same as the Postgres path). Nested relations are
155
- * unaffected — `FOR JSON PATH` already renders BIGINT as a JSON number.
155
+ * unaffected, `FOR JSON PATH` already renders BIGINT as a JSON number.
156
156
  */
157
157
  function coerceBigIntColumns(rows, columns) {
158
158
  if (!columns || rows.length === 0)
@@ -275,12 +275,12 @@ class MssqlTxClient {
275
275
  const { text: rawSql, params } = normalizeQueryArgs(text, values);
276
276
  const sql = rawSql.trim();
277
277
  // RELEASE SAVEPOINT has no SQL Server equivalent (savepoints auto-persist until
278
- // the outer commit/rollback) — releaseSavepointStatement() returns '' → no-op.
278
+ // the outer commit/rollback), releaseSavepointStatement() returns '' → no-op.
279
279
  if (sql === '')
280
280
  return EMPTY_RESULT;
281
281
  // The dialect composes `SET TRANSACTION ISOLATION LEVEL …; BEGIN TRANSACTION`
282
282
  // (SQL Server cannot take an inline isolation level on BEGIN). Split and run
283
- // the parts in order — same pattern as the MySQL shim. Gated on the exact
283
+ // the parts in order, same pattern as the MySQL shim. Gated on the exact
284
284
  // transaction-control prefix so no builder/user SQL is ever split.
285
285
  if (/^SET TRANSACTION ISOLATION LEVEL /i.test(sql) && sql.includes('; ')) {
286
286
  let last = EMPTY_RESULT;
@@ -341,7 +341,7 @@ class MssqlTxClient {
341
341
  * physical connection.
342
342
  */
343
343
  export class MssqlPool {
344
- /** The underlying `mssql` ConnectionPool — exposed as an escape hatch (seed / DDL / advanced ops). */
344
+ /** The underlying `mssql` ConnectionPool, exposed as an escape hatch (seed / DDL / advanced ops). */
345
345
  pool;
346
346
  sqlNS;
347
347
  closed = false;
@@ -446,7 +446,7 @@ function mssqlColumnType(type, maxLength) {
446
446
  return type;
447
447
  }
448
448
  // ---------------------------------------------------------------------------
449
- // mssqlDialect — the full Dialect contract for SQL Server 2016+
449
+ // mssqlDialect, the full Dialect contract for SQL Server 2016+
450
450
  // ---------------------------------------------------------------------------
451
451
  /**
452
452
  * Render the SQL Server `OUTPUT` clause for a write's returning selection.
@@ -519,6 +519,27 @@ export const mssqlDialect = {
519
519
  return `[${name.replace(/]/g, ']]')}]`;
520
520
  },
521
521
  // SQL Server aggregate casts: COUNT → INT, AVG/float → FLOAT.
522
+ jsonWireRule(columnType) {
523
+ const t = columnType.toLowerCase();
524
+ // FOR JSON PATH renders BIGINT as a JSON number, which is an IEEE double:
525
+ // a stored 9007199254740993 came back 9007199254740992 through the join
526
+ // strategy, while a top-level read and the batched loader both returned
527
+ // the exact decimal string. The tedious driver returns BIGINT as a STRING
528
+ // unconditionally (verified for both small and large values), so carrying
529
+ // text and keeping it reproduces the driver exactly.
530
+ if (t === 'bigint') {
531
+ return { sql: (ref) => `CAST(${ref} AS NVARCHAR(50))`, decode: (value) => value };
532
+ }
533
+ // Binary columns come out of FOR JSON PATH as BASE64 text ("AQL/") rather
534
+ // than bytes. Style 2 converts to bare hex, which rebuilds exactly.
535
+ if (t === 'binary' || t === 'varbinary' || t === 'image') {
536
+ return {
537
+ sql: (ref) => `CONVERT(VARCHAR(MAX), ${ref}, 2)`,
538
+ decode: (value) => (typeof value === 'string' ? Uint8Array.from(Buffer.from(value, 'hex')) : value),
539
+ };
540
+ }
541
+ return undefined;
542
+ },
522
543
  castAggregate(expr, target) {
523
544
  return `CAST(${expr} AS ${target === 'int' ? 'INT' : 'FLOAT'})`;
524
545
  },
@@ -533,7 +554,7 @@ export const mssqlDialect = {
533
554
  inClauseParam(values) {
534
555
  return JSON.stringify(values ?? []);
535
556
  },
536
- // OUTPUT replaces RETURNING — injected mid-statement by the statement builders,
557
+ // OUTPUT replaces RETURNING, injected mid-statement by the statement builders,
537
558
  // never as a trailing clause.
538
559
  buildReturningClause() {
539
560
  return '';
@@ -543,7 +564,7 @@ export const mssqlDialect = {
543
564
  return `INSERT INTO ${input.table} (${input.columns.join(', ')})${out} VALUES (${input.valuePlaceholders.join(', ')})`;
544
565
  },
545
566
  buildBulkInsertStatement(input) {
546
- // No UNNEST in SQL Server — emit multi-row VALUES with flattened, named `@pN`
567
+ // No UNNEST in SQL Server, emit multi-row VALUES with flattened, named `@pN`
547
568
  // placeholders. Enforce the engine's 1000-row / 2100-param statement limits.
548
569
  const rowCount = input.rowValues.length;
549
570
  const paramCount = input.rowValues.reduce((n, row) => n + row.length, 0);
@@ -573,7 +594,7 @@ export const mssqlDialect = {
573
594
  },
574
595
  buildUpsertStatement(input) {
575
596
  // MERGE is the SQL Server upsert. The MERGE statement MUST end with `;`.
576
- // CONCURRENCY CAVEAT: MERGE is not a substitute for a UNIQUE/PK constraint —
597
+ // CONCURRENCY CAVEAT: MERGE is not a substitute for a UNIQUE/PK constraint -
577
598
  // keep the conflict columns backed by one and rely on UniqueConstraintError
578
599
  // (2627/2601 → E008) for a concurrent loser.
579
600
  const on = input.conflictColumns.map((c) => `T.${c} = S.${c}`).join(' AND ');
@@ -588,7 +609,7 @@ export const mssqlDialect = {
588
609
  `${out};`);
589
610
  },
590
611
  // UPDATE/DELETE inject OUTPUT mid-statement (between SET and WHERE / FROM and
591
- // WHERE) — a trailing clause would be invalid T-SQL.
612
+ // WHERE), a trailing clause would be invalid T-SQL.
592
613
  buildUpdateStatement(input) {
593
614
  const out = mssqlOutput(input.returning, 'INSERTED');
594
615
  return `UPDATE ${input.table} SET ${input.setClauses.join(', ')}${out}${input.whereSql}`;
@@ -597,7 +618,7 @@ export const mssqlDialect = {
597
618
  const out = mssqlOutput(input.returning, 'DELETED');
598
619
  return `DELETE FROM ${input.table}${out}${input.whereSql}`;
599
620
  },
600
- // SQL Server has no LIMIT — emit OFFSET/FETCH, injecting a stable ORDER BY when
621
+ // SQL Server has no LIMIT, emit OFFSET/FETCH, injecting a stable ORDER BY when
601
622
  // the outer query has none (OFFSET/FETCH requires an ORDER BY).
602
623
  buildLimitOffset(input) {
603
624
  const { limitPlaceholder, offsetPlaceholder, hasOrderBy } = input;
@@ -623,7 +644,7 @@ export const mssqlDialect = {
623
644
  },
624
645
  // SQL Server has no JSON_CONTAINS. Emulate "the JSON array column contains the
625
646
  // scalar value" via OPENJSON (documented `limited`: object-containment and deep
626
- // paths are not supported — use a generated column + index for those).
647
+ // paths are not supported, use a generated column + index for those).
627
648
  buildJsonContains(column, paramRef) {
628
649
  return `EXISTS (SELECT 1 FROM OPENJSON(${column}) WHERE [value] = ${paramRef})`;
629
650
  },
@@ -702,7 +723,7 @@ export const mssqlDialect = {
702
723
  return `SAVE TRANSACTION ${name}`;
703
724
  },
704
725
  releaseSavepointStatement() {
705
- // SQL Server has no RELEASE SAVEPOINT — savepoints persist until the outer
726
+ // SQL Server has no RELEASE SAVEPOINT, savepoints persist until the outer
706
727
  // commit/rollback. The shim treats the empty statement as a no-op.
707
728
  return '';
708
729
  },
@@ -760,10 +781,21 @@ function buildForJsonSubquery(dialect, ctx) {
760
781
  const orderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
761
782
  const hasOrder = orderEntries.length > 0;
762
783
  const hasLimit = spec !== true && spec.limit !== undefined;
763
- /** `<alias>.<col> AS [<field>]` selection for the FOR JSON object keys. */
784
+ /**
785
+ * `<alias>.<col> AS [<field>]` selection for the FOR JSON object keys.
786
+ *
787
+ * A column whose FOR JSON rendering diverges from the driver's own value is
788
+ * wrapped by the dialect's {@link Dialect.jsonWireRule} cast, exactly as the
789
+ * shared `json_build_object` path does, this generator is an override, so it
790
+ * has to opt in explicitly or BIGINT silently loses precision and a binary
791
+ * column arrives as base64 text.
792
+ */
764
793
  const colSelect = (a) => targetColumns.map((col) => {
765
794
  const field = targetMeta.reverseColumnMap[col] ?? snakeToCamel(col);
766
- return `${a}.${q(col)} AS ${q(field)}`;
795
+ const ref = `${a}.${q(col)}`;
796
+ const type = targetMeta.pgTypes?.[col];
797
+ const expr = (type && dialect.jsonWireRule?.(type)?.sql(ref)) ?? ref;
798
+ return `${expr} AS ${q(field)}`;
767
799
  });
768
800
  /** Build nested relations as `JSON_QUERY((<subquery>)) AS [<name>]` columns (pushes their params). */
769
801
  const buildNested = (parentAlias) => {
@@ -921,7 +953,7 @@ const num = (v) => (typeof v === 'string' ? Number(v) : (v ?? 0));
921
953
  * (`deriveEngineRelations` → `buildRelationsFromForeignKeys` +
922
954
  * `addAutoManyToManyRelations` in introspect.ts), so this engine derives
923
955
  * IDENTICAL relation names to `turbine generate` against Postgres for the
924
- * same logical schema — legacy-first naming, per-column disambiguation, and
956
+ * same logical schema, legacy-first naming, per-column disambiguation, and
925
957
  * collision resolution against scalar column fields included.
926
958
  */
927
959
  function buildRelationsFromForeignKeys(tableNames, foreignKeys, pkByTable, columnsByTable) {
@@ -1109,7 +1141,7 @@ export async function introspectMssqlWith(exec, schema = 'dbo', options = {}) {
1109
1141
  indexes: indexesByTable.get(tableName) ?? [],
1110
1142
  };
1111
1143
  }
1112
- // SQL Server has no first-class enum type — enums are CHECK constraints; left empty.
1144
+ // SQL Server has no first-class enum type, enums are CHECK constraints; left empty.
1113
1145
  return { tables, enums: {} };
1114
1146
  }
1115
1147
  /**
@@ -1125,7 +1157,10 @@ export async function introspectMssql(options) {
1125
1157
  try {
1126
1158
  const mp = new MssqlPool(pool, sqlNS);
1127
1159
  const exec = async (sql, params) => (await mp.query(sql, params)).rows;
1128
- return introspectMssqlWith(exec, options.schema ?? 'dbo', {
1160
+ // `await` is LOAD-BEARING: `return somePromise` inside a try/finally runs
1161
+ // the finally BEFORE the promise settles, so `pool.close()` would tear the
1162
+ // connection down underneath the introspection queries.
1163
+ return await introspectMssqlWith(exec, options.schema ?? 'dbo', {
1129
1164
  include: options.include,
1130
1165
  exclude: options.exclude,
1131
1166
  });
@@ -1166,7 +1201,7 @@ async function loadMssql() {
1166
1201
  let mod;
1167
1202
  try {
1168
1203
  // `mssql` ships no bundled type declarations (it needs @types/mssql, which
1169
- // Turbine deliberately does not depend on) — the structural MssqlModule
1204
+ // Turbine deliberately does not depend on), the structural MssqlModule
1170
1205
  // above is our typed surface; the helper returns `unknown` so no TS7016.
1171
1206
  // Via the .cts helper so the CJS build keeps a path to a REAL dynamic
1172
1207
  // import() even if a future mssql major goes ESM-only (the CommonJS pass
@@ -1204,7 +1239,7 @@ function isMssqlPool(x) {
1204
1239
  * Pass one of:
1205
1240
  * - a connection string (`'mssql://sa:pass@host:1433/db'`),
1206
1241
  * - an `mssql` config object (`{ server, user, password, database, options }`),
1207
- * - an existing `MssqlPool` (injection — you own its lifecycle, `disconnect()` is
1242
+ * - an existing `MssqlPool` (injection, you own its lifecycle, `disconnect()` is
1208
1243
  * a no-op).
1209
1244
  *
1210
1245
  * When Turbine builds the pool (string/config), it probes
package/dist/mysql.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * turbine-orm/mysql — MySQL 8 engine (driver-injected, optional peer)
2
+ * turbine-orm/mysql, MySQL 8 engine (driver-injected, optional peer)
3
3
  *
4
4
  * Binds Turbine to MySQL 8 via the `mysql2` driver. `mysql2` is **not** a root
5
- * dependency — it is an **optional peer**: `npm i turbine-orm` pulls nothing
5
+ * dependency, it is an **optional peer**: `npm i turbine-orm` pulls nothing
6
6
  * extra, and only consumers who `import 'turbine-orm/mysql'` install `mysql2`
7
7
  * themselves. The factory loads it through a dynamic `import('mysql2/promise')`
8
8
  * so importing this module never crashes when `mysql2` is absent for a consumer
@@ -21,7 +21,7 @@
21
21
  * (e.g. a `with`-relation `LIMIT` lands in the SELECT list, ahead of the
22
22
  * outer `WHERE`). Postgres reconciles this via numbered `$N`; positional `?`
23
23
  * silently mis-binds. So `mysqlDialect` uses **mysql2 named placeholders**
24
- * (`:p1`, `:p2`, …) and the driver shim binds via a `{ p1, p2, … }` object —
24
+ * (`:p1`, `:p2`, …) and the driver shim binds via a `{ p1, p2, … }` object -
25
25
  * exactly mirroring `$N` semantics regardless of text order. (See
26
26
  * `turbine-orm/sqlite` for the same fix.)
27
27
  *
@@ -42,10 +42,10 @@
42
42
  * - **Advisory-lock migration locking** is available in principle via
43
43
  * `GET_LOCK`/`RELEASE_LOCK` (`supportsAdvisoryLock = true`); the migrate CLI is
44
44
  * still PostgreSQL-only, so this flag documents intent for a future adapter.
45
- * - **Case-insensitive matching** uses `LOWER(col) LIKE LOWER(ref)` — note this
45
+ * - **Case-insensitive matching** uses `LOWER(col) LIKE LOWER(ref)`, note this
46
46
  * can defeat indexes unless a functional/generated index exists.
47
47
  * - **bignum:** mysql2 is configured `supportBigNumbers:true, bigNumberStrings:false`
48
- * — the same safe-int policy Turbine uses for Postgres `int8` (number when it
48
+ * - the same safe-int policy Turbine uses for Postgres `int8` (number when it
49
49
  * fits, decimal string otherwise). `DECIMAL` comes back as a string; `TINYINT(1)`
50
50
  * binds booleans as 1/0. No global parser state is mutated.
51
51
  * - **Version:** MySQL **8.0+** required (5.7 lacks `JSON_ARRAYAGG`); MariaDB is
@@ -92,7 +92,7 @@ type QueryArg = string | {
92
92
  * `SAVEPOINT` nesting all run on the same physical connection.
93
93
  */
94
94
  export declare class MysqlPool implements PgCompatPool {
95
- /** The underlying mysql2 pool — exposed as an escape hatch (seed / DDL / advanced ops). */
95
+ /** The underlying mysql2 pool, exposed as an escape hatch (seed / DDL / advanced ops). */
96
96
  readonly pool: Mysql2Pool;
97
97
  private closed;
98
98
  constructor(pool: Mysql2Pool);
@@ -109,7 +109,7 @@ export declare class MysqlPool implements PgCompatPool {
109
109
  export declare function mysqlTypeToTs(dialectType: string, nullable: boolean, columnType?: string): string;
110
110
  /**
111
111
  * MySQL 8 implementation of the {@link Dialect} contract. Backtick identifier
112
- * quoting, named `:pN` placeholders (NOT positional `?` — see the module
112
+ * quoting, named `:pN` placeholders (NOT positional `?`, see the module
113
113
  * docstring), `JSON_OBJECT` / `JSON_ARRAYAGG` for the single-query nested
114
114
  * relation engine (`CAST(… AS JSON)`-wrapped nested subresults), no `RETURNING`
115
115
  * (`resultStrategy = 'reselect'`), `INSERT … ON DUPLICATE KEY UPDATE` upserts,
@@ -156,7 +156,7 @@ export interface TurbineMysqlOptions extends Pick<TurbineConfig, 'logging' | 'de
156
156
  * Pass one of:
157
157
  * - a connection string (`'mysql://user:pass@host:3306/db'`),
158
158
  * - a mysql2 connection config object (`{ host, user, password, database }`), or
159
- * - an existing mysql2 pool / {@link MysqlPool} (injection — you own its lifecycle,
159
+ * - an existing mysql2 pool / {@link MysqlPool} (injection, you own its lifecycle,
160
160
  * `disconnect()` is a no-op, advanced config like SSL lives here).
161
161
  *
162
162
  * When Turbine builds the pool (string/config), it pins the correct mysql2 flags
package/dist/mysql.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * turbine-orm/mysql — MySQL 8 engine (driver-injected, optional peer)
2
+ * turbine-orm/mysql, MySQL 8 engine (driver-injected, optional peer)
3
3
  *
4
4
  * Binds Turbine to MySQL 8 via the `mysql2` driver. `mysql2` is **not** a root
5
- * dependency — it is an **optional peer**: `npm i turbine-orm` pulls nothing
5
+ * dependency, it is an **optional peer**: `npm i turbine-orm` pulls nothing
6
6
  * extra, and only consumers who `import 'turbine-orm/mysql'` install `mysql2`
7
7
  * themselves. The factory loads it through a dynamic `import('mysql2/promise')`
8
8
  * so importing this module never crashes when `mysql2` is absent for a consumer
@@ -21,7 +21,7 @@
21
21
  * (e.g. a `with`-relation `LIMIT` lands in the SELECT list, ahead of the
22
22
  * outer `WHERE`). Postgres reconciles this via numbered `$N`; positional `?`
23
23
  * silently mis-binds. So `mysqlDialect` uses **mysql2 named placeholders**
24
- * (`:p1`, `:p2`, …) and the driver shim binds via a `{ p1, p2, … }` object —
24
+ * (`:p1`, `:p2`, …) and the driver shim binds via a `{ p1, p2, … }` object -
25
25
  * exactly mirroring `$N` semantics regardless of text order. (See
26
26
  * `turbine-orm/sqlite` for the same fix.)
27
27
  *
@@ -42,10 +42,10 @@
42
42
  * - **Advisory-lock migration locking** is available in principle via
43
43
  * `GET_LOCK`/`RELEASE_LOCK` (`supportsAdvisoryLock = true`); the migrate CLI is
44
44
  * still PostgreSQL-only, so this flag documents intent for a future adapter.
45
- * - **Case-insensitive matching** uses `LOWER(col) LIKE LOWER(ref)` — note this
45
+ * - **Case-insensitive matching** uses `LOWER(col) LIKE LOWER(ref)`, note this
46
46
  * can defeat indexes unless a functional/generated index exists.
47
47
  * - **bignum:** mysql2 is configured `supportBigNumbers:true, bigNumberStrings:false`
48
- * — the same safe-int policy Turbine uses for Postgres `int8` (number when it
48
+ * - the same safe-int policy Turbine uses for Postgres `int8` (number when it
49
49
  * fits, decimal string otherwise). `DECIMAL` comes back as a string; `TINYINT(1)`
50
50
  * binds booleans as 1/0. No global parser state is mutated.
51
51
  * - **Version:** MySQL **8.0+** required (5.7 lacks `JSON_ARRAYAGG`); MariaDB is
@@ -98,7 +98,7 @@ function toMysqlParam(value) {
98
98
  /**
99
99
  * Bind the positional `params[]` (in 1-indexed generation order) to the named
100
100
  * `:p1`, `:p2`, … placeholders the dialect emits. Mapping by NAME makes binding
101
- * independent of where each placeholder lands in the SQL text — the same
101
+ * independent of where each placeholder lands in the SQL text, the same
102
102
  * guarantee Postgres' numbered `$N` gives. Returns `undefined` for parameter-less
103
103
  * statements (BEGIN/COMMIT/DDL) so they bind nothing.
104
104
  */
@@ -132,7 +132,7 @@ function shapeResult(result) {
132
132
  * deadlock / lock-wait-timeout become retryable (E012/E013). The original mysql2
133
133
  * error (with its real message) is preserved as the wrapped error's `.cause`
134
134
  * downstream. Returns the value unchanged when it is not a recognizable mysql2
135
- * error. We only annotate here — `wrapPgError` (invoked downstream in the query
135
+ * error. We only annotate here, `wrapPgError` (invoked downstream in the query
136
136
  * executor / transaction proxy) does the actual translation.
137
137
  */
138
138
  function augmentMysqlError(err) {
@@ -215,7 +215,7 @@ async function execOne(runner, sql, params) {
215
215
  * emits `SET TRANSACTION ISOLATION LEVEL <level>; START TRANSACTION`. mysql2 runs
216
216
  * one statement per call (multi-statements stay OFF by design), so split exactly
217
217
  * that dialect-generated compound and run the parts in order. Gated on the precise
218
- * transaction-control prefix — the query builder never emits `SET TRANSACTION
218
+ * transaction-control prefix, the query builder never emits `SET TRANSACTION
219
219
  * ISOLATION LEVEL`, so no builder/user SQL is ever split.
220
220
  */
221
221
  async function runOnConnection(conn, sql, params) {
@@ -237,7 +237,7 @@ async function runOnConnection(conn, sql, params) {
237
237
  * `SAVEPOINT` nesting all run on the same physical connection.
238
238
  */
239
239
  export class MysqlPool {
240
- /** The underlying mysql2 pool — exposed as an escape hatch (seed / DDL / advanced ops). */
240
+ /** The underlying mysql2 pool, exposed as an escape hatch (seed / DDL / advanced ops). */
241
241
  pool;
242
242
  closed = false;
243
243
  constructor(pool) {
@@ -348,11 +348,11 @@ function mysqlColumnType(type, maxLength) {
348
348
  return type;
349
349
  }
350
350
  // ---------------------------------------------------------------------------
351
- // mysqlDialect — the full Dialect contract for MySQL 8
351
+ // mysqlDialect, the full Dialect contract for MySQL 8
352
352
  // ---------------------------------------------------------------------------
353
353
  /**
354
354
  * MySQL 8 implementation of the {@link Dialect} contract. Backtick identifier
355
- * quoting, named `:pN` placeholders (NOT positional `?` — see the module
355
+ * quoting, named `:pN` placeholders (NOT positional `?`, see the module
356
356
  * docstring), `JSON_OBJECT` / `JSON_ARRAYAGG` for the single-query nested
357
357
  * relation engine (`CAST(… AS JSON)`-wrapped nested subresults), no `RETURNING`
358
358
  * (`resultStrategy = 'reselect'`), `INSERT … ON DUPLICATE KEY UPDATE` upserts,
@@ -396,7 +396,7 @@ export const mysqlDialect = {
396
396
  jsonPathSupport: 'function',
397
397
  emptyJsonArrayLiteral: 'JSON_ARRAY()',
398
398
  nullJsonLiteral: 'NULL',
399
- // Named placeholders (`:p1`, `:p2`, …) — NOT positional `?`. Turbine pushes
399
+ // Named placeholders (`:p1`, `:p2`, …), NOT positional `?`. Turbine pushes
400
400
  // params in 1-indexed generation order but may EMIT them in a different SQL
401
401
  // text position; positional `?` mis-binds. mysql2 named parameters bind by
402
402
  // name, so `:pN` ↔ `params[N-1]` mirrors Postgres' `$N` regardless of text
@@ -433,7 +433,7 @@ export const mysqlDialect = {
433
433
  * Wrap a nested correlated subquery for embedding inside a parent `JSON_OBJECT`.
434
434
  * MySQL double-encodes a scalar subquery result as a JSON *string* unless it is
435
435
  * explicitly typed JSON, so `CAST((subquery) AS JSON)` forces the nested value
436
- * to be embedded as real JSON (objects/arrays), not a quoted string — the same
436
+ * to be embedded as real JSON (objects/arrays), not a quoted string, the same
437
437
  * intent as SQLite's `json(...)` wrap. The fallback (`JSON_ARRAY()` / `NULL`) is
438
438
  * already JSON-typed.
439
439
  */
@@ -442,6 +442,40 @@ export const mysqlDialect = {
442
442
  },
443
443
  // MySQL aggregate casts: COUNT → SIGNED (BIGINT, comes back as a JS number when
444
444
  // safe), AVG/float → DECIMAL (string, the aggregate transform Number()-coerces).
445
+ jsonWireRule(columnType) {
446
+ const t = columnType.toLowerCase();
447
+ // JSON_OBJECT renders these as JSON numbers, which are IEEE doubles, so a
448
+ // relation read through the join strategy disagreed with a top-level read
449
+ // and with the batched loader, LOSSILY. Measured: BIGINT 9007199254740993
450
+ // came back 9007199254740992, and DECIMAL '1000.50' came back 1000.5.
451
+ // mysql2 hands both back as STRINGS at top level (supportBigNumbers, and
452
+ // DECIMAL is always a string), so carry the text and keep it.
453
+ if (t === 'bigint') {
454
+ return {
455
+ sql: (ref) => `CAST(${ref} AS CHAR)`,
456
+ decode: (value) => {
457
+ // Same policy as the driver's supportBigNumbers/bigNumberStrings:false.
458
+ if (typeof value !== 'string' || !/^-?\d+$/.test(value))
459
+ return value;
460
+ const asNumber = Number(value);
461
+ return Number.isSafeInteger(asNumber) ? asNumber : value;
462
+ },
463
+ };
464
+ }
465
+ if (t === 'decimal' || t === 'numeric') {
466
+ return { sql: (ref) => `CAST(${ref} AS CHAR)`, decode: (value) => value };
467
+ }
468
+ // Binary columns are worse than lossy: JSON_OBJECT emits MySQL's internal
469
+ // `base64:type15:…` marker string, so the caller got that text instead of
470
+ // bytes. Carry hex and rebuild the buffer.
471
+ if (t === 'binary' || t === 'varbinary' || t.endsWith('blob')) {
472
+ return {
473
+ sql: (ref) => `HEX(${ref})`,
474
+ decode: (value) => (typeof value === 'string' ? Uint8Array.from(Buffer.from(value, 'hex')) : value),
475
+ };
476
+ }
477
+ return undefined;
478
+ },
445
479
  castAggregate(expr, target) {
446
480
  return `CAST(${expr} AS ${target === 'int' ? 'SIGNED' : 'DECIMAL(65,30)'})`;
447
481
  },
@@ -457,7 +491,7 @@ export const mysqlDialect = {
457
491
  inClauseParam(values) {
458
492
  return JSON.stringify(values ?? []);
459
493
  },
460
- // No RETURNING — resultStrategy 'reselect' re-fetches the row.
494
+ // No RETURNING, resultStrategy 'reselect' re-fetches the row.
461
495
  buildReturningClause() {
462
496
  return '';
463
497
  },
@@ -465,7 +499,7 @@ export const mysqlDialect = {
465
499
  return `INSERT INTO ${input.table} (${input.columns.join(', ')}) VALUES (${input.valuePlaceholders.join(', ')})`;
466
500
  },
467
501
  buildBulkInsertStatement(input) {
468
- // No UNNEST in MySQL — emit multi-row VALUES with flattened, named
502
+ // No UNNEST in MySQL, emit multi-row VALUES with flattened, named
469
503
  // placeholders (`:p1`, `:p2`, …) matching the flat param order.
470
504
  let n = 0;
471
505
  const placeholders = input.rowValues
@@ -480,7 +514,7 @@ export const mysqlDialect = {
480
514
  };
481
515
  },
482
516
  buildUpsertStatement(input) {
483
- // MySQL ignores the explicit conflict target — ON DUPLICATE KEY UPDATE keys
517
+ // MySQL ignores the explicit conflict target, ON DUPLICATE KEY UPDATE keys
484
518
  // off the table's PK/unique indexes. The `where`-derived conflictColumns
485
519
  // (input.conflictColumns) must therefore correspond to a real unique/PK index
486
520
  // for the upsert to target the intended row (plan §4 / R9).
@@ -589,7 +623,7 @@ const num = (v) => (typeof v === 'string' ? Number(v) : (v ?? 0));
589
623
  * (`deriveEngineRelations` → `buildRelationsFromForeignKeys` +
590
624
  * `addAutoManyToManyRelations` in introspect.ts), so this engine derives
591
625
  * IDENTICAL relation names to `turbine generate` against Postgres for the
592
- * same logical schema — legacy-first naming, per-column disambiguation, and
626
+ * same logical schema, legacy-first naming, per-column disambiguation, and
593
627
  * collision resolution against scalar column fields included.
594
628
  */
595
629
  function buildRelationsFromForeignKeys(tableNames, foreignKeys, pkByTable, columnsByTable) {
@@ -776,9 +810,13 @@ export async function introspectMysql(options) {
776
810
  schemaName = rows[0]?.db ?? '';
777
811
  }
778
812
  if (!schemaName) {
779
- throw new ConnectionError('[turbine] Could not determine the MySQL database to introspect — pass a database in the connection string or a `schema` option.');
813
+ throw new ConnectionError('[turbine] Could not determine the MySQL database to introspect, pass a database in the connection string or a `schema` option.');
780
814
  }
781
- return introspectMysqlWith(exec, schemaName, { include: options.include, exclude: options.exclude });
815
+ // `await` is LOAD-BEARING, not stylistic: `return somePromise` inside a
816
+ // try/finally runs the finally BEFORE the promise settles, so `pool.end()`
817
+ // closed the pool out from under every introspection query and the whole
818
+ // call failed with mysql2's "Pool is closed".
819
+ return await introspectMysqlWith(exec, schemaName, { include: options.include, exclude: options.exclude });
782
820
  }
783
821
  finally {
784
822
  await pool.end();
@@ -789,7 +827,7 @@ export async function introspectMysql(options) {
789
827
  // ---------------------------------------------------------------------------
790
828
  /** mysql2 connection flags Turbine pins for correct behavior. */
791
829
  const MYSQL_DRIVER_FLAGS = {
792
- // Named placeholders (`:pN`) — see paramPlaceholder + toNamedBinding.
830
+ // Named placeholders (`:pN`), see paramPlaceholder + toNamedBinding.
793
831
  namedPlaceholders: true,
794
832
  // Safe-int policy matching Postgres int8: number when it fits, decimal string
795
833
  // otherwise. DECIMAL always comes back as a string. No global state mutated.
@@ -875,7 +913,7 @@ function isMysql2Pool(x) {
875
913
  * Pass one of:
876
914
  * - a connection string (`'mysql://user:pass@host:3306/db'`),
877
915
  * - a mysql2 connection config object (`{ host, user, password, database }`), or
878
- * - an existing mysql2 pool / {@link MysqlPool} (injection — you own its lifecycle,
916
+ * - an existing mysql2 pool / {@link MysqlPool} (injection, you own its lifecycle,
879
917
  * `disconnect()` is a no-op, advanced config like SSL lives here).
880
918
  *
881
919
  * When Turbine builds the pool (string/config), it pins the correct mysql2 flags
@@ -907,12 +945,12 @@ export async function turbineMysql(target, schema, options = {}) {
907
945
  });
908
946
  // Every NEW physical connection: disable backslash string escaping so the
909
947
  // builder's `LIKE … ESCAPE '\'` clause (a single literal backslash) is valid
910
- // MySQL — by default MySQL would parse `'\'` as an escaped quote (syntax
948
+ // MySQL, by default MySQL would parse `'\'` as an escaped quote (syntax
911
949
  // error). This also makes string-literal semantics match Postgres. Turbine
912
950
  // parameterizes every value, so no behavior depends on backslash escaping.
913
951
  //
914
952
  // NOTE: even on a `mysql2/promise` pool, the 'connection' event yields the
915
- // *raw* (callback-style) connection — its `.query()` returns a non-thenable
953
+ // *raw* (callback-style) connection, its `.query()` returns a non-thenable
916
954
  // `Query`, so `.catch()`/`await` on it throws "result of query that is not a
917
955
  // promise". Use the callback form here (fire-and-forget): if the SET fails we
918
956
  // fall back to MySQL's default escaping, harmless since every value is a param.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * turbine-orm — Nested write engine
2
+ * turbine-orm, Nested write engine
3
3
  *
4
4
  * Tree-walking create/update that resolves relation fields in `data` into
5
5
  * batched SQL operations within a transaction. Supports create, connect,
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * This module is imported by `query/builder.ts` when the `data` argument
10
10
  * of `create()` or `update()` contains relation fields. It never imports
11
- * `client.ts` directly — the transaction handle is passed in via
11
+ * `client.ts` directly, the transaction handle is passed in via
12
12
  * `NestedWriteContext`.
13
13
  */
14
14
  import type { RelationDef, SchemaMetadata, TableMetadata } from './schema.js';
@@ -22,6 +22,25 @@ export interface ExtractedFields {
22
22
  */
23
23
  export interface NestedWriteContext {
24
24
  schema: SchemaMetadata;
25
+ /**
26
+ * Refuse a `connect` / `connectOrCreate` that would RE-PARENT a to-many
27
+ * child already owned by a different parent. Off by default.
28
+ *
29
+ * `connect: { id: 42 }` on a to-many relation means "make row 42 mine", and
30
+ * it does that unconditionally: if another parent owns row 42, the row is
31
+ * silently taken from them. A handler that forwards a client-supplied id
32
+ * into a nested connect therefore hands any caller a cross-tenant write
33
+ * primitive, and the only defense is a hand-rolled ownership check at every
34
+ * call site. With this on, connecting a child whose foreign key already
35
+ * points at a DIFFERENT parent raises E003; connecting an unowned child
36
+ * (null FK) or one this parent already owns still succeeds, so the
37
+ * legitimate uses are untouched.
38
+ *
39
+ * Applies to `hasMany` / `hasOne` only. A `belongsTo` connect points the row
40
+ * being written at a parent, which takes nothing from anyone, and a
41
+ * many-to-many connect adds a junction row rather than moving one.
42
+ */
43
+ scopedConnect?: boolean;
25
44
  tx: {
26
45
  table<T extends object>(name: string): {
27
46
  create(args: {