@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.
@@ -43,7 +43,9 @@ export type FieldHooks<
43
43
  *
44
44
  * @example
45
45
  * ```typescript
46
- * resolveInput: async ({ inputValue, operation }) => {
46
+ * resolveInput: async ({ inputValue, operation, item }) => {
47
+ * // For create operations, item is undefined
48
+ * // For update operations, item is the existing record
47
49
  * if (typeof inputValue === 'string' && !isHashedPassword(inputValue)) {
48
50
  * return await hashPassword(inputValue)
49
51
  * }
@@ -51,14 +53,25 @@ export type FieldHooks<
51
53
  * }
52
54
  * ```
53
55
  */
54
- resolveInput?: (args: {
55
- operation: 'create' | 'update'
56
- inputValue: GetFieldValueType<TTypeInfo['fields'], TFieldKey> | undefined
57
- item?: TTypeInfo['item']
58
- listKey: string
59
- fieldName: TFieldKey
60
- context: import('../access/types.js').AccessContext
61
- }) =>
56
+ resolveInput?: (
57
+ args:
58
+ | {
59
+ operation: 'create'
60
+ inputValue: GetFieldValueType<TTypeInfo['fields'], TFieldKey> | undefined
61
+ item: undefined
62
+ listKey: string
63
+ fieldName: TFieldKey
64
+ context: import('../access/types.js').AccessContext
65
+ }
66
+ | {
67
+ operation: 'update'
68
+ inputValue: GetFieldValueType<TTypeInfo['fields'], TFieldKey> | undefined
69
+ item: TTypeInfo['item']
70
+ listKey: string
71
+ fieldName: TFieldKey
72
+ context: import('../access/types.js').AccessContext
73
+ },
74
+ ) =>
62
75
  | Promise<GetFieldValueType<TTypeInfo['fields'], TFieldKey> | undefined>
63
76
  | GetFieldValueType<TTypeInfo['fields'], TFieldKey>
64
77
  | undefined
@@ -71,19 +84,34 @@ export type FieldHooks<
71
84
  * @example
72
85
  * ```typescript
73
86
  * beforeOperation: async ({ resolvedValue, operation, item }) => {
74
- * console.log(`About to ${operation} field with value:`, resolvedValue)
87
+ * // For create operations, item is undefined
88
+ * // For update/delete operations, item is the existing record
89
+ * if (operation === 'update' && item) {
90
+ * console.log(`Updating field from ${item.fieldName} to ${resolvedValue}`)
91
+ * }
75
92
  * await sendAuditLog({ operation, item })
76
93
  * }
77
94
  * ```
78
95
  */
79
- beforeOperation?: (args: {
80
- operation: 'create' | 'update' | 'delete'
81
- resolvedValue: GetFieldValueType<TTypeInfo['fields'], TFieldKey> | undefined
82
- item?: TTypeInfo['item']
83
- listKey: string
84
- fieldName: TFieldKey
85
- context: import('../access/types.js').AccessContext
86
- }) => Promise<void> | void
96
+ beforeOperation?: (
97
+ args:
98
+ | {
99
+ operation: 'create'
100
+ resolvedValue: GetFieldValueType<TTypeInfo['fields'], TFieldKey> | undefined
101
+ item: undefined
102
+ listKey: string
103
+ fieldName: TFieldKey
104
+ context: import('../access/types.js').AccessContext
105
+ }
106
+ | {
107
+ operation: 'update' | 'delete'
108
+ resolvedValue: GetFieldValueType<TTypeInfo['fields'], TFieldKey> | undefined
109
+ item: TTypeInfo['item']
110
+ listKey: string
111
+ fieldName: TFieldKey
112
+ context: import('../access/types.js').AccessContext
113
+ },
114
+ ) => Promise<void> | void
87
115
 
