@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.
Files changed (130) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +156 -0
  3. package/CLAUDE.md +51 -0
  4. package/dist/access/access-filter.d.ts +83 -10
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +139 -18
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +224 -12
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/engine.d.ts +43 -1
  11. package/dist/access/engine.d.ts.map +1 -1
  12. package/dist/access/engine.js +58 -0
  13. package/dist/access/engine.js.map +1 -1
  14. package/dist/access/engine.test.d.ts +2 -0
  15. package/dist/access/engine.test.d.ts.map +1 -0
  16. package/dist/access/engine.test.js +56 -0
  17. package/dist/access/engine.test.js.map +1 -0
  18. package/dist/access/errors.d.ts +28 -0
  19. package/dist/access/errors.d.ts.map +1 -1
  20. package/dist/access/errors.js +38 -2
  21. package/dist/access/errors.js.map +1 -1
  22. package/dist/access/field-access.d.ts +12 -0
  23. package/dist/access/field-access.d.ts.map +1 -1
  24. package/dist/access/field-access.js +22 -2
  25. package/dist/access/field-access.js.map +1 -1
  26. package/dist/access/field-access.test.js +66 -0
  27. package/dist/access/field-access.test.js.map +1 -1
  28. package/dist/access/field-visibility.d.ts +2 -1
  29. package/dist/access/field-visibility.d.ts.map +1 -1
  30. package/dist/access/field-visibility.js +45 -3
  31. package/dist/access/field-visibility.js.map +1 -1
  32. package/dist/access/index.d.ts +5 -2
  33. package/dist/access/index.d.ts.map +1 -1
  34. package/dist/access/index.js +4 -2
  35. package/dist/access/index.js.map +1 -1
  36. package/dist/config/types.d.ts +77 -11
  37. package/dist/config/types.d.ts.map +1 -1
  38. package/dist/context/index.d.ts +19 -1
  39. package/dist/context/index.d.ts.map +1 -1
  40. package/dist/context/index.js +61 -17
  41. package/dist/context/index.js.map +1 -1
  42. package/dist/context/nested-operations.d.ts.map +1 -1
  43. package/dist/context/nested-operations.js +54 -13
  44. package/dist/context/nested-operations.js.map +1 -1
  45. package/dist/context/write-pipeline.d.ts +3 -2
  46. package/dist/context/write-pipeline.d.ts.map +1 -1
  47. package/dist/context/write-pipeline.js +8 -5
  48. package/dist/context/write-pipeline.js.map +1 -1
  49. package/dist/fields/index.d.ts +13 -1
  50. package/dist/fields/index.d.ts.map +1 -1
  51. package/dist/fields/index.js +50 -5
  52. package/dist/fields/index.js.map +1 -1
  53. package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
  54. package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
  55. package/dist/fields/relationship-foreign-key.test.js +18 -0
  56. package/dist/fields/relationship-foreign-key.test.js.map +1 -0
  57. package/dist/index.d.ts +4 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +14 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/lib/case-utils.d.ts +15 -0
  62. package/dist/lib/case-utils.d.ts.map +1 -1
  63. package/dist/lib/case-utils.js +21 -0
  64. package/dist/lib/case-utils.js.map +1 -1
  65. package/dist/lib/case-utils.test.js +23 -1
  66. package/dist/lib/case-utils.test.js.map +1 -1
  67. package/dist/lib/prisma-errors.d.ts +38 -0
  68. package/dist/lib/prisma-errors.d.ts.map +1 -0
  69. package/dist/lib/prisma-errors.js +53 -0
  70. package/dist/lib/prisma-errors.js.map +1 -0
  71. package/dist/mcp/constants.d.ts +23 -0
  72. package/dist/mcp/constants.d.ts.map +1 -0
  73. package/dist/mcp/constants.js +23 -0
  74. package/dist/mcp/constants.js.map +1 -0
  75. package/dist/mcp/field-schema.d.ts +8 -0
  76. package/dist/mcp/field-schema.d.ts.map +1 -0
  77. package/dist/mcp/field-schema.js +69 -0
  78. package/dist/mcp/field-schema.js.map +1 -0
  79. package/dist/mcp/handler.d.ts.map +1 -1
  80. package/dist/mcp/handler.js +76 -90
  81. package/dist/mcp/handler.js.map +1 -1
  82. package/dist/mcp/projection.d.ts +62 -0
  83. package/dist/mcp/projection.d.ts.map +1 -0
  84. package/dist/mcp/projection.js +390 -0
  85. package/dist/mcp/projection.js.map +1 -0
  86. package/dist/utils/password.d.ts +3 -1
  87. package/dist/utils/password.d.ts.map +1 -1
  88. package/dist/utils/password.js +4 -2
  89. package/dist/utils/password.js.map +1 -1
  90. package/package.json +1 -1
  91. package/src/access/access-filter.test.ts +278 -12
  92. package/src/access/access-filter.ts +207 -19
  93. package/src/access/engine.test.ts +67 -0
  94. package/src/access/engine.ts +81 -1
  95. package/src/access/errors.ts +42 -2
  96. package/src/access/field-access.test.ts +78 -0
  97. package/src/access/field-access.ts +34 -2
  98. package/src/access/field-visibility.ts +75 -10
  99. package/src/access/index.ts +14 -0
  100. package/src/config/types.ts +77 -11
  101. package/src/context/index.ts +95 -15
  102. package/src/context/nested-operations.ts +61 -16
  103. package/src/context/write-pipeline.ts +8 -4
  104. package/src/fields/index.ts +55 -5
  105. package/src/fields/relationship-foreign-key.test.ts +29 -0
  106. package/src/index.ts +17 -1
  107. package/src/lib/case-utils.test.ts +32 -0
  108. package/src/lib/case-utils.ts +26 -0
  109. package/src/lib/prisma-errors.ts +86 -0
  110. package/src/mcp/constants.ts +24 -0
  111. package/src/mcp/field-schema.ts +84 -0
  112. package/src/mcp/handler.ts +109 -107
  113. package/src/mcp/projection.ts +497 -0
  114. package/src/utils/password.ts +5 -3
  115. package/tests/access-relationships.test.ts +3 -3
  116. package/tests/access.test.ts +83 -0
  117. package/tests/context.test.ts +259 -12
  118. package/tests/field-types.test.ts +113 -0
  119. package/tests/mcp-fields-projection-access.test.ts +250 -0
  120. package/tests/mcp-handler.test.ts +695 -4
  121. package/tests/nested-access-and-hooks.test.ts +65 -0
  122. package/tests/nested-write-synthetic-relation.test.ts +437 -0
  123. package/tests/password-types.test.ts +28 -0
  124. package/tests/password.test.ts +16 -2
  125. package/tests/prisma-errors.test.ts +141 -0
  126. package/tests/singleton.test.ts +9 -1
  127. package/tests/to-one-include-access-filter.test.ts +172 -0
  128. package/tests/with-session.test.ts +243 -0
  129. package/tests/write-pipeline.test.ts +29 -0
  130. 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 { checkAccess, filterWritableFields, getRelatedListConfig } from '../access/index.js'
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 accessResult = await checkAccess(createAccess, {
214
+ const allowed = await checkCreateAccess(relatedListName, createAccess, {
209
215
  session: context.session,
210
216
  context,
211
217
  })
212
218
 
213
- if (accessResult === false) {
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
- const fieldConfig = fieldConfigs[fieldName]
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 relationshipField = fieldConfig as { type: 'relationship'; ref: string }
1205
- const relatedConfig = getRelatedListConfig(relationshipField.ref, config)
1206
- if (!relatedConfig) {
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
- * proceeds with no re-check (unlike update/delete). Enforces the
564
- * singleton-create constraint even under sudo.
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 accessResult = await checkAccess(listConfig.access?.operation?.create, {
595
+ const allowed = await checkCreateAccess(listName, listConfig.access?.operation?.create, {
592
596
  session: context.session,
593
597
  context,
594
598
  })
595
- if (accessResult === false) {
599
+ if (!allowed) {
596
600
  return { status: 'denied' }
597
601
  }
598
602
  }
@@ -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 fields are excluded from read operations by default in access control
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 = `from_${listKey}_${fieldName}`
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 can only be used on bidirectional relationships (ref: "ListName.fieldName"). ' +
1500
- 'List-only refs (ref: "ListName") always create foreign keys automatically.',
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'
@@ -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
+ }