@opensaas/stack-core 0.10.0 → 0.12.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,8 +2,10 @@ import { z } from 'zod'
2
2
  import type {
3
3
  TextField,
4
4
  IntegerField,
5
+ DecimalField,
5
6
  CheckboxField,
6
7
  TimestampField,
8
+ CalendarDayField,
7
9
  PasswordField,
8
10
  SelectField,
9
11
  RelationshipField,
@@ -64,16 +66,23 @@ export function text<
64
66
 
65
67
  return !isRequired ? withMax.optional().nullable() : withMax
66
68
  },
67
- getPrismaType: () => {
69
+ getPrismaType: (_fieldName: string) => {
68
70
  const validation = options?.validation
71
+ const db = options?.db
69
72
  const isRequired = validation?.isRequired
73
+ const isNullable = db?.isNullable ?? !isRequired
70
74
  let modifiers = ''
71
75
 
72
76
  // Optional modifier
73
- if (!isRequired) {
77
+ if (isNullable) {
74
78
  modifiers += '?'
75
79
  }
76
80
 
81
+ // Native type modifier (e.g., @db.Text)
82
+ if (db?.nativeType) {
83
+ modifiers += ` @db.${db.nativeType}`
84
+ }
85
+
77
86
  // Unique/index modifiers
78
87
  if (options?.isIndexed === 'unique') {
79
88
  modifiers += ' @unique'
@@ -81,6 +90,11 @@ export function text<
81
90
  modifiers += ' @index'
82
91
  }
83
92
 
93
+ // Map modifier
94
+ if (db?.map) {
95
+ modifiers += ` @map("${db.map}")`
96
+ }
97
+
84
98
  return {
85
99
  type: 'String',
86
100
  modifiers: modifiers || undefined,
@@ -130,12 +144,23 @@ export function integer<
130
144
  ? withMax.optional().nullable()
131
145
  : withMax
132
146
  },
133
- getPrismaType: () => {
147
+ getPrismaType: (_fieldName: string) => {
134
148
  const isRequired = options?.validation?.isRequired
149
+ let modifiers = ''
150
+
151
+ // Optional modifier
152
+ if (!isRequired) {
153
+ modifiers += '?'
154
+ }
155
+
156
+ // Map modifier
157
+ if (options?.db?.map) {
158
+ modifiers += ` @map("${options.db.map}")`
159
+ }
135
160
 
136
161
  return {
137
162
  type: 'Int',
138
- modifiers: isRequired ? undefined : '?',
163
+ modifiers: modifiers || undefined,
139
164
  }
140
165
  },
141
166
  getTypeScriptType: () => {
@@ -149,6 +174,172 @@ export function integer<
149
174
  }
150
175
  }
151
176
 
177
+ /**
178
+ * Decimal field for precise numeric values (e.g., currency, measurements)
179
+ *
180
+ * **Features:**
181
+ * - Stores decimal numbers with configurable precision and scale
182
+ * - Uses Prisma's Decimal type (backed by decimal.js for precision)
183
+ * - Default precision: 18 digits, scale: 4 decimal places
184
+ * - Validation for min/max values
185
+ * - Optional database column mapping and nullability control
186
+ * - Index support (boolean or 'unique')
187
+ *
188
+ * **Usage Example:**
189
+ * ```typescript
190
+ * // In opensaas.config.ts
191
+ * fields: {
192
+ * price: decimal({
193
+ * precision: 10,
194
+ * scale: 2,
195
+ * validation: {
196
+ * isRequired: true,
197
+ * min: '0',
198
+ * max: '999999.99'
199
+ * }
200
+ * }),
201
+ * coordinates: decimal({
202
+ * precision: 18,
203
+ * scale: 8,
204
+ * db: { map: 'coord_value' }
205
+ * })
206
+ * }
207
+ *
208
+ * // Creating with decimal values
209
+ * const product = await context.db.product.create({
210
+ * data: {
211
+ * price: '19.99', // Can use string
212
+ * // price: 19.99, // or number (converted to Decimal)
213
+ * }
214
+ * })
215
+ * ```
216
+ *
217
+ * @param options - Field configuration options
218
+ * @returns Decimal field configuration
219
+ */
220
+ export function decimal<
221
+ TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
222
+ >(options?: Omit<DecimalField<TTypeInfo>, 'type'>): DecimalField<TTypeInfo> {
223
+ const precision = options?.precision ?? 18
224
+ const scale = options?.scale ?? 4
225
+
226
+ return {
227
+ type: 'decimal',
228
+ precision,
229
+ scale,
230
+ ...options,
231
+ getZodSchema: (fieldName: string, operation: 'create' | 'update') => {
232
+ // Decimal values can be provided as strings or numbers
233
+ // Prisma will convert them to Decimal instances
234
+ const baseSchema = z.union(
235
+ [
236
+ z.string({
237
+ message: `${formatFieldName(fieldName)} must be a decimal value (string or number)`,
238
+ }),
239
+ z.number({
240
+ message: `${formatFieldName(fieldName)} must be a decimal value (string or number)`,
241
+ }),
242
+ ],
243
+ {
244
+ message: `${formatFieldName(fieldName)} must be a decimal value`,
245
+ },
246
+ )
247
+
248
+ let schema = baseSchema
249
+
250
+ // Add min validation if specified
251
+ if (options?.validation?.min !== undefined) {
252
+ const minValue = parseFloat(options.validation.min)
253
+ schema = schema.refine(
254
+ (val) => {
255
+ const numVal = typeof val === 'string' ? parseFloat(val) : val
256
+ return !isNaN(numVal) && numVal >= minValue
257
+ },
258
+ {
259
+ message: `${formatFieldName(fieldName)} must be at least ${options.validation.min}`,
260
+ },
261
+ )
262
+ }
263
+
264
+ // Add max validation if specified
265
+ if (options?.validation?.max !== undefined) {
266
+ const maxValue = parseFloat(options.validation.max)
267
+ schema = schema.refine(
268
+ (val) => {
269
+ const numVal = typeof val === 'string' ? parseFloat(val) : val
270
+ return !isNaN(numVal) && numVal <= maxValue
271
+ },
272
+ {
273
+ message: `${formatFieldName(fieldName)} must be at most ${options.validation.max}`,
274
+ },
275
+ )
276
+ }
277
+
278
+ return !options?.validation?.isRequired || operation === 'update'
279
+ ? schema.optional().nullable()
280
+ : schema
281
+ },
282
+ getPrismaType: (_fieldName: string) => {
283
+ const validation = options?.validation
284
+ const db = options?.db
285
+ const isRequired = validation?.isRequired
286
+ const isNullable = db?.isNullable ?? !isRequired
287
+
288
+ let modifiers = ''
289
+
290
+ // Optional modifier
291
+ if (isNullable) {
292
+ modifiers += '?'
293
+ }
294
+
295
+ // Precision and scale
296
+ modifiers += ` @db.Decimal(${precision}, ${scale})`
297
+
298
+ // Default value if provided
299
+ if (options?.defaultValue !== undefined) {
300
+ modifiers += ` @default(${options.defaultValue})`
301
+ }
302
+
303
+ // Database mapping
304
+ if (db?.map) {
305
+ modifiers += ` @map("${db.map}")`
306
+ }
307
+
308
+ // Unique/index modifiers
309
+ if (options?.isIndexed === 'unique') {
310
+ modifiers += ' @unique'
311
+ } else if (options?.isIndexed === true) {
312
+ modifiers += ' @index'
313
+ }
314
+
315
+ return {
316
+ type: 'Decimal',
317
+ modifiers: modifiers.trimStart() || undefined,
318
+ }
319
+ },
320
+ getTypeScriptType: () => {
321
+ const validation = options?.validation
322
+ const db = options?.db
323
+ const isRequired = validation?.isRequired
324
+ const isNullable = db?.isNullable ?? !isRequired
325
+
326
+ return {
327
+ type: "import('decimal.js').Decimal",
328
+ optional: isNullable,
329
+ }
330
+ },
331
+ getTypeScriptImports: () => {
332
+ return [
333
+ {
334
+ names: ['Decimal'],
335
+ from: 'decimal.js',
336
+ typeOnly: true,
337
+ },
338
+ ]
339
+ },
340
+ }
341
+ }
342
+
152
343
  /**
153
344
  * Checkbox (boolean) field
154
345
  */
@@ -161,7 +352,7 @@ export function checkbox<
161
352
  getZodSchema: () => {
162
353
  return z.boolean().optional().nullable()
163
354
  },
164
- getPrismaType: () => {
355
+ getPrismaType: (_fieldName: string) => {
165
356
  const hasDefault = options?.defaultValue !== undefined
166
357
  let modifiers = ''
167
358
 
@@ -169,6 +360,11 @@ export function checkbox<
169
360
  modifiers = ` @default(${options.defaultValue})`
170
361
  }
171
362
 
363
+ // Map modifier
364
+ if (options?.db?.map) {
365
+ modifiers += ` @map("${options.db.map}")`
366
+ }
367
+
172
368
  return {
173
369
  type: 'Boolean',
174
370
  modifiers: modifiers || undefined,
@@ -195,7 +391,7 @@ export function timestamp<
195
391
  getZodSchema: () => {
196
392
  return z.union([z.date(), z.iso.datetime()]).optional().nullable()
197
393
  },
198
- getPrismaType: () => {
394
+ getPrismaType: (_fieldName: string) => {
199
395
  let modifiers = '?'
200
396
 
201
397
  // Check for default value
@@ -208,6 +404,11 @@ export function timestamp<
208
404
  modifiers = ' @default(now())'
209
405
  }
210
406
 
407
+ // Map modifier
408
+ if (options?.db?.map) {
409
+ modifiers += ` @map("${options.db.map}")`
410
+ }
411
+
211
412
  return {
212
413
  type: 'DateTime',
213
414
  modifiers,
@@ -228,6 +429,131 @@ export function timestamp<
228
429
  }
229
430
  }
230
431
 
432
+ /**
433
+ * Calendar Day field - date only (no time) in ISO8601 format
434
+ *
435
+ * **Features:**
436
+ * - Stores date values only (no time component)
437
+ * - PostgreSQL/MySQL: Uses native DATE type via @db.Date
438
+ * - SQLite: Uses String representation
439
+ * - Accepts ISO8601 date strings (YYYY-MM-DD format)
440
+ * - Optional validation for required fields
441
+ * - Database column mapping and nullability control
442
+ * - Index support (boolean or 'unique')
443
+ *
444
+ * **Usage Example:**
445
+ * ```typescript
446
+ * // In opensaas.config.ts
447
+ * fields: {
448
+ * birthDate: calendarDay({
449
+ * validation: { isRequired: true }
450
+ * }),
451
+ * startDate: calendarDay({
452
+ * defaultValue: '2025-01-01',
453
+ * db: { map: 'start_date' }
454
+ * }),
455
+ * endDate: calendarDay({
456
+ * isIndexed: true
457
+ * })
458
+ * }
459
+ *
460
+ * // Creating with date values
461
+ * const event = await context.db.event.create({
462
+ * data: {
463
+ * startDate: '2025-01-15',
464
+ * endDate: '2025-01-20'
465
+ * }
466
+ * })
467
+ * ```
468
+ *
469
+ * @param options - Field configuration options
470
+ * @returns Calendar Day field configuration
471
+ */
472
+ export function calendarDay<
473
+ TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
474
+ >(options?: Omit<CalendarDayField<TTypeInfo>, 'type'>): CalendarDayField<TTypeInfo> {
475
+ return {
476
+ type: 'calendarDay',
477
+ ...options,
478
+ getZodSchema: (fieldName: string, operation: 'create' | 'update') => {
479
+ const validation = options?.validation
480
+ const isRequired = validation?.isRequired
481
+
482
+ // Accept ISO8601 date strings (YYYY-MM-DD)
483
+ const baseSchema = z.string({
484
+ message: `${formatFieldName(fieldName)} must be a valid date in ISO8601 format (YYYY-MM-DD)`,
485
+ })
486
+
487
+ // Validate ISO8601 date format (YYYY-MM-DD)
488
+ const dateSchema = baseSchema.regex(/^\d{4}-\d{2}-\d{2}$/, {
489
+ message: `${formatFieldName(fieldName)} must be in YYYY-MM-DD format`,
490
+ })
491
+
492
+ if (isRequired && operation === 'create') {
493
+ return dateSchema
494
+ } else if (isRequired && operation === 'update') {
495
+ // Required in update mode: can be undefined for partial updates
496
+ return z.union([dateSchema, z.undefined()])
497
+ } else {
498
+ return dateSchema.optional().nullable()
499
+ }
500
+ },
501
+ getPrismaType: (_fieldName: string, provider?: string) => {
502
+ const validation = options?.validation
503
+ const db = options?.db
504
+ const isRequired = validation?.isRequired
505
+ const isNullable = db?.isNullable ?? !isRequired
506
+
507
+ let modifiers = ''
508
+
509
+ // Optional modifier
510
+ if (isNullable) {
511
+ modifiers += '?'
512
+ }
513
+
514
+ // Add @db.Date attribute for date-only storage
515
+ // Only for PostgreSQL/MySQL - SQLite doesn't support native DATE type
516
+ // SQLite will use TEXT for DateTime fields
517
+ if (provider && provider.toLowerCase() !== 'sqlite') {
518
+ modifiers += ' @db.Date'
519
+ }
520
+
521
+ // Default value if provided
522
+ if (options?.defaultValue !== undefined) {
523
+ modifiers += ` @default("${options.defaultValue}")`
524
+ }
525
+
526
+ // Database mapping
527
+ if (db?.map) {
528
+ modifiers += ` @map("${db.map}")`
529
+ }
530
+
531
+ // Unique/index modifiers
532
+ if (options?.isIndexed === 'unique') {
533
+ modifiers += ' @unique'
534
+ } else if (options?.isIndexed === true) {
535
+ modifiers += ' @index'
536
+ }
537
+
538
+ return {
539
+ type: 'DateTime',
540
+ modifiers: modifiers.trimStart() || undefined,
541
+ }
542
+ },
543
+ getTypeScriptType: () => {
544
+ const validation = options?.validation
545
+ const db = options?.db
546
+ const isRequired = validation?.isRequired
547
+ const isNullable = db?.isNullable ?? !isRequired
548
+
549
+ return {
550
+ type: 'Date',
551
+ optional: isNullable,
552
+ }
553
+ },
554
+ }
555
+ }
556
+
231
557
  /**
232
558
  * Password field (automatically hashed using bcrypt)
233
559
  *
@@ -360,12 +686,23 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
360
686
  .nullable()
361
687
  }
362
688
  },
363
- getPrismaType: () => {
689
+ getPrismaType: (_fieldName: string) => {
364
690
  const isRequired = options?.validation?.isRequired
691
+ let modifiers = ''
692
+
693
+ // Optional modifier
694
+ if (!isRequired) {
695
+ modifiers += '?'
696
+ }
697
+
698
+ // Map modifier
699
+ if (options?.db?.map) {
700
+ modifiers += ` @map("${options.db.map}")`
701
+ }
365
702
 
366
703
  return {
367
704
  type: 'String',
368
- modifiers: isRequired ? undefined : '?',
705
+ modifiers: modifiers || undefined,
369
706
  }
370
707
  },
371
708
  getTypeScriptType: () => {
@@ -404,17 +741,28 @@ export function select<
404
741
 
405
742
  return schema
406
743
  },
407
- getPrismaType: () => {
408
- let modifiers = '?'
744
+ getPrismaType: (_fieldName: string) => {
745
+ const isRequired = options.validation?.isRequired
746
+ let modifiers = ''
747
+
748
+ // Required fields don't get the ? modifier
749
+ if (!isRequired) {
750
+ modifiers = '?'
751
+ }
409
752
 
410
753
  // Add default value if provided
411
754
  if (options.defaultValue !== undefined) {
412
755
  modifiers = ` @default("${options.defaultValue}")`
413
756
  }
414
757
 
758
+ // Map modifier
759
+ if (options.db?.map) {
760
+ modifiers += ` @map("${options.db.map}")`
761
+ }
762
+
415
763
  return {
416
764
  type: 'String',
417
- modifiers,
765
+ modifiers: modifiers || undefined,
418
766
  }
419
767
  },
420
768
  getTypeScriptType: () => {
@@ -539,12 +887,23 @@ export function json<
539
887
  return baseSchema.optional().nullable()
540
888
  }
541
889
  },
542
- getPrismaType: () => {
890
+ getPrismaType: (_fieldName: string) => {
543
891
  const isRequired = options?.validation?.isRequired
892
+ let modifiers = ''
893
+
894
+ // Optional modifier
895
+ if (!isRequired) {
896
+ modifiers += '?'
897
+ }
898
+
899
+ // Map modifier
900
+ if (options?.db?.map) {
901
+ modifiers += ` @map("${options.db.map}")`
902
+ }
544
903
 
545
904
  return {
546
905
  type: 'Json',
547
- modifiers: isRequired ? undefined : '?',
906
+ modifiers: modifiers || undefined,
548
907
  }
549
908
  },
550
909
  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', () => {