@opensaas/stack-core 0.35.0 → 0.37.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 (46) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +88 -0
  3. package/CLAUDE.md +44 -0
  4. package/dist/access/declared-dependencies.d.ts +68 -0
  5. package/dist/access/declared-dependencies.d.ts.map +1 -0
  6. package/dist/access/declared-dependencies.js +79 -0
  7. package/dist/access/declared-dependencies.js.map +1 -0
  8. package/dist/access/field-visibility.d.ts +2 -1
  9. package/dist/access/field-visibility.d.ts.map +1 -1
  10. package/dist/access/field-visibility.js +23 -3
  11. package/dist/access/field-visibility.js.map +1 -1
  12. package/dist/access/index.d.ts +2 -0
  13. package/dist/access/index.d.ts.map +1 -1
  14. package/dist/access/index.js +3 -0
  15. package/dist/access/index.js.map +1 -1
  16. package/dist/config/index.d.ts +1 -1
  17. package/dist/config/index.d.ts.map +1 -1
  18. package/dist/config/types.d.ts +191 -1
  19. package/dist/config/types.d.ts.map +1 -1
  20. package/dist/context/index.d.ts.map +1 -1
  21. package/dist/context/index.js +51 -95
  22. package/dist/context/index.js.map +1 -1
  23. package/dist/fields/index.d.ts.map +1 -1
  24. package/dist/fields/index.js +40 -17
  25. package/dist/fields/index.js.map +1 -1
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +6 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/validation/needs-closure.d.ts +49 -0
  31. package/dist/validation/needs-closure.d.ts.map +1 -0
  32. package/dist/validation/needs-closure.js +139 -0
  33. package/dist/validation/needs-closure.js.map +1 -0
  34. package/package.json +1 -1
  35. package/src/access/declared-dependencies.ts +140 -0
  36. package/src/access/field-visibility.ts +24 -0
  37. package/src/access/index.ts +8 -0
  38. package/src/config/index.ts +2 -0
  39. package/src/config/types.ts +195 -1
  40. package/src/context/index.ts +96 -115
  41. package/src/fields/index.ts +47 -14
  42. package/src/index.ts +10 -0
  43. package/src/validation/needs-closure.ts +188 -0
  44. package/tests/field-types.test.ts +18 -2
  45. package/tests/needs-declared-dependencies.test.ts +500 -0
  46. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,139 @@
