@opensaas/stack-core 0.35.0 → 0.36.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 (43) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +35 -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/types.d.ts +65 -1
  17. package/dist/config/types.d.ts.map +1 -1
  18. package/dist/context/index.d.ts.map +1 -1
  19. package/dist/context/index.js +51 -95
  20. package/dist/context/index.js.map +1 -1
  21. package/dist/fields/index.d.ts.map +1 -1
  22. package/dist/fields/index.js +12 -12
  23. package/dist/fields/index.js.map +1 -1
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +6 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/validation/needs-closure.d.ts +49 -0
  29. package/dist/validation/needs-closure.d.ts.map +1 -0
  30. package/dist/validation/needs-closure.js +139 -0
  31. package/dist/validation/needs-closure.js.map +1 -0
  32. package/package.json +1 -1
  33. package/src/access/declared-dependencies.ts +140 -0
  34. package/src/access/field-visibility.ts +24 -0
  35. package/src/access/index.ts +8 -0
  36. package/src/config/types.ts +65 -1
  37. package/src/context/index.ts +96 -115
  38. package/src/fields/index.ts +12 -9
  39. package/src/index.ts +8 -0
  40. package/src/validation/needs-closure.ts +188 -0
  41. package/tests/field-types.test.ts +6 -2
  42. package/tests/needs-declared-dependencies.test.ts +500 -0
  43. package/tsconfig.tsbuildinfo +1 -1
@@ -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.
@@ -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
  /**
@@ -7,11 +7,14 @@ import {
7
7
  buildIncludeWithAccessControl,
8
8
  mergeIncludeWithAccessControl,
9
9
  stripVirtualFieldsFromInclude,
10
+ foldDeclaredDependencies,
10
11
  } from '../access/index.js'
12
+ import type { DeclaredOnlyTree } from '../access/index.js'
11
13
  import { ValidationError, DatabaseError } from '../hooks/index.js'
12
14
  import { getDbKey } from '../lib/case-utils.js'
13
15
  import type { PrismaClientLike } from '../access/types.js'
14
16
  import { buildInclude, pickFields, isFragment } from '../query/index.js'
17
+ import type { FieldSelection } from '../query/index.js'
15
18
  import { getRelationshipOptions } from '../query/relationship-options.js'
16
19
  import {
17
20
  runWritePipeline,
@@ -910,6 +913,62 @@ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
910
913
  return db as AccessControlledDB<TPrisma>
911
914
  }
912
915
 
916
+ /**
917
+ * Resolve the `include` (and declared-dependency provenance) a read should
918
+ * use, preserving each existing path's exact shape — fragment / sudo /
919
+ * caller include / bare (ADR-0024) — while folding declared dependencies
920
+ * (`needs`, ADR-0025) into whichever of those the read is already using.
921
+ *
922
+ * A fragment's own `include` and a sudo caller's `include` are folded and
923
+ * used as-is, matching their existing (unmerged) treatment. A non-sudo
924
+ * caller include is folded and then merged through the same
925
+ * access-scoping pipeline as before. A bare read stays on the exact
926
+ * ADR-0024 path — `include: undefined`, no related `query` access
927
+ * evaluated — unless folding actually added something, which only happens
928
+ * when a field on this list declares `needs`.
929
+ */
930
+ async function resolveReadInclude(
931
+ callerInclude: Record<string, unknown> | undefined,
932
+ fragmentFields: FieldSelection<unknown> | undefined,
933
+ listName: string,
934
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
935
+ listConfig: ListConfig<any>,
936
+ context: AccessContext & { _isSudo?: boolean },
937
+ config: OpenSaasConfig,
938
+ ): Promise<{ include: Record<string, unknown> | undefined; declaredOnly: DeclaredOnlyTree }> {
939
+ if (fragmentFields !== undefined) {
940
+ const fragmentInclude = buildInclude(fragmentFields) ?? undefined
941
+ return foldDeclaredDependencies(fragmentInclude, listConfig.fields, config)
942
+ }
943
+
944
+ if (context._isSudo) {
945
+ return foldDeclaredDependencies(callerInclude, listConfig.fields, config)
946
+ }
947
+
948
+ const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config)
949
+ if (!folded.include) {
950
+ return folded
951
+ }
952
+
953
+ const accessControlledInclude = await buildIncludeWithAccessControl(
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(
963
+ folded.include,
964
+ accessControlledInclude,
965
+ listConfig.fields,
966
+ config,
967
+ listName,
968
+ )
969
+ return { include, declaredOnly: folded.declaredOnly }
970
+ }
971
+
913
972
  /**
914
973
  * Create findUnique operation with access control
915
974
  */
