turbine-orm 0.60.1 → 0.62.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 (84) hide show
  1. package/README.md +71 -27
  2. package/dist/cjs/cli/config.d.ts +40 -0
  3. package/dist/cjs/cli/config.js +74 -2
  4. package/dist/cjs/cli/index.d.ts +85 -1
  5. package/dist/cjs/cli/index.js +374 -24
  6. package/dist/cjs/cli/mcp.d.ts +8 -0
  7. package/dist/cjs/cli/mcp.js +448 -29
  8. package/dist/cjs/cli/pii-tags.d.ts +64 -9
  9. package/dist/cjs/cli/pii-tags.js +218 -39
  10. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  11. package/dist/cjs/cli/studio.d.ts +23 -0
  12. package/dist/cjs/cli/studio.js +126 -53
  13. package/dist/cjs/cli/ui.d.ts +15 -1
  14. package/dist/cjs/cli/ui.js +19 -5
  15. package/dist/cjs/client.js +248 -11
  16. package/dist/cjs/errors.d.ts +38 -1
  17. package/dist/cjs/errors.js +235 -24
  18. package/dist/cjs/index.d.ts +2 -2
  19. package/dist/cjs/index.js +7 -2
  20. package/dist/cjs/pipeline-submittable.js +26 -3
  21. package/dist/cjs/pipeline.js +15 -2
  22. package/dist/cjs/powql.d.ts +12 -0
  23. package/dist/cjs/powql.js +46 -21
  24. package/dist/cjs/prisma-compat.d.ts +15 -5
  25. package/dist/cjs/prisma-compat.js +273 -78
  26. package/dist/cjs/query/aggregates.d.ts +1 -1
  27. package/dist/cjs/query/aggregates.js +24 -10
  28. package/dist/cjs/query/batched-loader.d.ts +9 -4
  29. package/dist/cjs/query/batched-loader.js +4 -1
  30. package/dist/cjs/query/builder.d.ts +47 -0
  31. package/dist/cjs/query/builder.js +149 -21
  32. package/dist/cjs/query/index.d.ts +3 -1
  33. package/dist/cjs/query/index.js +7 -1
  34. package/dist/cjs/query/option-surface.d.ts +11 -0
  35. package/dist/cjs/query/option-surface.js +13 -0
  36. package/dist/cjs/query/relations.d.ts +8 -0
  37. package/dist/cjs/query/relations.js +21 -1
  38. package/dist/cjs/query/types.d.ts +152 -18
  39. package/dist/cjs/query/types.js +212 -1
  40. package/dist/cjs/query/where.d.ts +3 -3
  41. package/dist/cjs/query/where.js +8 -2
  42. package/dist/cjs/query/writes.js +10 -9
  43. package/dist/cli/config.d.ts +40 -0
  44. package/dist/cli/config.js +73 -2
  45. package/dist/cli/index.d.ts +85 -1
  46. package/dist/cli/index.js +373 -27
  47. package/dist/cli/mcp.d.ts +8 -0
  48. package/dist/cli/mcp.js +448 -29
  49. package/dist/cli/pii-tags.d.ts +64 -9
  50. package/dist/cli/pii-tags.js +217 -39
  51. package/dist/cli/studio-ui.generated.js +1 -1
  52. package/dist/cli/studio.d.ts +23 -0
  53. package/dist/cli/studio.js +125 -53
  54. package/dist/cli/ui.d.ts +15 -1
  55. package/dist/cli/ui.js +18 -4
  56. package/dist/client.js +250 -13
  57. package/dist/errors.d.ts +38 -1
  58. package/dist/errors.js +234 -23
  59. package/dist/index.d.ts +2 -2
  60. package/dist/index.js +5 -2
  61. package/dist/pipeline-submittable.js +26 -3
  62. package/dist/pipeline.js +15 -2
  63. package/dist/powql.d.ts +12 -0
  64. package/dist/powql.js +46 -21
  65. package/dist/prisma-compat.d.ts +15 -5
  66. package/dist/prisma-compat.js +274 -79
  67. package/dist/query/aggregates.d.ts +1 -1
  68. package/dist/query/aggregates.js +24 -10
  69. package/dist/query/batched-loader.d.ts +9 -4
  70. package/dist/query/batched-loader.js +4 -1
  71. package/dist/query/builder.d.ts +47 -0
  72. package/dist/query/builder.js +148 -21
  73. package/dist/query/index.d.ts +3 -1
  74. package/dist/query/index.js +2 -0
  75. package/dist/query/option-surface.d.ts +11 -0
  76. package/dist/query/option-surface.js +13 -0
  77. package/dist/query/relations.d.ts +8 -0
  78. package/dist/query/relations.js +21 -1
  79. package/dist/query/types.d.ts +152 -18
  80. package/dist/query/types.js +207 -2
  81. package/dist/query/where.d.ts +3 -3
  82. package/dist/query/where.js +8 -2
  83. package/dist/query/writes.js +10 -9
  84. package/package.json +13 -3
@@ -154,6 +154,29 @@ export declare const AUTO_TO_ONE_JOIN_ROWS_MAX = 100000;
154
154
  * it.
155
155
  */
156
156
  export declare const AUTO_COUNT_BATCH_MIN_PARENT_ROWS = 2;
