@opensaas/stack-core 0.1.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 (95) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/README.md +447 -0
  3. package/dist/access/engine.d.ts +73 -0
  4. package/dist/access/engine.d.ts.map +1 -0
  5. package/dist/access/engine.js +244 -0
  6. package/dist/access/engine.js.map +1 -0
  7. package/dist/access/field-transforms.d.ts +47 -0
  8. package/dist/access/field-transforms.d.ts.map +1 -0
  9. package/dist/access/field-transforms.js +2 -0
  10. package/dist/access/field-transforms.js.map +1 -0
  11. package/dist/access/index.d.ts +3 -0
  12. package/dist/access/index.d.ts.map +1 -0
  13. package/dist/access/index.js +2 -0
  14. package/dist/access/index.js.map +1 -0
  15. package/dist/access/types.d.ts +83 -0
  16. package/dist/access/types.d.ts.map +1 -0
  17. package/dist/access/types.js +2 -0
  18. package/dist/access/types.js.map +1 -0
  19. package/dist/config/index.d.ts +39 -0
  20. package/dist/config/index.d.ts.map +1 -0
  21. package/dist/config/index.js +38 -0
  22. package/dist/config/index.js.map +1 -0
  23. package/dist/config/types.d.ts +413 -0
  24. package/dist/config/types.d.ts.map +1 -0
  25. package/dist/config/types.js +2 -0
  26. package/dist/config/types.js.map +1 -0
  27. package/dist/context/index.d.ts +31 -0
  28. package/dist/context/index.d.ts.map +1 -0
  29. package/dist/context/index.js +524 -0
  30. package/dist/context/index.js.map +1 -0
  31. package/dist/context/nested-operations.d.ts +10 -0
  32. package/dist/context/nested-operations.d.ts.map +1 -0
  33. package/dist/context/nested-operations.js +261 -0
  34. package/dist/context/nested-operations.js.map +1 -0
  35. package/dist/fields/index.d.ts +78 -0
  36. package/dist/fields/index.d.ts.map +1 -0
  37. package/dist/fields/index.js +381 -0
  38. package/dist/fields/index.js.map +1 -0
  39. package/dist/hooks/index.d.ts +58 -0
  40. package/dist/hooks/index.d.ts.map +1 -0
  41. package/dist/hooks/index.js +79 -0
  42. package/dist/hooks/index.js.map +1 -0
  43. package/dist/index.d.ts +11 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +12 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/lib/case-utils.d.ts +49 -0
  48. package/dist/lib/case-utils.d.ts.map +1 -0
  49. package/dist/lib/case-utils.js +68 -0
  50. package/dist/lib/case-utils.js.map +1 -0
  51. package/dist/lib/case-utils.test.d.ts +2 -0
  52. package/dist/lib/case-utils.test.d.ts.map +1 -0
  53. package/dist/lib/case-utils.test.js +101 -0
  54. package/dist/lib/case-utils.test.js.map +1 -0
  55. package/dist/utils/password.d.ts +81 -0
  56. package/dist/utils/password.d.ts.map +1 -0
  57. package/dist/utils/password.js +132 -0
  58. package/dist/utils/password.js.map +1 -0
  59. package/dist/validation/schema.d.ts +17 -0
  60. package/dist/validation/schema.d.ts.map +1 -0
  61. package/dist/validation/schema.js +42 -0
  62. package/dist/validation/schema.js.map +1 -0
  63. package/dist/validation/schema.test.d.ts +2 -0
  64. package/dist/validation/schema.test.d.ts.map +1 -0
  65. package/dist/validation/schema.test.js +143 -0
  66. package/dist/validation/schema.test.js.map +1 -0
  67. package/docs/type-distribution-fix.md +136 -0
  68. package/package.json +48 -0
  69. package/src/access/engine.ts +360 -0
  70. package/src/access/field-transforms.ts +99 -0
  71. package/src/access/index.ts +20 -0
  72. package/src/access/types.ts +103 -0
  73. package/src/config/index.ts +71 -0
  74. package/src/config/types.ts +478 -0
  75. package/src/context/index.ts +814 -0
  76. package/src/context/nested-operations.ts +412 -0
  77. package/src/fields/index.ts +438 -0
  78. package/src/hooks/index.ts +132 -0
  79. package/src/index.ts +62 -0
  80. package/src/lib/case-utils.test.ts +127 -0
  81. package/src/lib/case-utils.ts +74 -0
  82. package/src/utils/password.ts +147 -0
  83. package/src/validation/schema.test.ts +171 -0
  84. package/src/validation/schema.ts +59 -0
  85. package/tests/access-relationships.test.ts +613 -0
  86. package/tests/access.test.ts +499 -0
  87. package/tests/config.test.ts +195 -0
  88. package/tests/context.test.ts +248 -0
  89. package/tests/hooks.test.ts +417 -0
  90. package/tests/password-type-distribution.test.ts +155 -0
  91. package/tests/password-types.test.ts +147 -0
  92. package/tests/password.test.ts +249 -0
  93. package/tsconfig.json +12 -0
  94. package/tsconfig.tsbuildinfo +1 -0
  95. package/vitest.config.ts +27 -0
