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
@@ -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}. */
@@ -2,6 +2,217 @@
2
2
  /**
3
3
  * turbine-orm, Query builder types
4
4
  *
5
- * All exported type and interface definitions for the query builder module.
5
+ * All exported type and interface definitions for the query builder module,
6
+ * plus the small runtime guards that police the values those types describe
7
+ * (the {@link UNSAFE} sentinel and the orderBy-direction check). Those guards
8
+ * live here, next to the declarations they enforce, so a new privilege option
9
+ * or direction-bearing shape has one obvious place to be wired in.
6
10
  */
7
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.UNSAFE = void 0;
13
+ exports.resolveUnsafeFlag = resolveUnsafeFlag;
14
+ exports.resolveSkipGlobalFilters = resolveSkipGlobalFilters;
15
+ exports.assertOrderDirection = assertOrderDirection;
16
+ exports.assertDirectionToken = assertDirectionToken;
17
+ const errors_js_1 = require("../errors.js");
18
+ // ---------------------------------------------------------------------------
19
+ // The privilege sentinel
20
+ // ---------------------------------------------------------------------------
21
+ /**
22
+ * The value that unlocks a PRIVILEGE option: `skipGlobalFilters`,
23
+ * `includePii`, `allowFullTableScan`.
24
+ *
25
+ * ## The bug this closes
26
+ *
27
+ * All three of those options are ordinary siblings of `where` on the query-args
28
+ * object, so a handler written the idiomatic way,
29
+ *
30
+ * ```ts
31
+ * app.get('/users', (req, res) => db.users.findMany({ ...req.body }));
32
+ * ```
33
+ *
34
+ * used to let the REQUEST BODY turn them on. `{"where":{"name":"x"},
35
+ * "skipGlobalFilters":true}` compiled to the same statement minus the tenant
36
+ * predicate: the documented multi-tenancy mechanism, removed by an attacker
37
+ * over the wire. `includePii: true` unlocked the PII projection the same way,
38
+ * and `allowFullTableScan: true` disarmed the empty-`where` guard that stops a
39
+ * mass update or delete.
40
+ *
41
+ * Typing the options `boolean` and writing "be careful" in the docs is not a
42
+ * fix: the escalation is a mass-assignment shape, and mass assignment happens
43
+ * because nobody enumerated the keys.
44
+ *
45
+ * ## Why a symbol
46
+ *
47
+ * `JSON.parse` cannot produce a symbol. There is no JSON document, no query
48
+ * string, no form body and no `structuredClone` of untrusted input that yields
49
+ * this value, so an attacker cannot put it on an args object at all. The
50
+ * escalation stops being "discouraged" and becomes STRUCTURALLY impossible,
51
+ * which is the only property that survives a refactor.
52
+ *
53
+ * Registered via `Symbol.for` rather than `Symbol()` on purpose: this package
54
+ * ships dual ESM + CJS builds, and a consumer can easily import `UNSAFE` from
55
+ * one copy while the query interface it calls came from the other. A plain
56
+ * `Symbol()` would be a different value in each copy and every privileged call
57
+ * would throw. The global registry makes the two copies agree. (Reachability of
58
+ * `Symbol.for` from application code is irrelevant to the threat model here:
59
+ * the attacker's channel is parsed data, which cannot carry a symbol either
60
+ * way.)
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * import { UNSAFE } from 'turbine-orm';
65
+ *
66
+ * // Deliberate, in a background job that legitimately crosses tenants:
67
+ * await db.users.findMany({ where: { active: true }, skipGlobalFilters: UNSAFE });
68
+ *
69
+ * // Skip the global filter on named tables only (the head element is the opt-in):
70
+ * await db.users.findMany({ with: { posts: true }, skipGlobalFilters: [UNSAFE, 'posts'] });
71
+ * ```
72
+ */
73
+ exports.UNSAFE = Symbol.for('turbine-orm.UNSAFE');
74
+ /** The one message shape every privilege refusal uses. */
75
+ function privilegeRefusal(option, received, extra = '') {
76
+ return new errors_js_1.ValidationError(`[turbine] \`${option}\` must be the \`UNSAFE\` symbol, received ${describeValue(received)}. ` +
77
+ `${option} is a privilege option: it removes a safety boundary (a tenant filter, the PII ` +
78
+ 'projection, or the empty-`where` guard), so it cannot be enabled by a plain value that ' +
79
+ 'JSON.parse can produce. Import the sentinel and pass it explicitly: ' +
80
+ `import { UNSAFE } from 'turbine-orm'; → { ${option}: UNSAFE }.${extra ? ` ${extra}` : ''} ` +
81
+ 'If you did not write this option, an untrusted object was spread into these query args.');
82
+ }
83
+ /** Short, non-leaking rendering of a rejected value for the diagnostic. */
84
+ function describeValue(value) {
85
+ if (typeof value === 'symbol')
86
+ return 'a different symbol';
87
+ if (Array.isArray(value))
88
+ return 'an array';
89
+ if (value === null)
90
+ return 'null';
91
+ if (typeof value === 'object')
92
+ return 'an object';
93
+ if (typeof value === 'string')
94
+ return `the string ${JSON.stringify(value)}`;
95
+ return String(value);
96
+ }
97
+ /**
98
+ * Resolve a boolean-shaped privilege option (`includePii`,
99
+ * `allowFullTableScan`) into the boolean the builders use.
100
+ *
101
+ * - absent / `undefined` / `null` / `false` → `false`. These are unambiguously
102
+ * NOT a request for the privilege, and throwing on them would break the
103
+ * ordinary `allowFullTableScan: someFlag` call whose flag is off (prisma-compat
104
+ * relies on `allowFullTableScan: false` staying a no-op, see its updateMany).
105
+ * - {@link UNSAFE} → `true`.
106
+ * - ANYTHING else, `true` included → throws {@link ValidationError} (E003).
107
+ *
108
+ * The literal `true` throws rather than being ignored ON PURPOSE. Ignoring it
109
+ * would swap an escalation bug for a silent-failure bug: a legitimate caller
110
+ * who has not migrated would keep reading rows with the PII columns quietly
111
+ * missing, or keep expecting a cross-tenant read that no longer happens, with
112
+ * no signal anywhere. Throwing turns the attacker's spread into a 500 and the
113
+ * legitimate caller's stale code into an immediate, self-describing error.
114
+ */
115
+ function resolveUnsafeFlag(value, option) {
116
+ if (value === undefined || value === null || value === false)
117
+ return false;
118
+ if (value === exports.UNSAFE)
119
+ return true;
120
+ throw privilegeRefusal(option, value);
121
+ }
122
+ /**
123
+ * Validate and normalize {@link SkipGlobalFilters}.
124
+ *
125
+ * The array form is policed exactly as hard as the bare form: an attacker who
126
+ * can post `{"skipGlobalFilters":["users"]}` drops the tenant predicate on the
127
+ * table they care about, which is the same breach with an extra step. So the
128
+ * array must LEAD with the sentinel (`[UNSAFE, 'posts']`) and the rest must be
129
+ * table-name strings.
130
+ */
131
+ function resolveSkipGlobalFilters(value) {
132
+ if (value === undefined || value === null || value === false)
133
+ return undefined;
134
+ if (value === exports.UNSAFE)
135
+ return true;
136
+ if (Array.isArray(value)) {
137
+ if (value[0] !== exports.UNSAFE) {
138
+ throw privilegeRefusal('skipGlobalFilters', value, 'The array form skips the named tables and must lead with the sentinel: [UNSAFE, "posts"].');
139
+ }
140
+ const tables = value.slice(1);
141
+ // `[UNSAFE]` names no table. It used to resolve to `[]`, which every
142
+ // consumer reads as "skip nothing", so the one shape that is an explicit,
143
+ // correctly-imported privilege request was ALSO the one shape that was
144
+ // silently a no-op. It is not read as "skip all" on purpose: that would let
145
+ // `[UNSAFE, ...tables]` ESCALATE to a global skip whenever `tables` happens
146
+ // to come back empty, which is the same accident with a much worse outcome.
147
+ if (tables.length === 0) {
148
+ throw new errors_js_1.ValidationError('[turbine] `skipGlobalFilters: [UNSAFE]` names no table, so it would skip nothing. ' +
149
+ 'Pass the tables to skip (`[UNSAFE, "posts"]`), or the bare sentinel (`UNSAFE`) to skip every table.');
150
+ }
151
+ for (const t of tables) {
152
+ if (typeof t !== 'string') {
153
+ throw new errors_js_1.ValidationError('[turbine] `skipGlobalFilters: [UNSAFE, ...]` takes table accessor NAMES after the sentinel, ' +
154
+ `received ${describeValue(t)}.`);
155
+ }
156
+ }
157
+ return tables;
158
+ }
159
+ throw privilegeRefusal('skipGlobalFilters', value);
160
+ }
161
+ // ---------------------------------------------------------------------------
162
+ // orderBy direction validation
163
+ // ---------------------------------------------------------------------------
164
+ /**
165
+ * Refuse an orderBy direction that is neither `asc` nor `desc`.
166
+ *
167
+ * Every direction consumer used to be spelled `String(v).toLowerCase() ===
168
+ * 'desc' ? 'DESC' : 'ASC'`, so `'descending'`, `''`, `null`, `1` and every
169
+ * other typo all emitted `ASC`. TypeScript rejects them, but the value that
170
+ * reaches this code in practice is `orderBy: { [field]: req.query.dir }`, which
171
+ * is `any` at the boundary, and the failure is SILENT: the caller gets a
172
+ * correct-looking page sorted the exact opposite way, which is worse than an
173
+ * error and identical to a successful response.
174
+ *
175
+ * Accepts any casing (`'DESC'` already worked) and leaves `undefined` alone
176
+ * (every consumer treats an undefined entry as absent). Recursive/compound
177
+ * orderBy shapes are validated one node at a time by their own consumers.
178
+ */
179
+ function assertOrderDirection(value, context) {
180
+ // `undefined` alone means "this entry carries no ordering" (every consumer
181
+ // skips it). `null` is a VALUE, and a measured silent-ASC case, so it is
182
+ // refused like any other bad token.
183
+ if (value === undefined)
184
+ return;
185
+ if (typeof value === 'string') {
186
+ assertDirectionToken(value, context);
187
+ return;
188
+ }
189
+ if (typeof value === 'object' && value !== null) {
190
+ const v = value;
191
+ // `{ sort, nulls }` (OrderBySpec), `{ direction }` (JSON-path / pick-row),
192
+ // `{ distance: { metric, direction } }` (vector KNN).
193
+ if ('sort' in v)
194
+ assertDirectionToken(v.sort, context);
195
+ if ('direction' in v)
196
+ assertDirectionToken(v.direction, context);
197
+ const distance = v.distance;
198
+ if (distance !== null && typeof distance === 'object' && 'direction' in distance) {
199
+ assertDirectionToken(distance.direction, context);
200
+ }
201
+ return;
202
+ }
203
+ // A number or boolean is never a direction.
204
+ assertDirectionToken(value, context);
205
+ }
206
+ /** One direction token. `undefined` means "not specified", which defaults to asc. */
207
+ function assertDirectionToken(value, context) {
208
+ if (value === undefined)
209
+ return;
210
+ if (typeof value === 'string') {
211
+ const lowered = value.toLowerCase();
212
+ if (lowered === 'asc' || lowered === 'desc')
213
+ return;
214
+ }
215
+ throw new errors_js_1.ValidationError(`[turbine] Invalid orderBy direction ${describeValue(value)} for ${context}. ` +
216
+ "Use 'asc' or 'desc'. An unrecognized direction used to sort ASCENDING silently, " +
217
+ 'which returns a correct-looking page in the wrong order.');
218
+ }
@@ -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
@@ -529,13 +529,19 @@ function userPredicateIsEmpty(qi, userWhere) {
529
529
  const throwaway = [];
530
530
  return buildWhereClause(qi, userWhere, throwaway) === null;
531
531
  }
