@opensaas/stack-core 0.39.2 → 0.41.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 (165) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +189 -0
  3. package/CLAUDE.md +103 -0
  4. package/dist/access/access-filter.d.ts +157 -14
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +470 -42
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +982 -17
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/denied-relation-visibility.test.d.ts +2 -0
  11. package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
  12. package/dist/access/denied-relation-visibility.test.js +114 -0
  13. package/dist/access/denied-relation-visibility.test.js.map +1 -0
  14. package/dist/access/engine.d.ts +54 -1
  15. package/dist/access/engine.d.ts.map +1 -1
  16. package/dist/access/engine.js +84 -0
  17. package/dist/access/engine.js.map +1 -1
  18. package/dist/access/engine.test.d.ts +2 -0
  19. package/dist/access/engine.test.d.ts.map +1 -0
  20. package/dist/access/engine.test.js +56 -0
  21. package/dist/access/engine.test.js.map +1 -0
  22. package/dist/access/errors.d.ts +62 -0
  23. package/dist/access/errors.d.ts.map +1 -1
  24. package/dist/access/errors.js +85 -2
  25. package/dist/access/errors.js.map +1 -1
  26. package/dist/access/field-access.d.ts +12 -0
  27. package/dist/access/field-access.d.ts.map +1 -1
  28. package/dist/access/field-access.js +22 -2
  29. package/dist/access/field-access.js.map +1 -1
  30. package/dist/access/field-access.test.js +66 -0
  31. package/dist/access/field-access.test.js.map +1 -1
  32. package/dist/access/field-visibility.d.ts +2 -1
  33. package/dist/access/field-visibility.d.ts.map +1 -1
  34. package/dist/access/field-visibility.js +107 -15
  35. package/dist/access/field-visibility.js.map +1 -1
  36. package/dist/access/index.d.ts +9 -2
  37. package/dist/access/index.d.ts.map +1 -1
  38. package/dist/access/index.js +11 -2
  39. package/dist/access/index.js.map +1 -1
  40. package/dist/access/multi-column-read-write.test.js.map +1 -1
  41. package/dist/access/query-validation.d.ts +31 -0
  42. package/dist/access/query-validation.d.ts.map +1 -1
  43. package/dist/access/query-validation.js +75 -16
  44. package/dist/access/query-validation.js.map +1 -1
  45. package/dist/access/relationship-count.d.ts +41 -3
  46. package/dist/access/relationship-count.d.ts.map +1 -1
  47. package/dist/access/relationship-count.js +44 -7
  48. package/dist/access/relationship-count.js.map +1 -1
  49. package/dist/access/relationship-count.test.js +62 -0
  50. package/dist/access/relationship-count.test.js.map +1 -1
  51. package/dist/access/synthetic-include-read.test.d.ts +2 -0
  52. package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
  53. package/dist/access/synthetic-include-read.test.js +121 -0
  54. package/dist/access/synthetic-include-read.test.js.map +1 -0
  55. package/dist/config/types.d.ts +98 -32
  56. package/dist/config/types.d.ts.map +1 -1
  57. package/dist/context/hook-pipeline.d.ts +2 -2
  58. package/dist/context/hook-pipeline.d.ts.map +1 -1
  59. package/dist/context/hook-pipeline.js.map +1 -1
  60. package/dist/context/index.d.ts +35 -2
  61. package/dist/context/index.d.ts.map +1 -1
  62. package/dist/context/index.js +121 -27
  63. package/dist/context/index.js.map +1 -1
  64. package/dist/context/nested-operations.d.ts +2 -2
  65. package/dist/context/nested-operations.d.ts.map +1 -1
  66. package/dist/context/nested-operations.js +74 -15
  67. package/dist/context/nested-operations.js.map +1 -1
  68. package/dist/context/write-pipeline.d.ts +3 -2
  69. package/dist/context/write-pipeline.d.ts.map +1 -1
  70. package/dist/context/write-pipeline.js +31 -34
  71. package/dist/context/write-pipeline.js.map +1 -1
  72. package/dist/fields/index.d.ts +13 -1
  73. package/dist/fields/index.d.ts.map +1 -1
  74. package/dist/fields/index.js +50 -5
  75. package/dist/fields/index.js.map +1 -1
  76. package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
  77. package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
  78. package/dist/fields/relationship-foreign-key.test.js +18 -0
  79. package/dist/fields/relationship-foreign-key.test.js.map +1 -0
  80. package/dist/hooks/index.d.ts +16 -15
  81. package/dist/hooks/index.d.ts.map +1 -1
  82. package/dist/hooks/index.js.map +1 -1
  83. package/dist/index.d.ts +4 -1
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +14 -1
  86. package/dist/index.js.map +1 -1
  87. package/dist/lib/case-utils.d.ts +15 -0
  88. package/dist/lib/case-utils.d.ts.map +1 -1
  89. package/dist/lib/case-utils.js +21 -0
  90. package/dist/lib/case-utils.js.map +1 -1
  91. package/dist/lib/case-utils.test.js +23 -1
  92. package/dist/lib/case-utils.test.js.map +1 -1
  93. package/dist/lib/prisma-errors.d.ts +38 -0
  94. package/dist/lib/prisma-errors.d.ts.map +1 -0
  95. package/dist/lib/prisma-errors.js +53 -0
  96. package/dist/lib/prisma-errors.js.map +1 -0
  97. package/dist/mcp/constants.d.ts +23 -0
  98. package/dist/mcp/constants.d.ts.map +1 -0
  99. package/dist/mcp/constants.js +23 -0
  100. package/dist/mcp/constants.js.map +1 -0
  101. package/dist/mcp/field-schema.d.ts +8 -0
  102. package/dist/mcp/field-schema.d.ts.map +1 -0
  103. package/dist/mcp/field-schema.js +69 -0
  104. package/dist/mcp/field-schema.js.map +1 -0
  105. package/dist/mcp/handler.d.ts.map +1 -1
  106. package/dist/mcp/handler.js +76 -90
  107. package/dist/mcp/handler.js.map +1 -1
  108. package/dist/mcp/projection.d.ts +62 -0
  109. package/dist/mcp/projection.d.ts.map +1 -0
  110. package/dist/mcp/projection.js +390 -0
  111. package/dist/mcp/projection.js.map +1 -0
  112. package/dist/utils/password.d.ts +3 -1
  113. package/dist/utils/password.d.ts.map +1 -1
  114. package/dist/utils/password.js +4 -2
  115. package/dist/utils/password.js.map +1 -1
  116. package/package.json +2 -2
  117. package/src/access/access-filter.test.ts +1528 -18
  118. package/src/access/access-filter.ts +708 -42
  119. package/src/access/denied-relation-visibility.test.ts +173 -0
  120. package/src/access/engine.test.ts +67 -0
  121. package/src/access/engine.ts +109 -1
  122. package/src/access/errors.ts +97 -2
  123. package/src/access/field-access.test.ts +78 -0
  124. package/src/access/field-access.ts +34 -2
  125. package/src/access/field-visibility.ts +171 -25
  126. package/src/access/index.ts +25 -0
  127. package/src/access/multi-column-read-write.test.ts +3 -2
  128. package/src/access/query-validation.ts +136 -9
  129. package/src/access/relationship-count.test.ts +75 -0
  130. package/src/access/relationship-count.ts +53 -10
  131. package/src/access/synthetic-include-read.test.ts +179 -0
  132. package/src/config/types.ts +98 -32
  133. package/src/context/hook-pipeline.ts +4 -2
  134. package/src/context/index.ts +203 -42
  135. package/src/context/nested-operations.ts +96 -27
  136. package/src/context/write-pipeline.ts +60 -36
  137. package/src/fields/index.ts +55 -5
  138. package/src/fields/relationship-foreign-key.test.ts +29 -0
  139. package/src/hooks/index.ts +22 -15
  140. package/src/index.ts +17 -1
  141. package/src/lib/case-utils.test.ts +32 -0
  142. package/src/lib/case-utils.ts +26 -0
  143. package/src/lib/prisma-errors.ts +86 -0
  144. package/src/mcp/constants.ts +24 -0
  145. package/src/mcp/field-schema.ts +84 -0
  146. package/src/mcp/handler.ts +109 -107
  147. package/src/mcp/projection.ts +497 -0
  148. package/src/utils/password.ts +5 -3
  149. package/tests/access-relationships.test.ts +3 -3
  150. package/tests/access.test.ts +83 -0
  151. package/tests/context.test.ts +452 -17
  152. package/tests/field-types.test.ts +113 -0
  153. package/tests/hook-context-secured.test.ts +424 -0
  154. package/tests/mcp-fields-projection-access.test.ts +250 -0
  155. package/tests/mcp-handler.test.ts +695 -4
  156. package/tests/nested-access-and-hooks.test.ts +389 -0
  157. package/tests/nested-write-synthetic-relation.test.ts +437 -0
  158. package/tests/password-types.test.ts +28 -0
  159. package/tests/password.test.ts +16 -2
  160. package/tests/prisma-errors.test.ts +141 -0
  161. package/tests/singleton.test.ts +9 -1
  162. package/tests/to-one-include-access-filter.test.ts +172 -0
  163. package/tests/with-session.test.ts +243 -0
  164. package/tests/write-pipeline.test.ts +29 -0
  165. package/tsconfig.tsbuildinfo +1 -1