@@ -969,47 +1028,18 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
969
1028
  // instead of the access-controlled include. Access control still runs via
970
1029
  // filterReadableFields; the fragment then narrows to only the requested fields.
971
1030
  const fragment = isFragment(args.query) ? args.query : null
972
- let include: Record<string, unknown> | undefined
973
1031
 
974
- if (fragment) {
975
- include = buildInclude(fragment._fields) ?? undefined
976
- } else if (context._isSudo) {
977
- // Sudo bypasses access control entirely the caller's include is trusted
978
- // and used as-is (matching the prior behaviour); no per-relation filtering.
979
- include = args.include
980
- } else if (args.include) {
981
- // Caller named relations to fetch — build the access-controlled include
982
- // and MERGE (not replace) it with the caller's: the caller selects WHICH
983
- // relations to fetch, access control decides WHETHER and WITH WHAT
984
- // filter (#566). A caller include naming a relation past the depth the
985
- // engine can scope throws `AccessScopeDepthExceededError` (issue #830)
986
- // rather than being returned unscoped.
987
- const accessControlledInclude = await buildIncludeWithAccessControl(
988
- listConfig.fields,
989
- {
990
- session: context.session,
991
- context,
992
- },
993
- config,
994
- 0,
995
- // Seed the cycle guard with the root list so a relationship cycle back
996
- // to it (self-referential or longer) stops re-descending.
997
- [listName],
998
- )
999
- include = mergeIncludeWithAccessControl(
1000
- args.include,
1001
- accessControlledInclude,
1002
- listConfig.fields,
1003
- config,
1004
- listName,
1005
- )
1006
- } else {
1007
- // A bare read (no caller `include`) fetches the row's own columns only,
1008
- // matching Prisma's semantics for the same call (ADR-0024). Relations
1009
- // are fetched only when a caller names them. This also means no related
1010
- // list's operation-level `query` access is evaluated on a bare read.
1011
- include = undefined
1012
- }
1032
+ // Resolve `include`, folding any declared dependencies (`needs`,
1033
+ // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1034
+ // already produces — see `resolveReadInclude`'s doc comment.
1035
+ let { include, declaredOnly } = await resolveReadInclude(
1036
+ args.include,
1037
+ fragment ? fragment._fields : undefined,
1038
+ listName,
1039
+ listConfig,
1040
+ context,
1041
+ config,
1042
+ )
1013
1043
 
1014
1044
  // Virtual fields have no database column. Whichever path produced
1015
1045
  // `include` (fragment, access-controlled merge, or sudo passthrough), a
@@ -1043,6 +1073,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1043
1073
  config,
1044
1074
  0,
1045
1075
  listName,
1076
+ declaredOnly,
1046
1077
  )
1047
1078
 
1048
1079
  // When a fragment is provided, pick only the requested fields from the result
@@ -1110,46 +1141,18 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1110
1141
 
1111
1142
  // When a query fragment is provided, build include from fragment fields
1112
1143
  const fragment = isFragment(args?.query) ? args.query : null
1113
- let include: Record<string, unknown> | undefined
1114
- if (fragment) {
1115
- include = buildInclude(fragment._fields) ?? undefined
1116
- } else if (context._isSudo) {
1117
- // Sudo bypasses access control entirely the caller's include is trusted
1118
- // and used as-is (matching the prior behaviour); no per-relation filtering.
1119
- include = args?.include
1120
- } else if (args?.include) {
1121
- // Caller named relations to fetch — build the access-controlled include
1122
- // and MERGE (not replace) it with the caller's: the caller selects WHICH
1123
- // relations to fetch, access control decides WHETHER and WITH WHAT
1124
- // filter (#566). A caller include naming a relation past the depth the
1125
- // engine can scope throws `AccessScopeDepthExceededError` (issue #830)
1126
- // rather than being returned unscoped.
1127
- const accessControlledInclude = await buildIncludeWithAccessControl(
1128
- listConfig.fields,
1129
- {
1130
- session: context.session,
1131
- context,
1132
- },
1133
- config,
1134
- 0,
1135
- // Seed the cycle guard with the root list so a relationship cycle back
1136
- // to it (self-referential or longer) stops re-descending.
1137
- [listName],
1138
- )
1139
- include = mergeIncludeWithAccessControl(
1140
- args.include,
1141
- accessControlledInclude,
1142
- listConfig.fields,
1143
- config,
1144
- listName,
1145
- )
1146
- } else {
1147
- // A bare read (no caller `include`) fetches each row's own columns only,
1148
- // matching Prisma's semantics for the same call (ADR-0024). Relations
1149
- // are fetched only when a caller names them. This also means no related
1150
- // list's operation-level `query` access is evaluated on a bare read.
1151
- include = undefined
1152
- }
1144
+
1145
+ // Resolve `include`, folding any declared dependencies (`needs`,
1146
+ // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1147
+ // already produces — see `resolveReadInclude`'s doc comment.
1148
+ let { include, declaredOnly } = await resolveReadInclude(
1149
+ args?.include,
1150
+ fragment ? fragment._fields : undefined,
1151
+ listName,
1152
+ listConfig,
1153
+ context,
1154
+ config,
1155
+ )
1153
1156
 
