@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,438 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type {
|
|
3
|
+
TextField,
|
|
4
|
+
IntegerField,
|
|
5
|
+
CheckboxField,
|
|
6
|
+
TimestampField,
|
|
7
|
+
PasswordField,
|
|
8
|
+
SelectField,
|
|
9
|
+
RelationshipField,
|
|
10
|
+
} from '../config/types.js'
|
|
11
|
+
import { hashPassword, isHashedPassword, HashedPassword } from '../utils/password.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Format field name for display in error messages
|
|
15
|
+
*/
|
|
16
|
+
function formatFieldName(fieldName: string): string {
|
|
17
|
+
return fieldName
|
|
18
|
+
.replace(/([A-Z])/g, ' $1')
|
|
19
|
+
.replace(/^./, (str) => str.toUpperCase())
|
|
20
|
+
.trim()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Text field
|
|
25
|
+
*/
|
|
26
|
+
export function text(options?: Omit<TextField, 'type'>): TextField {
|
|
27
|
+
return {
|
|
28
|
+
type: 'text',
|
|
29
|
+
...options,
|
|
30
|
+
getZodSchema: (fieldName: string, operation: 'create' | 'update') => {
|
|
31
|
+
const validation = options?.validation
|
|
32
|
+
const isRequired = validation?.isRequired
|
|
33
|
+
const length = validation?.length
|
|
34
|
+
const minLength = length?.min && length.min > 0 ? length.min : 1
|
|
35
|
+
|
|
36
|
+
const baseSchema = z.string({
|
|
37
|
+
message: `${formatFieldName(fieldName)} must be text`,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const withMin =
|
|
41
|
+
isRequired || length?.min !== undefined
|
|
42
|
+
? baseSchema.min(minLength, {
|
|
43
|
+
message:
|
|
44
|
+
minLength > 1
|
|
45
|
+
? `${formatFieldName(fieldName)} must be at least ${minLength} characters`
|
|
46
|
+
: `${formatFieldName(fieldName)} is required`,
|
|
47
|
+
})
|
|
48
|
+
: baseSchema
|
|
49
|
+
|
|
50
|
+
const withMax =
|
|
51
|
+
length?.max !== undefined
|
|
52
|
+
? withMin.max(length.max, {
|
|
53
|
+
message: `${formatFieldName(fieldName)} must be at most ${length.max} characters`,
|
|
54
|
+
})
|
|
55
|
+
: withMin
|
|
56
|
+
|
|
57
|
+
if (isRequired && operation === 'update') {
|
|
58
|
+
return z.union([withMax, z.undefined()])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return !isRequired ? withMax.optional() : withMax
|
|
62
|
+
},
|
|
63
|
+
getPrismaType: () => {
|
|
64
|
+
const validation = options?.validation
|
|
65
|
+
const isRequired = validation?.isRequired
|
|
66
|
+
let modifiers = ''
|
|
67
|
+
|
|
68
|
+
// Optional modifier
|
|
69
|
+
if (!isRequired) {
|
|
70
|
+
modifiers += '?'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Unique/index modifiers
|
|
74
|
+
if (options?.isIndexed === 'unique') {
|
|
75
|
+
modifiers += ' @unique'
|
|
76
|
+
} else if (options?.isIndexed === true) {
|
|
77
|
+
modifiers += ' @index'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
type: 'String',
|
|
82
|
+
modifiers: modifiers || undefined,
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
getTypeScriptType: () => {
|
|
86
|
+
const validation = options?.validation
|
|
87
|
+
const isRequired = validation?.isRequired
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
type: 'string',
|
|
91
|
+
optional: !isRequired,
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Integer field
|
|
99
|
+
*/
|
|
100
|
+
export function integer(options?: Omit<IntegerField, 'type'>): IntegerField {
|
|
101
|
+
return {
|
|
102
|
+
type: 'integer',
|
|
103
|
+
...options,
|
|
104
|
+
getZodSchema: (fieldName: string, operation: 'create' | 'update') => {
|
|
105
|
+
const baseSchema = z.number({
|
|
106
|
+
message: `${formatFieldName(fieldName)} must be a number`,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const withMin =
|
|
110
|
+
options?.validation?.min !== undefined
|
|
111
|
+
? baseSchema.min(options.validation.min, {
|
|
112
|
+
message: `${formatFieldName(fieldName)} must be at least ${options.validation.min}`,
|
|
113
|
+
})
|
|
114
|
+
: baseSchema
|
|
115
|
+
|
|
116
|
+
const withMax =
|
|
117
|
+
options?.validation?.max !== undefined
|
|
118
|
+
? withMin.max(options.validation.max, {
|
|
119
|
+
message: `${formatFieldName(fieldName)} must be at most ${options.validation.max}`,
|
|
120
|
+
})
|
|
121
|
+
: withMin
|
|
122
|
+
|
|
123
|
+
return !options?.validation?.isRequired || operation === 'update'
|
|
124
|
+
? withMax.optional()
|
|
125
|
+
: withMax
|
|
126
|
+
},
|
|
127
|
+
getPrismaType: () => {
|
|
128
|
+
const isRequired = options?.validation?.isRequired
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
type: 'Int',
|
|
132
|
+
modifiers: isRequired ? undefined : '?',
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
getTypeScriptType: () => {
|
|
136
|
+
const isRequired = options?.validation?.isRequired
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
type: 'number',
|
|
140
|
+
optional: !isRequired,
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Checkbox (boolean) field
|
|
148
|
+
*/
|
|
149
|
+
export function checkbox(options?: Omit<CheckboxField, 'type'>): CheckboxField {
|
|
150
|
+
return {
|
|
151
|
+
type: 'checkbox',
|
|
152
|
+
...options,
|
|
153
|
+
getZodSchema: () => {
|
|
154
|
+
return z.boolean().optional()
|
|
155
|
+
},
|
|
156
|
+
getPrismaType: () => {
|
|
157
|
+
const hasDefault = options?.defaultValue !== undefined
|
|
158
|
+
let modifiers = ''
|
|
159
|
+
|
|
160
|
+
if (hasDefault) {
|
|
161
|
+
modifiers = ` @default(${options.defaultValue})`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
type: 'Boolean',
|
|
166
|
+
modifiers: modifiers || undefined,
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
getTypeScriptType: () => {
|
|
170
|
+
return {
|
|
171
|
+
type: 'boolean',
|
|
172
|
+
optional: options?.defaultValue === undefined,
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Timestamp (DateTime) field
|
|
180
|
+
*/
|
|
181
|
+
export function timestamp(options?: Omit<TimestampField, 'type'>): TimestampField {
|
|
182
|
+
return {
|
|
183
|
+
type: 'timestamp',
|
|
184
|
+
...options,
|
|
185
|
+
getZodSchema: () => {
|
|
186
|
+
return z.union([z.date(), z.iso.datetime()]).optional()
|
|
187
|
+
},
|
|
188
|
+
getPrismaType: () => {
|
|
189
|
+
let modifiers = '?'
|
|
190
|
+
|
|
191
|
+
// Check for default value
|
|
192
|
+
if (
|
|
193
|
+
options?.defaultValue &&
|
|
194
|
+
typeof options.defaultValue === 'object' &&
|
|
195
|
+
'kind' in options.defaultValue &&
|
|
196
|
+
options.defaultValue.kind === 'now'
|
|
197
|
+
) {
|
|
198
|
+
modifiers = ' @default(now())'
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
type: 'DateTime',
|
|
203
|
+
modifiers,
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
getTypeScriptType: () => {
|
|
207
|
+
const hasDefault =
|
|
208
|
+
options?.defaultValue &&
|
|
209
|
+
typeof options.defaultValue === 'object' &&
|
|
210
|
+
'kind' in options.defaultValue &&
|
|
211
|
+
options.defaultValue.kind === 'now'
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
type: 'Date',
|
|
215
|
+
optional: !hasDefault,
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Password field (automatically hashed using bcrypt)
|
|
223
|
+
*
|
|
224
|
+
* **Security Features:**
|
|
225
|
+
* - Passwords are automatically hashed during create/update operations
|
|
226
|
+
* - Uses bcrypt with cost factor 10 (good balance of security and performance)
|
|
227
|
+
* - Already-hashed passwords are not re-hashed (idempotent)
|
|
228
|
+
* - Password values in query results include a `compare()` method for authentication
|
|
229
|
+
*
|
|
230
|
+
* **Usage Example:**
|
|
231
|
+
* ```typescript
|
|
232
|
+
* // In opensaas.config.ts
|
|
233
|
+
* fields: {
|
|
234
|
+
* password: password({
|
|
235
|
+
* validation: { isRequired: true }
|
|
236
|
+
* })
|
|
237
|
+
* }
|
|
238
|
+
*
|
|
239
|
+
* // Creating a user - password is automatically hashed
|
|
240
|
+
* const user = await context.db.user.create({
|
|
241
|
+
* data: {
|
|
242
|
+
* email: 'user@example.com',
|
|
243
|
+
* password: 'plaintextPassword' // Automatically hashed before storage
|
|
244
|
+
* }
|
|
245
|
+
* })
|
|
246
|
+
*
|
|
247
|
+
* // Authenticating - use the compare() method
|
|
248
|
+
* const user = await context.db.user.findUnique({
|
|
249
|
+
* where: { email: 'user@example.com' }
|
|
250
|
+
* })
|
|
251
|
+
*
|
|
252
|
+
* if (user && await user.password.compare('plaintextPassword')) {
|
|
253
|
+
* // Password is correct - login successful
|
|
254
|
+
* }
|
|
255
|
+
* ```
|
|
256
|
+
*
|
|
257
|
+
* **Important Notes:**
|
|
258
|
+
* - Password fields are excluded from read operations by default in access control
|
|
259
|
+
* - Always use the `compare()` method to verify passwords - never compare strings directly
|
|
260
|
+
* - The password field value has type `HashedPassword` which extends string with compare()
|
|
261
|
+
* - Empty strings and undefined values are skipped (not hashed) to allow partial updates
|
|
262
|
+
*
|
|
263
|
+
* **Implementation Details:**
|
|
264
|
+
* - Uses field-level hooks (`resolveInput` and `resolveOutput`) for automatic transformations
|
|
265
|
+
* - The hashing happens via `hooks.resolveInput` during create/update operations
|
|
266
|
+
* - The wrapping happens via `hooks.resolveOutput` during read operations
|
|
267
|
+
* - This pattern allows third-party field types to define their own transformations
|
|
268
|
+
*
|
|
269
|
+
* @param options - Field configuration options
|
|
270
|
+
* @returns Password field configuration
|
|
271
|
+
*/
|
|
272
|
+
export function password(options?: Omit<PasswordField, 'type'>): PasswordField {
|
|
273
|
+
return {
|
|
274
|
+
type: 'password',
|
|
275
|
+
...options,
|
|
276
|
+
typePatch: {
|
|
277
|
+
resultType: "import('@opensaas/stack-core').HashedPassword",
|
|
278
|
+
patchScope: 'scalars-only',
|
|
279
|
+
},
|
|
280
|
+
ui: {
|
|
281
|
+
...options?.ui,
|
|
282
|
+
valueForClientSerialization: ({ value }) => ({ isSet: !!value }),
|
|
283
|
+
},
|
|
284
|
+
hooks: {
|
|
285
|
+
// Hash password before writing to database
|
|
286
|
+
resolveInput: async ({ inputValue }) => {
|
|
287
|
+
// Skip if undefined or null (allows partial updates)
|
|
288
|
+
if (inputValue === undefined || inputValue === null) {
|
|
289
|
+
return inputValue
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Skip if not a string
|
|
293
|
+
if (typeof inputValue !== 'string') {
|
|
294
|
+
return inputValue
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Skip empty strings (let validation handle this)
|
|
298
|
+
if (inputValue.length === 0) {
|
|
299
|
+
return inputValue
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Skip if already hashed (idempotent)
|
|
303
|
+
if (isHashedPassword(inputValue)) {
|
|
304
|
+
return inputValue
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Hash the password
|
|
308
|
+
return await hashPassword(inputValue)
|
|
309
|
+
},
|
|
310
|
+
// Wrap password with HashedPassword class after reading from database
|
|
311
|
+
resolveOutput: ({ value }) => {
|
|
312
|
+
// Only wrap string values (hashed passwords)
|
|
313
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
314
|
+
return new HashedPassword(value)
|
|
315
|
+
}
|
|
316
|
+
return undefined
|
|
317
|
+
},
|
|
318
|
+
// Merge with user-provided hooks if any
|
|
319
|
+
...options?.hooks,
|
|
320
|
+
},
|
|
321
|
+
getZodSchema: (fieldName: string, operation: 'create' | 'update') => {
|
|
322
|
+
const validation = options?.validation
|
|
323
|
+
const isRequired = validation?.isRequired
|
|
324
|
+
|
|
325
|
+
if (isRequired && operation === 'create') {
|
|
326
|
+
// Required in create mode: reject undefined and empty strings
|
|
327
|
+
return z
|
|
328
|
+
.string({
|
|
329
|
+
message: `${formatFieldName(fieldName)} must be text`,
|
|
330
|
+
})
|
|
331
|
+
.min(1, {
|
|
332
|
+
message: `${formatFieldName(fieldName)} is required`,
|
|
333
|
+
})
|
|
334
|
+
} else if (isRequired && operation === 'update') {
|
|
335
|
+
// Required in update mode: if provided, reject empty strings
|
|
336
|
+
return z.union([
|
|
337
|
+
z.string().min(1, {
|
|
338
|
+
message: `${formatFieldName(fieldName)} is required`,
|
|
339
|
+
}),
|
|
340
|
+
z.undefined(),
|
|
341
|
+
])
|
|
342
|
+
} else {
|
|
343
|
+
// Not required: can be undefined or any string
|
|
344
|
+
return z
|
|
345
|
+
.string({
|
|
346
|
+
message: `${formatFieldName(fieldName)} must be text`,
|
|
347
|
+
})
|
|
348
|
+
.optional()
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
getPrismaType: () => {
|
|
352
|
+
const isRequired = options?.validation?.isRequired
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
type: 'String',
|
|
356
|
+
modifiers: isRequired ? undefined : '?',
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
getTypeScriptType: () => {
|
|
360
|
+
const isRequired = options?.validation?.isRequired
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
type: 'string',
|
|
364
|
+
optional: !isRequired,
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Select field (enum-like)
|
|
372
|
+
*/
|
|
373
|
+
export function select(options: Omit<SelectField, 'type'>): SelectField {
|
|
374
|
+
if (!options.options || options.options.length === 0) {
|
|
375
|
+
throw new Error('Select field must have at least one option')
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
type: 'select',
|
|
380
|
+
...options,
|
|
381
|
+
getZodSchema: (fieldName: string, operation: 'create' | 'update') => {
|
|
382
|
+
const values = options.options.map((opt) => opt.value)
|
|
383
|
+
let schema: z.ZodTypeAny = z.enum(values as [string, ...string[]], {
|
|
384
|
+
message: `${formatFieldName(fieldName)} must be one of: ${values.join(', ')}`,
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
if (!options.validation?.isRequired || operation === 'update') {
|
|
388
|
+
schema = schema.optional()
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return schema
|
|
392
|
+
},
|
|
393
|
+
getPrismaType: () => {
|
|
394
|
+
let modifiers = '?'
|
|
395
|
+
|
|
396
|
+
// Add default value if provided
|
|
397
|
+
if (options.defaultValue !== undefined) {
|
|
398
|
+
modifiers = ` @default("${options.defaultValue}")`
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
type: 'String',
|
|
403
|
+
modifiers,
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
getTypeScriptType: () => {
|
|
407
|
+
// Generate union type from options
|
|
408
|
+
const unionType = options.options.map((opt) => `'${opt.value}'`).join(' | ')
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
type: unionType,
|
|
412
|
+
optional: !options.validation?.isRequired || options.defaultValue !== undefined,
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Relationship field
|
|
420
|
+
*/
|
|
421
|
+
export function relationship(options: Omit<RelationshipField, 'type'>): RelationshipField {
|
|
422
|
+
if (!options.ref) {
|
|
423
|
+
throw new Error('Relationship field must have a ref')
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Validate ref format: 'ListName.fieldName'
|
|
427
|
+
const refParts = options.ref.split('.')
|
|
428
|
+
if (refParts.length !== 2) {
|
|
429
|
+
throw new Error(
|
|
430
|
+
`Invalid relationship ref format: "${options.ref}". Expected format: "ListName.fieldName"`,
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
type: 'relationship',
|
|
436
|
+
...options,
|
|
437
|
+
}
|
|
438
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { Hooks } from '../config/types.js'
|
|
2
|
+
import type { AccessContext } from '../access/types.js'
|
|
3
|
+
import type { FieldConfig } from '../config/types.js'
|
|
4
|
+
import { validateWithZod } from '../validation/schema.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validation error collection
|
|
8
|
+
*/
|
|
9
|
+
export class ValidationError extends Error {
|
|
10
|
+
public errors: string[]
|
|
11
|
+
public fieldErrors: Record<string, string>
|
|
12
|
+
|
|
13
|
+
constructor(errors: string[], fieldErrors: Record<string, string> = {}) {
|
|
14
|
+
super(`Validation failed: ${errors.join(', ')}`)
|
|
15
|
+
this.name = 'ValidationError'
|
|
16
|
+
this.errors = errors
|
|
17
|
+
this.fieldErrors = fieldErrors
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Execute resolveInput hook
|
|
23
|
+
* Allows modification of input data before validation
|
|
24
|
+
*/
|
|
25
|
+
export async function executeResolveInput<T = Record<string, unknown>>(
|
|
26
|
+
hooks: Hooks<T> | undefined,
|
|
27
|
+
args: {
|
|
28
|
+
operation: 'create' | 'update'
|
|
29
|
+
resolvedData: Partial<T>
|
|
30
|
+
item?: T
|
|
31
|
+
context: AccessContext
|
|
32
|
+
},
|
|
33
|
+
): Promise<Partial<T>> {
|
|
34
|
+
if (!hooks?.resolveInput) {
|
|
35
|
+
return args.resolvedData
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const result = await hooks.resolveInput(args)
|
|
39
|
+
return result
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Execute validateInput hook
|
|
44
|
+
* Allows custom validation logic
|
|
45
|
+
*/
|
|
46
|
+
export async function executeValidateInput<T = Record<string, unknown>>(
|
|
47
|
+
hooks: Hooks<T> | undefined,
|
|
48
|
+
args: {
|
|
49
|
+
operation: 'create' | 'update'
|
|
50
|
+
resolvedData: Partial<T>
|
|
51
|
+
item?: T
|
|
52
|
+
context: AccessContext
|
|
53
|
+
},
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
if (!hooks?.validateInput) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const errors: string[] = []
|
|
60
|
+
|
|
61
|
+
const addValidationError = (msg: string) => {
|
|
62
|
+
errors.push(msg)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await hooks.validateInput({
|
|
66
|
+
...args,
|
|
67
|
+
addValidationError,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
if (errors.length > 0) {
|
|
71
|
+
throw new ValidationError(errors)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Execute beforeOperation hook
|
|
77
|
+
* Runs before database operation (cannot modify data)
|
|
78
|
+
*/
|
|
79
|
+
export async function executeBeforeOperation<T = Record<string, unknown>>(
|
|
80
|
+
hooks: Hooks<T> | undefined,
|
|
81
|
+
args: {
|
|
82
|
+
operation: 'create' | 'update' | 'delete'
|
|
83
|
+
item?: T
|
|
84
|
+
context: AccessContext
|
|
85
|
+
},
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
if (!hooks?.beforeOperation) {
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await hooks.beforeOperation(args)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Execute afterOperation hook
|
|
96
|
+
* Runs after database operation
|
|
97
|
+
*/
|
|
98
|
+
export async function executeAfterOperation<T = Record<string, unknown>>(
|
|
99
|
+
hooks: Hooks<T> | undefined,
|
|
100
|
+
args: {
|
|
101
|
+
operation: 'create' | 'update' | 'delete'
|
|
102
|
+
item: T
|
|
103
|
+
context: AccessContext
|
|
104
|
+
},
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
if (!hooks?.afterOperation) {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await hooks.afterOperation(args)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Validate field-level validation rules using Zod
|
|
115
|
+
* Checks isRequired, length constraints, etc.
|
|
116
|
+
*/
|
|
117
|
+
export function validateFieldRules(
|
|
118
|
+
data: Record<string, unknown>,
|
|
119
|
+
fieldConfigs: Record<string, FieldConfig>,
|
|
120
|
+
operation: 'create' | 'update' = 'create',
|
|
121
|
+
): { errors: string[]; fieldErrors: Record<string, string> } {
|
|
122
|
+
const result = validateWithZod(data, fieldConfigs, operation)
|
|
123
|
+
|
|
124
|
+
if (result.success) {
|
|
125
|
+
return { errors: [], fieldErrors: {} }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Convert field errors to array of error messages
|
|
129
|
+
const errors = Object.entries(result.errors).map(([_field, message]) => message)
|
|
130
|
+
|
|
131
|
+
return { errors, fieldErrors: result.errors }
|
|
132
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Config system
|
|
2
|
+
export { config, list } from './config/index.js'
|
|
3
|
+
export type {
|
|
4
|
+
OpenSaasConfig,
|
|
5
|
+
ListConfig,
|
|
6
|
+
FieldConfig,
|
|
7
|
+
BaseFieldConfig,
|
|
8
|
+
TextField,
|
|
9
|
+
IntegerField,
|
|
10
|
+
CheckboxField,
|
|
11
|
+
TimestampField,
|
|
12
|
+
PasswordField,
|
|
13
|
+
SelectField,
|
|
14
|
+
RelationshipField,
|
|
15
|
+
OperationAccess,
|
|
16
|
+
Hooks,
|
|
17
|
+
FieldHooks,
|
|
18
|
+
DatabaseConfig,
|
|
19
|
+
SessionConfig,
|
|
20
|
+
UIConfig,
|
|
21
|
+
ThemeConfig,
|
|
22
|
+
ThemePreset,
|
|
23
|
+
ThemeColors,
|
|
24
|
+
} from './config/index.js'
|
|
25
|
+
|
|
26
|
+
// Access control
|
|
27
|
+
export type {
|
|
28
|
+
AccessControl,
|
|
29
|
+
FieldAccess,
|
|
30
|
+
Session,
|
|
31
|
+
AccessContext,
|
|
32
|
+
PrismaFilter,
|
|
33
|
+
AccessControlledDB,
|
|
34
|
+
} from './access/index.js'
|
|
35
|
+
|
|
36
|
+
// Context
|
|
37
|
+
export { getContext } from './context/index.js'
|
|
38
|
+
export type { PrismaClientLike } from './access/types.js'
|
|
39
|
+
export type { ServerActionProps } from './context/index.js'
|
|
40
|
+
|
|
41
|
+
// Utilities
|
|
42
|
+
export {
|
|
43
|
+
getDbKey,
|
|
44
|
+
getUrlKey,
|
|
45
|
+
getListKeyFromUrl,
|
|
46
|
+
pascalToCamel,
|
|
47
|
+
pascalToKebab,
|
|
48
|
+
kebabToPascal,
|
|
49
|
+
kebabToCamel,
|
|
50
|
+
} from './lib/case-utils.js'
|
|
51
|
+
|
|
52
|
+
// Hooks and validation
|
|
53
|
+
export { ValidationError } from './hooks/index.js'
|
|
54
|
+
export { validateWithZod, generateZodSchema } from './validation/schema.js'
|
|
55
|
+
|
|
56
|
+
// Password utilities
|
|
57
|
+
export {
|
|
58
|
+
hashPassword,
|
|
59
|
+
comparePassword,
|
|
60
|
+
isHashedPassword,
|
|
61
|
+
HashedPassword,
|
|
62
|
+
} from './utils/password.js'
|