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.
- package/README.md +65 -21
- package/dist/cjs/cli/config.d.ts +40 -0
- package/dist/cjs/cli/config.js +74 -2
- package/dist/cjs/cli/index.d.ts +85 -1
- package/dist/cjs/cli/index.js +323 -24
- package/dist/cjs/cli/mcp.d.ts +8 -0
- package/dist/cjs/cli/mcp.js +448 -29
- package/dist/cjs/cli/pii-tags.d.ts +64 -9
- package/dist/cjs/cli/pii-tags.js +218 -39
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.d.ts +23 -0
- package/dist/cjs/cli/studio.js +126 -53
- package/dist/cjs/cli/ui.d.ts +15 -1
- package/dist/cjs/cli/ui.js +19 -5
- package/dist/cjs/client.js +186 -3
- package/dist/cjs/errors.d.ts +38 -1
- package/dist/cjs/errors.js +235 -24
- package/dist/cjs/index.d.ts +2 -2
- package/dist/cjs/index.js +7 -2
- package/dist/cjs/pipeline.js +15 -2
- package/dist/cjs/powql.d.ts +12 -0
- package/dist/cjs/powql.js +46 -21
- package/dist/cjs/prisma-compat.d.ts +15 -5
- package/dist/cjs/prisma-compat.js +273 -78
- package/dist/cjs/query/aggregates.d.ts +1 -1
- package/dist/cjs/query/aggregates.js +24 -10
- package/dist/cjs/query/batched-loader.d.ts +9 -4
- package/dist/cjs/query/batched-loader.js +4 -1
- package/dist/cjs/query/builder.d.ts +47 -0
- package/dist/cjs/query/builder.js +125 -21
- package/dist/cjs/query/index.d.ts +3 -1
- package/dist/cjs/query/index.js +7 -1
- package/dist/cjs/query/option-surface.d.ts +11 -0
- package/dist/cjs/query/option-surface.js +13 -0
- package/dist/cjs/query/relations.d.ts +8 -0
- package/dist/cjs/query/relations.js +21 -1
- package/dist/cjs/query/types.d.ts +152 -18
- package/dist/cjs/query/types.js +212 -1
- package/dist/cjs/query/where.d.ts +3 -3
- package/dist/cjs/query/where.js +8 -2
- package/dist/cjs/query/writes.js +10 -9
- package/dist/cli/config.d.ts +40 -0
- package/dist/cli/config.js +73 -2
- package/dist/cli/index.d.ts +85 -1
- package/dist/cli/index.js +321 -26
- package/dist/cli/mcp.d.ts +8 -0
- package/dist/cli/mcp.js +448 -29
- package/dist/cli/pii-tags.d.ts +64 -9
- package/dist/cli/pii-tags.js +217 -39
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +23 -0
- package/dist/cli/studio.js +125 -53
- package/dist/cli/ui.d.ts +15 -1
- package/dist/cli/ui.js +18 -4
- package/dist/client.js +187 -4
- package/dist/errors.d.ts +38 -1
- package/dist/errors.js +234 -23
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -2
- package/dist/pipeline.js +15 -2
- package/dist/powql.d.ts +12 -0
- package/dist/powql.js +46 -21
- package/dist/prisma-compat.d.ts +15 -5
- package/dist/prisma-compat.js +274 -79
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +24 -10
- package/dist/query/batched-loader.d.ts +9 -4
- package/dist/query/batched-loader.js +4 -1
- package/dist/query/builder.d.ts +47 -0
- package/dist/query/builder.js +124 -21
- package/dist/query/index.d.ts +3 -1
- package/dist/query/index.js +2 -0
- package/dist/query/option-surface.d.ts +11 -0
- package/dist/query/option-surface.js +13 -0
- package/dist/query/relations.d.ts +8 -0
- package/dist/query/relations.js +21 -1
- package/dist/query/types.d.ts +152 -18
- package/dist/query/types.js +207 -2
- package/dist/query/where.d.ts +3 -3
- package/dist/query/where.js +8 -2
- package/dist/query/writes.js +10 -9
- package/package.json +13 -3
package/dist/query/types.d.ts
CHANGED
|
@@ -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}.
|
|
190
|
-
* global filter on the query's own table AND on every relation target
|
|
191
|
-
* touches;
|
|
192
|
-
* relation targets). Global filters never satisfy the empty-`where`
|
|
193
|
-
* `update`/`delete`, that guard always checks the user-supplied
|
|
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 =
|
|
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
|
|
467
|
-
includePii?:
|
|
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?:
|
|
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`.
|
|
732
|
-
*
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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}. */
|
package/dist/query/types.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/query/where.d.ts
CHANGED
|
@@ -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,
|
|
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:
|
|
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?:
|
|
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
|
package/dist/query/where.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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).
|
package/dist/query/writes.js
CHANGED
|
@@ -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.
|
|
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"
|