1
+ import { getRelatedListConfig } from '../access/engine.js';
2
+ import { READ_INCLUDE_MAX_DEPTH } from '../access/depth-limits.js';
3
+ function isRelationshipFieldConfig(fieldConfig) {
4
+ return (!!fieldConfig &&
5
+ fieldConfig.type === 'relationship' &&
6
+ 'ref' in fieldConfig &&
7
+ !!fieldConfig.ref);
8
+ }
9
+ /**
10
+ * The deepest needs-chain reachable from `listKey`, considering EVERY field
11
+ * on every list along the way that declares `needs` — not only the field
12
+ * that triggered the walk. A computed field runs wherever its list's rows
13
+ * are fetched, so once a list is reached, ALL of its own declared
14
+ * dependencies must be satisfiable too (ADR-0025's "at every level a field
15
+ * is computed").
16
+ *
17
+ * `path` is the list keys already on this DFS branch, root-first, used to
18
+ * detect a cycle (a chain that can never terminate).
19
+ */
20
+ function listClosureDepth(listKey, config, path) {
21
+ const listConfig = config.lists[listKey];
22
+ if (!listConfig?.fields)
23
+ return { depth: 0, chain: [listKey] };
24
+ let maxDepth = 0;
25
+ let maxChain = [listKey];
26
+ for (const fieldConfig of Object.values(listConfig.fields)) {
27
+ if (!fieldConfig?.hooks?.resolveOutput)
28
+ continue;
29
+ for (const relationName of fieldConfig.needs ?? []) {
30
+ const relatedField = listConfig.fields[relationName];
31
+ if (!isRelationshipFieldConfig(relatedField))
32
+ continue;
33
+ const relatedConfig = getRelatedListConfig(relatedField.ref, config);
34
+ if (!relatedConfig)
35
+ continue;
36
+ const relatedListKey = relatedConfig.listName;
37
+ if (path.includes(relatedListKey)) {
38
+ return { cycle: [...path, relatedListKey] };
39
+ }
40
+ const sub = listClosureDepth(relatedListKey, config, [...path, relatedListKey]);
41
+ if ('cycle' in sub)
42
+ return sub;
43
+ if (1 + sub.depth > maxDepth) {
44
+ maxDepth = 1 + sub.depth;
45
+ maxChain = [listKey, ...sub.chain];
46
+ }
47
+ }
48
+ }
49
+ return { depth: maxDepth, chain: maxChain };
50
+ }
51
+ /**
52
+ * Validate that every `needs` entry names an immediate relationship field
53
+ * declared on the SAME list. The generated `Lists.<List>.TypeInfo` already
54
+ * makes a misspelled or non-relation entry a compile error for a config
55
+ * annotated with it (`list<Lists.X.TypeInfo>({...})`, the documented
56
+ * pattern) — this is the runtime backstop for configs that aren't, or that
57
+ * are authored in plain JS.
58
+ *
59
+ * @param config - The fully resolved OpenSaas config.
60
+ * @returns All invalid `needs` entries, flattened across lists and fields.
61
+ */
62
+ export function validateNeedsDeclarations(config) {
63
+ const errors = [];
64
+ for (const [listKey, listConfig] of Object.entries(config.lists)) {
65
+ if (!listConfig?.fields)
66
+ continue;
67
+ for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
68
+ for (const relationName of fieldConfig?.needs ?? []) {
69
+ if (isRelationshipFieldConfig(listConfig.fields[relationName]))
70
+ continue;
71
+ const exists = relationName in listConfig.fields;
72
+ errors.push({
73
+ listKey,
74
+ fieldKey,
75
+ chain: [listKey],
76
+ reason: 'invalid-relation',
77
+ message: exists
78
+ ? `"${listKey}.${fieldKey}" declares needs: ['${relationName}'], but "${relationName}" ` +
79
+ `is not a relationship field on "${listKey}". \`needs\` may only name immediate ` +
80
+ `relationship fields declared on the same list.`
81
+ : `"${listKey}.${fieldKey}" declares needs: ['${relationName}'], but "${listKey}" has ` +
82
+ `no field named "${relationName}".`,
83
+ });
84
+ }
85
+ }
86
+ }
87
+ return errors;
88
+ }
89
+ /**
90
+ * Validate that every field's `needs` closure fits within
91
+ * `READ_INCLUDE_MAX_DEPTH` when evaluated starting at the declaring field's
92
+ * own list — the most favourable starting point a caller could ever give it.
93
+ * Intended to run once, before generation, exactly like
94
+ * {@link validateConfigFields} — a config whose closure cannot fit must not
95
+ * generate, per ADR-0025's "Depth" section.
96
+ *
97
+ * @param config - The fully resolved OpenSaas config.
98
+ * @returns All unsatisfiable-closure violations, flattened across lists and fields.
99
+ */
100
+ export function validateNeedsClosureDepth(config) {
101
+ const errors = [];
102
+ for (const [listKey, listConfig] of Object.entries(config.lists)) {
103
+ if (!listConfig?.fields)
104
+ continue;
105
+ for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
106
+ if (!fieldConfig?.hooks?.resolveOutput || !fieldConfig.needs?.length)
107
+ continue;
108
+ const result = listClosureDepth(listKey, config, [listKey]);
109
+ if ('cycle' in result) {
110
+ errors.push({
111
+ listKey,
112
+ fieldKey,
113
+ chain: result.cycle,
114
+ reason: 'cycle',
115
+ message: `"${listKey}.${fieldKey}"'s needs declaration never terminates: ` +
116
+ `${result.cycle.join(' → ')} → … . A chain of \`needs\` across these lists cycles back ` +
117
+ `on itself, so no read could ever satisfy it. Break the cycle by removing one of the ` +
118
+ `\`needs\` entries on this chain, or compute the value without it.`,
119
+ });
120
+ continue;
121
+ }
122
+ if (result.depth >= READ_INCLUDE_MAX_DEPTH) {
123
+ errors.push({
124
+ listKey,
125
+ fieldKey,
126
+ chain: result.chain,
127
+ reason: 'depth',
128
+ message: `"${listKey}.${fieldKey}"'s needs declaration requires a closure ${result.depth} ` +
129
+ `relations deep even starting at "${listKey}" itself: ${result.chain.join(' → ')}. ` +
130
+ `This exceeds the Access Filter's maximum read-include depth ` +
131
+ `(${READ_INCLUDE_MAX_DEPTH}), so no caller — however they start the read — could ever ` +
132
+ `have this closure satisfied. Shorten the \`needs\` chain across these lists.`,
133
+ });
134
+ }
135
+ }
136
+ }
137
+ return errors;
138
+ }
139
+ //# sourceMappingURL=needs-closure.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"needs-closure.js","sourceRoot":"","sources":["../../src/validation/needs-closure.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opensaas/stack-core",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "description": "Core stack for OpenSaas - schema definition, access control, and runtime utilities",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,140 @@
1
+ import type { FieldConfig, OpenSaasConfig } from '../config/types.js'
2
+ import { getRelatedListConfig } from './engine.js'
3
+
4
+ /**
5
+ * Declared Dependencies — folding a computed field's `needs` into a read's
6
+ * `include` without widening what the caller receives (ADR-0025).
7
+ *
8
+ * A field's `needs` declares immediate sibling relations its `resolveOutput`
9
+ * hook cannot compute without. This module folds those relations into
10
+ * whatever `include` a read is already building (caller-supplied, fragment-
11
+ * derived, or none at all) BEFORE it reaches the existing access-scoping
12
+ * pipeline (`buildIncludeWithAccessControl` / `mergeIncludeWithAccessControl`
13
+ * in `access-filter.ts`) — a declared relation is scoped exactly like a
14
+ * caller-named one, never a bypass.
15
+ *
16
+ * The fold also tracks provenance: which relation keys, at which nesting
17
+ * level, were added ONLY to satisfy a declaration (as opposed to being named
18
+ * by the caller). `field-visibility.ts` uses that tree to strip those keys
19
+ * from the result after `resolveOutput` hooks have had a chance to read them
20
+ * — a declared dependency is private plumbing, not an implicit `include`.
21
+ *
22
+ * Reach beyond one hop: a relation added here to satisfy a declaration is
23
+ * added BARE (`true`), never with an explicit nested include of its own.
24
+ * `buildIncludeWithAccessControl` already auto-expands a bare relation's own
25
+ * readable-relationship subtree to `READ_INCLUDE_MAX_DEPTH` (pre-ADR-0026),
26
+ * so a chain of declarations rides that existing expansion for free — the
27
+ * related list's own declared needs are already present among what gets
28
+ * auto-included beneath it. This is also why a declaration-driven cycle
29
+ * (e.g. `Order.total` needs `lineItems`, `LineItem.orderRef` needs `order`)
30
+ * can't recurse without bound here: it flows through
31
+ * `buildIncludeWithAccessControl`'s existing `visitedLists` cycle guard
32
+ * rather than through any recursion of this module's own (see ADR-0026's
33
+ * note that this guard's remaining job, after that ADR lands, is defending
34
+ * exactly this fold).
35
+ *
36
+ * This module only recurses into EXPLICIT nested includes the caller wrote
37
+ * (narrowing what's fetched below a relation) — those cut off the free
38
+ * auto-expansion, so a nested list's own declared needs must be folded in
39
+ * explicitly. An explicit caller include is always a finite literal, so this
40
+ * recursion terminates on its own without a separate depth/cycle guard.
41
+ */
42
+
43
+ /** Which relation keys, at which nesting level, exist only to satisfy a `needs` declaration. */
44
+ export type DeclaredOnlyTree = {
45
+ /** Keys at THIS level whose entire branch was added purely by the fold. */
46
+ keys: Set<string>
47
+ /** Per-key trees for relations present in the include for other reasons (caller-named), whose OWN nested include may still contain declaration-only keys. */
48
+ nested: Record<string, DeclaredOnlyTree>
49
+ }
50
+
51
+ export function emptyDeclaredOnlyTree(): DeclaredOnlyTree {
52
+ return { keys: new Set(), nested: {} }
53
+ }
54
+
55
+ function isDeclaredOnlyTreeEmpty(tree: DeclaredOnlyTree): boolean {
56
+ return tree.keys.size === 0 && Object.keys(tree.nested).length === 0
57
+ }
58
+
59
+ function isRelationshipFieldConfig(
60
+ fieldConfig: FieldConfig | undefined,
61
+ ): fieldConfig is FieldConfig & { type: 'relationship'; ref: string } {
62
+ return (
63
+ !!fieldConfig &&
64
+ fieldConfig.type === 'relationship' &&
65
+ 'ref' in fieldConfig &&
66
+ !!fieldConfig.ref
67
+ )
68
+ }
69
+
70
+ /**
71
+ * The deduped set of relation names declared via `needs` by fields on this
72
+ * list that have a `resolveOutput` hook. A `needs` entry on a field without
73
+ * one is inert — there is no hook to feed it to — so it contributes nothing
74
+ * to fetch.
75
+ */
76
+ export function getDeclaredRelationNames(fieldConfigs: Record<string, FieldConfig>): string[] {
77
+ const names = new Set<string>()
78
+ for (const fieldConfig of Object.values(fieldConfigs)) {
79
+ if (!fieldConfig?.hooks?.resolveOutput) continue
80
+ for (const name of fieldConfig.needs ?? []) {
81
+ names.add(name)
82
+ }
83
+ }
84
+ return [...names]
85
+ }
86
+
87
+ /**
88
+ * Fold this list's declared dependencies into `rawInclude`, recursing into
89
+ * any EXPLICIT nested include the caller wrote so a related list's own
90
+ * declared needs are satisfied too (see module doc comment).
91
+ *
92
+ * Returns `rawInclude` itself (same reference) when there is nothing to
93
+ * fold, so a list with no `needs` fields and no caller include stays on the
94
+ * exact bare-read path (ADR-0024) — untouched, not merely equivalent.
95
+ */
96
+ export function foldDeclaredDependencies(
97
+ rawInclude: Record<string, unknown> | undefined,
98
+ fieldConfigs: Record<string, FieldConfig>,
99
+ config: OpenSaasConfig,
100
+ ): { include: Record<string, unknown> | undefined; declaredOnly: DeclaredOnlyTree } {
101
+ const declaredNames = getDeclaredRelationNames(fieldConfigs)
102
+
103
+ if (declaredNames.length === 0 && !rawInclude) {
104
+ return { include: rawInclude, declaredOnly: emptyDeclaredOnlyTree() }
105
+ }
106
+
107
+ const declaredOnly = emptyDeclaredOnlyTree()
108
+ const merged: Record<string, unknown> = { ...(rawInclude ?? {}) }
109
+
110
+ for (const name of declaredNames) {
111
+ if (name in merged) continue // caller (or fragment) already asked for it — not declaration-only
112
+ if (!isRelationshipFieldConfig(fieldConfigs[name])) continue // invalid `needs` entry; caught by generate-time validation
113
+ merged[name] = true
114
+ declaredOnly.keys.add(name)
115
+ }
116
+
117
+ for (const [key, value] of Object.entries(merged)) {
118
+ const fieldConfig = fieldConfigs[key]
119
+ if (!isRelationshipFieldConfig(fieldConfig)) continue
120
+ // A whole branch we just added is bare `true` — its own subtree auto-expands
121
+ // (see module doc comment), so there is no explicit nested include to recurse into.
122
+ if (declaredOnly.keys.has(key)) continue
123
+
124
+ const entry = value as { include?: Record<string, unknown> } | boolean
125
+ if (!entry || typeof entry !== 'object' || !entry.include) continue
126
+
127
+ const relatedConfig = getRelatedListConfig(fieldConfig.ref, config)
128
+ if (!relatedConfig) continue
129
+
130
+ const nested = foldDeclaredDependencies(entry.include, relatedConfig.listConfig.fields, config)
131
+ if (nested.include !== entry.include) {
132
+ merged[key] = { ...entry, include: nested.include }
133
+ }
134
+ if (!isDeclaredOnlyTreeEmpty(nested.declaredOnly)) {
135
+ declaredOnly.nested[key] = nested.declaredOnly
136
+ }
137
+ }
138
+
139
+ return { include: merged, declaredOnly }
140
+ }
@@ -4,6 +4,8 @@ import { getRelatedListConfig } from './engine.js'
4
4
  import { checkFieldAccess } from './field-access.js'
