turbine-orm 0.61.0 → 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 (82) hide show
  1. package/README.md +65 -21
  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 +323 -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 +186 -3
  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.js +15 -2
  21. package/dist/cjs/powql.d.ts +12 -0
  22. package/dist/cjs/powql.js +46 -21
  23. package/dist/cjs/prisma-compat.d.ts +15 -5
  24. package/dist/cjs/prisma-compat.js +273 -78
  25. package/dist/cjs/query/aggregates.d.ts +1 -1
  26. package/dist/cjs/query/aggregates.js +24 -10
  27. package/dist/cjs/query/batched-loader.d.ts +9 -4
  28. package/dist/cjs/query/batched-loader.js +4 -1
  29. package/dist/cjs/query/builder.d.ts +47 -0
  30. package/dist/cjs/query/builder.js +125 -21
  31. package/dist/cjs/query/index.d.ts +3 -1
  32. package/dist/cjs/query/index.js +7 -1
  33. package/dist/cjs/query/option-surface.d.ts +11 -0
  34. package/dist/cjs/query/option-surface.js +13 -0
  35. package/dist/cjs/query/relations.d.ts +8 -0
  36. package/dist/cjs/query/relations.js +21 -1
  37. package/dist/cjs/query/types.d.ts +152 -18
  38. package/dist/cjs/query/types.js +212 -1
  39. package/dist/cjs/query/where.d.ts +3 -3
  40. package/dist/cjs/query/where.js +8 -2
  41. package/dist/cjs/query/writes.js +10 -9
  42. package/dist/cli/config.d.ts +40 -0
  43. package/dist/cli/config.js +73 -2
  44. package/dist/cli/index.d.ts +85 -1
  45. package/dist/cli/index.js +321 -26
  46. package/dist/cli/mcp.d.ts +8 -0
  47. package/dist/cli/mcp.js +448 -29
  48. package/dist/cli/pii-tags.d.ts +64 -9
  49. package/dist/cli/pii-tags.js +217 -39
  50. package/dist/cli/studio-ui.generated.js +1 -1
  51. package/dist/cli/studio.d.ts +23 -0
  52. package/dist/cli/studio.js +125 -53
  53. package/dist/cli/ui.d.ts +15 -1
  54. package/dist/cli/ui.js +18 -4
  55. package/dist/client.js +187 -4
  56. package/dist/errors.d.ts +38 -1
  57. package/dist/errors.js +234 -23
  58. package/dist/index.d.ts +2 -2
  59. package/dist/index.js +5 -2
  60. package/dist/pipeline.js +15 -2
  61. package/dist/powql.d.ts +12 -0
  62. package/dist/powql.js +46 -21
  63. package/dist/prisma-compat.d.ts +15 -5
  64. package/dist/prisma-compat.js +274 -79
  65. package/dist/query/aggregates.d.ts +1 -1
  66. package/dist/query/aggregates.js +24 -10
  67. package/dist/query/batched-loader.d.ts +9 -4
  68. package/dist/query/batched-loader.js +4 -1
  69. package/dist/query/builder.d.ts +47 -0
  70. package/dist/query/builder.js +124 -21
  71. package/dist/query/index.d.ts +3 -1
  72. package/dist/query/index.js +2 -0
  73. package/dist/query/option-surface.d.ts +11 -0
  74. package/dist/query/option-surface.js +13 -0
  75. package/dist/query/relations.d.ts +8 -0
  76. package/dist/query/relations.js +21 -1
  77. package/dist/query/types.d.ts +152 -18
  78. package/dist/query/types.js +207 -2
  79. package/dist/query/where.d.ts +3 -3
  80. package/dist/query/where.js +8 -2
  81. package/dist/query/writes.js +10 -9
  82. package/package.json +13 -3
@@ -1,8 +1,122 @@
1
1
  /**
2
2
  * turbine-orm, Query builder types
3
3
  *
4
- * All exported type and interface definitions for the query builder module.
4
+ * All exported type and interface definitions for the query builder module,
5
+ * plus the small runtime guards that police the values those types describe
6
+ * (the {@link UNSAFE} sentinel and the orderBy-direction check). Those guards
7
+ * live here, next to the declarations they enforce, so a new privilege option
8
+ * or direction-bearing shape has one obvious place to be wired in.
5
9
  */
