@opensaas/stack-core 0.6.1 → 0.7.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.
@@ -539,6 +539,58 @@ export function json<
539
539
  }
540
540
  }
541
541
 
542
+ /**
543
+ * Convert a TypeDescriptor to a TypeScript type string
544
+ * Handles three formats:
545
+ * 1. Primitive string: 'string', 'number', 'boolean' -> returned as-is
546
+ * 2. Import string: "import('decimal.js').Decimal" -> returned as-is
547
+ * 3. Type object: { value: Decimal, from: 'decimal.js' } -> "import('decimal.js').Decimal"
548
+ */
549
+ function typeDescriptorToString(descriptor: import('../config/types.js').TypeDescriptor): string {
550
+ if (typeof descriptor === 'string') {
551
+ return descriptor
552
+ }
553
+
554
+ // Extract type name from constructor or use provided name
555
+ const typeName = descriptor.name || descriptor.value.name
556
+
557
+ // Generate import string
558
+ return `import('${descriptor.from}').${typeName}`
559
+ }
560
+
561
+ /**
562
+ * Extract TypeScript imports from a TypeDescriptor
563
+ * Returns array of import statements needed for type generation
564
+ */
565
+ function typeDescriptorToImports(
566
+ descriptor: import('../config/types.js').TypeDescriptor,
567
+ ): Array<{ names: string[]; from: string; typeOnly?: boolean }> {
568
+ // If it's a string, check if it's an import string
569
+ if (typeof descriptor === 'string') {
570
+ const importMatch = descriptor.match(/import\('([^']+)'\)\.(\w+)/)
571
+ if (importMatch) {
572
+ return [
573
+ {
574
+ names: [importMatch[2]],
575
+ from: importMatch[1],
576
+ typeOnly: true,
577
+ },
578
+ ]
579
+ }
580
+ return []
581
+ }
582
+
583
+ // Type object descriptor
584
+ const typeName = descriptor.name || descriptor.value.name
585
+ return [
586
+ {
587
+ names: [typeName],
588
+ from: descriptor.from,
589
+ typeOnly: true,
590
+ },
591
+ ]
592
+ }
593
+
542
594
  /**
543
595
  * Virtual field - not stored in database, computed via hooks
544
596
  *
@@ -548,10 +600,11 @@ export function json<
548
600
  * - Optionally uses resolveInput hook for write side effects (e.g., sync to external API)
549
601
  * - Only computed when explicitly selected/included in queries
550
602
  * - Supports both read and write operations via hooks
603
+ * - Supports custom scalar types (e.g., Decimal) for financial precision
551
604
  *
552
- * **Usage Example:**
605
+ * **Usage Examples:**
553
606
  * ```typescript
554
- * // Read-only computed field
607
+ * // Read-only computed field with primitive type
555
608
  * fields: {
556
609
  * firstName: text(),
557
610
  * lastName: text(),
@@ -563,6 +616,28 @@ export function json<
563
616
  * })
564
617
  * }
565
618
  *
619
+ * // Custom scalar type using import string
620
+ * fields: {
621
+ * totalPrice: virtual({
622
+ * type: "import('decimal.js').Decimal",
623
+ * hooks: {
624
+ * resolveOutput: ({ item }) => new Decimal(item.price).times(item.quantity)
625
+ * }
626
+ * })
627
+ * }
628
+ *
629
+ * // Custom scalar type using type descriptor (recommended)
630
+ * import Decimal from 'decimal.js'
631
+ *
632
+ * fields: {
633
+ * totalPrice: virtual({
634
+ * type: { value: Decimal, from: 'decimal.js' },
635
+ * hooks: {
636
+ * resolveOutput: ({ item }) => new Decimal(item.price).times(item.quantity)
637
+ * }
638
+ * })
639
+ * }
640
+ *
566
641
  * // Write side effects (e.g., sync to external API)
567
642
  * fields: {
568
643
  * externalSync: virtual({
@@ -576,16 +651,10 @@ export function json<
576
651
  * }
577
652
  * })
578
653
  * }
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
654
  * ```
586
655
  *
587
656
  * **Requirements:**
588
- * - Must provide `type` (TypeScript type string)
657
+ * - Must provide `type` (TypeScript type string, import string, or type descriptor)
589
658
  * - Must provide `resolveOutput` hook (for reads)
590
659
  * - Optional `resolveInput` hook (for write side effects)
591
660
  *
@@ -593,7 +662,9 @@ export function json<
593
662
  * @returns Virtual field configuration
594
663
  */