@@ -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,
@@ -22,10 +23,11 @@ import { enumerateInvolvedLists, runWithTransactionBoundary } from './transactio
22
23
  import { TransactionRegistry } from '../access/transaction-registry.js'
23
24
  import { getDbKey } from '../lib/case-utils.js'
24
25
  // NOTE: `index.ts` imports from this module too — this is an intentional cyclic
25
- // dependency. It is safe because `buildDbDelegate` is only INVOKED at write
26
- // time (never during module evaluation), so by the time it runs the export is
27
- // fully initialised.
28
- import { buildDbDelegate } from './index.js'
26
+ // dependency. It is safe because `getContext` is only INVOKED at write time
27
+ // (never during module evaluation), so by the time it runs the export is fully
28
+ // initialised.
29
+ import { getContext } from './index.js'
30
+ import type { StackContext } from './index.js'
29
31
 
30
32
  /**
31
33
  * Write Pipeline — runs the canonical, secured write sequence for one
@@ -165,6 +167,22 @@ export interface WritePipelineArgs<TPrisma extends PrismaClientLike> {
165
167
  preResolvedTarget?: TargetResolution
166
168
  }
167
169
 
170
+ /**
171
+ * {@link WritePipelineArgs} narrowed to the in-transaction phase: `context` is
172
+ * the full {@link StackContext} `bindContextToTransaction` rebuilds (issue
173
+ * #1176), not the plain {@link AccessContext} the pre-transaction gate and the
174
+ * transaction-boundary hooks use. `StackContext` is a structural superset of
175
+ * `AccessContext` (it carries `_resolveOutputChain`/`_transactionOwner` too),
176
+ * so nothing downstream of {@link runWriteInTransaction} that only needs
177
+ * `AccessContext` requires any change.
178
+ */
179
+ type WriteInTransactionArgs<TPrisma extends PrismaClientLike> = Omit<
180
+ WritePipelineArgs<TPrisma>,
181
+ 'context'
182
+ > & {
183
+ context: StackContext<TPrisma>
184
+ }
185
+
168
186
  /**
169
187
  * Run the canonical secured write sequence once. Phase order matches the
170
188
  * "Write Pipeline" glossary entry in CONTEXT.md.
@@ -241,41 +259,44 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
241
259
  }
242
260
 
243
261
  /**
244
- * Build an {@link AccessContext} whose `db`/`prisma` target the transaction
245
- * client `tx`, so a `context.db` write a hook performs runs inside — and rolls
246
- * back with — this write's transaction (ADR-0010).
262
+ * Build the full {@link StackContext} a hook's `context` is (issue #1176):
263
+ * bound to the transaction client `tx`, so a `context.db` write a hook
264
+ * performs runs inside — and rolls back with — this write's transaction
265
+ * (ADR-0010), and carrying `sudo()`/`withSession()`/`transaction()` so a hook
266
+ * can reach an elevated or substituted read/write that stays on the SAME
267
+ * transaction client rather than escaping to the base one.
247
268
  *
248
- * The access-controlled `db` delegates capture their Prisma client at
249
- * construction, so swapping `context.prisma` alone would not rebind `db` — we
250
- * rebuild the delegates against `tx` via {@link buildDbDelegate}, reusing the
251
- * request context's `session`, `storage`, `plugins`, `_isSudo`, and
252
- * `_resolveOutputChain` as-is (so a write from inside a `resolveOutput` hook
253
- * keeps that hook's chain). Plugin runtimes are NOT re-executed.
269
+ * Goes through the same {@link getContext} factory `context.transaction()`
270
+ * already rebuilds through (ADR-0012), rather than hand-assembling a plain
271
+ * object literal reusing the request context's `session`, `storage`,
272
+ * `_isSudo`, `plugins` (as `_sharedPlugins`, so plugin `runtime()` is NOT
273
+ * re-executed on the rebind), and `_resolveOutputChain` (so a write issued
274
+ * from inside a `resolveOutput` hook keeps that hook's cycle-guard chain,
275
+ * ADR-0023) as-is.
254
276
  *
255
277
  * `transactionOwner` (ADR-0028) is carried onto the rebuilt context so a hook's
256
- * own `context.db` write defers its transaction-boundary bracket to that owner
257
- * instead of firing eagerly.
278
+ * own `context.db` write and any write reached through its `sudo()`/
279
+ * `withSession()` defers its transaction-boundary bracket to that owner
280
+ * instead of firing eagerly; `context.transaction()` called from a hook joins
281
+ * this same owner rather than opening a nested transaction, for the same
282
+ * reason.
258
283
  */
