@opensaas/stack-core 0.1.0 → 0.1.2

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 (45) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +11 -0
  3. package/CLAUDE.md +264 -0
  4. package/LICENSE +21 -0
  5. package/dist/access/engine.d.ts +1 -1
  6. package/dist/access/engine.d.ts.map +1 -1
  7. package/dist/access/engine.js +22 -5
  8. package/dist/access/engine.js.map +1 -1
  9. package/dist/access/index.d.ts +1 -1
  10. package/dist/access/index.d.ts.map +1 -1
  11. package/dist/access/index.js.map +1 -1
  12. package/dist/access/types.d.ts +33 -0
  13. package/dist/access/types.d.ts.map +1 -1
  14. package/dist/config/index.d.ts +2 -1
  15. package/dist/config/index.d.ts.map +1 -1
  16. package/dist/config/index.js.map +1 -1
  17. package/dist/config/types.d.ts +236 -1
  18. package/dist/config/types.d.ts.map +1 -1
  19. package/dist/context/index.d.ts +4 -2
  20. package/dist/context/index.d.ts.map +1 -1
  21. package/dist/context/index.js +32 -54
  22. package/dist/context/index.js.map +1 -1
  23. package/dist/context/nested-operations.d.ts.map +1 -1
  24. package/dist/context/nested-operations.js +45 -2
  25. package/dist/context/nested-operations.js.map +1 -1
  26. package/dist/fields/index.d.ts +45 -1
  27. package/dist/fields/index.d.ts.map +1 -1
  28. package/dist/fields/index.js +81 -0
  29. package/dist/fields/index.js.map +1 -1
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js.map +1 -1
  33. package/package.json +2 -1
  34. package/src/access/engine.ts +34 -2
  35. package/src/access/index.ts +1 -0
  36. package/src/access/types.ts +47 -0
  37. package/src/config/index.ts +9 -0
  38. package/src/config/types.ts +246 -0
  39. package/src/context/index.ts +46 -63
  40. package/src/context/nested-operations.ts +70 -2
  41. package/src/fields/index.ts +85 -0
  42. package/src/index.ts +4 -0
  43. package/tests/nested-access-and-hooks.test.ts +903 -0
  44. package/tsconfig.tsbuildinfo +1 -1
  45. package/vitest.config.ts +1 -1
@@ -277,6 +277,18 @@ export type RelationshipField = BaseFieldConfig<string | string[], string | stri
277
277
  }
278
278
  }
279
279
 
280
+ export type JsonField = BaseFieldConfig<unknown, unknown> & {
281
+ type: 'json'
282
+ validation?: {
283
+ isRequired?: boolean
284
+ }
285
+ ui?: {
286
+ placeholder?: string
287
+ rows?: number
288
+ formatted?: boolean
289
+ }
290
+ }
291
+
280
292
  export type FieldConfig =
281
293
  | TextField
282
294
  | IntegerField
@@ -285,6 +297,7 @@ export type FieldConfig =
285
297
  | PasswordField
286
298
  | SelectField
287
299
  | RelationshipField
300
+ | JsonField
288
301
  | BaseFieldConfig // Allow any field extending BaseFieldConfig (for third-party fields)
289
302
 
290
303
  /**
@@ -351,6 +364,10 @@ export type ListConfig<T = any> = {
351
364
  operation?: OperationAccess<T>
352
365
  }
353
366
  hooks?: Hooks<T>
367
+ /**
368
+ * MCP server configuration for this list
369
+ */
370
+ mcp?: ListMcpConfig
354
371
  }
355
372
 
