@opensaas/stack-core 0.10.0 → 0.11.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.
@@ -2,6 +2,7 @@ import { z } from 'zod'
2
2
  import type {
3
3
  TextField,
4
4
  IntegerField,
5
+ DecimalField,
5
6
  CheckboxField,
6
7
  TimestampField,
7
8
  PasswordField,
@@ -64,7 +65,7 @@ export function text<
64
65
 
65
66
  return !isRequired ? withMax.optional().nullable() : withMax
66
67
  },
67
- getPrismaType: () => {
68
+ getPrismaType: (_fieldName: string) => {
68
69
  const validation = options?.validation
69
70
  const isRequired = validation?.isRequired
70
71
  let modifiers = ''
@@ -81,6 +82,11 @@ export function text<
81
82
  modifiers += ' @index'
82
83
  }
83
84
 
85
+ // Map modifier
86
+ if (options?.db?.map) {
87
+ modifiers += ` @map("${options.db.map}")`
88
+ }
89
+
84
90
  return {
85
91
  type: 'String',
86
92
  modifiers: modifiers || undefined,
@@ -130,12 +136,23 @@ export function integer<
130
136
  ? withMax.optional().nullable()
131
137
  : withMax
132
138
  },
133
- getPrismaType: () => {
139
+ getPrismaType: (_fieldName: string) => {
134
140
  const isRequired = options?.validation?.isRequired
141
+ let modifiers = ''
142
+
143
+ // Optional modifier
144
+ if (!isRequired) {
145
+ modifiers += '?'
146
+ }
147
+
148
+ // Map modifier
149
+ if (options?.db?.map) {
150
+ modifiers += ` @map("${options.db.map}")`
151
+ }
135
152
 
136
153
  return {
137
154
  type: 'Int',
138
- modifiers: isRequired ? undefined : '?',
155
+ modifiers: modifiers || undefined,
139
156
  }
140
157
  },
141
158
  getTypeScriptType: () => {
@@ -149,6 +166,172 @@ export function integer<
149
166
  }
150
167
  }
151
168
 
169
+ /**
170
+ * Decimal field for precise numeric values (e.g., currency, measurements)
171
+ *
172
+ * **Features:**
173
+ * - Stores decimal numbers with configurable precision and scale
174
+ * - Uses Prisma's Decimal type (backed by decimal.js for precision)
175
+ * - Default precision: 18 digits, scale: 4 decimal places
176
+ * - Validation for min/max values
177
+ * - Optional database column mapping and nullability control
178
+ * - Index support (boolean or 'unique')
179
+ *
180
+ * **Usage Example:**
181
+ * ```typescript
182
+ * // In opensaas.config.ts
183
+ * fields: {
184
+ * price: decimal({
185
+ * precision: 10,
186
+ * scale: 2,
187
+ * validation: {
188
+ * isRequired: true,
189
+ * min: '0',
190
+ * max: '999999.99'
191
+ * }
192
+ * }),
193
+ * coordinates: decimal({
194
+ * precision: 18,
195
+ * scale: 8,
196
+ * db: { map: 'coord_value' }
197
+ * })
198
+ * }
199
+ *
200
+ * // Creating with decimal values
201
+ * const product = await context.db.product.create({
202
+ * data: {
203
+ * price: '19.99', // Can use string
204
+ * // price: 19.99, // or number (converted to Decimal)
205
+ * }
206
+ * })
207
+ * ```
208
+ *
209
+ * @param options - Field configuration options
210
+ * @returns Decimal field configuration
211
+ */
212
+ export function decimal<
213
+ TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
214
+ >(options?: Omit<DecimalField<TTypeInfo>, 'type'>): DecimalField<TTypeInfo> {
215
+ const precision = options?.precision ?? 18
216
+ const scale = options?.scale ?? 4
217
+
218
+ return {
219
+ type: 'decimal',
220
+ precision,
221
+ scale,
222
+ ...options,
223
+ getZodSchema: (fieldName: string, operation: 'create' | 'update') => {
224
+ // Decimal values can be provided as strings or numbers
225
+ // Prisma will convert them to Decimal instances
226
+ const baseSchema = z.union(
227
+ [
228
+ z.string({
229
+ message: `${formatFieldName(fieldName)} must be a decimal value (string or number)`,
230
+ }),
231
+ z.number({
232
+ message: `${formatFieldName(fieldName)} must be a decimal value (string or number)`,
233
+ }),
234
+ ],
235
+ {
236
+ message: `${formatFieldName(fieldName)} must be a decimal value`,
237
+ },
238
+ )
239
+
240
+ let schema = baseSchema
241
+
242
+ // Add min validation if specified
243
+ if (options?.validation?.min !== undefined) {
244
+ const minValue = parseFloat(options.validation.min)
245
+ schema = schema.refine(
246
+ (val) => {
247
+ const numVal = typeof val === 'string' ? parseFloat(val) : val
248
+ return !isNaN(numVal) && numVal >= minValue
249
+ },
250
+ {
251
+ message: `${formatFieldName(fieldName)} must be at least ${options.validation.min}`,
252
+ },
253
+ )
254
+ }
255
+
256
+ // Add max validation if specified
257
+ if (options?.validation?.max !== undefined) {
258
+ const maxValue = parseFloat(options.validation.max)
259
+ schema = schema.refine(
260
+ (val) => {
261
+ const numVal = typeof val === 'string' ? parseFloat(val) : val
262
+ return !isNaN(numVal) && numVal <= maxValue
263
+ },
264
+ {
265
+ message: `${formatFieldName(fieldName)} must be at most ${options.validation.max}`,
266
+ },
267
+ )
268
+ }
269
+
270
+ return !options?.validation?.isRequired || operation === 'update'
271
+ ? schema.optional().nullable()
272
+ : schema
273
+ },
274
+ getPrismaType: (_fieldName: string) => {
275
+ const validation = options?.validation
276
+ const db = options?.db
277
+ const isRequired = validation?.isRequired
278
+ const isNullable = db?.isNullable ?? !isRequired
279
+
280
+ let modifiers = ''
281
+
282
+ // Optional modifier
283
+ if (isNullable) {
284
+ modifiers += '?'
285
+ }
286
+
287
+ // Precision and scale
288
+ modifiers += ` @db.Decimal(${precision}, ${scale})`
289
+
290
+ // Default value if provided
291
+ if (options?.defaultValue !== undefined) {
292
+ modifiers += ` @default(${options.defaultValue})`
293
+ }
294
+
295
+ // Database mapping
296
+ if (db?.map) {
297
+ modifiers += ` @map("${db.map}")`
298
+ }
299
+
300
+ // Unique/index modifiers
301
+ if (options?.isIndexed === 'unique') {
302
+ modifiers += ' @unique'
303
+ } else if (options?.isIndexed === true) {
304
+ modifiers += ' @index'
305
+ }
306
+
307
+ return {
308
+ type: 'Decimal',
309
+ modifiers: modifiers.trimStart() || undefined,
310
+ }
311
+ },
312
+ getTypeScriptType: () => {
313
+ const validation = options?.validation
314
+ const db = options?.db
315
+ const isRequired = validation?.isRequired
316
+ const isNullable = db?.isNullable ?? !isRequired
317
+
318
+ return {
319
+ type: "import('decimal.js').Decimal",
320
+ optional: isNullable,
321
+ }
322
+ },
323
+ getTypeScriptImports: () => {
324
+ return [
325
+ {
326
+ names: ['Decimal'],
327
+ from: 'decimal.js',
328
+ typeOnly: true,
329
+ },
330
+ ]
331
+ },
332
+ }
333
+ }
334
+
152
335
  /**
153
336
  * Checkbox (boolean) field
154
337
  */
@@ -161,7 +344,7 @@ export function checkbox<
161
344
  getZodSchema: () => {
162
345
  return z.boolean().optional().nullable()
163
346
  },
164
- getPrismaType: () => {
347
+ getPrismaType: (_fieldName: string) => {
165
348
  const hasDefault = options?.defaultValue !== undefined
166
349
  let modifiers = ''
167
350
 
@@ -169,6 +352,11 @@ export function checkbox<
169
352
  modifiers = ` @default(${options.defaultValue})`
170
353
  }
171
354
 
355
+ // Map modifier
356
+ if (options?.db?.map) {
357
+ modifiers += ` @map("${options.db.map}")`
358
+ }
359
+
172
360
  return {
173
361
  type: 'Boolean',
174
362
  modifiers: modifiers || undefined,
@@ -195,7 +383,7 @@ export function timestamp<
195
383
  getZodSchema: () => {
196
384
  return z.union([z.date(), z.iso.datetime()]).optional().nullable()
197
385
  },
198
- getPrismaType: () => {
386
+ getPrismaType: (_fieldName: string) => {
199
387
  let modifiers = '?'
200
388
 
201
389
  // Check for default value
@@ -208,6 +396,11 @@ export function timestamp<
208
396
  modifiers = ' @default(now())'
209
397
  }
210
398
 
399
+ // Map modifier
400
+ if (options?.db?.map) {
401
+ modifiers += ` @map("${options.db.map}")`
402
+ }
403
+
211
404
  return {
212
405
  type: 'DateTime',
213
406
  modifiers,
@@ -360,12 +553,23 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
360
553
  .nullable()
361
554
  }
362
555
  },
363
- getPrismaType: () => {
556
+ getPrismaType: (_fieldName: string) => {
364
557
  const isRequired = options?.validation?.isRequired
558
+ let modifiers = ''
559
+
560
+ // Optional modifier
561
+ if (!isRequired) {
562
+ modifiers += '?'
563
+ }
564
+
565
+ // Map modifier
566
+ if (options?.db?.map) {
567
+ modifiers += ` @map("${options.db.map}")`
568
+ }
365
569
 
366
570
  return {
367
571
  type: 'String',
368
- modifiers: isRequired ? undefined : '?',
572
+ modifiers: modifiers || undefined,
369
573
  }
370
574
  },
371
575
  getTypeScriptType: () => {
@@ -404,17 +608,28 @@ export function select<
404
608
 
405
609
  return schema
406
610
  },
407
- getPrismaType: () => {
408
- let modifiers = '?'
611
+ getPrismaType: (_fieldName: string) => {
612
+ const isRequired = options.validation?.isRequired
613
+ let modifiers = ''
614
+
615
+ // Required fields don't get the ? modifier
616
+ if (!isRequired) {
617
+ modifiers = '?'
618
+ }
409
619
 
410
620
  // Add default value if provided
411
621
  if (options.defaultValue !== undefined) {
412
622
  modifiers = ` @default("${options.defaultValue}")`
413
623
  }
414
624
 
625
+ // Map modifier
626
+ if (options.db?.map) {
627
+ modifiers += ` @map("${options.db.map}")`
628
+ }
629
+
415
630
  return {
416
631
  type: 'String',
417
- modifiers,
632
+ modifiers: modifiers || undefined,
418
633
  }
419
634
  },
420
635
  getTypeScriptType: () => {
@@ -539,12 +754,23 @@ export function json<
539
754
  return baseSchema.optional().nullable()
540
755
  }
541
756
  },
542
- getPrismaType: () => {
757
+ getPrismaType: (_fieldName: string) => {
543
758
  const isRequired = options?.validation?.isRequired
759
+ let modifiers = ''
760
+
761
+ // Optional modifier
762
+ if (!isRequired) {
763
+ modifiers += '?'
764
+ }
765
+
766
+ // Map modifier
767
+ if (options?.db?.map) {
768
+ modifiers += ` @map("${options.db.map}")`
769
+ }
544
770
 
545
771
  return {
546
772
  type: 'Json',
547
- modifiers: isRequired ? undefined : '?',
773
+ modifiers: modifiers || undefined,
548
774
  }
549
775
  },
550
776
  getTypeScriptType: () => {
@@ -48,13 +48,13 @@ export async function executeResolveInput<
48
48
  | {
49
49
  operation: 'create'
50
50
  resolvedData: TCreateInput
51
- item?: undefined
51
+ item: undefined
52
52
  context: AccessContext
53
53
  }
54
54
  | {
55
55
  operation: 'update'
56
56
  resolvedData: TUpdateInput
57
- item?: TOutput
57
+ item: TOutput
58
58
  context: AccessContext
59
59
  },
60
60
  ): Promise<TCreateInput | TUpdateInput> {
@@ -62,9 +62,7 @@ export async function executeResolveInput<
62
62
  return args.resolvedData
63
63
  }
64
64
 
65
- // Type assertion is safe because we've constrained the args type
66
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
- const result = await hooks.resolveInput(args as any)
65
+ const result = await hooks.resolveInput(args)
68
66
  return result
69
67
  }
70
68
 
@@ -78,12 +76,19 @@ export async function executeValidateInput<
78
76
  TUpdateInput = Record<string, unknown>,
79
77
  >(
80
78
  hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
81
- args: {
82
- operation: 'create' | 'update'
83
- resolvedData: TCreateInput | TUpdateInput
84
- item?: TOutput
85
- context: AccessContext
86
- },
79
+ args:
80
+ | {
81
+ operation: 'create'
82
+ resolvedData: TCreateInput
83
+ item: undefined
84
+ context: AccessContext
85
+ }
86
+ | {
87
+ operation: 'update'
88
+ resolvedData: TUpdateInput
89
+ item: TOutput
90
+ context: AccessContext
91
+ },
87
92
  ): Promise<void> {
88
93
  if (!hooks?.validateInput) {
89
94
  return
@@ -109,18 +114,18 @@ export async function executeValidateInput<
109
114
  * Execute beforeOperation hook
110
115
  * Runs before database operation (cannot modify data)
111
116
  */
112
- export async function executeBeforeOperation<
113
- TOutput = Record<string, unknown>,
114
- TCreateInput = Record<string, unknown>,
115
- TUpdateInput = Record<string, unknown>,
116
- >(
117
- hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
118
- args: {
119
- operation: 'create' | 'update' | 'delete'
120
- resolvedData?: TCreateInput | TUpdateInput
121
- item?: TOutput
122
- context: AccessContext
123
- },
117
+ export async function executeBeforeOperation<TOutput = Record<string, unknown>>(
118
+ hooks: Hooks<TOutput> | undefined,
119
+ args:
120
+ | {
121
+ operation: 'create'
122
+ context: AccessContext
123
+ }
124
+ | {
125
+ operation: 'update' | 'delete'
126
+ item: TOutput
127
+ context: AccessContext
128
+ },
124
129
  ): Promise<void> {
125
130
  if (!hooks?.beforeOperation) {
126
131
  return
@@ -133,18 +138,21 @@ export async function executeBeforeOperation<
133
138
  * Execute afterOperation hook
134
139
  * Runs after database operation
135
140
  */
136
- export async function executeAfterOperation<
137
- TOutput = Record<string, unknown>,
138
- TCreateInput = Record<string, unknown>,
139
- TUpdateInput = Record<string, unknown>,
140
- >(
141
- hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
142
- args: {
143
- operation: 'create' | 'update' | 'delete'
144
- resolvedData?: TCreateInput | TUpdateInput
145
- item: TOutput
146
- context: AccessContext
147
- },
141
+ export async function executeAfterOperation<TOutput = Record<string, unknown>>(
142
+ hooks: Hooks<TOutput> | undefined,
143
+ args:
144
+ | {
145
+ operation: 'create'
146
+ item: TOutput
147
+ originalItem: undefined
148
+ context: AccessContext
149
+ }
150
+ | {
151
+ operation: 'update' | 'delete'
152
+ item: TOutput
153
+ originalItem: TOutput
154
+ context: AccessContext
155
+ },
148
156
  ): Promise<void> {
149
157
  if (!hooks?.afterOperation) {
150
158
  return
@@ -252,6 +252,70 @@ describe('Hooks', () => {
252
252
  context: mockContext,
253
253
  })
254
254
  })
255
+
256
+ it('should pass originalItem to afterOperation hook for update operation', async () => {
257
+ const originalItem = { id: '1', name: 'John' }
258
+ const updatedItem = { id: '1', name: 'Jane' }
259
+ const hooks: Hooks = {
260
+ afterOperation: vi.fn(async () => {}),
261
+ }
262
+
263
+ await executeAfterOperation(hooks, {
264
+ operation: 'update',
265
+ item: updatedItem,
266
+ originalItem,
267
+ context: mockContext,
268
+ })
269
+
270
+ expect(hooks.afterOperation).toHaveBeenCalledWith({
271
+ operation: 'update',
272
+ item: updatedItem,
273
+ originalItem,
274
+ context: mockContext,
275
+ })
276
+ })
277
+
278
+ it('should pass originalItem to afterOperation hook for delete operation', async () => {
279
+ const item = { id: '1', name: 'John' }
280
+ const hooks: Hooks = {
281
+ afterOperation: vi.fn(async () => {}),
282
+ }
283
+
284
+ await executeAfterOperation(hooks, {
285
+ operation: 'delete',
286
+ item,
287
+ originalItem: item,
288
+ context: mockContext,
289
+ })
290
+
291
+ expect(hooks.afterOperation).toHaveBeenCalledWith({
292
+ operation: 'delete',
293
+ item,
294
+ originalItem: item,
295
+ context: mockContext,
296
+ })
297
+ })
298
+
299
+ it('should pass undefined originalItem for create operation', async () => {
300
+ const item = { id: '1', name: 'John' }
301
+ const hooks: Hooks = {
302
+ afterOperation: vi.fn(async () => {}),
303
+ }
304
+
305
+ await executeAfterOperation(hooks, {
306
+ operation: 'create',
307
+ item,
308
+ originalItem: undefined,
309
+ context: mockContext,
310
+ })
311
+
312
+ expect(hooks.afterOperation).toHaveBeenCalledWith({
313
+ operation: 'create',
314
+ item,
315
+ originalItem: undefined,
316
+ context: mockContext,
317
+ })
318
+ })
255
319
  })
256
320
 
257
321
  describe('validateFieldRules', () => {