259
284
  function bindContextToTransaction<TPrisma extends PrismaClientLike>(
260
285
  args: WritePipelineArgs<TPrisma>,
261
286
  tx: TPrisma,
262
287
  transactionOwner: TransactionRegistry | undefined,
263
- ): AccessContext<TPrisma> {
288
+ ): StackContext<TPrisma> {
264
289
  const { context, config } = args
265
- const txContext: AccessContext<TPrisma> = {
266
- session: context.session,
267
- prisma: tx,
268
- db: context.db,
269
- storage: context.storage,
270
- plugins: context.plugins,
271
- _isSudo: context._isSudo,
272
- _resolveOutputChain: context._resolveOutputChain,
273
- _transactionOwner: transactionOwner,
274
- }
275
- // Rebuild `db` against `tx`, referencing `txContext` itself so hooks reached
276
- // through it see the transactional context.
277
- txContext.db = buildDbDelegate(config, tx, txContext)
278
- return txContext
290
+ return getContext(
291
+ config,
292
+ tx,
293
+ context.session,
294
+ context.storage,
295
+ context._isSudo,
296
+ context.plugins,
297
+ transactionOwner,
298
+ context._resolveOutputChain,
299
+ )
279
300
  }
280
301
 
281
302
  /**
@@ -285,7 +306,7 @@ function bindContextToTransaction<TPrisma extends PrismaClientLike>(
285
306
  * `runInTransaction` and rolls the transaction back.
286
307
  */
