@opensaas/stack-core 0.1.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.
- package/.turbo/turbo-build.log +4 -0
- package/README.md +447 -0
- package/dist/access/engine.d.ts +73 -0
- package/dist/access/engine.d.ts.map +1 -0
- package/dist/access/engine.js +244 -0
- package/dist/access/engine.js.map +1 -0
- package/dist/access/field-transforms.d.ts +47 -0
- package/dist/access/field-transforms.d.ts.map +1 -0
- package/dist/access/field-transforms.js +2 -0
- package/dist/access/field-transforms.js.map +1 -0
- package/dist/access/index.d.ts +3 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/index.js +2 -0
- package/dist/access/index.js.map +1 -0
- package/dist/access/types.d.ts +83 -0
- package/dist/access/types.d.ts.map +1 -0
- package/dist/access/types.js +2 -0
- package/dist/access/types.js.map +1 -0
- package/dist/config/index.d.ts +39 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +38 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/types.d.ts +413 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +2 -0
- package/dist/config/types.js.map +1 -0
- package/dist/context/index.d.ts +31 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +524 -0
- package/dist/context/index.js.map +1 -0
- package/dist/context/nested-operations.d.ts +10 -0
- package/dist/context/nested-operations.d.ts.map +1 -0
- package/dist/context/nested-operations.js +261 -0
- package/dist/context/nested-operations.js.map +1 -0
- package/dist/fields/index.d.ts +78 -0
- package/dist/fields/index.d.ts.map +1 -0
- package/dist/fields/index.js +381 -0
- package/dist/fields/index.js.map +1 -0
- package/dist/hooks/index.d.ts +58 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +79 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/case-utils.d.ts +49 -0
- package/dist/lib/case-utils.d.ts.map +1 -0
- package/dist/lib/case-utils.js +68 -0
- package/dist/lib/case-utils.js.map +1 -0
- package/dist/lib/case-utils.test.d.ts +2 -0
- package/dist/lib/case-utils.test.d.ts.map +1 -0
- package/dist/lib/case-utils.test.js +101 -0
- package/dist/lib/case-utils.test.js.map +1 -0
- package/dist/utils/password.d.ts +81 -0
- package/dist/utils/password.d.ts.map +1 -0
- package/dist/utils/password.js +132 -0
- package/dist/utils/password.js.map +1 -0
- package/dist/validation/schema.d.ts +17 -0
- package/dist/validation/schema.d.ts.map +1 -0
- package/dist/validation/schema.js +42 -0
- package/dist/validation/schema.js.map +1 -0
- package/dist/validation/schema.test.d.ts +2 -0
- package/dist/validation/schema.test.d.ts.map +1 -0
- package/dist/validation/schema.test.js +143 -0
- package/dist/validation/schema.test.js.map +1 -0
- package/docs/type-distribution-fix.md +136 -0
- package/package.json +48 -0
- package/src/access/engine.ts +360 -0
- package/src/access/field-transforms.ts +99 -0
- package/src/access/index.ts +20 -0
- package/src/access/types.ts +103 -0
- package/src/config/index.ts +71 -0
- package/src/config/types.ts +478 -0
- package/src/context/index.ts +814 -0
- package/src/context/nested-operations.ts +412 -0
- package/src/fields/index.ts +438 -0
- package/src/hooks/index.ts +132 -0
- package/src/index.ts +62 -0
- package/src/lib/case-utils.test.ts +127 -0
- package/src/lib/case-utils.ts +74 -0
- package/src/utils/password.ts +147 -0
- package/src/validation/schema.test.ts +171 -0
- package/src/validation/schema.ts +59 -0
- package/tests/access-relationships.test.ts +613 -0
- package/tests/access.test.ts +499 -0
- package/tests/config.test.ts +195 -0
- package/tests/context.test.ts +248 -0
- package/tests/hooks.test.ts +417 -0
- package/tests/password-type-distribution.test.ts +155 -0
- package/tests/password-types.test.ts +147 -0
- package/tests/password.test.ts +249 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
pascalToCamel,
|
|
4
|
+
pascalToKebab,
|
|
5
|
+
kebabToPascal,
|
|
6
|
+
kebabToCamel,
|
|
7
|
+
getDbKey,
|
|
8
|
+
getUrlKey,
|
|
9
|
+
getListKeyFromUrl,
|
|
10
|
+
} from './case-utils.js'
|
|
11
|
+
|
|
12
|
+
describe('Case Conversion Utilities', () => {
|
|
13
|
+
describe('pascalToCamel', () => {
|
|
14
|
+
it('should convert single word PascalCase to camelCase', () => {
|
|
15
|
+
expect(pascalToCamel('User')).toBe('user')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('should convert multi-word PascalCase to camelCase', () => {
|
|
19
|
+
expect(pascalToCamel('AuthUser')).toBe('authUser')
|
|
20
|
+
expect(pascalToCamel('BlogPost')).toBe('blogPost')
|
|
21
|
+
expect(pascalToCamel('UserProfile')).toBe('userProfile')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should handle already camelCase strings', () => {
|
|
25
|
+
expect(pascalToCamel('user')).toBe('user')
|
|
26
|
+
expect(pascalToCamel('authUser')).toBe('authUser')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('pascalToKebab', () => {
|
|
31
|
+
it('should convert single word PascalCase to kebab-case', () => {
|
|
32
|
+
expect(pascalToKebab('User')).toBe('user')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should convert multi-word PascalCase to kebab-case', () => {
|
|
36
|
+
expect(pascalToKebab('AuthUser')).toBe('auth-user')
|
|
37
|
+
expect(pascalToKebab('BlogPost')).toBe('blog-post')
|
|
38
|
+
expect(pascalToKebab('UserProfile')).toBe('user-profile')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should handle consecutive capital letters', () => {
|
|
42
|
+
expect(pascalToKebab('HTMLElement')).toBe('h-t-m-l-element')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('kebabToPascal', () => {
|
|
47
|
+
it('should convert single word kebab-case to PascalCase', () => {
|
|
48
|
+
expect(kebabToPascal('user')).toBe('User')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should convert multi-word kebab-case to PascalCase', () => {
|
|
52
|
+
expect(kebabToPascal('auth-user')).toBe('AuthUser')
|
|
53
|
+
expect(kebabToPascal('blog-post')).toBe('BlogPost')
|
|
54
|
+
expect(kebabToPascal('user-profile')).toBe('UserProfile')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should handle already PascalCase strings without hyphens', () => {
|
|
58
|
+
expect(kebabToPascal('User')).toBe('User')
|
|
59
|
+
// Note: "AuthUser" without hyphens is treated as a single word
|
|
60
|
+
expect(kebabToPascal('AuthUser')).toBe('AuthUser')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('kebabToCamel', () => {
|
|
65
|
+
it('should convert single word kebab-case to camelCase', () => {
|
|
66
|
+
expect(kebabToCamel('user')).toBe('user')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should convert multi-word kebab-case to camelCase', () => {
|
|
70
|
+
expect(kebabToCamel('auth-user')).toBe('authUser')
|
|
71
|
+
expect(kebabToCamel('blog-post')).toBe('blogPost')
|
|
72
|
+
expect(kebabToCamel('user-profile')).toBe('userProfile')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('getDbKey', () => {
|
|
77
|
+
it('should convert list key to database key format', () => {
|
|
78
|
+
expect(getDbKey('User')).toBe('user')
|
|
79
|
+
expect(getDbKey('AuthUser')).toBe('authUser')
|
|
80
|
+
expect(getDbKey('BlogPost')).toBe('blogPost')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('getUrlKey', () => {
|
|
85
|
+
it('should convert list key to URL key format', () => {
|
|
86
|
+
expect(getUrlKey('User')).toBe('user')
|
|
87
|
+
expect(getUrlKey('AuthUser')).toBe('auth-user')
|
|
88
|
+
expect(getUrlKey('BlogPost')).toBe('blog-post')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('getListKeyFromUrl', () => {
|
|
93
|
+
it('should convert URL key to list key format', () => {
|
|
94
|
+
expect(getListKeyFromUrl('user')).toBe('User')
|
|
95
|
+
expect(getListKeyFromUrl('auth-user')).toBe('AuthUser')
|
|
96
|
+
expect(getListKeyFromUrl('blog-post')).toBe('BlogPost')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('Round-trip conversions', () => {
|
|
101
|
+
it('should maintain consistency: PascalCase -> kebab-case -> PascalCase', () => {
|
|
102
|
+
const original = 'BlogPost'
|
|
103
|
+
const kebab = getUrlKey(original)
|
|
104
|
+
const backToPascal = getListKeyFromUrl(kebab)
|
|
105
|
+
expect(backToPascal).toBe(original)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('should maintain consistency: PascalCase -> camelCase -> operations', () => {
|
|
109
|
+
const original = 'AuthUser'
|
|
110
|
+
const camel = getDbKey(original)
|
|
111
|
+
expect(camel).toBe('authUser')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('should handle multi-word conversions correctly', () => {
|
|
115
|
+
const testCases = ['User', 'AuthUser', 'BlogPost', 'UserProfile']
|
|
116
|
+
|
|
117
|
+
testCases.forEach((original) => {
|
|
118
|
+
const url = getUrlKey(original)
|
|
119
|
+
const db = getDbKey(original)
|
|
120
|
+
const fromUrl = getListKeyFromUrl(url)
|
|
121
|
+
|
|
122
|
+
expect(fromUrl).toBe(original)
|
|
123
|
+
expect(db.charAt(0)).toBe(original.charAt(0).toLowerCase())
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Case conversion utilities for consistent naming across the stack
|
|
3
|
+
*
|
|
4
|
+
* - Config list names: PascalCase (e.g., "AuthUser", "BlogPost")
|
|
5
|
+
* - Prisma models: PascalCase (e.g., "AuthUser", "BlogPost")
|
|
6
|
+
* - Prisma client properties: camelCase (e.g., "authUser", "blogPost")
|
|
7
|
+
* - Context db properties: camelCase (e.g., "authUser", "blogPost")
|
|
8
|
+
* - URLs: kebab-case (e.g., "auth-user", "blog-post")
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Convert PascalCase to camelCase
|
|
13
|
+
* AuthUser -> authUser
|
|
14
|
+
* BlogPost -> blogPost
|
|
15
|
+
*/
|
|
16
|
+
export function pascalToCamel(str: string): string {
|
|
17
|
+
return str.charAt(0).toLowerCase() + str.slice(1)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Convert PascalCase to kebab-case
|
|
22
|
+
* AuthUser -> auth-user
|
|
23
|
+
* BlogPost -> blog-post
|
|
24
|
+
*/
|
|
25
|
+
export function pascalToKebab(str: string): string {
|
|
26
|
+
return str.replace(/([A-Z])/g, (match, p1, offset) => {
|
|
27
|
+
return offset > 0 ? `-${p1.toLowerCase()}` : p1.toLowerCase()
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert kebab-case to PascalCase
|
|
33
|
+
* auth-user -> AuthUser
|
|
34
|
+
* blog-post -> BlogPost
|
|
35
|
+
*/
|
|
36
|
+
export function kebabToPascal(str: string): string {
|
|
37
|
+
return str
|
|
38
|
+
.split('-')
|
|
39
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
40
|
+
.join('')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Convert kebab-case to camelCase
|
|
45
|
+
* auth-user -> authUser
|
|
46
|
+
* blog-post -> blogPost
|
|
47
|
+
*/
|
|
48
|
+
export function kebabToCamel(str: string): string {
|
|
49
|
+
return str.replace(/-([a-z])/g, (match, p1) => p1.toUpperCase())
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get the database key for a list (camelCase)
|
|
54
|
+
* Used for accessing context.db and prisma client
|
|
55
|
+
*/
|
|
56
|
+
export function getDbKey(listKey: string): string {
|
|
57
|
+
return pascalToCamel(listKey)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get the URL segment for a list (kebab-case)
|
|
62
|
+
* Used for constructing admin URLs
|
|
63
|
+
*/
|
|
64
|
+
export function getUrlKey(listKey: string): string {
|
|
65
|
+
return pascalToKebab(listKey)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get the list key from a URL segment (PascalCase)
|
|
70
|
+
* Used for parsing admin URLs
|
|
71
|
+
*/
|
|
72
|
+
export function getListKeyFromUrl(urlSegment: string): string {
|
|
73
|
+
return kebabToPascal(urlSegment)
|
|
74
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import bcrypt from 'bcryptjs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default bcrypt cost factor (rounds)
|
|
5
|
+
* Higher values = more secure but slower
|
|
6
|
+
* 10 is a good balance for production use
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_COST_FACTOR = 10
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Hash a plain text password using bcrypt
|
|
12
|
+
*
|
|
13
|
+
* @param plainPassword - The plain text password to hash
|
|
14
|
+
* @param costFactor - The bcrypt cost factor (default: 10)
|
|
15
|
+
* @returns Promise resolving to the hashed password
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* const hashed = await hashPassword('mypassword')
|
|
20
|
+
* // Returns: $2a$10$...
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export async function hashPassword(
|
|
24
|
+
plainPassword: string,
|
|
25
|
+
costFactor: number = DEFAULT_COST_FACTOR,
|
|
26
|
+
): Promise<string> {
|
|
27
|
+
if (typeof plainPassword !== 'string' || plainPassword.length === 0) {
|
|
28
|
+
throw new Error('Password must be a non-empty string')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return bcrypt.hash(plainPassword, costFactor)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compare a plain text password with a hashed password
|
|
36
|
+
*
|
|
37
|
+
* @param plainPassword - The plain text password to compare
|
|
38
|
+
* @param hashedPassword - The hashed password to compare against
|
|
39
|
+
* @returns Promise resolving to true if passwords match, false otherwise
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```typescript
|
|
43
|
+
* const isValid = await comparePassword('mypassword', hashedPassword)
|
|
44
|
+
* if (isValid) {
|
|
45
|
+
* // Password is correct
|
|
46
|
+
* }
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export async function comparePassword(
|
|
50
|
+
plainPassword: string,
|
|
51
|
+
hashedPassword: string,
|
|
52
|
+
): Promise<boolean> {
|
|
53
|
+
if (typeof plainPassword !== 'string' || typeof hashedPassword !== 'string') {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (plainPassword.length === 0 || hashedPassword.length === 0) {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
return await bcrypt.compare(plainPassword, hashedPassword)
|
|
63
|
+
} catch (error) {
|
|
64
|
+
// Invalid hash format or other bcrypt error
|
|
65
|
+
console.error('Password comparison failed:', error)
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check if a string appears to be a bcrypt hash
|
|
72
|
+
* Bcrypt hashes follow the format: $2a$10$...
|
|
73
|
+
*
|
|
74
|
+
* @param value - The string to check
|
|
75
|
+
* @returns True if the string looks like a bcrypt hash
|
|
76
|
+
*/
|
|
77
|
+
export function isHashedPassword(value: string): boolean {
|
|
78
|
+
if (typeof value !== 'string') return false
|
|
79
|
+
|
|
80
|
+
// Bcrypt hashes start with $2a$, $2b$, or $2y$ followed by cost factor
|
|
81
|
+
// and are typically 60 characters long
|
|
82
|
+
return /^\$2[aby]\$\d{2}\$.{53}$/.test(value)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* HashedPassword class wraps a bcrypt hash and provides a compare method
|
|
87
|
+
* This allows password field values to be used as strings while also
|
|
88
|
+
* providing a convenient compare() method for authentication
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```typescript
|
|
92
|
+
* const user = await context.db.user.findUnique({ where: { id: '1' } })
|
|
93
|
+
* const isValid = await user.password.compare('plaintextPassword')
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export class HashedPassword {
|
|
97
|
+
constructor(private readonly hash: string) {
|
|
98
|
+
if (!hash || typeof hash !== 'string') {
|
|
99
|
+
throw new Error('HashedPassword requires a non-empty hash string')
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Compare a plain text password with this hashed password
|
|
105
|
+
*
|
|
106
|
+
* @param plainPassword - The plain text password to compare
|
|
107
|
+
* @returns Promise resolving to true if passwords match
|
|
108
|
+
*/
|
|
109
|
+
async compare(plainPassword: string): Promise<boolean> {
|
|
110
|
+
return comparePassword(plainPassword, this.hash)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get the underlying hash string
|
|
115
|
+
* This allows the HashedPassword to be used anywhere a string is expected
|
|
116
|
+
*/
|
|
117
|
+
toString(): string {
|
|
118
|
+
return this.hash
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get the underlying hash when used in string contexts
|
|
123
|
+
* This allows the HashedPassword to be coerced to a string automatically
|
|
124
|
+
*/
|
|
125
|
+
[Symbol.toPrimitive](hint: string): string {
|
|
126
|
+
if (hint === 'string' || hint === 'default') {
|
|
127
|
+
return this.hash
|
|
128
|
+
}
|
|
129
|
+
return this.hash
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Return the hash for JSON serialization
|
|
134
|
+
* This ensures the hash is properly serialized when converting to JSON
|
|
135
|
+
*/
|
|
136
|
+
toJSON(): string {
|
|
137
|
+
return this.hash
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get the underlying hash value
|
|
142
|
+
* This allows accessing the raw hash string
|
|
143
|
+
*/
|
|
144
|
+
valueOf(): string {
|
|
145
|
+
return this.hash
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { generateZodSchema, validateWithZod } from './schema.js'
|
|
3
|
+
import type { FieldConfig } from '../config/types.js'
|
|
4
|
+
import { text, integer, select } from '../fields/index.js'
|
|
5
|
+
|
|
6
|
+
describe('Zod Schema Generation', () => {
|
|
7
|
+
describe('generateZodSchema', () => {
|
|
8
|
+
it('should generate schema for text field with required validation', () => {
|
|
9
|
+
const fields: Record<string, FieldConfig> = {
|
|
10
|
+
name: text({ validation: { isRequired: true } }),
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const schema = generateZodSchema(fields, 'create')
|
|
14
|
+
expect(schema).toBeDefined()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('should generate schema for text field with length validation', () => {
|
|
18
|
+
const fields: Record<string, FieldConfig> = {
|
|
19
|
+
title: text({
|
|
20
|
+
validation: { isRequired: true, length: { min: 3, max: 100 } },
|
|
21
|
+
}),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const schema = generateZodSchema(fields, 'create')
|
|
25
|
+
expect(schema).toBeDefined()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('should generate schema for integer field with min/max validation', () => {
|
|
29
|
+
const fields: Record<string, FieldConfig> = {
|
|
30
|
+
age: integer({ validation: { isRequired: true, min: 0, max: 120 } }),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const schema = generateZodSchema(fields, 'create')
|
|
34
|
+
expect(schema).toBeDefined()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should generate schema for select field', () => {
|
|
38
|
+
const fields: Record<string, FieldConfig> = {
|
|
39
|
+
status: select({
|
|
40
|
+
options: [
|
|
41
|
+
{ label: 'Active', value: 'active' },
|
|
42
|
+
{ label: 'Inactive', value: 'inactive' },
|
|
43
|
+
],
|
|
44
|
+
validation: { isRequired: true },
|
|
45
|
+
}),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const schema = generateZodSchema(fields, 'create')
|
|
49
|
+
expect(schema).toBeDefined()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should make fields optional in update mode', () => {
|
|
53
|
+
const fields: Record<string, FieldConfig> = {
|
|
54
|
+
name: text({ validation: { isRequired: true } }),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const schema = generateZodSchema(fields, 'update')
|
|
58
|
+
expect(schema).toBeDefined()
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('validateWithZod', () => {
|
|
63
|
+
it('should pass validation for valid text field', () => {
|
|
64
|
+
const fields: Record<string, FieldConfig> = {
|
|
65
|
+
name: text({ validation: { isRequired: true } }),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const result = validateWithZod({ name: 'John Doe' }, fields, 'create')
|
|
69
|
+
expect(result.success).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('should fail validation for missing required field', () => {
|
|
73
|
+
const fields: Record<string, FieldConfig> = {
|
|
74
|
+
name: text({ validation: { isRequired: true } }),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const result = validateWithZod({}, fields, 'create')
|
|
78
|
+
expect(result.success).toBe(false)
|
|
79
|
+
if (!result.success) {
|
|
80
|
+
expect(result.errors).toHaveProperty('name')
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('should fail validation for text too short', () => {
|
|
85
|
+
const fields: Record<string, FieldConfig> = {
|
|
86
|
+
title: text({
|
|
87
|
+
validation: { isRequired: true, length: { min: 5 } },
|
|
88
|
+
}),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const result = validateWithZod({ title: 'Hi' }, fields, 'create')
|
|
92
|
+
expect(result.success).toBe(false)
|
|
93
|
+
if (!result.success) {
|
|
94
|
+
expect(result.errors.title).toContain('at least 5 characters')
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('should fail validation for text too long', () => {
|
|
99
|
+
const fields: Record<string, FieldConfig> = {
|
|
100
|
+
title: text({ validation: { length: { max: 10 } } }),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const result = validateWithZod({ title: 'This is a very long title' }, fields, 'create')
|
|
104
|
+
expect(result.success).toBe(false)
|
|
105
|
+
if (!result.success) {
|
|
106
|
+
expect(result.errors.title).toContain('at most 10 characters')
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should fail validation for integer below min', () => {
|
|
111
|
+
const fields: Record<string, FieldConfig> = {
|
|
112
|
+
age: integer({ validation: { min: 18 } }),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const result = validateWithZod({ age: 15 }, fields, 'create')
|
|
116
|
+
expect(result.success).toBe(false)
|
|
117
|
+
if (!result.success) {
|
|
118
|
+
expect(result.errors.age).toContain('at least 18')
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('should fail validation for integer above max', () => {
|
|
123
|
+
const fields: Record<string, FieldConfig> = {
|
|
124
|
+
age: integer({ validation: { max: 120 } }),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const result = validateWithZod({ age: 150 }, fields, 'create')
|
|
128
|
+
expect(result.success).toBe(false)
|
|
129
|
+
if (!result.success) {
|
|
130
|
+
expect(result.errors.age).toContain('at most 120')
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('should fail validation for invalid select value', () => {
|
|
135
|
+
const fields: Record<string, FieldConfig> = {
|
|
136
|
+
status: select({
|
|
137
|
+
options: [
|
|
138
|
+
{ label: 'Active', value: 'active' },
|
|
139
|
+
{ label: 'Inactive', value: 'inactive' },
|
|
140
|
+
],
|
|
141
|
+
validation: { isRequired: true },
|
|
142
|
+
}),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const result = validateWithZod({ status: 'invalid' }, fields, 'create')
|
|
146
|
+
expect(result.success).toBe(false)
|
|
147
|
+
if (!result.success) {
|
|
148
|
+
expect(result.errors.status).toBeDefined()
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('should skip system fields in validation', () => {
|
|
153
|
+
const fields: Record<string, FieldConfig> = {
|
|
154
|
+
id: text(),
|
|
155
|
+
name: text({ validation: { isRequired: true } }),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const result = validateWithZod({ name: 'John' }, fields, 'create')
|
|
159
|
+
expect(result.success).toBe(true)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('should allow required fields to be missing in update mode', () => {
|
|
163
|
+
const fields: Record<string, FieldConfig> = {
|
|
164
|
+
name: text({ validation: { isRequired: true } }),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const result = validateWithZod({}, fields, 'update')
|
|
168
|
+
expect(result.success).toBe(true)
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { FieldConfig } from '../config/types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generate Zod schema from field configurations
|
|
6
|
+
*/
|
|
7
|
+
export function generateZodSchema(
|
|
8
|
+
fieldConfigs: Record<string, FieldConfig>,
|
|
9
|
+
operation: 'create' | 'update' = 'create',
|
|
10
|
+
): z.ZodObject {
|
|
11
|
+
const shape: Record<string, z.ZodTypeAny> = {}
|
|
12
|
+
|
|
13
|
+
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
14
|
+
// Skip system fields and relationships
|
|
15
|
+
if (
|
|
16
|
+
['id', 'createdAt', 'updatedAt'].includes(fieldName) ||
|
|
17
|
+
fieldConfig.type === 'relationship'
|
|
18
|
+
) {
|
|
19
|
+
continue
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Use the field's schema generator
|
|
23
|
+
if (fieldConfig.getZodSchema) {
|
|
24
|
+
shape[fieldName] = fieldConfig.getZodSchema(fieldName, operation)
|
|
25
|
+
} else {
|
|
26
|
+
// Fallback for custom field types without schema generators
|
|
27
|
+
shape[fieldName] = z.any().optional()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return z.object(shape)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate data against field configurations using Zod
|
|
36
|
+
* Returns structured errors by field
|
|
37
|
+
*/
|
|
38
|
+
export function validateWithZod(
|
|
39
|
+
data: Record<string, unknown>,
|
|
40
|
+
fieldConfigs: Record<string, FieldConfig>,
|
|
41
|
+
operation: 'create' | 'update' = 'create',
|
|
42
|
+
): { success: true } | { success: false; errors: Record<string, string> } {
|
|
43
|
+
const schema = generateZodSchema(fieldConfigs, operation)
|
|
44
|
+
|
|
45
|
+
const result = schema.safeParse(data)
|
|
46
|
+
|
|
47
|
+
if (result.success) {
|
|
48
|
+
return { success: true }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Convert Zod errors to field-specific error messages
|
|
52
|
+
const errors: Record<string, string> = {}
|
|
53
|
+
for (const issue of result.error.issues) {
|
|
54
|
+
const fieldPath = issue.path.join('.')
|
|
55
|
+
errors[fieldPath] = issue.message
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { success: false, errors }
|
|
59
|
+
}
|