turbine-orm 0.19.2 → 0.21.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 (46) hide show
  1. package/README.md +82 -18
  2. package/dist/adapters/cockroachdb.js +4 -9
  3. package/dist/cjs/adapters/cockroachdb.js +4 -9
  4. package/dist/cjs/cli/index.js +6 -3
  5. package/dist/cjs/cli/observe-ui.js +12 -2
  6. package/dist/cjs/cli/observe.js +6 -7
  7. package/dist/cjs/cli/studio.js +6 -7
  8. package/dist/cjs/client.js +33 -20
  9. package/dist/cjs/dialect.js +116 -0
  10. package/dist/cjs/errors.js +19 -1
  11. package/dist/cjs/generate.js +4 -0
  12. package/dist/cjs/index.js +3 -2
  13. package/dist/cjs/introspect.js +20 -0
  14. package/dist/cjs/mssql.js +1338 -0
  15. package/dist/cjs/mysql.js +1052 -0
  16. package/dist/cjs/query/builder.js +293 -73
  17. package/dist/cjs/sqlite.js +849 -0
  18. package/dist/cjs/typed-sql.js +9 -7
  19. package/dist/cli/index.js +6 -3
  20. package/dist/cli/observe-ui.d.ts +1 -1
  21. package/dist/cli/observe-ui.js +12 -2
  22. package/dist/cli/observe.js +7 -8
  23. package/dist/cli/studio.js +7 -8
  24. package/dist/client.d.ts +23 -1
  25. package/dist/client.js +34 -21
  26. package/dist/dialect.d.ts +258 -1
  27. package/dist/dialect.js +83 -0
  28. package/dist/errors.d.ts +12 -0
  29. package/dist/errors.js +17 -0
  30. package/dist/generate.js +4 -0
  31. package/dist/index.d.ts +3 -3
  32. package/dist/index.js +1 -1
  33. package/dist/introspect.d.ts +18 -0
  34. package/dist/introspect.js +19 -0
  35. package/dist/mssql.d.ts +233 -0
  36. package/dist/mssql.js +1298 -0
  37. package/dist/mysql.d.ts +174 -0
  38. package/dist/mysql.js +1012 -0
  39. package/dist/query/builder.d.ts +97 -0
  40. package/dist/query/builder.js +294 -74
  41. package/dist/query/index.d.ts +1 -1
  42. package/dist/sqlite.d.ts +144 -0
  43. package/dist/sqlite.js +842 -0
  44. package/dist/typed-sql.d.ts +7 -5
  45. package/dist/typed-sql.js +9 -7
  46. package/package.json +30 -1
