@opensaas/stack-core 0.20.0 → 0.21.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 (105) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +74 -0
  3. package/CLAUDE.md +18 -2
  4. package/dist/access/access-filter.d.ts +29 -0
  5. package/dist/access/access-filter.d.ts.map +1 -0
  6. package/dist/access/access-filter.js +68 -0
  7. package/dist/access/access-filter.js.map +1 -0
  8. package/dist/access/engine.d.ts +15 -48
  9. package/dist/access/engine.d.ts.map +1 -1
  10. package/dist/access/engine.js +14 -280
  11. package/dist/access/engine.js.map +1 -1
  12. package/dist/access/field-access.d.ts +44 -0
  13. package/dist/access/field-access.d.ts.map +1 -0
  14. package/dist/access/field-access.js +123 -0
  15. package/dist/access/field-access.js.map +1 -0
  16. package/dist/access/field-access.test.d.ts +2 -0
  17. package/dist/access/field-access.test.d.ts.map +1 -0
  18. package/dist/access/{engine.test.js → field-access.test.js} +2 -2
  19. package/dist/access/field-access.test.js.map +1 -0
  20. package/dist/access/field-visibility.d.ts +13 -0
  21. package/dist/access/field-visibility.d.ts.map +1 -0
  22. package/dist/access/field-visibility.js +155 -0
  23. package/dist/access/field-visibility.js.map +1 -0
  24. package/dist/access/index.d.ts +4 -1
  25. package/dist/access/index.d.ts.map +1 -1
  26. package/dist/access/index.js +8 -1
  27. package/dist/access/index.js.map +1 -1
  28. package/dist/config/index.d.ts +1 -1
  29. package/dist/config/index.d.ts.map +1 -1
  30. package/dist/config/types.d.ts +45 -4
  31. package/dist/config/types.d.ts.map +1 -1
  32. package/dist/context/hook-pipeline.d.ts +49 -0
  33. package/dist/context/hook-pipeline.d.ts.map +1 -0
  34. package/dist/context/hook-pipeline.js +75 -0
  35. package/dist/context/hook-pipeline.js.map +1 -0
  36. package/dist/context/index.d.ts.map +1 -1
  37. package/dist/context/index.js +30 -462
  38. package/dist/context/index.js.map +1 -1
  39. package/dist/context/nested-operations.d.ts.map +1 -1
  40. package/dist/context/nested-operations.js +72 -68
  41. package/dist/context/nested-operations.js.map +1 -1
  42. package/dist/context/write-pipeline.d.ts +158 -0
  43. package/dist/context/write-pipeline.d.ts.map +1 -0
  44. package/dist/context/write-pipeline.js +306 -0
  45. package/dist/context/write-pipeline.js.map +1 -0
  46. package/dist/extend.d.ts +3 -0
  47. package/dist/extend.d.ts.map +1 -0
  48. package/dist/extend.js +10 -0
  49. package/dist/extend.js.map +1 -0
  50. package/dist/fields/index.d.ts +1 -0
  51. package/dist/fields/index.d.ts.map +1 -1
  52. package/dist/fields/index.js +213 -2
  53. package/dist/fields/index.js.map +1 -1
  54. package/dist/hooks/index.d.ts +20 -0
  55. package/dist/hooks/index.d.ts.map +1 -1
  56. package/dist/hooks/index.js +202 -0
  57. package/dist/hooks/index.js.map +1 -1
  58. package/dist/index.d.ts +5 -9
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +19 -10
  61. package/dist/index.js.map +1 -1
  62. package/dist/internal.d.ts +8 -0
  63. package/dist/internal.d.ts.map +1 -0
  64. package/dist/internal.js +16 -0
  65. package/dist/internal.js.map +1 -0
  66. package/dist/validation/field-config.d.ts +55 -0
  67. package/dist/validation/field-config.d.ts.map +1 -0
  68. package/dist/validation/field-config.js +100 -0
  69. package/dist/validation/field-config.js.map +1 -0
  70. package/dist/validation/field-config.test.d.ts +2 -0
  71. package/dist/validation/field-config.test.d.ts.map +1 -0
  72. package/dist/validation/field-config.test.js +159 -0
  73. package/dist/validation/field-config.test.js.map +1 -0
  74. package/package.json +12 -4
  75. package/src/access/access-filter.ts +97 -0
  76. package/src/access/engine.ts +13 -396
  77. package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
  78. package/src/access/field-access.ts +159 -0
  79. package/src/access/field-visibility.ts +247 -0
  80. package/src/access/index.ts +7 -4
  81. package/src/config/index.ts +1 -0
  82. package/src/config/types.ts +51 -4
  83. package/src/context/hook-pipeline.ts +160 -0
  84. package/src/context/index.ts +29 -667
  85. package/src/context/nested-operations.ts +142 -111
  86. package/src/context/write-pipeline.ts +543 -0
  87. package/src/extend.ts +14 -0
  88. package/src/fields/index.ts +310 -2
  89. package/src/hooks/index.ts +227 -0
  90. package/src/index.ts +27 -90
  91. package/src/internal.ts +49 -0
  92. package/src/validation/field-config.test.ts +199 -0
  93. package/src/validation/field-config.ts +145 -0
  94. package/tests/access-relationships.test.ts +4 -4
  95. package/tests/access.test.ts +1 -1
  96. package/tests/field-hooks.test.ts +410 -0
  97. package/tests/field-types.test.ts +1 -1
  98. package/tests/hook-pipeline.test.ts +233 -0
  99. package/tests/nested-operation-registry.test.ts +206 -0
  100. package/tests/write-pipeline.test.ts +588 -0
  101. package/tsconfig.tsbuildinfo +1 -1
  102. package/vitest.config.ts +43 -1
  103. package/dist/access/engine.test.d.ts +0 -2
  104. package/dist/access/engine.test.d.ts.map +0 -1
  105. package/dist/access/engine.test.js.map +0 -1
