@opensaas/stack-core 0.3.0 → 0.4.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 +150 -0
- package/dist/access/engine.d.ts +1 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +38 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/config/index.d.ts +5 -5
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +0 -1
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin-engine.d.ts.map +1 -1
- package/dist/config/plugin-engine.js +3 -0
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +159 -73
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +19 -6
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +88 -72
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/fields/index.d.ts +65 -9
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +89 -8
- package/dist/fields/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/handler.js +1 -0
- package/dist/mcp/handler.js.map +1 -1
- package/dist/validation/schema.d.ts.map +1 -1
- package/dist/validation/schema.js +4 -2
- package/dist/validation/schema.js.map +1 -1
- package/package.json +7 -7
- package/src/access/engine.ts +48 -3
- package/src/config/index.ts +8 -13
- package/src/config/plugin-engine.ts +6 -3
- package/src/config/types.ts +208 -109
- package/src/context/index.ts +14 -7
- package/src/context/nested-operations.ts +83 -71
- package/src/fields/index.ts +124 -20
- package/src/index.ts +9 -0
- package/src/mcp/handler.ts +2 -1
- package/src/validation/schema.ts +4 -2
- package/tests/field-types.test.ts +6 -5
- package/tests/sudo.test.ts +230 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -61,7 +61,8 @@ function isRelationshipField(fieldConfig: FieldConfig | undefined): boolean {
|
|
|
61
61
|
*/
|
|
62
62
|
async function processNestedCreate(
|
|
63
63
|
items: Record<string, unknown> | Array<Record<string, unknown>>,
|
|
64
|
-
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
65
|
+
relatedListConfig: ListConfig<any>,
|
|
65
66
|
context: AccessContext,
|
|
66
67
|
config: OpenSaasConfig,
|
|
67
68
|
): Promise<Record<string, unknown> | Array<Record<string, unknown>>> {
|
|
@@ -69,15 +70,17 @@ async function processNestedCreate(
|
|
|
69
70
|
|
|
70
71
|
const processedItems = await Promise.all(
|
|
71
72
|
itemsArray.map(async (item) => {
|
|
72
|
-
// 1. Check create access
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
// 1. Check create access (skip if sudo mode)
|
|
74
|
+
if (!context._isSudo) {
|
|
75
|
+
const createAccess = relatedListConfig.access?.operation?.create
|
|
76
|
+
const accessResult = await checkAccess(createAccess, {
|
|
77
|
+
session: context.session,
|
|
78
|
+
context,
|
|
79
|
+
})
|
|
78
80
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
if (accessResult === false) {
|
|
82
|
+
throw new Error('Access denied: Cannot create related item')
|
|
83
|
+
}
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
// 2. Execute list-level resolveInput hook
|
|
@@ -151,49 +154,52 @@ async function processNestedCreate(
|
|
|
151
154
|
async function processNestedConnect(
|
|
152
155
|
connections: Record<string, unknown> | Array<Record<string, unknown>>,
|
|
153
156
|
relatedListName: string,
|
|
154
|
-
|
|
157
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
158
|
+
relatedListConfig: ListConfig<any>,
|
|
155
159
|
context: AccessContext,
|
|
156
160
|
prisma: unknown,
|
|
157
161
|
): Promise<Record<string, unknown> | Array<Record<string, unknown>>> {
|
|
158
162
|
const connectionsArray = Array.isArray(connections) ? connections : [connections]
|
|
159
163
|
|
|
160
|
-
// Check update access for each item being connected
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
164
|
+
// Check update access for each item being connected (skip if sudo mode)
|
|
165
|
+
if (!context._isSudo) {
|
|
166
|
+
for (const connection of connectionsArray) {
|
|
167
|
+
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
168
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
169
|
+
const model = (prisma as any)[getDbKey(relatedListName)]
|
|
165
170
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
171
|
+
// Fetch the item to check access
|
|
172
|
+
const item = await model.findUnique({
|
|
173
|
+
where: connection,
|
|
174
|
+
})
|
|
170
175
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
176
|
+
if (!item) {
|
|
177
|
+
throw new Error(`Cannot connect: Item not found`)
|
|
178
|
+
}
|
|
174
179
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
// Check update access (connecting modifies the relationship)
|
|
181
|
+
const updateAccess = relatedListConfig.access?.operation?.update
|
|
182
|
+
const accessResult = await checkAccess(updateAccess, {
|
|
183
|
+
session: context.session,
|
|
184
|
+
item,
|
|
185
|
+
context,
|
|
186
|
+
})
|
|
182
187
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
188
|
+
if (accessResult === false) {
|
|
189
|
+
throw new Error('Access denied: Cannot connect to this item')
|
|
190
|
+
}
|
|
186
191
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
// If access returns a filter, check if item matches
|
|
193
|
+
if (typeof accessResult === 'object') {
|
|
194
|
+
// Simple field matching
|
|
195
|
+
for (const [key, value] of Object.entries(accessResult)) {
|
|
196
|
+
if (typeof value === 'object' && value !== null && 'equals' in value) {
|
|
197
|
+
if (item[key] !== (value as Record<string, unknown>).equals) {
|
|
198
|
+
throw new Error('Access denied: Cannot connect to this item')
|
|
199
|
+
}
|
|
200
|
+
} else if (item[key] !== value) {
|
|
193
201
|
throw new Error('Access denied: Cannot connect to this item')
|
|
194
202
|
}
|
|
195
|
-
} else if (item[key] !== value) {
|
|
196
|
-
throw new Error('Access denied: Cannot connect to this item')
|
|
197
203
|
}
|
|
198
204
|
}
|
|
199
205
|
}
|
|
@@ -209,7 +215,8 @@ async function processNestedConnect(
|
|
|
209
215
|
async function processNestedUpdate(
|
|
210
216
|
updates: Record<string, unknown> | Array<Record<string, unknown>>,
|
|
211
217
|
relatedListName: string,
|
|
212
|
-
|
|
218
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
219
|
+
relatedListConfig: ListConfig<any>,
|
|
213
220
|
context: AccessContext,
|
|
214
221
|
config: OpenSaasConfig,
|
|
215
222
|
prisma: unknown,
|
|
@@ -231,16 +238,18 @@ async function processNestedUpdate(
|
|
|
231
238
|
throw new Error('Cannot update: Item not found')
|
|
232
239
|
}
|
|
233
240
|
|
|
234
|
-
// Check update access
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
+
// Check update access (skip if sudo mode)
|
|
242
|
+
if (!context._isSudo) {
|
|
243
|
+
const updateAccess = relatedListConfig.access?.operation?.update
|
|
244
|
+
const accessResult = await checkAccess(updateAccess, {
|
|
245
|
+
session: context.session,
|
|
246
|
+
item,
|
|
247
|
+
context,
|
|
248
|
+
})
|
|
241
249
|
|
|
242
|
-
|
|
243
|
-
|
|
250
|
+
if (accessResult === false) {
|
|
251
|
+
throw new Error('Access denied: Cannot update related item')
|
|
252
|
+
}
|
|
244
253
|
}
|
|
245
254
|
|
|
246
255
|
// Execute list-level resolveInput hook
|
|
@@ -313,7 +322,8 @@ async function processNestedUpdate(
|
|
|
313
322
|
async function processNestedConnectOrCreate(
|
|
314
323
|
operations: Record<string, unknown> | Array<Record<string, unknown>>,
|
|
315
324
|
relatedListName: string,
|
|
316
|
-
|
|
325
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
326
|
+
relatedListConfig: ListConfig<any>,
|
|
317
327
|
context: AccessContext,
|
|
318
328
|
config: OpenSaasConfig,
|
|
319
329
|
prisma: unknown,
|
|
@@ -331,30 +341,32 @@ async function processNestedConnectOrCreate(
|
|
|
331
341
|
config,
|
|
332
342
|
)
|
|
333
343
|
|
|
334
|
-
// Check access for the connect portion (try to find existing item)
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
if (existingItem) {
|
|
344
|
-
// Check update access for connection
|
|
345
|
-
const updateAccess = relatedListConfig.access?.operation?.update
|
|
346
|
-
const accessResult = await checkAccess(updateAccess, {
|
|
347
|
-
session: context.session,
|
|
348
|
-
item: existingItem,
|
|
349
|
-
context,
|
|
344
|
+
// Check access for the connect portion (try to find existing item) (skip if sudo mode)
|
|
345
|
+
if (!context._isSudo) {
|
|
346
|
+
try {
|
|
347
|
+
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
348
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
349
|
+
const model = (prisma as any)[getDbKey(relatedListName)]
|
|
350
|
+
const existingItem = await model.findUnique({
|
|
351
|
+
where: opRecord.where,
|
|
350
352
|
})
|
|
351
353
|
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
+
if (existingItem) {
|
|
355
|
+
// Check update access for connection
|
|
356
|
+
const updateAccess = relatedListConfig.access?.operation?.update
|
|
357
|
+
const accessResult = await checkAccess(updateAccess, {
|
|
358
|
+
session: context.session,
|
|
359
|
+
item: existingItem,
|
|
360
|
+
context,
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
if (accessResult === false) {
|
|
364
|
+
throw new Error('Access denied: Cannot connect to existing item')
|
|
365
|
+
}
|
|
354
366
|
}
|
|
367
|
+
} catch {
|
|
368
|
+
// Item doesn't exist, will use create (already processed)
|
|
355
369
|
}
|
|
356
|
-
} catch {
|
|
357
|
-
// Item doesn't exist, will use create (already processed)
|
|
358
370
|
}
|
|
359
371
|
|
|
360
372
|
return {
|
package/src/fields/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
SelectField,
|
|
9
9
|
RelationshipField,
|
|
10
10
|
JsonField,
|
|
11
|
+
VirtualField,
|
|
11
12
|
} from '../config/types.js'
|
|
12
13
|
import { hashPassword, isHashedPassword, HashedPassword } from '../utils/password.js'
|
|
13
14
|
|
|
@@ -24,7 +25,9 @@ function formatFieldName(fieldName: string): string {
|
|
|
24
25
|
/**
|
|
25
26
|
* Text field
|
|
26
27
|
*/
|
|
27
|
-
export function text
|
|
28
|
+
export function text<
|
|
29
|
+
TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
|
|
30
|
+
>(options?: Omit<TextField<TTypeInfo>, 'type'>): TextField<TTypeInfo> {
|
|
28
31
|
return {
|
|
29
32
|
type: 'text',
|
|
30
33
|
...options,
|
|
@@ -98,7 +101,9 @@ export function text(options?: Omit<TextField, 'type'>): TextField {
|
|
|
98
101
|
/**
|
|
99
102
|
* Integer field
|
|
100
103
|
*/
|
|
101
|
-
export function integer
|
|
104
|
+
export function integer<
|
|
105
|
+
TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
|
|
106
|
+
>(options?: Omit<IntegerField<TTypeInfo>, 'type'>): IntegerField<TTypeInfo> {
|
|
102
107
|
return {
|
|
103
108
|
type: 'integer',
|
|
104
109
|
...options,
|
|
@@ -147,7 +152,9 @@ export function integer(options?: Omit<IntegerField, 'type'>): IntegerField {
|
|
|
147
152
|
/**
|
|
148
153
|
* Checkbox (boolean) field
|
|
149
154
|
*/
|
|
150
|
-
export function checkbox
|
|
155
|
+
export function checkbox<
|
|
156
|
+
TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
|
|
157
|
+
>(options?: Omit<CheckboxField<TTypeInfo>, 'type'>): CheckboxField<TTypeInfo> {
|
|
151
158
|
return {
|
|
152
159
|
type: 'checkbox',
|
|
153
160
|
...options,
|
|
@@ -179,7 +186,9 @@ export function checkbox(options?: Omit<CheckboxField, 'type'>): CheckboxField {
|
|
|
179
186
|
/**
|
|
180
187
|
* Timestamp (DateTime) field
|
|
181
188
|
*/
|
|
182
|
-
export function timestamp
|
|
189
|
+
export function timestamp<
|
|
190
|
+
TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
|
|
191
|
+
>(options?: Omit<TimestampField<TTypeInfo>, 'type'>): TimestampField<TTypeInfo> {
|
|
183
192
|
return {
|
|
184
193
|
type: 'timestamp',
|
|
185
194
|
...options,
|
|
@@ -270,33 +279,32 @@ export function timestamp(options?: Omit<TimestampField, 'type'>): TimestampFiel
|
|
|
270
279
|
* @param options - Field configuration options
|
|
271
280
|
* @returns Password field configuration
|
|
272
281
|
*/
|
|
273
|
-
export function password
|
|
282
|
+
export function password<TTypeInfo extends import('../config/types.js').TypeInfo>(
|
|
283
|
+
options?: Omit<PasswordField<TTypeInfo>, 'type'>,
|
|
284
|
+
): PasswordField<TTypeInfo> {
|
|
274
285
|
return {
|
|
275
286
|
type: 'password',
|
|
276
287
|
...options,
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
288
|
+
resultExtension: {
|
|
289
|
+
outputType: "import('@opensaas/stack-core').HashedPassword",
|
|
290
|
+
// No compute - delegates to resolveOutput hook
|
|
280
291
|
},
|
|
281
292
|
ui: {
|
|
282
293
|
...options?.ui,
|
|
283
294
|
valueForClientSerialization: ({ value }) => ({ isSet: !!value }),
|
|
284
295
|
},
|
|
296
|
+
// Cast hooks to any since field builders are generic and can't know the specific TFieldKey
|
|
285
297
|
hooks: {
|
|
286
298
|
// Hash password before writing to database
|
|
287
|
-
|
|
299
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
|
|
300
|
+
resolveInput: async ({ inputValue }: { inputValue: any }) => {
|
|
288
301
|
// Skip if undefined or null (allows partial updates)
|
|
289
302
|
if (inputValue === undefined || inputValue === null) {
|
|
290
303
|
return inputValue
|
|
291
304
|
}
|
|
292
305
|
|
|
293
306
|
// Skip if not a string
|
|
294
|
-
if (typeof inputValue !== 'string') {
|
|
295
|
-
return inputValue
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Skip empty strings (let validation handle this)
|
|
299
|
-
if (inputValue.length === 0) {
|
|
307
|
+
if (typeof inputValue !== 'string' || inputValue.length === 0) {
|
|
300
308
|
return inputValue
|
|
301
309
|
}
|
|
302
310
|
|
|
@@ -309,7 +317,8 @@ export function password(options?: Omit<PasswordField, 'type'>): PasswordField {
|
|
|
309
317
|
return await hashPassword(inputValue)
|
|
310
318
|
},
|
|
311
319
|
// Wrap password with HashedPassword class after reading from database
|
|
312
|
-
|
|
320
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
|
|
321
|
+
resolveOutput: ({ value }: { value: any }) => {
|
|
313
322
|
// Only wrap string values (hashed passwords)
|
|
314
323
|
if (typeof value === 'string' && value.length > 0) {
|
|
315
324
|
return new HashedPassword(value)
|
|
@@ -318,7 +327,8 @@ export function password(options?: Omit<PasswordField, 'type'>): PasswordField {
|
|
|
318
327
|
},
|
|
319
328
|
// Merge with user-provided hooks if any
|
|
320
329
|
...options?.hooks,
|
|
321
|
-
|
|
330
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Hook object needs type assertion for field builder
|
|
331
|
+
} as any,
|
|
322
332
|
getZodSchema: (fieldName: string, operation: 'create' | 'update') => {
|
|
323
333
|
const validation = options?.validation
|
|
324
334
|
const isRequired = validation?.isRequired
|
|
@@ -372,7 +382,9 @@ export function password(options?: Omit<PasswordField, 'type'>): PasswordField {
|
|
|
372
382
|
/**
|
|
373
383
|
* Select field (enum-like)
|
|
374
384
|
*/
|
|
375
|
-
export function select
|
|
385
|
+
export function select<
|
|
386
|
+
TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
|
|
387
|
+
>(options: Omit<SelectField<TTypeInfo>, 'type'>): SelectField<TTypeInfo> {
|
|
376
388
|
if (!options.options || options.options.length === 0) {
|
|
377
389
|
throw new Error('Select field must have at least one option')
|
|
378
390
|
}
|
|
@@ -420,7 +432,9 @@ export function select(options: Omit<SelectField, 'type'>): SelectField {
|
|
|
420
432
|
/**
|
|
421
433
|
* Relationship field
|
|
422
434
|
*/
|
|
423
|
-
export function relationship
|
|
435
|
+
export function relationship<
|
|
436
|
+
TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
|
|
437
|
+
>(options: Omit<RelationshipField<TTypeInfo>, 'type'>): RelationshipField<TTypeInfo> {
|
|
424
438
|
if (!options.ref) {
|
|
425
439
|
throw new Error('Relationship field must have a ref')
|
|
426
440
|
}
|
|
@@ -482,7 +496,9 @@ export function relationship(options: Omit<RelationshipField, 'type'>): Relation
|
|
|
482
496
|
* @param options - Field configuration options
|
|
483
497
|
* @returns JSON field configuration
|
|
484
498
|
*/
|
|
485
|
-
export function json
|
|
499
|
+
export function json<
|
|
500
|
+
TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
|
|
501
|
+
>(options?: Omit<JsonField<TTypeInfo>, 'type'>): JsonField<TTypeInfo> {
|
|
486
502
|
return {
|
|
487
503
|
type: 'json',
|
|
488
504
|
...options,
|
|
@@ -522,3 +538,91 @@ export function json(options?: Omit<JsonField, 'type'>): JsonField {
|
|
|
522
538
|
},
|
|
523
539
|
}
|
|
524
540
|
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Virtual field - not stored in database, computed via hooks
|
|
544
|
+
*
|
|
545
|
+
* **Features:**
|
|
546
|
+
* - Does not create a column in the database
|
|
547
|
+
* - Uses resolveOutput hook to compute value from other fields
|
|
548
|
+
* - Optionally uses resolveInput hook for write side effects (e.g., sync to external API)
|
|
549
|
+
* - Only computed when explicitly selected/included in queries
|
|
550
|
+
* - Supports both read and write operations via hooks
|
|
551
|
+
*
|
|
552
|
+
* **Usage Example:**
|
|
553
|
+
* ```typescript
|
|
554
|
+
* // Read-only computed field
|
|
555
|
+
* fields: {
|
|
556
|
+
* firstName: text(),
|
|
557
|
+
* lastName: text(),
|
|
558
|
+
* fullName: virtual({
|
|
559
|
+
* type: 'string',
|
|
560
|
+
* hooks: {
|
|
561
|
+
* resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}`
|
|
562
|
+
* }
|
|
563
|
+
* })
|
|
564
|
+
* }
|
|
565
|
+
*
|
|
566
|
+
* // Write side effects (e.g., sync to external API)
|
|
567
|
+
* fields: {
|
|
568
|
+
* externalSync: virtual({
|
|
569
|
+
* type: 'boolean',
|
|
570
|
+
* hooks: {
|
|
571
|
+
* resolveInput: async ({ item }) => {
|
|
572
|
+
* await syncToExternalAPI(item)
|
|
573
|
+
* return undefined // Don't store anything
|
|
574
|
+
* },
|
|
575
|
+
* resolveOutput: () => true
|
|
576
|
+
* }
|
|
577
|
+
* })
|
|
578
|
+
* }
|
|
579
|
+
*
|
|
580
|
+
* // Query with select
|
|
581
|
+
* const user = await context.db.user.findUnique({
|
|
582
|
+
* where: { id },
|
|
583
|
+
* select: { firstName: true, lastName: true, fullName: true } // fullName computed
|
|
584
|
+
* })
|
|
585
|
+
* ```
|
|
586
|
+
*
|
|
587
|
+
* **Requirements:**
|
|
588
|
+
* - Must provide `type` (TypeScript type string)
|
|
589
|
+
* - Must provide `resolveOutput` hook (for reads)
|
|
590
|
+
* - Optional `resolveInput` hook (for write side effects)
|
|
591
|
+
*
|
|
592
|
+
* @param options - Virtual field configuration
|
|
593
|
+
* @returns Virtual field configuration
|
|
594
|
+
*/
|
|
595
|
+
export function virtual<TTypeInfo extends import('../config/types.js').TypeInfo>(
|
|
596
|
+
options: Omit<VirtualField<TTypeInfo>, 'virtual' | 'outputType' | 'type'> & { type: string },
|
|
597
|
+
): VirtualField<TTypeInfo> {
|
|
598
|
+
// Validate that resolveOutput is provided
|
|
599
|
+
if (!options.hooks?.resolveOutput) {
|
|
600
|
+
throw new Error(
|
|
601
|
+
'Virtual fields must provide a resolveOutput hook to compute their value. ' +
|
|
602
|
+
'Example: hooks: { resolveOutput: ({ item }) => computeValue(item) }',
|
|
603
|
+
)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const { type: outputType, ...rest } = options
|
|
607
|
+
|
|
608
|
+
return {
|
|
609
|
+
type: 'virtual',
|
|
610
|
+
virtual: true,
|
|
611
|
+
outputType,
|
|
612
|
+
...rest,
|
|
613
|
+
// Virtual fields don't create database columns
|
|
614
|
+
// Return undefined to signal generator to skip this field
|
|
615
|
+
getPrismaType: undefined,
|
|
616
|
+
// Virtual fields appear in output types with their specified type
|
|
617
|
+
getTypeScriptType: () => {
|
|
618
|
+
return {
|
|
619
|
+
type: options.type,
|
|
620
|
+
optional: false, // Virtual fields always compute a value
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
// Virtual fields never validate input (they don't accept database input)
|
|
624
|
+
getZodSchema: () => {
|
|
625
|
+
return z.never()
|
|
626
|
+
},
|
|
627
|
+
}
|
|
628
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,15 +12,24 @@ export type {
|
|
|
12
12
|
PasswordField,
|
|
13
13
|
SelectField,
|
|
14
14
|
RelationshipField,
|
|
15
|
+
JsonField,
|
|
16
|
+
VirtualField,
|
|
17
|
+
TypeInfo,
|
|
15
18
|
OperationAccess,
|
|
16
19
|
Hooks,
|
|
17
20
|
FieldHooks,
|
|
21
|
+
FieldsWithTypeInfo,
|
|
18
22
|
DatabaseConfig,
|
|
19
23
|
SessionConfig,
|
|
20
24
|
UIConfig,
|
|
21
25
|
ThemeConfig,
|
|
22
26
|
ThemePreset,
|
|
23
27
|
ThemeColors,
|
|
28
|
+
McpConfig,
|
|
29
|
+
McpToolsConfig,
|
|
30
|
+
McpAuthConfig,
|
|
31
|
+
ListMcpConfig,
|
|
32
|
+
McpCustomTool,
|
|
24
33
|
FileMetadata,
|
|
25
34
|
ImageMetadata,
|
|
26
35
|
ImageTransformationResult,
|
package/src/mcp/handler.ts
CHANGED
|
@@ -248,7 +248,8 @@ function generateFieldSchemas(
|
|
|
248
248
|
if (
|
|
249
249
|
operation === 'create' &&
|
|
250
250
|
'validation' in fieldConfig &&
|
|
251
|
-
|
|
251
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Validation property varies by field type
|
|
252
|
+
(fieldConfig.validation as any)?.isRequired
|
|
252
253
|
) {
|
|
253
254
|
required.push(fieldName)
|
|
254
255
|
}
|
package/src/validation/schema.ts
CHANGED
|
@@ -11,10 +11,12 @@ export function generateZodSchema(
|
|
|
11
11
|
const shape: Record<string, z.ZodTypeAny> = {}
|
|
12
12
|
|
|
13
13
|
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
14
|
-
// Skip system fields and
|
|
14
|
+
// Skip system fields, relationships, and virtual fields
|
|
15
|
+
// Virtual fields don't accept input - they only compute output
|
|
15
16
|
if (
|
|
16
17
|
['id', 'createdAt', 'updatedAt'].includes(fieldName) ||
|
|
17
|
-
fieldConfig.type === 'relationship'
|
|
18
|
+
fieldConfig.type === 'relationship' ||
|
|
19
|
+
fieldConfig.virtual
|
|
18
20
|
) {
|
|
19
21
|
continue
|
|
20
22
|
}
|
|
@@ -407,13 +407,14 @@ describe('Field Types', () => {
|
|
|
407
407
|
})
|
|
408
408
|
})
|
|
409
409
|
|
|
410
|
-
describe('
|
|
411
|
-
test('has
|
|
410
|
+
describe('resultExtension', () => {
|
|
411
|
+
test('has result extension configured', () => {
|
|
412
412
|
const field = password()
|
|
413
413
|
|
|
414
|
-
expect(field.
|
|
415
|
-
expect(field.
|
|
416
|
-
|
|
414
|
+
expect(field.resultExtension).toBeDefined()
|
|
415
|
+
expect(field.resultExtension?.outputType).toBe(
|
|
416
|
+
"import('@opensaas/stack-core').HashedPassword",
|
|
417
|
+
)
|
|
417
418
|
})
|
|
418
419
|
})
|
|
419
420
|
})
|