@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +11 -0
- package/CLAUDE.md +264 -0
- package/LICENSE +21 -0
- package/dist/access/engine.d.ts +1 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +22 -5
- package/dist/access/engine.js.map +1 -1
- package/dist/access/index.d.ts +1 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/types.d.ts +33 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +2 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js.map +1 -1
- package/dist/config/types.d.ts +236 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +4 -2
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +32 -54
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +45 -2
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/fields/index.d.ts +45 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +81 -0
- package/dist/fields/index.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/access/engine.ts +34 -2
- package/src/access/index.ts +1 -0
- package/src/access/types.ts +47 -0
- package/src/config/index.ts +9 -0
- package/src/config/types.ts +246 -0
- package/src/context/index.ts +46 -63
- package/src/context/nested-operations.ts +70 -2
- package/src/fields/index.ts +85 -0
- package/src/index.ts +4 -0
- package/tests/nested-access-and-hooks.test.ts +903 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +1 -1
package/src/config/types.ts
CHANGED
|
@@ -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"
|
package/src/context/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
package/src/fields/index.ts
CHANGED
|
@@ -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
|
+
}
|