@@ -1,20 +1,20 @@
1
1
  import type { AccessControl, Session, AccessContext, PrismaFilter } from './types.js'
2
- import type { FieldAccess } from './types.js'
3
- import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
2
+ import type { OpenSaasConfig, ListConfig } from '../config/types.js'
4
3
 
5
4
  /**
6
- * Runtime type for resolveOutput hooks
7
- * Used when we need to call hooks generically without knowing the specific field type
8
- * Supports both sync and async implementations
5
+ * Access engine operation-level access control and shared helpers.
6
+ *
7
+ * This module holds the *operation-level* (list-level) access primitives and
8
+ * the ref-parsing helper shared across both phases of the two-phase read:
9
+ *
10
+ * - Phase 1, Access Filter (pre-query row/relation scoping): `access-filter.ts`
11
+ * - Phase 2, Field Visibility (post-query field stripping + resolveOutput +
12
+ * virtual fields): `field-visibility.ts`
13
+ *
14
+ * Field-level access evaluation is centralized in `field-access.ts`
15
+ * (`checkFieldAccess`). See `docs/adr/0001-access-control-is-a-two-phase-read.md`
16
+ * and the access-control glossary in `CONTEXT.md`.
9
17
  */
10
- type ResolveOutputHookRuntime = (args: {
11
- operation: 'query'
12
- value: unknown
13
- item: Record<string, unknown>
14
- listKey: string
15
- fieldName: string
16
- context: AccessContext
17
- }) => unknown | Promise<unknown>
18
18
 