356
373
  /**
@@ -462,6 +479,226 @@ export type UIConfig = {
462
479
  theme?: ThemeConfig
463
480
  }
464
481
 
482
+ /**
483
+ * MCP (Model Context Protocol) configuration
484
+ */
485
+
486
+ /**
487
+ * Configuration for which CRUD tools to enable for a list
488
+ */
489
+ export type McpToolsConfig = {
490
+ /**
491
+ * Enable read/query tool
492
+ * @default true
493
+ */
494
+ read?: boolean
495
+ /**
496
+ * Enable create tool
497
+ * @default true
498
+ */
499
+ create?: boolean
500
+ /**
501
+ * Enable update tool
502
+ * @default true
503
+ */
504
+ update?: boolean
505
+ /**
506
+ * Enable delete tool
507
+ * @default true
508
+ */
509
+ delete?: boolean
510
+ }
511
+
512
+ /**
513
+ * Custom MCP tool definition
514
+ * Allows developers to add custom tools for specific lists
515
+ */
516
+ export type McpCustomTool = {
517
+ /**
518
+ * Unique name for the tool
519
+ */
520
+ name: string
521
+ /**
522
+ * Description of what the tool does
523
+ */
524
+ description: string
525
+ /**
526
+ * Input schema (Zod schema)
527
+ */
528
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
529
+ inputSchema: any
530
+ /**
531
+ * Handler function that executes the tool
532
+ */
533
+ handler: (args: {
534
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
535
+ input: any
536
+ context: import('../access/types.js').AccessContext
537
+ }) => Promise<unknown>
538
+ }
539
+
540
+ /**
541
+ * List-level MCP configuration
542
+ */
543
+ export type ListMcpConfig = {
544
+ /**
545
+ * Enable MCP tools for this list
546
+ * @default true
547
+ */
548
+ enabled?: boolean
549
+ /**
550
+ * Configure which CRUD tools to enable
551
+ */
552
+ tools?: McpToolsConfig
553
+ /**
554
+ * Custom tools specific to this list
555
+ */
556
+ customTools?: McpCustomTool[]
557
+ }
558
+
559
+ /**
560
+ * Better Auth OAuth configuration for MCP
561
+ */
562
+ export type McpAuthConfig = {
563
+ /**
564
+ * Authentication type - currently only Better Auth is supported
565
+ */
566
+ type: 'better-auth'
567
+ /**
568
+ * Path to login page for OAuth flow
569
+ */
570
+ loginPage: string
571
+ /**
572
+ * OAuth scopes to request
573
+ * @default ["openid", "profile", "email"]
574
+ */
575
+ scopes?: string[]
576
+ /**
577
+ * Optional OIDC configuration
578
+ */
579
+ oidcConfig?: {
580
+ /**
581
+ * Code expiration time in seconds
582
+ * @default 600
583
+ */
584
+ codeExpiresIn?: number
585
+ /**
586
+ * Access token expiration time in seconds
587
+ * @default 3600
588
+ */
589
+ accessTokenExpiresIn?: number
590
+ /**
591
+ * Refresh token expiration time in seconds
592
+ * @default 604800
593
+ */
594
+ refreshTokenExpiresIn?: number
595
+ /**
596
+ * Default scope for OAuth requests
597
+ * @default "openid"
598
+ */
599
+ defaultScope?: string
600
+ /**
601
+ * Additional scopes to support
602
+ */
603
+ scopes?: string[]
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Global MCP server configuration
609
+ */
610
+ export type McpConfig = {
611
+ /**
612
+ * Enable MCP server globally
613
+ * @default false
614
+ */
615
+ enabled?: boolean
616
+ /**
617
+ * Base path for MCP API routes
618
+ * @default "/api/mcp"
619
+ */
620
+ basePath?: string
621
+ /**
622
+ * Authentication configuration
623
+ * Required when MCP is enabled
624
+ */
625
+ auth?: McpAuthConfig
626
+ /**
627
+ * Default tool configuration for all lists
628
+ * Can be overridden per-list
629
+ */
630
+ defaultTools?: McpToolsConfig
631
+ /**
632
+ * Resource identifier for OAuth protected resource metadata
633
+ * @default "https://yourdomain.com"
634
+ */
635
+ resource?: string
636
+ }
637
+
638
+ /**
639
+ * Storage configuration for file uploads
640
+ * Maps storage provider names to their configurations
641
+ *
642
+ * @example
643
+ * ```typescript
644
+ * storage: {
645
+ * avatars: s3Storage({ bucket: 'my-avatars', region: 'us-east-1' }),
646
+ * documents: localStorage({ uploadDir: './uploads', serveUrl: '/api/files' })
647
+ * }
648
+ * ```
649
+ */
650
+ /**
651
+ * File metadata stored in the database (as JSON)
652
+ * Used by file upload fields to track uploaded files
653
+ */
654
+ export interface FileMetadata {
655
+ /** Generated filename in storage */
656
+ filename: string
657
+ /** Original filename from upload */
658
+ originalFilename: string
659
+ /** Public URL to access the file */
660
+ url: string
661
+ /** MIME type */
662
+ mimeType: string
663
+ /** File size in bytes */
664
+ size: number
665
+ /** Upload timestamp */
666
+ uploadedAt: string
667
+ /** Storage provider name */
668
+ storageProvider: string
669
+ /** Additional provider-specific metadata */
670
+ metadata?: Record<string, unknown>
671
+ }
672
+
673
+ /**
674
+ * Image-specific metadata (extends FileMetadata)
675
+ * Includes dimensions and optional transformations
676
+ */
677
+ export interface ImageMetadata extends FileMetadata {
678
+ /** Image width in pixels */
679
+ width: number
680
+ /** Image height in pixels */
681
+ height: number
682
+ /** Generated image transformations/variants */
683
+ transformations?: Record<string, ImageTransformationResult>
684
+ }
685
+
686
+ /**
687
+ * Result of an image transformation
688
+ */
689
+ export interface ImageTransformationResult {
690
+ /** URL to the transformed image */
691
+ url: string
692
+ /** Width in pixels */
693
+ width: number
694
+ /** Height in pixels */
695
+ height: number
696
+ /** File size in bytes */
697
+ size: number
698
+ }
699
+
700
+ export type StorageConfig = Record<string, { type: string; [key: string]: unknown }>
701
+
465
702
  /**
466
703
  * Main configuration type
467
704
  */
@@ -470,6 +707,15 @@ export type OpenSaasConfig = {
470
707
  lists: Record<string, ListConfig>
471
708
  session?: SessionConfig
472
709
  ui?: UIConfig
710
+ /**
711
+ * MCP (Model Context Protocol) server configuration
712
+ */
713
+ mcp?: McpConfig
714
+ /**
715
+ * Storage configuration for file/image uploads
716
+ * Maps named storage providers to their configurations
717
+ */
718
+ storage?: StorageConfig
473
719
  /**
474
720
  * Path where OpenSaas generates files (context, types, patched Prisma client)
475
721
  * @default ".opensaas"
@@ -1,5 +1,5 @@
1
1
  import type { OpenSaasConfig, ListConfig } from '../config/types.js'
2
- import type { Session, AccessContext, AccessControlledDB } from '../access/index.js'
2
+ import type { Session, AccessContext, AccessControlledDB, StorageUtils } from '../access/index.js'
3
3
  import {
4
4
  checkAccess,
5
5
  mergeFilters,
@@ -126,44 +126,6 @@ async function executeFieldAfterOperationHooks(
126
126
  }
127
127
  }
128
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
129
  export type ServerActionProps =
168
130
  | { listKey: string; action: 'create'; data: Record<string, unknown> }
169
131
  | { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
@@ -174,6 +136,7 @@ export type ServerActionProps =
174
136
  * @param config - OpenSaas configuration
175
137
  * @param prisma - Your Prisma client instance (pass as generic for type safety)
176
138
  * @param session - Current session object (or null if not authenticated)
139
+ * @param storage - Optional storage utilities (uploadFile, uploadImage, deleteFile, deleteImage)
177
140
  */
178
141
  export function getContext<
179
142
  TConfig extends OpenSaasConfig,
@@ -182,10 +145,12 @@ export function getContext<
182
145
  config: TConfig,
183
146
  prisma: TPrisma,
184
147
  session: Session,
148
+ storage?: StorageUtils,
185
149
  ): {
186
150
  db: AccessControlledDB<TPrisma>
187
151
  session: Session
188
152
  prisma: TPrisma
153
+ storage: StorageUtils
189
154
  serverAction: (props: ServerActionProps) => Promise<unknown>
190
155
  } {
191
156
  // Initialize db object - will be populated with access-controlled operations
@@ -193,10 +158,33 @@ export function getContext<
193
158
  const db: Record<string, unknown> = {}
194
159
 
195
160
  // Create context with db reference (will be populated below)
161
+ // Storage utilities can be provided via parameter or use default stubs
196
162
  const context: AccessContext<TPrisma> = {
197
163
  session,
198
164
  prisma: prisma as TPrisma,
199
165
  db: db as AccessControlledDB<TPrisma>,
166
+ storage: storage ?? {
167
+ uploadFile: async () => {
168
+ throw new Error(
169
+ 'No storage providers configured. Add storage providers to your opensaas.config.ts',
170
+ )
171
+ },
172
+ uploadImage: async () => {
173
+ throw new Error(
174
+ 'No storage providers configured. Add storage providers to your opensaas.config.ts',
175
+ )
176
+ },
177
+ deleteFile: async () => {
178
+ throw new Error(
179
+ 'No storage providers configured. Add storage providers to your opensaas.config.ts',
180
+ )
181
+ },
182
+ deleteImage: async () => {
183
+ throw new Error(
184
+ 'No storage providers configured. Add storage providers to your opensaas.config.ts',
185
+ )
186
+ },
187
+ },
200
188
  }
201
189
 
202
190
  // Create access-controlled operations for each list
@@ -242,6 +230,7 @@ export function getContext<
242
230
  db: db as AccessControlledDB<TPrisma>,
243
231
  session,
244
232
  prisma,
233
+ storage: context.storage,
245
234
  serverAction,
246
235
  }
247
236
  }
@@ -300,7 +289,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
300
289
  return null
301
290
  }