5
5
  import { RESOLVE_CHAIN_MAX_LENGTH } from './depth-limits.js'
6
6
  import { ResolveOutputCycleError } from './errors.js'
7
+ import type { DeclaredOnlyTree } from './declared-dependencies.js'
8
+ import { emptyDeclaredOnlyTree } from './declared-dependencies.js'
7
9
  // NOTE: `context/index.ts` imports `filterReadableFields` from this module
8
10
  // (via the `access/index.ts` barrel) — this is an intentional cyclic
9
11
  // dependency, the same shape and for the same reason as the one documented in
@@ -181,6 +183,12 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
181
183
  config?: OpenSaasConfig,
182
184
  depth: number = 0,
183
185
  listKey?: string,
186
+ // Relation keys added purely to satisfy a field's `needs` (ADR-0025) at
187
+ // THIS level, and the same tree for each nested relation reached via a
188
+ // caller-named branch. Stripped from `filtered` right before it is
189
+ // returned — after resolveOutput has had a chance to read them — so a
190
+ // declared dependency never widens what the caller receives.
191
+ declaredOnly: DeclaredOnlyTree = emptyDeclaredOnlyTree(),
184
192
  ): Promise<Partial<T>> {
185
193
  const filtered: Record<string, unknown> = {}
186
194
 
@@ -247,6 +255,11 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
247
255
  }