19
19
  /**
20
20
  * Check if access control result is a boolean
@@ -108,386 +108,3 @@ export function mergeFilters(
108
108
  AND: [accessFilter, userFilter],
109
109
  }
110
110
  }
111
-
112
- /**
113
- * Check field-level access for a specific operation
114
- */
115
- export async function checkFieldAccess(
116
- fieldAccess: FieldAccess | undefined,
117
- operation: 'read' | 'create' | 'update',
118
- args: {
119
- session: Session | null
120
- item?: Record<string, unknown>
121
- context: AccessContext & { _isSudo?: boolean }
122
- inputData?: Record<string, unknown>
123
- },
124
- ): Promise<boolean> {
125
- // Skip access check in sudo mode
126
- if (args.context._isSudo) {
127
- return true
128
- }
129
-
130
- if (!fieldAccess) {
131
- return true // No field access means allow
132
- }
133
-
134
- const accessControl = fieldAccess[operation]
135
- if (!accessControl) {
136
- return true // No specific access control means allow
137
- }
138
-
139
- const result = await accessControl({
140
- session: args.session,
141
- item: args.item,
142
- context: args.context,
143
- inputData: args.inputData,
144
- operation,
145
- } as Parameters<typeof accessControl>[0])
146
-
147
- // If result is false, deny access
148
- if (result === false) {
149
- return false
150
- }
151
-
152
- // If result is true, allow access
153
- if (result === true) {
154
- return true
155
- }
156
-
157
- // Default to allowing access if we can't determine
158
- return true
159
- }
160
-
161
- /**
162
- * Simple filter matching for field-level access
163
- * Checks if an item matches a Prisma-like filter object
164
- */
165
- function matchesFilter(item: Record<string, unknown>, filter: Record<string, unknown>): boolean {
166
- for (const [key, condition] of Object.entries(filter)) {
167
- if (typeof condition === 'object' && condition !== null) {
168
- // Handle nested conditions like { equals: value }
169
- if ('equals' in condition) {
170
- if (item[key] !== condition.equals) {
171
- return false
172
- }
173
- } else if ('not' in condition) {
174
- if (item[key] === condition.not) {
175
- return false
176
- }
177
- }
178
- // Add more condition types as needed
179
- } else {
180
- // Direct equality check
181
- if (item[key] !== condition) {
182
- return false
183
- }
184
- }
185
- }
186
- return true
187
- }
188
-
189
- /**
190
- * Build Prisma include object with access control filters
191
- * This allows us to filter relationships at the database level instead of in memory
192
- */
193
- export async function buildIncludeWithAccessControl(
194
- fieldConfigs: Record<string, FieldConfig>,
195
- args: {
196
- session: Session | null
197
- context: AccessContext
198
- },
199
- config: OpenSaasConfig,
200
- depth: number = 0,
201
- ) {
202
- const MAX_DEPTH = 5
203
- if (depth >= MAX_DEPTH) {
204
- return undefined
205
- }
206
-
207
- // Skip auto-including relationships when inside a resolveOutput hook
208
- // This prevents infinite loops when hooks make DB queries that include
209
- // relationships back to the same entity (e.g., User virtual field queries Posts
210
- // which includes author back to User, triggering the virtual field again)
211
- if (args.context._resolveOutputCounter.depth > 0) {
212
- return undefined
213
- }
214
-
215
- type IncludeEntry = boolean | { where?: PrismaFilter; include?: Record<string, IncludeEntry> }
216
-
217
- const include: Record<string, IncludeEntry> = {}
218
- let hasRelationships = false
219
-
220
- for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
221
- if (fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && fieldConfig.ref) {
222
- hasRelationships = true
223
- const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
224
-
225
- if (relatedConfig) {
226
- // Check query access for the related list
227
- const queryAccess = relatedConfig.listConfig.access?.operation?.query
228
- const accessResult = await checkAccess(queryAccess, {
229
- session: args.session,
230
- context: args.context,
231
- })
232
-
233
- // If access is completely denied, exclude this relationship
234
- if (accessResult === false) {
235
- continue
236
- }
237
-
238
- // Build the include entry
239
- const includeEntry: Record<string, unknown> = {}
240
-
241
- // If access returns a filter, add it to the where clause
242
- if (typeof accessResult === 'object') {
243
- includeEntry.where = accessResult
244
- }
245
-
246
- // Recursively build nested includes
247
- const nestedInclude = await buildIncludeWithAccessControl(
248
- relatedConfig.listConfig.fields,
249
- args,
250
- config,
251
- depth + 1,
252
- )
253
-
254
- if (nestedInclude && Object.keys(nestedInclude).length > 0) {
255
- includeEntry.include = nestedInclude
256
- }
257
-
258
- // Add to include object
259
- include[fieldName] = Object.keys(includeEntry).length > 0 ? includeEntry : true
260
- }
261
- }
262
- }
263
-
264
- return hasRelationships ? include : undefined
265
- }
266
-
267
- /**
268
- * Filter fields from an object based on read access
269
- * Recursively applies access control to nested relationships
270
- */
271
- export async function filterReadableFields<T extends Record<string, unknown>>(
272
- item: T,
273
- fieldConfigs: Record<string, FieldConfig>,
274
- args: {
275
- session: Session | null
276
- context: AccessContext & { _isSudo?: boolean }
277
- },
278
- config?: OpenSaasConfig,
279
- depth: number = 0,
280
- listKey?: string,
281
- ): Promise<Partial<T>> {
282
- const filtered: Record<string, unknown> = {}
283
- const MAX_DEPTH = 5 // Prevent infinite recursion
284
-
285
- // Process existing fields from the database result
286
- for (const [fieldName, value] of Object.entries(item)) {
287
- const fieldConfig = fieldConfigs[fieldName]
288
-
289
- // Always include id, createdAt, updatedAt
290
- if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
291
- filtered[fieldName] = value
292
- continue
293
- }
294
-
295
- // Check field access (checkFieldAccess already handles sudo mode)
296
- const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
297
- ...args,
298
- item,
299
- })
300
-
301
- if (!canRead) {
302
- continue
303
- }
304
-
305
- // Handle relationship fields - recursively filter fields within related items
306
- // Note: Access control filtering is now done at database level via buildIncludeWithAccessControl
307
- // This only handles field-level access (hiding sensitive fields)
308
- if (
309
- config &&
310
- fieldConfig?.type === 'relationship' &&
311
- 'ref' in fieldConfig &&
312
- fieldConfig.ref &&
313
- value !== null &&
314
- value !== undefined &&
315
- depth < MAX_DEPTH
316
- ) {
317
- const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
318
-
319
- if (relatedConfig) {
320
- // For many relationships (arrays) - recursively filter fields in each item
321
- // The recursive call already handles applying resolveOutput hooks
322
- if (Array.isArray(value)) {
323
- filtered[fieldName] = await Promise.all(
324
- value.map((relatedItem) =>
325
- filterReadableFields(
326
- relatedItem,
327
- relatedConfig.listConfig.fields,
328
- args,
329
- config,
330
- depth + 1,
331
- relatedConfig.listName,
332
- ),
333
- ),
334
- )
335
- }
336
- // For single relationships (objects) - recursively filter fields
337
- // The recursive call already handles applying resolveOutput hooks
338
- else if (typeof value === 'object') {
339
- filtered[fieldName] = await filterReadableFields(
340
- value as Record<string, unknown>,
341
- relatedConfig.listConfig.fields,
342
- args,
343
- config,
344
- depth + 1,
345
- relatedConfig.listName,
346
- )
347
- }
348
- } else {
349
- // Related config not found, include the value as-is
350
- filtered[fieldName] = value
351
- }
352
- } else {
353
- // Non-relationship field or no config provided - apply resolveOutput hook if present
354
- if (fieldConfig?.hooks?.resolveOutput && listKey) {
355
- // Cast to runtime type for generic execution
356
- // At runtime, the hook will receive the correct value type for the field
357
- const hook = fieldConfig.hooks.resolveOutput as unknown as ResolveOutputHookRuntime
358
- // Increment depth counter to prevent infinite loops from hooks making DB queries
359
- // that include relationships back to the same entity
360
- args.context._resolveOutputCounter.depth++
361
- try {
362
- // Use Promise.resolve() to handle both sync and async hooks
363
- filtered[fieldName] = await Promise.resolve(
364
- hook({
365
- value,
366
- operation: 'query',
367
- fieldName,
368
- listKey,
369
- item,
370
- context: args.context,
371
- }),
372
- )
373
- } finally {
374
- args.context._resolveOutputCounter.depth--
375
- }
376
- } else {
377
- filtered[fieldName] = value
378
- }
379
- }
380
- }
381
-
382
- // Process virtual fields - compute values from other fields
383
- // Virtual fields don't exist in the database result, so we need to compute them separately
384
- for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
385
- // Skip if already processed (from database result)
386
- if (fieldName in filtered) {
387
- continue
388
- }
389
-
390
- // Only process virtual fields
391
- if (!fieldConfig.virtual) {
392
- continue
393
- }
394
-
395
- // Check field access
396
- const canRead = await checkFieldAccess(fieldConfig.access, 'read', {
397
- ...args,
398
- item,
399
- })
400
-
401
- if (!canRead) {
402
- continue
403
- }
404
-
405
- // Virtual fields must have resolveOutput hook to compute their value
406
- if (fieldConfig.hooks?.resolveOutput && listKey) {
407
- const hook = fieldConfig.hooks.resolveOutput as unknown as ResolveOutputHookRuntime
408
- // Increment depth counter to prevent infinite loops from hooks making DB queries
409
- // that include relationships back to the same entity
410
- args.context._resolveOutputCounter.depth++
411
- try {
412
- // Use Promise.resolve() to handle both sync and async hooks
413
- filtered[fieldName] = await Promise.resolve(
414
- hook({
415
- value: undefined, // Virtual fields don't have a database value
416
- operation: 'query',
417
- fieldName,
418
- listKey,
419
- item: filtered, // Pass filtered item so virtual field can access other fields
420
- context: args.context,
421
- }),
422
- )
423
- } finally {
424
- args.context._resolveOutputCounter.depth--
425
- }
426
- }
427
- }
428
-
429
- return filtered as Partial<T>
430
- }
431
-
432
- /**
433
- * Filter fields from input data based on write access (create/update)
434
- */
435
- export async function filterWritableFields<T extends Record<string, unknown>>(
436
- data: T,
437
- fieldConfigs: Record<string, { access?: FieldAccess; type?: string }>,
438
- operation: 'create' | 'update',
439
- args: {
440
- session: Session | null
441
- item?: Record<string, unknown>
442
- context: AccessContext & { _isSudo?: boolean }
443
- inputData?: Record<string, unknown>
444
- },
445
- ): Promise<Partial<T>> {
446
- const filtered: Record<string, unknown> = {}
447
-
448
- // Build a set of foreign key field names to exclude
449
- // Foreign keys should not be in the data when using Prisma's relation syntax
450
- const foreignKeyFields = new Set<string>()
451
- for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
452
- if (fieldConfig.type === 'relationship') {
453
- // For non-many relationships, Prisma creates a foreign key field named `${fieldName}Id`
454
- const relConfig = fieldConfig as { many?: boolean }
455
- if (!relConfig.many) {
456
- foreignKeyFields.add(`${fieldName}Id`)
457
- }
458
- }
459
- }
460
-
461
- for (const [fieldName, value] of Object.entries(data)) {
462
- const fieldConfig = fieldConfigs[fieldName]
463
-
464
- // Skip system fields
465
- if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
466
- continue
467
- }
468
-
469
- // Skip virtual fields - they don't store in database
470
- // Virtual fields with resolveInput hooks handle side effects separately
471
- if (fieldConfig && 'virtual' in fieldConfig && fieldConfig.virtual) {
472
- continue
473
- }
474
-
475
- // Skip foreign key fields (e.g., authorId) when their corresponding relationship field exists
476
- // This prevents conflicts when using Prisma's relation syntax (e.g., author: { connect: { id } })
477
- if (foreignKeyFields.has(fieldName)) {
478
- continue
479
- }
480
-
481
- // Check field access (checkFieldAccess already handles sudo mode)
482
- const canWrite = await checkFieldAccess(fieldConfig?.access, operation, {
483
- ...args,
484
- inputData: args.inputData,
485
- })
486
-
487
- if (canWrite) {
488
- filtered[fieldName] = value
489
- }
490
- }
491
-
492
- return filtered as Partial<T>
493
- }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { filterWritableFields } from './engine.js'
2
+ import { filterWritableFields } from './field-access.js'
3
3
 
