@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +82 -0
- package/CLAUDE.md +48 -0
- package/dist/access/declared-dependencies.d.ts +68 -0
- package/dist/access/declared-dependencies.d.ts.map +1 -0
- package/dist/access/declared-dependencies.js +79 -0
- package/dist/access/declared-dependencies.js.map +1 -0
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +23 -3
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +2 -0
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +3 -0
- package/dist/access/index.js.map +1 -1
- package/dist/config/types.d.ts +65 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +65 -72
- package/dist/context/index.js.map +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +12 -12
- package/dist/fields/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/validation/needs-closure.d.ts +49 -0
- package/dist/validation/needs-closure.d.ts.map +1 -0
- package/dist/validation/needs-closure.js +139 -0
- package/dist/validation/needs-closure.js.map +1 -0
- package/package.json +1 -1
- package/src/access/declared-dependencies.ts +140 -0
- package/src/access/field-visibility.ts +24 -0
- package/src/access/index.ts +8 -0
- package/src/config/types.ts +65 -1
- package/src/context/index.ts +117 -90
- package/src/fields/index.ts +12 -9
- package/src/index.ts +8 -0
- package/src/validation/needs-closure.ts +188 -0
- package/tests/bare-read-scalars.test.ts +147 -0
- package/tests/field-types.test.ts +6 -2
- package/tests/needs-declared-dependencies.test.ts +500 -0
- package/tests/nested-access-and-hooks.test.ts +10 -0
- package/tests/resolve-chain.test.ts +40 -23
- package/tests/singleton.test.ts +83 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/access/index.ts
CHANGED
|
@@ -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.
|
package/src/config/types.ts
CHANGED
|
@@ -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,
|
|
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
|
/**
|
package/src/context/index.ts
CHANGED
|
@@ -7,12 +7,14 @@ import {
|
|
|
7
7
|
buildIncludeWithAccessControl,
|
|
8
8
|
mergeIncludeWithAccessControl,
|
|
9
9
|
stripVirtualFieldsFromInclude,
|
|
10
|
-
|
|
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
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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
|
-
//
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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
|
|
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
|
|
package/src/fields/index.ts
CHANGED
|
@@ -144,11 +144,11 @@ export function text<
|
|
|
144
144
|
modifiers += ` @default(${defaultLiteral})`
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
// Unique
|
|
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
|
|
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
|
|
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
|
+
}
|