1154
1157
  // Virtual fields have no database column. Whichever path produced
1155
1158
  // `include` (fragment, access-controlled merge, or sudo passthrough), a
@@ -1184,6 +1187,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1184
1187
  config,
1185
1188
  0,
1186
1189
  listName,
1190
+ declaredOnly,
1187
1191
  ),
1188
1192
  ),
1189
1193
  )
@@ -1453,42 +1457,18 @@ function createGet<TPrisma extends PrismaClientLike>(
1453
1457
  // instead of the access-controlled include. Access control still runs via
1454
1458
  // filterReadableFields; the fragment then narrows to only the requested fields.
1455
1459
  const fragment = isFragment(args?.query) ? args.query : null
1456
- let include: Record<string, unknown> | undefined
1457
1460
 
1458
- if (fragment) {
1459
- include = buildInclude(fragment._fields) ?? undefined
1460
- } else if (context._isSudo) {
1461
- // Sudo bypasses access control entirely the caller's include is trusted
1462
- // and used as-is; no per-relation filtering.
1463
- include = args?.include
1464
- } else if (args?.include) {
1465
- // Caller named relations to fetch — build the access-controlled include
1466
- // and MERGE (not replace) it with the caller's, exactly like the other
1467
- // read ops (#566/#830).
1468
- const accessControlledInclude = await buildIncludeWithAccessControl(
1469
- listConfig.fields,
1470
- {
1471
- session: context.session,
1472
- context,
1473
- },
1474
- config,
1475
- 0,
1476
- // Seed the cycle guard with the root list so a relationship cycle back
1477
- // to it (self-referential or longer) stops re-descending.
1478
- [listName],
1479
- )
1480
- include = mergeIncludeWithAccessControl(
1481
- args.include,
1482
- accessControlledInclude,
1483
- listConfig.fields,
1484
- config,
1485
- listName,
1486
- )
1487
- } else {
1488
- // A bare read (no caller `include`) fetches the row's own columns only,
1489
- // matching Prisma's semantics for the same call (ADR-0024).
1490
- include = undefined
1491
- }
1461
+ // Resolve `include`, folding any declared dependencies (`needs`,
1462
+ // ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
1463
+ // already produces — see `resolveReadInclude`'s doc comment.
1464
+ let { include, declaredOnly } = await resolveReadInclude(
1465
+ args?.include,
1466
+ fragment ? fragment._fields : undefined,
1467
+ listName,
1468
+ listConfig,
1469
+ context,
1470
+ config,
1471
+ )
1492
1472
 
1493
1473
  // Virtual fields have no database column and must never reach Prisma (#628).
1494
1474
  include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
@@ -1512,6 +1492,7 @@ function createGet<TPrisma extends PrismaClientLike>(
1512
1492
  config,
1513
1493
  0,
1514
1494
  listName,
1495
+ declaredOnly,
1515
1496
  )
1516
1497
  // When a fragment is provided, pick only the requested fields from the result
1517
1498
  if (fragment) {
@@ -144,11 +144,11 @@ export function text<
144
144
  modifiers += ` @default(${defaultLiteral})`
145
145
  }
146
146
 
147
- // Unique/index modifiers
147
+ // Unique modifier. A non-unique index has no field-level form in Prisma,
148
+ // so it is requested out-of-line via `index` below and emitted by the
149
+ // generator as `@@index([...])` on the model.
148
150
  if (options?.isIndexed === 'unique') {
149
151
  modifiers += ' @unique'
150
- } else if (options?.isIndexed === true) {
151
- modifiers += ' @index'
152
152
  }
153
153
 
154
154
  // Map modifier
@@ -159,6 +159,7 @@ export function text<
159
159
  return {
160
160
  type: 'String',
161
161
  modifiers: modifiers.trimStart() || undefined,
162
+ index: options?.isIndexed === true ? true : undefined,
162
163
  }
163
164
  },
