@opensaas/stack-core 0.1.6 → 0.3.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 (50) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +208 -0
  3. package/CLAUDE.md +46 -1
  4. package/dist/access/engine.d.ts +15 -8
  5. package/dist/access/engine.d.ts.map +1 -1
  6. package/dist/access/engine.js +23 -2
  7. package/dist/access/engine.js.map +1 -1
  8. package/dist/access/engine.test.d.ts +2 -0
  9. package/dist/access/engine.test.d.ts.map +1 -0
  10. package/dist/access/engine.test.js +125 -0
  11. package/dist/access/engine.test.js.map +1 -0
  12. package/dist/access/types.d.ts +40 -9
  13. package/dist/access/types.d.ts.map +1 -1
  14. package/dist/config/index.d.ts +38 -18
  15. package/dist/config/index.d.ts.map +1 -1
  16. package/dist/config/index.js +34 -14
  17. package/dist/config/index.js.map +1 -1
  18. package/dist/config/plugin-engine.d.ts.map +1 -1
  19. package/dist/config/plugin-engine.js +6 -0
  20. package/dist/config/plugin-engine.js.map +1 -1
  21. package/dist/config/types.d.ts +128 -21
  22. package/dist/config/types.d.ts.map +1 -1
  23. package/dist/context/index.d.ts +14 -2
  24. package/dist/context/index.d.ts.map +1 -1
  25. package/dist/context/index.js +243 -100
  26. package/dist/context/index.js.map +1 -1
  27. package/dist/fields/index.d.ts.map +1 -1
  28. package/dist/fields/index.js +9 -8
  29. package/dist/fields/index.js.map +1 -1
  30. package/dist/hooks/index.d.ts +28 -12
  31. package/dist/hooks/index.d.ts.map +1 -1
  32. package/dist/hooks/index.js +16 -0
  33. package/dist/hooks/index.js.map +1 -1
  34. package/package.json +3 -4
  35. package/src/access/engine.test.ts +145 -0
  36. package/src/access/engine.ts +35 -11
  37. package/src/access/types.ts +39 -8
  38. package/src/config/index.ts +46 -19
  39. package/src/config/plugin-engine.ts +7 -0
  40. package/src/config/types.ts +149 -18
  41. package/src/context/index.ts +298 -110
  42. package/src/fields/index.ts +8 -7
  43. package/src/hooks/index.ts +63 -20
  44. package/tests/context.test.ts +38 -6
  45. package/tests/field-types.test.ts +728 -0
  46. package/tests/password-type-distribution.test.ts +0 -1
  47. package/tests/password-types.test.ts +0 -1
  48. package/tests/plugin-engine.test.ts +1102 -0
  49. package/tests/sudo.test.ts +405 -0
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -14,6 +14,7 @@ import {
14
14
  executeAfterOperation,
15
15
  validateFieldRules,
16
16
  ValidationError,
17
+ DatabaseError,
17
18
  } from '../hooks/index.js'
18
19
  import { processNestedOperations } from './nested-operations.js'
19
20
  import { getDbKey } from '../lib/case-utils.js'
@@ -130,6 +131,69 @@ export type ServerActionProps =
130
131
  | { listKey: string; action: 'create'; data: Record<string, unknown> }
