@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
@@ -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: () => {
@@ -1384,10 +1387,16 @@ function getPrismaRelation(
1384
1387
  ? ` @map("${field.db.foreignKey.map}")`
1385
1388
  : ` @map("${fieldName}")`
1386
1389
 
1387
- let fkLine = ` ${fkPaddedName} String?${uniqueModifier}${mapModifier}`
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}`
1388
1397
  let relationLine = targetField
1389
- ? ` ${paddedName} ${targetList}? @relation(fields: [${foreignKeyField}], references: [id])`
1390
- : ` ${paddedName} ${targetList}? @relation("${listKey}_${fieldName}", fields: [${foreignKeyField}], references: [id])`
1398
+ ? ` ${paddedName} ${targetList}${nullModifier} @relation(fields: [${foreignKeyField}], references: [id])`
1399
+ : ` ${paddedName} ${targetList}${nullModifier} @relation("${listKey}_${fieldName}", fields: [${foreignKeyField}], references: [id])`
1391
1400
 
1392
1401
  if (field.db?.extendPrismaSchema) {
1393
1402
  const extended = field.db.extendPrismaSchema({ fkLine, relationLine })
@@ -1399,10 +1408,24 @@ function getPrismaRelation(
1399
1408
  const indexType = field.isIndexed ?? true
1400
1409
  const foreignKeyIndex = indexType !== false ? { foreignKeyField, indexType } : undefined
1401
1410
 
1402
- 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
+ )
1403
1427
  }
1404
1428
 
1405
- // Non-FK side of a one-to-one relationship: just the relation field
1406
1429
  let relationLine = ` ${paddedName} ${targetList}?`
1407
1430
  if (field.db?.extendPrismaSchema) {
1408
1431
  relationLine = field.db.extendPrismaSchema({ relationLine }).relationLine
@@ -1448,6 +1471,16 @@ export function relationship<
1448
1471
  }
1449
1472
  }
1450
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
+
1451
1484
  const field: RelationshipField<TTypeInfo> = {
1452
1485
  type: 'relationship',
1453
1486
  ...options,
package/src/index.ts CHANGED
@@ -19,6 +19,8 @@ export type {
19
19
  OutputConfig,
20
20
  ListConfig,
21
21
  DatabaseConfig,
22
+ ListIndex,
23
+ ListIndexFieldRef,
22
24
  FieldConfig,
23
25
  OperationAccess,
24
26
  // Custom Bulk actions (issue #736) — declared per list in
@@ -80,6 +82,14 @@ export { ResolveOutputCycleError } from './access/index.js'
80
82
  export { validateFieldConfig, validateConfigFields } from './validation/field-config.js'
81
83
  export type { FieldConfigValidationError } from './validation/field-config.js'
82
84
 
85
+ // Declared-dependency validation (`needs`, ADR-0025) — checks every `needs`
86
+ // entry names an immediate relationship field on the same list, and that no
87
+ // field's declaration closure (the recursive fold of its dependencies, and
88
+ // theirs) exceeds the read-include depth cap from any starting point. A
89
+ // config that fails either must not generate.
90
+ export { validateNeedsDeclarations, validateNeedsClosureDepth } from './validation/needs-closure.js'
91
+ export type { NeedsClosureError } from './validation/needs-closure.js'
92
+
83
93
  // Fragment-based query API — composable, type-safe reads that mirror
84
94
  // Keystone's GraphQL fragments without a GraphQL runtime. The migration
85
95
  // 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', () => {
@@ -757,6 +761,18 @@ describe('Field Types', () => {
757
761
  expect(field.ref).toBe('Post.author')
758
762
  expect(field.many).toBe(true)
759
763
  })
764
+
765
+ test('accepts db.isNullable on a single relationship', () => {
766
+ const field = relationship({ ref: 'User.posts', db: { isNullable: false } })
767
+
768
+ expect(field.db?.isNullable).toBe(false)
769
+ })
770
+
771
+ test('throws error when db.isNullable is used with many: true', () => {
772
+ expect(() => {
773
+ relationship({ ref: 'Post.author', many: true, db: { isNullable: false } })
774
+ }).toThrow('db.isNullable can only be used on single relationships')
775
+ })
760
776
  })
761
777
  })
762
778