4
4
  describe('filterWritableFields', () => {
5
5
  it('should filter out foreign key fields when their corresponding relationship field exists', async () => {
@@ -0,0 +1,159 @@
1
+ import type { Session, AccessContext } from './types.js'
2
+ import type { FieldAccess } from './types.js'
3
+
4
+ /**
5
+ * Shared field-level access evaluation.
6
+ *
7
+ * This module is the single, canonical home for field-level access checks. Both
8
+ * read-time (Field Visibility, see `field-visibility.ts`) and write-time paths
9
+ * evaluate field access through `checkFieldAccess` — there is intentionally no
10
+ * second, parallel field-access evaluator. See
11
+ * `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
12
+ * glossary in `CONTEXT.md` for the two-phase read model that motivates this.
13
+ */
14
+
15
+ /**
16
+ * Check field-level access for a specific operation.
17
+ *
18
+ * This is the canonical field-access evaluator. Its signature is deliberate:
19
+ * field access can depend on the `operation`, on the already-fetched `item`
20
+ * (read/update/delete), and on the `inputData` being written (create/update),
21
+ * so all of those are accepted. Do not introduce a parallel evaluator with a
22
+ * narrower signature.
23
+ */
24
+ export async function checkFieldAccess(
25
+ fieldAccess: FieldAccess | undefined,
26
+ operation: 'read' | 'create' | 'update',
27
+ args: {
28
+ session: Session | null
29
+ item?: Record<string, unknown>
30
+ context: AccessContext & { _isSudo?: boolean }
31
+ inputData?: Record<string, unknown>
32
+ },
33
+ ): Promise<boolean> {
34
+ // Skip access check in sudo mode
35
+ if (args.context._isSudo) {
36
+ return true
37
+ }
38
+
39
+ if (!fieldAccess) {
40
+ return true // No field access means allow
41
+ }
42
+
43
+ const accessControl = fieldAccess[operation]
44
+ if (!accessControl) {
45
+ return true // No specific access control means allow
46
+ }
47
+
48
+ const result = await accessControl({
49
+ session: args.session,
50
+ item: args.item,
51
+ context: args.context,
52
+ inputData: args.inputData,
53
+ operation,
54
+ } as Parameters<typeof accessControl>[0])
55
+
56
+ // If result is false, deny access
57
+ if (result === false) {
58
+ return false
59
+ }
60
+
61
+ // If result is true, allow access
62
+ if (result === true) {
63
+ return true
64
+ }
65
+
66
+ // Default to allowing access if we can't determine
67
+ return true
68
+ }
69
+
70
+ /**
71
+ * Simple filter matching for field-level access
72
+ * Checks if an item matches a Prisma-like filter object
73
+ */
74
+ function matchesFilter(item: Record<string, unknown>, filter: Record<string, unknown>): boolean {
75
+ for (const [key, condition] of Object.entries(filter)) {
76
+ if (typeof condition === 'object' && condition !== null) {
77
+ // Handle nested conditions like { equals: value }
78
+ if ('equals' in condition) {
79
+ if (item[key] !== condition.equals) {
80
+ return false
81
+ }
82
+ } else if ('not' in condition) {
83
+ if (item[key] === condition.not) {
84
+ return false
85
+ }
86
+ }
87
+ // Add more condition types as needed
88
+ } else {
89
+ // Direct equality check
90
+ if (item[key] !== condition) {
91
+ return false
92
+ }
93
+ }
94
+ }
95
+ return true
96
+ }
97
+
98
+ /**
99
+ * Filter fields from input data based on write access (create/update)
100
+ */
101
+ export async function filterWritableFields<T extends Record<string, unknown>>(
102
+ data: T,
103
+ fieldConfigs: Record<string, { access?: FieldAccess; type?: string }>,
104
+ operation: 'create' | 'update',
105
+ args: {
106
+ session: Session | null
107
+ item?: Record<string, unknown>
108
+ context: AccessContext & { _isSudo?: boolean }
109
+ inputData?: Record<string, unknown>
110
+ },
111
+ ): Promise<Partial<T>> {
112
+ const filtered: Record<string, unknown> = {}
113
+
114
+ // Build a set of foreign key field names to exclude
115
+ // Foreign keys should not be in the data when using Prisma's relation syntax
116
+ const foreignKeyFields = new Set<string>()
117
+ for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
118
+ if (fieldConfig.type === 'relationship') {
119
+ // For non-many relationships, Prisma creates a foreign key field named `${fieldName}Id`
120
+ const relConfig = fieldConfig as { many?: boolean }
121
+ if (!relConfig.many) {
122
+ foreignKeyFields.add(`${fieldName}Id`)
123
+ }
124
+ }
125
+ }
126
+
127
+ for (const [fieldName, value] of Object.entries(data)) {
128
+ const fieldConfig = fieldConfigs[fieldName]
129
+
130
+ // Skip system fields
131
+ if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
132
+ continue
133
+ }
134
+
135
+ // Skip virtual fields - they don't store in database
136
+ // Virtual fields with resolveInput hooks handle side effects separately
137
+ if (fieldConfig && 'virtual' in fieldConfig && fieldConfig.virtual) {
138
+ continue
139
+ }
140
+
141
+ // Skip foreign key fields (e.g., authorId) when their corresponding relationship field exists
142
+ // This prevents conflicts when using Prisma's relation syntax (e.g., author: { connect: { id } })
143
+ if (foreignKeyFields.has(fieldName)) {
144
+ continue
145
+ }
146
+
147
+ // Check field access (checkFieldAccess already handles sudo mode)
148
+ const canWrite = await checkFieldAccess(fieldConfig?.access, operation, {
149
+ ...args,
150
+ inputData: args.inputData,
151
+ })
152
+
153
+ if (canWrite) {
154
+ filtered[fieldName] = value
155
+ }
156
+ }
157
+
158
+ return filtered as Partial<T>
159
+ }