248
256
 
249
257
  const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
258
+ // The declared-only tree for whatever THIS relation's own list computes,
259
+ // e.g. a field on the related list that declares its own `needs`. Falls
260
+ // back to an empty tree when this relation isn't declaration-related at
261
+ // all — the common case.
262
+ const nestedDeclaredOnly = declaredOnly.nested[fieldName] ?? emptyDeclaredOnlyTree()
250
263
 
251
264
  if (relatedConfig) {
252
265
  // For many relationships (arrays) - recursively filter fields in each item
@@ -261,6 +274,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
261
274
  config,
262
275
  depth + 1,
263
276
  relatedConfig.listName,
277
+ nestedDeclaredOnly,
264
278
  ),
265
279
  ),
266
280
  )
@@ -275,6 +289,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
275
289
  config,
276
290
  depth + 1,
277
291
  relatedConfig.listName,
292
+ nestedDeclaredOnly,
278
293
  )
279
294
  }
280
295
  } else {
@@ -341,5 +356,14 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
341
356
  }
342
357
  }
343
358
 
359
+ // Strip relations that were fetched ONLY to satisfy a `needs` declaration
360
+ // (ADR-0025), now that every resolveOutput hook at this level — including
361
+ // virtual fields, which read the assembled `filtered` object above — has
362
+ // had the chance to see them. A declared dependency is private plumbing,
363
+ // not an implicit `include`: it never widens what the caller receives.
364
+ for (const key of declaredOnly.keys) {
365
+ delete filtered[key]
366
+ }
367
+
344
368
  return filtered as Partial<T>