157
+ /**
158
+ * Bridge the nested-write engine's table handles onto the privilege sentinel.
159
+ *
160
+ * `nested-write.ts` clears a `set` relation by calling
161
+ * `updateMany({ where, data, allowFullTableScan: true })`: an INTERNAL,
162
+ * fully-derived predicate (the parent's own FK match, guarded there against a
163
+ * null reference key), not caller input, and the flag is what lets that
164
+ * unconditional-looking statement through the empty-`where` guard. Since
165
+ * `allowFullTableScan` now only accepts {@link UNSAFE}, that literal `true`
166
+ * would throw and every nested `set` would fail.
167
+ *
168
+ * Rather than hand the nested-write engine the sentinel (which would put the
169
+ * privilege value on a second, less obvious import path), the translation
170
+ * happens HERE, at the single seam where core hands it a table accessor. The
171
+ * rewrite is deliberately narrow: `updateMany` only, and only when the value is
172
+ * exactly `true`, so nothing else about the call is touched and no other
173
+ * operation gains an escape hatch.
174
+ *
175
+ * @internal Exported for its unit test; not part of the public surface.
176
+ */
177
+ export declare function unlockNestedWriteTx<X extends {
178
+ table(name: string): Record<string, unknown>;
179
+ }>(tx: X): X;
157
180
  export type { DeferredQuery, MiddlewareFn, QueryEvent, QueryEventListener, QueryInterfaceOptions, ReselectExecutor, TemporalInfinityReading, } from './deferred.js';
158
181
  import type { DeferredQuery, MiddlewareFn, QueryInterfaceOptions } from './deferred.js';
