@opensaas/stack-core 0.34.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 (47) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +82 -0
  3. package/CLAUDE.md +48 -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 +65 -72
  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 +117 -90
  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/bare-read-scalars.test.ts +147 -0
  42. package/tests/field-types.test.ts +6 -2
  43. package/tests/needs-declared-dependencies.test.ts +500 -0
  44. package/tests/nested-access-and-hooks.test.ts +10 -0
  45. package/tests/resolve-chain.test.ts +40 -23
  46. package/tests/singleton.test.ts +83 -0
  47. 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,12 +7,14 @@ import {
7
7
  buildIncludeWithAccessControl,
8
8
  mergeIncludeWithAccessControl,
9
9
  stripVirtualFieldsFromInclude,
10
- toPrismaInclude,
10
+ foldDeclaredDependencies,
11
11
  } from '../access/index.js'
12
+ import type { DeclaredOnlyTree } from '../access/index.js'
12
13
  import { ValidationError, DatabaseError } from '../hooks/index.js'
13
14
  import { getDbKey } from '../lib/case-utils.js'
14
15
  import type { PrismaClientLike } from '../access/types.js'
15
16
  import { buildInclude, pickFields, isFragment } from '../query/index.js'
17
+ import type { FieldSelection } from '../query/index.js'
16
18
  import { getRelationshipOptions } from '../query/relationship-options.js'
17
19
  import {
18
20
  runWritePipeline,
@@ -911,6 +913,62 @@ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
911
913
  return db as AccessControlledDB<TPrisma>
912
914
  }
913
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
+
914
972
  /**
915
973
  * Create findUnique operation with access control
916
974
  */
@@ -970,45 +1028,18 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
970
1028
  // instead of the access-controlled include. Access control still runs via
971
1029
  // filterReadableFields; the fragment then narrows to only the requested fields.
972
1030
  const fragment = isFragment(args.query) ? args.query : null
973
- let include: Record<string, unknown> | undefined
974
1031
 
975
- if (fragment) {
976
- include = buildInclude(fragment._fields) ?? undefined
977
- } else if (context._isSudo) {
978
- // Sudo bypasses access control entirely the caller's include is trusted
979
- // and used as-is (matching the prior behaviour); no per-relation filtering.
980
- include = args.include
981
- } else {
982
- // Build include with access control filters
983
- const accessControlledInclude = await buildIncludeWithAccessControl(
984
- listConfig.fields,
985
- {
986
- session: context.session,
987
- context,
988
- },
989
- config,
990
- 0,
991
- // Seed the cycle guard with the root list so a relationship cycle back
992
- // to it (self-referential or longer) stops re-descending.
993
- [listName],
994
- )
995
- // MERGE (not replace) a caller-supplied include with the access-controlled
996
- // include: the caller selects WHICH relations to fetch, access control
997
- // decides WHETHER and WITH WHAT filter (#566). A bare auto-include (no
998
- // caller include) still uses the access-controlled include directly. A
999
- // caller include naming a relation past the depth the engine can scope
1000
- // throws `AccessScopeDepthExceededError` (issue #830) rather than being
1001
- // returned unscoped.
1002
- include = args.include
1003
- ? mergeIncludeWithAccessControl(
1004
- args.include,
1005
- accessControlledInclude,
1006
- listConfig.fields,
1007
- config,
1008
- listName,
1009
- )
1010
- : toPrismaInclude(accessControlledInclude)
1011
- }
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
+ )
1012
1043
 
1013
1044
  // Virtual fields have no database column. Whichever path produced
1014
1045
  // `include` (fragment, access-controlled merge, or sudo passthrough), a
@@ -1042,6 +1073,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
1042
1073
  config,
1043
1074
  0,
1044
1075
  listName,
1076
+ declaredOnly,
1045
1077
  )
1046
1078
 
1047
1079
  // When a fragment is provided, pick only the requested fields from the result
@@ -1109,44 +1141,18 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1109
1141
 
1110
1142
  // When a query fragment is provided, build include from fragment fields
1111
1143
  const fragment = isFragment(args?.query) ? args.query : null