88
116
  /**
89
117
  * Perform side effects after database operation
@@ -92,20 +120,38 @@ export type FieldHooks<
92
120
  *
93
121
  * @example
94
122
  * ```typescript
95
- * afterOperation: async ({ operation, value, item }) => {
123
+ * afterOperation: async ({ operation, value, item, originalItem }) => {
124
+ * // For query/create operations, originalItem is undefined
125
+ * // For update/delete operations, originalItem is the item before the operation
126
+ * if (operation === 'update' && originalItem) {
127
+ * console.log('Changed from:', originalItem[fieldName], 'to:', value)
128
+ * }
96
129
  * await invalidateCache({ listKey, itemId: item.id })
97
130
  * await sendWebhook({ operation, item })
98
131
  * }
99
132
  * ```
100
133
  */
101
- afterOperation?: (args: {
102
- operation: 'create' | 'update' | 'delete' | 'query'
103
- value: GetFieldValueType<TTypeInfo['fields'], TFieldKey> | undefined
104
- item: TTypeInfo['item']
105
- listKey: string
106
- fieldName: TFieldKey
107
- context: import('../access/types.js').AccessContext
108
- }) => Promise<void> | void
134
+ afterOperation?: (
135
+ args:
136
+ | {
137
+ operation: 'query' | 'create'
138
+ value: GetFieldValueType<TTypeInfo['fields'], TFieldKey> | undefined
139
+ item: TTypeInfo['item']
140
+ originalItem: undefined
141
+ listKey: string
142
+ fieldName: TFieldKey
143
+ context: import('../access/types.js').AccessContext
144
+ }
145
+ | {
146
+ operation: 'update' | 'delete'
147
+ value: GetFieldValueType<TTypeInfo['fields'], TFieldKey> | undefined
148
+ item: TTypeInfo['item']
149
+ originalItem: TTypeInfo['item']
150
+ listKey: string
151
+ fieldName: TFieldKey
152
+ context: import('../access/types.js').AccessContext
153
+ },
154
+ ) => Promise<void> | void
109
155
 
110
156
  /**
111
157
  * Transform field value after database read
@@ -175,6 +221,23 @@ export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
175
221
  * Transforms field values and types in query results using Prisma's native extension system
176
222
  */
177
223
  resultExtension?: ResultExtensionConfig
224
+ /**
225
+ * Database configuration
226
+ */
227
+ db?: {
228
+ /**
229
+ * Custom database column name
230
+ * Adds a @map attribute in Prisma schema
231
+ * @example
232
+ * ```typescript
233
+ * fields: {
234
+ * firstName: text({ db: { map: 'first_name' } })
235
+ * }
236
+ * // Generates: firstName String @map("first_name")
237
+ * ```
238
+ */
239
+ map?: string
240
+ }
178
241
  ui?: {
179
242
  /**
180
243
  * Custom React component to render this field
@@ -214,9 +277,13 @@ export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
214
277
  /**
215
278
  * Get Prisma type and modifiers for schema generation
216
279
  * @param fieldName - The name of the field (for generating modifiers)
280
+ * @param provider - Optional database provider ('sqlite', 'postgresql', 'mysql', etc.)
217
281
  * @returns Prisma type string and optional modifiers
218
282
  */
219
- getPrismaType?: (fieldName: string) => {
283
+ getPrismaType?: (
284
+ fieldName: string,
285
+ provider?: string,
286
+ ) => {
220
287
  type: string
221
288
  modifiers?: string
222
289
  }
@@ -261,6 +328,37 @@ export type TextField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<T
261
328
  }
262
329
  }
263
330
  isIndexed?: boolean | 'unique'
