@opensaas/stack-core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +152 -0
  3. package/dist/access/engine.d.ts +1 -1
  4. package/dist/access/engine.d.ts.map +1 -1
  5. package/dist/access/engine.js +38 -0
  6. package/dist/access/engine.js.map +1 -1
  7. package/dist/config/index.d.ts +5 -5
  8. package/dist/config/index.d.ts.map +1 -1
  9. package/dist/config/index.js +0 -1
  10. package/dist/config/index.js.map +1 -1
  11. package/dist/config/plugin-engine.d.ts.map +1 -1
  12. package/dist/config/plugin-engine.js +3 -0
  13. package/dist/config/plugin-engine.js.map +1 -1
  14. package/dist/config/types.d.ts +159 -73
  15. package/dist/config/types.d.ts.map +1 -1
  16. package/dist/context/index.d.ts.map +1 -1
  17. package/dist/context/index.js +19 -6
  18. package/dist/context/index.js.map +1 -1
  19. package/dist/context/nested-operations.d.ts.map +1 -1
  20. package/dist/context/nested-operations.js +88 -72
  21. package/dist/context/nested-operations.js.map +1 -1
  22. package/dist/fields/index.d.ts +65 -9
  23. package/dist/fields/index.d.ts.map +1 -1
  24. package/dist/fields/index.js +89 -8
  25. package/dist/fields/index.js.map +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js.map +1 -1
  29. package/dist/mcp/handler.js +1 -0
  30. package/dist/mcp/handler.js.map +1 -1
  31. package/dist/validation/schema.d.ts.map +1 -1
  32. package/dist/validation/schema.js +4 -2
  33. package/dist/validation/schema.js.map +1 -1
  34. package/package.json +7 -7
  35. package/src/access/engine.ts +48 -3
  36. package/src/config/index.ts +8 -13
  37. package/src/config/plugin-engine.ts +6 -3
  38. package/src/config/types.ts +208 -109
  39. package/src/context/index.ts +14 -7
  40. package/src/context/nested-operations.ts +83 -71
  41. package/src/fields/index.ts +124 -20
  42. package/src/index.ts +9 -0
  43. package/src/mcp/handler.ts +2 -1
  44. package/src/validation/schema.ts +4 -2
  45. package/tests/field-types.test.ts +6 -5
  46. package/tests/sudo.test.ts +230 -1
  47. 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
- relatedListConfig: ListConfig,
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
- const createAccess = relatedListConfig.access?.operation?.create
74
- const accessResult = await checkAccess(createAccess, {
75
- session: context.session,
76
- context,
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
- if (accessResult === false) {
80
- throw new Error('Access denied: Cannot create related item')
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
- relatedListConfig: ListConfig,
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
- for (const connection of connectionsArray) {
162
- // Access Prisma model dynamically - required because model names are generated at runtime
163
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
164
- const model = (prisma as any)[getDbKey(relatedListName)]
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
- // Fetch the item to check access
167
- const item = await model.findUnique({
168
- where: connection,
169
- })
171
+ // Fetch the item to check access
172
+ const item = await model.findUnique({
173
+ where: connection,
174
+ })
170
175
 
171
- if (!item) {
172
- throw new Error(`Cannot connect: Item not found`)
173
- }
176
+ if (!item) {
177
+ throw new Error(`Cannot connect: Item not found`)
178
+ }
174
179
 
175
- // Check update access (connecting modifies the relationship)
176
- const updateAccess = relatedListConfig.access?.operation?.update
177
- const accessResult = await checkAccess(updateAccess, {
178
- session: context.session,
179
- item,
180
- context,
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
- if (accessResult === false) {
184
- throw new Error('Access denied: Cannot connect to this item')
185
- }
188
+ if (accessResult === false) {
189
+ throw new Error('Access denied: Cannot connect to this item')
190
+ }
186
191
 
187
- // If access returns a filter, check if item matches
188
- if (typeof accessResult === 'object') {
189
- // Simple field matching
190
- for (const [key, value] of Object.entries(accessResult)) {
191
- if (typeof value === 'object' && value !== null && 'equals' in value) {
192
- if (item[key] !== (value as Record<string, unknown>).equals) {
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
- relatedListConfig: ListConfig,
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
- const updateAccess = relatedListConfig.access?.operation?.update
236
- const accessResult = await checkAccess(updateAccess, {
237
- session: context.session,
238
- item,
239
- context,
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
- if (accessResult === false) {
243
- throw new Error('Access denied: Cannot update related item')
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
- relatedListConfig: ListConfig,
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
- try {
336
- // Access Prisma model dynamically - required because model names are generated at runtime
337
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
338
- const model = (prisma as any)[getDbKey(relatedListName)]
339
- const existingItem = await model.findUnique({
340
- where: opRecord.where,
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 (accessResult === false) {
353
- throw new Error('Access denied: Cannot connect to existing item')
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 {
@@ -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(options?: Omit<TextField, 'type'>): TextField {
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(options?: Omit<IntegerField, 'type'>): IntegerField {
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(options?: Omit<CheckboxField, 'type'>): CheckboxField {
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(options?: Omit<TimestampField, 'type'>): TimestampField {
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(options?: Omit<PasswordField, 'type'>): PasswordField {
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
- typePatch: {
278
- resultType: "import('@opensaas/stack-core').HashedPassword",
279
- patchScope: 'scalars-only',
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
- resolveInput: async ({ inputValue }) => {
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
- resolveOutput: ({ value }) => {
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(options: Omit<SelectField, 'type'>): SelectField {
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(options: Omit<RelationshipField, 'type'>): RelationshipField {
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(options?: Omit<JsonField, 'type'>): JsonField {
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,
@@ -248,7 +248,8 @@ function generateFieldSchemas(
248
248
  if (
249
249
  operation === 'create' &&
250
250
  'validation' in fieldConfig &&
251
- fieldConfig.validation?.isRequired
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
  }
@@ -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 relationships
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('typePatch', () => {
411
- test('has type patch configured', () => {
410
+ describe('resultExtension', () => {
411
+ test('has result extension configured', () => {
412
412
  const field = password()
413
413
 
414
- expect(field.typePatch).toBeDefined()
415
- expect(field.typePatch?.resultType).toBe("import('@opensaas/stack-core').HashedPassword")
416
- expect(field.typePatch?.patchScope).toBe('scalars-only')
414
+ expect(field.resultExtension).toBeDefined()
415
+ expect(field.resultExtension?.outputType).toBe(
416
+ "import('@opensaas/stack-core').HashedPassword",
417
+ )
417
418
  })
418
419
  })
419
420
  })