1112
- let include: Record<string, unknown> | undefined
1113
- if (fragment) {
1114
- include = buildInclude(fragment._fields) ?? undefined
1115
- } else if (context._isSudo) {
1116
- // Sudo bypasses access control entirely the caller's include is trusted
1117
- // and used as-is (matching the prior behaviour); no per-relation filtering.
1118
- include = args?.include
1119
- } else {
1120
- // Build include with access control filters
1121
- const accessControlledInclude = await buildIncludeWithAccessControl(
1122
- listConfig.fields,
1123
- {
1124
- session: context.session,
1125
- context,
1126
- },
1127
- config,
1128
- 0,
1129
- // Seed the cycle guard with the root list so a relationship cycle back
1130
- // to it (self-referential or longer) stops re-descending.
1131
- [listName],
1132
- )
1133
- // MERGE (not replace) a caller-supplied include with the access-controlled
1134
- // include: the caller selects WHICH relations to fetch, access control
1135
- // decides WHETHER and WITH WHAT filter (#566). A bare auto-include (no
1136
- // caller include) still uses the access-controlled include directly. A
1137
- // caller include naming a relation past the depth the engine can scope
1138
- // throws `AccessScopeDepthExceededError` (issue #830) rather than being
1139
- // returned unscoped.
1140
- include = args?.include
1141
- ? mergeIncludeWithAccessControl(
1142
- args.include,
1143
- accessControlledInclude,
1144
- listConfig.fields,
1145
- config,
1146
- listName,
1147
- )
1148
- : toPrismaInclude(accessControlledInclude)
1149
- }
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
+ )
1150
1156
 
1151
1157
  // Virtual fields have no database column. Whichever path produced
1152
1158
  // `include` (fragment, access-controlled merge, or sudo passthrough), a
@@ -1181,6 +1187,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1181
1187
  config,
1182
1188
  0,
1183
1189
  listName,
1190
+ declaredOnly,
1184
1191
  ),
1185
1192
  ),
1186
1193
  )
@@ -1412,7 +1419,16 @@ function createGet<TPrisma extends PrismaClientLike>(
1412
1419
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1413
1420
  createFn: any,
1414
1421
  ) {
1415
- return async () => {
1422
+ return async (args?: {
1423
+ include?: Record<string, unknown>
1424
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1425
+ query?: any
1426
+ // `select` is not honoured — accepted only so the no-op can be made visible.
1427
+ select?: Record<string, unknown>
1428
+ }) => {
1429
+ // `select` is a visible no-op: warn, then proceed with include/query narrowing.
1430
+ warnIfSelectIgnored(args, listName, 'get')
1431
+
1416
1432
  // First try to find the existing record
1417
1433
  // Access Prisma model dynamically - required because model names are generated at runtime
1418
1434
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1437,24 +1453,30 @@ function createGet<TPrisma extends PrismaClientLike>(
1437
1453
  }
1438
1454
  }
1439
1455
 
1440
- // Build include with access control filters
1441
- const accessControlledInclude = await buildIncludeWithAccessControl(
1442
- listConfig.fields,
1443
- {
1444
- session: context.session,
1445
- context,
1446
- },
1456
+ // When a query fragment is provided, build the include from the fragment
1457
+ // instead of the access-controlled include. Access control still runs via
1458
+ // filterReadableFields; the fragment then narrows to only the requested fields.
1459
+ const fragment = isFragment(args?.query) ? args.query : null
1460
+
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,
1447
1470
  config,
1448
- 0,
1449
- // Seed the cycle guard with the root list so a relationship cycle back
1450
- // to it (self-referential or longer) stops re-descending.
1451
- [listName],
1452
1471
  )
1453
1472
 
1473
+ // Virtual fields have no database column and must never reach Prisma (#628).
1474
+ include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
1475
+
1454
1476
  // Try to find the record
1455
1477
  const item = await model.findFirst({
1456
1478
  where,
1457
- include: toPrismaInclude(accessControlledInclude),
1479
+ include,
1458
1480
  })
1459
1481
 
1460
1482
  // If record exists, return it
@@ -1470,7 +1492,12 @@ function createGet<TPrisma extends PrismaClientLike>(
1470
1492
  config,
1471
1493
  0,
1472
1494
  listName,
1495
+ declaredOnly,
1473
1496
  )
1497
+ // When a fragment is provided, pick only the requested fields from the result
1498
+ if (fragment) {
1499
+ return pickFields(filtered, fragment._fields)
1500
+ }
1474
1501
  return filtered
1475
1502
  }
1476
1503
 
@@ -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
+ }