@@ -0,0 +1,814 @@
1
+ import type { OpenSaasConfig, ListConfig } from '../config/types.js'
2
+ import type { Session, AccessContext, AccessControlledDB } from '../access/index.js'
3
+ import {
4
+ checkAccess,
5
+ mergeFilters,
6
+ filterReadableFields,
7
+ filterWritableFields,
8
+ buildIncludeWithAccessControl,
9
+ } from '../access/index.js'
10
+ import {
11
+ executeResolveInput,
12
+ executeValidateInput,
13
+ executeBeforeOperation,
14
+ executeAfterOperation,
15
+ validateFieldRules,
16
+ ValidationError,
17
+ } from '../hooks/index.js'
18
+ import { processNestedOperations } from './nested-operations.js'
19
+ import { getDbKey } from '../lib/case-utils.js'
20
+ import type { PrismaClientLike } from '../access/types.js'
21
+ import type { FieldConfig } from '../config/types.js'
22
+
23
+ /**
24
+ * Execute field-level resolveInput hooks
25
+ * Allows fields to transform their input values before database write
26
+ */
27
+ async function executeFieldResolveInputHooks(
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ data: Record<string, any>,
30
+ fields: Record<string, FieldConfig>,
31
+ operation: 'create' | 'update',
32
+ context: AccessContext,
33
+ listKey: string,
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ item?: any,
36
+ ): Promise<Record<string, unknown>> {
37
+ const result = { ...data }
38
+
39
+ for (const [fieldName, fieldConfig] of Object.entries(fields)) {
40
+ // Skip if field not in data
41
+ if (!(fieldName in result)) continue
42
+
43
+ // Skip if no hooks defined
44
+ if (!fieldConfig.hooks?.resolveInput) continue
45
+
46
+ // Execute field hook
47
+ // Type assertion is safe here because hooks are typed correctly in field definitions
48
+ // and we're working with runtime values that match those types
49
+
50
+ const transformedValue = await fieldConfig.hooks.resolveInput({
51
+ inputValue: result[fieldName],
52
+ operation,
53
+ fieldName,
54
+ listKey,
55
+ item,
56
+ context,
57
+ })
58
+
59
+ result[fieldName] = transformedValue
60
+ }
61
+
62
+ return result
63
+ }
64
+
65
+ /**
66
+ * Execute field-level beforeOperation hooks (side effects only)
67
+ * Allows fields to perform side effects before database write
68
+ */
69
+ async function executeFieldBeforeOperationHooks(
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ data: Record<string, any>,
72
+ fields: Record<string, FieldConfig>,
73
+ operation: 'create' | 'update' | 'delete',
74
+ context: AccessContext,
75
+ listKey: string,
76
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
77
+ item?: any,
78
+ ): Promise<void> {
79
+ for (const [fieldName, fieldConfig] of Object.entries(fields)) {
80
+ // Skip if field not in data (for create/update) or if no hooks defined
81
+ if (!fieldConfig.hooks?.beforeOperation) continue
82
+ if (operation !== 'delete' && !(fieldName in data)) continue
83
+
84
+ // Execute field hook (side effects only, no return value used)
85
+ // Type assertion is safe here because hooks are typed correctly in field definitions
86
+ await fieldConfig.hooks.beforeOperation({
87
+ resolvedValue: data[fieldName],
88
+ operation,
89
+ fieldName,
90
+ listKey,
91
+ item,
92
+ context,
93
+ })
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Execute field-level afterOperation hooks (side effects only)
99
+ * Allows fields to perform side effects after database operations
100
+ */
101
+ async function executeFieldAfterOperationHooks(
102
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
103
+ item: any,
104
+ data: Record<string, unknown> | undefined,
105
+ fields: Record<string, FieldConfig>,
106
+ operation: 'create' | 'update' | 'delete' | 'query',
107
+ context: AccessContext,
108
+ listKey: string,
109
+ ): Promise<void> {
110
+ for (const [fieldName, fieldConfig] of Object.entries(fields)) {
111
+ // Skip if no hooks defined
112
+ if (!fieldConfig.hooks?.afterOperation) continue
113
+
114
+ // Get the value from item (for all operations)
115
+ const value = item?.[fieldName]
116
+
117
+ // Execute field hook (side effects only, no return value used)
118
+ await fieldConfig.hooks.afterOperation({
119
+ value,
120
+ operation,
121
+ fieldName,
122
+ listKey,
123
+ item,
124
+ context,
125
+ })
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Execute field-level resolveOutput hooks
131
+ * Allows fields to transform their output values after database read
132
+ */
133
+ function executeFieldResolveOutputHooks(
134
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
135
+ item: Record<string, any> | null,
136
+ fields: Record<string, FieldConfig>,
137
+ context: AccessContext,
138
+ listKey: string,
139
+ ): Record<string, unknown> | null {
140
+ if (!item) return null
141
+
142
+ const result = { ...item }
143
+
144
+ for (const [fieldName, fieldConfig] of Object.entries(fields)) {
145
+ // Skip if field not in result
146
+ if (!(fieldName in result)) continue
147
+
148
+ // Skip if no hooks defined
149
+ if (!fieldConfig.hooks?.resolveOutput) continue
150
+
151
+ // Execute field hook
152
+ // Type assertion is safe here because hooks are typed correctly in field definitions
153
+ const transformedValue = fieldConfig.hooks.resolveOutput({
154
+ value: result[fieldName],
155
+ operation: 'query',
156
+ fieldName,
157
+ listKey,
158
+ item,
159
+ context,
160
+ })
161
+
162
+ result[fieldName] = transformedValue
163
+ }
164
+
165
+ return result
166
+ }
167
+ export type ServerActionProps =
168
+ | { listKey: string; action: 'create'; data: Record<string, unknown> }
169
+ | { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
170
+ | { listKey: string; action: 'delete'; id: string }
171
+ /**
172
+ * Create an access-controlled context
173
+ *
174
+ * @param config - OpenSaas configuration
175
+ * @param prisma - Your Prisma client instance (pass as generic for type safety)
176
+ * @param session - Current session object (or null if not authenticated)
177
+ */
178
+ export function getContext<
179
+ TConfig extends OpenSaasConfig,
180
+ TPrisma extends PrismaClientLike = PrismaClientLike,
181
+ >(
182
+ config: TConfig,
183
+ prisma: TPrisma,
184
+ session: Session,
185
+ ): {
186
+ db: AccessControlledDB<TPrisma>
187
+ session: Session
188
+ prisma: TPrisma
189
+ serverAction: (props: ServerActionProps) => Promise<unknown>
190
+ } {
191
+ // Initialize db object - will be populated with access-controlled operations
192
+ // Type is intentionally broad to allow dynamic model access
193
+ const db: Record<string, unknown> = {}
194
+
195
+ // Create context with db reference (will be populated below)
196
+ const context: AccessContext<TPrisma> = {
197
+ session,
198
+ prisma: prisma as TPrisma,
199
+ db: db as AccessControlledDB<TPrisma>,
200
+ }
201
+
202
+ // Create access-controlled operations for each list
203
+ for (const [listName, listConfig] of Object.entries(config.lists)) {
204
+ const dbKey = getDbKey(listName)
205
+
206
+ db[dbKey] = {
207
+ findUnique: createFindUnique(listName, listConfig, prisma, context, config),
208
+ findMany: createFindMany(listName, listConfig, prisma, context, config),
209
+ create: createCreate(listName, listConfig, prisma, context, config),
210
+ update: createUpdate(listName, listConfig, prisma, context, config),
211
+ delete: createDelete(listName, listConfig, prisma, context),
212
+ count: createCount(listName, listConfig, prisma, context),
213
+ }
214
+ }
215
+
216
+ // Generic server action handler with discriminated union for type safety
217
+ async function serverAction(props: ServerActionProps): Promise<unknown> {
218
+ const dbKey = getDbKey(props.listKey)
219
+ const model = db[dbKey] as {
220
+ create: (args: { data: Record<string, unknown> }) => Promise<unknown>
221
+ update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<unknown>
222
+ delete: (args: { where: { id: string } }) => Promise<unknown>
223
+ }
224
+
225
+ if (props.action === 'create') {
226
+ return await model.create({ data: props.data })
227
+ } else if (props.action === 'update') {
228
+ return await model.update({
229
+ where: { id: props.id },
230
+ data: props.data,
231
+ })
232
+ } else if (props.action === 'delete') {
233
+ return await model.delete({
234
+ where: { id: props.id },
235
+ })
236
+ }
237
+
238
+ return null
239
+ }
240
+
241
+ return {
242
+ db: db as AccessControlledDB<TPrisma>,
243
+ session,
244
+ prisma,
245
+ serverAction,
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Create findUnique operation with access control
251
+ */
252
+ function createFindUnique<TPrisma extends PrismaClientLike>(
253
+ listName: string,
254
+ listConfig: ListConfig,
255
+ prisma: TPrisma,
256
+ context: AccessContext,
257
+ config: OpenSaasConfig,
258
+ ) {
259
+ return async (args: { where: { id: string }; include?: Record<string, unknown> }) => {
260
+ // Check query access
261
+ const queryAccess = listConfig.access?.operation?.query
262
+ const accessResult = await checkAccess(queryAccess, {
263
+ session: context.session,
264
+ context,
265
+ })
266
+
267
+ if (accessResult === false) {
268
+ return null
269
+ }
270
+
271
+ // Merge access filter with where clause
272
+ const where = mergeFilters(args.where, accessResult)
273
+ if (where === null) {
274
+ return null
275
+ }
276
+
277
+ // Build include with access control filters
278
+ const accessControlledInclude = await buildIncludeWithAccessControl(
279
+ listConfig.fields,
280
+ {
281
+ session: context.session,
282
+ context,
283
+ },
284
+ config,
285
+ )
286
+
287
+ // Merge user-provided include with access-controlled include
288
+ const include = args.include || accessControlledInclude
289
+
290
+ // Execute query with optimized includes
291
+ // Access Prisma model dynamically - required because model names are generated at runtime
292
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
293
+ const model = (prisma as any)[getDbKey(listName)]
294
+ const item = await model.findFirst({
295
+ where,
296
+ include,
297
+ })
298
+
299
+ if (!item) {
300
+ return null
301
+ }
302
+
303
+ // Filter readable fields (now only handles field-level access, not array filtering)
304
+ const filtered = await filterReadableFields(
305
+ item,
306
+ listConfig.fields,
307
+ {
308
+ session: context.session,
309
+ context,
310
+ },
311
+ config,
312
+ )
313
+
314
+ // Execute field resolveOutput hooks (e.g., wrap password with HashedPassword)
315
+ const resolved = executeFieldResolveOutputHooks(filtered, listConfig.fields, context, listName)
316
+
317
+ // Execute field afterOperation hooks (side effects only)
318
+ await executeFieldAfterOperationHooks(
319
+ resolved,
320
+ undefined,
321
+ listConfig.fields,
322
+ 'query',
323
+ context,
324
+ listName,
325
+ )
326
+
327
+ return resolved
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Create findMany operation with access control
333
+ */
334
+ function createFindMany<TPrisma extends PrismaClientLike>(
335
+ listName: string,
336
+ listConfig: ListConfig,
337
+ prisma: TPrisma,
338
+ context: AccessContext,
339
+ config: OpenSaasConfig,
340
+ ) {
341
+ return async (args?: {
342
+ where?: Record<string, unknown>
343
+ take?: number
344
+ skip?: number
345
+ include?: Record<string, unknown>
346
+ }) => {
347
+ // Check query access
348
+ const queryAccess = listConfig.access?.operation?.query
349
+ const accessResult = await checkAccess(queryAccess, {
350
+ session: context.session,
351
+ context,
352
+ })
353
+
354
+ if (accessResult === false) {
355
+ return []
356
+ }
357
+
358
+ // Merge access filter with where clause
359
+ const where = mergeFilters(args?.where, accessResult)
360
+ if (where === null) {
361
+ return []
362
+ }
363
+
364
+ // Build include with access control filters
365
+ const accessControlledInclude = await buildIncludeWithAccessControl(
366
+ listConfig.fields,
367
+ {
368
+ session: context.session,
369
+ context,
370
+ },
371
+ config,
372
+ )
373
+
374
+ // Merge user-provided include with access-controlled include
375
+ const include = args?.include || accessControlledInclude
376
+
377
+ // Execute query with optimized includes
378
+ // Access Prisma model dynamically - required because model names are generated at runtime
379
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
380
+ const model = (prisma as any)[getDbKey(listName)]
381
+ const items = await model.findMany({
382
+ where,
383
+ take: args?.take,
384
+ skip: args?.skip,
385
+ include,
386
+ })
387
+
388
+ // Filter readable fields for each item (now only handles field-level access)
389
+ const filtered = await Promise.all(
390
+ items.map((item: Record<string, unknown>) =>
391
+ filterReadableFields(
392
+ item,
393
+ listConfig.fields,
394
+ {
395
+ session: context.session,
396
+ context,
397
+ },
398
+ config,
399
+ ),
400
+ ),
401
+ )
402
+
403
+ // Execute field resolveOutput hooks for each item
404
+ const resolved = filtered.map((item) =>
405
+ executeFieldResolveOutputHooks(item, listConfig.fields, context, listName),
406
+ )
407
+
408
+ // Execute field afterOperation hooks for each item (side effects only)
409
+ await Promise.all(
410
+ resolved.map((item) =>
411
+ executeFieldAfterOperationHooks(
412
+ item,
413
+ undefined,
414
+ listConfig.fields,
415
+ 'query',
416
+ context,
417
+ listName,
418
+ ),
419
+ ),
420
+ )
421
+
422
+ return resolved
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Create create operation with access control and hooks
428
+ */
429
+ function createCreate<TPrisma extends PrismaClientLike>(
430
+ listName: string,
431
+ listConfig: ListConfig,
432
+ prisma: TPrisma,
433
+ context: AccessContext,
434
+ config: OpenSaasConfig,
435
+ ) {
436
+ return async (args: { data: Record<string, unknown> }) => {
437
+ // 1. Check create access
438
+ const createAccess = listConfig.access?.operation?.create
439
+ const accessResult = await checkAccess(createAccess, {
440
+ session: context.session,
441
+ context,
442
+ })
443
+
444
+ if (accessResult === false) {
445
+ return null
446
+ }
447
+
448
+ // 2. Execute list-level resolveInput hook
449
+ let resolvedData = await executeResolveInput(listConfig.hooks, {
450
+ operation: 'create',
451
+ resolvedData: args.data,
452
+ context,
453
+ })
454
+
455
+ // 2.5. Execute field-level resolveInput hooks (e.g., hash passwords)
456
+ resolvedData = await executeFieldResolveInputHooks(
457
+ resolvedData,
458
+ listConfig.fields,
459
+ 'create',
460
+ context,
461
+ listName,
462
+ )
463
+
464
+ // 3. Execute validateInput hook
465
+ await executeValidateInput(listConfig.hooks, {
466
+ operation: 'create',
467
+ resolvedData,
468
+ context,
469
+ })
470
+
471
+ // 4. Field validation (isRequired, length, etc.)
472
+ const validation = validateFieldRules(resolvedData, listConfig.fields, 'create')
473
+ if (validation.errors.length > 0) {
474
+ throw new ValidationError(validation.errors, validation.fieldErrors)
475
+ }
476
+
477
+ // 5. Filter writable fields (field-level access control)
478
+ const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'create', {
479
+ session: context.session,
480
+ context,
481
+ })
482
+
483
+ // 5.5. Process nested relationship operations
484
+ const data = await processNestedOperations(
485
+ filteredData,
486
+ listConfig.fields,
487
+ config,
488
+ { ...context, prisma },
489
+ 'create',
490
+ )
491
+
492
+ // 6. Execute field-level beforeOperation hooks (side effects only)
493
+ await executeFieldBeforeOperationHooks(data, listConfig.fields, 'create', context, listName)
494
+
495
+ // 7. Execute list-level beforeOperation hook
496
+ await executeBeforeOperation(listConfig.hooks, {
497
+ operation: 'create',
498
+ context,
499
+ })
500
+
501
+ // 8. Execute database create
502
+ // Access Prisma model dynamically - required because model names are generated at runtime
503
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
504
+ const model = (prisma as any)[getDbKey(listName)]
505
+ const item = await model.create({
506
+ data,
507
+ })
508
+
509
+ // 9. Execute list-level afterOperation hook
510
+ await executeAfterOperation(listConfig.hooks, {
511
+ operation: 'create',
512
+ item,
513
+ context,
514
+ })
515
+
516
+ // 10. Execute field-level afterOperation hooks (side effects only)
517
+ await executeFieldAfterOperationHooks(
518
+ item,
519
+ data,
520
+ listConfig.fields,
521
+ 'create',
522
+ context,
523
+ listName,
524
+ )
525
+
526
+ // 11. Filter readable fields
527
+ const filtered = await filterReadableFields(
528
+ item,
529
+ listConfig.fields,
530
+ {
531
+ session: context.session,
532
+ context,
533
+ },
534
+ config,
535
+ )
536
+
537
+ // 12. Execute field resolveOutput hooks (e.g., wrap password with HashedPassword)
538
+ const resolved = executeFieldResolveOutputHooks(filtered, listConfig.fields, context, listName)
539
+
540
+ return resolved
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Create update operation with access control and hooks
546
+ */
547
+ function createUpdate<TPrisma extends PrismaClientLike>(
548
+ listName: string,
549
+ listConfig: ListConfig,
550
+ prisma: TPrisma,
551
+ context: AccessContext,
552
+ config: OpenSaasConfig,
553
+ ) {
554
+ return async (args: { where: { id: string }; data: Record<string, unknown> }) => {
555
+ // 1. Fetch the item to pass to access control and hooks
556
+ // Access Prisma model dynamically - required because model names are generated at runtime
557
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
558
+ const model = (prisma as any)[getDbKey(listName)]
559
+ const item = await model.findUnique({
560
+ where: args.where,
561
+ })
562
+
563
+ if (!item) {
564
+ return null
565
+ }
566
+
567
+ // 2. Check update access
568
+ const updateAccess = listConfig.access?.operation?.update
569
+ const accessResult = await checkAccess(updateAccess, {
570
+ session: context.session,
571
+ item,
572
+ context,
573
+ })
574
+
575
+ if (accessResult === false) {
576
+ return null
577
+ }
578
+
579
+ // If access returns a filter, check if item matches
580
+ if (typeof accessResult === 'object') {
581
+ const matchesFilter = await model.findFirst({
582
+ where: mergeFilters(args.where, accessResult),
583
+ })
584
+
585
+ if (!matchesFilter) {
586
+ return null
587
+ }
588
+ }
589
+
590
+ // 3. Execute list-level resolveInput hook
591
+ let resolvedData = await executeResolveInput(listConfig.hooks, {
592
+ operation: 'update',
593
+ resolvedData: args.data,
594
+ item,
595
+ context,
596
+ })
597
+
598
+ // 3.5. Execute field-level resolveInput hooks (e.g., hash passwords)
599
+ resolvedData = await executeFieldResolveInputHooks(
600
+ resolvedData,
601
+ listConfig.fields,
602
+ 'update',
603
+ context,
604
+ listName,
605
+ item,
606
+ )
607
+
608
+ // 4. Execute validateInput hook
609
+ await executeValidateInput(listConfig.hooks, {
610
+ operation: 'update',
611
+ resolvedData,
612
+ item,
613
+ context,
614
+ })
615
+
616
+ // 5. Field validation (isRequired, length, etc.)
617
+ const validation = validateFieldRules(resolvedData, listConfig.fields, 'update')
618
+ if (validation.errors.length > 0) {
619
+ throw new ValidationError(validation.errors, validation.fieldErrors)
620
+ }
621
+
622
+ // 6. Filter writable fields (field-level access control)
623
+ const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'update', {
624
+ session: context.session,
625
+ item,
626
+ context,
627
+ })
628
+
629
+ // 6.5. Process nested relationship operations
630
+ const data = await processNestedOperations(
631
+ filteredData,
632
+ listConfig.fields,
633
+ config,
634
+ { ...context, prisma },
635
+ 'update',
636
+ )
637
+
638
+ // 7. Execute field-level beforeOperation hooks (side effects only)
639
+ await executeFieldBeforeOperationHooks(
640
+ data,
641
+ listConfig.fields,
642
+ 'update',
643
+ context,
644
+ listName,
645
+ item,
646
+ )
647
+
648
+ // 8. Execute list-level beforeOperation hook
649
+ await executeBeforeOperation(listConfig.hooks, {
650
+ operation: 'update',
651
+ item,
652
+ context,
653
+ })
654
+
655
+ // 9. Execute database update
656
+ const updated = await model.update({
657
+ where: args.where,
658
+ data,
659
+ })
660
+
661
+ // 10. Execute list-level afterOperation hook
662
+ await executeAfterOperation(listConfig.hooks, {
663
+ operation: 'update',
664
+ item: updated,
665
+ context,
666
+ })
667
+
668
+ // 11. Execute field-level afterOperation hooks (side effects only)
669
+ await executeFieldAfterOperationHooks(
670
+ updated,
671
+ data,
672
+ listConfig.fields,
673
+ 'update',
674
+ context,
675
+ listName,
676
+ )
677
+
678
+ // 12. Filter readable fields
679
+ const filtered = await filterReadableFields(
680
+ updated,
681
+ listConfig.fields,
682
+ {
683
+ session: context.session,
684
+ context,
685
+ },
686
+ config,
687
+ )
688
+
689
+ // 13. Execute field resolveOutput hooks (e.g., wrap password with HashedPassword)
690
+ const resolved = executeFieldResolveOutputHooks(filtered, listConfig.fields, context, listName)
691
+
692
+ return resolved
693
+ }
694
+ }
695
+
696
+ /**
697
+ * Create delete operation with access control and hooks
698
+ */
699
+ function createDelete<TPrisma extends PrismaClientLike>(
700
+ listName: string,
701
+ listConfig: ListConfig,
702
+ prisma: TPrisma,
703
+ context: AccessContext,
704
+ ) {
705
+ return async (args: { where: { id: string } }) => {
706
+ // 1. Fetch the item to pass to access control and hooks
707
+ // Access Prisma model dynamically - required because model names are generated at runtime
708
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
709
+ const model = (prisma as any)[getDbKey(listName)]
710
+ const item = await model.findUnique({
711
+ where: args.where,
712
+ })
713
+
714
+ if (!item) {
715
+ return null
716
+ }
717
+
718
+ // 2. Check delete access
719
+ const deleteAccess = listConfig.access?.operation?.delete
720
+ const accessResult = await checkAccess(deleteAccess, {
721
+ session: context.session,
722
+ item,
723
+ context,
724
+ })
725
+
726
+ if (accessResult === false) {
727
+ return null
728
+ }
729
+
730
+ // If access returns a filter, check if item matches
731
+ if (typeof accessResult === 'object') {
732
+ const matchesFilter = await model.findFirst({
733
+ where: mergeFilters(args.where, accessResult),
734
+ })
735
+
736
+ if (!matchesFilter) {
737
+ return null
738
+ }
739
+ }
740
+
741
+ // 3. Execute field-level beforeOperation hooks (side effects only)
742
+ await executeFieldBeforeOperationHooks({}, listConfig.fields, 'delete', context, listName, item)
743
+
744
+ // 4. Execute list-level beforeOperation hook
745
+ await executeBeforeOperation(listConfig.hooks, {
746
+ operation: 'delete',
747
+ item,
748
+ context,
749
+ })
750
+
751
+ // 5. Execute database delete
752
+ const deleted = await model.delete({
753
+ where: args.where,
754
+ })
755
+
756
+ // 6. Execute list-level afterOperation hook
757
+ await executeAfterOperation(listConfig.hooks, {
758
+ operation: 'delete',
759
+ item: deleted,
760
+ context,
761
+ })
762
+
763
+ // 7. Execute field-level afterOperation hooks (side effects only)
764
+ await executeFieldAfterOperationHooks(
765
+ deleted,
766
+ undefined,
767
+ listConfig.fields,
768
+ 'delete',
769
+ context,
770
+ listName,
771
+ )
772
+
773
+ return deleted
774
+ }
775
+ }
776
+
777
+ /**
778
+ * Create count operation with access control
779
+ */
780
+ function createCount<TPrisma extends PrismaClientLike>(
781
+ listName: string,
782
+ listConfig: ListConfig,
783
+ prisma: TPrisma,
784
+ context: AccessContext,
785
+ ) {
786
+ return async (args?: { where?: Record<string, unknown> }) => {
787
+ // Check query access
788
+ const queryAccess = listConfig.access?.operation?.query
789
+ const accessResult = await checkAccess(queryAccess, {
790
+ session: context.session,
791
+ context,
792
+ })
793
+
794
+ if (accessResult === false) {
795
+ return 0
796
+ }
797
+
798
+ // Merge access filter with where clause
799
+ const where = mergeFilters(args?.where, accessResult)
800
+ if (where === null) {
801
+ return 0
802
+ }
803
+
804
+ // Execute count
805
+ // Access Prisma model dynamically - required because model names are generated at runtime
806
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
807
+ const model = (prisma as any)[getDbKey(listName)]
808
+ const count = await model.count({
809
+ where,
810
+ })
811
+
812
+ return count
813
+ }
814
+ }