@opensaas/stack-core 0.24.0 → 0.26.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 (91) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +263 -0
  3. package/CLAUDE.md +50 -0
  4. package/dist/access/access-filter.d.ts +39 -0
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +121 -0
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/field-access.d.ts +1 -0
  9. package/dist/access/field-access.d.ts.map +1 -1
  10. package/dist/access/field-access.js +79 -4
  11. package/dist/access/field-access.js.map +1 -1
  12. package/dist/access/field-access.test.js +213 -0
  13. package/dist/access/field-access.test.js.map +1 -1
  14. package/dist/access/index.d.ts +1 -1
  15. package/dist/access/index.d.ts.map +1 -1
  16. package/dist/access/index.js +1 -1
  17. package/dist/access/index.js.map +1 -1
  18. package/dist/access/types.d.ts +39 -0
  19. package/dist/access/types.d.ts.map +1 -1
  20. package/dist/config/types.d.ts +318 -0
  21. package/dist/config/types.d.ts.map +1 -1
  22. package/dist/context/apply-defaults.d.ts +36 -0
  23. package/dist/context/apply-defaults.d.ts.map +1 -0
  24. package/dist/context/apply-defaults.js +70 -0
  25. package/dist/context/apply-defaults.js.map +1 -0
  26. package/dist/context/hook-pipeline.d.ts.map +1 -1
  27. package/dist/context/hook-pipeline.js +10 -0
  28. package/dist/context/hook-pipeline.js.map +1 -1
  29. package/dist/context/index.d.ts +79 -18
  30. package/dist/context/index.d.ts.map +1 -1
  31. package/dist/context/index.js +194 -39
  32. package/dist/context/index.js.map +1 -1
  33. package/dist/context/nested-operations.d.ts +59 -3
  34. package/dist/context/nested-operations.d.ts.map +1 -1
  35. package/dist/context/nested-operations.js +558 -129
  36. package/dist/context/nested-operations.js.map +1 -1
  37. package/dist/context/transaction-boundary.d.ts +91 -0
  38. package/dist/context/transaction-boundary.d.ts.map +1 -0
  39. package/dist/context/transaction-boundary.js +329 -0
  40. package/dist/context/transaction-boundary.js.map +1 -0
  41. package/dist/context/write-pipeline.d.ts +15 -1
  42. package/dist/context/write-pipeline.d.ts.map +1 -1
  43. package/dist/context/write-pipeline.js +173 -10
  44. package/dist/context/write-pipeline.js.map +1 -1
  45. package/dist/fields/calendar-day.test.d.ts +2 -0
  46. package/dist/fields/calendar-day.test.d.ts.map +1 -0
  47. package/dist/fields/calendar-day.test.js +120 -0
  48. package/dist/fields/calendar-day.test.js.map +1 -0
  49. package/dist/fields/index.d.ts +18 -2
  50. package/dist/fields/index.d.ts.map +1 -1
  51. package/dist/fields/index.js +93 -17
  52. package/dist/fields/index.js.map +1 -1
  53. package/dist/hooks/index.d.ts +116 -0
  54. package/dist/hooks/index.d.ts.map +1 -1
  55. package/dist/hooks/index.js +154 -0
  56. package/dist/hooks/index.js.map +1 -1
  57. package/dist/index.d.ts +1 -0
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js.map +1 -1
  60. package/dist/validation/schema.test.js +222 -1
  61. package/dist/validation/schema.test.js.map +1 -1
  62. package/package.json +1 -1
  63. package/src/access/access-filter.ts +156 -0
  64. package/src/access/field-access.test.ts +255 -0
  65. package/src/access/field-access.ts +91 -5
  66. package/src/access/index.ts +1 -1
  67. package/src/access/types.ts +45 -0
  68. package/src/config/types.ts +364 -0
  69. package/src/context/apply-defaults.ts +79 -0
  70. package/src/context/hook-pipeline.ts +11 -0
  71. package/src/context/index.ts +340 -68
  72. package/src/context/nested-operations.ts +976 -143
  73. package/src/context/transaction-boundary.ts +440 -0
  74. package/src/context/write-pipeline.ts +234 -13
  75. package/src/fields/calendar-day.test.ts +140 -0
  76. package/src/fields/index.ts +96 -16
  77. package/src/hooks/index.ts +265 -0
  78. package/src/index.ts +5 -0
  79. package/src/validation/schema.test.ts +266 -1
  80. package/tests/access.test.ts +24 -16
  81. package/tests/apply-defaults.test.ts +119 -0
  82. package/tests/context.test.ts +481 -0
  83. package/tests/default-value-create.test.ts +299 -0
  84. package/tests/field-types.test.ts +17 -3
  85. package/tests/interactive-transaction.test.ts +444 -0
  86. package/tests/nested-access-and-hooks.test.ts +1130 -54
  87. package/tests/nested-operation-registry.test.ts +28 -3
  88. package/tests/nested-write-hooks.test.ts +864 -0
  89. package/tests/sudo.test.ts +7 -3
  90. package/tests/transaction-boundary-hooks.test.ts +465 -0
  91. package/tsconfig.tsbuildinfo +1 -1