287
308
  async function runWriteInTransaction<TPrisma extends PrismaClientLike>(
288
- args: WritePipelineArgs<TPrisma>,
309
+ args: WriteInTransactionArgs<TPrisma>,
289
310
  ): Promise<Record<string, unknown> | null> {
290
311
  const { listName, listConfig, prisma: tx, context, config, inputData, strategy } = args
291
312
  const { operation } = strategy
@@ -330,6 +351,8 @@ async function runWriteInTransaction<TPrisma extends PrismaClientLike>(
330
351
  item: originalItem,
331
352
  context: { ...context, _isSudo: context._isSudo },
332
353
  inputData: input,
354
+ listName,
355
+ config,
333
356
  })
334
357
 
335
358
  // ── Phase 5.5: process nested relationship operations ───────────────────────
@@ -482,7 +505,7 @@ async function runDeletePath(args: {
482
505
  listName: string
483
506
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
484
507
  listConfig: ListConfig<any>
485
- context: AccessContext
508
+ context: StackContext
486
509
  originalItem: Record<string, unknown> | undefined
487
510
  model: PrismaModel
488
511
  strategy: WriteStrategy
@@ -560,8 +583,9 @@ async function runDeletePath(args: {
560
583
  * Create strategy for {@link WriteStrategy}.
561
584
  *
562
585
  * 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.
586
+ * throws `InvalidCreateAccessResultError` rather than proceeding unchecked
587
+ * create has no row to re-check a filter against, unlike update/delete
588
+ * (#1009). Enforces the singleton-create constraint even under sudo.
565
589
  * Axis 2: runs all input phases.
566
590
  * Axis 3: `model.create({ data })`, prepending `id: 1` for singleton lists.
567
591
  */
@@ -588,11 +612,11 @@ export function createWriteStrategy(
588
612
  }
589
613
 
590
614
  if (!context._isSudo) {
591
- const accessResult = await checkAccess(listConfig.access?.operation?.create, {
615
+ const allowed = await checkCreateAccess(listName, listConfig.access?.operation?.create, {
592
616
  session: context.session,
593
617
  context,
594
618
  })
595
- if (accessResult === false) {
619
+ if (!allowed) {
596
620
  return { status: 'denied' }
597
621
  }
598
622
  }
@@ -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
+ })
@@ -1,6 +1,13 @@
1
1
  import type { Hooks } from '../config/types.js'
2
2
  import type { AccessContext } from '../access/types.js'
3
3
  import type { FieldConfig } from '../config/types.js'
4
+ // #1176: resolveInput/validate/beforeOperation/afterOperation (list AND field
5
+ // level) receive the full secured `StackContext` — sudo()/withSession()/
6
+ // transaction() bound to the write's own transaction client — not the plain
7
+ // `AccessContext`. Transaction-boundary hooks (beforeTransaction/
8
+ // afterTransaction) are unaffected: they stay on `AccessContext`/the base
9
+ // client by ADR-0028 design.
10
+ import type { StackContext } from '../context/index.js'
4
11
  import { validateWithZod } from '../validation/schema.js'
5
12
  import { checkFieldAccess } from '../access/field-access.js'
6
13
 
@@ -42,7 +49,7 @@ export async function executeResolveInput<
42
49
  inputData: TCreateInput
43
50
  resolvedData: TCreateInput
44
51
  item: undefined
45
- context: AccessContext
52
+ context: StackContext
46
53
  }
47
54
  | {
48
55
  listKey: string
@@ -50,7 +57,7 @@ export async function executeResolveInput<
50
57
  inputData: TUpdateInput
51
58
  resolvedData: TUpdateInput
52
59
  item: TOutput
53
- context: AccessContext
60
+ context: StackContext
54
61
  },
55
62
  ): Promise<TCreateInput | TUpdateInput> {
56
63
  if (!hooks?.resolveInput) {
@@ -75,7 +82,7 @@ export async function executeValidate<
75
82
  inputData: TCreateInput
76
83
  resolvedData: TCreateInput
77
84
  item: undefined
78
- context: AccessContext
85
+ context: StackContext
79
86
  }
80
87
  | {
81
88
  listKey: string
@@ -83,13 +90,13 @@ export async function executeValidate<
83
90
  inputData: TUpdateInput
84
91
  resolvedData: TUpdateInput
85
92
  item: TOutput
86
- context: AccessContext
93
+ context: StackContext
87
94
  }
88
95
  | {
89
96
  listKey: string
90
97
  operation: 'delete'
91
98
  item: TOutput
92
- context: AccessContext
99
+ context: StackContext
93
100
  },
94
101
  ): Promise<void> {
95
102
  const validateHook = hooks?.validate || hooks?.validateInput
@@ -131,7 +138,7 @@ export async function executeBeforeOperation<
131
138
  operation: 'create'
132
139
  inputData: TCreateInput
133
140
  resolvedData: TCreateInput
134
- context: AccessContext
141
+ context: StackContext
135
142
  }
136
143
  | {
137
144
  listKey: string
@@ -139,13 +146,13 @@ export async function executeBeforeOperation<
139
146
  inputData: TUpdateInput
140
147
  item: TOutput
141
148
  resolvedData: TUpdateInput
142
- context: AccessContext
149
+ context: StackContext
143
150
  }
144
151
  | {
145
152
  listKey: string
146
153
  operation: 'delete'
147
154
  item: TOutput
148
- context: AccessContext
155
+ context: StackContext
149
156
  },
150
157
  ): Promise<void> {
151
158
  if (!hooks?.beforeOperation) {
@@ -168,7 +175,7 @@ export async function executeAfterOperation<
168
175
  inputData: TCreateInput
169
176
  item: TOutput
170
177
  resolvedData: TCreateInput
171
- context: AccessContext
178
+ context: StackContext
172
179
  }
173
180
  | {
174
181
  listKey: string
@@ -177,13 +184,13 @@ export async function executeAfterOperation<
177
184
  originalItem: TOutput
178
185
  item: TOutput
179
186
  resolvedData: TUpdateInput
180
- context: AccessContext
187
+ context: StackContext
181
188
  }
182
189
  | {
183
190
  listKey: string
184
191
  operation: 'delete'
185
192
  originalItem: TOutput
186
- context: AccessContext
193
+ context: StackContext
187
194
  },
188
195
  ): Promise<void> {
189
196
  if (!hooks?.afterOperation) {
@@ -472,7 +479,7 @@ export async function executeFieldResolveInputHooks(
472
479
  resolvedData: Record<string, any>,
473
480
  fields: Record<string, FieldConfig>,
474
481
  operation: 'create' | 'update',
475
- context: AccessContext,
482
+ context: StackContext,
476
483
  listKey: string,
477
484
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
478
485
  item?: any,
@@ -575,7 +582,7 @@ export async function executeFieldValidateHooks(
575
582
  resolvedData: Record<string, any> | undefined,
576
583
  fields: Record<string, FieldConfig>,
577
584
  operation: 'create' | 'update' | 'delete',
578
- context: AccessContext,
585
+ context: StackContext,
579
586
  listKey: string,
580
587
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
581
588
  item?: any,
@@ -640,7 +647,7 @@ export async function executeFieldBeforeOperationHooks(
640
647
  resolvedData: Record<string, any>,
641
648
  fields: Record<string, FieldConfig>,
642
649
  operation: 'create' | 'update' | 'delete',
643
- context: AccessContext,
650
+ context: StackContext,
644
651
  listKey: string,
645
652
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
646
653
  item?: any,
@@ -687,7 +694,7 @@ export async function executeFieldAfterOperationHooks(
687
694
  resolvedData: Record<string, unknown> | undefined,
688
695
  fields: Record<string, FieldConfig>,
689
696
  operation: 'create' | 'update' | 'delete',
690
- context: AccessContext,
697
+ context: StackContext,
691
698
  listKey: string,
692
699
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
693
700
  originalItem?: any,
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
+ }