10
+ /**
11
+ * The value that unlocks a PRIVILEGE option: `skipGlobalFilters`,
12
+ * `includePii`, `allowFullTableScan`.
13
+ *
14
+ * ## The bug this closes
15
+ *
16
+ * All three of those options are ordinary siblings of `where` on the query-args
17
+ * object, so a handler written the idiomatic way,
18
+ *
19
+ * ```ts
20
+ * app.get('/users', (req, res) => db.users.findMany({ ...req.body }));
21
+ * ```
22
+ *
23
+ * used to let the REQUEST BODY turn them on. `{"where":{"name":"x"},
24
+ * "skipGlobalFilters":true}` compiled to the same statement minus the tenant
25
+ * predicate: the documented multi-tenancy mechanism, removed by an attacker
26
+ * over the wire. `includePii: true` unlocked the PII projection the same way,
27
+ * and `allowFullTableScan: true` disarmed the empty-`where` guard that stops a
28
+ * mass update or delete.
29
+ *
30
+ * Typing the options `boolean` and writing "be careful" in the docs is not a
31
+ * fix: the escalation is a mass-assignment shape, and mass assignment happens
32
+ * because nobody enumerated the keys.
33
+ *
34
+ * ## Why a symbol
35
+ *
36
+ * `JSON.parse` cannot produce a symbol. There is no JSON document, no query
37
+ * string, no form body and no `structuredClone` of untrusted input that yields
38
+ * this value, so an attacker cannot put it on an args object at all. The
39
+ * escalation stops being "discouraged" and becomes STRUCTURALLY impossible,
40
+ * which is the only property that survives a refactor.
41
+ *
42
+ * Registered via `Symbol.for` rather than `Symbol()` on purpose: this package
43
+ * ships dual ESM + CJS builds, and a consumer can easily import `UNSAFE` from
44
+ * one copy while the query interface it calls came from the other. A plain
45
+ * `Symbol()` would be a different value in each copy and every privileged call
46
+ * would throw. The global registry makes the two copies agree. (Reachability of
47
+ * `Symbol.for` from application code is irrelevant to the threat model here:
48
+ * the attacker's channel is parsed data, which cannot carry a symbol either
49
+ * way.)
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * import { UNSAFE } from 'turbine-orm';
54
+ *
55
+ * // Deliberate, in a background job that legitimately crosses tenants:
56
+ * await db.users.findMany({ where: { active: true }, skipGlobalFilters: UNSAFE });
57
+ *
58
+ * // Skip the global filter on named tables only (the head element is the opt-in):
59
+ * await db.users.findMany({ with: { posts: true }, skipGlobalFilters: [UNSAFE, 'posts'] });
60
+ * ```
61
+ */
62
+ export declare const UNSAFE: unique symbol;
63
+ /** The type of the {@link UNSAFE} sentinel. */
64
+ export type Unsafe = typeof UNSAFE;
65
+ /** Every privilege option, for the diagnostic below and for tests to enumerate. */
66
+ export type PrivilegeOption = 'skipGlobalFilters' | 'includePii' | 'allowFullTableScan';
67
+ /**
68
+ * Resolve a boolean-shaped privilege option (`includePii`,
69
+ * `allowFullTableScan`) into the boolean the builders use.
70
+ *
71
+ * - absent / `undefined` / `null` / `false` → `false`. These are unambiguously
72
+ * NOT a request for the privilege, and throwing on them would break the
73
+ * ordinary `allowFullTableScan: someFlag` call whose flag is off (prisma-compat
74
+ * relies on `allowFullTableScan: false` staying a no-op, see its updateMany).
75
+ * - {@link UNSAFE} → `true`.
76
+ * - ANYTHING else, `true` included → throws {@link ValidationError} (E003).
77
+ *
78
+ * The literal `true` throws rather than being ignored ON PURPOSE. Ignoring it
79
+ * would swap an escalation bug for a silent-failure bug: a legitimate caller
80
+ * who has not migrated would keep reading rows with the PII columns quietly
81
+ * missing, or keep expecting a cross-tenant read that no longer happens, with
82
+ * no signal anywhere. Throwing turns the attacker's spread into a 500 and the
83
+ * legitimate caller's stale code into an immediate, self-describing error.
84
+ */
85
+ export declare function resolveUnsafeFlag(value: unknown, option: PrivilegeOption): boolean;
86
+ /**
87
+ * The internal form of {@link SkipGlobalFilters}: `true` (skip every table) or
88
+ * the list of table accessors to skip. This is what the global-filter resolver
89
+ * consumes, and it is produced ONLY by {@link resolveSkipGlobalFilters}.
90
+ */
91
+ export type ResolvedSkipGlobalFilters = true | readonly string[];
92
+ /**
93
+ * Validate and normalize {@link SkipGlobalFilters}.
94
+ *
95
+ * The array form is policed exactly as hard as the bare form: an attacker who
96
+ * can post `{"skipGlobalFilters":["users"]}` drops the tenant predicate on the
97
+ * table they care about, which is the same breach with an extra step. So the
98
+ * array must LEAD with the sentinel (`[UNSAFE, 'posts']`) and the rest must be
99
+ * table-name strings.
100
+ */
101
+ export declare function resolveSkipGlobalFilters(value: unknown): ResolvedSkipGlobalFilters | undefined;
102
+ /**
103
+ * Refuse an orderBy direction that is neither `asc` nor `desc`.
104
+ *
105
+ * Every direction consumer used to be spelled `String(v).toLowerCase() ===
106
+ * 'desc' ? 'DESC' : 'ASC'`, so `'descending'`, `''`, `null`, `1` and every
107
+ * other typo all emitted `ASC`. TypeScript rejects them, but the value that
108
+ * reaches this code in practice is `orderBy: { [field]: req.query.dir }`, which
109
+ * is `any` at the boundary, and the failure is SILENT: the caller gets a
110
+ * correct-looking page sorted the exact opposite way, which is worse than an
111
+ * error and identical to a successful response.
112
+ *
113
+ * Accepts any casing (`'DESC'` already worked) and leaves `undefined` alone
114
+ * (every consumer treats an undefined entry as absent). Recursive/compound
115
+ * orderBy shapes are validated one node at a time by their own consumers.
116
+ */
117
+ export declare function assertOrderDirection(value: unknown, context: string): void;
118
+ /** One direction token. `undefined` means "not specified", which defaults to asc. */
119
+ export declare function assertDirectionToken(value: unknown, context: string): void;
6
120
  export type OrderDirection = 'asc' | 'desc';