345
369
  }
@@ -31,6 +31,14 @@ export {
31
31
  export type { AccessIncludeResult } from './access-filter.js'
32
32
  // Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
33
33
  export { filterReadableFields } from './field-visibility.js'
34
+ // Declared Dependencies — folding `needs` into an include without widening
35
+ // the result (ADR-0025).
36
+ export {
37
+ foldDeclaredDependencies,
38
+ getDeclaredRelationNames,
39
+ emptyDeclaredOnlyTree,
40
+ } from './declared-dependencies.js'
41
+ export type { DeclaredOnlyTree } from './declared-dependencies.js'
34
42
  // Thrown when a caller include reaches past the depth the Access Filter can scope.
35
43
  export { AccessScopeDepthExceededError } from './errors.js'
36
44
  // Thrown when a resolveOutput hook's own resolve chain cycles back into itself.
@@ -146,6 +146,8 @@ export type {
146
146
  FieldHooks,
147
147
  FieldsWithTypeInfo,
148
148
  DatabaseConfig,
149
+ ListIndex,
150
+ ListIndexFieldRef,
149
151
  SessionConfig,
150
152
  UIConfig,
151
153
  ListUIConfig,
@@ -640,7 +640,8 @@ export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
640
640
  * @param keystoneCompat - Whether Keystone-compat mode is enabled (db.keystoneCompat).
641
641
  * When true, non-null text columns without an explicit defaultValue emit
642
642
  * `@default("")` to match Keystone 6's implicit empty-string text default.
643
- * @returns Prisma type string, optional modifiers, and optional enum values
643
+ * @returns Prisma type string, optional modifiers, optional enum values, and
644
+ * an optional block-level index request
644
645
  */
645
646
  getPrismaType?: (
646
647
  fieldName: string,
@@ -655,6 +656,26 @@ export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
655
656
  * The enum name is the value of `type`.
656
657
  */
657
658
  enumValues?: string[]
659
+ /**
660
+ * If set, this field requires a block-level index on the owning model:
661
+ * `@@index([fieldName])` for `true`, `@@unique([fieldName])` for
662
+ * `'unique'`. `false` and `undefined` both mean "no index".
663
+ *
664
+ * Prisma has no field-level `@index` attribute — a non-unique index can
665
+ * ONLY be expressed as the model-level `@@index([...])` — so a field that
666
+ * wants one has to ask for it out-of-line rather than appending to
667
+ * {@link modifiers}. (A unique index has both forms available; the
668
+ * built-in scalars keep emitting the inline `@unique` modifier for that
669
+ * case, so this channel carries only what cannot be written inline.)
670
+ *
671
+ * Same shape as {@link PrismaRelationResult.foreignKeyIndex}, which is how
672
+ * relationship fields have always emitted their foreign-key indexes. The
673
+ * generator handles both through one emit pass, so the field stays the
674
+ * authority on whether it can be indexed by name at all — a multi-column
675
+ * field (see {@link getPrismaColumns}) has no single column matching its
676
+ * field name and can decline, or name a real column of its own.
677
+ */
678
+ index?: boolean | 'unique'
658
679
  }
659
680
  /**
660
681
  * Get TypeScript type information for type generation
@@ -755,6 +776,49 @@ export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
755
776
  * @param value - The resolved logical value (metadata, or `null` to clear)
756
777
  */
757
778
  splitColumns?: (fieldName: string, value: unknown) => Record<string, unknown>
779
+ /**
780
+ * Declares the immediate sibling relations this field's `resolveOutput`
781
+ * hook cannot compute without (ADR-0025 — the "Declared dependency" glossary
782
+ * entry in `CONTEXT.md`). The read fetches each declared relation wherever
783
+ * this field is computed — at the root of a read and at every nested level
784
+ * alike — and scopes it through the Access Filter exactly like a
785
+ * caller-named relation: a dependency a session cannot query is not
786
+ * fetched, and the hook sees nothing in its place.
787
+ *
788
+ * A declared dependency is private plumbing, not an implicit `include`: it
789
+ * is stripped from the result unless the caller named it too, so declaring
790
+ * or removing one changes this field's implementation, never the shape of
791
+ * every read of the list.
792
+ *
793
+ * Names immediate relations only — no dotted paths. Reach beyond one hop
794
+ * comes from the recursive fold: a dependency's own list declares its own
795
+ * dependencies.
796
+ *
797
+ * Typed as a plain `string[]`, not narrowed to this list's own relation
798
+ * keys: `BaseFieldConfig` is the contextual type EVERY field builder's
799
+ * return type is checked against, including non-generic third-party ones
800
+ * (`richText(): RichTextField`, with no `TTypeInfo` parameter of its own —
801
+ * the documented third-party field pattern). Narrowing `needs` per-list
802
+ * would make `needs`'s type on a fixed, unparameterized third-party field
803
+ * config disagree with the narrower type this list's own slot expects,
804
+ * breaking assignability for every such field regardless of whether it
805
+ * uses `needs` at all. A misspelled or non-relation entry is instead
806
+ * caught by `pnpm generate` (`validateNeedsDeclarations`), which has no
807
+ * such constraint.
808
+ *
809
+ * @example
810
+ * ```typescript
811
+ * lineItems: relationship({ ref: 'LineItem.order', many: true }),
812
+ * total: virtual({
813
+ * type: 'number',
814
+ * needs: ['lineItems'],
815
+ * hooks: {
816
+ * resolveOutput: ({ item }) => item.lineItems.reduce((sum, li) => sum + li.price, 0),
817
+ * },
818
+ * }),
819
+ * ```
820
+ */
821
+ needs?: string[]
758
822
  }
759
823
 
760
824
  /**
@@ -1045,6 +1109,28 @@ export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
1045
1109
  */
1046
1110
  isIndexed?: boolean | 'unique'
1047
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
1048
1134
  /**
1049
1135
  * Controls foreign key placement and column name for bidirectional relationships
1050
1136
  * Can be a boolean or an object with a map property
@@ -1182,6 +1268,16 @@ export type PrismaRelationResult = {
1182
1268
  * for the many side or the non-FK side it is `[relationLine]`.
1183
1269
  */
1184
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
1185
1281
  /**
1186
1282
  * Foreign key index to add to the owning model, if this side owns an
1187
1283
  * indexed foreign key.
@@ -1738,6 +1834,43 @@ export type Hooks<
1738
1834
  validateInput?: (args: ValidateHookArgs<TOutput, TCreateInput, TUpdateInput>) => Promise<void>
1739
1835
  }
1740
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
+
1741
1874
  // Generic `any` default allows ListConfig to work with any list item type
1742
1875
  // This is needed because the item type varies per list and is inferred from Prisma models
1743
1876
  /**
@@ -1815,6 +1948,67 @@ export type ListConfig<TTypeInfo extends TypeInfo> = {
1815
1948
  * ```
1816
1949
  */
1817
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[]
1818
2012
  }
1819
2013
  /**
1820
2014
  * MCP server configuration for this list