532
- function assertMutationHasPredicate(qi, operation, whereSql, allowFullTableScan) {
532
+ function assertMutationHasPredicate(qi, operation, whereSql,
533
+ // Already RESOLVED by the caller (writes.ts) through `resolveUnsafeFlag`, so
534
+ // the sentinel check happens on every mutation, not only the guarded ones: a
535
+ // literal `allowFullTableScan: true` must throw even when the `where` is
536
+ // non-empty, or the escalation attempt goes unreported on most calls.
537
+ allowFullTableScan) {
533
538
  if (whereSql.length > 0)
534
539
  return;
535
540
  if (allowFullTableScan === true)
536
541
  return;
537
542
  throw new errors_js_1.ValidationError(`[turbine] ${operation} on "${qi.table}" refused: the \`where\` clause is empty. ` +
538
- `Pass \`allowFullTableScan: true\` to opt in, or check that your filter values are defined.`);
543
+ "Pass `allowFullTableScan: UNSAFE` to opt in (import { UNSAFE } from 'turbine-orm'), " +
544
+ 'or check that your filter values are defined.');
539
545
  }
540
546
  /**
541
547
  * Build the inner WHERE expression (without the WHERE keyword).
@@ -71,6 +71,7 @@ const errors_js_1 = require("../errors.js");
71
71
  const schema_js_1 = require("../schema.js");
72
72
  const compound_unique_js_1 = require("./compound-unique.js");
73
73
  const filters_js_1 = require("./filters.js");
74
+ const types_js_1 = require("./types.js");
74
75
  const utils_js_1 = require("./utils.js");
75
76
  const whereMod = __importStar(require("./where.js"));
76
77
  /**
@@ -365,7 +366,7 @@ function buildCreateMany(qi, args) {
365
366
  }
366
367
  function buildUpdate(qi, args) {
367
368
  assertWritable(qi, 'update');
368
- qi.currentSkip = args.skipGlobalFilters;
369
+ qi.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args.skipGlobalFilters);
369
370
  // `updatedAt`-tagged columns are filled in before anything reads `data`, so
370
371
  // the SET list, the fingerprint and the param collector all see one object.
371
372
  const dataObj = applyUpdatedAtColumns(qi, args.data);
@@ -378,7 +379,7 @@ function buildUpdate(qi, args) {
378
379
  // The empty-`where` guard checks the USER predicate only, a global filter
379
380
  // must never turn an unguarded mass update into an allowed one.
380
381
  const userHasPredicate = !whereMod.userPredicateIsEmpty(qi, userWhere) || !!lock;
381
- whereMod.assertMutationHasPredicate(qi, 'update', userHasPredicate ? ' WHERE x' : '', args.allowFullTableScan);
382
+ whereMod.assertMutationHasPredicate(qi, 'update', userHasPredicate ? ' WHERE x' : '', (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'));
382
383
  // The SQL is built from the global-filter-merged where (soft-delete keeps an
383
384
  // update from touching already-deleted rows).
384
385
  const whereObj = (whereMod.mergeGlobalFilter(qi, userWhere) ?? {});
@@ -507,11 +508,11 @@ function buildUpdate(qi, args) {
507
508
  }
508
509
  function buildDelete(qi, args) {
509
510
  assertWritable(qi, 'delete');
510
- qi.currentSkip = args.skipGlobalFilters;
511
+ qi.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args.skipGlobalFilters);
511
512
  // Prisma compound-unique selector → the column conjunction (before the guard).
512
513
  const userWhere = (0, compound_unique_js_1.expandCompoundUniqueWhere)(qi.tableMeta, args.where);
513
514
  // Guard the USER predicate (a global filter must not satisfy the guard).
514
- whereMod.assertMutationHasPredicate(qi, 'delete', whereMod.userPredicateIsEmpty(qi, userWhere) ? '' : ' WHERE x', args.allowFullTableScan);
515
+ whereMod.assertMutationHasPredicate(qi, 'delete', whereMod.userPredicateIsEmpty(qi, userWhere) ? '' : ' WHERE x', (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'));
515
516
  const whereObj = (whereMod.mergeGlobalFilter(qi, userWhere) ?? {});
516
517
  const whereFp = whereMod.fingerprintWhere(qi, whereObj);
517
518
  const ck = `d:${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
@@ -562,7 +563,7 @@ function buildUpsert(qi, args) {
562
563
  assertWritable(qi, 'upsert');
563
564
  assertNoGeneratedColumns(qi, args.create, 'upsert');
564
565
  assertNoGeneratedColumns(qi, args.update, 'upsert');
565
- qi.currentSkip = args.skipGlobalFilters;
566
+ qi.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args.skipGlobalFilters);
566
567
  // Prisma compound-unique selector on the conflict target → its member columns.
567
568
  const upsertWhere = (0, compound_unique_js_1.expandCompoundUniqueWhere)(qi.tableMeta, args.where);
568
569
  // Build the INSERT part from create data
@@ -631,10 +632,10 @@ function buildUpsert(qi, args) {
631
632
  }
632
633
  function buildUpdateMany(qi, args) {
633
634
  assertWritable(qi, 'updateMany');
634
- qi.currentSkip = args.skipGlobalFilters;
635
+ qi.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args.skipGlobalFilters);
635
636
  const dataObj = applyUpdatedAtColumns(qi, args.data);
636
637
  assertNoGeneratedColumns(qi, dataObj, 'updateMany');
637
- whereMod.assertMutationHasPredicate(qi, 'updateMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', args.allowFullTableScan);
638
+ whereMod.assertMutationHasPredicate(qi, 'updateMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'));
638
639
  const whereObj = (whereMod.mergeGlobalFilter(qi, args.where) ?? {});
639
640
  // Nothing to SET: a no-op, for the same reason as `update` above (that path
640
641
  // has the full rationale). Reports `count: 0` because zero rows were
@@ -673,8 +674,8 @@ function buildUpdateMany(qi, args) {
673
674
  }
674
675
  function buildDeleteMany(qi, args) {
675
676
  assertWritable(qi, 'deleteMany');
676
- qi.currentSkip = args.skipGlobalFilters;
677
- whereMod.assertMutationHasPredicate(qi, 'deleteMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', args.allowFullTableScan);
677
+ qi.currentSkip = (0, types_js_1.resolveSkipGlobalFilters)(args.skipGlobalFilters);
678
+ whereMod.assertMutationHasPredicate(qi, 'deleteMany', whereMod.userPredicateIsEmpty(qi, args.where) ? '' : ' WHERE x', (0, types_js_1.resolveUnsafeFlag)(args.allowFullTableScan, 'allowFullTableScan'));
678
679
  const whereObj = (whereMod.mergeGlobalFilter(qi, args.where) ?? {});
679
680
  const whereFp = whereMod.fingerprintWhere(qi, whereObj);
680
681
  const ck = `dm:${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
@@ -189,4 +189,44 @@ export declare function resolveConfig(fileConfig: TurbineCliConfig, overrides: C
189
189
  * order (root-level first, then the `turbine/` location `init` scaffolds).
190
190
  */