595
664
  export function virtual<TTypeInfo extends import('../config/types.js').TypeInfo>(
596
- options: Omit<VirtualField<TTypeInfo>, 'virtual' | 'outputType' | 'type'> & { type: string },
665
+ options: Omit<VirtualField<TTypeInfo>, 'virtual' | 'outputType' | 'type'> & {
666
+ type: import('../config/types.js').TypeDescriptor
667
+ },
597
668
  ): VirtualField<TTypeInfo> {
598
669
  // Validate that resolveOutput is provided
599
670
  if (!options.hooks?.resolveOutput) {
@@ -603,7 +674,11 @@ export function virtual<TTypeInfo extends import('../config/types.js').TypeInfo>
603
674
  )
604
675
  }
605
676
 
606
- const { type: outputType, ...rest } = options
677
+ // Convert type descriptor to string
678
+ const outputType = typeDescriptorToString(options.type)
679
+ const imports = typeDescriptorToImports(options.type)
680
+
681
+ const { type: _, ...rest } = options
607
682
 
608
683
  return {
609
684
  type: 'virtual',
@@ -616,10 +691,12 @@ export function virtual<TTypeInfo extends import('../config/types.js').TypeInfo>
616
691
  // Virtual fields appear in output types with their specified type
617
692
  getTypeScriptType: () => {
618
693
  return {
619
- type: options.type,
694
+ type: outputType,
620
695
  optional: false, // Virtual fields always compute a value
621
696
  }
622
697
  },
698
+ // Add import statements if needed
699
+ getTypeScriptImports: imports.length > 0 ? () => imports : undefined,
623
700
  // Virtual fields never validate input (they don't accept database input)
624
701
  getZodSchema: () => {
625
702
  return z.never()
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export type {
14
14
  RelationshipField,
15
15
  JsonField,
16
16
  VirtualField,
17
+ TypeDescriptor,
17
18
  TypeInfo,
18
19
  OperationAccess,
19
20
  Hooks,
@@ -0,0 +1,421 @@
1
+ import { describe, test, expect } from 'vitest'
2
+ import { virtual } from '../src/fields/index.js'
3
+
4
+ // Mock Decimal class for testing
5
+ class Decimal {
6
+ value: number
7
+
8
+ constructor(value: number | string) {
9
+ this.value = typeof value === 'string' ? parseFloat(value) : value
10
+ }
11
+
12
+ times(other: Decimal | number): Decimal {
13
+ const otherValue = other instanceof Decimal ? other.value : other
14
+ return new Decimal(this.value * otherValue)
15
+ }
16
+
17
+ plus(other: Decimal | number): Decimal {
18
+ const otherValue = other instanceof Decimal ? other.value : other
19
+ return new Decimal(this.value + otherValue)
20
+ }
21
+
22
+ toString(): string {
23
+ return this.value.toString()
24
+ }
25
+
26
+ static get name() {
27
+ return 'Decimal'
28
+ }
29
+ }
30
+
31
+ // Mock custom class for testing
32
+ class CustomType {
33
+ data: string
34
+
35
+ constructor(data: string) {
36
+ this.data = data
37
+ }
38
+
39
+ static get name() {
40
+ return 'CustomType'
41
+ }
42
+ }
43
+
44
+ describe('Virtual Fields with TypeDescriptor', () => {
45
+ describe('primitive type strings', () => {
46
+ test('accepts string type', () => {
47
+ const field = virtual({
48
+ type: 'string',
49
+ hooks: {
50
+ resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}`,
51
+ },
52
+ })
53
+
54
+ expect(field.type).toBe('virtual')
55
+ expect(field.virtual).toBe(true)
56
+ expect(field.outputType).toBe('string')
57
+ })
58
+
59
+ test('accepts number type', () => {
60
+ const field = virtual({
61
+ type: 'number',
62
+ hooks: {
63
+ resolveOutput: ({ item }) => item.count * 2,
64
+ },
65
+ })
66
+
67
+ expect(field.outputType).toBe('number')
68
+ })
69
+
70
+ test('accepts boolean type', () => {
71
+ const field = virtual({
72
+ type: 'boolean',
73
+ hooks: {
74
+ resolveOutput: ({ item }) => item.isActive,
75
+ },
76
+ })
77
+
78
+ expect(field.outputType).toBe('boolean')
79
+ })
80
+
81
+ test('accepts Date type', () => {
82
+ const field = virtual({
83
+ type: 'Date',
84
+ hooks: {
85
+ resolveOutput: ({ item }) => new Date(item.createdAt),
86
+ },
87
+ })
88
+
89
+ expect(field.outputType).toBe('Date')
90
+ })
91
+
92
+ test('accepts array types', () => {
93
+ const field = virtual({
94
+ type: 'string[]',
95
+ hooks: {
96
+ resolveOutput: ({ item }) => item.tags.split(','),
97
+ },
98
+ })
99
+
100
+ expect(field.outputType).toBe('string[]')
101
+ })
102
+ })
103
+
104
+ describe('import string types', () => {
105
+ test('accepts import string format', () => {
106
+ const field = virtual({
107
+ type: "import('decimal.js').Decimal",
108
+ hooks: {
109
+ resolveOutput: ({ item }) => new Decimal(item.price),
110
+ },
111
+ })
112
+
113
+ expect(field.outputType).toBe("import('decimal.js').Decimal")
114
+ })
115
+
116
+ test('generates TypeScript imports from import string', () => {
117
+ const field = virtual({
118
+ type: "import('decimal.js').Decimal",
119
+ hooks: {
120
+ resolveOutput: ({ item }) => new Decimal(item.price),
121
+ },
122
+ })
123
+
124
+ expect(field.getTypeScriptImports).toBeDefined()
125
+ const imports = field.getTypeScriptImports!()
126
+ expect(imports).toHaveLength(1)
127
+ expect(imports[0]).toEqual({
128
+ names: ['Decimal'],
129
+ from: 'decimal.js',
130
+ typeOnly: true,
131
+ })
132
+ })
133
+
134
+ test('handles complex import paths', () => {
135
+ const field = virtual({
136
+ type: "import('@myorg/custom-types').MyCustomType",
137
+ hooks: {
138
+ resolveOutput: ({ item }) => item.customData,
139
+ },
140
+ })
141
+
142
+ expect(field.outputType).toBe("import('@myorg/custom-types').MyCustomType")
143
+ const imports = field.getTypeScriptImports!()
144
+ expect(imports[0]).toEqual({
145
+ names: ['MyCustomType'],
146
+ from: '@myorg/custom-types',
147
+ typeOnly: true,
148
+ })
149
+ })
150
+ })
151
+
152
+ describe('type descriptor objects', () => {
153
+ test('accepts type descriptor with class constructor', () => {
154
+ const field = virtual({
155
+ type: { value: Decimal, from: 'decimal.js' },
156
+ hooks: {
157
+ resolveOutput: ({ item }) => new Decimal(item.price).times(item.quantity),
158
+ },
159
+ })
160
+
161
+ expect(field.outputType).toBe("import('decimal.js').Decimal")
162
+ })
163
+
164
+ test('generates TypeScript imports from type descriptor', () => {
165
+ const field = virtual({
166
+ type: { value: Decimal, from: 'decimal.js' },
167
+ hooks: {
168
+ resolveOutput: ({ item }) => new Decimal(item.price),
169
+ },
170
+ })
171
+
172
+ expect(field.getTypeScriptImports).toBeDefined()
173
+ const imports = field.getTypeScriptImports!()
174
+ expect(imports).toHaveLength(1)
175
+ expect(imports[0]).toEqual({
176
+ names: ['Decimal'],
177
+ from: 'decimal.js',
178
+ typeOnly: true,
179
+ })
180
+ })
181
+
182
+ test('uses custom name when provided', () => {
183
+ const field = virtual({
184
+ type: { value: Decimal, from: 'decimal.js', name: 'CustomDecimal' },
185
+ hooks: {
186
+ resolveOutput: ({ item }) => new Decimal(item.value),
187
+ },
188
+ })
189
+
190
+ expect(field.outputType).toBe("import('decimal.js').CustomDecimal")
191
+ const imports = field.getTypeScriptImports!()
192
+ expect(imports[0]).toEqual({
193
+ names: ['CustomDecimal'],
194
+ from: 'decimal.js',
195
+ typeOnly: true,
196
+ })
197
+ })
198
+
199
+ test('works with custom classes', () => {
200
+ const field = virtual({
201
+ type: { value: CustomType, from: './types' },
202
+ hooks: {
203
+ resolveOutput: ({ item }) => new CustomType(item.data),
204
+ },
205
+ })
206
+
207
+ expect(field.outputType).toBe("import('./types').CustomType")
208
+ const imports = field.getTypeScriptImports!()
209
+ expect(imports[0]).toEqual({
210
+ names: ['CustomType'],
211
+ from: './types',
212
+ typeOnly: true,
213
+ })
214
+ })
215
+ })
216
+
217
+ describe('getTypeScriptType', () => {
218
+ test('returns correct type for primitive string', () => {
219
+ const field = virtual({
220
+ type: 'string',
221
+ hooks: {
222
+ resolveOutput: ({ item }) => item.name,
223
+ },
224
+ })
225
+
226
+ const tsType = field.getTypeScriptType!()
227
+ expect(tsType.type).toBe('string')
228
+ expect(tsType.optional).toBe(false)
229
+ })
230
+
231
+ test('returns correct type for import string', () => {
232
+ const field = virtual({
233
+ type: "import('decimal.js').Decimal",
234
+ hooks: {
235
+ resolveOutput: ({ item }) => new Decimal(item.value),
236
+ },
237
+ })
238
+
239
+ const tsType = field.getTypeScriptType!()
240
+ expect(tsType.type).toBe("import('decimal.js').Decimal")
241
+ expect(tsType.optional).toBe(false)
242
+ })
243
+
244
+ test('returns correct type for type descriptor', () => {
245
+ const field = virtual({
246
+ type: { value: Decimal, from: 'decimal.js' },
247
+ hooks: {
248
+ resolveOutput: ({ item }) => new Decimal(item.value),
249
+ },
250
+ })
251
+
252
+ const tsType = field.getTypeScriptType!()
253
+ expect(tsType.type).toBe("import('decimal.js').Decimal")
254
+ expect(tsType.optional).toBe(false)
255
+ })
256
+
257
+ test('virtual fields are never optional', () => {
258
+ const field = virtual({
259
+ type: 'string',
260
+ hooks: {
261
+ resolveOutput: ({ item }) => item.value || 'default',
262
+ },
263
+ })
264
+
265
+ const tsType = field.getTypeScriptType!()
266
+ expect(tsType.optional).toBe(false)
267
+ })
268
+ })
269
+
270
+ describe('validation', () => {
271
+ test('throws error when resolveOutput hook is missing', () => {
272
+ expect(() => {
273
+ virtual({
274
+ type: 'string',
275
+ // @ts-expect-error - Testing missing hook
276
+ hooks: {},
277
+ })
278
+ }).toThrow('Virtual fields must provide a resolveOutput hook')
279
+ })
280
+
281
+ test('throws error when hooks are completely missing', () => {
282
+ expect(() => {
283
+ // @ts-expect-error - Testing missing hooks
284
+ virtual({
285
+ type: 'string',
286
+ })
287
+ }).toThrow('Virtual fields must provide a resolveOutput hook')
288
+ })
289
+ })
290
+
291
+ describe('getPrismaType', () => {
292
+ test('returns undefined to skip database column creation', () => {
293
+ const field = virtual({
294
+ type: 'string',
295
+ hooks: {
296
+ resolveOutput: ({ item }) => item.computed,
297
+ },
298
+ })
299
+
300
+ expect(field.getPrismaType).toBeUndefined()
301
+ })
302
+ })
303
+
304
+ describe('getZodSchema', () => {
305
+ test('returns z.never() to prevent input validation', () => {
306
+ const field = virtual({
307
+ type: 'string',
308
+ hooks: {
309
+ resolveOutput: ({ item }) => item.computed,
310
+ },
311
+ })
312
+
313
+ const schema = field.getZodSchema!()
314
+ expect(() => schema.parse('anything')).toThrow()
315
+ })
316
+ })
317
+
318
+ describe('real-world use cases', () => {
319
+ test('Decimal type for financial calculations', () => {
320
+ const field = virtual({
321
+ type: { value: Decimal, from: 'decimal.js' },
322
+ hooks: {
323
+ resolveOutput: ({ item }) => {
324
+ // Calculate total from price and quantity with decimal precision
325
+ return new Decimal(item.price).times(item.quantity)
326
+ },
327
+ },
328
+ })
329
+
330
+ expect(field.type).toBe('virtual')
331
+ expect(field.outputType).toBe("import('decimal.js').Decimal")
332
+
333
+ const imports = field.getTypeScriptImports!()
334
+ expect(imports).toEqual([
335
+ {
336
+ names: ['Decimal'],
337
+ from: 'decimal.js',
338
+ typeOnly: true,
339
+ },
340
+ ])
341
+ })
342
+
343
+ test('Complex computed field with multiple operations', () => {
344
+ const field = virtual({
345
+ type: { value: Decimal, from: 'decimal.js' },
346
+ hooks: {
347
+ resolveOutput: ({ item }) => {
348
+ // Calculate tax amount
349
+ const subtotal = new Decimal(item.subtotal)
350
+ const taxRate = new Decimal(item.taxRate)
351
+ return subtotal.times(taxRate)
352
+ },
353
+ },
354
+ })
355
+
356
+ expect(field.outputType).toBe("import('decimal.js').Decimal")
357
+ })
358
+
359
+ test('Full name concatenation (primitive type)', () => {
360
+ const field = virtual({
361
+ type: 'string',
362
+ hooks: {
363
+ resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}`,
364
+ },
365
+ })
366
+
367
+ expect(field.outputType).toBe('string')
368
+ expect(field.getTypeScriptImports).toBeUndefined()
369
+ })
370
+
371
+ test('Array transformation', () => {
372
+ const field = virtual({
373
+ type: 'string[]',
374
+ hooks: {
375
+ resolveOutput: ({ item }) => {
376
+ // Split comma-separated tags into array
377
+ return item.tags ? item.tags.split(',').map((t: string) => t.trim()) : []
378
+ },
379
+ },
380
+ })
381
+
382
+ expect(field.outputType).toBe('string[]')
383
+ })
384
+ })
385
+
386
+ describe('backwards compatibility', () => {
387
+ test('existing primitive type strings still work', () => {
388
+ const field = virtual({
389
+ type: 'string',
390
+ hooks: {
391
+ resolveOutput: ({ item }) => item.value,
392
+ },
393
+ })
394
+
395
+ expect(field.type).toBe('virtual')
396
+ expect(field.outputType).toBe('string')
397
+ expect(field.getTypeScriptImports).toBeUndefined()
398
+ })
399
+
400
+ test('existing field configuration is preserved', () => {
401
+ const field = virtual({
402
+ type: 'number',
403
+ hooks: {
404
+ resolveOutput: ({ item }) => item.count,
405
+ resolveInput: async ({ inputValue }) => inputValue,
406
+ },
407
+ access: {
408
+ read: () => true,
409
+ },
410
+ ui: {
411
+ displayMode: 'readonly',
412
+ },
413
+ })
414
+
415
+ expect(field.hooks?.resolveOutput).toBeDefined()
416
+ expect(field.hooks?.resolveInput).toBeDefined()
417
+ expect(field.access).toBeDefined()
418
+ expect(field.ui).toBeDefined()
419
+ })
420
+ })
421
+ })