331
+ db?: {
332
+ map?: string
333
+ /**
334
+ * Prisma native database type attribute
335
+ * Allows overriding the default String type for the database provider
336
+ * @example
337
+ * ```typescript
338
+ * // PostgreSQL/MySQL
339
+ * fields: {
340
+ * description: text({ db: { nativeType: 'Text' } })
341
+ * // Generates: description String @db.Text
342
+ * }
343
+ * ```
344
+ */
345
+ nativeType?: string
346
+ /**
347
+ * Controls nullability in the database schema
348
+ * When specified, overrides the default behavior (isRequired determines nullability)
349
+ * @example
350
+ * ```typescript
351
+ * fields: {
352
+ * description: text({
353
+ * validation: { isRequired: true },
354
+ * db: { isNullable: false }
355
+ * })
356
+ * // Generates: description String (non-nullable)
357
+ * }
358
+ * ```
359
+ */
360
+ isNullable?: boolean
361
+ }
264
362
  ui?: {
265
363
  displayMode?: 'input' | 'textarea'
266
364
  }
@@ -275,6 +373,23 @@ export type IntegerField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfi
275
373
  }
276
374
  }
277
375
 
376
+ export type DecimalField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
377
+ type: 'decimal'
378
+ defaultValue?: string
379
+ precision?: number
380
+ scale?: number
381
+ db?: {
382
+ map?: string
383
+ isNullable?: boolean
384
+ }
385
+ validation?: {
386
+ isRequired?: boolean
387
+ min?: string
388
+ max?: string
389
+ }
390
+ isIndexed?: boolean | 'unique'
391
+ }
392
+
278
393
  export type CheckboxField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
279
394
  type: 'checkbox'
280
395
  }
@@ -284,6 +399,19 @@ export type TimestampField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldCon
284
399
  defaultValue?: { kind: 'now' } | Date
285
400
  }
286
401
 
402
+ export type CalendarDayField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
403
+ type: 'calendarDay'
404
+ defaultValue?: string
405
+ db?: {
406
+ map?: string
407
+ isNullable?: boolean
408
+ }
409
+ validation?: {
410
+ isRequired?: boolean
411
+ }
412
+ isIndexed?: boolean | 'unique'
413
+ }
414
+
287
415
  export type PasswordField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
288
416
  type: 'password'