302
291
 
303
- // Filter readable fields (now only handles field-level access, not array filtering)
292
+ // Filter readable fields and apply resolveOutput hooks (including nested relationships)
304
293
  const filtered = await filterReadableFields(
305
294
  item,
306
295
  listConfig.fields,
@@ -309,14 +298,13 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
309
298
  context,
310
299
  },
311
300
  config,
301
+ 0,
302
+ listName,
312
303
  )
313
304
 
314
- // Execute field resolveOutput hooks (e.g., wrap password with HashedPassword)
315
- const resolved = executeFieldResolveOutputHooks(filtered, listConfig.fields, context, listName)
316
-
317
305
  // Execute field afterOperation hooks (side effects only)
318
306
  await executeFieldAfterOperationHooks(
319
- resolved,
307
+ filtered,
320
308
  undefined,
321
309
  listConfig.fields,
322
310
  'query',
@@ -324,7 +312,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
324
312
  listName,
325
313
  )
326
314
 
327
- return resolved
315
+ return filtered
328
316
  }
329
317
  }
330
318
 
@@ -385,7 +373,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
385
373
  include,
386
374
  })
387
375
 
388
- // Filter readable fields for each item (now only handles field-level access)
376
+ // Filter readable fields for each item and apply resolveOutput hooks (including nested relationships)
389
377
  const filtered = await Promise.all(
390
378
  items.map((item: Record<string, unknown>) =>
391
379
  filterReadableFields(
@@ -396,18 +384,15 @@ function createFindMany<TPrisma extends PrismaClientLike>(
396
384
  context,
397
385
  },
398
386
  config,
387
+ 0,
388
+ listName,
399
389
  ),
400
390
  ),
401
391
  )