159
182
  export declare class QueryInterface<T extends object, R extends object = {}> {
@@ -284,6 +307,12 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
284
307
  * `orderBy` all see it without threading it through dozens of signatures.
285
308
  * Only load-bearing when {@link globalFilters} is configured; build+collect are
286
309
  * synchronous per call, so this transient is never observed across an await.
310
+ *
311
+ * Holds the RESOLVED form ({@link ResolvedSkipGlobalFilters}), never the raw
312
+ * arg: every assignment goes through `resolveSkipGlobalFilters`, which is
313
+ * where a non-sentinel value (`true`, `['users']`, anything a JSON body can
314
+ * carry) is refused. Keeping the raw value here would push that decision out
315
+ * to each reader, and one reader forgetting it is the whole bug class.
287
316
  */
288
317
  private currentSkip;
289
318
  /**
@@ -927,6 +956,24 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
927
956
  update(args: UpdateArgs<T, R>): Promise<T>;
928
957
  private nestedCreate;
929
958
  private nestedUpdate;
959
+ /**
960
+ * Check out a pooled connection, translating a driver failure into a typed
961
+ * Turbine error.
962
+ *
963
+ * `pool.connect()` is where the first-run failures land: wrong password
964
+ * (SQLSTATE 28P01), no such database (3D000), nothing listening
965
+ * (ECONNREFUSED), an unverifiable TLS certificate. Unwrapped, every one of
966
+ * those surfaces from an ordinary `db.users.create({ data: { ...nested } })`
967
+ * as a raw pg `DatabaseError` whose `.code` is a SQLSTATE, on the same
968
+ * property Turbine puts `TURBINE_E0NN` in.
969
+ *
970
+ * client.ts has its own copy for `$transaction` / `connect()`; this one
971
+ * exists because `query/` must not import client.ts (circular dependency).
972
+ * The query paths need no equivalent: `pool.query()` opens the connection
973
+ * itself and rejects with the connect error, which the query boundary
974
+ * already wraps.
975
+ */
976
+ private acquireConnection;
930
977
  private runInImplicitTx;
931
978
  private buildNestedCtx;
932
979
  private makeTxProxy;
@@ -20,6 +20,7 @@ import { defaultProjectionFields, includeKeysForBatching, loadRelationsBatched,
20
20
  import { expandCompoundUniqueWhere } from './compound-unique.js';
21
21
  import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, isWhereOperator, orderByEntries, sortedEntries, } from './filters.js';
22
22
  import * as relationsMod from './relations.js';
23
+ import { resolveSkipGlobalFilters, resolveUnsafeFlag, UNSAFE } from './types.js';
23
24
  import { isTemporalInfinity, LRUCache, ownLookup, parseDbDate, resolveColumnName, sqlToPreparedName, unknownFieldMessage, } from './utils.js';
24
25
  import { shouldWarnOnce, WARN_NS } from './warn-registry.js';
25
26
  import * as whereMod from './where.js';
@@ -300,6 +301,49 @@ function maybeExpandCompoundUnique(meta, args) {
300
301
  const expanded = expandCompoundUniqueWhere(meta, where);
301
302
  return expanded === where ? args : { ...args, where: expanded };
302
303
  }
304
+ /**
305
+ * Bridge the nested-write engine's table handles onto the privilege sentinel.
306
+ *
307
+ * `nested-write.ts` clears a `set` relation by calling
308
+ * `updateMany({ where, data, allowFullTableScan: true })`: an INTERNAL,
309
+ * fully-derived predicate (the parent's own FK match, guarded there against a
310
+ * null reference key), not caller input, and the flag is what lets that
311
+ * unconditional-looking statement through the empty-`where` guard. Since
312
+ * `allowFullTableScan` now only accepts {@link UNSAFE}, that literal `true`
313
+ * would throw and every nested `set` would fail.
314
+ *
315
+ * Rather than hand the nested-write engine the sentinel (which would put the
316
+ * privilege value on a second, less obvious import path), the translation
317
+ * happens HERE, at the single seam where core hands it a table accessor. The
318
+ * rewrite is deliberately narrow: `updateMany` only, and only when the value is
319
+ * exactly `true`, so nothing else about the call is touched and no other
320
+ * operation gains an escape hatch.
321
+ *
322
+ * @internal Exported for its unit test; not part of the public surface.
323
+ */
324
+ export function unlockNestedWriteTx(tx) {
325
+ const wrapped = {
326
+ table(name) {
327
+ const handle = tx.table(name);
328
+ // A Proxy rather than a copied object: the handle is a class instance
329
+ // whose methods live on the prototype, so a spread would produce an
330
+ // object with no methods, and re-listing them by hand would silently
331
+ // drop any method added later.
332
+ return new Proxy(handle, {
333
+ get(target, prop) {
334
+ const value = Reflect.get(target, prop, target);
335
+ if (typeof value !== 'function')
336
+ return value;
337
+ const method = value;
338
+ if (prop !== 'updateMany')
339
+ return method.bind(target);
340
+ return (args) => method.call(target, args?.allowFullTableScan === true ? { ...args, allowFullTableScan: UNSAFE } : args);
341
+ },
342
+ });
343
+ },
344
+ };
345
+ return wrapped;
346
+ }
303
347
  /**
304
348
  * Whether a relation `with`-clause `orderBy` carries no actual ordering: an
305
349
  * empty array, or an object with no non-`undefined` own keys. Used by
@@ -444,6 +488,12 @@ export class QueryInterface {
444
488
  * `orderBy` all see it without threading it through dozens of signatures.
445
489
  * Only load-bearing when {@link globalFilters} is configured; build+collect are
446
490
  * synchronous per call, so this transient is never observed across an await.
491
+ *
492
+ * Holds the RESOLVED form ({@link ResolvedSkipGlobalFilters}), never the raw
493
+ * arg: every assignment goes through `resolveSkipGlobalFilters`, which is
494
+ * where a non-sentinel value (`true`, `['users']`, anything a JSON body can
495
+ * carry) is refused. Keeping the raw value here would push that decision out
496
+ * to each reader, and one reader forgetting it is the whole bug class.
447
497
  */
448
498
  currentSkip;
449
499
  /**
@@ -1335,8 +1385,13 @@ export class QueryInterface {
1335
1385
  // on the batched subset up front.
1336
1386
  rejectNestedPickOrder(batchedWith);
1337
1387
  const skip = args.skipGlobalFilters;
1388
+ // Resolve the sentinel HERE, not just in buildFindMany below: this method
1389
+ // decides the base projection before it ever builds SQL, so a literal
1390
+ // `true` would otherwise shape the projection (truthily) and only be
1391
+ // refused one step later.
1392
+ const includePii = resolveUnsafeFlag(args.includePii, 'includePii');
1338
1393
  const needed = neededParentKeyFields(this.tableMeta, batchedWith);
1339
- const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, args.includePii));
1394
+ const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, includePii));
1340
1395
  const hasJoin = Object.keys(joinWith).length > 0;
1341
1396
  // Force the residual `with` onto the join plan so the base query never
1342
1397
  // re-enters this auto planning.
@@ -1356,7 +1411,7 @@ export class QueryInterface {
1356
1411
  const rows = deferred.transform(result);
1357
1412
  const entities = single ? (rows ? [rows] : []) : rows;
1358
1413
  if (entities.length > 0) {
1359
- await loadRelationsBatched(this.batchedContext(args.timeout, skip, args.includePii === true, args.forceCustomPlan === true), entities, batchedWith, args.timeout);
1414
+ await loadRelationsBatched(this.batchedContext(args.timeout, skip, args.includePii, args.forceCustomPlan === true), entities, batchedWith, args.timeout);
1360
1415
  }
1361
1416
  stripFields(entities, proj.strip);
1362
1417
  return single ? (entities[0] ?? null) : entities;
@@ -1372,7 +1427,17 @@ export class QueryInterface {
1372
1427
  * and unlimited-warnings silenced, a relation load must fetch every matching
1373
1428
  * child, and the per-relation `limit` is applied client-side by the loader.
1374
1429
  */
1375
- batchedContext(timeout, skip, includePii, forceCustomPlan = false) {
1430
+ batchedContext(timeout, skip,
1431
+ // BOTH privilege values stay in their RAW (sentinel) form here, and that is
1432
+ // load-bearing rather than laziness: the batched loader re-enters
1433
+ // `buildFindMany` one level down with these exact values on the child args,
1434
+ // where they are resolved (and refused) again. Handing it a plain `true`
1435
+ // would make every relation follow-up throw. The caller has already
1436
+ // resolved them once for its own projection decisions, so an invalid value
1437
+ // never reaches this point.
1438
+ includePii, forceCustomPlan = false) {
1439
+ // The loader's own global-filter callback below needs the RESOLVED form.
1440
+ const resolvedSkip = resolveSkipGlobalFilters(skip);
1376
1441
  const childOptions = {
1377
1442
  ...this.options,
1378
1443
  defaultLimit: undefined,
@@ -1396,7 +1461,7 @@ export class QueryInterface {
1396
1461
  // so a batched load excludes/includes PII exactly as the join strategy.
1397
1462
  includePii,
1398
1463
  tableGlobalFilter: (table, alias, precedingParams) => {
1399
- const gf = this.resolveGlobalFilter(table, skip);
1464
+ const gf = this.resolveGlobalFilter(table, resolvedSkip);
1400
1465
  if (!gf)
1401
1466
  return null;
1402
1467
  const meta = this.schema.tables[table];
@@ -1441,7 +1506,7 @@ export class QueryInterface {
1441
1506
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
1442
1507
  const entities = deferred.transform(result);
1443
1508
  if (entities.length > 0) {
1444
- await loadRelationsBatched(this.batchedContext(args.timeout, skip, args.includePii === true, args.forceCustomPlan === true), entities, withClause, args.timeout);
1509
+ await loadRelationsBatched(this.batchedContext(args.timeout, skip, args.includePii, args.forceCustomPlan === true), entities, withClause, args.timeout);
1445
1510
  }
1446
1511
  stripFields(entities, strip);
1447
1512
  return entities;
@@ -1453,7 +1518,7 @@ export class QueryInterface {
1453
1518
  */
1454
1519
  prepareBatchedBase(args, withClause) {
1455
1520
  const needed = neededParentKeyFields(this.tableMeta, withClause);
1456
- const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, args.includePii));
1521
+ const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, resolveUnsafeFlag(args.includePii, 'includePii')));
1457
1522
  const baseArgs = {
1458
1523
  ...args,
1459
1524
  with: undefined,
@@ -1826,23 +1891,47 @@ export class QueryInterface {
1826
1891
  // Same scope-rule parity as runFindManyBatched: reject before querying.
1827
1892
  rejectNestedPickOrder(withClause);
1828
1893
  const needed = neededParentKeyFields(this.tableMeta, withClause);
1829
- const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, args.includePii));
1894
+ const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, resolveUnsafeFlag(args.includePii, 'includePii')));
1830
1895
  const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
1831
1896
  const deferred = this.buildFindUnique(baseArgs);
1832
1897
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, this.preparedNameFor(args, deferred.preparedName));
1833
1898
  const entity = deferred.transform(result);
1834
1899
  if (!entity)
1835
1900
  return null;
1836
- await loadRelationsBatched(this.batchedContext(args.timeout, args.skipGlobalFilters, args.includePii === true, args.forceCustomPlan === true), [entity], withClause, args.timeout);
1901
+ await loadRelationsBatched(this.batchedContext(args.timeout, args.skipGlobalFilters, args.includePii, args.forceCustomPlan === true), [entity], withClause, args.timeout);
1837
1902
  stripFields([entity], proj.strip);
1838
1903
  return entity;
1839
1904
  }
1840
1905
  // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause", matches TypedWithClause default
1841
1906
  buildFindUnique(args) {
1842
- this.currentSkip = args.skipGlobalFilters;
1907
+ this.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
1843
1908
  // Prisma compound-unique selector expansion (before global-filter merge and
1844
1909
  // fingerprinting, so the cache only ever sees the canonical expanded where).
1845
1910
  args = maybeExpandCompoundUnique(this.tableMeta, args);
1911
+ // A findUnique whose `where` carries NO predicate must not run.
1912
+ //
1913
+ // `{ id: undefined }` is what `{ id: req.params.id }` becomes on a request
1914
+ // that omitted the parameter, or on a typo'd one. Undefined keys are dropped
1915
+ // downstream, so that used to emit `SELECT … FROM t LIMIT 1`: no predicate,
1916
+ // and the caller gets an ARBITRARY row where it asked for a specific one and
1917
+ // would have handled `null`. On an authorization check keyed by id that is a
1918
+ // silent cross-record read, and `findUniqueOrThrow` made it worse by
1919
+ // promising to throw when nothing matched and then returning a stranger's
1920
+ // row instead.
1921
+ //
1922
+ // Checked against the USER's where, before the global-filter merge, and
1923
+ // deliberately so: a tenant filter is not a unique selector, and letting it
1924
+ // satisfy this would still hand back an arbitrary row from inside the tenant.
1925
+ //
1926
+ // `findFirst` is intentionally NOT guarded. "The first row matching an
1927
+ // optional filter" is its whole contract and Prisma's `findFirst` behaves
1928
+ // the same way; ask for one row by identity with `findUnique`.
1929
+ if (whereMod.userPredicateIsEmpty(this.ctx, (args.where ?? {}))) {
1930
+ throw new ValidationError(`[turbine] findUnique on "${this.table}" refused: the \`where\` clause has no predicate, ` +
1931
+ 'so this would return an arbitrary row rather than a specific one. ' +
1932
+ 'A key whose value is `undefined` does not count, check that the value you are looking up is defined. ' +
1933
+ 'If you meant "any row matching an optional filter", use `findFirst`.');
1934
+ }
1846
1935
  // Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
1847
1936
  // relations before fingerprinting (see buildFindMany).
1848
1937
  if (args.with && this.resolveStableOrder(args.stableRelationOrder)) {
@@ -1850,7 +1939,8 @@ export class QueryInterface {
1850
1939
  if (normalized !== args.with)
1851
1940
  args = { ...args, with: normalized };
1852
1941
  }
1853
- const includePii = args.includePii === true;
1942
+ // Resolved once, see buildFindMany.
1943
+ const includePii = resolveUnsafeFlag(args.includePii, 'includePii');
1854
1944
  // findUnique is never flatten-planned: it reads ONE parent row, so the
1855
1945
  // correlated subquery already runs exactly once and a join buys nothing.
1856
1946
  // Say so, because the caller did ask for a strategy that is not running.
@@ -1961,7 +2051,7 @@ export class QueryInterface {
1961
2051
  this.collectWhereParams(whereObj, params);
1962
2052
  this.collectWithParams(args.with, params);
1963
2053
  this.crossCheckCache('findUnique', ck, entry, buildSql, params);
1964
- const parseWith = this.makeNestedParser(args.with, args.includePii === true);
2054
+ const parseWith = this.makeNestedParser(args.with, includePii);
1965
2055
  return {
1966
2056
  sql: entry.sql,
1967
2057
  params,
@@ -2153,7 +2243,7 @@ export class QueryInterface {
2153
2243
  }
2154
2244
  // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause", matches TypedWithClause default
2155
2245
  buildFindMany(args) {
2156
- this.currentSkip = args?.skipGlobalFilters;
2246
+ this.currentSkip = resolveSkipGlobalFilters(args?.skipGlobalFilters);
2157
2247
  // Stable relation order (opt-in): fill PK-asc orderBy into unordered to-many
2158
2248
  // relations BEFORE fingerprinting, so the two orderings get distinct cache
2159
2249
  // entries and every downstream path (SQL build, collect, parser) inherits it.
@@ -2194,7 +2284,10 @@ export class QueryInterface {
2194
2284
  }
2195
2285
  }
2196
2286
  }
2197
- const includePii = args?.includePii === true;
2287
+ // Resolve the PII opt-in ONCE, before any projection decision. The value is
2288
+ // the UNSAFE sentinel or nothing: a literal `true` (what a spread request
2289
+ // body carries) throws here rather than unlocking the projection.
2290
+ const includePii = resolveUnsafeFlag(args?.includePii, 'includePii');
2198
2291
  const columnsList = this.resolveColumns(args?.select, args?.omit, includePii);
2199
2292
  const colKey = columnsList ? columnsList.join(',') : '*';
2200
2293
  // AND-merge this table's global filter into the user where; `hasWhere` gates
@@ -2445,9 +2538,14 @@ export class QueryInterface {
2445
2538
  // function of the schema, the `with` shape and `includePii`, never of
2446
2539
  // `limit`, so the batch-size override the speculative fetch applies cannot
2447
2540
  // change it, and the stream's parser matches the emitted SQL.
2448
- const streamFlattenPlan = hasRelations ? this.planFlatten(args, args?.includePii === true) : null;
2541
+ // Resolved once for the whole stream: the flatten plan and the row parser
2542
+ // MUST agree with the SQL buildFindMany emits below, and reading the raw
2543
+ // sentinel with `=== true` here would have quietly planned a no-PII parser
2544
+ // over a with-PII statement.
2545
+ const streamPii = resolveUnsafeFlag(args?.includePii, 'includePii');
2546
+ const streamFlattenPlan = hasRelations ? this.planFlatten(args, streamPii) : null;
2449
2547
  const parseWith = hasRelations
2450
- ? this.makeNestedParser(args.with, args?.includePii === true, streamFlattenPlan)
2548
+ ? this.makeNestedParser(args.with, streamPii, streamFlattenPlan)
2451
2549
  : null;
2452
2550
  // --- Speculative first fetch: try to satisfy the entire drain in one RTT ---
2453
2551
  const speculativeDeferred = this.buildFindMany({
@@ -2481,7 +2579,7 @@ export class QueryInterface {
2481
2579
  // connection, so `pool.query` already IS that connection. The stream rides
2482
2580
  // on it, is never released here, and the dialect is told to emit no
2483
2581
  // transaction control of its own (`ambientTransaction`).
2484
- const client = this.txScoped ? null : await this.pool.connect();
2582
+ const client = this.txScoped ? null : await this.acquireConnection();
2485
2583
  const conn = client ?? {
2486
2584
  query: async (text, values) => (await this.pool.query(text, values)),
2487
2585
  };
@@ -2663,8 +2761,33 @@ export class QueryInterface {
2663
2761
  return result;
2664
2762
  });
2665
2763
  }
2764
+ /**
2765
+ * Check out a pooled connection, translating a driver failure into a typed
2766
+ * Turbine error.
2767
+ *
2768
+ * `pool.connect()` is where the first-run failures land: wrong password
2769
+ * (SQLSTATE 28P01), no such database (3D000), nothing listening
2770
+ * (ECONNREFUSED), an unverifiable TLS certificate. Unwrapped, every one of
2771
+ * those surfaces from an ordinary `db.users.create({ data: { ...nested } })`
2772
+ * as a raw pg `DatabaseError` whose `.code` is a SQLSTATE, on the same
2773
+ * property Turbine puts `TURBINE_E0NN` in.
2774
+ *
2775
+ * client.ts has its own copy for `$transaction` / `connect()`; this one
2776
+ * exists because `query/` must not import client.ts (circular dependency).
2777
+ * The query paths need no equivalent: `pool.query()` opens the connection
2778
+ * itself and rejects with the connect error, which the query boundary
2779
+ * already wraps.
2780
+ */
2781
+ async acquireConnection() {
2782
+ try {
2783
+ return await this.pool.connect();
2784
+ }
2785
+ catch (err) {
2786
+ throw wrapPgError(err);
2787
+ }
2788
+ }
2666
2789
  async runInImplicitTx(fn) {
2667
- const client = await this.pool.connect();
2790
+ const client = await this.acquireConnection();
2668
2791
  let began = false;
2669
2792
  try {
2670
2793
  await client.query(this.dialect.beginStatement());
@@ -2681,8 +2804,12 @@ export class QueryInterface {
2681
2804
  // older-engine client falls back to the full capability set inside the
2682
2805
  // implicit transaction.
2683
2806
  this.pool);
2684
- // biome-ignore lint/suspicious/noExplicitAny: TransactionClient satisfies NestedWriteContext['tx'] at runtime
2685
- const ctx = { schema: this.schema, tx: tx, scopedConnect: this.scopedConnect };
2807
+ const ctx = {
2808
+ schema: this.schema,
2809
+ // biome-ignore lint/suspicious/noExplicitAny: TransactionClient satisfies NestedWriteContext['tx'] at runtime
2810
+ tx: unlockNestedWriteTx(tx),
2811
+ scopedConnect: this.scopedConnect,
2812
+ };
2686
2813
  const result = await fn(ctx);
2687
2814
  await client.query(this.dialect.commitStatement());
2688
2815
  return result;
@@ -2711,7 +2838,7 @@ export class QueryInterface {
2711
2838
  const opts = { ...this.options, _txScoped: true };
2712
2839
  return {
2713
2840
  schema,
2714
- tx: this.makeTxProxy(pool, schema, middlewares, opts),
2841
+ tx: unlockNestedWriteTx(this.makeTxProxy(pool, schema, middlewares, opts)),
2715
2842
  };
2716
2843
  }
2717
2844
  // biome-ignore lint/suspicious/noExplicitAny: bridges MiddlewareFn[] ↔ Middleware[] and QI ↔ NestedWriteContext type gap
@@ -2769,7 +2896,7 @@ export class QueryInterface {
2769
2896
  });
2770
2897
  }
2771
2898
  buildCount(args) {
2772
- this.currentSkip = args?.skipGlobalFilters;
2899
+ this.currentSkip = resolveSkipGlobalFilters(args?.skipGlobalFilters);
2773
2900
  const effWhere = this.mergeGlobalFilter(args?.where);
2774
2901
  const hasWhere = effWhere !== undefined;
2775
2902
  const whereObj = (effWhere ?? {});
@@ -5,11 +5,13 @@
5
5
  * `import { … } from './query/index.js'` is a drop-in replacement for the
6
6
  * former monolithic `import { … } from './query.js'`.
7
7
  */
8
- export type { AggregateArgs, AggregateResult, ArrayFilter, ColumnRef, ConnectOrCreateOp, CountArgs, CreateArgs, CreateDataInput, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GlobalFilters, GroupByAggregateSpec, GroupByArgs, GroupByDistinctOn, GroupByResult, HavingClause, JsonFilter, JsonPathAggregateTarget, JsonPathGroupKey, JsonPathOrderBy, NestedCreateOp, NestedUpdateOp, NestedUpdateOpItem, NestedUpsertOpItem, OmitResult, OrderByClause, OrderByObject, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, RelationLoadStrategy, RelationPickBy, RelationPickOrderBy, SelectResult, SkipGlobalFilters, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateDataInput, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, VectorDistanceFilter, VectorFilter, VectorMetric, VectorOrderBy, VectorOrderByDistance, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithOrderByObject, WithResult, } from './types.js';
8
+ export type { AggregateArgs, AggregateResult, ArrayFilter, ColumnRef, ConnectOrCreateOp, CountArgs, CreateArgs, CreateDataInput, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GlobalFilters, GroupByAggregateSpec, GroupByArgs, GroupByDistinctOn, GroupByResult, HavingClause, JsonFilter, JsonPathAggregateTarget, JsonPathGroupKey, JsonPathOrderBy, NestedCreateOp, NestedUpdateOp, NestedUpdateOpItem, NestedUpsertOpItem, OmitResult, OrderByClause, OrderByObject, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, RelationLoadStrategy, RelationPickBy, RelationPickOrderBy, ResolvedSkipGlobalFilters, SelectResult, SkipGlobalFilters, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateDataInput, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, VectorDistanceFilter, VectorFilter, VectorMetric, VectorOrderBy, VectorOrderByDistance, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithOrderByObject, WithResult, } from './types.js';
9
9
  export type { BuiltStatement, BulkInsertStatementInput, ColumnDefinitionInput, ColumnTypeInput, CreateIndexStatementInput, CreateTableStatementInput, Dialect, InsertStatementInput, UpsertStatementInput, } from '../dialect.js';
10
10
  export { postgresDialect } from '../dialect.js';
11
11
  export type { OptionKind, OptionTable } from './option-surface.js';
12
12
  export { AGGREGATE_OPTIONS, ALL_OPTION_TABLES, applyNativeOptions, COUNT_OPTIONS, CREATE_MANY_OPTIONS, CREATE_OPTIONS, DELETE_MANY_OPTIONS, DELETE_OPTIONS, FIND_MANY_OPTIONS, FIND_MANY_STREAM_OPTIONS, FIND_UNIQUE_OPTIONS, GROUP_BY_OPTIONS, optionKeysOfKind, UPDATE_MANY_OPTIONS, UPDATE_OPTIONS, UPSERT_OPTIONS, } from './option-surface.js';
13
+ export type { PrivilegeOption, Unsafe } from './types.js';
14
+ export { assertOrderDirection, resolveSkipGlobalFilters, resolveUnsafeFlag, UNSAFE } from './types.js';
13
15
  export type { SqlCacheEntry } from './utils.js';
14
16
  export { buildCorrelation, escapeLike, escSingleQuote, fnv1a64Hex, LRUCache, OPERATOR_KEYS, quoteIdent, sqlToPreparedName, } from './utils.js';
15
17
  export type { DeferredQuery, MiddlewareFn, QueryEvent, QueryEventListener, QueryInterfaceOptions, ReselectExecutor, TemporalInfinityReading, } from './builder.js';
@@ -7,5 +7,7 @@
7
7
  */
8
8
  export { postgresDialect } from '../dialect.js';
9
9
  export { AGGREGATE_OPTIONS, ALL_OPTION_TABLES, applyNativeOptions, COUNT_OPTIONS, CREATE_MANY_OPTIONS, CREATE_OPTIONS, DELETE_MANY_OPTIONS, DELETE_OPTIONS, FIND_MANY_OPTIONS, FIND_MANY_STREAM_OPTIONS, FIND_UNIQUE_OPTIONS, GROUP_BY_OPTIONS, optionKeysOfKind, UPDATE_MANY_OPTIONS, UPDATE_OPTIONS, UPSERT_OPTIONS, } from './option-surface.js';
10
+ // The privilege sentinel: a runtime value, not a type (see types.ts).
11
+ export { assertOrderDirection, resolveSkipGlobalFilters, resolveUnsafeFlag, UNSAFE } from './types.js';
10
12
  export { buildCorrelation, escapeLike, escSingleQuote, fnv1a64Hex, LRUCache, OPERATOR_KEYS, quoteIdent, sqlToPreparedName, } from './utils.js';
11
13
  export { AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_COUNT_BATCH_MIN_PARENT_ROWS, AUTO_JOIN_PENALTY_MS_PER_ROW, AUTO_TO_ONE_JOIN_MAX_ROWS, AUTO_TO_ONE_JOIN_ROWS_MAX, AUTO_TO_ONE_JOIN_ROWS_MIN, QueryInterface, } from './builder.js';
@@ -38,6 +38,17 @@
38
38
  * column, or model NAME. If the value names anything in the schema, it is
39
39
  * `'prisma'`: a name-translating consumer has to walk it by hand.
40
40
  *
41
+ * ## The privilege options
42
+ *
43
+ * `skipGlobalFilters`, `includePii` and `allowFullTableScan` are `'native'` and
44
+ * MUST stay that way. Their values are the `UNSAFE` sentinel (plus, for
45
+ * skipGlobalFilters, table ACCESSOR names, which the rule above explicitly
46
+ * allows), and forwarding them verbatim is what lets core be the single place
47
+ * that decides whether a value is a real opt-in. Hand-translating them here
48
+ * would mean a second copy of that decision, and a consumer's
49
+ * `includePii: true` would then be judged by whichever copy it happened to
50
+ * reach first.
51
+ *
41
52
  * @module
42
53
  */
43
54
  import type { AggregateArgs, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, UpdateArgs, UpdateManyArgs, UpsertArgs } from './types.js';
@@ -38,6 +38,17 @@
38
38
  * column, or model NAME. If the value names anything in the schema, it is
39
39
  * `'prisma'`: a name-translating consumer has to walk it by hand.
40
40
  *
41
+ * ## The privilege options
42
+ *
43
+ * `skipGlobalFilters`, `includePii` and `allowFullTableScan` are `'native'` and
44
+ * MUST stay that way. Their values are the `UNSAFE` sentinel (plus, for
45
+ * skipGlobalFilters, table ACCESSOR names, which the rule above explicitly
46
+ * allows), and forwarding them verbatim is what lets core be the single place
47
+ * that decides whether a value is a real opt-in. Hand-translating them here
48
+ * would mean a second copy of that decision, and a consumer's
49
+ * `includePii: true` would then be judged by whichever copy it happened to
50
+ * reach first.
51
+ *
41
52
  * @module
42
53
  */
43
54
  export const FIND_UNIQUE_OPTIONS = {
@@ -52,6 +63,8 @@ export const FIND_UNIQUE_OPTIONS = {
52
63
  relationLoadStrategy: 'prisma',
53
64
  timeout: 'native',
54
65
  stableRelationOrder: 'native',
66
+ // Privilege options: forwarded verbatim, refused by core unless the value is
67
+ // the UNSAFE sentinel. See the note at the top of this file.
55
68
  skipGlobalFilters: 'native',
56
69
  includePii: 'native',
57
70
  forceCustomPlan: 'native',
@@ -59,6 +59,14 @@ export declare function collectRelationSubqueryParams(qi: BuilderCtx, relDef: Re
59
59
  * ORDER BY differs only in nulls placement, vector metric, or relation-count
60
60
  * vs relation-column never collide on one cached SQL string. Captures the
61
61
  * SQL-shaping bits (direction, nulls, metric, relation keys), never values.
62
+ *
63
+ * NOTE for the direction guard (see {@link assertOrderDirection}): every
64
+ * direction is embedded here BYTE-FOR-BYTE (`String(d)` for the plain form,
65
+ * `d.sort` / `d.direction` for the spec forms), never normalized to ASC/DESC.
66
+ * That is what lets the direction validation live on the BUILD side alone: a
67
+ * bad direction has a fingerprint no good direction can produce, so it can
68
+ * never be served by a warm cache entry whose build already validated. Do not
69
+ * "tidy" this into a normalized token.
62
70
  */
63
71
  export declare function orderByEntryFingerprint(qi: BuilderCtx, d: unknown, targetTable?: string): string;
64
72
  export declare function buildOrderBy(qi: BuilderCtx, orderBy: OrderByClause, params?: unknown[], lateralSink?: string[]): string;
@@ -17,6 +17,7 @@ import { missingIndexForRelation } from '../index-advisor.js';
17
17
  import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
18
18
  import { resolveCountRelations } from './batched-loader.js';
19
19
  import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, normalizeOrderBy, orderByEntries, sortedEntries, } from './filters.js';
20
+ import { assertDirectionToken, assertOrderDirection } from './types.js';
20
21
  import { ownLookup } from './utils.js';
21
22
  import { hasWarnedOnce, shouldWarnOnce, WARN_NS } from './warn-registry.js';
22
23
  import * as whereMod from './where.js';
@@ -42,7 +43,7 @@ export function resolveColumns(qi, select, omit, includePii) {
42
43
  .map(([k]) => qi.toColumn(k));
43
44
  }
44
45
  // Default / omit-only projection: PII-tagged columns are excluded unless the
45
- // caller passed `includePii: true`. An empty set (untagged schema) keeps the
46
+ // caller opted in with `includePii: UNSAFE`. An empty set (untagged schema) keeps the
46
47
  // `null`/`*` fast path so the emitted SQL is byte-identical to before.
47
48
  const piiCols = includePii ? undefined : writesMod.piiColumns(qi, qi.tableMeta);
48
49
  const hasPii = piiCols !== undefined && piiCols.size > 0;
@@ -264,6 +265,14 @@ export function collectRelationSubqueryParams(qi, relDef, spec, params, _parentR
264
265
  * ORDER BY differs only in nulls placement, vector metric, or relation-count
265
266
  * vs relation-column never collide on one cached SQL string. Captures the
266
267
  * SQL-shaping bits (direction, nulls, metric, relation keys), never values.
268
+ *
269
+ * NOTE for the direction guard (see {@link assertOrderDirection}): every
270
+ * direction is embedded here BYTE-FOR-BYTE (`String(d)` for the plain form,
271
+ * `d.sort` / `d.direction` for the spec forms), never normalized to ASC/DESC.
272
+ * That is what lets the direction validation live on the BUILD side alone: a
273
+ * bad direction has a fingerprint no good direction can produce, so it can
274
+ * never be served by a warm cache entry whose build already validated. Do not
275
+ * "tidy" this into a normalized token.
267
276
  */
268
277
  export function orderByEntryFingerprint(qi, d, targetTable) {
269
278
  // Vector KNN ordering changes the emitted operator by metric and adds a
@@ -347,6 +356,7 @@ export function buildOrderBy(qi, orderBy, params, lateralSink) {
347
356
  const rawColumn = qi.toColumn(key);
348
357
  const operator = whereMod.vectorOperator(qi, key, rawColumn, value.distance.metric);
349
358
  const placeholder = whereMod.pushVectorParam(qi, key, rawColumn, value.distance.to, params);
359
+ assertDirectionToken(value.distance.direction, `vector distance orderBy on "${key}"`);
350
360
  const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
351
361
  return `${qi.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
352
362
  }
@@ -366,6 +376,10 @@ export function buildOrderBy(qi, orderBy, params, lateralSink) {
366
376
  throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${qi.table}". ` +
367
377
  `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
368
378
  }
379
+ // Refuse a direction that is neither asc nor desc. normalizeOrderBy is
380
+ // `=== 'desc' ? DESC : ASC`, so without this every typo sorted ASCENDING
381
+ // and returned a correct-looking page in the reverse order.
382
+ assertOrderDirection(value, `orderBy "${key}" on table "${qi.table}"`);
369
383
  const { dir, nulls } = normalizeOrderBy(value);
370
384
  return `${qi.toSqlColumn(key)} ${dir}${nullsSuffix(qi, nulls)}`;
371
385
  })
@@ -452,6 +466,7 @@ export function buildJsonPathOrderEntry(qi, table, meta, field, spec, prefix, pa
452
466
  params.push(whereMod.jsonPathParam(qi, spec.path));
453
467
  const extract = qi.dialect.buildJsonPathExtract(`${prefix}${qi.q(col)}`, qi.p(params.length));
454
468
  const lhs = spec.type === 'numeric' ? whereMod.castJsonNumeric(qi, extract) : extract;
469
+ assertDirectionToken(spec.direction, `JSON-path orderBy on "${field}"`);
455
470
  const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
456
471
  // Rows whose document lacks the path extract to NULL. Without a nulls
457
472
  // clause, Postgres DESC defaults to NULLS FIRST, which both diverges from
@@ -546,6 +561,7 @@ function buildChainedToOneOrderBy(qi, head, nextRelName, nextValue, params) {
546
561
  if (!currentMeta.allColumns.includes(snakeCol)) {
547
562
  throw new ValidationError(`[turbine] Unknown column "${key}" in orderBy on relation "${path.join('.')}" (table "${currentMeta.name}").`);
548
563
  }
564
+ assertOrderDirection(entryValue, `orderBy on relation path "${path.join('.')}"`);
549
565
  const { dir, nulls } = normalizeOrderBy(entryValue);
550
566
  let where = head.correlation;
551
567
  if (params) {
@@ -608,6 +624,7 @@ export function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lat
608
624
  throw new ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
609
625
  `or a pick-row ordering ({ pick, by }) (got: ${keys.join(', ') || '(empty)'}).`);
610
626
  }
627
+ assertOrderDirection(value._count, `orderBy { ${relName}: { _count } }`);
611
628
  const { dir } = normalizeOrderBy(value._count);
612
629
  return `${buildRelationCountExpr(qi, relDef, parentRef, alias, params)} ${dir}`;
613
630
  }
@@ -645,6 +662,7 @@ export function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lat
645
662
  : '';
646
663
  throw new ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").${relationHint}`);
647
664
  }
665
+ assertOrderDirection(dirValue, `orderBy "${col}" on relation "${relName}"`);
648
666
  const { dir, nulls } = normalizeOrderBy(dirValue);
649
667
  // Target's global filter applies here too, otherwise ordering keys off
650
668
  // a soft-deleted / other-tenant related row's value (matches the with
@@ -746,6 +764,7 @@ export function buildRelationPickOrderBy(qi, relName, relDef, spec, alias, paren
746
764
  const targetMeta = qi.schema.tables[relDef.to];
747
765
  if (!targetMeta)
748
766
  throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
767
+ assertDirectionToken(spec.direction, `pick-row orderBy on relation "${relName}"`);
749
768
  const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
750
769
  const limitOne = qi.buildPagination('1', undefined, true);
751
770
  // Parents with ZERO surviving related rows have no row to pick: the
@@ -880,6 +899,7 @@ export function buildRelationOrderClause(qi, targetTable, targetMeta, alias, ord
880
899
  return buildRelationOrderBy(qi, key, dirValue, `${alias}ord${relOrdCounter++}`, params, { meta: targetMeta, table: targetTable, parentRef: alias });
881
900
  }
882
901
  const col = resolveOrderByColumn(qi, targetTable, targetMeta, key);
902
+ assertOrderDirection(dirValue, `orderBy "${key}" in the \`with\` clause for "${targetTable}"`);
883
903
  const { dir, nulls } = normalizeOrderBy(dirValue);
884
904
  return `${alias}.${qi.q(col)} ${dir}${nullsSuffix(qi, nulls)}`;
885
905
  })