289
417
  validation?: {
@@ -309,18 +437,32 @@ export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
309
437
  many?: boolean
310
438
  db?: {
311
439
  /**
312
- * Controls foreign key placement for bidirectional relationships
440
+ * Controls foreign key placement and column name for bidirectional relationships
441
+ * Can be a boolean or an object with a map property
313
442
  * Only valid on single (non-many) relationships
314
443
  * Cannot be true on both sides of a one-to-one relationship
315
444
  *
445
+ * When a boolean, defaults the foreign key column name to the field name
446
+ * When an object with map, uses the provided column name
447
+ *
316
448
  * @example
317
449
  * ```typescript
318
- * // One-to-one: User has one Account
450
+ * // One-to-one: User has one Account (default foreign key name)
319
451
  * User: list({
320
452
  * fields: {
321
453
  * account: relationship({ ref: 'Account.user', db: { foreignKey: true } })
454
+ * // Generates: accountId String? @unique
455
+ * }
456
+ * })
457
+ *
458
+ * // One-to-one: User has one Account (custom foreign key name)
459
+ * User: list({
460
+ * fields: {
461
+ * account: relationship({ ref: 'Account.user', db: { foreignKey: { map: 'account_id' } } })
462
+ * // Generates: accountId String? @unique @map("account_id")
322
463
  * }
323
464
  * })
465
+ *
324
466
  * Account: list({
325
467
  * fields: {
326
468
  * user: relationship({ ref: 'User.account' }) // No foreign key on this side
@@ -328,7 +470,7 @@ export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
328
470
  * })
329
471
  * ```
330
472
  */
331
- foreignKey?: boolean
473
+ foreignKey?: boolean | { map?: string }
332
474
  }
333
475
  ui?: {
334
476
  displayMode?: 'select' | 'cards'
@@ -567,19 +709,69 @@ export type ResolveInputHookArgs<
567
709
  }
568
710
 
569
711
  /**
570
- * Hook arguments for other hooks (validateInput, beforeOperation, afterOperation)
571
- * These hooks receive the same structure regardless of operation
712
+ * Hook arguments for validateInput hook
713
+ * Uses discriminated union to provide proper types based on operation
714
+ * - create: resolvedData is CreateInput, item is undefined
715
+ * - update: resolvedData is UpdateInput, item is the existing record
572
716
  */
573
- export type HookArgs<
717
+ export type ValidateInputHookArgs<
574
718
  TOutput = Record<string, unknown>,
575
719
  TCreateInput = Record<string, unknown>,
576
720
  TUpdateInput = Record<string, unknown>,
577
- > = {
578
- operation: 'create' | 'update' | 'delete'
579
- resolvedData?: TCreateInput | TUpdateInput
580
- item?: TOutput
581
- context: import('../access/types.js').AccessContext
582
- }
721
+ > =
722
+ | {
723
+ operation: 'create'
724
+ resolvedData: TCreateInput
725
+ item: undefined
726
+ context: import('../access/types.js').AccessContext
727
+ addValidationError: (msg: string) => void
728
+ }
729
+ | {
730
+ operation: 'update'
731
+ resolvedData: TUpdateInput
732
+ item: TOutput
733
+ context: import('../access/types.js').AccessContext
734
+ addValidationError: (msg: string) => void
735
+ }
736
+
737
+ /**
738
+ * Hook arguments for beforeOperation hook
739
+ * Uses discriminated union to provide proper types based on operation
740
+ * - create: no resolvedData, no item
741
+ * - update: no resolvedData, has item
742
+ * - delete: no resolvedData, has item
743
+ */
744
+ export type BeforeOperationHookArgs<TOutput = Record<string, unknown>> =
745
+ | {
746
+ operation: 'create'
747
+ context: import('../access/types.js').AccessContext
748
+ }
749
+ | {
750
+ operation: 'update' | 'delete'
751
+ item: TOutput
752
+ context: import('../access/types.js').AccessContext
753
+ }
754
+
755
+ /**
756
+ * Hook arguments for afterOperation hook
757
+ * Uses discriminated union to provide proper types based on operation
758
+ * - create: has item, no originalItem
759
+ * - update: has item, has originalItem
760
+ * - delete: has item, has originalItem
761
+ */
762
+ export type AfterOperationHookArgs<TOutput = Record<string, unknown>> =
763
+ | {
764
+ operation: 'create'
765
+ item: TOutput
766
+ originalItem: undefined
767
+ context: import('../access/types.js').AccessContext
768
+ }
769
+ | {
770
+ operation: 'update' | 'delete'
771
+ item: TOutput
772
+ originalItem: TOutput
773
+ context: import('../access/types.js').AccessContext
774
+ }
583
775
 
584
776
  export type Hooks<
585
777
  TOutput = Record<string, unknown>,
@@ -590,13 +782,10 @@ export type Hooks<
590
782
  args: ResolveInputHookArgs<TOutput, TCreateInput, TUpdateInput>,
591
783
  ) => Promise<TCreateInput | TUpdateInput>
592
784
  validateInput?: (
593
- args: HookArgs<TOutput, TCreateInput, TUpdateInput> & {
594
- operation: 'create' | 'update'
595
- addValidationError: (msg: string) => void
596
- },
785
+ args: ValidateInputHookArgs<TOutput, TCreateInput, TUpdateInput>,
597
786
  ) => Promise<void>
598
- beforeOperation?: (args: HookArgs<TOutput, TCreateInput, TUpdateInput>) => Promise<void>
599
- afterOperation?: (args: HookArgs<TOutput, TCreateInput, TUpdateInput>) => Promise<void>
787
+ beforeOperation?: (args: BeforeOperationHookArgs<TOutput>) => Promise<void>
788
+ afterOperation?: (args: AfterOperationHookArgs<TOutput>) => Promise<void>
600
789
  }
601
790
 
602
791
  // Generic `any` default allows ListConfig to work with any list item type
