@opensaas/stack-core 0.39.1 → 0.40.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 +156 -0
- package/CLAUDE.md +51 -0
- package/dist/access/access-filter.d.ts +83 -10
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +139 -18
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +224 -12
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/engine.d.ts +43 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +58 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +56 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/errors.d.ts +28 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +38 -2
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +12 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +22 -2
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +66 -0
- package/dist/access/field-access.test.js.map +1 -1
- 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 +45 -3
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +5 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +4 -2
- package/dist/access/index.js.map +1 -1
- package/dist/config/types.d.ts +77 -11
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +19 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +61 -17
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +54 -13
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +3 -2
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +8 -5
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/index.d.ts +13 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +50 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
- package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
- package/dist/fields/relationship-foreign-key.test.js +18 -0
- package/dist/fields/relationship-foreign-key.test.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/case-utils.d.ts +15 -0
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +21 -0
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/lib/case-utils.test.js +23 -1
- package/dist/lib/case-utils.test.js.map +1 -1
- package/dist/lib/prisma-errors.d.ts +38 -0
- package/dist/lib/prisma-errors.d.ts.map +1 -0
- package/dist/lib/prisma-errors.js +53 -0
- package/dist/lib/prisma-errors.js.map +1 -0
- package/dist/mcp/constants.d.ts +23 -0
- package/dist/mcp/constants.d.ts.map +1 -0
- package/dist/mcp/constants.js +23 -0
- package/dist/mcp/constants.js.map +1 -0
- package/dist/mcp/field-schema.d.ts +8 -0
- package/dist/mcp/field-schema.d.ts.map +1 -0
- package/dist/mcp/field-schema.js +69 -0
- package/dist/mcp/field-schema.js.map +1 -0
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +76 -90
- package/dist/mcp/handler.js.map +1 -1
- package/dist/mcp/projection.d.ts +62 -0
- package/dist/mcp/projection.d.ts.map +1 -0
- package/dist/mcp/projection.js +390 -0
- package/dist/mcp/projection.js.map +1 -0
- package/dist/utils/password.d.ts +3 -1
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +4 -2
- package/dist/utils/password.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +278 -12
- package/src/access/access-filter.ts +207 -19
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +81 -1
- package/src/access/errors.ts +42 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +75 -10
- package/src/access/index.ts +14 -0
- package/src/config/types.ts +77 -11
- package/src/context/index.ts +95 -15
- package/src/context/nested-operations.ts +61 -16
- package/src/context/write-pipeline.ts +8 -4
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- package/src/index.ts +17 -1
- package/src/lib/case-utils.test.ts +32 -0
- package/src/lib/case-utils.ts +26 -0
- package/src/lib/prisma-errors.ts +86 -0
- package/src/mcp/constants.ts +24 -0
- package/src/mcp/field-schema.ts +84 -0
- package/src/mcp/handler.ts +109 -107
- package/src/mcp/projection.ts +497 -0
- package/src/utils/password.ts +5 -3
- package/tests/access-relationships.test.ts +3 -3
- package/tests/access.test.ts +83 -0
- package/tests/context.test.ts +259 -12
- package/tests/field-types.test.ts +113 -0
- package/tests/mcp-fields-projection-access.test.ts +250 -0
- package/tests/mcp-handler.test.ts +695 -4
- package/tests/nested-access-and-hooks.test.ts +65 -0
- package/tests/nested-write-synthetic-relation.test.ts +437 -0
- package/tests/password-types.test.ts +28 -0
- package/tests/password.test.ts +16 -2
- package/tests/prisma-errors.test.ts +141 -0
- package/tests/singleton.test.ts +9 -1
- package/tests/to-one-include-access-filter.test.ts +172 -0
- package/tests/with-session.test.ts +243 -0
- package/tests/write-pipeline.test.ts +29 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
|
|
2
2
|
import type { AccessContext, FieldAccess } from '../access/types.js'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
checkAccess,
|
|
5
|
+
checkCreateAccess,
|
|
6
|
+
filterWritableFields,
|
|
7
|
+
getRelatedListConfig,
|
|
8
|
+
resolveSyntheticReverseRelation,
|
|
9
|
+
} from '../access/index.js'
|
|
4
10
|
import { checkFieldAccess } from '../access/field-access.js'
|
|
5
11
|
import {
|
|
6
12
|
executeResolveInput,
|
|
@@ -205,12 +211,12 @@ async function processNestedCreate(
|
|
|
205
211
|
itemsArray.map(async (item, index) => {
|
|
206
212
|
if (!context._isSudo) {
|
|
207
213
|
const createAccess = relatedListConfig.access?.operation?.create
|
|
208
|
-
const
|
|
214
|
+
const allowed = await checkCreateAccess(relatedListName, createAccess, {
|
|
209
215
|
session: context.session,
|
|
210
216
|
context,
|
|
211
217
|
})
|
|
212
218
|
|
|
213
|
-
if (
|
|
219
|
+
if (!allowed) {
|
|
214
220
|
throw new Error('Access denied: Cannot create related item')
|
|
215
221
|
}
|
|
216
222
|
}
|
|
@@ -279,6 +285,8 @@ async function processNestedCreate(
|
|
|
279
285
|
session: context.session,
|
|
280
286
|
context,
|
|
281
287
|
inputData: item,
|
|
288
|
+
listName: relatedListName,
|
|
289
|
+
config,
|
|
282
290
|
},
|
|
283
291
|
)
|
|
284
292
|
|
|
@@ -590,6 +598,8 @@ async function processNestedUpdate(
|
|
|
590
598
|
item: originalItem,
|
|
591
599
|
context,
|
|
592
600
|
inputData: updateData,
|
|
601
|
+
listName: relatedListName,
|
|
602
|
+
config,
|
|
593
603
|
},
|
|
594
604
|
)
|
|
595
605
|
|
|
@@ -1194,27 +1204,62 @@ export async function processNestedOperations(
|
|
|
1194
1204
|
const processed: Record<string, unknown> = {}
|
|
1195
1205
|
|
|
1196
1206
|
for (const [fieldName, value] of Object.entries(data)) {
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
if (!isRelationshipField(fieldConfig) || value === null || value === undefined) {
|
|
1207
|
+
if (value === null || value === undefined) {
|
|
1200
1208
|
processed[fieldName] = value
|
|
1201
1209
|
continue
|
|
1202
1210
|
}
|
|
1203
1211
|
|
|
1204
|
-
const
|
|
1205
|
-
|
|
1206
|
-
|
|
1212
|
+
const fieldConfig = fieldConfigs[fieldName]
|
|
1213
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
1214
|
+
let relatedListConfig: ListConfig<any>
|
|
1215
|
+
let resolvedListName: string
|
|
1216
|
+
let owningFieldAccess: FieldAccess | undefined
|
|
1217
|
+
|
|
1218
|
+
if (isRelationshipField(fieldConfig)) {
|
|
1219
|
+
const relationshipField = fieldConfig as { type: 'relationship'; ref: string }
|
|
1220
|
+
const relatedConfig = getRelatedListConfig(relationshipField.ref, config)
|
|
1221
|
+
if (!relatedConfig) {
|
|
1222
|
+
processed[fieldName] = value
|
|
1223
|
+
continue
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const { listName: relatedListName, listConfig } = relatedConfig
|
|
1227
|
+
resolvedListName = relatedListName || findListName(listConfig, config)
|
|
1228
|
+
relatedListConfig = listConfig
|
|
1229
|
+
// The owning relationship field's field-level access, for the #588 gate
|
|
1230
|
+
// in verifyConnectReachable.
|
|
1231
|
+
owningFieldAccess = fieldConfig.access
|
|
1232
|
+
} else if (!fieldConfig) {
|
|
1233
|
+
// Not a field the parent's config declares — check whether it names the
|
|
1234
|
+
// synthetic back-relation a list-only `ref` generates on this list
|
|
1235
|
+
// (`from_<List>_<field>`, #978). Resolved, it is processed exactly like a
|
|
1236
|
+
// nested write through the declared field that owns it; unresolved, it
|
|
1237
|
+
// is left untouched. This is NOT solely a sudo path: a multi-column
|
|
1238
|
+
// field's raw per-part columns (e.g. `m_url`/`m_size`, #789) are ALSO
|
|
1239
|
+
// undeclared from this list's `fieldConfigs` perspective and reach here
|
|
1240
|
+
// on every write, sudo or not — `filterWritableFields` already
|
|
1241
|
+
// recognises and gates those via its own `splitColumnOwners` map before
|
|
1242
|
+
// its undeclared-key branch, and this function has no such map to tell
|
|
1243
|
+
// them apart from a genuinely-unrecognised key, so it must not throw
|
|
1244
|
+
// here. A genuinely unrecognised key under sudo is refused instead by
|
|
1245
|
+
// `filterWritableFields`'s own resolution attempt, one level up.
|
|
1246
|
+
const synthetic = resolveSyntheticReverseRelation(fieldName, parentListName, config)
|
|
1247
|
+
if (!synthetic) {
|
|
1248
|
+
processed[fieldName] = value
|
|
1249
|
+
continue
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
resolvedListName = synthetic.sourceListName
|
|
1253
|
+
relatedListConfig = synthetic.sourceListConfig
|
|
1254
|
+
// There is no parent-side field here (unlike the declared-relationship
|
|
1255
|
+
// branch above) — the FK-owning field genuinely IS the one the source
|
|
1256
|
+
// list declares, so its access is the owning-field gate.
|
|
1257
|
+
owningFieldAccess = synthetic.sourceFieldConfig.access
|
|
1258
|
+
} else {
|
|
1207
1259
|
processed[fieldName] = value
|
|
1208
1260
|
continue
|
|
1209
1261
|
}
|
|
1210
1262
|
|
|
1211
|
-
const { listName: relatedListName, listConfig: relatedListConfig } = relatedConfig
|
|
1212
|
-
const resolvedListName = relatedListName || findListName(relatedListConfig, config)
|
|
1213
|
-
|
|
1214
|
-
// The owning relationship field's field-level access, for the #588 gate
|
|
1215
|
-
// in verifyConnectReachable.
|
|
1216
|
-
const owningFieldAccess = fieldConfig.access
|
|
1217
|
-
|
|
1218
1263
|
processed[fieldName] = await processFieldNestedOps(
|
|
1219
1264
|
fieldName,
|
|
1220
1265
|
value as Record<string, unknown>,
|
|
@@ -2,6 +2,7 @@ import type { OpenSaasConfig, ListConfig } from '../config/types.js'
|
|
|
2
2
|
import type { AccessContext, PrismaClientLike } from '../access/types.js'
|
|
3
3
|
import {
|
|
4
4
|
checkAccess,
|
|
5
|
+
checkCreateAccess,
|
|
5
6
|
mergeFilters,
|
|
6
7
|
filterReadableFields,
|
|
7
8
|
filterWritableFields,
|
|
@@ -330,6 +331,8 @@ async function runWriteInTransaction<TPrisma extends PrismaClientLike>(
|
|
|
330
331
|
item: originalItem,
|
|
331
332
|
context: { ...context, _isSudo: context._isSudo },
|
|
332
333
|
inputData: input,
|
|
334
|
+
listName,
|
|
335
|
+
config,
|
|
333
336
|
})
|
|
334
337
|
|
|
335
338
|
// ── Phase 5.5: process nested relationship operations ───────────────────────
|
|
@@ -560,8 +563,9 @@ async function runDeletePath(args: {
|
|
|
560
563
|
* Create strategy for {@link WriteStrategy}.
|
|
561
564
|
*
|
|
562
565
|
* Axis 1: checks `create` access with no existing row; a filter result
|
|
563
|
-
*
|
|
564
|
-
*
|
|
566
|
+
* throws `InvalidCreateAccessResultError` rather than proceeding unchecked —
|
|
567
|
+
* create has no row to re-check a filter against, unlike update/delete
|
|
568
|
+
* (#1009). Enforces the singleton-create constraint even under sudo.
|
|
565
569
|
* Axis 2: runs all input phases.
|
|
566
570
|
* Axis 3: `model.create({ data })`, prepending `id: 1` for singleton lists.
|
|
567
571
|
*/
|
|
@@ -588,11 +592,11 @@ export function createWriteStrategy(
|
|
|
588
592
|
}
|
|
589
593
|
|
|
590
594
|
if (!context._isSudo) {
|
|
591
|
-
const
|
|
595
|
+
const allowed = await checkCreateAccess(listName, listConfig.access?.operation?.create, {
|
|
592
596
|
session: context.session,
|
|
593
597
|
context,
|
|
594
598
|
})
|
|
595
|
-
if (
|
|
599
|
+
if (!allowed) {
|
|
596
600
|
return { status: 'denied' }
|
|
597
601
|
}
|
|
598
602
|
}
|
package/src/fields/index.ts
CHANGED
|
@@ -229,9 +229,16 @@ export function integer<
|
|
|
229
229
|
modifiers += ` @map("${db.map}")`
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
// Unique modifier — non-unique index routes through `index` below,
|
|
233
|
+
// same as `text()`'s getPrismaType.
|
|
234
|
+
if (options?.isIndexed === 'unique') {
|
|
235
|
+
modifiers += ' @unique'
|
|
236
|
+
}
|
|
237
|
+
|
|
232
238
|
return {
|
|
233
239
|
type: 'Int',
|
|
234
240
|
modifiers: modifiers.trimStart() || undefined,
|
|
241
|
+
index: options?.isIndexed === true ? true : undefined,
|
|
235
242
|
}
|
|
236
243
|
},
|
|
237
244
|
getTypeScriptType: () => {
|
|
@@ -657,9 +664,16 @@ export function timestamp<
|
|
|
657
664
|
modifiers += ` @map("${db.map}")`
|
|
658
665
|
}
|
|
659
666
|
|
|
667
|
+
// Unique modifier — non-unique index routes through `index` below,
|
|
668
|
+
// same as `text()`'s getPrismaType.
|
|
669
|
+
if (options?.isIndexed === 'unique') {
|
|
670
|
+
modifiers += ' @unique'
|
|
671
|
+
}
|
|
672
|
+
|
|
660
673
|
return {
|
|
661
674
|
type: 'DateTime',
|
|
662
675
|
modifiers: modifiers.trimStart() || undefined,
|
|
676
|
+
index: options?.isIndexed === true ? true : undefined,
|
|
663
677
|
}
|
|
664
678
|
},
|
|
665
679
|
getTypeScriptType: () => {
|
|
@@ -930,7 +944,10 @@ function formatCalendarDay(value: unknown): string | null | undefined {
|
|
|
930
944
|
* ```
|
|
931
945
|
*
|
|
932
946
|
* **Important Notes:**
|
|
933
|
-
* - Password
|
|
947
|
+
* - Password field values are redacted to `{ isSet: boolean }` on serialization
|
|
948
|
+
* (`JSON.stringify`, the admin UI). Field-level `read` access is not denied by
|
|
949
|
+
* default — configure `access.field.read` if the raw value should never reach
|
|
950
|
+
* `context.db` callers at all.
|
|
934
951
|
* - Always use the `compare()` method to verify passwords - never compare strings directly
|
|
935
952
|
* - The password field value has type `HashedPassword` which extends string with compare()
|
|
936
953
|
* - Empty strings and undefined values are skipped (not hashed) to allow partial updates
|
|
@@ -955,6 +972,13 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
|
|
|
955
972
|
},
|
|
956
973
|
ui: {
|
|
957
974
|
...options?.ui,
|
|
975
|
+
// Excluded from default admin table columns (issue #1018) — declared
|
|
976
|
+
// via the flag rather than matched by field type/name, so an app can
|
|
977
|
+
// still opt a real password field back in with `ui.listView.defaultColumn: true`.
|
|
978
|
+
listView: {
|
|
979
|
+
defaultColumn: false,
|
|
980
|
+
...options?.ui?.listView,
|
|
981
|
+
},
|
|
958
982
|
valueForClientSerialization: ({ value }) => ({ isSet: !!value }),
|
|
959
983
|
},
|
|
960
984
|
hooks: {
|
|
@@ -1129,10 +1153,17 @@ export function select<
|
|
|
1129
1153
|
modifiers += ` @map("${options.db.map}")`
|
|
1130
1154
|
}
|
|
1131
1155
|
|
|
1156
|
+
// Unique modifier — non-unique index routes through `index` below,
|
|
1157
|
+
// same as `text()`'s getPrismaType.
|
|
1158
|
+
if (options.isIndexed === 'unique') {
|
|
1159
|
+
modifiers += ' @unique'
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1132
1162
|
return {
|
|
1133
1163
|
type: enumName,
|
|
1134
1164
|
modifiers: modifiers || undefined,
|
|
1135
1165
|
enumValues: options.options.map((opt) => opt.value),
|
|
1166
|
+
index: options.isIndexed === true ? true : undefined,
|
|
1136
1167
|
}
|
|
1137
1168
|
}
|
|
1138
1169
|
|
|
@@ -1146,9 +1177,14 @@ export function select<
|
|
|
1146
1177
|
modifiers += ` @map("${options.db.map}")`
|
|
1147
1178
|
}
|
|
1148
1179
|
|
|
1180
|
+
if (options.isIndexed === 'unique') {
|
|
1181
|
+
modifiers += ' @unique'
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1149
1184
|
return {
|
|
1150
1185
|
type: 'String',
|
|
1151
1186
|
modifiers: modifiers || undefined,
|
|
1187
|
+
index: options.isIndexed === true ? true : undefined,
|
|
1152
1188
|
}
|
|
1153
1189
|
},
|
|
1154
1190
|
getTypeScriptType: () => {
|
|
@@ -1366,6 +1402,18 @@ function computeManyToManyRelationName(
|
|
|
1366
1402
|
return undefined
|
|
1367
1403
|
}
|
|
1368
1404
|
|
|
1405
|
+
/**
|
|
1406
|
+
* The name Prisma generation synthesizes for the back-relation a list-only
|
|
1407
|
+
* `ref` (`ref: 'ListName'`, no target field) creates on its target model —
|
|
1408
|
+
* Prisma requires an opposite field, and the config never declares one.
|
|
1409
|
+
* Exported so runtime nested-write resolution (`resolveSyntheticReverseRelation`
|
|
1410
|
+
* in `access/engine.ts`) can recognise the same name rather than re-deriving
|
|
1411
|
+
* the format by string parsing (#978).
|
|
1412
|
+
*/
|
|
1413
|
+
export function getSyntheticFieldName(listKey: string, fieldName: string): string {
|
|
1414
|
+
return `from_${listKey}_${fieldName}`
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1369
1417
|
function getPrismaRelation(
|
|
1370
1418
|
field: RelationshipField,
|
|
1371
1419
|
fieldName: string,
|
|
@@ -1378,7 +1426,7 @@ function getPrismaRelation(
|
|
|
1378
1426
|
// Synthetic back-relation for list-only refs (Prisma requires an opposite field)
|
|
1379
1427
|
let backRelation: PrismaRelationResult['backRelation']
|
|
1380
1428
|
if (!targetField) {
|
|
1381
|
-
const syntheticFieldName =
|
|
1429
|
+
const syntheticFieldName = getSyntheticFieldName(listKey, fieldName)
|
|
1382
1430
|
const relationName = field.db?.relationName ?? `${listKey}_${fieldName}`
|
|
1383
1431
|
backRelation = {
|
|
1384
1432
|
targetList,
|
|
@@ -1494,10 +1542,12 @@ export function relationship<
|
|
|
1494
1542
|
)
|
|
1495
1543
|
}
|
|
1496
1544
|
|
|
1497
|
-
if (refParts.length === 1) {
|
|
1545
|
+
if (refParts.length === 1 && typeof options.db.foreignKey === 'boolean') {
|
|
1498
1546
|
throw new Error(
|
|
1499
|
-
'db.foreignKey
|
|
1500
|
-
'List-only refs
|
|
1547
|
+
'db.foreignKey cannot be a boolean on list-only refs (ref: "ListName"). ' +
|
|
1548
|
+
'List-only refs always create foreign keys automatically, so the ownership sense of ' +
|
|
1549
|
+
'db.foreignKey is meaningless here. Use db.foreignKey: { map: "columnName" } to rename ' +
|
|
1550
|
+
'the foreign key column instead.',
|
|
1501
1551
|
)
|
|
1502
1552
|
}
|
|
1503
1553
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { relationship } from './index.js'
|
|
3
|
+
|
|
4
|
+
describe('relationship field builder: db.foreignKey validation', () => {
|
|
5
|
+
it('rejects a boolean db.foreignKey on a list-only ref', () => {
|
|
6
|
+
expect(() => relationship({ ref: 'User', db: { foreignKey: true } })).toThrow(
|
|
7
|
+
'db.foreignKey cannot be a boolean on list-only refs',
|
|
8
|
+
)
|
|
9
|
+
expect(() => relationship({ ref: 'User', db: { foreignKey: false } })).toThrow(
|
|
10
|
+
'db.foreignKey cannot be a boolean on list-only refs',
|
|
11
|
+
)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('allows an object db.foreignKey (map) on a list-only ref', () => {
|
|
15
|
+
expect(() =>
|
|
16
|
+
relationship({ ref: 'User', db: { foreignKey: { map: 'user_id' } } }),
|
|
17
|
+
).not.toThrow()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('still allows a boolean db.foreignKey on a bidirectional ref', () => {
|
|
21
|
+
expect(() => relationship({ ref: 'User.posts', db: { foreignKey: true } })).not.toThrow()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('still rejects db.foreignKey on a many relationship regardless of ref shape', () => {
|
|
25
|
+
expect(() =>
|
|
26
|
+
relationship({ ref: 'User', many: true, db: { foreignKey: { map: 'user_id' } } }),
|
|
27
|
+
).toThrow('db.foreignKey can only be used on single relationships')
|
|
28
|
+
})
|
|
29
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -50,7 +50,7 @@ export type {
|
|
|
50
50
|
} from './context/index.js'
|
|
51
51
|
|
|
52
52
|
// Naming utilities (documented public helpers; used for URLs and db keys)
|
|
53
|
-
export { getDbKey, getUrlKey, getListKeyFromUrl } from './lib/case-utils.js'
|
|
53
|
+
export { getDbKey, getUrlKey, getListKeyFromUrl, resolveListKeyFromUrl } from './lib/case-utils.js'
|
|
54
54
|
|
|
55
55
|
// Label seam — resolves the field that represents a row as a single label
|
|
56
56
|
// (projection) and reads it off a row (render). Used by the admin UI for
|
|
@@ -64,6 +64,14 @@ export { resolveNavCounts, isListQueryStaticallyDenied } from './config/nav-coun
|
|
|
64
64
|
// Validation error surfaced by write operations
|
|
65
65
|
export { ValidationError } from './hooks/index.js'
|
|
66
66
|
|
|
67
|
+
// Resolves which columns (and, where recoverable, which named constraint) a
|
|
68
|
+
// caught P2002 unique-constraint violation hit — normalising Prisma 7 driver
|
|
69
|
+
// adapters' undocumented error shape to the documented `meta.target` one, so
|
|
70
|
+
// a caller of `context.db.*` never needs to reach into adapter internals
|
|
71
|
+
// (see issue #979).
|
|
72
|
+
export { uniqueConstraintOf } from './lib/prisma-errors.js'
|
|
73
|
+
export type { UniqueConstraintInfo } from './lib/prisma-errors.js'
|
|
74
|
+
|
|
67
75
|
// Thrown by a read when a caller-supplied `include` names a relation nested
|
|
68
76
|
// deeper than the Access Filter can scope (see ADR-0022). Distinct from
|
|
69
77
|
// `ValidationError` — this is the engine refusing to return unscoped data, not
|
|
@@ -83,6 +91,14 @@ export { ResolveOutputCycleError } from './access/index.js'
|
|
|
83
91
|
// validation failure.
|
|
84
92
|
export { InvalidFieldAccessResultError } from './access/index.js'
|
|
85
93
|
|
|
94
|
+
// Thrown by the write pipeline's create strategy and by the nested-create
|
|
95
|
+
// path when operation-level `create` access returns anything other than a
|
|
96
|
+
// strict boolean (see #1009, ADR-0022, and ADR-0030). A filter — the shape
|
|
97
|
+
// `query`/`update`/`delete` legitimately return — cannot be honoured on
|
|
98
|
+
// create: there is no existing row and no way to test it against input data,
|
|
99
|
+
// so it is refused loudly rather than silently treated as a full allow.
|
|
100
|
+
export { InvalidCreateAccessResultError } from './access/index.js'
|
|
101
|
+
|
|
86
102
|
// Thrown by a read when a caller-supplied `where` filters on a relation whose
|
|
87
103
|
// related list denies operation-level `query` access outright (see #916 and
|
|
88
104
|
// ADR-0022). Distinct from `ValidationError` for the same reason as
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getDbKey,
|
|
8
8
|
getUrlKey,
|
|
9
9
|
getListKeyFromUrl,
|
|
10
|
+
resolveListKeyFromUrl,
|
|
10
11
|
} from './case-utils.js'
|
|
11
12
|
|
|
12
13
|
describe('Case Conversion Utilities', () => {
|
|
@@ -97,6 +98,37 @@ describe('Case Conversion Utilities', () => {
|
|
|
97
98
|
})
|
|
98
99
|
})
|
|
99
100
|
|
|
101
|
+
describe('resolveListKeyFromUrl', () => {
|
|
102
|
+
it('resolves a PascalCase list key from its URL segment', () => {
|
|
103
|
+
expect(resolveListKeyFromUrl('blog-post', ['User', 'BlogPost'])).toBe('BlogPost')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('resolves a camelCase list key (e.g. a derived plugin list) from its URL segment', () => {
|
|
107
|
+
// getListKeyFromUrl('oauth-application') would reconstruct 'OauthApplication',
|
|
108
|
+
// which isn't a key in this list — that's the bug this resolver fixes.
|
|
109
|
+
expect(resolveListKeyFromUrl('oauth-application', ['oauthApplication'])).toBe(
|
|
110
|
+
'oauthApplication',
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('returns undefined for a URL segment matching no list key', () => {
|
|
115
|
+
expect(resolveListKeyFromUrl('does-not-exist', ['User', 'BlogPost'])).toBeUndefined()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('throws when two distinct list keys produce the same URL segment', () => {
|
|
119
|
+
expect(() =>
|
|
120
|
+
resolveListKeyFromUrl('oauth-application', ['oauthApplication', 'OauthApplication']),
|
|
121
|
+
).toThrow(/Ambiguous list URL/)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('is derived from getUrlKey, so it cannot drift from how URLs are built', () => {
|
|
125
|
+
const listKeys = ['User', 'AuthUser', 'BlogPost', 'oauthApplication']
|
|
126
|
+
for (const listKey of listKeys) {
|
|
127
|
+
expect(resolveListKeyFromUrl(getUrlKey(listKey), listKeys)).toBe(listKey)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
100
132
|
describe('Round-trip conversions', () => {
|
|
101
133
|
it('should maintain consistency: PascalCase -> kebab-case -> PascalCase', () => {
|
|
102
134
|
const original = 'BlogPost'
|
package/src/lib/case-utils.ts
CHANGED
|
@@ -40,3 +40,29 @@ export function getUrlKey(listKey: string): string {
|
|
|
40
40
|
export function getListKeyFromUrl(urlSegment: string): string {
|
|
41
41
|
return kebabToPascal(urlSegment)
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolves a URL segment back to the list key that produced it, by checking
|
|
46
|
+
* each candidate key against `getUrlKey` — the same helper that builds the
|
|
47
|
+
* URL — rather than reconstructing a key by string transformation the way
|
|
48
|
+
* `getListKeyFromUrl` does. That reconstruction is lossy for any list key
|
|
49
|
+
* that isn't strict PascalCase (e.g. a camelCase key from a derived list),
|
|
50
|
+
* so this is the config-aware resolver route lookups should use instead.
|
|
51
|
+
*
|
|
52
|
+
* Returns `undefined` when no candidate key's URL matches — callers render
|
|
53
|
+
* their usual "not found" state rather than treating this as an error.
|
|
54
|
+
* Throws if more than one candidate key produces the same URL segment: that
|
|
55
|
+
* is a configuration error (two list keys collide once case-folded to a
|
|
56
|
+
* URL) and must be reported, not resolved by silently picking one.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveListKeyFromUrl(urlSegment: string, listKeys: string[]): string | undefined {
|
|
59
|
+
const matches = listKeys.filter((listKey) => getUrlKey(listKey) === urlSegment)
|
|
60
|
+
|
|
61
|
+
if (matches.length > 1) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Ambiguous list URL "${urlSegment}": list keys ${matches.map((key) => `"${key}"`).join(', ')} all resolve to this URL. Rename these lists so their URLs are unique.`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return matches[0]
|
|
68
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
type PrismaP2002Meta = {
|
|
2
|
+
target?: string[]
|
|
3
|
+
driverAdapterError?: {
|
|
4
|
+
cause?: {
|
|
5
|
+
originalMessage?: string
|
|
6
|
+
constraint?: { fields?: unknown[] }
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Postgres quotes an identifier in `constraint.fields` only when it needed
|
|
12
|
+
// quoting (e.g. camelCase columns), so `"tenantId"` and `slug` are both valid
|
|
13
|
+
// entries for the same array.
|
|
14
|
+
function stripIdentifierQuotes(field: string): string {
|
|
15
|
+
const match = field.match(/^"(.*)"$/)
|
|
16
|
+
return match ? match[1] : field
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UniqueConstraintInfo {
|
|
20
|
+
/** Column names covered by the violated constraint, quote-stripped. */
|
|
21
|
+
fields: string[]
|
|
22
|
+
/**
|
|
23
|
+
* The violated constraint's name, when recoverable from the error.
|
|
24
|
+
*
|
|
25
|
+
* Known limit: this is parsed out of Postgres' English-locale error text
|
|
26
|
+
* (`cause.originalMessage`) — the adapter exposes no structured field for
|
|
27
|
+
* it. A server running under a non-English `lc_messages` locale will not
|
|
28
|
+
* match, leaving this `undefined` while `fields` (structured data) still
|
|
29
|
+
* resolves correctly.
|
|
30
|
+
*/
|
|
31
|
+
constraintName?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve which columns — and, where recoverable, which named constraint — a
|
|
36
|
+
* Prisma `P2002` unique-constraint violation hit.
|
|
37
|
+
*
|
|
38
|
+
* Prisma documents `error.meta.target` for this. Under Prisma 7 driver
|
|
39
|
+
* adapters (verified against `@prisma/adapter-pg` and PGlite; see issue #979)
|
|
40
|
+
* that field is `undefined` instead — the same information sits at
|
|
41
|
+
* `error.meta.driverAdapterError.cause`, with the constraint name only
|
|
42
|
+
* recoverable as free text inside `cause.originalMessage`. This normalises
|
|
43
|
+
* both shapes to one stable result so callers never need to reach into that
|
|
44
|
+
* adapter-specific structure themselves.
|
|
45
|
+
*
|
|
46
|
+
* A derived constraint name is truncated by Postgres at its 63-character
|
|
47
|
+
* identifier limit, and one row can violate two unique indexes at once (with
|
|
48
|
+
* Postgres reporting whichever it checked first) — an index an application
|
|
49
|
+
* means to branch on should be named explicitly, and a caller may still need
|
|
50
|
+
* to re-read the colliding row to fully disambiguate.
|
|
51
|
+
*
|
|
52
|
+
* Returns `undefined` when `error` isn't a `P2002` with any recoverable
|
|
53
|
+
* column or constraint information — never throws, since the adapter error
|
|
54
|
+
* shape is untrusted and every level of it may be absent.
|
|
55
|
+
*/
|
|
56
|
+
export function uniqueConstraintOf(error: unknown): UniqueConstraintInfo | undefined {
|
|
57
|
+
if (
|
|
58
|
+
!error ||
|
|
59
|
+
typeof error !== 'object' ||
|
|
60
|
+
!('code' in error) ||
|
|
61
|
+
(error as { code?: unknown }).code !== 'P2002'
|
|
62
|
+
) {
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const meta = (error as { meta?: PrismaP2002Meta }).meta
|
|
67
|
+
|
|
68
|
+
if (meta?.target && Array.isArray(meta.target)) {
|
|
69
|
+
return { fields: meta.target }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const cause = meta?.driverAdapterError?.cause
|
|
73
|
+
const rawFields = cause?.constraint?.fields
|
|
74
|
+
const fields = Array.isArray(rawFields)
|
|
75
|
+
? rawFields.filter((f): f is string => typeof f === 'string').map(stripIdentifierQuotes)
|
|
76
|
+
: []
|
|
77
|
+
|
|
78
|
+
const constraintName =
|
|
79
|
+
typeof cause?.originalMessage === 'string'
|
|
80
|
+
? cause.originalMessage.match(/unique constraint "([^"]+)"/)?.[1]
|
|
81
|
+
: undefined
|
|
82
|
+
|
|
83
|
+
if (fields.length === 0 && !constraintName) return undefined
|
|
84
|
+
|
|
85
|
+
return constraintName ? { fields, constraintName } : { fields }
|
|
86
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many levels deep the `query` tool's `fields` projection schema
|
|
3
|
+
* enumerates a list's relations: the root list's own fields (level 1), and
|
|
4
|
+
* for each relation, the related list's own scalar/virtual fields (level 2).
|
|
5
|
+
* A relation named at level 2 is not itself selectable further — reaching
|
|
6
|
+
* past it is a second tool call, the same round-trip a bare read already
|
|
7
|
+
* assumes. Deliberately shallower than `READ_INCLUDE_MAX_DEPTH` (5) so no
|
|
8
|
+
* schema-conforming request can reach the engine's depth refusal.
|
|
9
|
+
*
|
|
10
|
+
* Fixed (not configurable) by design (ADR-0033, ADR-0026's position on the
|
|
11
|
+
* depth cap) — `generateFieldsProjectionSchema` and `resolveFieldsProjection`
|
|
12
|
+
* (`projection.ts`) are hand-written for exactly this depth (one loop over
|
|
13
|
+
* the root list's fields, one nested loop over a relation's), not driven by
|
|
14
|
+
* this value at runtime. It exists so the depth has one documented, tested
|
|
15
|
+
* name rather than an unexplained "2" in two places; changing it is a
|
|
16
|
+
* deliberate rewrite of both functions, not a config knob.
|
|
17
|
+
*/
|
|
18
|
+
export const MCP_FIELDS_SCHEMA_DEPTH = 2
|
|
19
|
+
|
|
20
|
+
/** Row cap applied to a nested to-many relation in a `fields` projection when the caller omits `take`. */
|
|
21
|
+
export const MCP_NESTED_TAKE_DEFAULT = 5
|
|
22
|
+
|
|
23
|
+
/** Hard ceiling a nested to-many relation's `take` is clamped to, regardless of what the caller requests. */
|
|
24
|
+
export const MCP_NESTED_TAKE_MAX = 50
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { FieldConfig } from '../config/types.js'
|
|
2
|
+
|
|
3
|
+
/** JSON Schema for one field's own value — shared by the create/update `data` schema and the `query` tool's `fields` projection schema. */
|
|
4
|
+
export function fieldToJsonSchema(
|
|
5
|
+
fieldName: string,
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field configs have varying structures
|
|
7
|
+
fieldConfig: any,
|
|
8
|
+
): Record<string, unknown> {
|
|
9
|
+
const baseSchema: Record<string, unknown> = {}
|
|
10
|
+
|
|
11
|
+
switch (fieldConfig.type) {
|
|
12
|
+
case 'text':
|
|
13
|
+
case 'password':
|
|
14
|
+
baseSchema.type = 'string'
|
|
15
|
+
if (fieldConfig.validation?.length) {
|
|
16
|
+
if (fieldConfig.validation.length.min)
|
|
17
|
+
baseSchema.minLength = fieldConfig.validation.length.min
|
|
18
|
+
if (fieldConfig.validation.length.max)
|
|
19
|
+
baseSchema.maxLength = fieldConfig.validation.length.max
|
|
20
|
+
}
|
|
21
|
+
break
|
|
22
|
+
case 'integer':
|
|
23
|
+
baseSchema.type = 'number'
|
|
24
|
+
if (fieldConfig.validation?.min !== undefined) baseSchema.minimum = fieldConfig.validation.min
|
|
25
|
+
if (fieldConfig.validation?.max !== undefined) baseSchema.maximum = fieldConfig.validation.max
|
|
26
|
+
break
|
|
27
|
+
case 'checkbox':
|
|
28
|
+
baseSchema.type = 'boolean'
|
|
29
|
+
break
|
|
30
|
+
case 'timestamp':
|
|
31
|
+
baseSchema.type = 'string'
|
|
32
|
+
baseSchema.format = 'date-time'
|
|
33
|
+
break
|
|
34
|
+
case 'select':
|
|
35
|
+
baseSchema.type = 'string'
|
|
36
|
+
if (fieldConfig.options) {
|
|
37
|
+
baseSchema.enum = fieldConfig.options.map((opt: { value: string }) => opt.value)
|
|
38
|
+
}
|
|
39
|
+
break
|
|
40
|
+
case 'relationship':
|
|
41
|
+
baseSchema.type = 'object'
|
|
42
|
+
baseSchema.properties = {
|
|
43
|
+
connect: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
id: { type: 'string' },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
break
|
|
51
|
+
default:
|
|
52
|
+
baseSchema.type = 'string'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return baseSchema
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function generateFieldSchemas(
|
|
59
|
+
fields: Record<string, FieldConfig>,
|
|
60
|
+
operation: 'create' | 'update',
|
|
61
|
+
): {
|
|
62
|
+
properties: Record<string, unknown>
|
|
63
|
+
required: string[]
|
|
64
|
+
} {
|
|
65
|
+
const properties: Record<string, unknown> = {}
|
|
66
|
+
const required: string[] = []
|
|
67
|
+
|
|
68
|
+
for (const [fieldName, fieldConfig] of Object.entries(fields)) {
|
|
69
|
+
if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) continue
|
|
70
|
+
|
|
71
|
+
properties[fieldName] = fieldToJsonSchema(fieldName, fieldConfig)
|
|
72
|
+
|
|
73
|
+
if (
|
|
74
|
+
operation === 'create' &&
|
|
75
|
+
'validation' in fieldConfig &&
|
|
76
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Validation property varies by field type
|
|
77
|
+
(fieldConfig.validation as any)?.isRequired
|
|
78
|
+
) {
|
|
79
|
+
required.push(fieldName)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { properties, required }
|
|
84
|
+
}
|