@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +88 -0
- package/CLAUDE.md +44 -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/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/types.d.ts +191 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +51 -95
- package/dist/context/index.js.map +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +40 -17
- package/dist/fields/index.js.map +1 -1
- package/dist/index.d.ts +3 -1
- 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/index.ts +2 -0
- package/src/config/types.ts +195 -1
- package/src/context/index.ts +96 -115
- package/src/fields/index.ts +47 -14
- package/src/index.ts +10 -0
- package/src/validation/needs-closure.ts +188 -0
- package/tests/field-types.test.ts +18 -2
- package/tests/needs-declared-dependencies.test.ts +500 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/context/index.ts
CHANGED
|
@@ -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
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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) {
|
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: () => {
|
|
@@ -1384,10 +1387,16 @@ function getPrismaRelation(
|
|
|
1384
1387
|
? ` @map("${field.db.foreignKey.map}")`
|
|
1385
1388
|
: ` @map("${fieldName}")`
|
|
1386
1389
|
|
|
1387
|
-
|
|
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}
|
|
1390
|
-
: ` ${paddedName} ${targetList}
|
|
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('
|
|
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
|
-
|
|
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
|
|