@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.
Files changed (105) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +72 -0
  3. package/CLAUDE.md +18 -2
  4. package/dist/access/access-filter.d.ts +29 -0
  5. package/dist/access/access-filter.d.ts.map +1 -0
  6. package/dist/access/access-filter.js +68 -0
  7. package/dist/access/access-filter.js.map +1 -0
  8. package/dist/access/engine.d.ts +15 -48
  9. package/dist/access/engine.d.ts.map +1 -1
  10. package/dist/access/engine.js +14 -280
  11. package/dist/access/engine.js.map +1 -1
  12. package/dist/access/field-access.d.ts +44 -0
  13. package/dist/access/field-access.d.ts.map +1 -0
  14. package/dist/access/field-access.js +123 -0
  15. package/dist/access/field-access.js.map +1 -0
  16. package/dist/access/field-access.test.d.ts +2 -0
  17. package/dist/access/field-access.test.d.ts.map +1 -0
  18. package/dist/access/{engine.test.js → field-access.test.js} +2 -2
  19. package/dist/access/field-access.test.js.map +1 -0
  20. package/dist/access/field-visibility.d.ts +13 -0
  21. package/dist/access/field-visibility.d.ts.map +1 -0
  22. package/dist/access/field-visibility.js +155 -0
  23. package/dist/access/field-visibility.js.map +1 -0
  24. package/dist/access/index.d.ts +4 -1
  25. package/dist/access/index.d.ts.map +1 -1
  26. package/dist/access/index.js +8 -1
  27. package/dist/access/index.js.map +1 -1
  28. package/dist/config/index.d.ts +1 -1
  29. package/dist/config/index.d.ts.map +1 -1
  30. package/dist/config/types.d.ts +45 -4
  31. package/dist/config/types.d.ts.map +1 -1
  32. package/dist/context/hook-pipeline.d.ts +49 -0
  33. package/dist/context/hook-pipeline.d.ts.map +1 -0
  34. package/dist/context/hook-pipeline.js +75 -0
  35. package/dist/context/hook-pipeline.js.map +1 -0
  36. package/dist/context/index.d.ts.map +1 -1
  37. package/dist/context/index.js +30 -462
  38. package/dist/context/index.js.map +1 -1
  39. package/dist/context/nested-operations.d.ts.map +1 -1
  40. package/dist/context/nested-operations.js +72 -68
  41. package/dist/context/nested-operations.js.map +1 -1
  42. package/dist/context/write-pipeline.d.ts +158 -0
  43. package/dist/context/write-pipeline.d.ts.map +1 -0
  44. package/dist/context/write-pipeline.js +306 -0
  45. package/dist/context/write-pipeline.js.map +1 -0
  46. package/dist/extend.d.ts +3 -0
  47. package/dist/extend.d.ts.map +1 -0
  48. package/dist/extend.js +10 -0
  49. package/dist/extend.js.map +1 -0
  50. package/dist/fields/index.d.ts +1 -0
  51. package/dist/fields/index.d.ts.map +1 -1
  52. package/dist/fields/index.js +213 -2
  53. package/dist/fields/index.js.map +1 -1
  54. package/dist/hooks/index.d.ts +20 -0
  55. package/dist/hooks/index.d.ts.map +1 -1
  56. package/dist/hooks/index.js +202 -0
  57. package/dist/hooks/index.js.map +1 -1
  58. package/dist/index.d.ts +5 -9
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +19 -10
  61. package/dist/index.js.map +1 -1
  62. package/dist/internal.d.ts +8 -0
  63. package/dist/internal.d.ts.map +1 -0
  64. package/dist/internal.js +16 -0
  65. package/dist/internal.js.map +1 -0
  66. package/dist/validation/field-config.d.ts +55 -0
  67. package/dist/validation/field-config.d.ts.map +1 -0
  68. package/dist/validation/field-config.js +100 -0
  69. package/dist/validation/field-config.js.map +1 -0
  70. package/dist/validation/field-config.test.d.ts +2 -0
  71. package/dist/validation/field-config.test.d.ts.map +1 -0
  72. package/dist/validation/field-config.test.js +159 -0
  73. package/dist/validation/field-config.test.js.map +1 -0
  74. package/package.json +11 -3
  75. package/src/access/access-filter.ts +97 -0
  76. package/src/access/engine.ts +13 -396
  77. package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
  78. package/src/access/field-access.ts +159 -0
  79. package/src/access/field-visibility.ts +247 -0
  80. package/src/access/index.ts +7 -4
  81. package/src/config/index.ts +1 -0
  82. package/src/config/types.ts +51 -4
  83. package/src/context/hook-pipeline.ts +160 -0
  84. package/src/context/index.ts +29 -667
  85. package/src/context/nested-operations.ts +142 -111
  86. package/src/context/write-pipeline.ts +543 -0
  87. package/src/extend.ts +14 -0
  88. package/src/fields/index.ts +310 -2
  89. package/src/hooks/index.ts +227 -0
  90. package/src/index.ts +27 -90
  91. package/src/internal.ts +49 -0
  92. package/src/validation/field-config.test.ts +199 -0
  93. package/src/validation/field-config.ts +145 -0
  94. package/tests/access-relationships.test.ts +4 -4
  95. package/tests/access.test.ts +1 -1
  96. package/tests/field-hooks.test.ts +410 -0
  97. package/tests/field-types.test.ts +1 -1
  98. package/tests/hook-pipeline.test.ts +233 -0
  99. package/tests/nested-operation-registry.test.ts +206 -0
  100. package/tests/write-pipeline.test.ts +588 -0
  101. package/tsconfig.tsbuildinfo +1 -1
  102. package/vitest.config.ts +43 -1
  103. package/dist/access/engine.test.d.ts +0 -2
  104. package/dist/access/engine.test.d.ts.map +0 -1
  105. 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
+ }
@@ -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'
@@ -134,6 +134,7 @@ export type {
134
134
  PasswordField,
135
135
  SelectField,
136
136
  RelationshipField,
137
+ PrismaRelationResult,
137
138
  JsonField,
138
139
  VirtualField,
139
140
  TypeDescriptor,
@@ -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 { PrismaBetterSQLite3 } from '@prisma/adapter-better-sqlite3'
1225
- * import Database from 'better-sqlite3'
1273
+ * import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
1226
1274
  *
1227
1275
  * prismaClientConstructor: (PrismaClient) => {
1228
- * const db = new Database(process.env.DATABASE_URL || './dev.db')
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
+ }