191
191
  export declare function resolveSeedFile(config: Pick<TurbineCliConfig, 'seed' | 'seedFile'>, cwd?: string): string | null;
192
+ /**
193
+ * Does this connection string carry a password?
194
+ *
195
+ * `turbine init --url` used to inline whatever it was given straight into
196
+ * `turbine.config.ts`, a file projects commit, so the documented one-liner
197
+ * (`turbine init --url postgres://user:PASSWORD@host/db`) committed a live
198
+ * database password. Everything that decides between "inline the string" and
199
+ * "read `process.env.DATABASE_URL`" asks this function, so there is exactly one
200
+ * definition of "this value is a secret".
201
+ *
202
+ * Three spellings carry a password, and libpq (and `pg-connection-string`,
203
+ * which is what `pg` actually parses with) accepts all three:
204
+ *
205
+ * 1. URL userinfo: `postgres://user:pass@host/db`.
206
+ * 2. URL query parameter: `postgres://user@host/db?password=pass`, and its
207
+ * siblings such as `?sslpassword=`. This one was missed, on the reasoning
208
+ * that a successful `new URL` ruled out the keyword form below, which is
209
+ * true and irrelevant: the secret was in the query string.
210
+ * 3. libpq keyword form: `host=... password=...` (also `sslpassword=`), which
211
+ * is not a URL at all.
212
+ *
213
+ * The query-parameter test reuses `PASSWORD_QUERY_PARAM_PATTERN`, the very
214
+ * regex `redactUrl` redacts with, so a spelling the terminal output hides can
215
+ * never be a spelling this function calls safe to commit. `pg-connection-string`
216
+ * itself would be the ideal oracle, but it is only a TRANSITIVE dependency (of
217
+ * `pg`), and importing an undeclared package would break on any strict,
218
+ * non-hoisting installer. Detection stays self-contained.
219
+ *
220
+ * @internal exported for tests.
221
+ */
222
+ export declare function connectionStringHasPassword(connectionString: string): boolean;
223
+ /**
224
+ * The `turbine.config.ts` scaffold.
225
+ *
226
+ * A password-bearing `connectionString` is NEVER inlined: the emitted config
227
+ * reads `process.env.DATABASE_URL` instead, and `turbine init` scaffolds the
228
+ * `.env` that holds the real value. The refusal lives here rather than at the
229
+ * call site so no future caller can reintroduce the leak by passing the raw
230
+ * `--url` through.
231
+ */
192
232
  export declare function configTemplate(connectionString?: string): string;