@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.
Files changed (95) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/README.md +447 -0
  3. package/dist/access/engine.d.ts +73 -0
  4. package/dist/access/engine.d.ts.map +1 -0
  5. package/dist/access/engine.js +244 -0
  6. package/dist/access/engine.js.map +1 -0
  7. package/dist/access/field-transforms.d.ts +47 -0
  8. package/dist/access/field-transforms.d.ts.map +1 -0
  9. package/dist/access/field-transforms.js +2 -0
  10. package/dist/access/field-transforms.js.map +1 -0
  11. package/dist/access/index.d.ts +3 -0
  12. package/dist/access/index.d.ts.map +1 -0
  13. package/dist/access/index.js +2 -0
  14. package/dist/access/index.js.map +1 -0
  15. package/dist/access/types.d.ts +83 -0
  16. package/dist/access/types.d.ts.map +1 -0
  17. package/dist/access/types.js +2 -0
  18. package/dist/access/types.js.map +1 -0
  19. package/dist/config/index.d.ts +39 -0
  20. package/dist/config/index.d.ts.map +1 -0
  21. package/dist/config/index.js +38 -0
  22. package/dist/config/index.js.map +1 -0
  23. package/dist/config/types.d.ts +413 -0
  24. package/dist/config/types.d.ts.map +1 -0
  25. package/dist/config/types.js +2 -0
  26. package/dist/config/types.js.map +1 -0
  27. package/dist/context/index.d.ts +31 -0
  28. package/dist/context/index.d.ts.map +1 -0
  29. package/dist/context/index.js +524 -0
  30. package/dist/context/index.js.map +1 -0
  31. package/dist/context/nested-operations.d.ts +10 -0
  32. package/dist/context/nested-operations.d.ts.map +1 -0
  33. package/dist/context/nested-operations.js +261 -0
  34. package/dist/context/nested-operations.js.map +1 -0
  35. package/dist/fields/index.d.ts +78 -0
  36. package/dist/fields/index.d.ts.map +1 -0
  37. package/dist/fields/index.js +381 -0
  38. package/dist/fields/index.js.map +1 -0
  39. package/dist/hooks/index.d.ts +58 -0
  40. package/dist/hooks/index.d.ts.map +1 -0
  41. package/dist/hooks/index.js +79 -0
  42. package/dist/hooks/index.js.map +1 -0
  43. package/dist/index.d.ts +11 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +12 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/lib/case-utils.d.ts +49 -0
  48. package/dist/lib/case-utils.d.ts.map +1 -0
  49. package/dist/lib/case-utils.js +68 -0
  50. package/dist/lib/case-utils.js.map +1 -0
  51. package/dist/lib/case-utils.test.d.ts +2 -0
  52. package/dist/lib/case-utils.test.d.ts.map +1 -0
  53. package/dist/lib/case-utils.test.js +101 -0
  54. package/dist/lib/case-utils.test.js.map +1 -0
  55. package/dist/utils/password.d.ts +81 -0
  56. package/dist/utils/password.d.ts.map +1 -0
  57. package/dist/utils/password.js +132 -0
  58. package/dist/utils/password.js.map +1 -0
  59. package/dist/validation/schema.d.ts +17 -0
  60. package/dist/validation/schema.d.ts.map +1 -0
  61. package/dist/validation/schema.js +42 -0
  62. package/dist/validation/schema.js.map +1 -0
  63. package/dist/validation/schema.test.d.ts +2 -0
  64. package/dist/validation/schema.test.d.ts.map +1 -0
  65. package/dist/validation/schema.test.js +143 -0
  66. package/dist/validation/schema.test.js.map +1 -0
  67. package/docs/type-distribution-fix.md +136 -0
  68. package/package.json +48 -0
  69. package/src/access/engine.ts +360 -0
  70. package/src/access/field-transforms.ts +99 -0
  71. package/src/access/index.ts +20 -0
  72. package/src/access/types.ts +103 -0
  73. package/src/config/index.ts +71 -0
  74. package/src/config/types.ts +478 -0
  75. package/src/context/index.ts +814 -0
  76. package/src/context/nested-operations.ts +412 -0
  77. package/src/fields/index.ts +438 -0
  78. package/src/hooks/index.ts +132 -0
  79. package/src/index.ts +62 -0
  80. package/src/lib/case-utils.test.ts +127 -0
  81. package/src/lib/case-utils.ts +74 -0
  82. package/src/utils/password.ts +147 -0
  83. package/src/validation/schema.test.ts +171 -0
  84. package/src/validation/schema.ts +59 -0
  85. package/tests/access-relationships.test.ts +613 -0
  86. package/tests/access.test.ts +499 -0
  87. package/tests/config.test.ts +195 -0
  88. package/tests/context.test.ts +248 -0
  89. package/tests/hooks.test.ts +417 -0
  90. package/tests/password-type-distribution.test.ts +155 -0
  91. package/tests/password-types.test.ts +147 -0
  92. package/tests/password.test.ts +249 -0
  93. package/tsconfig.json +12 -0
  94. package/tsconfig.tsbuildinfo +1 -0
  95. package/vitest.config.ts +27 -0