7
121
  /**
8
122
  * How a query resolves its `with` relations.
@@ -186,13 +300,18 @@ export type GlobalFilters = {
186
300
  [tableAccessor: string]: WhereClause<any> | (() => WhereClause<any>);
187
301
  };
188
302
  /**
189
- * Per-query opt-out of the configured {@link GlobalFilters}. `true` skips the
190
- * global filter on the query's own table AND on every relation target it
191
- * touches; an array skips only the named table accessors (own table and/or
192
- * relation targets). Global filters never satisfy the empty-`where` guard for
193
- * `update`/`delete`, that guard always checks the user-supplied `where`.
303
+ * Per-query opt-out of the configured {@link GlobalFilters}. {@link UNSAFE}
304
+ * skips the global filter on the query's own table AND on every relation target
305
+ * it touches; `[UNSAFE, ...names]` skips only the named table accessors (own
306
+ * table and/or relation targets). Global filters never satisfy the empty-`where`
307
+ * guard for `update`/`delete`, that guard always checks the user-supplied
308
+ * `where`.
309
+ *
310
+ * PRIVILEGE OPTION: it removes the documented multi-tenancy boundary, so it is
311
+ * unlocked by the {@link UNSAFE} sentinel only. `true` / `['users']` throw
312
+ * (E003). See {@link UNSAFE} for why.
194
313
  */
195
- export type SkipGlobalFilters = true | readonly string[];
314
+ export type SkipGlobalFilters = Unsafe | readonly [Unsafe, ...string[]];
196
315
  /**
197
316
  * Reserved key in a `with` clause that requests correlated relation counts.
198
317
  * `_count: true` counts every to-many relation of the table; a record form
@@ -463,8 +582,8 @@ export interface FindUniqueArgs<T, R extends object = {}, W extends TypedWithCla
463
582
  stableRelationOrder?: boolean;
464
583
  /** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
465
584
  skipGlobalFilters?: SkipGlobalFilters;
466
- /** Include PII-tagged columns in the result. See {@link FindManyArgs.includePii}. */
467
- includePii?: boolean;
585
+ /** Include PII-tagged columns (`includePii: UNSAFE`). See {@link FindManyArgs.includePii}. */
586
+ includePii?: Unsafe;
468
587
  /** Plan this query with its real parameter values. See {@link FindManyArgs.forceCustomPlan}. */
469
588
  forceCustomPlan?: boolean;
470
589
  }
@@ -518,8 +637,12 @@ export interface FindManyArgs<T, R extends object = {}, W extends TypedWithClaus
518
637
  *
519
638
  * Referencing a PII column in `where` / `orderBy` / `groupBy` / aggregates is
520
639
  * always allowed regardless of this flag (the reference is explicit).
640
+ *
641
+ * PRIVILEGE OPTION: unlocked by the {@link UNSAFE} sentinel only
642
+ * (`includePii: UNSAFE`). A literal `true` throws (E003), because a request
643
+ * body spread into these args must not be able to unlock the PII projection.
521
644
  */
522
- includePii?: boolean;
645
+ includePii?: Unsafe;
523
646
  /**
524
647
  * Plan THIS query with its actual parameter values, every execution.
525
648
  * PostgreSQL only (see the refusal below).
@@ -728,10 +851,15 @@ export interface UpdateArgs<T, R extends object = {}> {
728
851
  * Opt in to running this mutation when `where` resolves to an empty
729
852
  * predicate (e.g. `{}` or `{ id: undefined }`). Default `false`, an
730
853
  * empty predicate throws `ValidationError` to catch the common case of
731
- * a filter value accidentally being `undefined`. Set this to `true` only
732
- * when an unconditional mutation is the intended behaviour.
854
+ * a filter value accidentally being `undefined`.
855
+ *
856
+ * PRIVILEGE OPTION: unlocked by the {@link UNSAFE} sentinel only
857
+ * (`allowFullTableScan: UNSAFE`), and only when an unconditional mutation is
858
+ * genuinely the intent. A literal `true` throws (E003): this guard is the
859
+ * last thing standing between a spread request body and an unconditional
860
+ * `UPDATE` / `DELETE` over the whole table.
733
861
  */
734
- allowFullTableScan?: boolean;
862
+ allowFullTableScan?: Unsafe;
735
863
  /**
736
864
  * Optimistic locking, prevents lost updates in concurrent scenarios.
737
865
  * Specify the version field and its expected value. The update adds a
@@ -762,7 +890,7 @@ export interface UpdateManyArgs<T, R extends object = {}> {
762
890
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
763
891
  timeout?: number;
764
892
  /** See {@link UpdateArgs.allowFullTableScan}. */
765
- allowFullTableScan?: boolean;
893
+ allowFullTableScan?: Unsafe;
766
894
  /** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
767
895
  skipGlobalFilters?: SkipGlobalFilters;
768
896
  }
@@ -771,7 +899,7 @@ export interface DeleteArgs<T, R extends object = {}> {
771
899
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
772
900
  timeout?: number;
773
901
  /** See {@link UpdateArgs.allowFullTableScan}. */
774
- allowFullTableScan?: boolean;
902
+ allowFullTableScan?: Unsafe;
775
903
  /** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
776
904
  skipGlobalFilters?: SkipGlobalFilters;
777
905
  }
@@ -780,7 +908,7 @@ export interface DeleteManyArgs<T, R extends object = {}> {
780
908
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
781
909
  timeout?: number;
782
910
  /** See {@link UpdateArgs.allowFullTableScan}. */