@@ -5,6 +5,7 @@ import {
5
5
  mergeFilters,
6
6
  filterReadableFields,
7
7
  buildIncludeWithAccessControl,
8
+ mergeIncludeWithAccessControl,
8
9
  } from '../access/index.js'
9
10
  import { ValidationError, DatabaseError } from '../hooks/index.js'
10
11
  import { getDbKey } from '../lib/case-utils.js'
@@ -65,6 +66,72 @@ function isSingletonList(listConfig: ListConfig<any>): boolean {
65
66
  return !!listConfig.isSingleton
66
67
  }
67
68
 
69
+ /**
70
+ * Compute the set of single-field unique selectors a `findUnique` `where` may be
71
+ * keyed by, derived from what the list config exposes at runtime.
72
+ *
73
+ * The set is:
74
+ * - `id` — always a unique identifier on every list.
75
+ * - Any field declared `isIndexed: 'unique'` in the config (e.g. `text({ isIndexed: 'unique' })`).
76
+ * - For a `relationship` field declared `isIndexed: 'unique'`, the foreign-key
77
+ * column name (`<field>Id`) — that is the column Prisma marks `@unique`, so the
78
+ * unique `where` is keyed by `<field>Id`, not the relation field itself.
79
+ *
80
+ * Chosen rule (documented intentionally): the config does NOT expose compound
81
+ * (`@@unique`) keys at runtime — there is no list-level unique declaration in the
82
+ * config API — so we cannot validate compound `<Model>_<a>_<b>` selectors. We
83
+ * therefore enforce the tractable subset: `where` must contain EXACTLY ONE
84
+ * recognised single-field unique key and NO other keys. This rejects non-unique
85
+ * filters (the bug in #567) and rejects extra non-unique keys alongside a unique
86
+ * one, while never falsely rejecting a valid single-field unique lookup. If a
87
+ * project legitimately needs a compound-unique lookup, that path is not covered
88
+ * here and would need explicit config support; the safe escape hatch for any
89
+ * non-unique single-row lookup is `findFirst` (see #565).
90
+ */
91
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
92
+ function getUniqueWhereKeys(listConfig: ListConfig<any>): Set<string> {
93
+ const keys = new Set<string>(['id'])
94
+
95
+ for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
96
+ if (!fieldConfig || typeof fieldConfig !== 'object') continue
97
+ if (!('isIndexed' in fieldConfig) || fieldConfig.isIndexed !== 'unique') continue
98
+
99
+ if (fieldConfig.type === 'relationship') {
100
+ // A unique relationship's `@unique` lives on the FK column `<field>Id`.
101
+ keys.add(`${fieldKey}Id`)
102
+ } else {
103
+ keys.add(fieldKey)
104
+ }
105
+ }
106
+
107
+ return keys
108
+ }
109
+
110
+ /**
111
+ * Enforce Keystone `findOne` semantics for `findUnique`: the caller-supplied
112
+ * `where` must be a valid unique selector. A non-unique `where` is a caller-shape
113
+ * error (not an access denial), so this THROWS rather than silently returning
114
+ * `null` — consistent with the fail-loud-on-misuse stance of PRD #581. A
115
+ * non-unique single-row lookup should use `findFirst` instead (see #565).
116
+ */
117
+ function assertUniqueWhere(
118
+ where: Record<string, unknown> | undefined,
119
+ uniqueKeys: Set<string>,
120
+ listName: string,
121
+ ): void {
122
+ const keys = where ? Object.keys(where) : []
123
+
124
+ const message =
125
+ `findUnique on "${listName}" requires a unique \`where\` (a single unique key such as ` +
126
+ `${Array.from(uniqueKeys).join(', ')}). ` +
127
+ `Received: ${keys.length === 0 ? '{}' : `{ ${keys.join(', ')} }`}. ` +
128
+ `Use \`findFirst\` for a non-unique single-row lookup.`
129
+
130
+ if (keys.length !== 1 || !uniqueKeys.has(keys[0])) {
131
+ throw new ValidationError([message], {})
132
+ }
133
+ }
134
+
68
135
  /**
69
136
  * Check if auto-create is enabled for a singleton list
70
137
  * Defaults to true if not explicitly set to false
@@ -163,6 +230,91 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
163
230
  return new Error('An unknown error occurred')
164
231
  }
165
232
 
233
+ /**
234
+ * Database transaction isolation levels.
235
+ *
236
+ * Mirrors Prisma's `TransactionIsolationLevel`. The level passed to
237
+ * {@link StackContext.transaction} is forwarded to the underlying interactive
238
+ * transaction; provider support varies (e.g. `Serializable` is supported by
239
+ * PostgreSQL — required for the concurrency-sensitive capacity-gate pattern).
240
+ */
241
+ export type TransactionIsolationLevel =
242
+ | 'ReadUncommitted'
243
+ | 'ReadCommitted'
244
+ | 'RepeatableRead'
245
+ | 'Serializable'
246
+ | 'Snapshot'
247
+
248
+ /**
249
+ * Options for {@link StackContext.transaction}, forwarded verbatim to the
250
+ * underlying Prisma interactive transaction.
251
+ */
252
+ export interface TransactionOptions {
253
+ /** Max ms to wait to acquire a transaction from the pool. */
254
+ maxWait?: number
255
+ /** Max ms the interactive transaction may run before timing out. */
256
+ timeout?: number
257
+ /** Isolation level for the transaction (e.g. `'Serializable'`). */
258
+ isolationLevel?: TransactionIsolationLevel
259
+ }
260
+
261
+ /**
262
+ * Minimal shape of a Prisma client that can open an interactive transaction.
263
+ * A Prisma transaction client (the `tx` handed to the callback) intentionally
264
+ * does NOT expose `$transaction`, which is how nested writes detect they are
265
+ * already inside a transaction and join it rather than opening another.
266
+ */
267
+ interface TransactionCapable<TPrisma> {
268
+ $transaction?: (
269
+ fn: (tx: TPrisma) => Promise<unknown>,
270
+ options?: TransactionOptions,
271
+ ) => Promise<unknown>
272
+ }
273
+
274
+ /**
275
+ * The access-controlled context returned by {@link getContext}.
276
+ *
277
+ * Exposes the secured `db` delegate plus the session, raw `prisma`, storage,
278
+ * plugin services, the generic `serverAction` handler, `sudo()` (bypasses access
279
+ * control but still runs hooks), and `transaction()` (interactive, hook-firing
280
+ * transaction). All access-checked operations run their list/field hooks.
281
+ */
282
+ export interface StackContext<TPrisma extends PrismaClientLike = PrismaClientLike> {
283
+ db: AccessControlledDB<TPrisma>
284
+ session: Session | null
285
+ prisma: TPrisma
286
+ storage: StorageUtils
287
+ plugins: Record<string, unknown>
288
+ serverAction: (props: ServerActionProps) => Promise<unknown>
289
+ /**
290
+ * Run `fn` inside ONE interactive transaction. The `txContext` handed to `fn`
291
+ * is a full {@link StackContext} whose `db.*` operations are access-checked and
292
+ * hook-firing (identical to this context) but persist against the transaction
293
+ * client, so every write in the callback is atomic — a throw anywhere rolls the
294
+ * whole transaction back.
295
+ *
296
+ * `options` (notably `isolationLevel`) is forwarded to the underlying Prisma
297
+ * transaction. Serialization failures (e.g. Prisma `P2034`) propagate to the
298
+ * caller rather than being swallowed, so the caller can own a retry loop. If
299
+ * the client cannot open an interactive transaction (e.g. a plain mock, or we
300
+ * are already inside a transaction), `fn` runs directly against the current
301
+ * client with identical hook/access semantics.
302
+ *
303
+ * Caveat: plugin runtime services (`txContext.plugins`) stay bound to the
304
+ * top-level (non-transaction) client — they are shared services initialised
305
+ * once per request. Reads through a plugin service therefore won't see this
306
+ * transaction's uncommitted writes, and a plugin service that WRITES would
307
+ * escape the transaction and survive a rollback. Use `txContext.db` (not a
308
+ * plugin service) for writes that must be atomic with the transaction.
309
+ */
310
+ transaction: <T>(
311
+ fn: (txContext: StackContext<TPrisma>) => Promise<T>,
312
+ options?: TransactionOptions,
313
+ ) => Promise<T>
314
+ sudo: () => StackContext<TPrisma>
315
+ _isSudo: boolean
316
+ }
317
+
166
318
  /**
167
319
  * Create an access-controlled context
168
320
  *
@@ -180,25 +332,10 @@ export function getContext<
180
332
  session: Session | null,
181
333
  storage?: StorageUtils,
182
334
  _isSudo: boolean = false,
183
- ): {
184
- db: AccessControlledDB<TPrisma>
185
- session: Session | null
186
- prisma: TPrisma
187
- storage: StorageUtils
188
- plugins: Record<string, unknown>
189
- serverAction: (props: ServerActionProps) => Promise<unknown>
190
- _isSudo: boolean
191
- sudo: () => {
192
- db: AccessControlledDB<TPrisma>
193
- session: Session | null
194
- prisma: TPrisma
195
- storage: StorageUtils
196
- plugins: Record<string, unknown>
197
- serverAction: (props: ServerActionProps) => Promise<unknown>
198
- sudo: () => unknown
199
- _isSudo: boolean
200
- }
201
- } {
335
+ // Internal: when rebuilding the context against a transaction client, reuse the
336
+ // already-initialised plugin services rather than re-running plugin runtimes.
337
+ _sharedPlugins?: Record<string, unknown>,
338
+ ): StackContext<TPrisma> {
202
339
  // Initialize db object - will be populated with access-controlled operations
203
340
  // Type is intentionally broad to allow dynamic model access
204
341
  const db: Record<string, unknown> = {}
@@ -231,56 +368,30 @@ export function getContext<
231
368
  )
232
369
  },
233
370
  },
234
- plugins: {}, // Will be populated with plugin runtime services
371
+ // Reuse already-initialised plugin services when rebinding to a transaction
372
+ // client, otherwise start empty and populate via plugin runtimes below.
373
+ plugins: _sharedPlugins ?? {},
235
374
  _isSudo,
236
375
  _resolveOutputCounter: { depth: 0 },
237
376
  }
238
377
 
239
- // Create access-controlled operations for each list
240
- for (const [listName, listConfig] of Object.entries(config.lists)) {
241
- const dbKey = getDbKey(listName)
378
+ // Create access-controlled operations for each list, populating `db` in place.
379
+ populateDbDelegate(db, config, prisma, context)
242
380
 
243
- // Create base operations
244
- const createOp = createCreate(listName, listConfig, prisma, context, config)
245
- const findManyOp = createFindMany(listName, listConfig, prisma, context, config)
246
- const updateOp = createUpdate(listName, listConfig, prisma, context, config)
247
- const operations: Record<string, unknown> = {
248
- findUnique: createFindUnique(listName, listConfig, prisma, context, config),
249
- findMany: findManyOp,
250
- create: createOp,
251
- update: updateOp,
252
- delete: createDelete(listName, listConfig, prisma, context, config),
253
- count: createCount(listName, listConfig, prisma, context),
254
- createMany: createCreateMany(listName, listConfig, prisma, context, config, createOp),
255
- updateMany: createUpdateMany(
256
- listName,
257
- listConfig,
258
- prisma,
259
- context,
260
- config,
261
- findManyOp,
262
- updateOp,
263
- ),
264
- }
265
-
266
- // Add get() method for singleton lists
267
- if (isSingletonList(listConfig)) {
268
- operations.get = createGet(listName, listConfig, prisma, context, config, createOp)
269
- }
270
-
271
- db[dbKey] = operations
272
- }
273
-
274
- // Execute plugin runtime functions and populate context.plugins
381
+ // Execute plugin runtime functions and populate context.plugins.
382
+ // Skipped when reusing shared plugins (transaction rebind) so runtimes — and
383
+ // any side effects they carry — run exactly once per top-level context.
275
384
  // Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
276
- const pluginsToExecute = config._plugins || config.plugins || []
277
- for (const plugin of pluginsToExecute) {
278
- if (plugin.runtime) {
279
- try {
280
- context.plugins[plugin.name] = plugin.runtime(context)
281
- } catch (error) {
282
- console.error(`Error executing runtime for plugin "${plugin.name}":`, error)
283
- // Continue with other plugins even if one fails
385
+ if (!_sharedPlugins) {
386
+ const pluginsToExecute = config._plugins || config.plugins || []
387
+ for (const plugin of pluginsToExecute) {
388
+ if (plugin.runtime) {
389
+ try {
390
+ context.plugins[plugin.name] = plugin.runtime(context)
391
+ } catch (error) {
392
+ console.error(`Error executing runtime for plugin "${plugin.name}":`, error)
393
+ // Continue with other plugins even if one fails
394
+ }
284
395
  }
285
396
  }
286
397
  }
@@ -376,11 +487,35 @@ export function getContext<
376
487
 
377
488
  // Sudo function - creates a new context that bypasses access control
378
489
  // but still executes all hooks and validation
379
- function sudo() {
490
+ function sudo(): StackContext<TPrisma> {
380
491
  return getContext(config, prisma, session, context.storage, true)
381
492
  }
382
493
 
383
- return {
494
+ // Interactive, hook-firing transaction (#614). Rebinds the access-controlled
495
+ // context to the transaction client so every `txContext.db.*` write runs its
496
+ // access checks + hooks but persists inside ONE transaction (atomic). The
497
+ // transaction `options` (e.g. `isolationLevel`) pass through to Prisma, and a
498
+ // serialization failure thrown inside the callback propagates to the caller
499
+ // for retry (it is never converted to a silent `null`).
500
+ function transaction<T>(
501
+ fn: (txContext: StackContext<TPrisma>) => Promise<T>,
502
+ options?: TransactionOptions,
503
+ ): Promise<T> {
504
+ const client = prisma as unknown as TransactionCapable<TPrisma>
505
+ if (typeof client.$transaction !== 'function') {
506
+ // No interactive transaction available — either a plain client/mock or we
507
+ // are already inside a transaction (a Prisma tx client exposes no
508
+ // `$transaction`). Run directly: hook/access semantics are identical and
509
+ // atomicity is provided by any enclosing transaction.
510
+ return fn(returned)
511
+ }
512
+ return client.$transaction(
513
+ (tx) => fn(getContext(config, tx, session, context.storage, _isSudo, context.plugins)),
514
+ options,
515
+ ) as Promise<T>
516
+ }
517
+
518
+ const returned: StackContext<TPrisma> = {
384
519
  db: db as AccessControlledDB<TPrisma>,
385
520
  session,
386
521
  prisma,
@@ -388,8 +523,78 @@ export function getContext<
388
523
  plugins: context.plugins,
389
524
  serverAction,
390
525
  sudo,
526
+ transaction,
391
527
  _isSudo,
392
528
  }
529
+ return returned
530
+ }
531
+
532
+ /**
533
+ * Populate `target` with the access-controlled CRUD operations for every list,
534
+ * each bound to `prisma` and `context`. Used both by {@link getContext} (at
535
+ * request setup) and by the Write Pipeline to rebuild a `db` delegate against a
536
+ * transaction client (ADR-0010), so a hook's `context.db` write participates in
537
+ * the same transaction.
538
+ *
539
+ * The operations capture `prisma` at construction, so rebinding to a different
540
+ * client (e.g. a transaction `tx`) requires rebuilding the delegate — which is
541
+ * exactly what this function enables.
542
+ */
543
+ export function populateDbDelegate<TPrisma extends PrismaClientLike>(
544
+ target: Record<string, unknown>,
545
+ config: OpenSaasConfig,
546
+ prisma: TPrisma,
547
+ context: AccessContext<TPrisma>,
548
+ ): void {
549
+ for (const [listName, listConfig] of Object.entries(config.lists)) {
550
+ const dbKey = getDbKey(listName)
551
+
552
+ // Create base operations
553
+ const createOp = createCreate(listName, listConfig, prisma, context, config)
554
+ const findManyOp = createFindMany(listName, listConfig, prisma, context, config)
555
+ const updateOp = createUpdate(listName, listConfig, prisma, context, config)
556
+ const operations: Record<string, unknown> = {
557
+ findUnique: createFindUnique(listName, listConfig, prisma, context, config),
558
+ findMany: findManyOp,
559
+ findFirst: createFindFirst(findManyOp),
560
+ create: createOp,
561
+ update: updateOp,
562
+ delete: createDelete(listName, listConfig, prisma, context, config),
563
+ count: createCount(listName, listConfig, prisma, context),
564
+ createMany: createCreateMany(listName, listConfig, prisma, context, config, createOp),
565
+ updateMany: createUpdateMany(
566
+ listName,
567
+ listConfig,
568
+ prisma,
569
+ context,
570
+ config,
571
+ findManyOp,
572
+ updateOp,
573
+ ),
574
+ }
575
+
576
+ // Add get() method for singleton lists
577
+ if (isSingletonList(listConfig)) {
578
+ operations.get = createGet(listName, listConfig, prisma, context, config, createOp)
579
+ }
580
+
581
+ target[dbKey] = operations
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Build a fresh access-controlled `db` delegate bound to `prisma` and `context`.
587
+ * Convenience wrapper over {@link populateDbDelegate} returning a new object,
588
+ * used by the Write Pipeline to rebind `db` to a transaction client.
589
+ */
590
+ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
591
+ config: OpenSaasConfig,
592
+ prisma: TPrisma,
593
+ context: AccessContext<TPrisma>,
594
+ ): AccessControlledDB<TPrisma> {
595
+ const db: Record<string, unknown> = {}
596
+ populateDbDelegate(db, config, prisma, context)
597
+ return db as AccessControlledDB<TPrisma>
393
598
  }
394
599
 
395
600
  /**
@@ -404,7 +609,10 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
404
609
  config: OpenSaasConfig,
405
610
  ) {
406
611
  return async (args: {
407
- where: { id: string }
612
+ // Accepts any unique selector at the delegate level (the generated
613
+ // `<List>FindUniqueArgs` type narrows `where` to Prisma's `WhereUniqueInput`).
614
+ // The runtime guard below rejects non-unique shapes.
615
+ where: Record<string, unknown>
408
616
  include?: Record<string, unknown>
409
617
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
410
618
  query?: any
@@ -414,6 +622,15 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
414
622
  // `select` is a visible no-op: warn, then proceed with include/query narrowing.
415
623
  warnIfSelectIgnored(args, listName, 'findUnique')
416
624
 
625
+ // Enforce unique-`where` (Keystone `findOne` parity). This is a caller-shape
626
+ // check independent of access, so it runs first and THROWS on misuse — it is
627
+ // not an access denial and must not be masked as a silent `null`. The
628
+ // type-level constraint already lives on the generated delegate: the custom
629
+ // `<List>FindUniqueArgs` only Omits `select`/`include` from
630
+ // `Prisma.<List>FindUniqueArgs`, so its `where` stays Prisma's
631
+ // `<List>WhereUniqueInput` — this runtime guard backstops untyped callers.
632
+ assertUniqueWhere(args.where, getUniqueWhereKeys(listConfig), listName)
633
+
417
634
  // Check query access (skip if sudo mode)
418
635
  let where: Record<string, unknown> = args.where
419
636
  if (!context._isSudo) {
@@ -443,6 +660,10 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
443
660
 
444
661
  if (fragment) {
445
662
  include = buildInclude(fragment._fields) ?? undefined
663
+ } else if (context._isSudo) {
664
+ // Sudo bypasses access control entirely — the caller's include is trusted
665
+ // and used as-is (matching the prior behaviour); no per-relation filtering.
666
+ include = args.include
446
667
  } else {
447
668
  // Build include with access control filters
448
669
  const accessControlledInclude = await buildIncludeWithAccessControl(
@@ -453,7 +674,18 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
453
674
  },
454
675
  config,
455
676
  )
456
- include = args.include || accessControlledInclude
677
+ // MERGE (not replace) a caller-supplied include with the access-controlled
678
+ // include: the caller selects WHICH relations to fetch, access control
679
+ // decides WHETHER and WITH WHAT filter (#566). A bare auto-include (no
680
+ // caller include) still uses the access-controlled include directly.
681
+ include = args.include
682
+ ? mergeIncludeWithAccessControl(
683
+ args.include,
684
+ accessControlledInclude,
685
+ listConfig.fields,
686
+ config,
687
+ )
688
+ : accessControlledInclude
457
689
  }
458
690
 
459
691
  // Execute query with optimized includes
@@ -551,6 +783,10 @@ function createFindMany<TPrisma extends PrismaClientLike>(
551
783
  let include: Record<string, unknown> | undefined
552
784
  if (fragment) {
553
785
  include = buildInclude(fragment._fields) ?? undefined
786
+ } else if (context._isSudo) {
787
+ // Sudo bypasses access control entirely — the caller's include is trusted
788
+ // and used as-is (matching the prior behaviour); no per-relation filtering.
789
+ include = args?.include
554
790
  } else {
555
791
  // Build include with access control filters
556
792
  const accessControlledInclude = await buildIncludeWithAccessControl(
@@ -561,7 +797,18 @@ function createFindMany<TPrisma extends PrismaClientLike>(
561
797
  },
562
798
  config,
563
799
  )
564
- include = args?.include || accessControlledInclude
800
+ // MERGE (not replace) a caller-supplied include with the access-controlled
801
+ // include: the caller selects WHICH relations to fetch, access control
802
+ // decides WHETHER and WITH WHAT filter (#566). A bare auto-include (no
803
+ // caller include) still uses the access-controlled include directly.
804
+ include = args?.include
805
+ ? mergeIncludeWithAccessControl(
806
+ args.include,
807
+ accessControlledInclude,
808
+ listConfig.fields,
809
+ config,
810
+ )
811
+ : accessControlledInclude
565
812
  }
566
813
 
567
814
  // Execute query with optimized includes
@@ -603,6 +850,31 @@ function createFindMany<TPrisma extends PrismaClientLike>(
603
850
  }
604
851
  }
605
852
 
853
+ /**
854
+ * Create findFirst operation with access control.
855
+ *
856
+ * findFirst is sugar over the access-controlled findMany: it runs the exact same
857
+ * query-access checks and access-controlled include building as findMany, then
858
+ * returns the first matching row (or null when nothing matches). This introduces
859
+ * no new access surface — it inherits findMany's silent-failure contract (an
860
+ * access-denied query yields `[]`, which becomes `null` here).
861
+ */
862
+ function createFindFirst(findManyOp: ReturnType<typeof createFindMany>) {
863
+ return async (args?: {
864
+ where?: Record<string, unknown>
865
+ orderBy?: Record<string, 'asc' | 'desc'> | Array<Record<string, 'asc' | 'desc'>>
866
+ skip?: number
867
+ include?: Record<string, unknown>
868
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
869
+ query?: any
870
+ // `select` is not honoured — accepted only so the no-op can be made visible.
871
+ select?: Record<string, unknown>
872
+ }) => {
873
+ const result = await findManyOp({ ...args, take: 1 })
874
+ return result[0] ?? null
875
+ }
876
+ }
877
+
606
878
  /**
607
879
  * Create create operation with access control and hooks
608
880
  */