turbine-orm 0.75.0 → 0.76.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 (78) hide show
  1. package/README.md +48 -7
  2. package/dist/cjs/cli/compile-query.d.ts +22 -2
  3. package/dist/cjs/cli/compile-query.js +49 -5
  4. package/dist/cjs/cli/config.d.ts +2 -0
  5. package/dist/cjs/cli/config.js +1 -1
  6. package/dist/cjs/cli/destructive.js +78 -43
  7. package/dist/cjs/cli/index.d.ts +95 -1
  8. package/dist/cjs/cli/index.js +609 -145
  9. package/dist/cjs/cli/mcp.js +30 -1
  10. package/dist/cjs/cli/pii-predicate-guard.d.ts +25 -0
  11. package/dist/cjs/cli/pii-predicate-guard.js +72 -12
  12. package/dist/cjs/cli/rate-limit.js +38 -1
  13. package/dist/cjs/cli/studio.js +26 -5
  14. package/dist/cjs/cli/ui.d.ts +33 -0
  15. package/dist/cjs/cli/ui.js +53 -7
  16. package/dist/cjs/client.d.ts +13 -1
  17. package/dist/cjs/client.js +1 -1
  18. package/dist/cjs/errors.d.ts +12 -1
  19. package/dist/cjs/errors.js +11 -2
  20. package/dist/cjs/generate.d.ts +26 -0
  21. package/dist/cjs/generate.js +174 -27
  22. package/dist/cjs/index.d.ts +1 -1
  23. package/dist/cjs/index.js +1 -1
  24. package/dist/cjs/introspect.d.ts +17 -0
  25. package/dist/cjs/introspect.js +100 -1
  26. package/dist/cjs/mssql.d.ts +18 -0
  27. package/dist/cjs/mssql.js +20 -1
  28. package/dist/cjs/pipeline.js +44 -6
  29. package/dist/cjs/powql.js +51 -17
  30. package/dist/cjs/query/batched-loader.js +3 -3
  31. package/dist/cjs/query/builder.js +1 -1
  32. package/dist/cjs/query/relations.d.ts +5 -0
  33. package/dist/cjs/query/relations.js +141 -69
  34. package/dist/cjs/query/utils.d.ts +13 -0
  35. package/dist/cjs/query/utils.js +16 -0
  36. package/dist/cjs/serverless.d.ts +1 -1
  37. package/dist/cjs/serverless.js +1 -1
  38. package/dist/cjs/sqlite.d.ts +33 -1
  39. package/dist/cjs/sqlite.js +84 -3
  40. package/dist/cli/compile-query.d.ts +22 -2
  41. package/dist/cli/compile-query.js +50 -6
  42. package/dist/cli/config.d.ts +2 -0
  43. package/dist/cli/config.js +1 -1
  44. package/dist/cli/destructive.js +78 -43
  45. package/dist/cli/index.d.ts +95 -1
  46. package/dist/cli/index.js +604 -147
  47. package/dist/cli/mcp.js +30 -1
  48. package/dist/cli/pii-predicate-guard.d.ts +25 -0
  49. package/dist/cli/pii-predicate-guard.js +73 -13
  50. package/dist/cli/rate-limit.js +38 -1
  51. package/dist/cli/studio.js +27 -6
  52. package/dist/cli/ui.d.ts +33 -0
  53. package/dist/cli/ui.js +51 -7
  54. package/dist/client.d.ts +13 -1
  55. package/dist/client.js +1 -1
  56. package/dist/errors.d.ts +12 -1
  57. package/dist/errors.js +11 -2
  58. package/dist/generate.d.ts +26 -0
  59. package/dist/generate.js +172 -27
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.js +1 -1
  62. package/dist/introspect.d.ts +17 -0
  63. package/dist/introspect.js +98 -1
  64. package/dist/mssql.d.ts +18 -0
  65. package/dist/mssql.js +20 -1
  66. package/dist/pipeline.js +44 -6
  67. package/dist/powql.js +53 -19
  68. package/dist/query/batched-loader.js +4 -4
  69. package/dist/query/builder.js +2 -2
  70. package/dist/query/relations.d.ts +5 -0
  71. package/dist/query/relations.js +141 -70
  72. package/dist/query/utils.d.ts +13 -0
  73. package/dist/query/utils.js +15 -0
  74. package/dist/serverless.d.ts +1 -1
  75. package/dist/serverless.js +1 -1
  76. package/dist/sqlite.d.ts +33 -1
  77. package/dist/sqlite.js +85 -4
  78. package/package.json +2 -2
@@ -18,7 +18,7 @@ import { normalizeKeyColumns, snakeToCamel } from '../schema.js';
18
18
  import { resolveCountRelations } from './batched-loader.js';