783
- allowFullTableScan?: boolean;
911
+ allowFullTableScan?: Unsafe;
784
912
  /** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
785
913
  skipGlobalFilters?: SkipGlobalFilters;
786
914
  }
@@ -1085,8 +1213,11 @@ export interface GroupByArgs<T, R extends object = {}> {
1085
1213
  * STORED VALUES, so without this they throw `ValidationError` (E003).
1086
1214
  * `_count` (a count, not a value), `_sum` / `_avg`, and `where` / `orderBy` /
1087
1215
  * `having` on PII columns stay allowed.
1216
+ *
1217
+ * PRIVILEGE OPTION: unlocked by the {@link UNSAFE} sentinel only. See
1218
+ * {@link FindManyArgs.includePii}.
1088
1219
  */
1089
- includePii?: boolean;
1220
+ includePii?: Unsafe;
1090
1221
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
1091
1222
  timeout?: number;
1092
1223
  /** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
@@ -1187,8 +1318,11 @@ export interface AggregateArgs<T, R extends object = {}> {
1187
1318
  * `ValidationError` (E003) on a PII column. `_count` (a count, not a value)
1188
1319
  * and `_sum` / `_avg` (a computed total over many rows) stay allowed, as do
1189
1320
  * `where` filters on PII columns.
1321
+ *
1322
+ * PRIVILEGE OPTION: unlocked by the {@link UNSAFE} sentinel only. See
1323
+ * {@link FindManyArgs.includePii}.
1190
1324
  */
1191
- includePii?: boolean;
1325
+ includePii?: Unsafe;
1192
1326
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
1193
1327
  timeout?: number;
1194
1328
  /** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
@@ -1,6 +1,211 @@
1
1
  /**
2
2
  * turbine-orm, Query builder types
3
3
  *
4
- * All exported type and interface definitions for the query builder module.
4
+ * All exported type and interface definitions for the query builder module,
5
+ * plus the small runtime guards that police the values those types describe
6
+ * (the {@link UNSAFE} sentinel and the orderBy-direction check). Those guards
7
+ * live here, next to the declarations they enforce, so a new privilege option
8
+ * or direction-bearing shape has one obvious place to be wired in.
5
9
  */