164
165
  getTypeScriptType: () => {
@@ -402,16 +403,17 @@ export function decimal<
402
403
  modifiers += ` @map("${db.map}")`
403
404
  }
404
405
 
405
- // Unique/index modifiers
406
+ // Unique modifier. A non-unique index has no field-level form in Prisma,
407
+ // so it is requested out-of-line via `index` below and emitted by the
408
+ // generator as `@@index([...])` on the model.
406
409
  if (options?.isIndexed === 'unique') {
407
410
  modifiers += ' @unique'
408
- } else if (options?.isIndexed === true) {
409
- modifiers += ' @index'
410
411
  }
411
412
 
412
413
  return {
413
414
  type: 'Decimal',
414
415
  modifiers: modifiers.trimStart() || undefined,
416
+ index: options?.isIndexed === true ? true : undefined,
415
417
  }
416
418
  },
417
419
  getTypeScriptType: () => {
@@ -747,16 +749,17 @@ export function calendarDay<
747
749
  modifiers += ` @map("${db.map}")`
748
750
  }
749
751
 
750
- // Unique/index modifiers
752
+ // Unique modifier. A non-unique index has no field-level form in Prisma,
753
+ // so it is requested out-of-line via `index` below and emitted by the
754
+ // generator as `@@index([...])` on the model.
751
755
  if (options?.isIndexed === 'unique') {
752
756
  modifiers += ' @unique'
753
- } else if (options?.isIndexed === true) {
754
- modifiers += ' @index'
755
757
  }
756
758
 
757
759
  return {
758
760
  type: 'DateTime',
759
761
  modifiers: modifiers.trimStart() || undefined,
762
+ index: options?.isIndexed === true ? true : undefined,
760
763
  }
761
764
  },