19
19
  import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, MAX_NAMED_ORDER_KEYS, normalizeOrderBy, orderByEntries, sortedEntries, } from './filters.js';
20
20
  import { assertDirectionToken, assertOrderDirection } from './types.js';
21
- import { canonicalColumnOrder, ownLookup, relationInProjectionMessage, resolveColumnName, resolveRelation, resolveRelationDef, selectNamesNothingMessage, selectOmitExclusiveMessage, unknownFieldMessage, } from './utils.js';
21
+ import { availableClause, canonicalColumnOrder, ownLookup, relationInProjectionMessage, resolveColumnName, resolveRelation, resolveRelationDef, selectNamesNothingMessage, selectOmitExclusiveMessage, unknownFieldMessage, } from './utils.js';
22
22
  import { hasWarnedOnce, shouldWarnOnce, WARN_NS } from './warn-registry.js';
23
23
  import * as whereMod from './where.js';
24
24
  import * as writesMod from './writes.js';
@@ -175,6 +175,46 @@ export function resolveProjection(qi, table, meta, select, omit, includePii) {
175
175
  export function resolveColumns(qi, select, omit, includePii) {
176
176
  return resolveProjection(qi, qi.table, qi.tableMeta, select, omit, includePii);
177
177
  }
178
+ /**
179
+ * The one place `with: { posts: true }` becomes an options object.
180
+ *
181
+ * `true` is not a third kind of relation spec, it is shorthand for "include
182
+ * this relation with no options", i.e. `{}`. Every walker over a `with` tree
183
+ * needs the same reading of it, and there are SIX of them: the build path
184
+ * ({@link buildRelationSubquery} / {@link buildManyToManySubquery}), the
185
+ * param-collect mirror ({@link collectRelationSubqueryParams}), the cache
186
+ * fingerprint ({@link withFingerprint}), the decode shape
187
+ * ({@link buildRelationShape}), the projection resolver
188
+ * ({@link resolveTargetColumns}) and the flatten planner.
189
+ *
190
+ * Each of them USED TO spell the shorthand out for itself, with a
191
+ * `spec !== true && spec.x` guard per option on the build side and a single
192
+ * `if (spec === true) return` early exit on the collect side. Those two
193
+ * readings are not the same, and the difference was a live bug: since global
194
+ * filters reached relation subqueries the build path has emitted the target's
195
+ * filter UNCONDITIONALLY (a `with` must never surface rows the filter hides),
196
+ * while the collect path's early exit returned before pushing its params. A
197
+ * value-bearing filter on a relation target therefore compiled a `$N` that no
198
+ * value backed, and `with: { posts: true }` failed at bind time with
199
+ * "bind message supplies N parameters, but prepared statement requires N+1"
200
+ * while the identical query written `with: { posts: {} }` worked.
201
+ *
202
+ * Returning ONE frozen empty options object removes the branch instead of
203
+ * duplicating it: below this call there is no `true` case left to keep in
204
+ * sync, so a future option added to one walker cannot be forgotten by the
205
+ * shorthand in another. Frozen and shared because it is read-only by
206
+ * construction (every consumer only reads `select`/`omit`/`where`/`orderBy`/
207
+ * `limit`/`with`), so one instance serves every call and allocates nothing on
208
+ * the hot path.
209
+ */
210
+ const EMPTY_WITH_OPTIONS = Object.freeze({});
211
+ /**
212
+ * Read a relation spec as options. See {@link EMPTY_WITH_OPTIONS} for why this
213
+ * is a shared authority rather than a guard repeated per walker.
214
+ */
215
+ export function relationOptions(spec) {
216
+ return spec === true ? EMPTY_WITH_OPTIONS : spec;
217
+ }
178
218
  /**
179
219
  * Produce a fingerprint for a `with` clause tree. Recursion mirrors
180
220
  * buildSelectWithRelations / buildRelationSubquery.
@@ -211,11 +251,7 @@ export function withFingerprint(qi, withClause, table, depth = 0) {
211
251
  parts.push(`unknown:${relName}`);
212
252
  continue;
213
253
  }
214
- if (spec === true) {
215
- parts.push(relName);
216
- continue;
217
- }
218
- const opts = spec;
254
+ const opts = relationOptions(spec);
219
255
  const subParts = [];
220
256
  // select/omit shape
221
257
  if (opts.select) {
@@ -293,8 +329,10 @@ export function collectWithParams(qi, withClause, params, table, flattenPlan) {
293
329
  * Collect params from a single relation subquery. Mirrors buildRelationSubquery.
294
330
  */
295
331
  export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentRef, depth = 0) {
296
- if (spec === true)
297
- return; // No params for default include
332
+ // `true` IS `{}` (see {@link relationOptions}). This used to be an early
333
+ // `return`, which skipped the target's global-filter params the build path
334
+ // always pushes.
335
+ const opts = relationOptions(spec);
298
336
  const targetTable = relDef.to;
299
337
  const targetMeta = qi.schema.tables[targetTable];
300
338
  if (!targetMeta)
@@ -309,19 +347,19 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
309
347
  // orderBy params → where params → limit param → nested-with params
310
348
  // (always, both paths).
311
349
  if (relDef.type === 'manyToMany') {
312
- const m2mOrderEntries = spec.orderBy ? orderByEntries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
350
+ const m2mOrderEntries = opts.orderBy ? orderByEntries(opts.orderBy).filter(([, dir]) => dir !== undefined) : [];
313
351
  if (nativeOrderPath && m2mOrderEntries.length > 0) {
314
352
  collectRelationOrderParams(qi, targetTable, targetMeta, m2mOrderEntries, params);
315
353
  }
316
- if (spec.where) {
317
- whereMod.collectAliasWhereParams(qi, targetTable, targetMeta, spec.where, params);
354
+ if (opts.where) {
355
+ whereMod.collectAliasWhereParams(qi, targetTable, targetMeta, opts.where, params);
318
356
  }
319
357
  whereMod.collectTargetGlobalFilterAlias(qi, targetTable, params);
320
- if (spec.limit !== undefined && !qi.dialect.inlineLimitOffset) {
321
- params.push(qi.paginationValue(spec.limit, 'relation limit'));
358
+ if (opts.limit !== undefined && !qi.dialect.inlineLimitOffset) {
359
+ params.push(qi.paginationValue(opts.limit, 'relation limit'));
322
360
  }
323
- if (spec.with) {
324
- for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
361
+ if (opts.with) {
362
+ for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
325
363
  const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
326
364
  if (!nestedRelDef)
327
365
  continue;
@@ -331,12 +369,12 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
331
369
  return;
332
370
  }
333
371
  // Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
334
- const relOrderEntries = spec.orderBy ? orderByEntries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
372
+ const relOrderEntries = opts.orderBy ? orderByEntries(opts.orderBy).filter(([, dir]) => dir !== undefined) : [];
335
373
  const hasOrder = relOrderEntries.length > 0;
336
- const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
374
+ const willWrap = relDef.type === 'hasMany' && (opts.limit !== undefined || hasOrder);
337
375
  // Non-wrapped path: nested relations BEFORE where/limit
338
- if (!willWrap && spec.with) {
339
- for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
376
+ if (!willWrap && opts.with) {
377
+ for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
340
378
  const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
341
379
  if (!nestedRelDef)
342
380
  continue;
@@ -350,8 +388,8 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
350
388
  collectRelationOrderParams(qi, targetTable, targetMeta, relOrderEntries, params);
351
389
  }
352
390
  // where params, mirrors buildAliasWhere push order
353
- if (spec.where) {
354
- whereMod.collectAliasWhereParams(qi, targetTable, targetMeta, spec.where, params);
391
+ if (opts.where) {
392
+ whereMod.collectAliasWhereParams(qi, targetTable, targetMeta, opts.where, params);
355
393
  }
356
394
  // Global filter on the target, mirrors targetGlobalFilterAlias in
357
395
  // buildRelationSubquery (pushed after spec.where, before limit).
@@ -360,12 +398,12 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
360
398
  // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
361
399
  // pushing one here would orphan a param and desync the collect path.
362
400
  // `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
363
- if (relDef.type === 'hasMany' && spec.limit !== undefined && !qi.dialect.inlineLimitOffset) {
364
- params.push(qi.paginationValue(spec.limit, 'relation limit'));
401
+ if (relDef.type === 'hasMany' && opts.limit !== undefined && !qi.dialect.inlineLimitOffset) {
402
+ params.push(qi.paginationValue(opts.limit, 'relation limit'));
365
403
  }
366
404
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
367
- if (willWrap && spec.with) {
368
- for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
405
+ if (willWrap && opts.with) {
406
+ for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
369
407
  const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
370
408
  if (!nestedRelDef)
371
409
  continue;
@@ -734,13 +772,12 @@ export function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lat
734
772
  // landing here on such a table is an orderBy VALUE of the wrong shape on a
735
773
  // scalar column, which deserves to be named rather than reported as a
736
774
  // missing relation.
737
- const known = Object.keys(ownerMeta.relations);
738
775
  const isColumn = resolveColumnName(ownerMeta, relName) !== undefined;
739
776
  throw new RelationError(isColumn
740
777
  ? `[turbine] orderBy on "${ownerTable}.${relName}" got a relation-shaped value, but "${relName}" is a ` +
741
778
  `column. Order a column with 'asc' / 'desc' (or { sort, nulls }); the object form is for relations.`
742
779
  : `[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
743
- (known.length > 0 ? `Available: ${known.join(', ')}` : `"${ownerTable}" has no relations.`));
780
+ availableClause(Object.keys(ownerMeta.relations), `"${ownerTable}" has no relations.`));
744
781
  }
745
782
  // Pick-row ordering (`{ pick, by }`): order by a value from ONE related
746
783
  // row: a correlated scalar subquery with its own ORDER BY … LIMIT 1.
@@ -1319,8 +1356,9 @@ export function parseNestedRow(qi, row, table, fromJson = false) {
1319
1356
  * second implementation.
1320
1357
  */
1321
1358
  export function resolveTargetColumns(qi, spec, targetMeta, includePii, targetTable = targetMeta.name) {
1322
- const select = spec === true ? undefined : spec.select;
1323
- const omit = spec === true ? undefined : spec.omit;
1359
+ const opts = relationOptions(spec);
1360
+ const select = opts.select;
1361
+ const omit = opts.omit;
1324
1362
  return resolveProjection(qi, targetTable, targetMeta, select, omit, includePii) ?? targetMeta.allColumns;
1325
1363
  }
1326
1364
  /**
@@ -1475,8 +1513,9 @@ export function buildRelationShape(qi, relDef, spec, parentMeta, includePii) {
1475
1513
  const targetColumns = resolveTargetColumns(qi, spec, targetMeta, includePii, relDef.to);
1476
1514
  const keys = targetColumns.map((col) => targetMeta.reverseColumnMap[col] ?? snakeToCamel(col));
1477
1515
  const nested = {};
1478
- if (spec !== true && spec.with) {
1479
- for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
1516
+ const nestedWith = relationOptions(spec).with;
1517
+ if (nestedWith) {
1518
+ for (const [nestedRelName, nestedSpec] of sortedEntries(nestedWith)) {
1480
1519
  const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
1481
1520
  if (!nestedRelDef)
1482
1521
  continue;
@@ -1713,15 +1752,13 @@ function planFlattenNode(qi, counter, relName, relDef, spec, depth, path, includ
1713
1752
  : `its correlation column(s) on "${relDef.to}" are not provably unique (no primary key, ` +
1714
1753
  'unique constraint or non-partial unique index covers them), so a join could multiply parent rows');
1715
1754
  }
1716
- const opts = spec === true ? undefined : spec;
1717
- if (opts) {
1718
- if (opts.limit !== undefined)
1719
- return decline('it declares a `limit`');
1720
- if (opts.orderBy && orderByEntries(opts.orderBy).some(([, dir]) => dir !== undefined)) {
1721
- return decline('it declares an `orderBy`');
1722
- }
1755
+ const opts = relationOptions(spec);
1756
+ if (opts.limit !== undefined)
1757
+ return decline('it declares a `limit`');
1758
+ if (opts.orderBy && orderByEntries(opts.orderBy).some(([, dir]) => dir !== undefined)) {
1759
+ return decline('it declares an `orderBy`');
1723
1760
  }
1724
- const nestedEntries = opts?.with ? sortedEntries(opts.with) : [];
1761
+ const nestedEntries = opts.with ? sortedEntries(opts.with) : [];
1725
1762
  for (const [nestedRelName] of nestedEntries) {
1726
1763
  if (nestedRelName === '_count')
1727
1764
  return decline('its nested `with` uses `_count`');
@@ -1922,8 +1959,9 @@ function emitFlattenInner(qi, node, params, innerSelects, innerJoins, innerWhere
1922
1959
  // than dropping the parent row, matching what the correlated subquery's
1923
1960
  // `LIMIT 1` did when it returned NULL.
1924
1961
  const filters = innerWhere;
1925
- if (node.spec !== true && node.spec.where) {
1926
- const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, srcAlias, node.spec.where, params);
1962
+ const nodeWhere = relationOptions(node.spec).where;
1963
+ if (nodeWhere) {
1964
+ const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, srcAlias, nodeWhere, params);
1927
1965
  if (extra)
1928
1966
  filters.push(extra);
1929
1967
  }
@@ -1989,8 +2027,9 @@ function projectFlattenNode(qi, node, outerAlias, selectSink) {
1989
2027
  }
1990
2028
  /** Param-collect mirror of {@link emitFlattenNode}. */
1991
2029
  export function collectFlattenNodeParams(qi, node, params) {
1992
- if (node.spec !== true && node.spec.where) {
1993
- whereMod.collectAliasWhereParams(qi, node.targetTable, node.targetMeta, node.spec.where, params);
2030
+ const nodeWhere = relationOptions(node.spec).where;
2031
+ if (nodeWhere) {
2032
+ whereMod.collectAliasWhereParams(qi, node.targetTable, node.targetMeta, nodeWhere, params);
1994
2033
  }
1995
2034
  whereMod.collectTargetGlobalFilterAlias(qi, node.targetTable, params);
1996
2035
  for (const slot of node.slots) {
@@ -2139,7 +2178,7 @@ export function buildSelectWithRelations(qi, table, withClause, params, columnsL
2139
2178
  const relDef = ownLookup(meta.relations, relName);
2140
2179
  if (!relDef) {
2141
2180
  throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
2142
- `Available: ${Object.keys(meta.relations).join(', ')}`);
2181
+ availableClause(Object.keys(meta.relations), `"${table}" has no relations.`));
2143
2182
  }
2144
2183
  // `relationLoadStrategy: 'flatten'`: an eligible to-one relation becomes a
2145
2184
  // LEFT JOIN + prefixed scalar projection instead of a per-parent-row
@@ -2273,6 +2312,11 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2273
2312
  const targetMeta = qi.schema.tables[targetTable];
2274
2313
  if (!targetMeta)
2275
2314
  throw new RelationError(`[turbine] Unknown relation target "${targetTable}"`);
2315
+ // `true` IS `{}`; below this line there is no `true` case, which is what
2316
+ // keeps this walk and collectRelationSubqueryParams reading one shape.
2317
+ // The raw `spec` is still what crosses the dialect seam below, since
2318
+ // RelationSubqueryContext is a published contract.
2319
+ const opts = relationOptions(spec);
2276
2320
  // Dev-only: correlated relation loading probes the child table once per parent
2277
2321
  // row, so a missing FK index multiplies into per-parent full-table scans (a
2278
2322
  // batched-loader ORM pays the same missing index only once, which is why
@@ -2321,9 +2365,33 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2321
2365
  depth: currentDepth,
2322
2366
  path: currentPath,
2323
2367
  quote: (name) => qi.q(name),
2324
- buildWhere: (whereAlias) => (spec !== true && spec.where
2325
- ? whereMod.buildAliasWhere(qi, targetTable, targetMeta, whereAlias, spec.where, params)
2326
- : '') ?? '',
2368
+ // The relation target's WHERE, which is the caller's `spec.where` AND the
2369
+ // target table's GLOBAL FILTER. Both, not just the first.
2370
+ //
2371
+ // A dialect that overrides buildRelationSubquery takes over the whole
2372
+ // subquery, so the generic builder returns at this seam BEFORE it would
2373
+ // have applied targetGlobalFilterAlias itself. The override cannot apply
2374
+ // the filter on its own: RelationSubqueryContext deliberately hands it no
2375
+ // BuilderCtx, so `targetGlobalFilterAlias` is not reachable from a dialect
2376
+ // file. That left SQL Server's `FOR JSON PATH` path emitting a correlation
2377
+ // predicate and nothing else, so a `with` returned rows a tenancy or
2378
+ // soft-delete filter is supposed to hide, while the collect mirror still
2379
+ // pushed the filter's params. Wrong rows AND an orphan param, on every
2380
+ // spec shape.
2381
+ //
2382
+ // Folding it in here rather than at each override keeps ONE authority for
2383
+ // "what does this relation's WHERE contain", and lands the params exactly
2384
+ // where collectRelationSubqueryParams already expects them: after
2385
+ // spec.where, before the limit.
2386
+ buildWhere: (whereAlias) => {
2387
+ const userWhere = (opts.where
2388
+ ? whereMod.buildAliasWhere(qi, targetTable, targetMeta, whereAlias, opts.where, params)
2389
+ : '') ?? '';
2390
+ const gf = whereMod.targetGlobalFilterAlias(qi, targetTable, whereAlias, params);
2391
+ if (userWhere && gf)
2392
+ return `${userWhere} AND ${gf}`;
2393
+ return userWhere || gf || '';
2394
+ },
2327
2395
  recurse: (nRelDef, nSpec, nParent, nDepth, nPath) => buildRelationSubquery(qi, nRelDef, nSpec, params, nParent, aliasCounter, nDepth, nPath, includePii),
2328
2396
  });
2329
2397
  }
@@ -2335,8 +2403,8 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2335
2403
  // An orderBy with no defined entries (`orderBy: {}`) is treated as absent -
2336
2404
  // it must neither trigger the wrap (dropping nested relations) nor render a
2337
2405
  // dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
2338
- const relOrderEntries = spec !== true && spec.orderBy ? orderByEntries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
2339
- const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || relOrderEntries.length > 0);
2406
+ const relOrderEntries = opts.orderBy ? orderByEntries(opts.orderBy).filter(([, dir]) => dir !== undefined) : [];
2407
+ const willWrap = relDef.type === 'hasMany' && (opts.limit !== undefined || relOrderEntries.length > 0);
2340
2408
  // manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
2341
2409
  // where, orderBy, and select/omit are handled there (the target alias is the
2342
2410
  // row source, exactly like hasMany), so short-circuit before the hasMany logic.
@@ -2344,12 +2412,12 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2344
2412
  return buildManyToManySubquery(qi, relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, alias, targetMeta, targetColumns, includePii);
2345
2413
  }
2346
2414
  // Nested relations, only in the non-wrapped path (wrapped path builds them separately)
2347
- if (!willWrap && spec !== true && spec.with) {
2348
- for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2415
+ if (!willWrap && opts.with) {
2416
+ for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
2349
2417
  const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
2350
2418
  if (!nestedRelDef) {
2351
2419
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
2352
- `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
2420
+ availableClause(Object.keys(targetMeta.relations), `"${relDef.to}" has no relations.`));
2353
2421
  }
2354
2422
  // Recursively build nested subquery, passing THIS alias as the parent reference
2355
2423
  const nestedSubquery = buildRelationSubquery(qi, nestedRelDef, nestedSpec, params, alias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name], includePii);
@@ -2386,8 +2454,8 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2386
2454
  }
2387
2455
  // Additional filters, full scalar where surface (equality, null, operator
2388
2456
  // objects, OR/AND/NOT), properly parameterized against this alias.
2389
- if (spec !== true && spec.where) {
2390
- const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, alias, spec.where, params);
2457
+ if (opts.where) {
2458
+ const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, alias, opts.where, params);
2391
2459
  if (extra)
2392
2460
  whereClause += ` AND ${extra}`;
2393
2461
  }
@@ -2404,8 +2472,8 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2404
2472
  // (and shifts every later placeholder by one). To-one relations ignore limit.
2405
2473
  // `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
2406
2474
  let limitClause = '';
2407
- if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
2408
- limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params, 'relation limit')}`;
2475
+ if (relDef.type === 'hasMany' && opts.limit !== undefined) {
2476
+ limitClause = ` LIMIT ${qi.paginationRef(opts.limit, params, 'relation limit')}`;
2409
2477
  }
2410
2478
  if (relDef.type === 'hasMany') {
2411
2479
  // When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
@@ -2418,12 +2486,12 @@ export function buildRelationSubquery(qi, relDef, spec, params, parentRef, alias
2418
2486
  // For the json_build_object, reference the inner alias, only include resolved columns
2419
2487
  const innerJsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, innerAlias);
2420
2488
  // Build nested relation subqueries referencing innerAlias
2421
- if (spec !== true && spec.with) {
2422
- for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2489
+ if (opts.with) {
2490
+ for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
2423
2491
  const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
2424
2492
  if (!nestedRelDef) {
2425
2493
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
2426
- `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
2494
+ availableClause(Object.keys(targetMeta.relations), `"${relDef.to}" has no relations.`));
2427
2495
  }
2428
2496
  const nestedSub = buildRelationSubquery(qi, nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name], includePii);
2429
2497
  const fallback = nestedRelDef.type === 'hasMany' ? qi.dialect.emptyJsonArrayLiteral : qi.dialect.nullJsonLiteral;
@@ -2468,6 +2536,9 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
2468
2536
  if (!relDef.through) {
2469
2537
  throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
2470
2538
  }
2539
+ // `true` IS `{}`; see {@link relationOptions}. Same reading as
2540
+ // buildRelationSubquery and collectRelationSubqueryParams' m2m branch.
2541
+ const opts = relationOptions(spec);
2471
2542
  const targetTable = relDef.to;
2472
2543
  const qTarget = qi.q(targetTable);
2473
2544
  const qJunction = qi.q(relDef.through.table);
@@ -2503,15 +2574,15 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
2503
2574
  // `orderBy: {}` (no defined entries) is treated as absent: it must not
2504
2575
  // render a dangling `ORDER BY `. Param pushes here land BEFORE the
2505
2576
  // spec.where params, mirrored by collectRelationSubqueryParams' m2m branch.
2506
- const relOrderEntries = spec !== true && spec.orderBy ? orderByEntries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
2577
+ const relOrderEntries = opts.orderBy ? orderByEntries(opts.orderBy).filter(([, dir]) => dir !== undefined) : [];
2507
2578
  let orderClause = '';
2508
2579
  if (relOrderEntries.length > 0) {
2509
2580
  orderClause = buildRelationOrderClause(qi, targetTable, targetMeta, talias, relOrderEntries, params);
2510
2581
  }
2511
2582
  // Additional WHERE filters on the target, full scalar where surface,
2512
2583
  // properly parameterized against the target alias.
2513
- if (spec !== true && spec.where) {
2514
- const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, talias, spec.where, params);
2584
+ if (opts.where) {
2585
+ const extra = whereMod.buildAliasWhere(qi, targetTable, targetMeta, talias, opts.where, params);
2515
2586
  if (extra)
2516
2587
  whereClause += ` AND ${extra}`;
2517
2588
  }
@@ -2522,8 +2593,8 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
2522
2593
  whereClause += ` AND ${gfExtra}`;
2523
2594
  // LIMIT, `limit: 0` is honored (LIMIT 0 → empty array)
2524
2595
  let limitClause = '';
2525
- if (spec !== true && spec.limit !== undefined) {
2526
- limitClause = ` LIMIT ${qi.paginationRef(spec.limit, params, 'relation limit')}`;
2596
+ if (opts.limit !== undefined) {
2597
+ limitClause = ` LIMIT ${qi.paginationRef(opts.limit, params, 'relation limit')}`;
2527
2598
  }
2528
2599
  const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
2529
2600
  // When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
@@ -2534,12 +2605,12 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
2534
2605
  `${fromJoin} WHERE ${whereClause}${orderClause}${limitClause}`;
2535
2606
  const innerJsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, innerAlias);
2536
2607
  // Nested relations reference the inner alias.
2537
- if (spec !== true && spec.with) {
2538
- for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2608
+ if (opts.with) {
2609
+ for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
2539
2610
  const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
2540
2611
  if (!nestedRelDef) {
2541
2612
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
2542
- `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
2613
+ availableClause(Object.keys(targetMeta.relations), `"${relDef.to}" has no relations.`));
2543
2614
  }
2544
2615
  const nestedSub = buildRelationSubquery(qi, nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name], includePii);
2545
2616
  const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
@@ -2554,12 +2625,12 @@ export function buildManyToManySubquery(qi, relDef, spec, params, parentRef, ali
2554
2625
  // Simple path: build the json object pairs directly off the target alias,
2555
2626
  // including any nested relations (correlated to the target alias).
2556
2627
  const jsonPairs = jsonScalarPairs(qi, targetMeta, targetColumns, talias);
2557
- if (spec !== true && spec.with) {
2558
- for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2628
+ if (opts.with) {
2629
+ for (const [nestedRelName, nestedSpec] of sortedEntries(opts.with)) {
2559
2630
  const nestedRelDef = ownLookup(targetMeta.relations, nestedRelName);
2560
2631
  if (!nestedRelDef) {
2561
2632
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
2562
- `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
2633
+ availableClause(Object.keys(targetMeta.relations), `"${relDef.to}" has no relations.`));
2563
2634
  }
2564
2635
  const nestedSub = buildRelationSubquery(qi, nestedRelDef, nestedSpec, params, talias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name], includePii);
2565
2636
  const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
@@ -677,6 +677,19 @@ export declare function unknownFieldMessage(table: string, field: string, meta:
677
677
  columnMap: Record<string, string>;
678
678
  relations?: Record<string, unknown>;
679
679
  }): string;
680
+ /**
681
+ * The trailing "Available: a, b, c" clause of a name-not-found error, or
682
+ * `emptySentence` when there is nothing to list.
683
+ *
684
+ * The empty branch is the whole reason this exists. Fifteen error sites
685
+ * interpolated `Object.keys(...).join(', ')` directly, and on a table with no
686
+ * relations (or a schema with no tables) that renders a dangling
687
+ * `Available: ` with nothing after the colon, which reads as a broken error
688
+ * message rather than as an answer. One site already got this right by hand;
689
+ * the other fourteen are now the same function, so the next one cannot get it
690
+ * wrong by omission.
691
+ */
692
+ export declare function availableClause(names: readonly string[], emptySentence: string): string;
680
693
  /**
681
694
  * The error text for a RELATION named inside `select` / `omit`.
682
695
  *
@@ -1419,6 +1419,21 @@ export function unknownFieldMessage(table, field, meta) {
1419
1419
  ` Known columns: ${columns.join(', ') || '(none)'}.` +
1420
1420
  (relations.length ? ` Known relations (valid in \`where\` and \`with\`): ${relations.join(', ')}.` : ''));
1421
1421
  }
1422
+ /**
1423
+ * The trailing "Available: a, b, c" clause of a name-not-found error, or
1424
+ * `emptySentence` when there is nothing to list.
1425
+ *
1426
+ * The empty branch is the whole reason this exists. Fifteen error sites
1427
+ * interpolated `Object.keys(...).join(', ')` directly, and on a table with no
1428
+ * relations (or a schema with no tables) that renders a dangling
1429
+ * `Available: ` with nothing after the colon, which reads as a broken error
1430
+ * message rather than as an answer. One site already got this right by hand;
1431
+ * the other fourteen are now the same function, so the next one cannot get it
1432
+ * wrong by omission.
1433
+ */
1434
+ export function availableClause(names, emptySentence) {
1435
+ return names.length > 0 ? `Available: ${names.join(', ')}` : emptySentence;
1436
+ }
1422
1437
  /**
1423
1438
  * The error text for a RELATION named inside `select` / `omit`.
1424
1439
  *
@@ -143,7 +143,7 @@ export type TurbineHttpOptions = Omit<TurbineConfig, 'pool'>;
143
143
  * ```ts
144
144
  * import { Pool } from '@neondatabase/serverless';
145
145
  * import { turbineHttp } from 'turbine-orm/serverless';
146
- * import type { TurbineClient } from './generated/turbine';
146
+ * import type { TurbineClient } from './generated/turbine/index.js';
147
147
  * import { SCHEMA } from './generated/turbine/metadata.js';
148
148
  *
149
149
  * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
@@ -123,7 +123,7 @@ import { TurbineClient } from './client.js';
123
123
  * ```ts
124
124
  * import { Pool } from '@neondatabase/serverless';
125
125
  * import { turbineHttp } from 'turbine-orm/serverless';
126
- * import type { TurbineClient } from './generated/turbine';
126
+ * import type { TurbineClient } from './generated/turbine/index.js';
127
127
  * import { SCHEMA } from './generated/turbine/metadata.js';
128
128
  *
129
129
  * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
package/dist/sqlite.d.ts CHANGED
@@ -34,7 +34,7 @@
34
34
  * - **Case-insensitive matching** uses `COLLATE NOCASE`, which is **ASCII-only**
35
35
  * (no Unicode case folding).
36
36
  *
37
- * ## Example, `:memory:` database
37
+ * ## Example, `:memory:` database, from a generated schema
38
38
  *
39
39
  * ```ts
40
40
  * import { turbineSqlite } from 'turbine-orm/sqlite';
@@ -44,6 +44,38 @@
44
44
  * const users = await db.users.findMany({ with: { posts: true }, limit: 10 });
45
45
  * await db.disconnect();
46
46
  * ```
47
+ *
48
+ * ## Example, SQLite with no Postgres anywhere
49
+ *
50
+ * The snippet above needs `turbine generate`, which reads a live **Postgres**
51
+ * catalog, so it is the wrong starting point if SQLite is your only database.
52
+ * Describe the schema in code instead: `schemaToSQL` emits the DDL and
53
+ * `schemaDefToMetadata` derives the runtime metadata (relations included, from
54
+ * the same `references:`), both pure functions with no database involved.
55
+ *
56
+ * ```ts
57
+ * import { defineSchema, schemaDefToMetadata, schemaToSQL } from 'turbine-orm';
58
+ * import { sqliteDialect, turbineSqlite } from 'turbine-orm/sqlite';
59
+ *
60
+ * const schema = defineSchema({
61
+ * users: { id: { type: 'serial', primaryKey: true }, email: { type: 'text', notNull: true } },
62
+ * posts: {
63
+ * id: { type: 'serial', primaryKey: true },
64
+ * userId: { type: 'integer', notNull: true, references: 'users.id' },
65
+ * title: { type: 'text', notNull: true },
66
+ * },
67
+ * });
68
+ *
69
+ * const db = turbineSqlite(':memory:', schemaDefToMetadata(schema));
70
+ * for (const stmt of schemaToSQL(schema, { dialect: sqliteDialect })) {
71
+ * await db.raw([stmt] as never);
72
+ * }
73
+ *
74
+ * // `db.table(...)`, not `db.users`: the typed property accessors are emitted
75
+ * // by `turbine generate`, and this path skips it.
76
+ * const users = await db.table('users').findMany({ with: { posts: true }, orderBy: { id: 'asc' } });
77
+ * await db.disconnect();
78
+ * ```
47
79
  */
48
80
  import type { DatabaseSync } from 'node:sqlite';
49
81
  import { type PgCompatPool, type PgCompatPoolClient, TurbineClient } from './client.js';