402
392
 
403
- // Execute field resolveOutput hooks for each item
404
- const resolved = filtered.map((item) =>
405
- executeFieldResolveOutputHooks(item, listConfig.fields, context, listName),
406
- )
407
-
408
393
  // Execute field afterOperation hooks for each item (side effects only)
409
394
  await Promise.all(
410
- resolved.map((item) =>
395
+ filtered.map((item) =>
411
396
  executeFieldAfterOperationHooks(
412
397
  item,
413
398
  undefined,
@@ -419,7 +404,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
419
404
  ),
420
405
  )
421
406
 
422
- return resolved
407
+ return filtered
423
408
  }
424
409
  }
425
410
 
@@ -523,7 +508,7 @@ function createCreate<TPrisma extends PrismaClientLike>(
523
508
  listName,
524
509
  )
525
510
 
526
- // 11. Filter readable fields
511
+ // 11. Filter readable fields and apply resolveOutput hooks (including nested relationships)
527
512
  const filtered = await filterReadableFields(
528
513
  item,
529
514
  listConfig.fields,
@@ -532,12 +517,11 @@ function createCreate<TPrisma extends PrismaClientLike>(
532
517
  context,
533
518
  },
534
519
  config,
520
+ 0,
521
+ listName,
535
522
  )
536
523
 
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
524
+ return filtered
541
525
  }
