@opensaas/stack-core 0.20.1 → 0.21.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 +1 -1
- package/CHANGELOG.md +72 -0
- package/CLAUDE.md +18 -2
- package/dist/access/access-filter.d.ts +29 -0
- package/dist/access/access-filter.d.ts.map +1 -0
- package/dist/access/access-filter.js +68 -0
- package/dist/access/access-filter.js.map +1 -0
- package/dist/access/engine.d.ts +15 -48
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +14 -280
- package/dist/access/engine.js.map +1 -1
- package/dist/access/field-access.d.ts +44 -0
- package/dist/access/field-access.d.ts.map +1 -0
- package/dist/access/field-access.js +123 -0
- package/dist/access/field-access.js.map +1 -0
- package/dist/access/field-access.test.d.ts +2 -0
- package/dist/access/field-access.test.d.ts.map +1 -0
- package/dist/access/{engine.test.js → field-access.test.js} +2 -2
- package/dist/access/field-access.test.js.map +1 -0
- package/dist/access/field-visibility.d.ts +13 -0
- package/dist/access/field-visibility.d.ts.map +1 -0
- package/dist/access/field-visibility.js +155 -0
- package/dist/access/field-visibility.js.map +1 -0
- package/dist/access/index.d.ts +4 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +8 -1
- package/dist/access/index.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/types.d.ts +45 -4
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +49 -0
- package/dist/context/hook-pipeline.d.ts.map +1 -0
- package/dist/context/hook-pipeline.js +75 -0
- package/dist/context/hook-pipeline.js.map +1 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +30 -462
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +72 -68
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +158 -0
- package/dist/context/write-pipeline.d.ts.map +1 -0
- package/dist/context/write-pipeline.js +306 -0
- package/dist/context/write-pipeline.js.map +1 -0
- package/dist/extend.d.ts +3 -0
- package/dist/extend.d.ts.map +1 -0
- package/dist/extend.js +10 -0
- package/dist/extend.js.map +1 -0
- package/dist/fields/index.d.ts +1 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +213 -2
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +20 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +202 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +5 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -10
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +8 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +16 -0
- package/dist/internal.js.map +1 -0
- package/dist/validation/field-config.d.ts +55 -0
- package/dist/validation/field-config.d.ts.map +1 -0
- package/dist/validation/field-config.js +100 -0
- package/dist/validation/field-config.js.map +1 -0
- package/dist/validation/field-config.test.d.ts +2 -0
- package/dist/validation/field-config.test.d.ts.map +1 -0
- package/dist/validation/field-config.test.js +159 -0
- package/dist/validation/field-config.test.js.map +1 -0
- package/package.json +11 -3
- package/src/access/access-filter.ts +97 -0
- package/src/access/engine.ts +13 -396
- package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
- package/src/access/field-access.ts +159 -0
- package/src/access/field-visibility.ts +247 -0
- package/src/access/index.ts +7 -4
- package/src/config/index.ts +1 -0
- package/src/config/types.ts +51 -4
- package/src/context/hook-pipeline.ts +160 -0
- package/src/context/index.ts +29 -667
- package/src/context/nested-operations.ts +142 -111
- package/src/context/write-pipeline.ts +543 -0
- package/src/extend.ts +14 -0
- package/src/fields/index.ts +310 -2
- package/src/hooks/index.ts +227 -0
- package/src/index.ts +27 -90
- package/src/internal.ts +49 -0
- package/src/validation/field-config.test.ts +199 -0
- package/src/validation/field-config.ts +145 -0
- package/tests/access-relationships.test.ts +4 -4
- package/tests/access.test.ts +1 -1
- package/tests/field-hooks.test.ts +410 -0
- package/tests/field-types.test.ts +1 -1
- package/tests/hook-pipeline.test.ts +233 -0
- package/tests/nested-operation-registry.test.ts +206 -0
- package/tests/write-pipeline.test.ts +588 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +43 -1
- package/dist/access/engine.test.d.ts +0 -2
- package/dist/access/engine.test.d.ts.map +0 -1
- package/dist/access/engine.test.js.map +0 -1
package/src/internal.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// ───────────────────────────────────────────────────────────────
|
|
2
|
+
// @opensaas/stack-core/internal
|
|
3
|
+
//
|
|
4
|
+
// @internal — plumbing shared between the @opensaas/* packages and the
|
|
5
|
+
// code emitted by the generator (`.opensaas/`). NOT a public API: these
|
|
6
|
+
// exports carry NO semver guarantees and may change or disappear in any
|
|
7
|
+
// release. Application authors should never import from this path; use
|
|
8
|
+
// the root entry point, `/fields`, or `/extend` instead.
|
|
9
|
+
// ───────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
// Runtime context internals consumed by generated `.opensaas/` code
|
|
12
|
+
export type { ServerActionProps } from './context/index.js'
|
|
13
|
+
export type {
|
|
14
|
+
PrismaClientLike,
|
|
15
|
+
AccessControlledDB,
|
|
16
|
+
StorageUtils,
|
|
17
|
+
AugmentedFindMany,
|
|
18
|
+
AugmentedFindUnique,
|
|
19
|
+
FindManyQueryArgs,
|
|
20
|
+
} from './access/types.js'
|
|
21
|
+
|
|
22
|
+
// Typed-query internals (Fragment/FieldSelection appear in generated types)
|
|
23
|
+
export type { Fragment, FieldSelection } from './query/index.js'
|
|
24
|
+
|
|
25
|
+
// Password hashing internals (the password field emits HashedPassword into generated types)
|
|
26
|
+
export {
|
|
27
|
+
hashPassword,
|
|
28
|
+
comparePassword,
|
|
29
|
+
isHashedPassword,
|
|
30
|
+
HashedPassword,
|
|
31
|
+
} from './utils/password.js'
|
|
32
|
+
|
|
33
|
+
// Case conversion helpers used by sibling packages
|
|
34
|
+
export { pascalToCamel, pascalToKebab, kebabToPascal, kebabToCamel } from './lib/case-utils.js'
|
|
35
|
+
|
|
36
|
+
// Zod schema helpers used internally for validation
|
|
37
|
+
export { validateWithZod, generateZodSchema } from './validation/schema.js'
|
|
38
|
+
|
|
39
|
+
// Config-shape sub-types consumed by sibling packages (not part of the consumer surface)
|
|
40
|
+
export type {
|
|
41
|
+
DatabaseConfig,
|
|
42
|
+
SessionConfig,
|
|
43
|
+
ThemeConfig,
|
|
44
|
+
ThemePreset,
|
|
45
|
+
ThemeColors,
|
|
46
|
+
FileMetadata,
|
|
47
|
+
ImageMetadata,
|
|
48
|
+
ImageTransformationResult,
|
|
49
|
+
} from './config/index.js'
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { validateFieldConfig, validateConfigFields } from './field-config.js'
|
|
3
|
+
import {
|
|
4
|
+
text,
|
|
5
|
+
integer,
|
|
6
|
+
checkbox,
|
|
7
|
+
timestamp,
|
|
8
|
+
password,
|
|
9
|
+
select,
|
|
10
|
+
json,
|
|
11
|
+
relationship,
|
|
12
|
+
virtual,
|
|
13
|
+
} from '../fields/index.js'
|
|
14
|
+
import type { FieldConfig, OpenSaasConfig } from '../config/types.js'
|
|
15
|
+
|
|
16
|
+
describe('validateFieldConfig', () => {
|
|
17
|
+
describe('well-formed fields pass', () => {
|
|
18
|
+
it.each([
|
|
19
|
+
['text', text()],
|
|
20
|
+
['integer', integer()],
|
|
21
|
+
['checkbox', checkbox()],
|
|
22
|
+
['timestamp', timestamp()],
|
|
23
|
+
['password', password()],
|
|
24
|
+
['select', select({ options: [{ label: 'A', value: 'a' }] })],
|
|
25
|
+
['json', json()],
|
|
26
|
+
])('a built-in %s field is self-contained', (_name, field) => {
|
|
27
|
+
expect(validateFieldConfig(field as FieldConfig, 'myField', 'MyList')).toEqual([])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('a relationship field is self-contained via getPrismaRelation', () => {
|
|
31
|
+
const field = relationship({ ref: 'User.posts' })
|
|
32
|
+
expect(validateFieldConfig(field as FieldConfig, 'author', 'Post')).toEqual([])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('a virtual field is self-contained without getPrismaType', () => {
|
|
36
|
+
const field = virtual({
|
|
37
|
+
type: 'string',
|
|
38
|
+
hooks: { resolveOutput: () => 'x' },
|
|
39
|
+
})
|
|
40
|
+
expect(validateFieldConfig(field as FieldConfig, 'fullName', 'User')).toEqual([])
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('missing scalar contract methods fail', () => {
|
|
45
|
+
it('reports a missing getPrismaType naming the list, field, and method', () => {
|
|
46
|
+
const field = text()
|
|
47
|
+
delete field.getPrismaType
|
|
48
|
+
|
|
49
|
+
const errors = validateFieldConfig(field as FieldConfig, 'title', 'Post')
|
|
50
|
+
|
|
51
|
+
expect(errors).toHaveLength(1)
|
|
52
|
+
expect(errors[0]).toMatchObject({
|
|
53
|
+
listKey: 'Post',
|
|
54
|
+
fieldKey: 'title',
|
|
55
|
+
fieldType: 'text',
|
|
56
|
+
missingMethod: 'getPrismaType',
|
|
57
|
+
})
|
|
58
|
+
expect(errors[0].message).toContain('Post.title')
|
|
59
|
+
expect(errors[0].message).toContain('getPrismaType')
|
|
60
|
+
expect(errors[0].message).toContain('not self-contained')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('reports a missing getTypeScriptType naming the method', () => {
|
|
64
|
+
const field = text()
|
|
65
|
+
delete field.getTypeScriptType
|
|
66
|
+
|
|
67
|
+
const errors = validateFieldConfig(field as FieldConfig, 'title', 'Post')
|
|
68
|
+
|
|
69
|
+
expect(errors).toHaveLength(1)
|
|
70
|
+
expect(errors[0].missingMethod).toBe('getTypeScriptType')
|
|
71
|
+
expect(errors[0].message).toContain('getTypeScriptType')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('reports a missing getZodSchema naming the method', () => {
|
|
75
|
+
const field = text()
|
|
76
|
+
delete field.getZodSchema
|
|
77
|
+
|
|
78
|
+
const errors = validateFieldConfig(field as FieldConfig, 'title', 'Post')
|
|
79
|
+
|
|
80
|
+
expect(errors).toHaveLength(1)
|
|
81
|
+
expect(errors[0].missingMethod).toBe('getZodSchema')
|
|
82
|
+
expect(errors[0].message).toContain('getZodSchema')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('reports every missing method when a field implements none', () => {
|
|
86
|
+
const field: FieldConfig = { type: 'custom' }
|
|
87
|
+
|
|
88
|
+
const errors = validateFieldConfig(field, 'mystery', 'Widget')
|
|
89
|
+
|
|
90
|
+
expect(errors.map((e) => e.missingMethod).sort()).toEqual([
|
|
91
|
+
'getPrismaType',
|
|
92
|
+
'getTypeScriptType',
|
|
93
|
+
'getZodSchema',
|
|
94
|
+
])
|
|
95
|
+
for (const error of errors) {
|
|
96
|
+
expect(error.message).toContain('Widget.mystery')
|
|
97
|
+
expect(error.message).toContain('custom')
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('works without a listKey (bare field validation)', () => {
|
|
102
|
+
const field: FieldConfig = { type: 'custom' }
|
|
103
|
+
const errors = validateFieldConfig(field, 'mystery')
|
|
104
|
+
expect(errors).toHaveLength(3)
|
|
105
|
+
expect(errors[0].listKey).toBeUndefined()
|
|
106
|
+
expect(errors[0].message).toContain('Field "mystery"')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('relationship and virtual variants', () => {
|
|
111
|
+
it('reports a relationship missing getPrismaRelation', () => {
|
|
112
|
+
const field: FieldConfig = { type: 'relationship' }
|
|
113
|
+
|
|
114
|
+
const errors = validateFieldConfig(field, 'author', 'Post')
|
|
115
|
+
|
|
116
|
+
expect(errors).toHaveLength(1)
|
|
117
|
+
expect(errors[0].missingMethod).toBe('getPrismaRelation')
|
|
118
|
+
expect(errors[0].message).toContain('Post.author')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('reports a virtual field missing getTypeScriptType and getZodSchema', () => {
|
|
122
|
+
const field: FieldConfig = { type: 'virtual', virtual: true }
|
|
123
|
+
|
|
124
|
+
const errors = validateFieldConfig(field, 'fullName', 'User')
|
|
125
|
+
|
|
126
|
+
expect(errors.map((e) => e.missingMethod).sort()).toEqual([
|
|
127
|
+
'getTypeScriptType',
|
|
128
|
+
'getZodSchema',
|
|
129
|
+
])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('does not require getPrismaType for virtual fields', () => {
|
|
133
|
+
const field: FieldConfig = { type: 'virtual', virtual: true }
|
|
134
|
+
const errors = validateFieldConfig(field, 'fullName', 'User')
|
|
135
|
+
expect(errors.some((e) => e.missingMethod === 'getPrismaType')).toBe(false)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('validateConfigFields', () => {
|
|
141
|
+
it('returns no errors for a fully compliant config', () => {
|
|
142
|
+
const config: OpenSaasConfig = {
|
|
143
|
+
db: {
|
|
144
|
+
provider: 'sqlite',
|
|
145
|
+
prismaClientConstructor: () => null as never,
|
|
146
|
+
},
|
|
147
|
+
lists: {
|
|
148
|
+
User: {
|
|
149
|
+
fields: {
|
|
150
|
+
name: text({ validation: { isRequired: true } }),
|
|
151
|
+
posts: relationship({ ref: 'Post.author', many: true }),
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
Post: {
|
|
155
|
+
fields: {
|
|
156
|
+
title: text(),
|
|
157
|
+
author: relationship({ ref: 'User.posts' }),
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
expect(validateConfigFields(config)).toEqual([])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('collects per-field errors across every list and names each location', () => {
|
|
167
|
+
const brokenTitle = text()
|
|
168
|
+
delete brokenTitle.getPrismaType
|
|
169
|
+
|
|
170
|
+
const brokenName = text()
|
|
171
|
+
delete brokenName.getZodSchema
|
|
172
|
+
|
|
173
|
+
const config: OpenSaasConfig = {
|
|
174
|
+
db: {
|
|
175
|
+
provider: 'sqlite',
|
|
176
|
+
prismaClientConstructor: () => null as never,
|
|
177
|
+
},
|
|
178
|
+
lists: {
|
|
179
|
+
User: {
|
|
180
|
+
fields: {
|
|
181
|
+
name: brokenName,
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
Post: {
|
|
185
|
+
fields: {
|
|
186
|
+
title: brokenTitle,
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const errors = validateConfigFields(config)
|
|
193
|
+
|
|
194
|
+
expect(errors).toHaveLength(2)
|
|
195
|
+
const byField = Object.fromEntries(errors.map((e) => [`${e.listKey}.${e.fieldKey}`, e]))
|
|
196
|
+
expect(byField['User.name'].missingMethod).toBe('getZodSchema')
|
|
197
|
+
expect(byField['Post.title'].missingMethod).toBe('getPrismaType')
|
|
198
|
+
})
|
|
199
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { FieldConfig, OpenSaasConfig } from '../config/types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single self-containment violation found on a field.
|
|
5
|
+
*
|
|
6
|
+
* Field builders advertise a self-containment contract: every field provides
|
|
7
|
+
* the generation hooks the schema/type generators delegate to. When a field
|
|
8
|
+
* (often a third-party one) fails to implement a required method, the
|
|
9
|
+
* generators historically threw deep inside generation with an opaque stack
|
|
10
|
+
* trace. This structured error lets the contract be checked up front and
|
|
11
|
+
* reported per-field with enough context to act on.
|
|
12
|
+
*/
|
|
13
|
+
export interface FieldConfigValidationError {
|
|
14
|
+
/** The list the offending field belongs to (`undefined` when validating a bare field). */
|
|
15
|
+
listKey?: string
|
|
16
|
+
/** The field key (property name) within the list. */
|
|
17
|
+
fieldKey: string
|
|
18
|
+
/** The field's declared `type` discriminator (e.g. `'text'`, `'virtual'`). */
|
|
19
|
+
fieldType: string
|
|
20
|
+
/** The contract method that is missing. */
|
|
21
|
+
missingMethod: 'getPrismaType' | 'getTypeScriptType' | 'getZodSchema' | 'getPrismaRelation'
|
|
22
|
+
/** A human-readable, ready-to-print message naming the list, field, and method. */
|
|
23
|
+
message: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Describe a field for an error message. Falls back to a literal when a
|
|
28
|
+
* `type`-less value slips through (e.g. a malformed third-party field).
|
|
29
|
+
*/
|
|
30
|
+
function describeFieldType(field: FieldConfig): string {
|
|
31
|
+
return typeof field.type === 'string' && field.type.length > 0 ? field.type : 'unknown'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Type-safe probe for a function-valued property on a field config.
|
|
36
|
+
*
|
|
37
|
+
* The three scalar contract methods live on `BaseFieldConfig` and are checked
|
|
38
|
+
* directly. `getPrismaRelation` only exists on the relationship field variant,
|
|
39
|
+
* so it is probed structurally here without widening the public type or
|
|
40
|
+
* reaching for a cast.
|
|
41
|
+
*/
|
|
42
|
+
function hasFieldMethod(field: FieldConfig, method: string): boolean {
|
|
43
|
+
const value: unknown = Reflect.get(field, method)
|
|
44
|
+
return typeof value === 'function'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the canonical error message for a missing contract method.
|
|
49
|
+
*/
|
|
50
|
+
function buildMessage(
|
|
51
|
+
fieldType: string,
|
|
52
|
+
method: FieldConfigValidationError['missingMethod'],
|
|
53
|
+
fieldKey: string,
|
|
54
|
+
listKey?: string,
|
|
55
|
+
): string {
|
|
56
|
+
const location = listKey ? `Field "${listKey}.${fieldKey}"` : `Field "${fieldKey}"`
|
|
57
|
+
return (
|
|
58
|
+
`${location} (type "${fieldType}") is not self-contained: it does not implement ` +
|
|
59
|
+
`${method}(). Field builders must provide this method so the generator can ` +
|
|
60
|
+
`produce schema and types without inspecting field internals.`
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Validate a single field against the self-containment contract.
|
|
66
|
+
*
|
|
67
|
+
* The contract is conditional on field kind, mirroring exactly where the
|
|
68
|
+
* generators delegate:
|
|
69
|
+
*
|
|
70
|
+
* - `relationship` fields contribute schema via `getPrismaRelation` and are
|
|
71
|
+
* skipped by the scalar Prisma/TypeScript/Zod paths, so only
|
|
72
|
+
* `getPrismaRelation` is required.
|
|
73
|
+
* - `virtual` fields are not stored in the database, so `getPrismaType` is
|
|
74
|
+
* legitimately absent; they must still provide `getTypeScriptType` and
|
|
75
|
+
* `getZodSchema`.
|
|
76
|
+
* - every other (stored scalar) field must provide `getPrismaType`,
|
|
77
|
+
* `getTypeScriptType`, and `getZodSchema`.
|
|
78
|
+
*
|
|
79
|
+
* @param field - The field config produced by a field builder.
|
|
80
|
+
* @param fieldKey - The field's key within its list (for messages).
|
|
81
|
+
* @param listKey - The owning list's key (optional, for messages).
|
|
82
|
+
* @returns Zero or more structured errors; empty means the field is compliant.
|
|
83
|
+
*/
|
|
84
|
+
export function validateFieldConfig(
|
|
85
|
+
field: FieldConfig,
|
|
86
|
+
fieldKey: string,
|
|
87
|
+
listKey?: string,
|
|
88
|
+
): FieldConfigValidationError[] {
|
|
89
|
+
const errors: FieldConfigValidationError[] = []
|
|
90
|
+
const fieldType = describeFieldType(field)
|
|
91
|
+
|
|
92
|
+
const requireMethod = (method: FieldConfigValidationError['missingMethod']): void => {
|
|
93
|
+
if (!hasFieldMethod(field, method)) {
|
|
94
|
+
errors.push({
|
|
95
|
+
listKey,
|
|
96
|
+
fieldKey,
|
|
97
|
+
fieldType,
|
|
98
|
+
missingMethod: method,
|
|
99
|
+
message: buildMessage(fieldType, method, fieldKey, listKey),
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (field.type === 'relationship') {
|
|
105
|
+
// Relationships render through the relationship path only.
|
|
106
|
+
requireMethod('getPrismaRelation')
|
|
107
|
+
return errors
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (field.virtual === true || field.type === 'virtual') {
|
|
111
|
+
// Virtual fields are not persisted, so getPrismaType is intentionally absent.
|
|
112
|
+
requireMethod('getTypeScriptType')
|
|
113
|
+
requireMethod('getZodSchema')
|
|
114
|
+
return errors
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Stored scalar fields must implement the full generation contract.
|
|
118
|
+
requireMethod('getPrismaType')
|
|
119
|
+
requireMethod('getTypeScriptType')
|
|
120
|
+
requireMethod('getZodSchema')
|
|
121
|
+
|
|
122
|
+
return errors
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validate every field across every list in a config.
|
|
127
|
+
*
|
|
128
|
+
* Intended to run once, before any generation, so a misimplemented field
|
|
129
|
+
* surfaces a clear per-field message instead of a deep generator stack trace.
|
|
130
|
+
*
|
|
131
|
+
* @param config - The fully resolved OpenSaas config.
|
|
132
|
+
* @returns All self-containment violations, flattened across lists and fields.
|
|
133
|
+
*/
|
|
134
|
+
export function validateConfigFields(config: OpenSaasConfig): FieldConfigValidationError[] {
|
|
135
|
+
const errors: FieldConfigValidationError[] = []
|
|
136
|
+
|
|
137
|
+
for (const [listKey, listConfig] of Object.entries(config.lists)) {
|
|
138
|
+
if (!listConfig?.fields) continue
|
|
139
|
+
for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
|
|
140
|
+
errors.push(...validateFieldConfig(fieldConfig, fieldKey, listKey))
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return errors
|
|
145
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { filterReadableFields, getRelatedListConfig } from '../src/access/
|
|
2
|
+
import { filterReadableFields, getRelatedListConfig } from '../src/access/index.js'
|
|
3
3
|
import type { OpenSaasConfig, AccessContext } from '../src/index.js'
|
|
4
4
|
|
|
5
5
|
describe('Relationship Access Control', () => {
|
|
@@ -153,7 +153,7 @@ describe('Relationship Access Control', () => {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
// Test that buildIncludeWithAccessControl excludes the denied relationship
|
|
156
|
-
const { buildIncludeWithAccessControl } = await import('../src/access/
|
|
156
|
+
const { buildIncludeWithAccessControl } = await import('../src/access/index.js')
|
|
157
157
|
|
|
158
158
|
const include = await buildIncludeWithAccessControl(
|
|
159
159
|
config.lists.Post.fields,
|
|
@@ -319,7 +319,7 @@ describe('Relationship Access Control', () => {
|
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
// Test that buildIncludeWithAccessControl creates the right where clause
|
|
322
|
-
const { buildIncludeWithAccessControl } = await import('../src/access/
|
|
322
|
+
const { buildIncludeWithAccessControl } = await import('../src/access/index.js')
|
|
323
323
|
|
|
324
324
|
const include = await buildIncludeWithAccessControl(
|
|
325
325
|
config.lists.User.fields,
|
|
@@ -486,7 +486,7 @@ describe('Relationship Access Control', () => {
|
|
|
486
486
|
}
|
|
487
487
|
|
|
488
488
|
// Test that buildIncludeWithAccessControl creates session-based where clause
|
|
489
|
-
const { buildIncludeWithAccessControl } = await import('../src/access/
|
|
489
|
+
const { buildIncludeWithAccessControl } = await import('../src/access/index.js')
|
|
490
490
|
|
|
491
491
|
const include = await buildIncludeWithAccessControl(
|
|
492
492
|
config.lists.User.fields,
|
package/tests/access.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
filterWritableFields,
|
|
8
8
|
isBoolean,
|
|
9
9
|
isPrismaFilter,
|
|
10
|
-
} from '../src/access/
|
|
10
|
+
} from '../src/access/index.js'
|
|
11
11
|
import type { AccessControl, FieldAccess, AccessContext } from '../src/access/types.js'
|
|
12
12
|
|
|
13
13
|
describe('Access Control', () => {
|