@@ -107,6 +107,8 @@ async function executeFieldAfterOperationHooks(
107
107
  operation: 'create' | 'update' | 'delete' | 'query',
108
108
  context: AccessContext,
109
109
  listKey: string,
110
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
+ originalItem?: any,
110
112
  ): Promise<void> {
111
113
  for (const [fieldName, fieldConfig] of Object.entries(fields)) {
112
114
  // Skip if no hooks defined
@@ -122,6 +124,7 @@ async function executeFieldAfterOperationHooks(
122
124
  fieldName,
123
125
  listKey,
124
126
  item,
127
+ originalItem,
125
128
  context,
126
129
  })
127
130
  }
@@ -483,6 +486,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
483
486
  'query',
484
487
  context,
485
488
  listName,
489
+ undefined, // originalItem is undefined for query operations
486
490
  )
487
491
 
488
492
  return filtered
@@ -579,6 +583,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
579
583
  'query',
580
584
  context,
581
585
  listName,
586
+ undefined, // originalItem is undefined for query operations
582
587
  ),
583
588
  ),
584
589
  )
@@ -616,6 +621,7 @@ function createCreate<TPrisma extends PrismaClientLike>(
616
621
  let resolvedData = await executeResolveInput(listConfig.hooks, {
617
622
  operation: 'create',
618
623
  resolvedData: args.data,
624
+ item: undefined,
619
625
  context,
620
626
  })
621
627
 
@@ -632,6 +638,7 @@ function createCreate<TPrisma extends PrismaClientLike>(
632
638
  await executeValidateInput(listConfig.hooks, {
633
639
  operation: 'create',
634
640
  resolvedData,
641
+ item: undefined,
635
642
  context,
636
643
  })
637
644
 
@@ -677,6 +684,7 @@ function createCreate<TPrisma extends PrismaClientLike>(
677
684
  await executeAfterOperation(listConfig.hooks, {
678
685
  operation: 'create',
679
686
  item,
687
+ originalItem: undefined,
680
688
  context,
681
689
  })
682
690
 
@@ -688,6 +696,7 @@ function createCreate<TPrisma extends PrismaClientLike>(
688
696
  'create',
689
697
  context,
690
698
  listName,
699
+ undefined, // originalItem is undefined for create operations
691
700
  )
692
701
 
693
702
  // 11. Filter readable fields and apply resolveOutput hooks (including nested relationships)
@@ -832,6 +841,7 @@ function createUpdate<TPrisma extends PrismaClientLike>(
832
841
  await executeAfterOperation(listConfig.hooks, {
833
842
  operation: 'update',
834
843
  item: updated,
844
+ originalItem: item, // item is the original item before the update
835
845
  context,
836
846
  })
837
847
 
@@ -843,6 +853,7 @@ function createUpdate<TPrisma extends PrismaClientLike>(
843
853
  'update',
844
854
  context,
845
855
  listName,
856
+ item, // item is the original item before the update
846
857
  )
847
858
 
848
859
  // 12. Filter readable fields and apply resolveOutput hooks (including nested relationships)
@@ -930,6 +941,7 @@ function createDelete<TPrisma extends PrismaClientLike>(
930
941
  await executeAfterOperation(listConfig.hooks, {
931
942
  operation: 'delete',
932
943
  item: deleted,
944
+ originalItem: item, // item is the original item before deletion
933
945
  context,
934
946
  })
935
947
 
@@ -941,6 +953,7 @@ function createDelete<TPrisma extends PrismaClientLike>(
941
953
  'delete',
942
954
  context,
943
955
  listName,
956
+ item, // item is the original item before deletion
944
957
  )
945
958
 
946
959
  return deleted
@@ -87,6 +87,7 @@ async function processNestedCreate(
87
87
  let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
88
88
  operation: 'create',
89
89
  resolvedData: item,
90
+ item: undefined,
90
91
  context,
91
92
  })
92
93
 
@@ -113,6 +114,7 @@ async function processNestedCreate(
113
114
  await executeValidateInput(relatedListConfig.hooks, {
114
115
  operation: 'create',
115
116
  resolvedData,
117
+ item: undefined,
116
118
  context,
117
119
  })
118
120