131
132
  | { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
132
133
  | { listKey: string; action: 'delete'; id: string }
134
+
135
+ /**
136
+ * Parse Prisma error and convert to user-friendly DatabaseError
137
+ */
138
+ function parsePrismaError(error: unknown, listConfig: ListConfig): Error {
139
+ // Check if it's a Prisma error
140
+ if (
141
+ error &&
142
+ typeof error === 'object' &&
143
+ 'code' in error &&
144
+ 'meta' in error &&
145
+ typeof error.code === 'string'
146
+ ) {
147
+ const prismaError = error as { code: string; meta?: { target?: string[] }; message?: string }
148
+
149
+ // Handle unique constraint violation
150
+ if (prismaError.code === 'P2002') {
151
+ const target = prismaError.meta?.target
152
+ const fieldErrors: Record<string, string> = {}
153
+
154
+ if (target && Array.isArray(target)) {
155
+ // Get field names from the constraint target
156
+ for (const fieldName of target) {
157
+ // Get the field config to get a better label
158
+ const fieldConfig = listConfig.fields[fieldName]
159
+ const label = fieldName.charAt(0).toUpperCase() + fieldName.slice(1)
160
+
161
+ if (fieldConfig) {
162
+ fieldErrors[fieldName] = `This ${label.toLowerCase()} is already in use`
163
+ } else {
164
+ fieldErrors[fieldName] = `This value is already in use`
165
+ }
166
+ }
167
+
168
+ // Create a user-friendly general message
169
+ const fieldLabels = target.map((f) => f.charAt(0).toUpperCase() + f.slice(1)).join(', ')
170
+ return new DatabaseError(
171
+ `${fieldLabels} must be unique. The value you entered is already in use.`,
172
+ fieldErrors,
173
+ prismaError.code,
174
+ )
175
+ }
176
+
177
+ return new DatabaseError('A record with this value already exists', {}, prismaError.code)
178
+ }
179
+
180
+ // Handle other Prisma errors - return generic message
181
+ return new DatabaseError(
182
+ prismaError.message || 'A database error occurred',
183
+ {},
184
+ prismaError.code,
185
+ )
186
+ }
187
+
188
+ // Not a Prisma error, return as-is if it's already an Error
189
+ if (error instanceof Error) {
190
+ return error
191
+ }
192
+
193
+ // Unknown error type
194
+ return new Error('An unknown error occurred')
195
+ }
196
+
133
197
  /**
134
198
  * Create an access-controlled context
135
199
  *
@@ -144,14 +208,27 @@ export function getContext<
144
208
  >(
145
209
  config: TConfig,
146
210
  prisma: TPrisma,
147
- session: Session,
211
+ session: Session | null,
148
212
  storage?: StorageUtils,
213
+ _isSudo: boolean = false,
149
214
  ): {
150
215
  db: AccessControlledDB<TPrisma>
151
- session: Session
216
+ session: Session | null
152
217
  prisma: TPrisma
153
218
  storage: StorageUtils
219
+ plugins: Record<string, unknown>
154
220
  serverAction: (props: ServerActionProps) => Promise<unknown>
221
+ _isSudo: boolean
222
+ sudo: () => {
223
+ db: AccessControlledDB<TPrisma>
224
+ session: Session | null
225
+ prisma: TPrisma
226
+ storage: StorageUtils
227
+ plugins: Record<string, unknown>
228
+ serverAction: (props: ServerActionProps) => Promise<unknown>
229
+ sudo: () => unknown
230
+ _isSudo: boolean
231
+ }
155
232
  } {
156
233
  // Initialize db object - will be populated with access-controlled operations
157
234
  // Type is intentionally broad to allow dynamic model access
@@ -185,6 +262,8 @@ export function getContext<
185
262
  )
186
263
  },
187
264
  },
265
+ plugins: {}, // Will be populated with plugin runtime services
266
+ _isSudo,
188
267
  }
189
268
 
190
269
  // Create access-controlled operations for each list
@@ -201,29 +280,113 @@ export function getContext<
201
280
  }
202
281
  }
203
282
 
283
+ // Execute plugin runtime functions and populate context.plugins
284
+ // Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
285
+ const pluginsToExecute = config._plugins || config.plugins || []
286
+ for (const plugin of pluginsToExecute) {
287
+ if (plugin.runtime) {
288
+ try {
289
+ context.plugins[plugin.name] = plugin.runtime(context)
290
+ } catch (error) {
291
+ console.error(`Error executing runtime for plugin "${plugin.name}":`, error)
292
+ // Continue with other plugins even if one fails
293
+ }
294
+ }
295
+ }
296
+
204
297
  // Generic server action handler with discriminated union for type safety
205
- async function serverAction(props: ServerActionProps): Promise<unknown> {
298
+ // Returns a result object instead of throwing to work properly in Next.js production
299
+ async function serverAction(
300
+ props: ServerActionProps,
301
+ ): Promise<
302
+ | { success: true; data: unknown }
303
+ | { success: false; error: string; fieldErrors?: Record<string, string> }
304
+ > {
206
305
  const dbKey = getDbKey(props.listKey)
306
+ const listConfig = config.lists[props.listKey]
307
+
308
+ if (!listConfig) {
309
+ return {
310
+ success: false,
311
+ error: `List "${props.listKey}" not found in configuration`,
312
+ }
313
+ }
314
+
207
315
  const model = db[dbKey] as {
208
316
  create: (args: { data: Record<string, unknown> }) => Promise<unknown>
209
317
  update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<unknown>
210
318
  delete: (args: { where: { id: string } }) => Promise<unknown>
211
319
  }
212
320
 
213
- if (props.action === 'create') {
214
- return await model.create({ data: props.data })
215
- } else if (props.action === 'update') {
216
- return await model.update({
217
- where: { id: props.id },
218
- data: props.data,
219
- })
220
- } else if (props.action === 'delete') {
221
- return await model.delete({
222
- where: { id: props.id },
223
- })
321
+ try {
322
+ let result: unknown = null
323
+
324
+ if (props.action === 'create') {
325
+ result = await model.create({ data: props.data })
326
+ } else if (props.action === 'update') {
327
+ result = await model.update({
328
+ where: { id: props.id },
329
+ data: props.data,
330
+ })
331
+ } else if (props.action === 'delete') {
332
+ result = await model.delete({
333
+ where: { id: props.id },
334
+ })
335
+ }
336
+
337
+ // Check for access denial (null return from access-controlled operations)
338
+ if (result === null) {
339
+ return {
340
+ success: false,
341
+ error: 'Access denied or operation failed',
342
+ }
343
+ }
344
+
345
+ return {
346
+ success: true,
347
+ data: result,
348
+ }
349
+ } catch (error) {
350
+ // Handle ValidationError (has fieldErrors)
351
+ if (error instanceof ValidationError) {
352
+ return {
353
+ success: false,
354
+ error: error.message,
355
+ fieldErrors: error.fieldErrors,
356
+ }
357
+ }
358
+
359
+ // Handle DatabaseError (has fieldErrors)
360
+ if (error instanceof DatabaseError) {
361
+ return {
362
+ success: false,
363
+ error: error.message,
364
+ fieldErrors: error.fieldErrors,
365
+ }
366
+ }
367
+
368
+ // Parse and convert Prisma errors to user-friendly DatabaseError
369
+ const dbError = parsePrismaError(error, listConfig)
370
+ if (dbError instanceof DatabaseError) {
371
+ return {
372
+ success: false,
373
+ error: dbError.message,
374
+ fieldErrors: dbError.fieldErrors,
375
+ }
376
+ }
377
+
378
+ // Generic error fallback
379
+ return {
380
+ success: false,
381
+ error: dbError.message,
382
+ }
224
383
  }
384
+ }
225
385
 
226
- return null
386
+ // Sudo function - creates a new context that bypasses access control
387
+ // but still executes all hooks and validation
388
+ function sudo() {
389
+ return getContext(config, prisma, session, context.storage, true)
227
390
  }
228
391
 
229
392
  return {
@@ -231,7 +394,10 @@ export function getContext<
231
394
  session,
232
395
  prisma,
233
396
  storage: context.storage,
397
+ plugins: context.plugins,
234
398
  serverAction,
399
+ sudo,
400
+ _isSudo,
235
401
  }
236
402
  }
237
403
 
@@ -242,25 +408,29 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
242
408
  listName: string,
243
409
  listConfig: ListConfig,
244
410
  prisma: TPrisma,
245
- context: AccessContext,
411
+ context: AccessContext<TPrisma>,
246
412
  config: OpenSaasConfig,
247
413
  ) {
248
414
  return async (args: { where: { id: string }; include?: Record<string, unknown> }) => {
249
- // Check query access
250
- const queryAccess = listConfig.access?.operation?.query
251
- const accessResult = await checkAccess(queryAccess, {
252
- session: context.session,
253
- context,
254
- })
415
+ // Check query access (skip if sudo mode)
416
+ let where: Record<string, unknown> = args.where
417
+ if (!context._isSudo) {
418
+ const queryAccess = listConfig.access?.operation?.query
419
+ const accessResult = await checkAccess(queryAccess, {
420
+ session: context.session,
421
+ context,
422
+ })
255
423
 
256
- if (accessResult === false) {
257
- return null
258
- }
424
+ if (accessResult === false) {
425
+ return null
426
+ }
259
427
 
260
- // Merge access filter with where clause
261
- const where = mergeFilters(args.where, accessResult)
262
- if (where === null) {
263
- return null
428
+ // Merge access filter with where clause
429
+ const mergedWhere = mergeFilters(args.where, accessResult)
430
+ if (mergedWhere === null) {
431
+ return null
432
+ }
433
+ where = mergedWhere
264
434
  }
265
435
 
266
436
  // Build include with access control filters
@@ -290,12 +460,13 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
290
460
  }
291
461
 
292
462
  // Filter readable fields and apply resolveOutput hooks (including nested relationships)
463
+ // Pass sudo flag through context to skip field-level access checks
293
464
  const filtered = await filterReadableFields(
294
465
  item,
295
466
  listConfig.fields,
296
467
  {
297
468
  session: context.session,
298
- context,
469
+ context: { ...context, _isSudo: context._isSudo },
299
470
  },
300
471
  config,
301
472
  0,
@@ -323,7 +494,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
323
494
  listName: string,
324
495
  listConfig: ListConfig,
325
496
  prisma: TPrisma,
326
- context: AccessContext,
497
+ context: AccessContext<TPrisma>,
327
498
  config: OpenSaasConfig,
328
499
  ) {
329
500
  return async (args?: {
@@ -332,21 +503,25 @@ function createFindMany<TPrisma extends PrismaClientLike>(
332
503
  skip?: number
333
504
  include?: Record<string, unknown>
334
505
  }) => {
335
- // Check query access
336
- const queryAccess = listConfig.access?.operation?.query
337
- const accessResult = await checkAccess(queryAccess, {
338
- session: context.session,
339
- context,
340
- })
506
+ // Check query access (skip if sudo mode)
507
+ let where: Record<string, unknown> | undefined = args?.where
508
+ if (!context._isSudo) {
509
+ const queryAccess = listConfig.access?.operation?.query
510
+ const accessResult = await checkAccess(queryAccess, {
511
+ session: context.session,
512
+ context,
513
+ })
341
514
 
342
- if (accessResult === false) {
343
- return []
344
- }
515
+ if (accessResult === false) {
516
+ return []
517
+ }
345
518
 
346
- // Merge access filter with where clause
347
- const where = mergeFilters(args?.where, accessResult)
348
- if (where === null) {
349
- return []
519
+ // Merge access filter with where clause
520
+ const mergedWhere = mergeFilters(args?.where, accessResult)
521
+ if (mergedWhere === null) {
522
+ return []
523
+ }
524
+ where = mergedWhere
350
525
  }
351
526
 
352
527
  // Build include with access control filters
@@ -374,6 +549,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
374
549
  })
375
550
 
376
551
  // Filter readable fields for each item and apply resolveOutput hooks (including nested relationships)
552
+ // Pass sudo flag through context to skip field-level access checks
377
553
  const filtered = await Promise.all(
378
554
  items.map((item: Record<string, unknown>) =>
379
555
  filterReadableFields(
@@ -381,7 +557,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
381
557
  listConfig.fields,
382
558
  {
383
559
  session: context.session,
384
- context,
560
+ context: { ...context, _isSudo: context._isSudo },
385
561
  },
386
562
  config,
387
563
  0,
@@ -415,19 +591,21 @@ function createCreate<TPrisma extends PrismaClientLike>(
415
591
  listName: string,
416
592
  listConfig: ListConfig,
417
593
  prisma: TPrisma,
418
- context: AccessContext,
594
+ context: AccessContext<TPrisma>,
419
595
  config: OpenSaasConfig,
420
596
  ) {
421
597
  return async (args: { data: Record<string, unknown> }) => {
422
- // 1. Check create access
423
- const createAccess = listConfig.access?.operation?.create
424
- const accessResult = await checkAccess(createAccess, {
425
- session: context.session,
426
- context,
427
- })
598
+ // 1. Check create access (skip if sudo mode)
599
+ if (!context._isSudo) {
600
+ const createAccess = listConfig.access?.operation?.create
601
+ const accessResult = await checkAccess(createAccess, {
602
+ session: context.session,
603
+ context,
604
+ })
428
605
 
429
- if (accessResult === false) {
430
- return null
606
+ if (accessResult === false) {
607
+ return null
608
+ }
431
609
  }
432
610
 
433
611
  // 2. Execute list-level resolveInput hook
@@ -459,10 +637,10 @@ function createCreate<TPrisma extends PrismaClientLike>(
459
637
  throw new ValidationError(validation.errors, validation.fieldErrors)
460
638
  }
461
639
 
462
- // 5. Filter writable fields (field-level access control)
640
+ // 5. Filter writable fields (field-level access control, skip if sudo mode)
463
641
  const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'create', {
464
642
  session: context.session,
465
- context,
643
+ context: { ...context, _isSudo: context._isSudo },
466
644
  })
467
645
 
468
646
  // 5.5. Process nested relationship operations
@@ -509,12 +687,13 @@ function createCreate<TPrisma extends PrismaClientLike>(
509
687
  )
510
688
 
511
689
  // 11. Filter readable fields and apply resolveOutput hooks (including nested relationships)
690
+ // Pass sudo flag through context to skip field-level access checks
512
691
  const filtered = await filterReadableFields(
513
692
  item,
514
693
  listConfig.fields,
515
694
  {
516
695
  session: context.session,
517
- context,
696
+ context: { ...context, _isSudo: context._isSudo },
518
697
  },
519
698
  config,
520
699
  0,
@@ -532,7 +711,7 @@ function createUpdate<TPrisma extends PrismaClientLike>(
532
711
  listName: string,
533
712
  listConfig: ListConfig,
534
713
  prisma: TPrisma,
535
- context: AccessContext,
714
+ context: AccessContext<TPrisma>,
536
715
  config: OpenSaasConfig,
537
716
  ) {
538
717
  return async (args: { where: { id: string }; data: Record<string, unknown> }) => {
@@ -548,27 +727,29 @@ function createUpdate<TPrisma extends PrismaClientLike>(
548
727
  return null
549
728
  }
550
729
 
551
- // 2. Check update access
552
- const updateAccess = listConfig.access?.operation?.update
553
- const accessResult = await checkAccess(updateAccess, {
554
- session: context.session,
555
- item,
556
- context,
557
- })
558
-
559
- if (accessResult === false) {
560
- return null
561
- }
562
-
563
- // If access returns a filter, check if item matches
564
- if (typeof accessResult === 'object') {
565
- const matchesFilter = await model.findFirst({
566
- where: mergeFilters(args.where, accessResult),
730
+ // 2. Check update access (skip if sudo mode)
731
+ if (!context._isSudo) {
732
+ const updateAccess = listConfig.access?.operation?.update
733
+ const accessResult = await checkAccess(updateAccess, {
734
+ session: context.session,
735
+ item,
736
+ context,
567
737
  })
568
738
 
569
- if (!matchesFilter) {
739
+ if (accessResult === false) {
570
740
  return null
571
741
  }
742
+
743
+ // If access returns a filter, check if item matches
744
+ if (typeof accessResult === 'object') {
745
+ const matchesFilter = await model.findFirst({
746
+ where: mergeFilters(args.where, accessResult),
747
+ })
748
+
749
+ if (!matchesFilter) {
750
+ return null
751
+ }
752
+ }
572
753
  }
573
754
 
574
755
  // 3. Execute list-level resolveInput hook
@@ -603,11 +784,11 @@ function createUpdate<TPrisma extends PrismaClientLike>(
603
784
  throw new ValidationError(validation.errors, validation.fieldErrors)
604
785
  }
605
786
 
606
- // 6. Filter writable fields (field-level access control)
787
+ // 6. Filter writable fields (field-level access control, skip if sudo mode)
607
788
  const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'update', {
608
789
  session: context.session,
609
790
  item,
610
- context,
791
+ context: { ...context, _isSudo: context._isSudo },
611
792
  })
612
793
 
613
794
  // 6.5. Process nested relationship operations
@@ -660,12 +841,13 @@ function createUpdate<TPrisma extends PrismaClientLike>(
660
841
  )
661
842
 
662
843
  // 12. Filter readable fields and apply resolveOutput hooks (including nested relationships)
844
+ // Pass sudo flag through context to skip field-level access checks
663
845
  const filtered = await filterReadableFields(
664
846
  updated,
665
847
  listConfig.fields,
666
848
  {
667
849
  session: context.session,
668
- context,
850
+ context: { ...context, _isSudo: context._isSudo },
669
851
  },
670
852
  config,
671
853
  0,
@@ -683,7 +865,7 @@ function createDelete<TPrisma extends PrismaClientLike>(
683
865
  listName: string,
684
866
  listConfig: ListConfig,
685
867
  prisma: TPrisma,
686
- context: AccessContext,
868
+ context: AccessContext<TPrisma>,
687
869
  ) {
688
870
  return async (args: { where: { id: string } }) => {
689
871
  // 1. Fetch the item to pass to access control and hooks
@@ -698,27 +880,29 @@ function createDelete<TPrisma extends PrismaClientLike>(
698
880
  return null
699
881
  }
700
882
 
701
- // 2. Check delete access
702
- const deleteAccess = listConfig.access?.operation?.delete
703
- const accessResult = await checkAccess(deleteAccess, {
704
- session: context.session,
705
- item,
706
- context,
707
- })
708
-
709
- if (accessResult === false) {
710
- return null
711
- }
712
-
713
- // If access returns a filter, check if item matches
714
- if (typeof accessResult === 'object') {
715
- const matchesFilter = await model.findFirst({
716
- where: mergeFilters(args.where, accessResult),
883
+ // 2. Check delete access (skip if sudo mode)
884
+ if (!context._isSudo) {
885
+ const deleteAccess = listConfig.access?.operation?.delete
886
+ const accessResult = await checkAccess(deleteAccess, {
887
+ session: context.session,
888
+ item,
889
+ context,
717
890
  })
718
891
 
719
- if (!matchesFilter) {
892
+ if (accessResult === false) {
720
893
  return null
721
894
  }
895
+
896
+ // If access returns a filter, check if item matches
897
+ if (typeof accessResult === 'object') {
898
+ const matchesFilter = await model.findFirst({
899
+ where: mergeFilters(args.where, accessResult),
900
+ })
901
+
902
+ if (!matchesFilter) {
903
+ return null
904
+ }
905
+ }
722
906
  }
723
907
 
724
908
  // 3. Execute field-level beforeOperation hooks (side effects only)
@@ -764,24 +948,28 @@ function createCount<TPrisma extends PrismaClientLike>(
764
948
  listName: string,
765
949
  listConfig: ListConfig,
766
950
  prisma: TPrisma,
767
- context: AccessContext,
951
+ context: AccessContext<TPrisma>,
768
952
  ) {
769
953
  return async (args?: { where?: Record<string, unknown> }) => {
770
- // Check query access
771
- const queryAccess = listConfig.access?.operation?.query
772
- const accessResult = await checkAccess(queryAccess, {
773
- session: context.session,
774
- context,
775
- })
954
+ // Check query access (skip if sudo mode)
955
+ let where: Record<string, unknown> | undefined = args?.where
956
+ if (!context._isSudo) {
957
+ const queryAccess = listConfig.access?.operation?.query
958
+ const accessResult = await checkAccess(queryAccess, {
959
+ session: context.session,
960
+ context,
961
+ })
776
962
 
777
- if (accessResult === false) {
778
- return 0
779
- }
963
+ if (accessResult === false) {
964
+ return 0
965
+ }
780
966
 
781
- // Merge access filter with where clause
782
- const where = mergeFilters(args?.where, accessResult)
783
- if (where === null) {
784
- return 0
967
+ // Merge access filter with where clause
968
+ const mergedWhere = mergeFilters(args?.where, accessResult)
969
+ if (mergedWhere === null) {
970
+ return 0
971
+ }
972
+ where = mergedWhere
785
973
  }
786
974
 
787
975
  // Execute count
@@ -59,7 +59,7 @@ export function text(options?: Omit<TextField, 'type'>): TextField {
59
59
  return z.union([withMax, z.undefined()])
60
60
  }
61
61
 
62
- return !isRequired ? withMax.optional() : withMax
62
+ return !isRequired ? withMax.optional().nullable() : withMax
63
63
  },
64
64
  getPrismaType: () => {
65
65
  const validation = options?.validation
@@ -122,7 +122,7 @@ export function integer(options?: Omit<IntegerField, 'type'>): IntegerField {
122
122
  : withMin
123
123
 
124
124
  return !options?.validation?.isRequired || operation === 'update'
125
- ? withMax.optional()
125
+ ? withMax.optional().nullable()
126
126
  : withMax
127
127
  },
128
128
  getPrismaType: () => {
@@ -152,7 +152,7 @@ export function checkbox(options?: Omit<CheckboxField, 'type'>): CheckboxField {
152
152
  type: 'checkbox',
153
153
  ...options,
154
154
  getZodSchema: () => {
155
- return z.boolean().optional()
155
+ return z.boolean().optional().nullable()
156
156
  },
157
157
  getPrismaType: () => {
158
158
  const hasDefault = options?.defaultValue !== undefined
@@ -184,7 +184,7 @@ export function timestamp(options?: Omit<TimestampField, 'type'>): TimestampFiel
184
184
  type: 'timestamp',
185
185
  ...options,
186
186
  getZodSchema: () => {
187
- return z.union([z.date(), z.iso.datetime()]).optional()
187
+ return z.union([z.date(), z.iso.datetime()]).optional().nullable()
188
188
  },
189
189
  getPrismaType: () => {
190
190
  let modifiers = '?'
@@ -347,6 +347,7 @@ export function password(options?: Omit<PasswordField, 'type'>): PasswordField {
347
347
  message: `${formatFieldName(fieldName)} must be text`,
348
348
  })
349
349
  .optional()
350
+ .nullable()
350
351
  }
351
352
  },
352
353
  getPrismaType: () => {
@@ -386,7 +387,7 @@ export function select(options: Omit<SelectField, 'type'>): SelectField {
386
387
  })
387
388
 
388
389
  if (!options.validation?.isRequired || operation === 'update') {
389
- schema = schema.optional()
390
+ schema = schema.optional().nullable()
390
391
  }
391
392
 
392
393
  return schema
@@ -499,8 +500,8 @@ export function json(options?: Omit<JsonField, 'type'>): JsonField {
499
500
  // Required in update mode: can be undefined for partial updates
500
501
  return z.union([baseSchema, z.undefined()])
501
502
  } else {
502
- // Not required: can be undefined
503
- return baseSchema.optional()
503
+ // Not required: can be undefined or null
504
+ return baseSchema.optional().nullable()
504
505
  }
505
506
  },
506
507
  getPrismaType: () => {