542
526
  }
543
527
 
@@ -675,7 +659,7 @@ function createUpdate<TPrisma extends PrismaClientLike>(
675
659
  listName,
676
660
  )
677
661
 
678
- // 12. Filter readable fields
662
+ // 12. Filter readable fields and apply resolveOutput hooks (including nested relationships)
679
663
  const filtered = await filterReadableFields(
680
664
  updated,
681
665
  listConfig.fields,
@@ -684,12 +668,11 @@ function createUpdate<TPrisma extends PrismaClientLike>(
684
668
  context,
685
669
  },
686
670
  config,
671
+ 0,
672
+ listName,
687
673
  )
688
674
 
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
675
+ return filtered
693
676
  }
694
677
  }
695
678
 
@@ -9,6 +9,45 @@ import {
9
9
  } from '../hooks/index.js'
10
10
  import { getDbKey } from '../lib/case-utils.js'
11
11
 
12
+ /**
13
+ * Execute field-level resolveInput hooks
14
+ * Allows fields to transform their input values before database write
15
+ */
16
+ async function executeFieldResolveInputHooks(
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
+ data: Record<string, any>,
19
+ fields: Record<string, FieldConfig>,
20
+ operation: 'create' | 'update',
21
+ context: AccessContext,
22
+ listKey: string,
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ item?: any,
25
+ ): Promise<Record<string, unknown>> {
26
+ const result = { ...data }
27
+
28
+ for (const [fieldName, fieldConfig] of Object.entries(fields)) {
29
+ // Skip if field not in data
30
+ if (!(fieldName in result)) continue
31
+
32
+ // Skip if no hooks defined
33
+ if (!fieldConfig.hooks?.resolveInput) continue
34
+
35
+ // Execute field hook
36
+ const transformedValue = await fieldConfig.hooks.resolveInput({
37
+ inputValue: result[fieldName],
38
+ operation,
39
+ fieldName,
40
+ listKey,
41
+ item,
42
+ context,
43
+ })
44
+
45
+ result[fieldName] = transformedValue
46
+ }
47
+
48
+ return result
49
+ }
50
+
12
51
  /**
13
52
  * Check if a field config is a relationship field
14
53
  */
@@ -41,13 +80,32 @@ async function processNestedCreate(
41
80
  throw new Error('Access denied: Cannot create related item')
42
81
  }
43
82
 
44
- // 2. Execute resolveInput hook
83
+ // 2. Execute list-level resolveInput hook
45
84
  let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
46
85
  operation: 'create',
47
86
  resolvedData: item,
48
87
  context,
49
88
  })
50
89
 
90
+ // 2.5. Execute field-level resolveInput hooks
91
+ // We need to get the list name for this related config
92
+ // Since we don't have it directly, we'll need to find it from the config
93
+ let relatedListName = ''
94
+ for (const [listKey, listCfg] of Object.entries(config.lists)) {
95
+ if (listCfg === relatedListConfig) {
96
+ relatedListName = listKey
97
+ break
98
+ }
99
+ }
100
+
101
+ resolvedData = await executeFieldResolveInputHooks(
102
+ resolvedData,
103
+ relatedListConfig.fields,
104
+ 'create',
105
+ context,
106
+ relatedListName,
107
+ )
108
+
51
109
  // 3. Execute validateInput hook
