@opensaas/stack-core 0.1.7 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +202 -0
- package/CLAUDE.md +46 -1
- package/dist/access/engine.d.ts +6 -5
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +17 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +125 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/types.d.ts +39 -9
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +38 -18
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +34 -14
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin-engine.d.ts.map +1 -1
- package/dist/config/plugin-engine.js +6 -0
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +128 -21
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +5 -3
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +127 -14
- package/dist/context/index.js.map +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +9 -8
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +28 -12
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +16 -0
- package/dist/hooks/index.js.map +1 -1
- package/package.json +3 -4
- package/src/access/engine.test.ts +145 -0
- package/src/access/engine.ts +25 -6
- package/src/access/types.ts +38 -8
- package/src/config/index.ts +46 -19
- package/src/config/plugin-engine.ts +7 -0
- package/src/config/types.ts +149 -18
- package/src/context/index.ts +163 -17
- package/src/fields/index.ts +8 -7
- package/src/hooks/index.ts +63 -20
- package/tests/context.test.ts +38 -6
- package/tests/field-types.test.ts +728 -0
- package/tests/password-type-distribution.test.ts +0 -1
- package/tests/password-types.test.ts +0 -1
- package/tests/plugin-engine.test.ts +1102 -0
- package/tests/sudo.test.ts +0 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { filterWritableFields } from './engine.js'
|
|
3
|
+
|
|
4
|
+
describe('filterWritableFields', () => {
|
|
5
|
+
it('should filter out foreign key fields when their corresponding relationship field exists', async () => {
|
|
6
|
+
// Setup: Define field configs with a relationship field
|
|
7
|
+
const fieldConfigs = {
|
|
8
|
+
title: {
|
|
9
|
+
type: 'text',
|
|
10
|
+
},
|
|
11
|
+
author: {
|
|
12
|
+
type: 'relationship',
|
|
13
|
+
many: false,
|
|
14
|
+
},
|
|
15
|
+
tags: {
|
|
16
|
+
type: 'relationship',
|
|
17
|
+
many: true, // Many-to-many relationships don't have foreign keys
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Data that includes both the foreign key (authorId) and other fields
|
|
22
|
+
const data = {
|
|
23
|
+
title: 'Test Post',
|
|
24
|
+
authorId: 'user-123', // This should be filtered out
|
|
25
|
+
tagsId: 'tag-456', // This should NOT be filtered (tags is many:true)
|
|
26
|
+
author: {
|
|
27
|
+
connect: { id: 'user-123' },
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
|
|
32
|
+
session: null,
|
|
33
|
+
context: {
|
|
34
|
+
session: null,
|
|
35
|
+
_isSudo: true, // Use sudo to bypass access control checks
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
} as any,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// authorId should be filtered out
|
|
41
|
+
expect(filtered).not.toHaveProperty('authorId')
|
|
42
|
+
|
|
43
|
+
// title should remain
|
|
44
|
+
expect(filtered).toHaveProperty('title', 'Test Post')
|
|
45
|
+
|
|
46
|
+
// author relationship should remain
|
|
47
|
+
expect(filtered).toHaveProperty('author')
|
|
48
|
+
expect(filtered.author).toEqual({ connect: { id: 'user-123' } })
|
|
49
|
+
|
|
50
|
+
// tagsId should remain (tags is many:true, so no foreign key is created)
|
|
51
|
+
expect(filtered).toHaveProperty('tagsId', 'tag-456')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('should filter out system fields', async () => {
|
|
55
|
+
const fieldConfigs = {
|
|
56
|
+
title: { type: 'text' },
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const data = {
|
|
60
|
+
id: 'post-123',
|
|
61
|
+
title: 'Test',
|
|
62
|
+
createdAt: new Date(),
|
|
63
|
+
updatedAt: new Date(),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
|
|
67
|
+
session: null,
|
|
68
|
+
context: {
|
|
69
|
+
session: null,
|
|
70
|
+
_isSudo: true,
|
|
71
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
72
|
+
} as any,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// System fields should be filtered out
|
|
76
|
+
expect(filtered).not.toHaveProperty('id')
|
|
77
|
+
expect(filtered).not.toHaveProperty('createdAt')
|
|
78
|
+
expect(filtered).not.toHaveProperty('updatedAt')
|
|
79
|
+
|
|
80
|
+
// Regular fields should remain
|
|
81
|
+
expect(filtered).toHaveProperty('title', 'Test')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should handle update operation', async () => {
|
|
85
|
+
const fieldConfigs = {
|
|
86
|
+
title: { type: 'text' },
|
|
87
|
+
author: {
|
|
88
|
+
type: 'relationship',
|
|
89
|
+
many: false,
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const data = {
|
|
94
|
+
title: 'Updated Title',
|
|
95
|
+
authorId: 'user-456', // Should be filtered out
|
|
96
|
+
author: {
|
|
97
|
+
connect: { id: 'user-456' },
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'update', {
|
|
102
|
+
session: null,
|
|
103
|
+
item: { id: 'post-123' },
|
|
104
|
+
context: {
|
|
105
|
+
session: null,
|
|
106
|
+
_isSudo: true,
|
|
107
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
108
|
+
} as any,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
expect(filtered).not.toHaveProperty('authorId')
|
|
112
|
+
expect(filtered).toHaveProperty('title', 'Updated Title')
|
|
113
|
+
expect(filtered).toHaveProperty('author')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should not filter fields that happen to end with "Id" but are not foreign keys', async () => {
|
|
117
|
+
const fieldConfigs = {
|
|
118
|
+
trackingId: { type: 'text' }, // Regular field that happens to end with "Id"
|
|
119
|
+
author: {
|
|
120
|
+
type: 'relationship',
|
|
121
|
+
many: false,
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const data = {
|
|
126
|
+
trackingId: 'track-123', // Should NOT be filtered (it's a regular field)
|
|
127
|
+
authorId: 'user-456', // SHOULD be filtered (it's a foreign key)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const filtered = await filterWritableFields(data, fieldConfigs, 'create', {
|
|
131
|
+
session: null,
|
|
132
|
+
context: {
|
|
133
|
+
session: null,
|
|
134
|
+
_isSudo: true,
|
|
135
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
136
|
+
} as any,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// trackingId is a defined field, so it should remain
|
|
140
|
+
expect(filtered).toHaveProperty('trackingId', 'track-123')
|
|
141
|
+
|
|
142
|
+
// authorId is a foreign key for author relationship, so it should be filtered
|
|
143
|
+
expect(filtered).not.toHaveProperty('authorId')
|
|
144
|
+
})
|
|
145
|
+
})
|
package/src/access/engine.ts
CHANGED
|
@@ -63,7 +63,7 @@ export function getRelatedListConfig(
|
|
|
63
63
|
export async function checkAccess<T = Record<string, unknown>>(
|
|
64
64
|
accessControl: AccessControl<T> | undefined,
|
|
65
65
|
args: {
|
|
66
|
-
session: Session
|
|
66
|
+
session: Session | null
|
|
67
67
|
item?: T
|
|
68
68
|
context: AccessContext
|
|
69
69
|
},
|
|
@@ -114,7 +114,7 @@ export async function checkFieldAccess(
|
|
|
114
114
|
fieldAccess: FieldAccess | undefined,
|
|
115
115
|
operation: 'read' | 'create' | 'update',
|
|
116
116
|
args: {
|
|
117
|
-
session: Session
|
|
117
|
+
session: Session | null
|
|
118
118
|
item?: Record<string, unknown>
|
|
119
119
|
context: AccessContext & { _isSudo?: boolean }
|
|
120
120
|
},
|
|
@@ -190,7 +190,7 @@ function matchesFilter(item: Record<string, unknown>, filter: Record<string, unk
|
|
|
190
190
|
export async function buildIncludeWithAccessControl(
|
|
191
191
|
fieldConfigs: Record<string, FieldConfig>,
|
|
192
192
|
args: {
|
|
193
|
-
session: Session
|
|
193
|
+
session: Session | null
|
|
194
194
|
context: AccessContext
|
|
195
195
|
},
|
|
196
196
|
config: OpenSaasConfig,
|
|
@@ -261,7 +261,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
261
261
|
item: T,
|
|
262
262
|
fieldConfigs: Record<string, FieldConfig>,
|
|
263
263
|
args: {
|
|
264
|
-
session: Session
|
|
264
|
+
session: Session | null
|
|
265
265
|
context: AccessContext & { _isSudo?: boolean }
|
|
266
266
|
},
|
|
267
267
|
config?: OpenSaasConfig,
|
|
@@ -365,16 +365,29 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
365
365
|
*/
|
|
366
366
|
export async function filterWritableFields<T extends Record<string, unknown>>(
|
|
367
367
|
data: T,
|
|
368
|
-
fieldConfigs: Record<string, { access?: FieldAccess }>,
|
|
368
|
+
fieldConfigs: Record<string, { access?: FieldAccess; type?: string }>,
|
|
369
369
|
operation: 'create' | 'update',
|
|
370
370
|
args: {
|
|
371
|
-
session: Session
|
|
371
|
+
session: Session | null
|
|
372
372
|
item?: Record<string, unknown>
|
|
373
373
|
context: AccessContext & { _isSudo?: boolean }
|
|
374
374
|
},
|
|
375
375
|
): Promise<Partial<T>> {
|
|
376
376
|
const filtered: Record<string, unknown> = {}
|
|
377
377
|
|
|
378
|
+
// Build a set of foreign key field names to exclude
|
|
379
|
+
// Foreign keys should not be in the data when using Prisma's relation syntax
|
|
380
|
+
const foreignKeyFields = new Set<string>()
|
|
381
|
+
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
382
|
+
if (fieldConfig.type === 'relationship') {
|
|
383
|
+
// For non-many relationships, Prisma creates a foreign key field named `${fieldName}Id`
|
|
384
|
+
const relConfig = fieldConfig as { many?: boolean }
|
|
385
|
+
if (!relConfig.many) {
|
|
386
|
+
foreignKeyFields.add(`${fieldName}Id`)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
378
391
|
for (const [fieldName, value] of Object.entries(data)) {
|
|
379
392
|
const fieldConfig = fieldConfigs[fieldName]
|
|
380
393
|
|
|
@@ -383,6 +396,12 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
|
|
|
383
396
|
continue
|
|
384
397
|
}
|
|
385
398
|
|
|
399
|
+
// Skip foreign key fields (e.g., authorId) when their corresponding relationship field exists
|
|
400
|
+
// This prevents conflicts when using Prisma's relation syntax (e.g., author: { connect: { id } })
|
|
401
|
+
if (foreignKeyFields.has(fieldName)) {
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
|
|
386
405
|
// Check field access (checkFieldAccess already handles sudo mode)
|
|
387
406
|
const canWrite = await checkFieldAccess(fieldConfig?.access, operation, {
|
|
388
407
|
...args,
|
package/src/access/types.ts
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session
|
|
2
|
+
* Session interface - can be augmented by developers to add custom fields
|
|
3
|
+
*
|
|
4
|
+
* By default, Session is a permissive object that can contain any properties.
|
|
5
|
+
* To get type safety and autocomplete, use module augmentation:
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* // types/session.d.ts
|
|
10
|
+
* import '@opensaas/stack-core'
|
|
11
|
+
*
|
|
12
|
+
* declare module '@opensaas/stack-core' {
|
|
13
|
+
* interface Session {
|
|
14
|
+
* userId: string
|
|
15
|
+
* email: string
|
|
16
|
+
* role: 'admin' | 'user'
|
|
17
|
+
* }
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* After augmentation, session will be fully typed everywhere:
|
|
22
|
+
* - Access control functions
|
|
23
|
+
* - Hooks (resolveInput, validateInput, etc.)
|
|
24
|
+
* - Context object
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* // With augmentation, this is fully typed:
|
|
29
|
+
* const isAdmin: AccessControl = ({ session }) => {
|
|
30
|
+
* return session?.role === 'admin' // ✅ Autocomplete works
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
3
33
|
*/
|
|
4
|
-
export
|
|
5
|
-
userId?: string
|
|
34
|
+
export interface Session {
|
|
6
35
|
[key: string]: unknown
|
|
7
|
-
}
|
|
36
|
+
}
|
|
8
37
|
|
|
9
38
|
/**
|
|
10
39
|
* Generic Prisma model delegate type
|
|
@@ -113,14 +142,15 @@ export type StorageUtils = {
|
|
|
113
142
|
|
|
114
143
|
/**
|
|
115
144
|
* Context type (simplified for access control)
|
|
145
|
+
* Using interface instead of type to allow module augmentation
|
|
116
146
|
*/
|
|
117
|
-
export
|
|
118
|
-
session: Session
|
|
147
|
+
export interface AccessContext<TPrisma extends PrismaClientLike = PrismaClientLike> {
|
|
148
|
+
session: Session | null
|
|
119
149
|
prisma: TPrisma
|
|
120
150
|
db: AccessControlledDB<TPrisma>
|
|
121
151
|
storage: StorageUtils
|
|
152
|
+
plugins: Record<string, unknown>
|
|
122
153
|
_isSudo: boolean
|
|
123
|
-
[key: string]: unknown
|
|
124
154
|
}
|
|
125
155
|
|
|
126
156
|
/**
|
|
@@ -136,7 +166,7 @@ export type PrismaFilter<T = Record<string, unknown>> = Partial<Record<keyof T,
|
|
|
136
166
|
* - PrismaFilter: Prisma where clause to filter results
|
|
137
167
|
*/
|
|
138
168
|
export type AccessControl<T = Record<string, unknown>> = (args: {
|
|
139
|
-
session: Session
|
|
169
|
+
session: Session | null
|
|
140
170
|
item?: T // Present for update/delete operations
|
|
141
171
|
context: AccessContext
|
|
142
172
|
}) => boolean | PrismaFilter<T> | Promise<boolean | PrismaFilter<T>>
|
package/src/config/index.ts
CHANGED
|
@@ -21,40 +21,66 @@ export function config(userConfig: OpenSaasConfig): OpenSaasConfig | Promise<Ope
|
|
|
21
21
|
/**
|
|
22
22
|
* Helper function to define a list with type safety
|
|
23
23
|
*
|
|
24
|
-
* Accepts raw field configs and transforms them to inject the item type
|
|
25
|
-
* This enables proper typing in field hooks where item
|
|
24
|
+
* Accepts raw field configs and transforms them to inject the item type
|
|
25
|
+
* This enables proper typing in field hooks where item is typed correctly
|
|
26
26
|
*
|
|
27
27
|
* @example
|
|
28
28
|
* ```typescript
|
|
29
|
-
*
|
|
29
|
+
* // Basic usage (before generation)
|
|
30
|
+
* Post: list({
|
|
31
|
+
* fields: { title: text() },
|
|
32
|
+
* hooks: {
|
|
33
|
+
* resolveInput: async ({ resolvedData }) => {
|
|
34
|
+
* // resolvedData: Record<string, unknown>
|
|
35
|
+
* return resolvedData
|
|
36
|
+
* }
|
|
37
|
+
* }
|
|
38
|
+
* })
|
|
39
|
+
*
|
|
40
|
+
* // With TypeInfo (after generation)
|
|
41
|
+
* import type { Lists } from './.opensaas/lists'
|
|
30
42
|
*
|
|
31
|
-
*
|
|
32
|
-
* fields: {
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
43
|
+
* Post: list<Lists.Post.TypeInfo>({
|
|
44
|
+
* fields: { title: text() },
|
|
45
|
+
* hooks: {
|
|
46
|
+
* resolveInput: async ({ operation, resolvedData, item }) => {
|
|
47
|
+
* if (operation === 'create') {
|
|
48
|
+
* // resolvedData: Prisma.PostCreateInput
|
|
49
|
+
* // item: undefined
|
|
50
|
+
* } else {
|
|
51
|
+
* // resolvedData: Prisma.PostUpdateInput
|
|
52
|
+
* // item: Post
|
|
40
53
|
* }
|
|
41
|
-
*
|
|
54
|
+
* return resolvedData
|
|
55
|
+
* }
|
|
42
56
|
* }
|
|
43
57
|
* })
|
|
58
|
+
*
|
|
59
|
+
* // Or as a typed constant
|
|
60
|
+
* const Post: Lists.Post = list({
|
|
61
|
+
* fields: { title: text() },
|
|
62
|
+
* hooks: { ... }
|
|
63
|
+
* })
|
|
44
64
|
* ```
|
|
45
65
|
*/
|
|
46
66
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
47
|
-
export function list<
|
|
67
|
+
export function list<
|
|
68
|
+
TTypeInfo extends import('./types.js').TypeInfo = import('./types.js').TypeInfo,
|
|
69
|
+
>(config: {
|
|
48
70
|
fields: Record<string, FieldConfig>
|
|
49
71
|
access?: {
|
|
50
|
-
operation?: OperationAccess<
|
|
72
|
+
operation?: OperationAccess<TTypeInfo['item']>
|
|
51
73
|
}
|
|
52
|
-
hooks?: Hooks<
|
|
74
|
+
hooks?: Hooks<TTypeInfo['item'], TTypeInfo['inputs']['create'], TTypeInfo['inputs']['update']>
|
|
53
75
|
mcp?: import('./types.js').ListMcpConfig
|
|
54
|
-
}): ListConfig<
|
|
76
|
+
}): ListConfig<TTypeInfo['item'], TTypeInfo['inputs']['create'], TTypeInfo['inputs']['update']> {
|
|
55
77
|
// At runtime, field configs are unchanged
|
|
56
|
-
// At type level, they're transformed to inject
|
|
57
|
-
return config as ListConfig<
|
|
78
|
+
// At type level, they're transformed to inject TypeInfo types
|
|
79
|
+
return config as ListConfig<
|
|
80
|
+
TTypeInfo['item'],
|
|
81
|
+
TTypeInfo['inputs']['create'],
|
|
82
|
+
TTypeInfo['inputs']['update']
|
|
83
|
+
>
|
|
58
84
|
}
|
|
59
85
|
|
|
60
86
|
// Re-export all types
|
|
@@ -70,6 +96,7 @@ export type {
|
|
|
70
96
|
PasswordField,
|
|
71
97
|
SelectField,
|
|
72
98
|
RelationshipField,
|
|
99
|
+
TypeInfo,
|
|
73
100
|
OperationAccess,
|
|
74
101
|
Hooks,
|
|
75
102
|
FieldHooks,
|
|
@@ -257,6 +257,13 @@ export async function executePlugins(config: OpenSaasConfig): Promise<OpenSaasCo
|
|
|
257
257
|
currentConfig._pluginData.__mcpTools = mcpToolsRegistry
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
// Store plugin instances in config for runtime access
|
|
261
|
+
// This allows context creation to call plugin.runtime() functions
|
|
262
|
+
if (!currentConfig._plugins) {
|
|
263
|
+
currentConfig._plugins = []
|
|
264
|
+
}
|
|
265
|
+
currentConfig._plugins = sortedPlugins
|
|
266
|
+
|
|
260
267
|
return currentConfig
|
|
261
268
|
}
|
|
262
269
|
|
package/src/config/types.ts
CHANGED
|
@@ -345,6 +345,42 @@ export type FieldsWithItemType<TFields extends Record<string, FieldConfig>, TIte
|
|
|
345
345
|
[K in keyof TFields]: WithItemType<TFields[K], TItem>
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
/**
|
|
349
|
+
* TypeInfo interface for list type information
|
|
350
|
+
* Provides a structured way to pass all type information for a list
|
|
351
|
+
* Inspired by Keystone's TypeInfo pattern
|
|
352
|
+
*
|
|
353
|
+
* @template TKey - The list key/name (e.g., 'Post', 'User')
|
|
354
|
+
* @template TItem - The output type (Prisma model type)
|
|
355
|
+
* @template TCreateInput - The Prisma create input type
|
|
356
|
+
* @template TUpdateInput - The Prisma update input type
|
|
357
|
+
*
|
|
358
|
+
* @example
|
|
359
|
+
* ```typescript
|
|
360
|
+
* type PostTypeInfo = {
|
|
361
|
+
* key: 'Post'
|
|
362
|
+
* item: Post
|
|
363
|
+
* inputs: {
|
|
364
|
+
* create: Prisma.PostCreateInput
|
|
365
|
+
* update: Prisma.PostUpdateInput
|
|
366
|
+
* }
|
|
367
|
+
* }
|
|
368
|
+
* ```
|
|
369
|
+
*/
|
|
370
|
+
export interface TypeInfo<
|
|
371
|
+
TKey extends string = string,
|
|
372
|
+
TItem = any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
373
|
+
TCreateInput = any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
374
|
+
TUpdateInput = any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
375
|
+
> {
|
|
376
|
+
key: TKey
|
|
377
|
+
item: TItem
|
|
378
|
+
inputs: {
|
|
379
|
+
create: TCreateInput
|
|
380
|
+
update: TUpdateInput
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
348
384
|
// Generic `any` default allows OperationAccess to work with any list item type
|
|
349
385
|
// This is needed because the item type varies per list and is inferred from Prisma models
|
|
350
386
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -355,36 +391,74 @@ export type OperationAccess<T = any> = {
|
|
|
355
391
|
delete?: AccessControl<T>
|
|
356
392
|
}
|
|
357
393
|
|
|
358
|
-
|
|
394
|
+
/**
|
|
395
|
+
* Hook arguments for resolveInput hook
|
|
396
|
+
* Uses discriminated union to provide proper types based on operation
|
|
397
|
+
* - create: resolvedData is CreateInput, item is undefined
|
|
398
|
+
* - update: resolvedData is UpdateInput, item is the existing record
|
|
399
|
+
*/
|
|
400
|
+
export type ResolveInputHookArgs<
|
|
401
|
+
TOutput = Record<string, unknown>,
|
|
402
|
+
TCreateInput = Record<string, unknown>,
|
|
403
|
+
TUpdateInput = Record<string, unknown>,
|
|
404
|
+
> =
|
|
405
|
+
| {
|
|
406
|
+
operation: 'create'
|
|
407
|
+
resolvedData: TCreateInput
|
|
408
|
+
item: undefined
|
|
409
|
+
context: import('../access/types.js').AccessContext
|
|
410
|
+
}
|
|
411
|
+
| {
|
|
412
|
+
operation: 'update'
|
|
413
|
+
resolvedData: TUpdateInput
|
|
414
|
+
item: TOutput
|
|
415
|
+
context: import('../access/types.js').AccessContext
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Hook arguments for other hooks (validateInput, beforeOperation, afterOperation)
|
|
420
|
+
* These hooks receive the same structure regardless of operation
|
|
421
|
+
*/
|
|
422
|
+
export type HookArgs<
|
|
423
|
+
TOutput = Record<string, unknown>,
|
|
424
|
+
TCreateInput = Record<string, unknown>,
|
|
425
|
+
TUpdateInput = Record<string, unknown>,
|
|
426
|
+
> = {
|
|
359
427
|
operation: 'create' | 'update' | 'delete'
|
|
360
|
-
resolvedData?:
|
|
361
|
-
item?:
|
|
428
|
+
resolvedData?: TCreateInput | TUpdateInput
|
|
429
|
+
item?: TOutput
|
|
362
430
|
context: import('../access/types.js').AccessContext
|
|
363
431
|
}
|
|
364
432
|
|
|
365
|
-
export type Hooks<
|
|
366
|
-
|
|
433
|
+
export type Hooks<
|
|
434
|
+
TOutput = Record<string, unknown>,
|
|
435
|
+
TCreateInput = Record<string, unknown>,
|
|
436
|
+
TUpdateInput = Record<string, unknown>,
|
|
437
|
+
> = {
|
|
438
|
+
resolveInput?: (
|
|
439
|
+
args: ResolveInputHookArgs<TOutput, TCreateInput, TUpdateInput>,
|
|
440
|
+
) => Promise<TCreateInput | TUpdateInput>
|
|
367
441
|
validateInput?: (
|
|
368
|
-
args: HookArgs<
|
|
442
|
+
args: HookArgs<TOutput, TCreateInput, TUpdateInput> & {
|
|
369
443
|
operation: 'create' | 'update'
|
|
370
444
|
addValidationError: (msg: string) => void
|
|
371
445
|
},
|
|
372
446
|
) => Promise<void>
|
|
373
|
-
beforeOperation?: (args: HookArgs<
|
|
374
|
-
afterOperation?: (args: HookArgs<
|
|
447
|
+
beforeOperation?: (args: HookArgs<TOutput, TCreateInput, TUpdateInput>) => Promise<void>
|
|
448
|
+
afterOperation?: (args: HookArgs<TOutput, TCreateInput, TUpdateInput>) => Promise<void>
|
|
375
449
|
}
|
|
376
450
|
|
|
377
451
|
// Generic `any` default allows ListConfig to work with any list item type
|
|
378
452
|
// This is needed because the item type varies per list and is inferred from Prisma models
|
|
379
453
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
380
|
-
export type ListConfig<
|
|
454
|
+
export type ListConfig<TOutput = any, TCreateInput = any, TUpdateInput = any> = {
|
|
381
455
|
// Field configs are automatically transformed to inject the item type T
|
|
382
456
|
// This enables proper typing in field hooks where item: TItem
|
|
383
|
-
fields: FieldsWithItemType<Record<string, FieldConfig>,
|
|
457
|
+
fields: FieldsWithItemType<Record<string, FieldConfig>, TOutput>
|
|
384
458
|
access?: {
|
|
385
|
-
operation?: OperationAccess<
|
|
459
|
+
operation?: OperationAccess<TOutput>
|
|
386
460
|
}
|
|
387
|
-
hooks?: Hooks<
|
|
461
|
+
hooks?: Hooks<TOutput, TCreateInput, TUpdateInput>
|
|
388
462
|
/**
|
|
389
463
|
* MCP server configuration for this list
|
|
390
464
|
*/
|
|
@@ -396,12 +470,37 @@ export type ListConfig<T = any> = {
|
|
|
396
470
|
*/
|
|
397
471
|
export type DatabaseConfig = {
|
|
398
472
|
provider: 'postgresql' | 'mysql' | 'sqlite'
|
|
399
|
-
url: string
|
|
400
473
|
/**
|
|
401
|
-
*
|
|
402
|
-
*
|
|
474
|
+
* Factory function to create a Prisma client instance with a database adapter
|
|
475
|
+
* Required in Prisma 7+ - receives the PrismaClient class and returns a configured instance
|
|
403
476
|
*
|
|
404
|
-
*
|
|
477
|
+
* The connection URL is passed directly to the adapter, not to the config.
|
|
478
|
+
*
|
|
479
|
+
* @example SQLite with better-sqlite3
|
|
480
|
+
* ```typescript
|
|
481
|
+
* import { PrismaBetterSQLite3 } from '@prisma/adapter-better-sqlite3'
|
|
482
|
+
* import Database from 'better-sqlite3'
|
|
483
|
+
*
|
|
484
|
+
* prismaClientConstructor: (PrismaClient) => {
|
|
485
|
+
* const db = new Database(process.env.DATABASE_URL || './dev.db')
|
|
486
|
+
* const adapter = new PrismaBetterSQLite3(db)
|
|
487
|
+
* return new PrismaClient({ adapter })
|
|
488
|
+
* }
|
|
489
|
+
* ```
|
|
490
|
+
*
|
|
491
|
+
* @example PostgreSQL with pg
|
|
492
|
+
* ```typescript
|
|
493
|
+
* import { PrismaPg } from '@prisma/adapter-pg'
|
|
494
|
+
* import pg from 'pg'
|
|
495
|
+
*
|
|
496
|
+
* prismaClientConstructor: (PrismaClient) => {
|
|
497
|
+
* const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
|
|
498
|
+
* const adapter = new PrismaPg(pool)
|
|
499
|
+
* return new PrismaClient({ adapter })
|
|
500
|
+
* }
|
|
501
|
+
* ```
|
|
502
|
+
*
|
|
503
|
+
* @example Neon serverless (PostgreSQL)
|
|
405
504
|
* ```typescript
|
|
406
505
|
* import { PrismaNeon } from '@prisma/adapter-neon'
|
|
407
506
|
* import { neonConfig } from '@neondatabase/serverless'
|
|
@@ -419,7 +518,7 @@ export type DatabaseConfig = {
|
|
|
419
518
|
// Uses `any` for maximum flexibility with Prisma client constructors and adapters
|
|
420
519
|
// Different database adapters have varying type signatures that are hard to unify
|
|
421
520
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
422
|
-
prismaClientConstructor
|
|
521
|
+
prismaClientConstructor: (PrismaClientClass: any) => any
|
|
423
522
|
}
|
|
424
523
|
|
|
425
524
|
/**
|
|
@@ -846,12 +945,38 @@ export type Plugin = {
|
|
|
846
945
|
* Return value is stored in context.plugins[pluginName]
|
|
847
946
|
*/
|
|
848
947
|
runtime?: (context: import('../access/types.js').AccessContext) => unknown
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Optional: Type metadata for runtime services
|
|
951
|
+
* Enables type-safe code generation for context.plugins
|
|
952
|
+
*
|
|
953
|
+
* @example
|
|
954
|
+
* ```typescript
|
|
955
|
+
* {
|
|
956
|
+
* import: "import type { AuthRuntimeServices } from '@opensaas/stack-auth/runtime'",
|
|
957
|
+
* typeName: "AuthRuntimeServices"
|
|
958
|
+
* }
|
|
959
|
+
* ```
|
|
960
|
+
*/
|
|
961
|
+
runtimeServiceTypes?: {
|
|
962
|
+
/**
|
|
963
|
+
* Import statement to include in generated types file
|
|
964
|
+
* Must be a complete import statement with 'import type' and quotes
|
|
965
|
+
*/
|
|
966
|
+
import: string
|
|
967
|
+
/**
|
|
968
|
+
* TypeScript type name to use in PluginServices interface
|
|
969
|
+
* Should match the exported type from the import
|
|
970
|
+
*/
|
|
971
|
+
typeName: string
|
|
972
|
+
}
|
|
849
973
|
}
|
|
850
974
|
|
|
851
975
|
/**
|
|
852
976
|
* Main configuration type
|
|
977
|
+
* Using interface instead of type to allow module augmentation
|
|
853
978
|
*/
|
|
854
|
-
export
|
|
979
|
+
export interface OpenSaasConfig {
|
|
855
980
|
db: DatabaseConfig
|
|
856
981
|
lists: Record<string, ListConfig>
|
|
857
982
|
session?: SessionConfig
|
|
@@ -881,4 +1006,10 @@ export type OpenSaasConfig = {
|
|
|
881
1006
|
* @internal
|
|
882
1007
|
*/
|
|
883
1008
|
_pluginData?: Record<string, unknown>
|
|
1009
|
+
/**
|
|
1010
|
+
* Sorted plugin instances (stored after plugin execution)
|
|
1011
|
+
* Used at runtime to call plugin.runtime() functions
|
|
1012
|
+
* @internal
|
|
1013
|
+
*/
|
|
1014
|
+
_plugins?: Plugin[]
|
|
884
1015
|
}
|