762
765
  getTypeScriptType: () => {
package/src/index.ts CHANGED
@@ -80,6 +80,14 @@ export { ResolveOutputCycleError } from './access/index.js'
80
80
  export { validateFieldConfig, validateConfigFields } from './validation/field-config.js'
81
81
  export type { FieldConfigValidationError } from './validation/field-config.js'
82
82
 
83
+ // Declared-dependency validation (`needs`, ADR-0025) — checks every `needs`
84
+ // entry names an immediate relationship field on the same list, and that no
85
+ // field's declaration closure (the recursive fold of its dependencies, and
86
+ // theirs) exceeds the read-include depth cap from any starting point. A
87
+ // config that fails either must not generate.
88
+ export { validateNeedsDeclarations, validateNeedsClosureDepth } from './validation/needs-closure.js'
89
+ export type { NeedsClosureError } from './validation/needs-closure.js'
90
+
83
91
  // Fragment-based query API — composable, type-safe reads that mirror
84
92
  // Keystone's GraphQL fragments without a GraphQL runtime. The migration
85
93
  // guide, CHANGELOG, and migrate-context-calls skill all advertise importing
@@ -0,0 +1,188 @@
1
+ import type { FieldConfig, OpenSaasConfig } from '../config/types.js'
2
+ import { getRelatedListConfig } from '../access/engine.js'
3
+ import { READ_INCLUDE_MAX_DEPTH } from '../access/depth-limits.js'
4
+
5
+ /**
6
+ * A field's `needs` declaration (ADR-0025) whose closure — the recursive
7
+ * fold of its own dependencies, and THEIR dependencies, and so on — cannot
8
+ * be satisfied by the read pipeline, independent of where any caller starts
9
+ * a read.
10
+ *
11
+ * Two distinct refusals, both fail-closed rather than silently truncated
12
+ * (ADR-0022): `'cycle'` means the chain never terminates (e.g. `Order.total`
13
+ * needs `lineItems`, `LineItem.orderRef` needs `order`); `'depth'` means it
14
+ * terminates but reaches deeper than `READ_INCLUDE_MAX_DEPTH` even when
15
+ * evaluated starting AT the declaring field's own list — the most
16
+ * favourable starting point available, so no caller could ever do better.
17
+ */
18
+ export interface NeedsClosureError {
19
+ /** The list whose field declares the (transitively) unsatisfiable `needs`. */
20
+ listKey: string
21
+ /** The field key within that list. */
22
+ fieldKey: string
23
+ /** The list keys on the offending chain, starting at `listKey`. */
24
+ chain: string[]
25
+ reason: 'cycle' | 'depth' | 'invalid-relation'
26
+ message: string
27
+ }
28
+
29
+ function isRelationshipFieldConfig(
30
+ fieldConfig: FieldConfig | undefined,
31
+ ): fieldConfig is FieldConfig & { type: 'relationship'; ref: string } {
32
+ return (
33
+ !!fieldConfig &&
34
+ fieldConfig.type === 'relationship' &&
35
+ 'ref' in fieldConfig &&
36
+ !!fieldConfig.ref
37
+ )
38
+ }
39
+
40
+ type ListClosureResult = { depth: number; chain: string[] } | { cycle: string[] }
41
+
42
+ /**
43
+ * The deepest needs-chain reachable from `listKey`, considering EVERY field
44
+ * on every list along the way that declares `needs` — not only the field
45
+ * that triggered the walk. A computed field runs wherever its list's rows
46
+ * are fetched, so once a list is reached, ALL of its own declared
47
+ * dependencies must be satisfiable too (ADR-0025's "at every level a field
48
+ * is computed").
49
+ *
50
+ * `path` is the list keys already on this DFS branch, root-first, used to
51
+ * detect a cycle (a chain that can never terminate).
52
+ */
53
+ function listClosureDepth(
54
+ listKey: string,
55
+ config: OpenSaasConfig,
56
+ path: readonly string[],
57
+ ): ListClosureResult {
58
+ const listConfig = config.lists[listKey]
59
+ if (!listConfig?.fields) return { depth: 0, chain: [listKey] }
60
+
61
+ let maxDepth = 0
62
+ let maxChain = [listKey]
63
+
64
+ for (const fieldConfig of Object.values(listConfig.fields)) {
65
+ if (!fieldConfig?.hooks?.resolveOutput) continue
66
+
67
+ for (const relationName of fieldConfig.needs ?? []) {
68
+ const relatedField = listConfig.fields[relationName]
69
+ if (!isRelationshipFieldConfig(relatedField)) continue
70
+
71
+ const relatedConfig = getRelatedListConfig(relatedField.ref, config)
72
+ if (!relatedConfig) continue
73
+ const relatedListKey = relatedConfig.listName
74
+
75
+ if (path.includes(relatedListKey)) {
76
+ return { cycle: [...path, relatedListKey] }
77
+ }
78
+
79
+ const sub = listClosureDepth(relatedListKey, config, [...path, relatedListKey])
80
+ if ('cycle' in sub) return sub
81
+
82
+ if (1 + sub.depth > maxDepth) {
83
+ maxDepth = 1 + sub.depth
84
+ maxChain = [listKey, ...sub.chain]
85
+ }
86
+ }
87
+ }
88
+
89
+ return { depth: maxDepth, chain: maxChain }
90
+ }
91
+
92
+ /**
93
+ * Validate that every `needs` entry names an immediate relationship field
94
+ * declared on the SAME list. The generated `Lists.<List>.TypeInfo` already
95
+ * makes a misspelled or non-relation entry a compile error for a config
96
+ * annotated with it (`list<Lists.X.TypeInfo>({...})`, the documented
97
+ * pattern) — this is the runtime backstop for configs that aren't, or that
98
+ * are authored in plain JS.
99
+ *
100
+ * @param config - The fully resolved OpenSaas config.
101
+ * @returns All invalid `needs` entries, flattened across lists and fields.
102
+ */
103
+ export function validateNeedsDeclarations(config: OpenSaasConfig): NeedsClosureError[] {
104
+ const errors: NeedsClosureError[] = []
105
+
106
+ for (const [listKey, listConfig] of Object.entries(config.lists)) {
107
+ if (!listConfig?.fields) continue
108
+
109
+ for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
110
+ for (const relationName of fieldConfig?.needs ?? []) {
111
+ if (isRelationshipFieldConfig(listConfig.fields[relationName])) continue
112
+
113
+ const exists = relationName in listConfig.fields
114
+ errors.push({
115
+ listKey,
116
+ fieldKey,
117
+ chain: [listKey],
118
+ reason: 'invalid-relation',
119
+ message: exists
120
+ ? `"${listKey}.${fieldKey}" declares needs: ['${relationName}'], but "${relationName}" ` +
121
+ `is not a relationship field on "${listKey}". \`needs\` may only name immediate ` +
122
+ `relationship fields declared on the same list.`
123
+ : `"${listKey}.${fieldKey}" declares needs: ['${relationName}'], but "${listKey}" has ` +
124
+ `no field named "${relationName}".`,
125
+ })
126
+ }
127
+ }
128
+ }
129
+
130
+ return errors
131
+ }
132
+
133
+ /**
134
+ * Validate that every field's `needs` closure fits within
135
+ * `READ_INCLUDE_MAX_DEPTH` when evaluated starting at the declaring field's
136
+ * own list — the most favourable starting point a caller could ever give it.
137
+ * Intended to run once, before generation, exactly like
138
+ * {@link validateConfigFields} — a config whose closure cannot fit must not
139
+ * generate, per ADR-0025's "Depth" section.
140
+ *
141
+ * @param config - The fully resolved OpenSaas config.
142
+ * @returns All unsatisfiable-closure violations, flattened across lists and fields.
143
+ */
144
+ export function validateNeedsClosureDepth(config: OpenSaasConfig): NeedsClosureError[] {
145
+ const errors: NeedsClosureError[] = []
146
+
147
+ for (const [listKey, listConfig] of Object.entries(config.lists)) {
148
+ if (!listConfig?.fields) continue
149
+
150
+ for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
151
+ if (!fieldConfig?.hooks?.resolveOutput || !fieldConfig.needs?.length) continue
152
+
153
+ const result = listClosureDepth(listKey, config, [listKey])
154
+
155
+ if ('cycle' in result) {
156
+ errors.push({
157
+ listKey,
158
+ fieldKey,
159
+ chain: result.cycle,
160
+ reason: 'cycle',
161
+ message:
162
+ `"${listKey}.${fieldKey}"'s needs declaration never terminates: ` +
163
+ `${result.cycle.join(' → ')} → … . A chain of \`needs\` across these lists cycles back ` +
164
+ `on itself, so no read could ever satisfy it. Break the cycle by removing one of the ` +
165
+ `\`needs\` entries on this chain, or compute the value without it.`,
166
+ })
167
+ continue
168
+ }
169
+
170
+ if (result.depth >= READ_INCLUDE_MAX_DEPTH) {
171
+ errors.push({
172
+ listKey,
173
+ fieldKey,
174
+ chain: result.chain,
175
+ reason: 'depth',
176
+ message:
177
+ `"${listKey}.${fieldKey}"'s needs declaration requires a closure ${result.depth} ` +
178
+ `relations deep even starting at "${listKey}" itself: ${result.chain.join(' → ')}. ` +
179
+ `This exceeds the Access Filter's maximum read-include depth ` +
180
+ `(${READ_INCLUDE_MAX_DEPTH}), so no caller — however they start the read — could ever ` +
181
+ `have this closure satisfied. Shorten the \`needs\` chain across these lists.`,
182
+ })
183
+ }
184
+ }
185
+ }
186
+
187
+ return errors
188
+ }
@@ -103,12 +103,16 @@ describe('Field Types', () => {
103
103
  expect(prismaType.modifiers).toContain('@unique')
104
104
  })
105
105
 
106
- test('includes @index modifier', () => {
106
+ test('requests a block-level index rather than an inline modifier', () => {
107
107
  const field = text({ isIndexed: true })
108
108
  const prismaType = field.getPrismaType('slug')
109
109
 
110
110
  expect(prismaType.type).toBe('String')
111
- expect(prismaType.modifiers).toContain('@index')
111
+ // Prisma has no field-level `@index` attribute — emitting one produces a
112
+ // schema Prisma refuses to parse. A non-unique index is requested
113
+ // out-of-line and lands as `@@index([slug])` on the model.
114
+ expect(prismaType.index).toBe(true)
115
+ expect(prismaType.modifiers ?? '').not.toContain('@index')
112
116
  })
113
117
 
114
118
  test('db.isNullable: true makes optional field explicitly nullable', () => {