@opensaas/stack-core 0.36.0 → 0.38.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +121 -0
- package/CLAUDE.md +21 -3
- package/dist/access/access-filter.d.ts +30 -118
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +70 -206
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +148 -188
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/declared-dependencies.d.ts +66 -26
- package/dist/access/declared-dependencies.d.ts.map +1 -1
- package/dist/access/declared-dependencies.js +67 -17
- package/dist/access/declared-dependencies.js.map +1 -1
- package/dist/access/declared-dependencies.test.d.ts +2 -0
- package/dist/access/declared-dependencies.test.d.ts.map +1 -0
- package/dist/access/declared-dependencies.test.js +226 -0
- package/dist/access/declared-dependencies.test.js.map +1 -0
- package/dist/access/depth-limits.d.ts +8 -7
- package/dist/access/depth-limits.d.ts.map +1 -1
- package/dist/access/depth-limits.js +8 -7
- package/dist/access/depth-limits.js.map +1 -1
- package/dist/access/errors.d.ts +12 -8
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +16 -12
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +91 -17
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +1 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/relationship-count.d.ts +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/types.d.ts +126 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +38 -27
- package/dist/context/index.js.map +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +28 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/query/index.d.ts +29 -0
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +27 -0
- package/dist/query/index.js.map +1 -1
- package/dist/query/relationship-options.d.ts +1 -1
- package/dist/query/relationship-options.js +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +205 -275
- package/src/access/access-filter.ts +84 -267
- package/src/access/declared-dependencies.test.ts +277 -0
- package/src/access/declared-dependencies.ts +122 -37
- package/src/access/depth-limits.ts +8 -7
- package/src/access/errors.ts +16 -12
- package/src/access/field-visibility.ts +99 -14
- package/src/access/index.ts +1 -7
- package/src/access/relationship-count.ts +1 -1
- package/src/config/index.ts +2 -0
- package/src/config/types.ts +130 -0
- package/src/context/index.ts +52 -33
- package/src/fields/index.ts +35 -5
- package/src/index.ts +2 -0
- package/src/query/index.ts +53 -0
- package/src/query/relationship-options.ts +1 -1
- package/tests/access-relationships.test.ts +18 -16
- package/tests/computed-field-selective-evaluation.test.ts +418 -0
- package/tests/context.test.ts +27 -0
- package/tests/field-types.test.ts +12 -0
- package/tests/needs-declared-dependencies.test.ts +7 -4
- package/tests/resolve-chain.test.ts +11 -11
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -6,6 +6,7 @@ import { RESOLVE_CHAIN_MAX_LENGTH } from './depth-limits.js'
|
|
|
6
6
|
import { ResolveOutputCycleError } from './errors.js'
|
|
7
7
|
import type { DeclaredOnlyTree } from './declared-dependencies.js'
|
|
8
8
|
import { emptyDeclaredOnlyTree } from './declared-dependencies.js'
|
|
9
|
+
import type { FieldSelectionScope } from '../query/index.js'
|
|
9
10
|
// NOTE: `context/index.ts` imports `filterReadableFields` from this module
|
|
10
11
|
// (via the `access/index.ts` barrel) — this is an intentional cyclic
|
|
11
12
|
// dependency, the same shape and for the same reason as the one documented in
|
|
@@ -23,6 +24,14 @@ import { buildDbDelegate } from '../context/index.js'
|
|
|
23
24
|
* virtual fields. None of this can move into phase 1: virtual fields are
|
|
24
25
|
* computed in JavaScript and field access can depend on the fetched row.
|
|
25
26
|
*
|
|
27
|
+
* A computed field — any field carrying a `resolveOutput` hook, virtual or
|
|
28
|
+
* not — is produced only where the read is going to return it (ADR-0027). A
|
|
29
|
+
* fragment `query`'s own field selection is the only thing that restricts a
|
|
30
|
+
* level this way; a bare or `include`-based read, and any relation reached
|
|
31
|
+
* purely to satisfy a `needs` declaration, still compute every field, as
|
|
32
|
+
* before. A field the read is not going to return does no work at all —
|
|
33
|
+
* neither its read-access evaluation nor its hook.
|
|
34
|
+
*
|
|
26
35
|
* Phase 1 (pre-query row/relation scoping) lives in `access-filter.ts`. See
|
|
27
36
|
* `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
|
|
28
37
|
* glossary in `CONTEXT.md`.
|
|
@@ -96,8 +105,11 @@ function deriveResolveOutputContext(
|
|
|
96
105
|
* from the result.
|
|
97
106
|
*
|
|
98
107
|
* `accessItem` is the row used to evaluate field access; `hookItem` is the
|
|
99
|
-
* object passed to the hook as `item
|
|
100
|
-
*
|
|
108
|
+
* object passed to the hook as `item`. For a stored field, both are
|
|
109
|
+
* `workingItem` (the row's own stored/fetched columns). For a virtual field,
|
|
110
|
+
* `hookItem` is `computedFieldItem` instead — the same stored columns with
|
|
111
|
+
* every skipped-or-denied key removed — so it never sees another computed
|
|
112
|
+
* field's resolved value (ADR-0027).
|
|
101
113
|
*/
|
|
102
114
|
async function resolveReadableFieldValue(params: {
|
|
103
115
|
fieldConfig: FieldConfig | undefined
|
|
@@ -189,6 +201,13 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
189
201
|
// returned — after resolveOutput has had a chance to read them — so a
|
|
190
202
|
// declared dependency never widens what the caller receives.
|
|
191
203
|
declaredOnly: DeclaredOnlyTree = emptyDeclaredOnlyTree(),
|
|
204
|
+
// The fragment scope this level was reached under (ADR-0027), and the same
|
|
205
|
+
// tree one level down for each nested relation. `undefined` — the default,
|
|
206
|
+
// and what a bare/`include`-based read passes at every level — means
|
|
207
|
+
// unrestricted: every field on the list is computed, unchanged from
|
|
208
|
+
// before ADR-0027. Only a `query` fragment's own field selection ever
|
|
209
|
+
// restricts a level.
|
|
210
|
+
selection?: FieldSelectionScope,
|
|
192
211
|
): Promise<Partial<T>> {
|
|
193
212
|
const filtered: Record<string, unknown> = {}
|
|
194
213
|
|
|
@@ -214,6 +233,15 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
214
233
|
workingItem[fieldName] = assembled
|
|
215
234
|
}
|
|
216
235
|
|
|
236
|
+
// Keys denied by field-level read access during the pass below — as opposed
|
|
237
|
+
// to a key merely skipped by `selection` or held back only for
|
|
238
|
+
// `declaredOnly` stripping. Tracked separately because a denied key must
|
|
239
|
+
// stay invisible to a computed field's hook (below), while a declared-only
|
|
240
|
+
// key must stay VISIBLE to one — that is the entire point of declaring it
|
|
241
|
+
// (ADR-0025) — even though `selection` above skipped adding it to
|
|
242
|
+
// `filtered` because the caller's fragment never asked for it.
|
|
243
|
+
const accessDeniedKeys = new Set<string>()
|
|
244
|
+
|
|
217
245
|
// Process existing fields from the database result
|
|
218
246
|
for (const [fieldName, value] of Object.entries(workingItem)) {
|
|
219
247
|
const fieldConfig = fieldConfigs[fieldName]
|
|
@@ -224,8 +252,19 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
224
252
|
continue
|
|
225
253
|
}
|
|
226
254
|
|
|
255
|
+
// Projection-aware skip (ADR-0027): a fragment read that does not select
|
|
256
|
+
// this field does no work for it at all — no field-level read-access
|
|
257
|
+
// check, no resolveOutput, no recursion into a relation — because the
|
|
258
|
+
// read is never going to return it. `selection` is only ever restricted
|
|
259
|
+
// by a fragment's own field selection; a bare/`include`-based read, and a
|
|
260
|
+
// relation reached only to satisfy another field's `needs`, pass no
|
|
261
|
+
// selection at all and compute every field here, unchanged.
|
|
262
|
+
if (selection?.fields && !selection.fields.has(fieldName)) {
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
|
|
227
266
|
// Handle relationship fields - recursively filter fields within related items
|
|
228
|
-
// Note: Access control filtering is now done at database level via
|
|
267
|
+
// Note: Access control filtering is now done at database level via buildAccessScopedInclude
|
|
229
268
|
// This only handles field-level access (hiding sensitive fields)
|
|
230
269
|
//
|
|
231
270
|
// Deliberately uncapped: the row/relation scoping in access-filter.ts bounds
|
|
@@ -251,6 +290,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
251
290
|
})
|
|
252
291
|
|
|
253
292
|
if (!canRead) {
|
|
293
|
+
accessDeniedKeys.add(fieldName)
|
|
254
294
|
continue
|
|
255
295
|
}
|
|
256
296
|
|
|
@@ -260,6 +300,12 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
260
300
|
// back to an empty tree when this relation isn't declaration-related at
|
|
261
301
|
// all — the common case.
|
|
262
302
|
const nestedDeclaredOnly = declaredOnly.nested[fieldName] ?? emptyDeclaredOnlyTree()
|
|
303
|
+
// This relation's own fragment scope, if the caller's fragment named it
|
|
304
|
+
// with a nested Fragment/RelationSelector. `undefined` (a bare `true`
|
|
305
|
+
// selector, or no `selection` at all) means the nested list computes
|
|
306
|
+
// unrestricted — matching what naming a relation without narrowing it
|
|
307
|
+
// further has always meant.
|
|
308
|
+
const nestedSelection = selection?.nested[fieldName]
|
|
263
309
|
|
|
264
310
|
if (relatedConfig) {
|
|
265
311
|
// For many relationships (arrays) - recursively filter fields in each item
|
|
@@ -275,6 +321,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
275
321
|
depth + 1,
|
|
276
322
|
relatedConfig.listName,
|
|
277
323
|
nestedDeclaredOnly,
|
|
324
|
+
nestedSelection,
|
|
278
325
|
),
|
|
279
326
|
),
|
|
280
327
|
)
|
|
@@ -290,6 +337,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
290
337
|
depth + 1,
|
|
291
338
|
relatedConfig.listName,
|
|
292
339
|
nestedDeclaredOnly,
|
|
340
|
+
nestedSelection,
|
|
293
341
|
)
|
|
294
342
|
}
|
|
295
343
|
} else {
|
|
@@ -314,7 +362,36 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
314
362
|
|
|
315
363
|
if (result.readable) {
|
|
316
364
|
filtered[fieldName] = result.value
|
|
365
|
+
} else {
|
|
366
|
+
accessDeniedKeys.add(fieldName)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// The item a virtual field's hook sees: stored columns and fetched
|
|
371
|
+
// relations (from `workingItem`, never a resolved value — no hook's output
|
|
372
|
+
// is ever written back into `workingItem`). A key is visible here if it
|
|
373
|
+
// either survived into `filtered` (selected and allowed) OR exists only to
|
|
374
|
+
// satisfy a `needs` declaration (`declaredOnly` — that IS the point of
|
|
375
|
+
// declaring it: fetched for a hook, never for the caller, ADR-0025).
|
|
376
|
+
// Everything else — field-level denied, or skipped by `selection` and
|
|
377
|
+
// declared by no one — is deleted. A computed field reaches for exactly its
|
|
378
|
+
// own declared dependencies and nothing another field's hook produced
|
|
379
|
+
// (ADR-0027): reaching for a sibling that was denied or skipped-and-
|
|
380
|
+
// undeclared finds nothing there, the same as reaching for one never
|
|
381
|
+
// declared at all, and reaching for a sibling that DID survive finds its
|
|
382
|
+
// raw stored form, never another hook's resolved value — a virtual field
|
|
383
|
+
// computed earlier in declaration order is exactly as invisible as one
|
|
384
|
+
// computed later.
|
|
385
|
+
const computedFieldItem: Record<string, unknown> = { ...workingItem }
|
|
386
|
+
for (const key of Object.keys(workingItem)) {
|
|
387
|
+
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue
|
|
388
|
+
if (accessDeniedKeys.has(key)) {
|
|
389
|
+
delete computedFieldItem[key]
|
|
390
|
+
continue
|
|
317
391
|
}
|
|
392
|
+
if (key in filtered) continue
|
|
393
|
+
if (declaredOnly.keys.has(key)) continue
|
|
394
|
+
delete computedFieldItem[key]
|
|
318
395
|
}
|
|
319
396
|
|
|
320
397
|
// Process virtual fields - compute values from other fields
|
|
@@ -330,22 +407,29 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
330
407
|
continue
|
|
331
408
|
}
|
|
332
409
|
|
|
333
|
-
//
|
|
334
|
-
//
|
|
410
|
+
// Projection-aware skip (ADR-0027): same rule as the stored-field pass
|
|
411
|
+
// above — a fragment that does not select this virtual field does no
|
|
412
|
+
// work for it at all.
|
|
413
|
+
if (selection?.fields && !selection.fields.has(fieldName)) {
|
|
414
|
+
continue
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// A virtual field with no resolveOutput hook can never produce a value
|
|
418
|
+
// on ANY read — there is nothing to compute, so there is nothing to do,
|
|
419
|
+
// including evaluating its read access (ADR-0027 reconciles the
|
|
420
|
+
// access-only evaluation this branch used to preserve: a field that
|
|
421
|
+
// never has output has no side effect worth preserving access for).
|
|
335
422
|
if (!(fieldConfig.hooks?.resolveOutput && listKey)) {
|
|
336
|
-
// Still evaluate read access to preserve any access-fn side effects.
|
|
337
|
-
await checkFieldAccess(fieldConfig.access, 'read', { ...args, item: workingItem })
|
|
338
423
|
continue
|
|
339
424
|
}
|
|
340
425
|
|
|
341
|
-
// Check read access and compute the value via the shared helper.
|
|
342
|
-
// fields see the already-filtered item so they can read sibling fields.
|
|
426
|
+
// Check read access and compute the value via the shared helper.
|
|
343
427
|
const result = await resolveReadableFieldValue({
|
|
344
428
|
fieldConfig,
|
|
345
429
|
fieldName,
|
|
346
430
|
value: undefined, // Virtual fields don't have a database value
|
|
347
431
|
accessItem: workingItem,
|
|
348
|
-
hookItem:
|
|
432
|
+
hookItem: computedFieldItem,
|
|
349
433
|
listKey,
|
|
350
434
|
args,
|
|
351
435
|
config,
|
|
@@ -357,10 +441,11 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
357
441
|
}
|
|
358
442
|
|
|
359
443
|
// Strip relations that were fetched ONLY to satisfy a `needs` declaration
|
|
360
|
-
// (ADR-0025), now that every resolveOutput hook at this level
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
444
|
+
// (ADR-0025), now that every resolveOutput hook at this level has had the
|
|
445
|
+
// chance to see them (via `computedFieldItem`, never `filtered` itself — a
|
|
446
|
+
// declared dependency is read from stored columns, not from another
|
|
447
|
+
// field's resolved output). A declared dependency is private plumbing, not
|
|
448
|
+
// an implicit `include`: it never widens what the caller receives.
|
|
364
449
|
for (const key of declaredOnly.keys) {
|
|
365
450
|
delete filtered[key]
|
|
366
451
|
}
|
package/src/access/index.ts
CHANGED
|
@@ -22,13 +22,7 @@ export {
|
|
|
22
22
|
// Canonical field-level access evaluation (shared by read and write paths).
|
|
23
23
|
export { checkFieldAccess, filterWritableFields } from './field-access.js'
|
|
24
24
|
// Phase 1 — Access Filter (pre-query row/relation scoping).
|
|
25
|
-
export {
|
|
26
|
-
buildIncludeWithAccessControl,
|
|
27
|
-
mergeIncludeWithAccessControl,
|
|
28
|
-
stripVirtualFieldsFromInclude,
|
|
29
|
-
toPrismaInclude,
|
|
30
|
-
} from './access-filter.js'
|
|
31
|
-
export type { AccessIncludeResult } from './access-filter.js'
|
|
25
|
+
export { buildAccessScopedInclude, stripVirtualFieldsFromInclude } from './access-filter.js'
|
|
32
26
|
// Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
|
|
33
27
|
export { filterReadableFields } from './field-visibility.js'
|
|
34
28
|
// Declared Dependencies — folding `needs` into an include without widening
|
|
@@ -15,7 +15,7 @@ import { getDbKey } from '../lib/case-utils.js'
|
|
|
15
15
|
* per-row query is issued and the count can never include rows the session
|
|
16
16
|
* cannot read. This module is the single place the related list's
|
|
17
17
|
* operation-level `query` access is folded into that `_count`, mirroring how
|
|
18
|
-
* `
|
|
18
|
+
* `buildAccessScopedInclude` folds it into relation includes.
|
|
19
19
|
*
|
|
20
20
|
* It also resolves the count Filter spec's markers: Prisma cannot compare a
|
|
21
21
|
* relation count in a `where`, so a to-many relationship's Filter spec emits a
|
package/src/config/index.ts
CHANGED
package/src/config/types.ts
CHANGED
|
@@ -1109,6 +1109,28 @@ export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
|
|
|
1109
1109
|
*/
|
|
1110
1110
|
isIndexed?: boolean | 'unique'
|
|
1111
1111
|
db?: {
|
|
1112
|
+
/**
|
|
1113
|
+
* Controls DB-level nullability of the foreign key column (and its
|
|
1114
|
+
* relation field) independently of the many side's own shape. Only
|
|
1115
|
+
* meaningful on the FK-owning (single) side of a relationship — the
|
|
1116
|
+
* many side has no column of its own to make non-nullable and rejects
|
|
1117
|
+
* this option.
|
|
1118
|
+
*
|
|
1119
|
+
* @default true (nullable, matching every relationship generated before
|
|
1120
|
+
* this option existed)
|
|
1121
|
+
*
|
|
1122
|
+
* @example
|
|
1123
|
+
* ```typescript
|
|
1124
|
+
* // Every session genuinely belongs to a user — make the FK required
|
|
1125
|
+
* user: relationship({
|
|
1126
|
+
* ref: 'User.sessions',
|
|
1127
|
+
* db: { isNullable: false },
|
|
1128
|
+
* })
|
|
1129
|
+
* // Generates: userId String (was String?)
|
|
1130
|
+
* // user User @relation(...) (was User?)
|
|
1131
|
+
* ```
|
|
1132
|
+
*/
|
|
1133
|
+
isNullable?: boolean
|
|
1112
1134
|
/**
|
|
1113
1135
|
* Controls foreign key placement and column name for bidirectional relationships
|
|
1114
1136
|
* Can be a boolean or an object with a map property
|
|
@@ -1246,6 +1268,16 @@ export type PrismaRelationResult = {
|
|
|
1246
1268
|
* for the many side or the non-FK side it is `[relationLine]`.
|
|
1247
1269
|
*/
|
|
1248
1270
|
modelLines: string[]
|
|
1271
|
+
/**
|
|
1272
|
+
* The Prisma-level foreign key field name this side owns (e.g. `authorId`),
|
|
1273
|
+
* regardless of whether it is indexed. `undefined` when this side doesn't
|
|
1274
|
+
* own a foreign key column at all (the many side, or the non-FK side of a
|
|
1275
|
+
* one-to-one). Lets other generator passes resolve a relationship field
|
|
1276
|
+
* name to its physical column — e.g. a model-level composite index
|
|
1277
|
+
* ({@link ListIndex}) naming a relationship field — without duplicating
|
|
1278
|
+
* {@link foreignKeyIndex}'s narrower, indexing-conditional presence.
|
|
1279
|
+
*/
|
|
1280
|
+
foreignKeyField?: string
|
|
1249
1281
|
/**
|
|
1250
1282
|
* Foreign key index to add to the owning model, if this side owns an
|
|
1251
1283
|
* indexed foreign key.
|
|
@@ -1802,6 +1834,43 @@ export type Hooks<
|
|
|
1802
1834
|
validateInput?: (args: ValidateHookArgs<TOutput, TCreateInput, TUpdateInput>) => Promise<void>
|
|
1803
1835
|
}
|
|
1804
1836
|
|
|
1837
|
+
/**
|
|
1838
|
+
* A single field reference within a model-level {@link ListIndex}. Either
|
|
1839
|
+
* just the OpenSaaS field name, or an object naming it alongside a sort
|
|
1840
|
+
* direction.
|
|
1841
|
+
*/
|
|
1842
|
+
export type ListIndexFieldRef =
|
|
1843
|
+
| string
|
|
1844
|
+
| {
|
|
1845
|
+
field: string
|
|
1846
|
+
/** Sort direction for this column within the index/constraint. */
|
|
1847
|
+
sort?: 'asc' | 'desc'
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/**
|
|
1851
|
+
* A model-level composite `@@unique`/`@@index` constraint spanning two or
|
|
1852
|
+
* more of a list's own fields. See {@link ListConfig.db}'s `indexes` for the
|
|
1853
|
+
* full explanation and examples (#864).
|
|
1854
|
+
*/
|
|
1855
|
+
export type ListIndex = {
|
|
1856
|
+
/**
|
|
1857
|
+
* The fields participating in this index/constraint, in declaration order.
|
|
1858
|
+
* OpenSaaS field names, not raw database column names.
|
|
1859
|
+
*/
|
|
1860
|
+
fields: ListIndexFieldRef[]
|
|
1861
|
+
/**
|
|
1862
|
+
* Emit `@@unique([...])` instead of `@@index([...])`.
|
|
1863
|
+
* @default false
|
|
1864
|
+
*/
|
|
1865
|
+
unique?: boolean
|
|
1866
|
+
/**
|
|
1867
|
+
* Constraint/index name, emitted as Prisma's `map:` argument — lets an
|
|
1868
|
+
* existing live constraint be adopted under its current name rather than
|
|
1869
|
+
* renamed.
|
|
1870
|
+
*/
|
|
1871
|
+
name?: string
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1805
1874
|
// Generic `any` default allows ListConfig to work with any list item type
|
|
1806
1875
|
// This is needed because the item type varies per list and is inferred from Prisma models
|
|
1807
1876
|
/**
|
|
@@ -1879,6 +1948,67 @@ export type ListConfig<TTypeInfo extends TypeInfo> = {
|
|
|
1879
1948
|
* ```
|
|
1880
1949
|
*/
|
|
1881
1950
|
timestamps?: boolean
|
|
1951
|
+
/**
|
|
1952
|
+
* Model-level composite `@@unique`/`@@index` constraints spanning two or
|
|
1953
|
+
* more of this list's own fields (#864).
|
|
1954
|
+
*
|
|
1955
|
+
* Field-level `isIndexed` (on a scalar or relationship field) can only
|
|
1956
|
+
* ever produce a single-column index — it has no way to express a
|
|
1957
|
+
* constraint or index that spans more than one column. `db.indexes` is
|
|
1958
|
+
* that multi-column case: each entry names two or more of the list's own
|
|
1959
|
+
* OpenSaaS field names, not raw database column names. The generator
|
|
1960
|
+
* resolves each to its underlying Prisma column — a scalar field's own
|
|
1961
|
+
* name (its Prisma field name is unaffected by `db.map`), or a
|
|
1962
|
+
* relationship field's foreign key column (`<field>Id`) when this side
|
|
1963
|
+
* owns it.
|
|
1964
|
+
*
|
|
1965
|
+
* A composite **unique** is the load-bearing case: it's the
|
|
1966
|
+
* database-level backstop for a business rule two concurrent writes could
|
|
1967
|
+
* otherwise both slip past (e.g. "one booking per student per
|
|
1968
|
+
* production") — something a hook's existence check cannot close on its
|
|
1969
|
+
* own, and can't be retrofitted once duplicate rows exist. A composite
|
|
1970
|
+
* **index** (non-unique) is the equivalent performance-only case (a hot
|
|
1971
|
+
* multi-column lookup path).
|
|
1972
|
+
*
|
|
1973
|
+
* An entry naming a field the list doesn't have, a virtual field, a
|
|
1974
|
+
* to-many relationship, or the non-FK side of a one-to-one relationship
|
|
1975
|
+
* fails `pnpm generate` with an error naming the list, the entry, and the
|
|
1976
|
+
* bad field — it is never silently dropped or emitted as invalid Prisma.
|
|
1977
|
+
*
|
|
1978
|
+
* @example One audition per student per production (composite unique)
|
|
1979
|
+
* ```typescript
|
|
1980
|
+
* Audition: list({
|
|
1981
|
+
* fields: {
|
|
1982
|
+
* student: relationship({ ref: 'Student.auditions' }),
|
|
1983
|
+
* production: relationship({ ref: 'Production.auditions' }),
|
|
1984
|
+
* },
|
|
1985
|
+
* db: {
|
|
1986
|
+
* indexes: [{ fields: ['student', 'production'], unique: true }],
|
|
1987
|
+
* },
|
|
1988
|
+
* })
|
|
1989
|
+
* // Generates: @@unique([studentId, productionId])
|
|
1990
|
+
* ```
|
|
1991
|
+
*
|
|
1992
|
+
* @example Hot lookup path (composite index) with a sort direction and an adopted constraint name
|
|
1993
|
+
* ```typescript
|
|
1994
|
+
* AuthVerification: list({
|
|
1995
|
+
* fields: {
|
|
1996
|
+
* identifier: text(),
|
|
1997
|
+
* createdAt: timestamp(),
|
|
1998
|
+
* },
|
|
1999
|
+
* db: {
|
|
2000
|
+
* indexes: [
|
|
2001
|
+
* {
|
|
2002
|
+
* fields: ['identifier', { field: 'createdAt', sort: 'desc' }],
|
|
2003
|
+
* name: 'AuthVerification_identifier_createdAt_idx',
|
|
2004
|
+
* },
|
|
2005
|
+
* ],
|
|
2006
|
+
* },
|
|
2007
|
+
* })
|
|
2008
|
+
* // Generates: @@index([identifier, createdAt(sort: Desc)], map: "AuthVerification_identifier_createdAt_idx")
|
|
2009
|
+
* ```
|
|
2010
|
+
*/
|
|
2011
|
+
indexes?: ListIndex[]
|
|
1882
2012
|
}
|
|
1883
2013
|
/**
|
|
1884
2014
|
* MCP server configuration for this list
|
package/src/context/index.ts
CHANGED
|
@@ -4,8 +4,7 @@ import {
|
|
|
4
4
|
checkAccess,
|
|
5
5
|
mergeFilters,
|
|
6
6
|
filterReadableFields,
|
|
7
|
-
|
|
8
|
-
mergeIncludeWithAccessControl,
|
|
7
|
+
buildAccessScopedInclude,
|
|
9
8
|
stripVirtualFieldsFromInclude,
|
|
10
9
|
foldDeclaredDependencies,
|
|
11
10
|
} from '../access/index.js'
|
|
@@ -13,8 +12,8 @@ import type { DeclaredOnlyTree } from '../access/index.js'
|
|
|
13
12
|
import { ValidationError, DatabaseError } from '../hooks/index.js'
|
|
14
13
|
import { getDbKey } from '../lib/case-utils.js'
|
|
15
14
|
import type { PrismaClientLike } from '../access/types.js'
|
|
16
|
-
import { buildInclude, pickFields, isFragment } from '../query/index.js'
|
|
17
|
-
import type { FieldSelection } from '../query/index.js'
|
|
15
|
+
import { buildInclude, pickFields, isFragment, buildFieldSelectionScope } from '../query/index.js'
|
|
16
|
+
import type { FieldSelection, FieldSelectionScope } from '../query/index.js'
|
|
18
17
|
import { getRelationshipOptions } from '../query/relationship-options.js'
|
|
19
18
|
import {
|
|
20
19
|
runWritePipeline,
|
|
@@ -921,11 +920,19 @@ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
|
|
|
921
920
|
*
|
|
922
921
|
* A fragment's own `include` and a sudo caller's `include` are folded and
|
|
923
922
|
* used as-is, matching their existing (unmerged) treatment. A non-sudo
|
|
924
|
-
* caller include is folded and then
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
923
|
+
* caller include is folded and then scoped by `buildAccessScopedInclude`
|
|
924
|
+
* (ADR-0026) — caller-directed, so a relation named nowhere in the folded
|
|
925
|
+
* tree never has its list's `query` access evaluated at all. A bare read
|
|
926
|
+
* stays on the exact ADR-0024 path — `include: undefined`, no related
|
|
927
|
+
* `query` access evaluated — unless folding actually added something, which
|
|
928
|
+
* only happens when a field on this list declares `needs`.
|
|
929
|
+
*
|
|
930
|
+
* Also returns the `FieldSelectionScope` a fragment's own field selection
|
|
931
|
+
* produces (ADR-0027), so the caller can pass it to `filterReadableFields`
|
|
932
|
+
* and make computation itself projection-aware, not only the fold above.
|
|
933
|
+
* `undefined` for every non-fragment path: a caller `include` (sudo or not)
|
|
934
|
+
* and a bare read both mean "compute every field," matching what they
|
|
935
|
+
* already fetch.
|
|
929
936
|
*/
|
|
930
937
|
async function resolveReadInclude(
|
|
931
938
|
callerInclude: Record<string, unknown> | undefined,
|
|
@@ -935,38 +942,43 @@ async function resolveReadInclude(
|
|
|
935
942
|
listConfig: ListConfig<any>,
|
|
936
943
|
context: AccessContext & { _isSudo?: boolean },
|
|
937
944
|
config: OpenSaasConfig,
|
|
938
|
-
): Promise<{
|
|
945
|
+
): Promise<{
|
|
946
|
+
include: Record<string, unknown> | undefined
|
|
947
|
+
declaredOnly: DeclaredOnlyTree
|
|
948
|
+
selection: FieldSelectionScope | undefined
|
|
949
|
+
}> {
|
|
939
950
|
if (fragmentFields !== undefined) {
|
|
940
951
|
const fragmentInclude = buildInclude(fragmentFields) ?? undefined
|
|
941
|
-
|
|
952
|
+
const selection = buildFieldSelectionScope(fragmentFields)
|
|
953
|
+
const folded = foldDeclaredDependencies(
|
|
954
|
+
fragmentInclude,
|
|
955
|
+
listConfig.fields,
|
|
956
|
+
config,
|
|
957
|
+
listName,
|
|
958
|
+
[listName],
|
|
959
|
+
selection,
|
|
960
|
+
)
|
|
961
|
+
return { ...folded, selection }
|
|
942
962
|
}
|
|
943
963
|
|
|
944
964
|
if (context._isSudo) {
|
|
945
|
-
|
|
965
|
+
const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
|
|
966
|
+
return { ...folded, selection: undefined }
|
|
946
967
|
}
|
|
947
968
|
|
|
948
|
-
const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config)
|
|
969
|
+
const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
|
|
949
970
|
if (!folded.include) {
|
|
950
|
-
return folded
|
|
971
|
+
return { ...folded, selection: undefined }
|
|
951
972
|
}
|
|
952
973
|
|
|
953
|
-
const
|
|
954
|
-
listConfig.fields,
|
|
955
|
-
{ session: context.session, context },
|
|
956
|
-
config,
|
|
957
|
-
0,
|
|
958
|
-
// Seed the cycle guard with the root list so a relationship cycle back
|
|
959
|
-
// to it (self-referential or longer) stops re-descending.
|
|
960
|
-
[listName],
|
|
961
|
-
)
|
|
962
|
-
const include = mergeIncludeWithAccessControl(
|
|
974
|
+
const include = await buildAccessScopedInclude(
|
|
963
975
|
folded.include,
|
|
964
|
-
accessControlledInclude,
|
|
965
976
|
listConfig.fields,
|
|
977
|
+
{ session: context.session, context },
|
|
966
978
|
config,
|
|
967
979
|
listName,
|
|
968
980
|
)
|
|
969
|
-
return { include, declaredOnly: folded.declaredOnly }
|
|
981
|
+
return { include, declaredOnly: folded.declaredOnly, selection: undefined }
|
|
970
982
|
}
|
|
971
983
|
|
|
972
984
|
/**
|
|
@@ -1032,7 +1044,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1032
1044
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
1033
1045
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
1034
1046
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
1035
|
-
let { include, declaredOnly } = await resolveReadInclude(
|
|
1047
|
+
let { include, declaredOnly, selection } = await resolveReadInclude(
|
|
1036
1048
|
args.include,
|
|
1037
1049
|
fragment ? fragment._fields : undefined,
|
|
1038
1050
|
listName,
|
|
@@ -1044,8 +1056,10 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1044
1056
|
// Virtual fields have no database column. Whichever path produced
|
|
1045
1057
|
// `include` (fragment, access-controlled merge, or sudo passthrough), a
|
|
1046
1058
|
// virtual key must never reach Prisma — it would throw "Unknown field"
|
|
1047
|
-
// (#628).
|
|
1048
|
-
// `
|
|
1059
|
+
// (#628). Below, `filterReadableFields` computes a virtual field's value
|
|
1060
|
+
// exactly when `selection` says the read is going to return it (ADR-0027)
|
|
1061
|
+
// — every one of them for a bare/`include`-based read (`selection` is
|
|
1062
|
+
// `undefined`), only the ones a fragment named otherwise.
|
|
1049
1063
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
|
|
1050
1064
|
|
|
1051
1065
|
// Execute query with optimized includes
|
|
@@ -1074,6 +1088,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1074
1088
|
0,
|
|
1075
1089
|
listName,
|
|
1076
1090
|
declaredOnly,
|
|
1091
|
+
selection,
|
|
1077
1092
|
)
|
|
1078
1093
|
|
|
1079
1094
|
// When a fragment is provided, pick only the requested fields from the result
|
|
@@ -1145,7 +1160,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1145
1160
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
1146
1161
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
1147
1162
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
1148
|
-
let { include, declaredOnly } = await resolveReadInclude(
|
|
1163
|
+
let { include, declaredOnly, selection } = await resolveReadInclude(
|
|
1149
1164
|
args?.include,
|
|
1150
1165
|
fragment ? fragment._fields : undefined,
|
|
1151
1166
|
listName,
|
|
@@ -1157,8 +1172,10 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1157
1172
|
// Virtual fields have no database column. Whichever path produced
|
|
1158
1173
|
// `include` (fragment, access-controlled merge, or sudo passthrough), a
|
|
1159
1174
|
// virtual key must never reach Prisma — it would throw "Unknown field"
|
|
1160
|
-
// (#628).
|
|
1161
|
-
// `
|
|
1175
|
+
// (#628). Below, `filterReadableFields` computes a virtual field's value
|
|
1176
|
+
// exactly when `selection` says the read is going to return it (ADR-0027)
|
|
1177
|
+
// — every one of them for a bare/`include`-based read (`selection` is
|
|
1178
|
+
// `undefined`), only the ones a fragment named otherwise.
|
|
1162
1179
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
|
|
1163
1180
|
|
|
1164
1181
|
// Execute query with optimized includes
|
|
@@ -1188,6 +1205,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1188
1205
|
0,
|
|
1189
1206
|
listName,
|
|
1190
1207
|
declaredOnly,
|
|
1208
|
+
selection,
|
|
1191
1209
|
),
|
|
1192
1210
|
),
|
|
1193
1211
|
)
|
|
@@ -1461,7 +1479,7 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1461
1479
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
1462
1480
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
1463
1481
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
1464
|
-
let { include, declaredOnly } = await resolveReadInclude(
|
|
1482
|
+
let { include, declaredOnly, selection } = await resolveReadInclude(
|
|
1465
1483
|
args?.include,
|
|
1466
1484
|
fragment ? fragment._fields : undefined,
|
|
1467
1485
|
listName,
|
|
@@ -1493,6 +1511,7 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1493
1511
|
0,
|
|
1494
1512
|
listName,
|
|
1495
1513
|
declaredOnly,
|
|
1514
|
+
selection,
|
|
1496
1515
|
)
|
|
1497
1516
|
// When a fragment is provided, pick only the requested fields from the result
|
|
1498
1517
|
if (fragment) {
|
package/src/fields/index.ts
CHANGED
|
@@ -1387,10 +1387,16 @@ function getPrismaRelation(
|
|
|
1387
1387
|
? ` @map("${field.db.foreignKey.map}")`
|
|
1388
1388
|
: ` @map("${fieldName}")`
|
|
1389
1389
|
|
|
1390
|
-
|
|
1390
|
+
// Nullability: explicit db.isNullable overrides the default (nullable),
|
|
1391
|
+
// matching the scalar fields' `db.isNullable` convention. It moves the FK
|
|
1392
|
+
// column and its relation field together — they can never disagree.
|
|
1393
|
+
const isNullable = field.db?.isNullable ?? true
|
|
1394
|
+
const nullModifier = isNullable ? '?' : ''
|
|
1395
|
+
|
|
1396
|
+
let fkLine = ` ${fkPaddedName} String${nullModifier}${uniqueModifier}${mapModifier}`
|
|
1391
1397
|
let relationLine = targetField
|
|
1392
|
-
? ` ${paddedName} ${targetList}
|
|
1393
|
-
: ` ${paddedName} ${targetList}
|
|
1398
|
+
? ` ${paddedName} ${targetList}${nullModifier} @relation(fields: [${foreignKeyField}], references: [id])`
|
|
1399
|
+
: ` ${paddedName} ${targetList}${nullModifier} @relation("${listKey}_${fieldName}", fields: [${foreignKeyField}], references: [id])`
|
|
1394
1400
|
|
|
1395
1401
|
if (field.db?.extendPrismaSchema) {
|
|
1396
1402
|
const extended = field.db.extendPrismaSchema({ fkLine, relationLine })
|
|
@@ -1402,10 +1408,24 @@ function getPrismaRelation(
|
|
|
1402
1408
|
const indexType = field.isIndexed ?? true
|
|
1403
1409
|
const foreignKeyIndex = indexType !== false ? { foreignKeyField, indexType } : undefined
|
|
1404
1410
|
|
|
1405
|
-
return { modelLines: [fkLine, relationLine], foreignKeyIndex, backRelation }
|
|
1411
|
+
return { modelLines: [fkLine, relationLine], foreignKeyField, foreignKeyIndex, backRelation }
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// Non-FK side of a one-to-one relationship: just the relation field. This
|
|
1415
|
+
// side has no foreign key column, so `db.isNullable` (which only makes
|
|
1416
|
+
// sense paired with a column) cannot be honoured here — reject rather than
|
|
1417
|
+
// silently ignore a developer's stated intent (the FK-owning side is
|
|
1418
|
+
// determined by `db.foreignKey`/alphabetical ordering, not by which field
|
|
1419
|
+
// declares `isNullable`).
|
|
1420
|
+
if (field.db?.isNullable === false) {
|
|
1421
|
+
throw new Error(
|
|
1422
|
+
`db.isNullable can only be used on the foreign-key-owning side of a relationship. ` +
|
|
1423
|
+
`"${listKey}.${fieldName}" does not own the foreign key for this one-to-one relationship — ` +
|
|
1424
|
+
`set db.isNullable on "${targetList}.${targetField}" instead, or make this side own the ` +
|
|
1425
|
+
`foreign key via db.foreignKey.`,
|
|
1426
|
+
)
|
|
1406
1427
|
}
|
|
1407
1428
|
|
|
1408
|
-
// Non-FK side of a one-to-one relationship: just the relation field
|
|
1409
1429
|
let relationLine = ` ${paddedName} ${targetList}?`
|
|
1410
1430
|
if (field.db?.extendPrismaSchema) {
|
|
1411
1431
|
relationLine = field.db.extendPrismaSchema({ relationLine }).relationLine
|
|
@@ -1451,6 +1471,16 @@ export function relationship<
|
|
|
1451
1471
|
}
|
|
1452
1472
|
}
|
|
1453
1473
|
|
|
1474
|
+
// Validate db.isNullable usage: only the FK-owning (single) side of a
|
|
1475
|
+
// relationship has a column to make non-nullable — the many side always
|
|
1476
|
+
// generates an array field with no nullability of its own.
|
|
1477
|
+
if (options.db?.isNullable !== undefined && options.many) {
|
|
1478
|
+
throw new Error(
|
|
1479
|
+
'db.isNullable can only be used on single relationships (many: false or undefined). ' +
|
|
1480
|
+
'Many-side of a relationship has no foreign key column to make non-nullable.',
|
|
1481
|
+
)
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1454
1484
|
const field: RelationshipField<TTypeInfo> = {
|
|
1455
1485
|
type: 'relationship',
|
|
1456
1486
|
...options,
|