@@ -0,0 +1,136 @@
1
+ # Password Field Type Distribution Fix
2
+
3
+ ## Problem
4
+
5
+ All fields in returned objects were being typed as `string | HashedPassword` instead of only the password field being typed as `HashedPassword`.
6
+
7
+ ### Example of the Bug
8
+
9
+ ```typescript
10
+ const users = await context.db.user.findMany()
11
+ // Before fix:
12
+ // users: { name: string | HashedPassword, email: string | HashedPassword, password: string | HashedPassword, ... }
13
+ //
14
+ // Expected:
15
+ // users: { name: string, email: string, password: HashedPassword, ... }
16
+ ```
17
+
18
+ ## Root Cause
19
+
20
+ The issue was in how TypeScript's **distributive conditional types** work with union types.
21
+
22
+ ### The Problem Code
23
+
24
+ In `packages/core/src/access/types.ts`, the `TransformObject` type was delegating to a `TransformField` helper:
25
+
26
+ ```typescript
27
+ type TransformObject<TConfig, TListKey, TObj> = {
28
+ [K in keyof TObj]: K extends keyof TConfig['lists'][TListKey]['fields']
29
+ ? TransformField<TConfig['lists'][TListKey]['fields'][K], TObj[K]>
30
+ : // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
31
+ // This is a UNION of all field config types!
32
+ TObj[K]
33
+ }
34
+ ```
35
+
36
+ When TypeScript evaluated `TConfig['lists'][TListKey]['fields'][K]`, it couldn't narrow the type precisely, resulting in:
37
+
38
+ ```typescript
39
+ TextField | IntegerField | CheckboxField | TimestampField | PasswordField | ...
40
+ ```
41
+
42
+ This union type then **distributed** over the conditional type in `TransformField`:
43
+
44
+ ```typescript
45
+ type TransformField<TFieldConfig, TOriginal> = TFieldConfig extends { type: infer TType }
46
+ ? 'password' extends TType // Distributes over the union!
47
+ ? HashedPassword
48
+ : TOriginal
49
+ : TOriginal
50
+
51
+ // Becomes:
52
+ // TransformField<TextField, string> | TransformField<PasswordField, string> | ...
53
+ // = TOriginal | HashedPassword | ...
54
+ // = string | HashedPassword
55
+ ```
56
+
57
+ ## Solution
58
+
59
+ **Remove the `TransformField` helper and inline the logic directly in `TransformObject`.**
60
+
61
+ This allows TypeScript to narrow the field config type **before** applying the conditional type, preventing distribution.
62
+
63
+ ### The Fix
64
+
65
+ ```typescript
66
+ type TransformObject<TConfig, TListKey, TObj> =
67
+ TObj extends Record<string, any>
68
+ ? {
69
+ [K in keyof TObj]: K extends keyof TConfig['lists'][TListKey]['fields']
70
+ ? TConfig['lists'][TListKey]['fields'][K] extends { type: 'relationship', ref: infer TRef }
71
+ ? /* relationship logic */
72
+ : // Inline password check - NO helper function
73
+ TConfig['lists'][TListKey]['fields'][K] extends { type: 'password' }
74
+ ? TObj[K] extends string
75
+ ? HashedPassword
76
+ : TObj[K]
77
+ : TObj[K] // Not password, preserve original type
78
+ : TransformIncludedRelationship<TConfig, K, TObj[K]>
79
+ }
80
+ : TObj
81
+ ```
82
+
83
+ ### Why This Works
84
+
85
+ By inlining the logic, TypeScript can:
86
+
87
+ 1. **First narrow** `TConfig['lists'][TListKey]['fields'][K]` to the specific field type (e.g., `TextField`)
88
+ 2. **Then check** if it extends `{ type: 'password' }`
89
+ 3. **Return** the appropriate type without creating a union
90
+
91
+ The check `TConfig['lists'][TListKey]['fields'][K] extends { type: 'password' }` evaluates to:
92
+
93
+ - `TextField extends { type: 'password' }` → `false` → return `TObj[K]` (preserve original)
94
+ - `PasswordField extends { type: 'password' }` → `true` → return `HashedPassword`
95
+
96
+ No distribution occurs because we're not using a separate type alias that receives the union.
97
+
98
+ ## Verification
99
+
100
+ ### Tests
101
+
102
+ Created `tests/password-type-distribution.test.ts` with 3 tests:
103
+
104
+ 1. Verifies non-password fields remain `string`
105
+ 2. Verifies password field becomes `HashedPassword`
106
+ 3. Verifies TypeScript narrowing works correctly
107
+
108
+ All 191 tests pass ✅
109
+
110
+ ### Type Checks
111
+
112
+ ```typescript
113
+ const users = await context.db.user.findMany()
114
+ const user = users[0]
115
+
116
+ // These now compile correctly:
117
+ const name: string = user.name // ✅ string
118
+ const email: string = user.email // ✅ string
119
+ const password: HashedPassword = user.password // ✅ HashedPassword
120
+ await password.compare('test') // ✅ Has compare method
121
+ ```
122
+
123
+ ## Files Changed
124
+
125
+ - `packages/core/src/access/types.ts`:
126
+ - Modified `TransformObject` type (lines 125-160) to inline password transformation logic
127
+ - Removed `TransformField` helper type (was lines 175-189)
128
+ - `packages/core/tests/password-type-distribution.test.ts`:
129
+ - New test file with 3 tests verifying the fix
130
+
131
+ ## Lessons Learned
132
+
133
+ 1. **Distributive conditional types** in TypeScript can cause unexpected union types
134
+ 2. **Helper type aliases** can prevent proper type narrowing
135
+ 3. **Inlining type logic** allows TypeScript to narrow types before applying conditionals
136
+ 4. **Test both runtime and compile-time behavior** when working with TypeScript transformations
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@opensaas/stack-core",
3
+ "version": "0.1.0",
4
+ "description": "Core stack for OpenSaas - schema definition, access control, and runtime utilities",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./fields": {
14
+ "types": "./dist/fields/index.d.ts",
15
+ "default": "./dist/fields/index.js"
16
+ }
17
+ },
18
+ "keywords": [
19
+ "opensaas",
20
+ "nextjs",
21
+ "prisma",
22
+ "admin"
23
+ ],
24
+ "author": "",
25
+ "license": "MIT",
26
+ "peerDependencies": {
27
+ "@prisma/client": "^6.17.0"
28
+ },
29
+ "dependencies": {
30
+ "bcryptjs": "^3.0.2",
31
+ "zod": "^4.1.12"
32
+ },
33
+ "devDependencies": {
34
+ "@prisma/client": "^6.17.1",
35
+ "@types/bcryptjs": "^3.0.0",
36
+ "@types/node": "^24.7.2",
37
+ "typescript": "^5.9.3",
38
+ "vitest": "^4.0.0"
39
+ },
40
+ "scripts": {
41
+ "build": "tsc",
42
+ "dev": "tsc --watch",
43
+ "test": "vitest",
44
+ "test:ui": "vitest --ui",
45
+ "test:coverage": "vitest --coverage",
46
+ "clean": "rm -rf .turbo dist tsconfig.tsbuildinfo"
47
+ }
48
+ }
@@ -0,0 +1,360 @@
1
+ import type { AccessControl, Session, AccessContext, PrismaFilter } from './types.js'
2
+ import type { FieldAccess } from './types.js'
3
+ import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
4
+
5
+ /**
6
+ * Check if access control result is a boolean
7
+ */
8
+ export function isBoolean(value: unknown): value is boolean {
9
+ return typeof value === 'boolean'
10
+ }
11
+
12
+ /**
13
+ * Check if access control result is a Prisma filter
14
+ */
15
+ export function isPrismaFilter(value: unknown): value is PrismaFilter {
16
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
17
+ }
18
+
19
+ /**
20
+ * Parse a relationship ref and get the related list configuration
21
+ * Relationship refs are in the format "ListName.fieldName"
22
+ *
23
+ * @param relationshipRef - The ref string (e.g., "Post.author")
24
+ * @param config - The OpenSaas configuration
25
+ * @returns The related list name and config, or null if not found
26
+ */
27
+ export function getRelatedListConfig(
28
+ relationshipRef: string,
29
+ config: OpenSaasConfig,
30
+ ): { listName: string; listConfig: ListConfig } | null {
31
+ // Parse ref format: "ListName.fieldName"
32
+ const parts = relationshipRef.split('.')
33
+ if (parts.length !== 2) {
34
+ return null
35
+ }
36
+
37
+ const listName = parts[0]
38
+ const listConfig = config.lists[listName]
39
+
40
+ if (!listConfig) {
41
+ return null
42
+ }
43
+
44
+ return { listName, listConfig }
45
+ }
46
+
47
+ /**
48
+ * Execute an access control function
49
+ */
50
+ export async function checkAccess<T = Record<string, unknown>>(
51
+ accessControl: AccessControl<T> | undefined,
52
+ args: {
53
+ session: Session
54
+ item?: T
55
+ context: AccessContext
56
+ },
57
+ ): Promise<boolean | PrismaFilter<T>> {
58
+ // No access control means deny by default
59
+ if (!accessControl) {
60
+ return false
61
+ }
62
+
63
+ // Execute the access control function
64
+ const result = await accessControl(args)
65
+
66
+ return result
67
+ }
68
+
69
+ /**
70
+ * Merge user filter with access control filter
71
+ */
72
+ export function mergeFilters(
73
+ userFilter: PrismaFilter | undefined,
74
+ accessFilter: boolean | PrismaFilter,
75
+ ): PrismaFilter | null {
76
+ // If access is denied, return null
77
+ if (accessFilter === false) {
78
+ return null
79
+ }
80
+
81
+ // If access is fully granted, use user filter
82
+ if (accessFilter === true) {
83
+ return userFilter || {}
84
+ }
85
+
86
+ // Merge access filter with user filter
87
+ if (!userFilter) {
88
+ return accessFilter
89
+ }
90
+
91
+ // Combine filters with AND
92
+ return {
93
+ AND: [accessFilter, userFilter],
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Check field-level access for a specific operation
99
+ */
100
+ export async function checkFieldAccess(
101
+ fieldAccess: FieldAccess | undefined,
102
+ operation: 'read' | 'create' | 'update',
103
+ args: {
104
+ session: Session
105
+ item?: Record<string, unknown>
106
+ context: AccessContext
107
+ },
108
+ ): Promise<boolean> {
109
+ if (!fieldAccess) {
110
+ return true // No field access means allow
111
+ }
112
+
113
+ const accessControl = fieldAccess[operation]
114
+ if (!accessControl) {
115
+ return true // No specific access control means allow
116
+ }
117
+
118
+ const result = await accessControl(args)
119
+
120
+ // If result is false, deny access
121
+ if (result === false) {
122
+ return false
123
+ }
124
+
125
+ // If result is true, allow access
126
+ if (result === true) {
127
+ return true
128
+ }
129
+
130
+ // If result is a filter object, check if the item matches
131
+ // For field-level access, we need to evaluate the filter against the item
132
+ if (typeof result === 'object' && args.item) {
133
+ return matchesFilter(args.item, result)
134
+ }
135
+
136
+ // Default to allowing access if we can't determine
137
+ return true
138
+ }
139
+
140
+ /**
141
+ * Simple filter matching for field-level access
142
+ * Checks if an item matches a Prisma-like filter object
143
+ */
144
+ function matchesFilter(item: Record<string, unknown>, filter: Record<string, unknown>): boolean {
145
+ for (const [key, condition] of Object.entries(filter)) {
146
+ if (typeof condition === 'object' && condition !== null) {
147
+ // Handle nested conditions like { equals: value }
148
+ if ('equals' in condition) {
149
+ if (item[key] !== condition.equals) {
150
+ return false
151
+ }
152
+ } else if ('not' in condition) {
153
+ if (item[key] === condition.not) {
154
+ return false
155
+ }
156
+ }
157
+ // Add more condition types as needed
158
+ } else {
159
+ // Direct equality check
160
+ if (item[key] !== condition) {
161
+ return false
162
+ }
163
+ }
164
+ }
165
+ return true
166
+ }
167
+
168
+ /**
169
+ * Build Prisma include object with access control filters
170
+ * This allows us to filter relationships at the database level instead of in memory
171
+ */
172
+ export async function buildIncludeWithAccessControl(
173
+ fieldConfigs: Record<string, FieldConfig>,
174
+ args: {
175
+ session: Session
176
+ context: AccessContext
177
+ },
178
+ config: OpenSaasConfig,
179
+ depth: number = 0,
180
+ ) {
181
+ const MAX_DEPTH = 5
182
+ if (depth >= MAX_DEPTH) {
183
+ return undefined
184
+ }
185
+
186
+ type IncludeEntry = boolean | { where?: PrismaFilter; include?: Record<string, IncludeEntry> }
187
+
188
+ const include: Record<string, IncludeEntry> = {}
189
+ let hasRelationships = false
190
+
191
+ for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
192
+ if (fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && fieldConfig.ref) {
193
+ hasRelationships = true
194
+ const relatedConfig = getRelatedListConfig(fieldConfig.ref, config)
195
+
196
+ if (relatedConfig) {
197
+ // Check query access for the related list
198
+ const queryAccess = relatedConfig.listConfig.access?.operation?.query
199
+ const accessResult = await checkAccess(queryAccess, {
200
+ session: args.session,
201
+ context: args.context,
202
+ })
203
+
204
+ // If access is completely denied, exclude this relationship
205
+ if (accessResult === false) {
206
+ continue
207
+ }
208
+
209
+ // Build the include entry
210
+ const includeEntry: Record<string, unknown> = {}
211
+
212
+ // If access returns a filter, add it to the where clause
213
+ if (typeof accessResult === 'object') {
214
+ includeEntry.where = accessResult
215
+ }
216
+
217
+ // Recursively build nested includes
218
+ const nestedInclude = await buildIncludeWithAccessControl(
219
+ relatedConfig.listConfig.fields,
220
+ args,
221
+ config,
222
+ depth + 1,
223
+ )
224
+
225
+ if (nestedInclude && Object.keys(nestedInclude).length > 0) {
226
+ includeEntry.include = nestedInclude
227
+ }
228
+
229
+ // Add to include object
230
+ include[fieldName] = Object.keys(includeEntry).length > 0 ? includeEntry : true
231
+ }
232
+ }
233
+ }
234
+
235
+ return hasRelationships ? include : undefined
236
+ }
237
+
238
+ /**
239
+ * Filter fields from an object based on read access
240
+ * Recursively applies access control to nested relationships
241
+ */
242
+ export async function filterReadableFields<T extends Record<string, unknown>>(
243
+ item: T,
244
+ fieldConfigs: Record<string, FieldConfig>,
245
+ args: {
246
+ session: Session
247
+ context: AccessContext
248
+ },
249
+ config?: OpenSaasConfig,
250
+ depth: number = 0,
251
+ ): Promise<Partial<T>> {
252
+ const filtered: Record<string, unknown> = {}
253
+ const MAX_DEPTH = 5 // Prevent infinite recursion
254
+
255
+ for (const [fieldName, value] of Object.entries(item)) {
256
+ const fieldConfig = fieldConfigs[fieldName]
257
+
258
+ // Always include id, createdAt, updatedAt
259
+ if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
260
+ filtered[fieldName] = value
261
+ continue
262
+ }
263
+
264
+ // Check field access
265
+ const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
266
+ ...args,
267
+ item,
268
+ })
269
+
270
+ if (!canRead) {
271
+ continue
272
+ }
273
+
274
+ // Handle relationship fields - recursively filter fields within related items
275
+ // Note: Access control filtering is now done at database level via buildIncludeWithAccessControl
276
+ // This only handles field-level access (hiding sensitive fields)
277
+ if (
278
+ config &&
279
+ fieldConfig?.type === 'relationship' &&
280
+ 'ref' in fieldConfig &&
281
+ fieldConfig.ref &&
282
+ value !== null &&
283
+ value !== undefined &&
284
+ depth < MAX_DEPTH
285
+ ) {
286
+ const relatedConfig = getRelatedListConfig(fieldConfig.ref, config)
287
+
288
+ if (relatedConfig) {
289
+ // For many relationships (arrays) - recursively filter fields in each item
290
+ if (Array.isArray(value)) {
291
+ filtered[fieldName] = await Promise.all(
292
+ value.map((relatedItem) =>
293
+ filterReadableFields(
294
+ relatedItem,
295
+ relatedConfig.listConfig.fields,
296
+ args,
297
+ config,
298
+ depth + 1,
299
+ ),
300
+ ),
301
+ )
302
+ }
303
+ // For single relationships (objects) - recursively filter fields
304
+ else if (typeof value === 'object') {
305
+ filtered[fieldName] = await filterReadableFields(
306
+ value as Record<string, unknown>,
307
+ relatedConfig.listConfig.fields,
308
+ args,
309
+ config,
310
+ depth + 1,
311
+ )
312
+ }
313
+ } else {
314
+ // Related config not found, include the value as-is
315
+ filtered[fieldName] = value
316
+ }
317
+ } else {
318
+ // Non-relationship field or no config provided
319
+ filtered[fieldName] = value
320
+ }
321
+ }
322
+
323
+ return filtered as Partial<T>
324
+ }
325
+
326
+ /**
327
+ * Filter fields from input data based on write access (create/update)
328
+ */
329
+ export async function filterWritableFields<T extends Record<string, unknown>>(
330
+ data: T,
331
+ fieldConfigs: Record<string, { access?: FieldAccess }>,
332
+ operation: 'create' | 'update',
333
+ args: {
334
+ session: Session
335
+ item?: Record<string, unknown>
336
+ context: AccessContext
337
+ },
338
+ ): Promise<Partial<T>> {
339
+ const filtered: Record<string, unknown> = {}
340
+
341
+ for (const [fieldName, value] of Object.entries(data)) {
342
+ const fieldConfig = fieldConfigs[fieldName]
343
+
344
+ // Skip system fields
345
+ if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
346
+ continue
347
+ }
348
+
349
+ // Check field access
350
+ const canWrite = await checkFieldAccess(fieldConfig?.access, operation, {
351
+ ...args,
352
+ })
353
+
354
+ if (canWrite) {
355
+ filtered[fieldName] = value
356
+ }
357
+ }
358
+
359
+ return filtered as Partial<T>
360
+ }
@@ -0,0 +1,99 @@
1
+ import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
2
+ import type { HashedPassword } from '../utils/password.js'
3
+
4
+ /**
5
+ * Extract the return type of a field's afterOperation hook
6
+ * If the field has an afterOperation hook, infer its return type
7
+ * Otherwise, use the original type
8
+ */
9
+ export type InferFieldReadType<TField extends FieldConfig, TOriginal> = TField extends {
10
+ // Generic `any` is required here for TypeScript's conditional type inference to work correctly
11
+ // This allows us to infer the exact return type `R` from hooks of any signature
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
+ hooks?: { afterOperation?: (...args: any[]) => infer R }
14
+ }
15
+ ? R extends never
16
+ ? TOriginal // No hook defined
17
+ : R // Hook return type
18
+ : TOriginal // No hooks at all
19
+
20
+ /**
21
+ * Transform a Prisma model's field types based on OpenSaas field configs
22
+ * This applies afterOperation hook transformations to field types
23
+ */
24
+ export type TransformModelFields<
25
+ // Generic constraint requires `any` to allow indexing by string keys from Prisma models
26
+ // This is necessary for mapped types to work with Prisma's generated model types
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ TModel extends Record<string, any>,
29
+ TFields extends Record<string, FieldConfig>,
30
+ > = {
31
+ [K in keyof TModel]: K extends keyof TFields
32
+ ? InferFieldReadType<TFields[K], TModel[K]>
33
+ : TModel[K]
34
+ }
35
+
36
+ /**
37
+ * Get the field configs for a specific list from the OpenSaas config
38
+ */
39
+ export type GetListFields<
40
+ TConfig extends OpenSaasConfig,
41
+ TListKey extends keyof TConfig['lists'],
42
+ > = TConfig['lists'][TListKey]['fields']
43
+
44
+ /**
45
+ * Transform a Prisma model result based on OpenSaas config
46
+ * Applies field hooks transformations
47
+ */
48
+ export type TransformResult<
49
+ TConfig extends OpenSaasConfig,
50
+ TListKey extends keyof TConfig['lists'],
51
+ TResult,
52
+ > =
53
+ // Generic constraint requires `any` to check if TResult is an object type that can be transformed
54
+ // This pattern is standard in TypeScript for conditional types on object shapes
55
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
+ TResult extends Record<string, any>
57
+ ? TransformModelFields<TResult, GetListFields<TConfig, TListKey>>
58
+ : TResult
59
+
60
+ /**
61
+ * Transform a Prisma operation's return type
62
+ * Handles single results, arrays, and null cases
63
+ */
64
+ export type TransformOperationResult<
65
+ TConfig extends OpenSaasConfig,
66
+ TListKey extends keyof TConfig['lists'],
67
+ TResult,
68
+ > =
69
+ TResult extends Promise<infer R>
70
+ ? Promise<
71
+ R extends Array<infer Item>
72
+ ? Array<TransformResult<TConfig, TListKey, Item>>
73
+ : R extends null
74
+ ? null
75
+ : TransformResult<TConfig, TListKey, R>
76
+ >
77
+ : never
78
+
79
+ /**
80
+ * Known field type mappings for afterOperation hooks
81
+ * These provide concrete type hints for common field transformations
82
+ */
83
+ export interface FieldTypeTransforms {
84
+ password: HashedPassword
85
+ // Future field types can be added here
86
+ // richText: TiptapContent
87
+ // json: JSONValue
88
+ }
89
+
90
+ /**
91
+ * Helper to infer field type based on field config type
92
+ */
93
+ export type InferFieldTypeTransform<TField extends FieldConfig> = TField extends {
94
+ type: infer TType
95
+ }
96
+ ? TType extends keyof FieldTypeTransforms
97
+ ? FieldTypeTransforms[TType]
98
+ : never
99
+ : never
@@ -0,0 +1,20 @@
1
+ export type {
2
+ AccessControl,
3
+ FieldAccess,
4
+ Session,
5
+ AccessContext,
6
+ PrismaFilter,
7
+ AccessControlledDB,
8
+ PrismaClientLike,
9
+ } from './types.js'
10
+ export {
11
+ checkAccess,
12
+ mergeFilters,
13
+ checkFieldAccess,
14
+ filterReadableFields,
15
+ filterWritableFields,
16
+ isBoolean,
17
+ isPrismaFilter,
18
+ getRelatedListConfig,
19
+ buildIncludeWithAccessControl,
20
+ } from './engine.js'