package/dist/mssql.js ADDED
@@ -0,0 +1,1298 @@
1
+ /**
2
+ * turbine-orm/mssql — Microsoft SQL Server engine (driver-injected, optional peer)
3
+ *
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**:
6
+ * `npm i turbine-orm` pulls nothing extra, and only consumers who
7
+ * `import 'turbine-orm/mssql'` install `mssql` themselves. The factory loads it
8
+ * through a dynamic `import('mssql')` so importing this module never crashes when
9
+ * `mssql` is absent for a consumer who does not use it. Turbine's root runtime
10
+ * dependency stays exactly `pg`.
11
+ *
12
+ * ## The three hard SQL Server realities this engine solves
13
+ *
14
+ * 1. **No `RETURNING`.** `INSERT`/`UPDATE`/`DELETE` cannot trail a `RETURNING`
15
+ * clause; SQL Server returns affected rows via `OUTPUT INSERTED.*` /
16
+ * `OUTPUT DELETED.*` injected MID-statement (between the column list and
17
+ * `VALUES`, or between `SET …` and `WHERE …`). `mssqlDialect.resultStrategy =
18
+ * 'output'`: the statement returns its own rows in ONE round-trip (executed
19
+ * exactly like the PostgreSQL `'returning'` path). Upsert becomes a
20
+ * `MERGE … WHEN MATCHED … WHEN NOT MATCHED … OUTPUT INSERTED.* ;` (a MERGE
21
+ * must end with `;`). This is the first shipped engine to exercise the
22
+ * Phase-0 `'output'` result strategy.
23
+ * 2. **No `json_agg`.** SQL Server has no JSON aggregate function; the idiomatic
24
+ * single-query nested-relation path is `(SELECT child cols … FOR JSON PATH)`,
25
+ * whose object shape is expressed by the child SELECT's column ALIASES rather
26
+ * than an explicit `JSON_OBJECT(...)`. That does NOT map onto
27
+ * `buildJsonObject`/`buildJsonArrayAgg`, so `mssqlDialect` defines the
28
+ * additive `Dialect.buildRelationSubquery` override (the sanctioned Phase-3
29
+ * seam extension) and owns the whole correlated subquery. To-many wraps
30
+ * `ISNULL((… FOR JSON PATH), '[]')` (FOR JSON over zero rows is NULL, not
31
+ * `[]`); to-one adds `, WITHOUT_ARRAY_WRAPPER` and lets NULL be the no-row
32
+ * value. Nested relations are embedded with `JSON_QUERY(...)` so they stay
33
+ * real JSON instead of being escaped as a string. `INCLUDE_NULL_VALUES`
34
+ * keeps NULL columns present (matching PostgreSQL `json_build_object`).
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
37
+ * when the query has none (`Dialect.buildLimitOffset`).
38
+ *
39
+ * ## Named `@pN` placeholders (no positional `?`)
40
+ *
41
+ * `mssqlDialect.paramPlaceholder = (i) => '@p' + i`. The driver shim binds via
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
44
+ * numbered `$N` gives. (SQL Server is naturally named-param friendly, sidestepping
45
+ * the positional-`?` mis-bind bug the SQLite/MySQL phases hit.)
46
+ *
47
+ * ## Capabilities & limits (vs PostgreSQL)
48
+ *
49
+ * - **Single query nested relations preserved** via `FOR JSON PATH` (SQL Server
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
52
+ * the row selection).
53
+ * - **Result strategy `'output'`:** create/update/delete/upsert return their rows
54
+ * from the same statement. `createMany` returns the inserted rows via
55
+ * `OUTPUT INSERTED.*` on the multi-row VALUES insert (≤ 1000 rows / 2100 params
56
+ * per statement — exceeding either throws a clear `ValidationError`; chunk
57
+ * yourself or use single `create`s).
58
+ * - **MERGE concurrency caveat:** `MERGE` is the upsert primitive; under high
59
+ * concurrency a `MERGE` can still race (it is NOT a substitute for a unique
60
+ * constraint). Keep the conflict target backed by a real `UNIQUE`/`PK` index,
61
+ * and rely on the typed `UniqueConstraintError` (2627/2601 → E008) for the
62
+ * loser of a race.
63
+ * - **Unsupported (throw `UnsupportedFeatureError`):** pgvector distance ops,
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 —
66
+ * throws rather than silently leaking context across pooled connections).
67
+ * - **Advisory-lock migration locking** is available in principle via
68
+ * `sp_getapplock`/`sp_releaseapplock` (`supportsAdvisoryLock = true`); the
69
+ * migrate CLI is still PostgreSQL-only, so this flag documents intent for a
70
+ * future adapter.
71
+ * - **Case-insensitive matching** uses `LOWER(col) LIKE LOWER(ref)` — deterministic
72
+ * regardless of the column's collation (note this can defeat an index unless a
73
+ * computed/persisted `LOWER()` index exists).
74
+ * - **bignum:** the shim applies the same safe-int policy Turbine uses for Postgres
75
+ * `int8` (number when it fits in 2^53, decimal string otherwise) WITHOUT mutating
76
+ * any global driver state. `DECIMAL`/`NUMERIC`/`MONEY` come back as strings;
77
+ * `BIT` binds/returns booleans.
78
+ * - **`DISTINCT ON`** is PostgreSQL-only and is not translated — avoid `distinct`
79
+ * on SQL Server.
80
+ *
81
+ * ## Example
82
+ *
83
+ * ```ts
84
+ * import { turbineMssql } from 'turbine-orm/mssql';
85
+ * import { SCHEMA } from './generated/turbine/metadata.js';
86
+ *
87
+ * const db = await turbineMssql('mssql://sa:Passw0rd!@localhost:1433/app', SCHEMA);
88
+ * const users = await db.users.findMany({ with: { posts: true }, limit: 10 });
89
+ * await db.disconnect();
90
+ * ```
91
+ */
92
+ import { TurbineClient } from './client.js';
93
+ import { postgresDialect, } from './dialect.js';
94
+ import { ConnectionError, RelationError, UnsupportedFeatureError, ValidationError } from './errors.js';
95
+ import { camelToSnake, isDateType, normalizeKeyColumns, singularize, snakeToCamel, } from './schema.js';
96
+ // ---------------------------------------------------------------------------
97
+ // SQL Server / connection limits
98
+ // ---------------------------------------------------------------------------
99
+ /** Max bound parameters per SQL Server statement. */
100
+ const MSSQL_MAX_PARAMS = 2100;
101
+ /** Max rows per multi-row INSERT … VALUES statement. */
102
+ const MSSQL_MAX_INSERT_ROWS = 1000;
103
+ /** Canonical key sort matching the query builder's `sortedEntries`/collect order. */
104
+ function sortedRelEntries(obj) {
105
+ return Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
106
+ }
107
+ // ---------------------------------------------------------------------------
108
+ // Value coercion (params in)
109
+ // ---------------------------------------------------------------------------
110
+ /**
111
+ * Coerce an arbitrary JS value into something `mssql` can bind. `BIT` accepts JS
112
+ * booleans directly; `undefined`/`null` → NULL; `bigint` follows the safe-int
113
+ * policy (number when it fits in 2^53, else a decimal string — no precision
114
+ * loss); `Date`/`Uint8Array` pass through; any remaining object/array → JSON text
115
+ * (matches how Turbine already pre-serializes JSON filter / `IN`-list params).
116
+ */
117
+ function toMssqlParam(value) {
118
+ if (value === undefined || value === null)
119
+ return null;
120
+ switch (typeof value) {
121
+ case 'boolean':
122
+ case 'number':
123
+ case 'string':
124
+ return value;
125
+ case 'bigint':
126
+ return value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(Number.MIN_SAFE_INTEGER)
127
+ ? Number(value)
128
+ : value.toString();
129
+ }
130
+ if (value instanceof Date)
131
+ return value;
132
+ if (value instanceof Uint8Array)
133
+ return value; // Buffer is a Uint8Array subclass
134
+ return JSON.stringify(value);
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // Result shaping + error translation
138
+ // ---------------------------------------------------------------------------
139
+ /** Shape an `mssql` result into a `pg.QueryResult`-like object. */
140
+ function shapeResult(result) {
141
+ const rows = result.recordset ?? [];
142
+ coerceBigIntColumns(rows, result.recordset?.columns);
143
+ const affected = result.rowsAffected?.[0];
144
+ return { rows, rowCount: typeof affected === 'number' ? affected : rows.length };
145
+ }
146
+ /**
147
+ * Coerce top-level `BIGINT` columns from strings to numbers, mirroring the
148
+ * Postgres `int8` policy (the TurbineClient registers a pg type parser that
149
+ * returns bigint as number within the JS safe-integer range). The `mssql`/tedious
150
+ * driver returns BIGINT as a string to avoid precision loss, so a BIGINT IDENTITY
151
+ * `id` would otherwise surface as `'1'` instead of `1`. Values outside the safe
152
+ * range are left as strings (same as the Postgres path). Nested relations are
153
+ * unaffected — `FOR JSON PATH` already renders BIGINT as a JSON number.
154
+ */
155
+ function coerceBigIntColumns(rows, columns) {
156
+ if (!columns || rows.length === 0)
157
+ return;
158
+ const bigIntCols = [];
159
+ for (const [name, col] of Object.entries(columns)) {
160
+ const t = col?.type;
161
+ const decl = String(t?.declaration ?? t?.name ?? '').toLowerCase();
162
+ if (decl === 'bigint')
163
+ bigIntCols.push(name);
164
+ }
165
+ if (bigIntCols.length === 0)
166
+ return;
167
+ for (const row of rows) {
168
+ for (const c of bigIntCols) {
169
+ const v = row[c];
170
+ if (typeof v === 'string' && /^-?\d+$/.test(v)) {
171
+ const n = Number(v);
172
+ if (Number.isSafeInteger(n))
173
+ row[c] = n;
174
+ }
175
+ }
176
+ }
177
+ }
178
+ const EMPTY_RESULT = { rows: [], rowCount: 0 };
179
+ /**
180
+ * Augment a `mssql` driver error with the Postgres-shaped `.code` (SQLSTATE) and
181
+ * detail fields that `wrapPgError` understands, so SQL Server constraint failures
182
+ * surface as the same typed Turbine errors as Postgres (E008/E009/E010/E011) and
183
+ * deadlock / lock-timeout become retryable (E012/E013). The original error (with
184
+ * its real message) is preserved as the wrapped error's `.cause` downstream.
185
+ * Returns the value unchanged when it is not a recognizable mssql error.
186
+ */
187
+ function augmentMssqlError(err) {
188
+ if (!err || typeof err !== 'object')
189
+ return err;
190
+ const e = err;
191
+ if (typeof e.number !== 'number')
192
+ return err;
193
+ const target = e;
194
+ const msg = e.message ?? '';
195
+ switch (e.number) {
196
+ // 2627 = unique constraint / PK violation; 2601 = unique index violation.
197
+ case 2627:
198
+ case 2601: {
199
+ target.code = '23505';
200
+ // "...constraint 'UQ_users_email'..." or "...index 'IX_users_email'..."
201
+ const m = /(?:constraint|index)\s+'([^']+)'/i.exec(msg) ?? /'([^']+)'/.exec(msg);
202
+ if (m?.[1])
203
+ target.constraint = m[1];
204
+ return err;
205
+ }
206
+ // 547 = FOREIGN KEY / CHECK constraint conflict (message distinguishes them).
207
+ case 547:
208
+ target.code = /CHECK constraint/i.test(msg) ? '23514' : '23503';
209
+ return err;
210
+ // 515 = cannot insert NULL into a non-nullable column.
211
+ case 515: {
212
+ target.code = '23502';
213
+ const c = /column '([^']+)'/i.exec(msg);
214
+ if (c?.[1])
215
+ target.column = c[1];
216
+ return err;
217
+ }
218
+ // 1205 = transaction chosen as deadlock victim (retryable → pg 40P01).
219
+ case 1205:
220
+ target.code = '40P01';
221
+ return err;
222
+ // 1222 = lock request time out (retryable → pg 40001 serialization failure).
223
+ case 1222:
224
+ target.code = '40001';
225
+ return err;
226
+ default:
227
+ return err;
228
+ }
229
+ }
230
+ function normalizeQueryArgs(arg, values) {
231
+ if (typeof arg === 'string')
232
+ return { text: arg, params: values ?? [] };
233
+ return { text: arg.text, params: arg.values ?? values ?? [] };
234
+ }
235
+ /** Bind positional `params[]` to `@p1`, `@p2`, … inputs and run the statement. */
236
+ async function runRequest(request, text, params) {
237
+ for (let i = 0; i < params.length; i++) {
238
+ request.input(`p${i + 1}`, toMssqlParam(params[i]));
239
+ }
240
+ try {
241
+ const result = await request.query(text);
242
+ return shapeResult(result);
243
+ }
244
+ catch (err) {
245
+ throw augmentMssqlError(err);
246
+ }
247
+ }
248
+ /** Map a SQL-standard isolation-level name to the mssql `ISOLATION_LEVEL` constant. */
249
+ function mapIsolationLevel(sqlNS, level) {
250
+ if (!level)
251
+ return undefined;
252
+ return sqlNS.ISOLATION_LEVEL[level.trim().toUpperCase().replace(/\s+/g, '_')];
253
+ }
254
+ /**
255
+ * A transaction-scoped {@link PgCompatPoolClient}. SQL Server transactions are
256
+ * driven through the `mssql` `Transaction` API, NOT raw `BEGIN`/`COMMIT` SQL (the
257
+ * driver owns the connection a transaction is pinned to). This client intercepts
258
+ * the dialect's transaction-control statements and routes them to that API, while
259
+ * regular queries run on a `Request` bound to the active transaction (or the pool
260
+ * before BEGIN). Mirrors the MySQL shim's `runOnConnection` intent.
261
+ */
262
+ class MssqlTxClient {
263
+ pool;
264
+ sqlNS;
265
+ tx = null;
266
+ pendingIsolation = null;
267
+ constructor(pool, sqlNS) {
268
+ this.pool = pool;
269
+ this.sqlNS = sqlNS;
270
+ }
271
+ // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
272
+ async query(text, values) {
273
+ const { text: rawSql, params } = normalizeQueryArgs(text, values);
274
+ const sql = rawSql.trim();
275
+ // RELEASE SAVEPOINT has no SQL Server equivalent (savepoints auto-persist until
276
+ // the outer commit/rollback) — releaseSavepointStatement() returns '' → no-op.
277
+ if (sql === '')
278
+ return EMPTY_RESULT;
279
+ // The dialect composes `SET TRANSACTION ISOLATION LEVEL …; BEGIN TRANSACTION`
280
+ // (SQL Server cannot take an inline isolation level on BEGIN). Split and run
281
+ // the parts in order — same pattern as the MySQL shim. Gated on the exact
282
+ // transaction-control prefix so no builder/user SQL is ever split.
283
+ if (/^SET TRANSACTION ISOLATION LEVEL /i.test(sql) && sql.includes('; ')) {
284
+ let last = EMPTY_RESULT;
285
+ for (const part of sql.split('; ')) {
286
+ const p = part.trim();
287
+ if (p)
288
+ last = await this.query(p, []);
289
+ }
290
+ return last;
291
+ }
292
+ if (/^SET TRANSACTION ISOLATION LEVEL /i.test(sql)) {
293
+ this.pendingIsolation = sql.replace(/^SET TRANSACTION ISOLATION LEVEL /i, '').trim();
294
+ return EMPTY_RESULT;
295
+ }
296
+ if (/^BEGIN TRAN(SACTION)?\b/i.test(sql)) {
297
+ this.tx = new this.sqlNS.Transaction(this.pool);
298
+ await this.tx.begin(mapIsolationLevel(this.sqlNS, this.pendingIsolation));
299
+ this.pendingIsolation = null;
300
+ return EMPTY_RESULT;
301
+ }
302
+ if (/^COMMIT\b/i.test(sql)) {
303
+ if (this.tx)
304
+ await this.tx.commit();
305
+ this.tx = null;
306
+ return EMPTY_RESULT;
307
+ }
308
+ // ROLLBACK TRANSACTION <name> = rollback to a savepoint; bare ROLLBACK = abort.
309
+ const spRollback = /^ROLLBACK TRAN(?:SACTION)?\s+(\S+)/i.exec(sql);
310
+ if (spRollback) {
311
+ const req = new this.sqlNS.Request(this.tx ?? this.pool);
312
+ await req.batch(`ROLLBACK TRANSACTION ${spRollback[1]}`);
313
+ return EMPTY_RESULT;
314
+ }
315
+ if (/^ROLLBACK\b/i.test(sql)) {
316
+ if (this.tx)
317
+ await this.tx.rollback();
318
+ this.tx = null;
319
+ return EMPTY_RESULT;
320
+ }
321
+ if (/^SAVE TRAN(SACTION)?\b/i.test(sql)) {
322
+ const req = new this.sqlNS.Request(this.tx ?? this.pool);
323
+ await req.batch(sql);
324
+ return EMPTY_RESULT;
325
+ }
326
+ const request = new this.sqlNS.Request(this.tx ?? this.pool);
327
+ return runRequest(request, rawSql, params);
328
+ }
329
+ release() {
330
+ // The transaction's connection is owned by the mssql Transaction object and is
331
+ // returned to the pool on commit/rollback; nothing to release here.
332
+ }
333
+ }
334
+ /**
335
+ * A {@link PgCompatPool} backed by an `mssql` ConnectionPool. Non-transaction
336
+ * queries run on a fresh pooled `Request`; `connect()` returns a
337
+ * {@link MssqlTxClient} that drives a single transaction through the mssql
338
+ * `Transaction` API so `BEGIN`/`COMMIT`/`ROLLBACK`/savepoints all run on the same
339
+ * physical connection.
340
+ */
341
+ export class MssqlPool {
342
+ /** The underlying `mssql` ConnectionPool — exposed as an escape hatch (seed / DDL / advanced ops). */
343
+ pool;
344
+ sqlNS;
345
+ closed = false;
346
+ constructor(pool, sqlNS) {
347
+ this.pool = pool;
348
+ this.sqlNS = sqlNS;
349
+ }
350
+ // biome-ignore lint/suspicious/noExplicitAny: pg-compat query is generic over the row shape.
351
+ async query(text, values) {
352
+ const { text: sql, params } = normalizeQueryArgs(text, values);
353
+ return runRequest(this.pool.request(), sql, params);
354
+ }
355
+ async connect() {
356
+ return new MssqlTxClient(this.pool, this.sqlNS);
357
+ }
358
+ async end() {
359
+ if (this.closed)
360
+ return;
361
+ this.closed = true;
362
+ await this.pool.close();
363
+ }
364
+ }
365
+ // ---------------------------------------------------------------------------
366
+ // Type mapping (SQL Server data type → TypeScript)
367
+ // ---------------------------------------------------------------------------
368
+ /**
369
+ * Map a SQL Server column type to a TypeScript type. `dialectType` is the
370
+ * `INFORMATION_SCHEMA.COLUMNS.DATA_TYPE` (lowercase, e.g. `bigint`, `nvarchar`,
371
+ * `datetime2`, `bit`, `uniqueidentifier`).
372
+ */
373
+ export function mssqlTypeToTs(dialectType, nullable) {
374
+ const t = dialectType.toLowerCase();
375
+ let base;
376
+ if (t === 'bit')
377
+ base = 'boolean';
378
+ else if (/^(tinyint|smallint|int|bigint)$/.test(t))
379
+ base = 'number';
380
+ else if (/^(decimal|numeric|money|smallmoney)$/.test(t))
381
+ base = 'string';
382
+ else if (/^(float|real)$/.test(t))
383
+ base = 'number';
384
+ else if (/^(datetime|datetime2|smalldatetime|date|datetimeoffset)$/.test(t))
385
+ base = 'Date';
386
+ else if (t === 'uniqueidentifier')
387
+ base = 'string';
388
+ else if (/(varbinary|binary|image|rowversion|timestamp)$/.test(t))
389
+ base = 'Uint8Array';
390
+ else if (/(char|text|xml|time)/.test(t))
391
+ base = 'string';
392
+ else
393
+ base = 'unknown';
394
+ return nullable ? `${base} | null` : base;
395
+ }
396
+ /** Is a SQL Server declared type a date/time type (so values coerce back to `Date`)? */
397
+ function isMssqlDateType(dialectType) {
398
+ const t = dialectType.toLowerCase();
399
+ return isDateType(t) || /^(datetime|datetime2|smalldatetime|date|datetimeoffset)$/.test(t);
400
+ }
401
+ /** Map a schema-builder (Postgres-flavored) column type to SQL Server DDL. */
402
+ function mssqlColumnType(type, maxLength) {
403
+ const t = type.toUpperCase();
404
+ if (/BIGSERIAL/.test(t))
405
+ return 'BIGINT IDENTITY(1,1)';
406
+ if (/SERIAL/.test(t))
407
+ return 'INT IDENTITY(1,1)';
408
+ if (/BIGINT|INT8/.test(t))
409
+ return 'BIGINT';
410
+ if (/SMALLINT|INT2/.test(t))
411
+ return 'SMALLINT';
412
+ if (/INTEGER|INT4|\bINT\b/.test(t))
413
+ return 'INT';
414
+ if (/BOOL/.test(t))
415
+ return 'BIT';
416
+ if (/DOUBLE|FLOAT8/.test(t))
417
+ return 'FLOAT';
418
+ if (/REAL|FLOAT4|FLOAT/.test(t))
419
+ return 'REAL';
420
+ if (/NUMERIC|DECIMAL|MONEY/.test(t))
421
+ return 'DECIMAL(38,18)';
422
+ if (/JSONB|JSON/.test(t))
423
+ return 'NVARCHAR(MAX)';
424
+ if (/UUID/.test(t))
425
+ return 'UNIQUEIDENTIFIER';
426
+ if (/TIMESTAMPTZ/.test(t))
427
+ return 'DATETIMEOFFSET';
428
+ if (/TIMESTAMP|DATETIME/.test(t))
429
+ return 'DATETIME2';
430
+ if (/\bDATE\b/.test(t))
431
+ return 'DATE';
432
+ if (/\bTIME\b/.test(t))
433
+ return 'TIME';
434
+ if (/BYTEA|BLOB/.test(t))
435
+ return 'VARBINARY(MAX)';
436
+ if (/VARCHAR/.test(t))
437
+ return maxLength != null ? `NVARCHAR(${maxLength})` : 'NVARCHAR(255)';
438
+ if (/CHAR/.test(t))
439
+ return maxLength != null ? `NCHAR(${maxLength})` : 'NCHAR(255)';
440
+ if (/TEXT|CLOB/.test(t))
441
+ return 'NVARCHAR(MAX)';
442
+ if (/ENUM/.test(t))
443
+ return 'NVARCHAR(255)';
444
+ return type;
445
+ }
446
+ // ---------------------------------------------------------------------------
447
+ // mssqlDialect — the full Dialect contract for SQL Server 2016+
448
+ // ---------------------------------------------------------------------------
449
+ /**
450
+ * SQL Server 2016+ implementation of the {@link Dialect} contract. Bracket
451
+ * identifier quoting (`[…]`), named `@pN` placeholders, the `FOR JSON PATH`
452
+ * nested-relation override (no `json_agg`), no `RETURNING`
453
+ * (`resultStrategy = 'output'` via `OUTPUT INSERTED.*` / `MERGE`), `OFFSET/FETCH`
454
+ * paging, and the Postgres-only capabilities disabled (vector / LISTEN-NOTIFY /
455
+ * RLS).
456
+ */
457
+ export const mssqlDialect = {
458
+ ...postgresDialect,
459
+ name: 'mssql',
460
+ // No RETURNING → the statement emits its rows via OUTPUT INSERTED.* in ONE
461
+ // round-trip (Phase-0 'output' strategy, executed like 'returning').
462
+ resultStrategy: 'output',
463
+ supportsReturning: false,
464
+ supportsILike: false,
465
+ supportsVector: false,
466
+ supportsListenNotify: false,
467
+ supportsRLS: false,
468
+ // sp_getapplock / sp_releaseapplock exist (used by a future migrate adapter).
469
+ supportsAdvisoryLock: true,
470
+ // FOR JSON over zero rows is NULL → coalesced in the relation override.
471
+ aggSupportsInlineOrderBy: false,
472
+ jsonPathSupport: 'limited',
473
+ emptyJsonArrayLiteral: "'[]'",
474
+ nullJsonLiteral: 'NULL',
475
+ // Named `@pN` placeholders bound by name via request.input('p'+i, value).
476
+ paramPlaceholder(index) {
477
+ return `@p${index}`;
478
+ },
479
+ // Bracket-quote identifiers; escape a literal ']' as ']]' per T-SQL rules.
480
+ quoteIdentifier(name) {
481
+ return `[${name.replace(/]/g, ']]')}]`;
482
+ },
483
+ // SQL Server aggregate casts: COUNT → INT, AVG/float → FLOAT.
484
+ castAggregate(expr, target) {
485
+ return `CAST(${expr} AS ${target === 'int' ? 'INT' : 'FLOAT'})`;
486
+ },
487
+ // No array params. OPENJSON expands a single JSON-array param into a row set,
488
+ // keeping ONE placeholder (so the SQL cache stays valid regardless of list
489
+ // length) and handling the empty list (OPENJSON('[]') → zero rows). SQL Server
490
+ // implicitly converts the nvarchar `value` to the column's type, so this works
491
+ // for numbers AND strings. OPENJSON requires SQL Server 2016+.
492
+ buildInClause(expr, paramRef, negated) {
493
+ return `${expr} ${negated ? 'NOT IN' : 'IN'} (SELECT [value] FROM OPENJSON(${paramRef}))`;
494
+ },
495
+ inClauseParam(values) {
496
+ return JSON.stringify(values ?? []);
497
+ },
498
+ // OUTPUT replaces RETURNING — injected mid-statement by the statement builders,
499
+ // never as a trailing clause.
500
+ buildReturningClause() {
501
+ return '';
502
+ },
503
+ buildInsertStatement(input) {
504
+ const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
505
+ return `INSERT INTO ${input.table} (${input.columns.join(', ')})${out} VALUES (${input.valuePlaceholders.join(', ')})`;
506
+ },
507
+ buildBulkInsertStatement(input) {
508
+ // No UNNEST in SQL Server — emit multi-row VALUES with flattened, named `@pN`
509
+ // placeholders. Enforce the engine's 1000-row / 2100-param statement limits.
510
+ const rowCount = input.rowValues.length;
511
+ const paramCount = input.rowValues.reduce((n, row) => n + row.length, 0);
512
+ if (rowCount > MSSQL_MAX_INSERT_ROWS) {
513
+ throw new ValidationError(`[turbine] SQL Server INSERT … VALUES is limited to ${MSSQL_MAX_INSERT_ROWS} rows per statement (got ${rowCount}). ` +
514
+ 'Chunk the data or use individual create() calls.');
515
+ }
516
+ if (paramCount > MSSQL_MAX_PARAMS) {
517
+ throw new ValidationError(`[turbine] SQL Server is limited to ${MSSQL_MAX_PARAMS} bound parameters per statement (got ${paramCount}). ` +
518
+ 'Reduce the batch size.');
519
+ }
520
+ let n = 0;
521
+ const placeholders = input.rowValues
522
+ .map((row) => `(${row.map(() => this.paramPlaceholder(++n)).join(', ')})`)
523
+ .join(', ');
524
+ const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
525
+ // skipDuplicates has no single-statement equivalent here; ignored (documented).
526
+ return {
527
+ sql: `INSERT INTO ${input.table} (${input.columns.join(', ')})${out} VALUES ${placeholders}`,
528
+ params: input.rowValues.flat(),
529
+ };
530
+ },
531
+ buildUpsertStatement(input) {
532
+ // MERGE is the SQL Server upsert. The MERGE statement MUST end with `;`.
533
+ // CONCURRENCY CAVEAT: MERGE is not a substitute for a UNIQUE/PK constraint —
534
+ // keep the conflict columns backed by one and rely on UniqueConstraintError
535
+ // (2627/2601 → E008) for a concurrent loser.
536
+ const on = input.conflictColumns.map((c) => `T.${c} = S.${c}`).join(' AND ');
537
+ const insertCols = input.insertColumns.join(', ');
538
+ const sourceVals = input.insertColumns.map((c) => `S.${c}`).join(', ');
539
+ const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
540
+ return (`MERGE INTO ${input.table} AS T ` +
541
+ `USING (VALUES (${input.valuePlaceholders.join(', ')})) AS S (${insertCols}) ` +
542
+ `ON (${on}) ` +
543
+ `WHEN MATCHED THEN UPDATE SET ${input.updateSetClauses.join(', ')} ` +
544
+ `WHEN NOT MATCHED THEN INSERT (${insertCols}) VALUES (${sourceVals})` +
545
+ `${out};`);
546
+ },
547
+ // UPDATE/DELETE inject OUTPUT mid-statement (between SET and WHERE / FROM and
548
+ // WHERE) — a trailing clause would be invalid T-SQL.
549
+ buildUpdateStatement(input) {
550
+ const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
551
+ return `UPDATE ${input.table} SET ${input.setClauses.join(', ')}${out}${input.whereSql}`;
552
+ },
553
+ buildDeleteStatement(input) {
554
+ const out = input.returning ? ` OUTPUT DELETED.${input.returning}` : '';
555
+ return `DELETE FROM ${input.table}${out}${input.whereSql}`;
556
+ },
557
+ // SQL Server has no LIMIT — emit OFFSET/FETCH, injecting a stable ORDER BY when
558
+ // the outer query has none (OFFSET/FETCH requires an ORDER BY).
559
+ buildLimitOffset(input) {
560
+ const { limitPlaceholder, offsetPlaceholder, hasOrderBy } = input;
561
+ if (limitPlaceholder === undefined && offsetPlaceholder === undefined)
562
+ return '';
563
+ const order = hasOrderBy ? '' : ' ORDER BY (SELECT NULL)';
564
+ const offset = offsetPlaceholder ?? '0';
565
+ let clause = `${order} OFFSET ${offset} ROWS`;
566
+ if (limitPlaceholder !== undefined)
567
+ clause += ` FETCH NEXT ${limitPlaceholder} ROWS ONLY`;
568
+ return clause;
569
+ },
570
+ // The crown jewel: nested relations via FOR JSON PATH (no json_agg). See the
571
+ // module docstring + RelationSubqueryContext for the param-push ordering
572
+ // contract (mirrors collectRelationSubqueryParams so the SQL cache stays valid).
573
+ buildRelationSubquery(ctx) {
574
+ return buildForJsonSubquery(this, ctx);
575
+ },
576
+ buildInsensitiveLike(column, paramRef) {
577
+ // Deterministic regardless of collation. NOTE: LOWER(col) can defeat an index
578
+ // unless a computed/persisted LOWER() index exists.
579
+ return `LOWER(${column}) LIKE LOWER(${paramRef})`;
580
+ },
581
+ // SQL Server has no JSON_CONTAINS. Emulate "the JSON array column contains the
582
+ // scalar value" via OPENJSON (documented `limited`: object-containment and deep
583
+ // paths are not supported — use a generated column + index for those).
584
+ buildJsonContains(column, paramRef) {
585
+ return `EXISTS (SELECT 1 FROM OPENJSON(${column}) WHERE [value] = ${paramRef})`;
586
+ },
587
+ buildJsonPathExtract(column, pathParamRef) {
588
+ return `JSON_VALUE(${column}, ${pathParamRef})`;
589
+ },
590
+ // ---- Type mapping -------------------------------------------------------
591
+ typeToTypeScript(dialectType, nullable) {
592
+ return mssqlTypeToTs(dialectType, nullable);
593
+ },
594
+ // No native array columns; bulk insert uses multi-row VALUES.
595
+ arrayType: undefined,
596
+ // ---- DDL ----------------------------------------------------------------
597
+ buildColumnType(input) {
598
+ return mssqlColumnType(input.type, input.maxLength);
599
+ },
600
+ buildColumnDefinition(input) {
601
+ const isSerial = /serial/i.test(input.type);
602
+ const parts = [input.name, this.buildColumnType(input)];
603
+ if (input.primaryKey)
604
+ parts.push('PRIMARY KEY');
605
+ else if (input.unique)
606
+ parts.push('UNIQUE');
607
+ // IDENTITY columns are implicitly NOT NULL; PK is implicitly NOT NULL too.
608
+ if (input.notNull && !isSerial && !input.primaryKey)
609
+ parts.push('NOT NULL');
610
+ if (input.defaultValue != null)
611
+ parts.push(`DEFAULT ${input.defaultValue}`);
612
+ if (input.references)
613
+ parts.push(`REFERENCES ${input.references.table}(${input.references.column})`);
614
+ return parts.join(' ');
615
+ },
616
+ buildCreateIndexStatement(input) {
617
+ return `CREATE INDEX ${input.name} ON ${input.table}(${input.columns.join(', ')});`;
618
+ },
619
+ buildCreateTableStatement(input) {
620
+ const body = input.definitions.map((d) => ` ${d}`).join(',\n');
621
+ return `CREATE TABLE ${input.table} (\n${body}\n);`;
622
+ },
623
+ // ---- Migration tracking -------------------------------------------------
624
+ // SelectApplied / UpdateChecksum / DeleteApplied inherit from postgresDialect:
625
+ // they call `this.paramPlaceholder(n)` (→ `@pN`) and emit standard SQL valid on
626
+ // SQL Server. The tracking-table DDL and the conflict-free INSERT need T-SQL forms.
627
+ buildMigrationTrackingTable(table) {
628
+ return `
629
+ IF OBJECT_ID(N'${table}', N'U') IS NULL
630
+ CREATE TABLE ${table} (
631
+ id BIGINT IDENTITY(1,1) PRIMARY KEY,
632
+ name NVARCHAR(255) NOT NULL UNIQUE,
633
+ checksum NVARCHAR(255) NOT NULL,
634
+ applied_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
635
+ );
636
+ `;
637
+ },
638
+ buildMigrationInsertApplied(table) {
639
+ // INSERT-if-absent (SQL Server has no ON CONFLICT DO NOTHING).
640
+ return (`IF NOT EXISTS (SELECT 1 FROM ${table} WHERE name = ${this.paramPlaceholder(1)}) ` +
641
+ `INSERT INTO ${table} (name, checksum) VALUES (${this.paramPlaceholder(1)}, ${this.paramPlaceholder(2)})`);
642
+ },
643
+ // ---- Transaction control ------------------------------------------------
644
+ beginStatement(isolationLevel) {
645
+ // SQL Server cannot take an inline isolation level on BEGIN TRANSACTION. The
646
+ // driver shim (MssqlTxClient) splits this exact compound and runs the parts in
647
+ // order on the same transaction.
648
+ return isolationLevel
649
+ ? `SET TRANSACTION ISOLATION LEVEL ${isolationLevel}; BEGIN TRANSACTION`
650
+ : 'BEGIN TRANSACTION';
651
+ },
652
+ commitStatement() {
653
+ return 'COMMIT TRANSACTION';
654
+ },
655
+ rollbackStatement() {
656
+ return 'ROLLBACK TRANSACTION';
657
+ },
658
+ savepointStatement(name) {
659
+ return `SAVE TRANSACTION ${name}`;
660
+ },
661
+ releaseSavepointStatement() {
662
+ // SQL Server has no RELEASE SAVEPOINT — savepoints persist until the outer
663
+ // commit/rollback. The shim treats the empty statement as a no-op.
664
+ return '';
665
+ },
666
+ rollbackToSavepointStatement(name) {
667
+ return `ROLLBACK TRANSACTION ${name}`;
668
+ },
669
+ buildSetSessionConfig() {
670
+ // Never reached: supportsRLS=false makes $transaction throw before calling
671
+ // this. Guarded here too so a misuse fails loudly. SQL Server's
672
+ // sp_set_session_context is CONNECTION-scoped (not transaction-local like
673
+ // Postgres set_config), so wiring it safely behind pooling is future work.
674
+ throw new UnsupportedFeatureError('sessionContext (RLS session GUCs)', 'mssql', 'SQL Server sp_set_session_context is connection-scoped, not transaction-local; not wired.');
675
+ },
676
+ // ---- Streaming ----------------------------------------------------------
677
+ /**
678
+ * The {@link StreamableConnection} seam only exposes `query()`, not the `mssql`
679
+ * `request.stream` event API, so this fetches once and yields the rows in
680
+ * `batchSize` chunks. True server-side streaming would require direct `mssql`
681
+ * access (documented limitation).
682
+ */
683
+ async *openStream(connection, sql, params, batchSize) {
684
+ const result = await connection.query(sql, params);
685
+ for (let i = 0; i < result.rows.length; i += batchSize) {
686
+ yield result.rows.slice(i, i + batchSize);
687
+ }
688
+ },
689
+ // ---- Introspection ------------------------------------------------------
690
+ introspector: {
691
+ async introspect(options) {
692
+ return introspectMssql(options);
693
+ },
694
+ },
695
+ };
696
+ // ---------------------------------------------------------------------------
697
+ // FOR JSON PATH relation-subquery generator (the buildRelationSubquery override)
698
+ // ---------------------------------------------------------------------------
699
+ /**
700
+ * Generate a correlated `FOR JSON PATH` subquery for one relation. SQL Server has
701
+ * no `json_agg`, so the object shape is expressed by the child SELECT's column
702
+ * ALIASES; to-many wraps `ISNULL((… FOR JSON PATH), '[]')` and to-one adds
703
+ * `, WITHOUT_ARRAY_WRAPPER`. `INCLUDE_NULL_VALUES` keeps NULL columns present so
704
+ * the parsed tree matches PostgreSQL's `json_build_object`. Nested relations are
705
+ * embedded with `JSON_QUERY(...)` so they stay real JSON instead of being escaped
706
+ * as a string.
707
+ *
708
+ * **Param-push order** strictly mirrors `collectRelationSubqueryParams`:
709
+ * - manyToMany / to-many with limit|orderBy: `where` → `limit` → nested;
710
+ * - to-one / unordered-unlimited to-many: nested → `where` (no limit).
711
+ */
712
+ function buildForJsonSubquery(dialect, ctx) {
713
+ const { relDef, spec, params, parentRef, alias, targetTable, targetMeta, targetColumns, depth, path } = ctx;
714
+ const q = (name) => dialect.quoteIdentifier(name);
715
+ const qTarget = q(targetTable);
716
+ const qParent = q(parentRef);
717
+ const orderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
718
+ const hasOrder = orderEntries.length > 0;
719
+ const hasLimit = spec !== true && spec.limit !== undefined;
720
+ /** `<alias>.<col> AS [<field>]` selection for the FOR JSON object keys. */
721
+ const colSelect = (a) => targetColumns.map((col) => {
722
+ const field = targetMeta.reverseColumnMap[col] ?? snakeToCamel(col);
723
+ return `${a}.${q(col)} AS ${q(field)}`;
724
+ });
725
+ /** Build nested relations as `JSON_QUERY((<subquery>)) AS [<name>]` columns (pushes their params). */
726
+ const buildNested = (parentAlias) => {
727
+ if (spec === true || !spec.with)
728
+ return [];
729
+ const cols = [];
730
+ for (const [nestedRelName, nestedSpec] of sortedRelEntries(spec.with)) {
731
+ const nestedRelDef = targetMeta.relations[nestedRelName];
732
+ if (!nestedRelDef) {
733
+ throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
734
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
735
+ }
736
+ const sub = ctx.recurse(nestedRelDef, nestedSpec, parentAlias, depth + 1, [...path, relDef.name]);
737
+ cols.push(`JSON_QUERY((${sub})) AS ${q(nestedRelName)}`);
738
+ }
739
+ return cols;
740
+ };
741
+ /** ORDER BY + OFFSET/FETCH paging clause for a to-many FOR JSON subquery. */
742
+ const buildPaging = (a, limitPlaceholder) => {
743
+ if (hasOrder) {
744
+ const orderBy = orderEntries
745
+ .map(([k, dir]) => {
746
+ const col = camelToSnake(k);
747
+ if (!targetMeta.allColumns.includes(col)) {
748
+ throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
749
+ }
750
+ const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
751
+ return `${a}.${q(col)} ${safeDir}`;
752
+ })
753
+ .join(', ');
754
+ // FOR JSON permits ORDER BY; OFFSET/FETCH is only needed to apply a LIMIT.
755
+ return limitPlaceholder !== undefined
756
+ ? ` ORDER BY ${orderBy} OFFSET 0 ROWS FETCH NEXT ${limitPlaceholder} ROWS ONLY`
757
+ : ` ORDER BY ${orderBy}`;
758
+ }
759
+ if (limitPlaceholder !== undefined) {
760
+ return ` ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT ${limitPlaceholder} ROWS ONLY`;
761
+ }
762
+ return '';
763
+ };
764
+ // ----- manyToMany: JOIN target through the junction table -----------------
765
+ if (relDef.type === 'manyToMany') {
766
+ return buildForJsonManyToMany(dialect, ctx, { colSelect, buildNested, buildPaging, hasLimit });
767
+ }
768
+ const isToOne = relDef.type === 'belongsTo' || relDef.type === 'hasOne';
769
+ const correlation = isToOne
770
+ ? dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
771
+ : dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
772
+ // ----- to-one (belongsTo / hasOne): single object, no paging --------------
773
+ if (isToOne) {
774
+ // Non-wrap order: nested → where (matches collectRelationSubqueryParams).
775
+ const nestedCols = buildNested(alias);
776
+ const extra = ctx.buildWhere(alias);
777
+ const where = extra ? `${correlation} AND ${extra}` : correlation;
778
+ const cols = [...colSelect(alias), ...nestedCols].join(', ');
779
+ // TOP 1 guarantees a single object for WITHOUT_ARRAY_WRAPPER; NULL when no row.
780
+ return `SELECT TOP 1 ${cols} FROM ${qTarget} ${alias} WHERE ${where} FOR JSON PATH, WITHOUT_ARRAY_WRAPPER, INCLUDE_NULL_VALUES`;
781
+ }
782
+ // ----- hasMany ------------------------------------------------------------
783
+ const willWrap = hasLimit || hasOrder;
784
+ let nestedCols;
785
+ let extra;
786
+ let limitPlaceholder;
787
+ if (willWrap) {
788
+ // Wrap order: where → limit → nested.
789
+ extra = ctx.buildWhere(alias);
790
+ if (hasLimit) {
791
+ params.push(Number(spec.limit));
792
+ limitPlaceholder = dialect.paramPlaceholder(params.length);
793
+ }
794
+ nestedCols = buildNested(alias);
795
+ }
796
+ else {
797
+ // Non-wrap order: nested → where (no limit param).
798
+ nestedCols = buildNested(alias);
799
+ extra = ctx.buildWhere(alias);
800
+ }
801
+ const where = extra ? `${correlation} AND ${extra}` : correlation;
802
+ const cols = [...colSelect(alias), ...nestedCols].join(', ');
803
+ const paging = buildPaging(alias, limitPlaceholder);
804
+ return `SELECT ISNULL((SELECT ${cols} FROM ${qTarget} ${alias} WHERE ${where}${paging} FOR JSON PATH, INCLUDE_NULL_VALUES), '[]')`;
805
+ }
806
+ /** `FOR JSON PATH` subquery for a manyToMany relation (JOIN target through junction). */
807
+ function buildForJsonManyToMany(dialect, ctx, h) {
808
+ const { relDef, spec, params, parentRef, alias, targetTable, targetMeta } = ctx;
809
+ const q = (name) => dialect.quoteIdentifier(name);
810
+ if (!relDef.through) {
811
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
812
+ }
813
+ const qTarget = q(targetTable);
814
+ const qJunction = q(relDef.through.table);
815
+ const qParent = q(parentRef);
816
+ const jalias = `${alias}j`;
817
+ // JOIN: junction.targetKey = target.<PK>.
818
+ const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
819
+ if (targetMeta.primaryKey.length === 0) {
820
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
821
+ 'cannot determine the join column.');
822
+ }
823
+ const targetPk = targetMeta.primaryKey;
824
+ if (targetKeys.length !== targetPk.length) {
825
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.targetKey has ${targetKeys.length} column(s) ` +
826
+ `but target "${targetTable}" primary key has ${targetPk.length}.`);
827
+ }
828
+ const joinOn = targetKeys.map((jcol, i) => `${jalias}.${q(jcol)} = ${alias}.${q(targetPk[i])}`).join(' AND ');
829
+ // Correlation: junction.sourceKey = parent.<referenceKey>.
830
+ const sourceKeys = normalizeKeyColumns(relDef.through.sourceKey);
831
+ const refKeys = normalizeKeyColumns(relDef.referenceKey);
832
+ if (sourceKeys.length !== refKeys.length) {
833
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.sourceKey has ${sourceKeys.length} column(s) ` +
834
+ `but referenceKey has ${refKeys.length}.`);
835
+ }
836
+ const correlation = sourceKeys.map((jcol, i) => `${jalias}.${q(jcol)} = ${qParent}.${q(refKeys[i])}`).join(' AND ');
837
+ // Param order mirrors collectRelationSubqueryParams: where → limit → nested.
838
+ const extra = ctx.buildWhere(alias);
839
+ let limitPlaceholder;
840
+ if (h.hasLimit) {
841
+ params.push(Number(spec.limit));
842
+ limitPlaceholder = dialect.paramPlaceholder(params.length);
843
+ }
844
+ const nestedCols = h.buildNested(alias);
845
+ const where = extra ? `${correlation} AND ${extra}` : correlation;
846
+ const cols = [...h.colSelect(alias), ...nestedCols].join(', ');
847
+ const paging = h.buildPaging(alias, limitPlaceholder);
848
+ return (`SELECT ISNULL((SELECT ${cols} FROM ${qTarget} ${alias} ` +
849
+ `JOIN ${qJunction} ${jalias} ON ${joinOn} WHERE ${where}${paging} ` +
850
+ `FOR JSON PATH, INCLUDE_NULL_VALUES), '[]')`);
851
+ }
852
+ const num = (v) => (typeof v === 'string' ? Number(v) : (v ?? 0));
853
+ /**
854
+ * Derive belongsTo + hasMany relations (and conservatively-detected manyToMany
855
+ * junctions) from a flat foreign-key list. Mirrors the PostgreSQL / MySQL / SQLite
856
+ * introspectors so the produced {@link SchemaMetadata} has an identical relation
857
+ * shape across engines.
858
+ */
859
+ function buildRelationsFromForeignKeys(tableNames, foreignKeys, pkByTable, columnsByTable) {
860
+ const tableSet = new Set(tableNames);
861
+ const fkCounts = new Map();
862
+ for (const fk of foreignKeys) {
863
+ const key = `${fk.sourceTable}->${fk.targetTable}`;
864
+ fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
865
+ }
866
+ const relationsByTable = new Map();
867
+ for (const fk of foreignKeys) {
868
+ if (!tableSet.has(fk.targetTable))
869
+ continue;
870
+ const needsDisambiguation = (fkCounts.get(`${fk.sourceTable}->${fk.targetTable}`) ?? 0) > 1;
871
+ const foreignKey = fk.sourceColumns.length === 1 ? fk.sourceColumns[0] : fk.sourceColumns;
872
+ const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
873
+ const belongsToName = needsDisambiguation && fk.sourceColumns.length === 1
874
+ ? snakeToCamel(fk.sourceColumns[0].replace(/_id$/, ''))
875
+ : singularize(snakeToCamel(fk.targetTable));
876
+ if (!relationsByTable.has(fk.sourceTable))
877
+ relationsByTable.set(fk.sourceTable, {});
878
+ relationsByTable.get(fk.sourceTable)[belongsToName] = {
879
+ type: 'belongsTo',
880
+ name: belongsToName,
881
+ from: fk.sourceTable,
882
+ to: fk.targetTable,
883
+ foreignKey,
884
+ referenceKey,
885
+ };
886
+ const hasManyName = needsDisambiguation && fk.sourceColumns.length === 1
887
+ ? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
888
+ : snakeToCamel(fk.sourceTable);
889
+ if (!relationsByTable.has(fk.targetTable))
890
+ relationsByTable.set(fk.targetTable, {});
891
+ relationsByTable.get(fk.targetTable)[hasManyName] = {
892
+ type: 'hasMany',
893
+ name: hasManyName,
894
+ from: fk.targetTable,
895
+ to: fk.sourceTable,
896
+ foreignKey,
897
+ referenceKey,
898
+ };
899
+ }
900
+ // Conservative many-to-many auto-detection (additive): a table J is a pure
901
+ // junction iff PK is exactly two columns, exactly two single-column FKs whose
902
+ // source columns ARE the PK, two distinct target tables, and no payload columns.
903
+ for (const tableName of tableNames) {
904
+ const pk = pkByTable.get(tableName) ?? [];
905
+ if (pk.length !== 2)
906
+ continue;
907
+ const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
908
+ if (tableFks.length !== 2)
909
+ continue;
910
+ if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
911
+ continue;
912
+ const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
913
+ const pkSet = new Set(pk);
914
+ if (!fkCols.every((c) => pkSet.has(c)))
915
+ continue;
916
+ if (new Set(fkCols).size !== 2)
917
+ continue;
918
+ const [fkA, fkB] = tableFks;
919
+ if (fkA.targetTable === fkB.targetTable)
920
+ continue;
921
+ const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
922
+ if (jCols.length !== 2)
923
+ continue;
924
+ const addM2M = (self, other) => {
925
+ const sourceTbl = self.targetTable;
926
+ const targetTbl = other.targetTable;
927
+ const relName = snakeToCamel(targetTbl);
928
+ if (!relationsByTable.has(sourceTbl))
929
+ relationsByTable.set(sourceTbl, {});
930
+ const existing = relationsByTable.get(sourceTbl);
931
+ if (existing[relName])
932
+ return;
933
+ existing[relName] = {
934
+ type: 'manyToMany',
935
+ name: relName,
936
+ from: sourceTbl,
937
+ to: targetTbl,
938
+ referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
939
+ foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
940
+ through: {
941
+ table: tableName,
942
+ sourceKey: self.sourceColumns[0],
943
+ targetKey: other.sourceColumns[0],
944
+ },
945
+ };
946
+ };
947
+ addM2M(fkA, fkB);
948
+ addM2M(fkB, fkA);
949
+ }
950
+ return relationsByTable;
951
+ }
952
+ /**
953
+ * Introspect a SQL Server database into the same {@link SchemaMetadata} shape the
954
+ * Postgres catalog introspector produces, using a caller-supplied query executor
955
+ * (so tests can dogfood an already-open `mssql` pool/connection). Reads
956
+ * `INFORMATION_SCHEMA.*` plus `sys.identity_columns` / `sys.foreign_keys` /
957
+ * `sys.indexes`.
958
+ *
959
+ * @param exec Runs a parameterized (`@p1`, `@p2`, …) query and returns rows.
960
+ * @param schema The SQL Server schema to introspect (default `dbo`).
961
+ */
962
+ export async function introspectMssqlWith(exec, schema = 'dbo', options = {}) {
963
+ // ----- Tables -----
964
+ let tableNames = (await exec("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @p1 AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME", [schema])).map((r) => String(r.TABLE_NAME));
965
+ if (options.include?.length) {
966
+ const inc = new Set(options.include);
967
+ tableNames = tableNames.filter((t) => inc.has(t));
968
+ }
969
+ if (options.exclude?.length) {
970
+ const exc = new Set(options.exclude);
971
+ tableNames = tableNames.filter((t) => !exc.has(t));
972
+ }
973
+ const tableSet = new Set(tableNames);
974
+ // ----- Identity columns (mark hasDefault) -----
975
+ const identityRows = await exec(`SELECT t.name AS TABLE_NAME, c.name AS COLUMN_NAME
976
+ FROM sys.identity_columns c
977
+ JOIN sys.tables t ON t.object_id = c.object_id
978
+ JOIN sys.schemas s ON s.schema_id = t.schema_id
979
+ WHERE s.name = @p1`, [schema]);
980
+ const identityCols = new Set(identityRows.map((r) => `${r.TABLE_NAME}.${r.COLUMN_NAME}`));
981
+ // ----- Columns -----
982
+ const columnRows = await exec(`SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, CHARACTER_MAXIMUM_LENGTH
983
+ FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @p1 ORDER BY TABLE_NAME, ORDINAL_POSITION`, [schema]);
984
+ // ----- Primary keys (ordered) -----
985
+ const pkRows = await exec(`SELECT kcu.TABLE_NAME, kcu.COLUMN_NAME, kcu.ORDINAL_POSITION
986
+ FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
987
+ JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
988
+ ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA
989
+ WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' AND tc.TABLE_SCHEMA = @p1
990
+ ORDER BY kcu.TABLE_NAME, kcu.ORDINAL_POSITION`, [schema]);
991
+ const pkByTable = new Map();
992
+ for (const r of pkRows) {
993
+ const t = String(r.TABLE_NAME);
994
+ if (!tableSet.has(t))
995
+ continue;
996
+ if (!pkByTable.has(t))
997
+ pkByTable.set(t, []);
998
+ pkByTable.get(t).push(String(r.COLUMN_NAME));
999
+ }
1000
+ const pkColSet = new Set();
1001
+ for (const [t, cols] of pkByTable)
1002
+ for (const c of cols)
1003
+ pkColSet.add(`${t}.${c}`);
1004
+ // ----- Assemble columns -----
1005
+ const columnsByTable = new Map();
1006
+ for (const c of columnRows) {
1007
+ const tableName = String(c.TABLE_NAME);
1008
+ if (!tableSet.has(tableName))
1009
+ continue;
1010
+ const colName = String(c.COLUMN_NAME);
1011
+ const dataType = String(c.DATA_TYPE).toLowerCase();
1012
+ const isPk = pkColSet.has(`${tableName}.${colName}`);
1013
+ const nullable = String(c.IS_NULLABLE).toUpperCase() === 'YES' && !isPk;
1014
+ const maxLen = c.CHARACTER_MAXIMUM_LENGTH != null ? num(c.CHARACTER_MAXIMUM_LENGTH) : undefined;
1015
+ const col = {
1016
+ name: colName,
1017
+ field: snakeToCamel(colName),
1018
+ dialectType: dataType,
1019
+ pgType: dataType,
1020
+ tsType: mssqlTypeToTs(dataType, nullable),
1021
+ nullable,
1022
+ hasDefault: c.COLUMN_DEFAULT !== null || identityCols.has(`${tableName}.${colName}`),
1023
+ isArray: false,
1024
+ arrayType: undefined,
1025
+ pgArrayType: 'text[]',
1026
+ };
1027
+ if (maxLen !== undefined && maxLen >= 0)
1028
+ col.maxLength = maxLen;
1029
+ if (!columnsByTable.has(tableName))
1030
+ columnsByTable.set(tableName, []);
1031
+ columnsByTable.get(tableName).push(col);
1032
+ }
1033
+ // ----- Foreign keys (grouped by constraint for composite FKs) -----
1034
+ const fkRows = await exec(`SELECT fk.name AS CONSTRAINT_NAME,
1035
+ OBJECT_NAME(fkc.parent_object_id) AS TABLE_NAME,
1036
+ cps.name AS COLUMN_NAME,
1037
+ OBJECT_NAME(fkc.referenced_object_id) AS REFERENCED_TABLE_NAME,
1038
+ cpt.name AS REFERENCED_COLUMN_NAME,
1039
+ fkc.constraint_column_id AS ORDINAL_POSITION
1040
+ FROM sys.foreign_keys fk
1041
+ JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id
1042
+ JOIN sys.columns cps ON cps.object_id = fkc.parent_object_id AND cps.column_id = fkc.parent_column_id
1043
+ JOIN sys.columns cpt ON cpt.object_id = fkc.referenced_object_id AND cpt.column_id = fkc.referenced_column_id
1044
+ JOIN sys.schemas s ON s.schema_id = fk.schema_id
1045
+ WHERE s.name = @p1
1046
+ ORDER BY fk.name, fkc.constraint_column_id`, [schema]);
1047
+ const fkByConstraint = new Map();
1048
+ for (const r of fkRows) {
1049
+ const sourceTable = String(r.TABLE_NAME);
1050
+ const targetTable = String(r.REFERENCED_TABLE_NAME);
1051
+ if (!tableSet.has(sourceTable))
1052
+ continue;
1053
+ const key = String(r.CONSTRAINT_NAME);
1054
+ let entry = fkByConstraint.get(key);
1055
+ if (!entry) {
1056
+ entry = { sourceTable, sourceColumns: [], targetTable, targetColumns: [], constraintName: key };
1057
+ fkByConstraint.set(key, entry);
1058
+ }
1059
+ entry.sourceColumns.push(String(r.COLUMN_NAME));
1060
+ entry.targetColumns.push(String(r.REFERENCED_COLUMN_NAME));
1061
+ }
1062
+ const foreignKeys = [...fkByConstraint.values()].filter((fk) => tableSet.has(fk.targetTable));
1063
+ // ----- Indexes + unique constraints -----
1064
+ const idxRows = await exec(`SELECT t.name AS TABLE_NAME, i.name AS INDEX_NAME, i.is_unique AS IS_UNIQUE,
1065
+ c.name AS COLUMN_NAME, ic.key_ordinal AS SEQ
1066
+ FROM sys.indexes i
1067
+ JOIN sys.tables t ON t.object_id = i.object_id
1068
+ JOIN sys.schemas s ON s.schema_id = t.schema_id
1069
+ JOIN sys.index_columns ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
1070
+ JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
1071
+ WHERE s.name = @p1 AND i.is_primary_key = 0 AND ic.is_included_column = 0 AND i.name IS NOT NULL
1072
+ ORDER BY t.name, i.name, ic.key_ordinal`, [schema]);
1073
+ const indexGroups = new Map();
1074
+ for (const r of idxRows) {
1075
+ const t = String(r.TABLE_NAME);
1076
+ if (!tableSet.has(t))
1077
+ continue;
1078
+ const name = String(r.INDEX_NAME);
1079
+ const key = `${t}.${name}`;
1080
+ let g = indexGroups.get(key);
1081
+ if (!g) {
1082
+ g = { table: t, name, unique: num(r.IS_UNIQUE) === 1, columns: [] };
1083
+ indexGroups.set(key, g);
1084
+ }
1085
+ g.columns.push(String(r.COLUMN_NAME));
1086
+ }
1087
+ const indexesByTable = new Map();
1088
+ const uniqueByTable = new Map();
1089
+ for (const g of indexGroups.values()) {
1090
+ if (!indexesByTable.has(g.table))
1091
+ indexesByTable.set(g.table, []);
1092
+ indexesByTable.get(g.table).push({
1093
+ name: g.name,
1094
+ columns: g.columns,
1095
+ unique: g.unique,
1096
+ definition: `${g.unique ? 'UNIQUE ' : ''}INDEX ${g.name} ON ${g.table}(${g.columns.join(', ')})`,
1097
+ });
1098
+ if (g.unique && g.columns.length > 0) {
1099
+ if (!uniqueByTable.has(g.table))
1100
+ uniqueByTable.set(g.table, []);
1101
+ uniqueByTable.get(g.table).push(g.columns);
1102
+ }
1103
+ }
1104
+ // ----- Relations -----
1105
+ const relationsByTable = buildRelationsFromForeignKeys(tableNames, foreignKeys, pkByTable, columnsByTable);
1106
+ // ----- Assemble TableMetadata -----
1107
+ const tables = {};
1108
+ for (const tableName of tableNames) {
1109
+ const columns = columnsByTable.get(tableName) ?? [];
1110
+ const columnMap = {};
1111
+ const reverseColumnMap = {};
1112
+ const dateColumns = new Set();
1113
+ const dialectTypes = {};
1114
+ const pgTypes = {};
1115
+ const allColumns = [];
1116
+ for (const col of columns) {
1117
+ columnMap[col.field] = col.name;
1118
+ reverseColumnMap[col.name] = col.field;
1119
+ allColumns.push(col.name);
1120
+ dialectTypes[col.name] = col.dialectType ?? col.pgType;
1121
+ pgTypes[col.name] = col.pgType;
1122
+ if (isMssqlDateType(col.dialectType ?? col.pgType))
1123
+ dateColumns.add(col.name);
1124
+ }
1125
+ tables[tableName] = {
1126
+ name: tableName,
1127
+ columns,
1128
+ columnMap,
1129
+ reverseColumnMap,
1130
+ dateColumns,
1131
+ dialectTypes,
1132
+ pgTypes,
1133
+ allColumns,
1134
+ primaryKey: pkByTable.get(tableName) ?? [],
1135
+ uniqueColumns: uniqueByTable.get(tableName) ?? [],
1136
+ relations: relationsByTable.get(tableName) ?? {},
1137
+ indexes: indexesByTable.get(tableName) ?? [],
1138
+ };
1139
+ }
1140
+ // SQL Server has no first-class enum type — enums are CHECK constraints; left empty.
1141
+ return { tables, enums: {} };
1142
+ }
1143
+ /**
1144
+ * Open a short-lived `mssql` connection from `options.connectionString`, introspect
1145
+ * the database (schema = `options.schema` or `dbo`), and close it. Wraps
1146
+ * {@link introspectMssqlWith} for the {@link DialectIntrospector} seam used by
1147
+ * `introspect()` / `npx turbine generate`.
1148
+ */
1149
+ export async function introspectMssql(options) {
1150
+ const sqlNS = await loadMssql();
1151
+ const config = parseMssqlConfig(options.connectionString);
1152
+ const pool = await new sqlNS.ConnectionPool(config).connect();
1153
+ try {
1154
+ const mp = new MssqlPool(pool, sqlNS);
1155
+ const exec = async (sql, params) => (await mp.query(sql, params)).rows;
1156
+ return introspectMssqlWith(exec, options.schema ?? 'dbo', {
1157
+ include: options.include,
1158
+ exclude: options.exclude,
1159
+ });
1160
+ }
1161
+ finally {
1162
+ await pool.close();
1163
+ }
1164
+ }
1165
+ /** Parse a `mssql://user:pass@host:port/db` connection string into mssql config. */
1166
+ function parseMssqlConfig(connectionString) {
1167
+ try {
1168
+ const u = new URL(connectionString);
1169
+ const config = {
1170
+ server: u.hostname || 'localhost',
1171
+ port: u.port ? Number(u.port) : 1433,
1172
+ // Sensible local/dev defaults; pass an explicit config object to override.
1173
+ options: { encrypt: false, trustServerCertificate: true },
1174
+ };
1175
+ if (u.username)
1176
+ config.user = decodeURIComponent(u.username);
1177
+ if (u.password)
1178
+ config.password = decodeURIComponent(u.password);
1179
+ const db = u.pathname.replace(/^\//, '');
1180
+ if (db)
1181
+ config.database = decodeURIComponent(db);
1182
+ return config;
1183
+ }
1184
+ catch {
1185
+ throw new ConnectionError(`[turbine] Invalid MSSQL connection string: "${connectionString}"`);
1186
+ }
1187
+ }
1188
+ /**
1189
+ * Dynamically load the `mssql` module. Kept out of the static import graph so
1190
+ * `import 'turbine-orm/mssql'` never throws when `mssql` is absent for a consumer
1191
+ * that does not use the factory.
1192
+ */
1193
+ async function loadMssql() {
1194
+ let mod;
1195
+ try {
1196
+ // `mssql` ships no bundled type declarations (it needs @types/mssql, which
1197
+ // Turbine deliberately does not depend on) — the structural MssqlModule above
1198
+ // is our typed surface. Import through a widened specifier so tsc treats the
1199
+ // typeless module as `any` instead of erroring (TS7016).
1200
+ const specifier = 'mssql';
1201
+ mod = (await import(specifier));
1202
+ }
1203
+ catch (err) {
1204
+ throw new ConnectionError("[turbine] turbine-orm/mssql requires the optional peer dependency 'mssql'. Install it: npm i mssql. " +
1205
+ `(${err.message})`);
1206
+ }
1207
+ // `mssql` is a CommonJS module; the namespace may be on `default` under ESM interop.
1208
+ const ns = (mod.default ?? mod);
1209
+ if (typeof ns.ConnectionPool !== 'function' || typeof ns.Request !== 'function') {
1210
+ throw new ConnectionError("[turbine] Loaded 'mssql' but it is missing ConnectionPool/Request exports.");
1211
+ }
1212
+ return ns;
1213
+ }
1214
+ /**
1215
+ * Fail fast on unsupported servers: SQL Server 2016+ is required (`FOR JSON PATH`,
1216
+ * `OPENJSON`, and the relation engine need it). ProductMajorVersion 13 = 2016.
1217
+ */
1218
+ function assertSupportedVersion(majorVersion) {
1219
+ if (majorVersion > 0 && majorVersion < 13) {
1220
+ throw new ConnectionError(`[turbine] turbine-orm/mssql requires SQL Server 2016+ (FOR JSON / OPENJSON); got major version ${majorVersion}.`);
1221
+ }
1222
+ }
1223
+ function isMssqlPool(x) {
1224
+ return (!!x &&
1225
+ typeof x.request === 'function' &&
1226
+ typeof x.close === 'function');
1227
+ }
1228
+ /**
1229
+ * Create a {@link TurbineClient} bound to SQL Server 2016+ via `mssql`.
1230
+ *
1231
+ * Pass one of:
1232
+ * - a connection string (`'mssql://sa:pass@host:1433/db'`),
1233
+ * - an `mssql` config object (`{ server, user, password, database, options }`),
1234
+ * - an existing `MssqlPool` (injection — you own its lifecycle, `disconnect()` is
1235
+ * a no-op).
1236
+ *
1237
+ * When Turbine builds the pool (string/config), it probes
1238
+ * `SERVERPROPERTY('ProductMajorVersion')` to reject SQL Server < 2016, and
1239
+ * `disconnect()` closes the pool it created.
1240
+ *
1241
+ * @example
1242
+ * ```ts
1243
+ * import { turbineMssql } from 'turbine-orm/mssql';
1244
+ * const db = await turbineMssql('mssql://sa:Passw0rd!@localhost:1433/app', SCHEMA);
1245
+ * ```
1246
+ */
1247
+ export async function turbineMssql(target, schema, options = {}) {
1248
+ let pool;
1249
+ let owns = false;
1250
+ if (target instanceof MssqlPool) {
1251
+ pool = target;
1252
+ }
1253
+ else {
1254
+ const sqlNS = await loadMssql();
1255
+ if (isMssqlPool(target)) {
1256
+ pool = new MssqlPool(target, sqlNS);
1257
+ }
1258
+ else {
1259
+ const config = typeof target === 'string' ? parseMssqlConfig(target) : target;
1260
+ const rawPool = await new sqlNS.ConnectionPool(config).connect();
1261
+ pool = new MssqlPool(rawPool, sqlNS);
1262
+ owns = true;
1263
+ }
1264
+ }
1265
+ // Probe the server version (fail fast on SQL Server < 2016).
1266
+ try {
1267
+ const rows = (await pool.query("SELECT CAST(SERVERPROPERTY('ProductMajorVersion') AS INT) AS v")).rows;
1268
+ assertSupportedVersion(Number(rows[0]?.v ?? 0));
1269
+ }
1270
+ catch (err) {
1271
+ if (err instanceof ConnectionError)
1272
+ throw err;
1273
+ // A non-version probe failure (permissions, etc.) should not block startup.
1274
+ }
1275
+ const client = new TurbineClient({
1276
+ pool,
1277
+ dialect: mssqlDialect,
1278
+ preparedStatements: false,
1279
+ logging: options.logging,
1280
+ defaultLimit: options.defaultLimit,
1281
+ warnOnUnlimited: options.warnOnUnlimited,
1282
+ }, schema);
1283
+ if (owns) {
1284
+ // Turbine built this pool, so disconnect()/end() must close it. External pools
1285
+ // (injection) stay the caller's responsibility (disconnect() no-op), consistent
1286
+ // with turbineHttp / turbineMysql / turbineSqlite.
1287
+ const baseDisconnect = client.disconnect.bind(client);
1288
+ const close = async () => {
1289
+ await baseDisconnect();
1290
+ await pool.end();
1291
+ };
1292
+ const patch = client;
1293
+ patch.disconnect = close;
1294
+ patch.end = close;
1295
+ }
1296
+ return client;
1297
+ }
1298
+ //# sourceMappingURL=mssql.js.map