@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
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { Session, AccessContext } from './types.js'
|
|
2
|
+
import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
|
|
3
|
+
import { getRelatedListConfig } from './engine.js'
|
|
4
|
+
import { checkFieldAccess } from './field-access.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Field Visibility — phase 2 of the two-phase read (post-query).
|
|
8
|
+
*
|
|
9
|
+
* This module runs after the database query against the returned rows. It
|
|
10
|
+
* strips fields the session cannot read (via the canonical `checkFieldAccess`
|
|
11
|
+
* evaluator in `field-access.ts`), runs `resolveOutput` hooks, and computes
|
|
12
|
+
* virtual fields. None of this can move into phase 1: virtual fields are
|
|
13
|
+
* computed in JavaScript and field access can depend on the fetched row.
|
|
14
|
+
*
|
|
15
|
+
* Phase 1 (pre-query row/relation scoping) lives in `access-filter.ts`. See
|
|
16
|
+
* `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
|
|
17
|
+
* glossary in `CONTEXT.md`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Runtime type for resolveOutput hooks
|
|
22
|
+
* Used when we need to call hooks generically without knowing the specific field type
|
|
23
|
+
* Supports both sync and async implementations
|
|
24
|
+
*/
|
|
25
|
+
type ResolveOutputHookRuntime = (args: {
|
|
26
|
+
operation: 'query'
|
|
27
|
+
value: unknown
|
|
28
|
+
item: Record<string, unknown>
|
|
29
|
+
listKey: string
|
|
30
|
+
fieldName: string
|
|
31
|
+
context: AccessContext
|
|
32
|
+
}) => unknown | Promise<unknown>
|
|
33
|
+
|
|
34
|
+
type FieldVisibilityArgs = {
|
|
35
|
+
session: Session | null
|
|
36
|
+
context: AccessContext & { _isSudo?: boolean }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The core Field Visibility step for a single field: check read access and, if
|
|
41
|
+
* granted, produce the output value by running any `resolveOutput` hook.
|
|
42
|
+
*
|
|
43
|
+
* This is the single place the "check read access → skip if denied →
|
|
44
|
+
* resolveOutput" sequence lives. Both the regular-field branch and the
|
|
45
|
+
* virtual-field branch of `filterReadableFields` call it, so the sequence is
|
|
46
|
+
* never duplicated. Returns `{ readable: false }` when the field must be omitted
|
|
47
|
+
* from the result.
|
|
48
|
+
*
|
|
49
|
+
* `accessItem` is the row used to evaluate field access; `hookItem` is the
|
|
50
|
+
* object passed to the hook as `item` (these differ for virtual fields, which
|
|
51
|
+
* see the already-filtered output so they can read sibling fields).
|
|
52
|
+
*/
|
|
53
|
+
async function resolveReadableFieldValue(params: {
|
|
54
|
+
fieldConfig: FieldConfig | undefined
|
|
55
|
+
fieldName: string
|
|
56
|
+
value: unknown
|
|
57
|
+
accessItem: Record<string, unknown>
|
|
58
|
+
hookItem: Record<string, unknown>
|
|
59
|
+
listKey: string | undefined
|
|
60
|
+
args: FieldVisibilityArgs
|
|
61
|
+
}): Promise<{ readable: false } | { readable: true; value: unknown }> {
|
|
62
|
+
const { fieldConfig, fieldName, value, accessItem, hookItem, listKey, args } = params
|
|
63
|
+
|
|
64
|
+
// Check field access (checkFieldAccess already handles sudo mode)
|
|
65
|
+
const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
|
|
66
|
+
...args,
|
|
67
|
+
item: accessItem,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
if (!canRead) {
|
|
71
|
+
return { readable: false }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Apply resolveOutput hook if present
|
|
75
|
+
if (fieldConfig?.hooks?.resolveOutput && listKey) {
|
|
76
|
+
// Cast to runtime type for generic execution
|
|
77
|
+
// At runtime, the hook will receive the correct value type for the field
|
|
78
|
+
const hook = fieldConfig.hooks.resolveOutput as unknown as ResolveOutputHookRuntime
|
|
79
|
+
// Increment depth counter to prevent infinite loops from hooks making DB queries
|
|
80
|
+
// that include relationships back to the same entity
|
|
81
|
+
args.context._resolveOutputCounter.depth++
|
|
82
|
+
try {
|
|
83
|
+
// Use Promise.resolve() to handle both sync and async hooks
|
|
84
|
+
const resolved = await Promise.resolve(
|
|
85
|
+
hook({
|
|
86
|
+
value,
|
|
87
|
+
operation: 'query',
|
|
88
|
+
fieldName,
|
|
89
|
+
listKey,
|
|
90
|
+
item: hookItem,
|
|
91
|
+
context: args.context,
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
return { readable: true, value: resolved }
|
|
95
|
+
} finally {
|
|
96
|
+
args.context._resolveOutputCounter.depth--
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { readable: true, value }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Filter fields from an object based on read access
|
|
105
|
+
* Recursively applies access control to nested relationships
|
|
106
|
+
*/
|
|
107
|
+
export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
108
|
+
item: T,
|
|
109
|
+
fieldConfigs: Record<string, FieldConfig>,
|
|
110
|
+
args: {
|
|
111
|
+
session: Session | null
|
|
112
|
+
context: AccessContext & { _isSudo?: boolean }
|
|
113
|
+
},
|
|
114
|
+
config?: OpenSaasConfig,
|
|
115
|
+
depth: number = 0,
|
|
116
|
+
listKey?: string,
|
|
117
|
+
): Promise<Partial<T>> {
|
|
118
|
+
const filtered: Record<string, unknown> = {}
|
|
119
|
+
const MAX_DEPTH = 5 // Prevent infinite recursion
|
|
120
|
+
|
|
121
|
+
// Process existing fields from the database result
|
|
122
|
+
for (const [fieldName, value] of Object.entries(item)) {
|
|
123
|
+
const fieldConfig = fieldConfigs[fieldName]
|
|
124
|
+
|
|
125
|
+
// Always include id, createdAt, updatedAt
|
|
126
|
+
if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
|
|
127
|
+
filtered[fieldName] = value
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Handle relationship fields - recursively filter fields within related items
|
|
132
|
+
// Note: Access control filtering is now done at database level via buildIncludeWithAccessControl
|
|
133
|
+
// This only handles field-level access (hiding sensitive fields)
|
|
134
|
+
if (
|
|
135
|
+
config &&
|
|
136
|
+
fieldConfig?.type === 'relationship' &&
|
|
137
|
+
'ref' in fieldConfig &&
|
|
138
|
+
fieldConfig.ref &&
|
|
139
|
+
value !== null &&
|
|
140
|
+
value !== undefined &&
|
|
141
|
+
depth < MAX_DEPTH
|
|
142
|
+
) {
|
|
143
|
+
// Gate the relationship on read access before recursing.
|
|
144
|
+
const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
|
|
145
|
+
...args,
|
|
146
|
+
item,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
if (!canRead) {
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
|
|
154
|
+
|
|
155
|
+
if (relatedConfig) {
|
|
156
|
+
// For many relationships (arrays) - recursively filter fields in each item
|
|
157
|
+
// The recursive call already handles applying resolveOutput hooks
|
|
158
|
+
if (Array.isArray(value)) {
|
|
159
|
+
filtered[fieldName] = await Promise.all(
|
|
160
|
+
value.map((relatedItem) =>
|
|
161
|
+
filterReadableFields(
|
|
162
|
+
relatedItem,
|
|
163
|
+
relatedConfig.listConfig.fields,
|
|
164
|
+
args,
|
|
165
|
+
config,
|
|
166
|
+
depth + 1,
|
|
167
|
+
relatedConfig.listName,
|
|
168
|
+
),
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
// For single relationships (objects) - recursively filter fields
|
|
173
|
+
// The recursive call already handles applying resolveOutput hooks
|
|
174
|
+
else if (typeof value === 'object') {
|
|
175
|
+
filtered[fieldName] = await filterReadableFields(
|
|
176
|
+
value as Record<string, unknown>,
|
|
177
|
+
relatedConfig.listConfig.fields,
|
|
178
|
+
args,
|
|
179
|
+
config,
|
|
180
|
+
depth + 1,
|
|
181
|
+
relatedConfig.listName,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
// Related config not found, include the value as-is
|
|
186
|
+
filtered[fieldName] = value
|
|
187
|
+
}
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Non-relationship field (or relationship without an includable value):
|
|
192
|
+
// check read access and apply resolveOutput via the shared helper.
|
|
193
|
+
const result = await resolveReadableFieldValue({
|
|
194
|
+
fieldConfig,
|
|
195
|
+
fieldName,
|
|
196
|
+
value,
|
|
197
|
+
accessItem: item,
|
|
198
|
+
hookItem: item,
|
|
199
|
+
listKey,
|
|
200
|
+
args,
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
if (result.readable) {
|
|
204
|
+
filtered[fieldName] = result.value
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Process virtual fields - compute values from other fields
|
|
209
|
+
// Virtual fields don't exist in the database result, so we need to compute them separately
|
|
210
|
+
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
211
|
+
// Skip if already processed (from database result)
|
|
212
|
+
if (fieldName in filtered) {
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Only process virtual fields
|
|
217
|
+
if (!fieldConfig.virtual) {
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Virtual fields must have a resolveOutput hook to compute their value;
|
|
222
|
+
// without one there is nothing to add to the result.
|
|
223
|
+
if (!(fieldConfig.hooks?.resolveOutput && listKey)) {
|
|
224
|
+
// Still evaluate read access to preserve any access-fn side effects.
|
|
225
|
+
await checkFieldAccess(fieldConfig.access, 'read', { ...args, item })
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Check read access and compute the value via the shared helper. Virtual
|
|
230
|
+
// fields see the already-filtered item so they can read sibling fields.
|
|
231
|
+
const result = await resolveReadableFieldValue({
|
|
232
|
+
fieldConfig,
|
|
233
|
+
fieldName,
|
|
234
|
+
value: undefined, // Virtual fields don't have a database value
|
|
235
|
+
accessItem: item,
|
|
236
|
+
hookItem: filtered,
|
|
237
|
+
listKey,
|
|
238
|
+
args,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
if (result.readable) {
|
|
242
|
+
filtered[fieldName] = result.value
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return filtered as Partial<T>
|
|
247
|
+
}
|
package/src/access/index.ts
CHANGED
|
@@ -11,14 +11,17 @@ export type {
|
|
|
11
11
|
AugmentedFindUnique,
|
|
12
12
|
FindManyQueryArgs,
|
|
13
13
|
} from './types.js'
|
|
14
|
+
// Operation-level access primitives and shared ref-parsing helper.
|
|
14
15
|
export {
|
|
15
16
|
checkAccess,
|
|
16
17
|
mergeFilters,
|
|
17
|
-
checkFieldAccess,
|
|
18
|
-
filterReadableFields,
|
|
19
|
-
filterWritableFields,
|
|
20
18
|
isBoolean,
|
|
21
19
|
isPrismaFilter,
|
|
22
20
|
getRelatedListConfig,
|
|
23
|
-
buildIncludeWithAccessControl,
|
|
24
21
|
} from './engine.js'
|
|
22
|
+
// Canonical field-level access evaluation (shared by read and write paths).
|
|
23
|
+
export { checkFieldAccess, filterWritableFields } from './field-access.js'
|
|
24
|
+
// Phase 1 — Access Filter (pre-query row/relation scoping).
|
|
25
|
+
export { buildIncludeWithAccessControl } from './access-filter.js'
|
|
26
|
+
// Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
|
|
27
|
+
export { filterReadableFields } from './field-visibility.js'
|
package/src/config/index.ts
CHANGED
package/src/config/types.ts
CHANGED
|
@@ -723,7 +723,56 @@ export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
|
|
|
723
723
|
ui?: {
|
|
724
724
|
displayMode?: 'select' | 'cards'
|
|
725
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* Get the complete Prisma schema contribution for this relationship field.
|
|
728
|
+
*
|
|
729
|
+
* Relationships are special: unlike scalar fields (which return a single
|
|
730
|
+
* type via `getPrismaType`), a relationship can contribute a foreign key
|
|
731
|
+
* line, a relation line on the owning model, and a synthetic back-relation
|
|
732
|
+
* line on the target model. This method encapsulates all of that logic so
|
|
733
|
+
* the generator can remain a neutral coordinator.
|
|
734
|
+
*
|
|
735
|
+
* @param fieldName - The name of this relationship field
|
|
736
|
+
* @param allFields - All fields on the list this relationship belongs to
|
|
737
|
+
* @param listKey - The name of the list this relationship belongs to
|
|
738
|
+
* @param config - The full OpenSaas config (used to resolve the target list/field)
|
|
739
|
+
*/
|
|
740
|
+
getPrismaRelation?: (
|
|
741
|
+
fieldName: string,
|
|
742
|
+
allFields: Record<string, FieldConfig>,
|
|
743
|
+
listKey: string,
|
|
744
|
+
config: OpenSaasConfig,
|
|
745
|
+
) => PrismaRelationResult
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* The complete Prisma schema contribution of a relationship field.
|
|
750
|
+
*/
|
|
751
|
+
export type PrismaRelationResult = {
|
|
752
|
+
/**
|
|
753
|
+
* Lines to add to the owning model.
|
|
754
|
+
* For an FK-owning single relationship this is `[fkLine, relationLine]`;
|
|
755
|
+
* for the many side or the non-FK side it is `[relationLine]`.
|
|
756
|
+
*/
|
|
757
|
+
modelLines: string[]
|
|
758
|
+
/**
|
|
759
|
+
* Foreign key index to add to the owning model, if this side owns an
|
|
760
|
+
* indexed foreign key.
|
|
761
|
+
*/
|
|
762
|
+
foreignKeyIndex?: {
|
|
763
|
+
foreignKeyField: string
|
|
764
|
+
indexType: boolean | 'unique'
|
|
726
765
|
}
|
|
766
|
+
/**
|
|
767
|
+
* Synthetic back-relation field to add to the target model. Only present
|
|
768
|
+
* for list-only refs (e.g., `ref: 'Category'`), where the target model
|
|
769
|
+
* needs an opposite relation field for Prisma to validate the relation.
|
|
770
|
+
*/
|
|
771
|
+
backRelation?: {
|
|
772
|
+
targetList: string
|
|
773
|
+
line: string
|
|
774
|
+
}
|
|
775
|
+
}
|
|
727
776
|
|
|
728
777
|
export type JsonField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
|
|
729
778
|
type: 'json'
|
|
@@ -1221,12 +1270,10 @@ export type DatabaseConfig = {
|
|
|
1221
1270
|
*
|
|
1222
1271
|
* @example SQLite with better-sqlite3
|
|
1223
1272
|
* ```typescript
|
|
1224
|
-
* import {
|
|
1225
|
-
* import Database from 'better-sqlite3'
|
|
1273
|
+
* import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
|
|
1226
1274
|
*
|
|
1227
1275
|
* prismaClientConstructor: (PrismaClient) => {
|
|
1228
|
-
* const
|
|
1229
|
-
* const adapter = new PrismaBetterSQLite3(db)
|
|
1276
|
+
* const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL || 'file:./dev.db' })
|
|
1230
1277
|
* return new PrismaClient({ adapter })
|
|
1231
1278
|
* }
|
|
1232
1279
|
* ```
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { ListConfig } from '../config/types.js'
|
|
2
|
+
import type { AccessContext } from '../access/types.js'
|
|
3
|
+
import {
|
|
4
|
+
executeResolveInput,
|
|
5
|
+
executeValidate,
|
|
6
|
+
executeFieldResolveInputHooks,
|
|
7
|
+
executeFieldValidateHooks,
|
|
8
|
+
validateFieldRules,
|
|
9
|
+
ValidationError,
|
|
10
|
+
} from '../hooks/index.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hook Pipeline — the single module that runs the transform+validate span of a
|
|
14
|
+
* write: list `resolveInput` → field `resolveInput` → list `validate` → field
|
|
15
|
+
* `validate` → built-in field rules (`validateFieldRules`). It owns the order of
|
|
16
|
+
* these phases and the threading of `resolvedData` through them, in one place.
|
|
17
|
+
*
|
|
18
|
+
* It is THE place where input is shaped and validated; it throws
|
|
19
|
+
* {@link ValidationError} on failure exactly as before (validate hooks via
|
|
20
|
+
* `addValidationError`, then `validateFieldRules`) — validation is never silent.
|
|
21
|
+
*
|
|
22
|
+
* Side-effect hooks (`beforeOperation`/`afterOperation`), operation-level access,
|
|
23
|
+
* writable-field filtering, nested operations, persistence and Field Visibility
|
|
24
|
+
* are deliberately OUT of this span — they stay in the Write Pipeline. See the
|
|
25
|
+
* "Hook Pipeline" and "Write Pipeline" glossary terms in CONTEXT.md.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Arguments for one transform+validate span. Only the create/update operations
|
|
30
|
+
* run this span (delete skips the input-shaping phases entirely).
|
|
31
|
+
*/
|
|
32
|
+
export interface HookPipelineArgs {
|
|
33
|
+
operation: 'create' | 'update'
|
|
34
|
+
listName: string
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
36
|
+
listConfig: ListConfig<any>
|
|
37
|
+
/** The original input data for the write. */
|
|
38
|
+
inputData: Record<string, unknown>
|
|
39
|
+
/** The existing row for update; `undefined` for create. */
|
|
40
|
+
item: Record<string, unknown> | undefined
|
|
41
|
+
context: AccessContext
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Result of a transform+validate span: the fully-resolved write data after the
|
|
46
|
+
* resolveInput hooks have run and all validation has passed.
|
|
47
|
+
*/
|
|
48
|
+
export interface HookPipelineResult {
|
|
49
|
+
resolvedData: Record<string, unknown>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The transform+validate span, owning order + `resolvedData` threading.
|
|
54
|
+
*/
|
|
55
|
+
export interface HookPipeline {
|
|
56
|
+
run(args: HookPipelineArgs): Promise<HookPipelineResult>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run the transform+validate span once.
|
|
61
|
+
*
|
|
62
|
+
* Phase order (owned here, in one place):
|
|
63
|
+
* list `resolveInput`
|
|
64
|
+
* → field `resolveInput`
|
|
65
|
+
* → list `validate`
|
|
66
|
+
* → field `validate`
|
|
67
|
+
* → built-in field rules (`validateFieldRules`)
|
|
68
|
+
*
|
|
69
|
+
* Contract preserved exactly:
|
|
70
|
+
* - `resolvedData` starts as `inputData` and is threaded through each phase;
|
|
71
|
+
* - validate hooks report failures via `addValidationError` → THROW
|
|
72
|
+
* `ValidationError` (never silent);
|
|
73
|
+
* - built-in field rule failures THROW `ValidationError`;
|
|
74
|
+
* - on success returns the transformed `resolvedData`.
|
|
75
|
+
*/
|
|
76
|
+
async function runHookPipeline(args: HookPipelineArgs): Promise<HookPipelineResult> {
|
|
77
|
+
const { operation, listName, listConfig, inputData, item, context } = args
|
|
78
|
+
|
|
79
|
+
// ── Phase 1: list-level resolveInput ──────────────────────────────────────
|
|
80
|
+
let resolvedData = await executeResolveInput(
|
|
81
|
+
listConfig.hooks,
|
|
82
|
+
operation === 'create'
|
|
83
|
+
? {
|
|
84
|
+
listKey: listName,
|
|
85
|
+
operation: 'create',
|
|
86
|
+
inputData,
|
|
87
|
+
resolvedData: inputData,
|
|
88
|
+
item: undefined,
|
|
89
|
+
context,
|
|
90
|
+
}
|
|
91
|
+
: {
|
|
92
|
+
listKey: listName,
|
|
93
|
+
operation: 'update',
|
|
94
|
+
inputData,
|
|
95
|
+
resolvedData: inputData,
|
|
96
|
+
item,
|
|
97
|
+
context,
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
// ── Phase 1.5: field-level resolveInput (e.g. hash passwords) ──────────────
|
|
102
|
+
resolvedData = await executeFieldResolveInputHooks(
|
|
103
|
+
inputData,
|
|
104
|
+
resolvedData,
|
|
105
|
+
listConfig.fields,
|
|
106
|
+
operation,
|
|
107
|
+
context,
|
|
108
|
+
listName,
|
|
109
|
+
item,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
// ── Phase 2: list-level validate ──────────────────────────────────────────
|
|
113
|
+
await executeValidate(
|
|
114
|
+
listConfig.hooks,
|
|
115
|
+
operation === 'create'
|
|
116
|
+
? {
|
|
117
|
+
listKey: listName,
|
|
118
|
+
operation: 'create',
|
|
119
|
+
inputData,
|
|
120
|
+
resolvedData,
|
|
121
|
+
item: undefined,
|
|
122
|
+
context,
|
|
123
|
+
}
|
|
124
|
+
: {
|
|
125
|
+
listKey: listName,
|
|
126
|
+
operation: 'update',
|
|
127
|
+
inputData,
|
|
128
|
+
resolvedData,
|
|
129
|
+
item,
|
|
130
|
+
context,
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
// ── Phase 2.5: field-level validate ───────────────────────────────────────
|
|
135
|
+
await executeFieldValidateHooks(
|
|
136
|
+
inputData,
|
|
137
|
+
resolvedData,
|
|
138
|
+
listConfig.fields,
|
|
139
|
+
operation,
|
|
140
|
+
context,
|
|
141
|
+
listName,
|
|
142
|
+
item,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
// ── Phase 3: built-in field rules (isRequired, length, etc.) ──────────────
|
|
146
|
+
// Validation failures THROW (validation is not silent).
|
|
147
|
+
const validation = validateFieldRules(resolvedData, listConfig.fields, operation)
|
|
148
|
+
if (validation.errors.length > 0) {
|
|
149
|
+
throw new ValidationError(validation.errors, validation.fieldErrors)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { resolvedData }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The default Hook Pipeline instance used by the Write Pipeline.
|
|
157
|
+
*/
|
|
158
|
+
export const hookPipeline: HookPipeline = {
|
|
159
|
+
run: runHookPipeline,
|
|
160
|
+
}
|