52
110
  await executeValidateInput(relatedListConfig.hooks, {
53
111
  operation: 'create',
@@ -185,7 +243,7 @@ async function processNestedUpdate(
185
243
  throw new Error('Access denied: Cannot update related item')
186
244
  }
187
245
 
188
- // Execute resolveInput hook
246
+ // Execute list-level resolveInput hook
189
247
  const updateData = (update as Record<string, unknown>).data as Record<string, unknown>
190
248
  let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
191
249
  operation: 'update',
@@ -194,6 +252,16 @@ async function processNestedUpdate(
194
252
  context,
195
253
  })
196
254
 
255
+ // Execute field-level resolveInput hooks
256
+ resolvedData = await executeFieldResolveInputHooks(
257
+ resolvedData,
258
+ relatedListConfig.fields,
259
+ 'update',
260
+ context,
261
+ relatedListName,
262
+ item,
263
+ )
264
+
197
265
  // Execute validateInput hook
198
266
  await executeValidateInput(relatedListConfig.hooks, {
199
267
  operation: 'update',
@@ -7,6 +7,7 @@ import type {
7
7
  PasswordField,
8
8
  SelectField,
9
9
  RelationshipField,
10
+ JsonField,
10
11
  } from '../config/types.js'
11
12
  import { hashPassword, isHashedPassword, HashedPassword } from '../utils/password.js'
12
13
 
@@ -436,3 +437,87 @@ export function relationship(options: Omit<RelationshipField, 'type'>): Relation
436
437
  ...options,
437
438
  }
438
439
  }
440
+
441
+ /**
442
+ * JSON field for storing arbitrary JSON data
443
+ *
444
+ * **Features:**
445
+ * - Stores any valid JSON data (objects, arrays, primitives)
446
+ * - Stored as JSON type in database (PostgreSQL/MySQL) or TEXT in SQLite
447
+ * - Optional validation for required fields
448
+ * - UI options for formatting and display
449
+ *
450
+ * **Usage Example:**
451
+ * ```typescript
452
+ * // In opensaas.config.ts
453
+ * fields: {
454
+ * metadata: json({
455
+ * validation: { isRequired: false },
456
+ * ui: {
457
+ * placeholder: 'Enter JSON data...',
458
+ * rows: 10,
459
+ * formatted: true
460
+ * }
461
+ * }),
462
+ * settings: json({
463
+ * validation: { isRequired: true }
464
+ * })
465
+ * }
466
+ *
467
+ * // Creating with JSON data
468
+ * const item = await context.db.item.create({
469
+ * data: {
470
+ * metadata: { key: 'value', nested: { data: [1, 2, 3] } }
471
+ * }
472
+ * })
473
+ *
474
+ * // Querying returns parsed JSON
475
+ * const item = await context.db.item.findUnique({
476
+ * where: { id: '...' }
477
+ * })
478
+ * console.log(item.metadata.key) // 'value'
479
+ * ```
480
+ *
481
+ * @param options - Field configuration options
482
+ * @returns JSON field configuration
483
+ */
484
+ export function json(options?: Omit<JsonField, 'type'>): JsonField {
485
+ return {
486
+ type: 'json',
487
+ ...options,
488
+ getZodSchema: (fieldName: string, operation: 'create' | 'update') => {
489
+ const validation = options?.validation
490
+ const isRequired = validation?.isRequired
491
+
492
+ // Accept any valid JSON value
493
+ const baseSchema = z.unknown()
494
+
495
+ if (isRequired && operation === 'create') {
496
+ // Required in create mode: value must be provided
497
+ return baseSchema
498
+ } else if (isRequired && operation === 'update') {
499
+ // Required in update mode: can be undefined for partial updates
500
+ return z.union([baseSchema, z.undefined()])
501
+ } else {
502
+ // Not required: can be undefined
503
+ return baseSchema.optional()
504
+ }
505
+ },
506
+ getPrismaType: () => {
507
+ const isRequired = options?.validation?.isRequired
508
+
509
+ return {
510
+ type: 'Json',
511
+ modifiers: isRequired ? undefined : '?',
512
+ }
513
+ },
514
+ getTypeScriptType: () => {
515
+ const isRequired = options?.validation?.isRequired
516
+
517
+ return {
518
+ type: 'unknown',
519
+ optional: !isRequired,
520
+ }
521
+ },
522
+ }
523
+ }