6
- export {};
10
+ import { ValidationError } from '../errors.js';
11
+ // ---------------------------------------------------------------------------
12
+ // The privilege sentinel
13
+ // ---------------------------------------------------------------------------
14
+ /**
15
+ * The value that unlocks a PRIVILEGE option: `skipGlobalFilters`,
16
+ * `includePii`, `allowFullTableScan`.
17
+ *
18
+ * ## The bug this closes
19
+ *
20
+ * All three of those options are ordinary siblings of `where` on the query-args
21
+ * object, so a handler written the idiomatic way,
22
+ *
23
+ * ```ts
24
+ * app.get('/users', (req, res) => db.users.findMany({ ...req.body }));
25
+ * ```
26
+ *
27
+ * used to let the REQUEST BODY turn them on. `{"where":{"name":"x"},
28
+ * "skipGlobalFilters":true}` compiled to the same statement minus the tenant
29
+ * predicate: the documented multi-tenancy mechanism, removed by an attacker
30
+ * over the wire. `includePii: true` unlocked the PII projection the same way,
31
+ * and `allowFullTableScan: true` disarmed the empty-`where` guard that stops a
32
+ * mass update or delete.
33
+ *
34
+ * Typing the options `boolean` and writing "be careful" in the docs is not a
35
+ * fix: the escalation is a mass-assignment shape, and mass assignment happens
36
+ * because nobody enumerated the keys.
37
+ *
38
+ * ## Why a symbol
39
+ *
40
+ * `JSON.parse` cannot produce a symbol. There is no JSON document, no query
41
+ * string, no form body and no `structuredClone` of untrusted input that yields
42
+ * this value, so an attacker cannot put it on an args object at all. The
43
+ * escalation stops being "discouraged" and becomes STRUCTURALLY impossible,
44
+ * which is the only property that survives a refactor.
45
+ *
46
+ * Registered via `Symbol.for` rather than `Symbol()` on purpose: this package
47
+ * ships dual ESM + CJS builds, and a consumer can easily import `UNSAFE` from
48
+ * one copy while the query interface it calls came from the other. A plain
49
+ * `Symbol()` would be a different value in each copy and every privileged call
50
+ * would throw. The global registry makes the two copies agree. (Reachability of
51
+ * `Symbol.for` from application code is irrelevant to the threat model here:
52
+ * the attacker's channel is parsed data, which cannot carry a symbol either
53
+ * way.)
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * import { UNSAFE } from 'turbine-orm';
58
+ *
59
+ * // Deliberate, in a background job that legitimately crosses tenants:
60
+ * await db.users.findMany({ where: { active: true }, skipGlobalFilters: UNSAFE });
61
+ *
62
+ * // Skip the global filter on named tables only (the head element is the opt-in):
63
+ * await db.users.findMany({ with: { posts: true }, skipGlobalFilters: [UNSAFE, 'posts'] });
64
+ * ```
65
+ */
66
+ export const UNSAFE = Symbol.for('turbine-orm.UNSAFE');
67
+ /** The one message shape every privilege refusal uses. */
68
+ function privilegeRefusal(option, received, extra = '') {
69
+ return new ValidationError(`[turbine] \`${option}\` must be the \`UNSAFE\` symbol, received ${describeValue(received)}. ` +
70
+ `${option} is a privilege option: it removes a safety boundary (a tenant filter, the PII ` +
71
+ 'projection, or the empty-`where` guard), so it cannot be enabled by a plain value that ' +
72
+ 'JSON.parse can produce. Import the sentinel and pass it explicitly: ' +
73
+ `import { UNSAFE } from 'turbine-orm'; → { ${option}: UNSAFE }.${extra ? ` ${extra}` : ''} ` +
74
+ 'If you did not write this option, an untrusted object was spread into these query args.');
75
+ }
76
+ /** Short, non-leaking rendering of a rejected value for the diagnostic. */
77
+ function describeValue(value) {
78
+ if (typeof value === 'symbol')
79
+ return 'a different symbol';
80
+ if (Array.isArray(value))
81
+ return 'an array';
82
+ if (value === null)
83
+ return 'null';
84
+ if (typeof value === 'object')
85
+ return 'an object';
86
+ if (typeof value === 'string')
87
+ return `the string ${JSON.stringify(value)}`;
88
+ return String(value);
89
+ }
90
+ /**
91
+ * Resolve a boolean-shaped privilege option (`includePii`,
92
+ * `allowFullTableScan`) into the boolean the builders use.
93
+ *
94
+ * - absent / `undefined` / `null` / `false` → `false`. These are unambiguously
95
+ * NOT a request for the privilege, and throwing on them would break the
96
+ * ordinary `allowFullTableScan: someFlag` call whose flag is off (prisma-compat
97
+ * relies on `allowFullTableScan: false` staying a no-op, see its updateMany).
98
+ * - {@link UNSAFE} → `true`.
99
+ * - ANYTHING else, `true` included → throws {@link ValidationError} (E003).
100
+ *
101
+ * The literal `true` throws rather than being ignored ON PURPOSE. Ignoring it
102
+ * would swap an escalation bug for a silent-failure bug: a legitimate caller
103
+ * who has not migrated would keep reading rows with the PII columns quietly
104
+ * missing, or keep expecting a cross-tenant read that no longer happens, with
105
+ * no signal anywhere. Throwing turns the attacker's spread into a 500 and the
106
+ * legitimate caller's stale code into an immediate, self-describing error.
107
+ */
108
+ export function resolveUnsafeFlag(value, option) {
109
+ if (value === undefined || value === null || value === false)
110
+ return false;
111
+ if (value === UNSAFE)
112
+ return true;
113
+ throw privilegeRefusal(option, value);
114
+ }
115
+ /**
116
+ * Validate and normalize {@link SkipGlobalFilters}.
117
+ *
118
+ * The array form is policed exactly as hard as the bare form: an attacker who
119
+ * can post `{"skipGlobalFilters":["users"]}` drops the tenant predicate on the
120
+ * table they care about, which is the same breach with an extra step. So the
121
+ * array must LEAD with the sentinel (`[UNSAFE, 'posts']`) and the rest must be
122
+ * table-name strings.
123
+ */
124
+ export function resolveSkipGlobalFilters(value) {
125
+ if (value === undefined || value === null || value === false)
126
+ return undefined;
127
+ if (value === UNSAFE)
128
+ return true;
129
+ if (Array.isArray(value)) {
130
+ if (value[0] !== UNSAFE) {
131
+ throw privilegeRefusal('skipGlobalFilters', value, 'The array form skips the named tables and must lead with the sentinel: [UNSAFE, "posts"].');
132
+ }
133
+ const tables = value.slice(1);
134
+ // `[UNSAFE]` names no table. It used to resolve to `[]`, which every
135
+ // consumer reads as "skip nothing", so the one shape that is an explicit,
136
+ // correctly-imported privilege request was ALSO the one shape that was
137
+ // silently a no-op. It is not read as "skip all" on purpose: that would let
138
+ // `[UNSAFE, ...tables]` ESCALATE to a global skip whenever `tables` happens
139
+ // to come back empty, which is the same accident with a much worse outcome.
140
+ if (tables.length === 0) {
141
+ throw new ValidationError('[turbine] `skipGlobalFilters: [UNSAFE]` names no table, so it would skip nothing. ' +
142
+ 'Pass the tables to skip (`[UNSAFE, "posts"]`), or the bare sentinel (`UNSAFE`) to skip every table.');
143
+ }
144
+ for (const t of tables) {
145
+ if (typeof t !== 'string') {
146
+ throw new ValidationError('[turbine] `skipGlobalFilters: [UNSAFE, ...]` takes table accessor NAMES after the sentinel, ' +
147
+ `received ${describeValue(t)}.`);
148
+ }
149
+ }
150
+ return tables;
151
+ }
152
+ throw privilegeRefusal('skipGlobalFilters', value);
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // orderBy direction validation
156
+ // ---------------------------------------------------------------------------
157
+ /**
158
+ * Refuse an orderBy direction that is neither `asc` nor `desc`.
159
+ *
160
+ * Every direction consumer used to be spelled `String(v).toLowerCase() ===
161
+ * 'desc' ? 'DESC' : 'ASC'`, so `'descending'`, `''`, `null`, `1` and every
162
+ * other typo all emitted `ASC`. TypeScript rejects them, but the value that
163
+ * reaches this code in practice is `orderBy: { [field]: req.query.dir }`, which
164
+ * is `any` at the boundary, and the failure is SILENT: the caller gets a
165
+ * correct-looking page sorted the exact opposite way, which is worse than an
166
+ * error and identical to a successful response.
167
+ *
168
+ * Accepts any casing (`'DESC'` already worked) and leaves `undefined` alone
169
+ * (every consumer treats an undefined entry as absent). Recursive/compound
170
+ * orderBy shapes are validated one node at a time by their own consumers.
171
+ */
172
+ export function assertOrderDirection(value, context) {
173
+ // `undefined` alone means "this entry carries no ordering" (every consumer
174
+ // skips it). `null` is a VALUE, and a measured silent-ASC case, so it is
175
+ // refused like any other bad token.
176
+ if (value === undefined)
177
+ return;
178
+ if (typeof value === 'string') {
179
+ assertDirectionToken(value, context);
180
+ return;
181
+ }
182
+ if (typeof value === 'object' && value !== null) {
183
+ const v = value;
184
+ // `{ sort, nulls }` (OrderBySpec), `{ direction }` (JSON-path / pick-row),
185
+ // `{ distance: { metric, direction } }` (vector KNN).
186
+ if ('sort' in v)
187
+ assertDirectionToken(v.sort, context);
188
+ if ('direction' in v)
189
+ assertDirectionToken(v.direction, context);
190
+ const distance = v.distance;
191
+ if (distance !== null && typeof distance === 'object' && 'direction' in distance) {
192
+ assertDirectionToken(distance.direction, context);
193
+ }
194
+ return;
195
+ }
196
+ // A number or boolean is never a direction.
197
+ assertDirectionToken(value, context);
198
+ }
199
+ /** One direction token. `undefined` means "not specified", which defaults to asc. */
200
+ export function assertDirectionToken(value, context) {
201
+ if (value === undefined)
202
+ return;
203
+ if (typeof value === 'string') {
204
+ const lowered = value.toLowerCase();
205
+ if (lowered === 'asc' || lowered === 'desc')
206
+ return;
207
+ }
208
+ throw new ValidationError(`[turbine] Invalid orderBy direction ${describeValue(value)} for ${context}. ` +
209
+ "Use 'asc' or 'desc'. An unrecognized direction used to sort ASCENDING silently, " +
210
+ 'which returns a correct-looking page in the wrong order.');
211
+ }
@@ -15,7 +15,7 @@ import type { Dialect } from '../dialect.js';
15
15
  import { ValidationError } from '../errors.js';
16
16
  import type { RelationDef, SchemaMetadata, TableMetadata } from '../schema.js';
17
17
  import type { TemporalInfinityReading } from './deferred.js';
18
- import type { ArrayFilter, ColumnRef, GlobalFilters, JsonFilter, JsonPathOrderBy, SkipGlobalFilters, TextSearchFilter, VectorFilter, WhereClause, WhereOperator } from './types.js';
18
+ import type { ArrayFilter, ColumnRef, GlobalFilters, JsonFilter, JsonPathOrderBy, ResolvedSkipGlobalFilters, TextSearchFilter, VectorFilter, WhereClause, WhereOperator } from './types.js';
19
19
  import { type SqlCacheEntry } from './utils.js';
20
20
  import { type WhereHost, type WhereRecord } from './where-compile.js';
21
21
  /**
@@ -58,7 +58,7 @@ export interface BuilderCtx {
58
58
  * synchronous SQL-build + param-collect tree reads it deep inside
59
59
  * `resolveGlobalFilter`.
60
60
  */
61
- currentSkip: SkipGlobalFilters | undefined;
61
+ currentSkip: ResolvedSkipGlobalFilters | undefined;
62
62
  q(name: string): string;
63
63
  p(index: number): string;
64
64
  inParam(values: unknown): unknown;
@@ -216,7 +216,7 @@ export declare function buildWhere<T extends object>(qi: BuilderCtx, where: Wher
216
216
  * filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
217
217
  * no filter applies, the query opted out, or the filter is empty.
218
218
  */
219
- export declare function resolveGlobalFilter(qi: BuilderCtx, table: string, skip?: SkipGlobalFilters | undefined): Record<string, unknown> | null;
219
+ export declare function resolveGlobalFilter(qi: BuilderCtx, table: string, skip?: ResolvedSkipGlobalFilters | undefined): Record<string, unknown> | null;
220
220
  /**
221
221
  * AND-merge this table's resolved global filter into a user `where`. Either
222
222
  * side may be absent. When no filter applies the user where is returned by
@@ -464,13 +464,19 @@ export function userPredicateIsEmpty(qi, userWhere) {
464
464
  const throwaway = [];
465
465
  return buildWhereClause(qi, userWhere, throwaway) === null;
466
466
  }
467
- export function assertMutationHasPredicate(qi, operation, whereSql, allowFullTableScan) {
467
+ export function assertMutationHasPredicate(qi, operation, whereSql,
468
+ // Already RESOLVED by the caller (writes.ts) through `resolveUnsafeFlag`, so
469
+ // the sentinel check happens on every mutation, not only the guarded ones: a
470
+ // literal `allowFullTableScan: true` must throw even when the `where` is
471
+ // non-empty, or the escalation attempt goes unreported on most calls.
472
+ allowFullTableScan) {
468
473
  if (whereSql.length > 0)
469
474
  return;
470
475
  if (allowFullTableScan === true)
471
476
  return;
472
477
  throw new ValidationError(`[turbine] ${operation} on "${qi.table}" refused: the \`where\` clause is empty. ` +
473
- `Pass \`allowFullTableScan: true\` to opt in, or check that your filter values are defined.`);
478
+ "Pass `allowFullTableScan: UNSAFE` to opt in (import { UNSAFE } from 'turbine-orm'), " +
479
+ 'or check that your filter values are defined.');
474
480
  }
475
481
  /**
476
482
  * Build the inner WHERE expression (without the WHERE keyword).
@@ -14,6 +14,7 @@ import { NotFoundError, OptimisticLockError, UnsupportedFeatureError, Validation
14
14
  import { camelToSnake, snakeToCamel } from '../schema.js';
15
15
  import { expandCompoundUniqueWhere } from './compound-unique.js';
16
16
  import { isUnmatchedPlainObject, UPDATE_OPERATOR_KEYS } from './filters.js';
17
+ import { resolveSkipGlobalFilters, resolveUnsafeFlag } from './types.js';
17
18
  import { coerceTemporalValue, resolveColumnName } from './utils.js';
18
19
  import * as whereMod from './where.js';
19
20
  /**
@@ -308,7 +309,7 @@ export function buildCreateMany(qi, args) {
308
309
  }
309
310
  export function buildUpdate(qi, args) {
310
311
  assertWritable(qi, 'update');
311
- qi.currentSkip = args.skipGlobalFilters;
312
+ qi.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
312
313
  // `updatedAt`-tagged columns are filled in before anything reads `data`, so
313
314
  // the SET list, the fingerprint and the param collector all see one object.
314
315
  const dataObj = applyUpdatedAtColumns(qi, args.data);
@@ -321,7 +322,7 @@ export function buildUpdate(qi, args) {
321
322
  // The empty-`where` guard checks the USER predicate only, a global filter
322
323
  // must never turn an unguarded mass update into an allowed one.
323
324
  const userHasPredicate = !whereMod.userPredicateIsEmpty(qi, userWhere) || !!lock;
324
- whereMod.assertMutationHasPredicate(qi, 'update', userHasPredicate ? ' WHERE x' : '', args.allowFullTableScan);
325
+ whereMod.assertMutationHasPredicate(qi, 'update', userHasPredicate ? ' WHERE x' : '', resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan'));
325
326
  // The SQL is built from the global-filter-merged where (soft-delete keeps an
326
327
  // update from touching already-deleted rows).
327
328
  const whereObj = (whereMod.mergeGlobalFilter(qi, userWhere) ?? {});
@@ -450,11 +451,11 @@ export function buildUpdate(qi, args) {
450
451
  }
451
452
  export function buildDelete(qi, args) {
452
453
  assertWritable(qi, 'delete');
453
- qi.currentSkip = args.skipGlobalFilters;
454
+ qi.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
454
455
  // Prisma compound-unique selector → the column conjunction (before the guard).
455
456
  const userWhere = expandCompoundUniqueWhere(qi.tableMeta, args.where);
456
457
  // Guard the USER predicate (a global filter must not satisfy the guard).
457
- whereMod.assertMutationHasPredicate(qi, 'delete', whereMod.userPredicateIsEmpty(qi, userWhere) ? '' : ' WHERE x', args.allowFullTableScan);
458
+ whereMod.assertMutationHasPredicate(qi, 'delete', whereMod.userPredicateIsEmpty(qi, userWhere) ? '' : ' WHERE x', resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan'));
458
459
  const whereObj = (whereMod.mergeGlobalFilter(qi, userWhere) ?? {});
459
460
  const whereFp = whereMod.fingerprintWhere(qi, whereObj);
460
461
  const ck = `d:${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
@@ -505,7 +506,7 @@ export function buildUpsert(qi, args) {
505
506
  assertWritable(qi, 'upsert');
506
507
  assertNoGeneratedColumns(qi, args.create, 'upsert');
507
508
  assertNoGeneratedColumns(qi, args.update, 'upsert');
508
- qi.currentSkip = args.skipGlobalFilters;
509
+ qi.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
509
510
  // Prisma compound-unique selector on the conflict target → its member columns.
510
511
  const upsertWhere = expandCompoundUniqueWhere(qi.tableMeta, args.where);
511
512
  // Build the INSERT part from create data
@@ -574,10 +575,10 @@ export function buildUpsert(qi, args) {
574
575
  }
575
576
  export function buildUpdateMany(qi, args) {
576
577
  assertWritable(qi, 'updateMany');
577
- qi.currentSkip = args.skipGlobalFilters;
578
+ qi.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
578
579
  const dataObj = applyUpdatedAtColumns(qi, args.data);
579
580
  assertNoGeneratedColumns(qi, dataObj, 'updateMany');
580
- whereMod.assertMutationHasPredicate(qi, 'updateMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', args.allowFullTableScan);
581
+ whereMod.assertMutationHasPredicate(qi, 'updateMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan'));
581
582
  const whereObj = (whereMod.mergeGlobalFilter(qi, args.where) ?? {});
582
583
  // Nothing to SET: a no-op, for the same reason as `update` above (that path
583
584
  // has the full rationale). Reports `count: 0` because zero rows were
@@ -616,8 +617,8 @@ export function buildUpdateMany(qi, args) {
616
617
  }
617
618
  export function buildDeleteMany(qi, args) {
618
619
  assertWritable(qi, 'deleteMany');
619
- qi.currentSkip = args.skipGlobalFilters;
620
- whereMod.assertMutationHasPredicate(qi, 'deleteMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', args.allowFullTableScan);
620
+ qi.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
621
+ whereMod.assertMutationHasPredicate(qi, 'deleteMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', resolveUnsafeFlag(args.allowFullTableScan, 'allowFullTableScan'));
621
622
  const whereObj = (whereMod.mergeGlobalFilter(qi, args.where) ?? {});
622
623
  const whereFp = whereMod.fingerprintWhere(qi, whereObj);
623
624
  const ck = `dm:${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.61.0",
3
+ "version": "0.62.0",
4
4
  "description": "Postgres-native TypeScript ORM, runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "//exports": "Each subpath declares its types PER CONDITION. A single shared top-level \"types\" resolves to the ESM declarations for `require` too, which is TS1479 (\"is an ES module ... cannot be require()d\") for any CJS consumer on moduleResolution node16/nodenext. The require condition points at dist/cjs, which ships its own {\"type\":\"commonjs\"} package.json, so those declarations are CJS declarations. Gated in CI by publint + @arethetypeswrong/cli + a real .cts consumer typecheck (see the package-types job in ci.yml).",
@@ -111,6 +111,7 @@
111
111
  "README.md"
112
112
  ],
113
113
  "sideEffects": false,
114
+ "//prepublishOnly": "The gate for a MANUAL LOCAL `npm publish`, which is the normal release flow here, so it is the ONLY gate on that path (release.yml runs on a pushed tag, and a local publish never triggers it). `check:package` joined it 2026-07-28: publint --strict + attw --pack, the two checks that catch a broken exports map or CJS declarations that resolve to ESM, which is a class of bug that shipped once already and is invisible to every other script here. `test:coverage:cli` joined at the same time (~3s) so a local publish cannot ship a Studio/migrate coverage regression. Still deliberately absent, because they need a live database or minutes of packing: the Postgres integration suite, the engine containers, and the tarball install smoke, all of which release.yml + ci.yml run.",
114
115
  "scripts": {
115
116
  "prebuild": "npm run gen:studio",
116
117
  "build": "rm -rf dist && tsc && tsc --project tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json",
@@ -122,13 +123,21 @@
122
123
  "test": "tsx --test --test-concurrency=1 src/test/*.test.ts",
123
124
  "test:unit": "DATABASE_URL= tsx --test src/test/*.test.ts",
124
125
  "test:coverage": "c8 tsx --test --test-concurrency=1 src/test/*.test.ts",
126
+ "//coverage:cli": "The CLI coverage gate, split into ONE collection run plus FOUR threshold checks. c8 enforces a single threshold set per invocation and its --per-file applies the SAME numbers to every file, neither of which can express 'destructive.ts holds 100 while migrate.ts holds 70'. An aggregate-only floor lets the least-covered file spend the whole slack the best-covered file earned: at the measured 3623/2951 lines, migrate.ts could fall from 70.3% to 66.6% with the aggregate still green. So each file gets its OWN floor, checked by re-reporting the coverage already on disk (c8 report re-reads ./coverage/tmp, so this costs no extra test run). The aggregate check is kept as well: it catches all three sagging together inside their individual margins. Per-file gates run FIRST because their failure names the file. DATABASE_URL is neutralized on the collection run: these test files include live migration tests that create and drop tables, and this script runs from prepublishOnly.",
127
+ "test:coverage:cli": "npm run coverage:cli:collect && npm run coverage:cli:gate:destructive && npm run coverage:cli:gate:studio && npm run coverage:cli:gate:migrate && npm run coverage:cli:gate:aggregate",
128
+ "coverage:cli:collect": "DATABASE_URL= c8 --all --reporter text --exclude 'src/test/**' --include src/cli/studio.ts --include src/cli/migrate.ts --include src/cli/destructive.ts tsx --test src/test/studio-write.test.ts src/test/studio-demo.test.ts src/test/studio.test.ts src/test/studio-security.test.ts src/test/migrate.test.ts src/test/migrate-deploy.test.ts src/test/migrate-smoke-fixes.test.ts src/test/destructive-migrations.test.ts src/test/backfill-recipe.test.ts src/test/cli.test.ts src/test/cli-diff-migration.test.ts src/test/cli-flags.test.ts src/test/cli-first-run.test.ts",
129
+ "coverage:cli:gate": "c8 report --all --exclude 'src/test/**' --reporter text --check-coverage",
130
+ "coverage:cli:gate:destructive": "npm run coverage:cli:gate -- --include src/cli/destructive.ts --lines 98 --statements 98 --branches 84 --functions 98",
131
+ "coverage:cli:gate:studio": "npm run coverage:cli:gate -- --include src/cli/studio.ts --lines 82 --statements 82 --branches 81 --functions 86",
132
+ "coverage:cli:gate:migrate": "npm run coverage:cli:gate -- --include src/cli/migrate.ts --lines 69 --statements 69 --branches 91 --functions 73",
133
+ "coverage:cli:gate:aggregate": "npm run coverage:cli:gate -- --include src/cli/studio.ts --include src/cli/migrate.ts --include src/cli/destructive.ts --lines 78 --statements 78 --branches 84 --functions 82",
125
134
  "lint": "biome check src/",
126
135
  "lint:fix": "biome check --write src/",
127
136
  "format": "biome format --write src/",
128
137
  "check:error-codes": "tsx scripts/check-error-codes.ts",
129
138
  "check:changelog": "node scripts/check-changelog-headings.mjs",
130
139
  "check:package": "publint --strict && attw --pack . --profile node16",
131
- "prepublishOnly": "npm run build && npm run typecheck && npm run lint && npm run test:unit && npm run check:error-codes && npm run check:changelog && npm run size",
140
+ "prepublishOnly": "npm run build && npm run typecheck && npm run lint && npm run test:unit && npm run test:coverage:cli && npm run check:error-codes && npm run check:changelog && npm run check:package && npm run size",
132
141
  "prepack": "node scripts/strip-prepare.mjs",
133
142
  "postpack": "node scripts/restore-prepare.mjs",
134
143
  "size": "size-limit",
@@ -143,7 +152,8 @@
143
152
  "pretypecheck": "npm run gen:studio",
144
153
  "pretest": "npm run gen:studio",
145
154
  "pretest:unit": "npm run gen:studio",
146
- "pretest:coverage": "npm run gen:studio"
155
+ "pretest:coverage": "npm run gen:studio",
156
+ "pretest:coverage:cli": "npm run gen:studio"
147
157
  },
148
158
  "engines": {
149
159
  "node": ">=20.0.0"