@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,410 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import {
3
+ ValidationError,
4
+ executeFieldResolveInputHooks,
5
+ executeFieldValidateHooks,
6
+ executeFieldBeforeOperationHooks,
7
+ executeFieldAfterOperationHooks,
8
+ } from '../src/hooks/index.js'
9
+ import { text } from '../src/fields/index.js'
10
+ import type { AccessContext } from '../src/access/types.js'
11
+
12
+ const mockContext = {
13
+ session: null,
14
+ prisma: {},
15
+ db: {},
16
+ } as unknown as AccessContext
17
+
18
+ describe('Field-level hook helpers', () => {
19
+ describe('executeFieldResolveInputHooks', () => {
20
+ it('should return data unchanged when no field hook is defined', async () => {
21
+ const fields = { name: text() }
22
+ const resolvedData = { name: 'john' }
23
+
24
+ const result = await executeFieldResolveInputHooks(
25
+ { name: 'john' },
26
+ resolvedData,
27
+ fields,
28
+ 'create',
29
+ mockContext,
30
+ 'User',
31
+ )
32
+
33
+ expect(result).toEqual({ name: 'john' })
34
+ // Should not mutate the passed reference
35
+ expect(result).not.toBe(resolvedData)
36
+ })
37
+
38
+ it('should transform field values via the field resolveInput hook', async () => {
39
+ const fields = {
40
+ name: text({
41
+ hooks: {
42
+ resolveInput: ({ resolvedData, fieldKey }) =>
43
+ (resolvedData[fieldKey] as string).toUpperCase(),
44
+ },
45
+ }),
46
+ }
47
+
48
+ const result = await executeFieldResolveInputHooks(
49
+ { name: 'john' },
50
+ { name: 'john' },
51
+ fields,
52
+ 'create',
53
+ mockContext,
54
+ 'User',
55
+ )
56
+
57
+ expect(result).toEqual({ name: 'JOHN' })
58
+ })
59
+
60
+ it('should pass the correct arguments to the field hook', async () => {
61
+ const resolveInput = vi.fn(({ resolvedData, fieldKey }) => resolvedData[fieldKey])
62
+ const fields = { name: text({ hooks: { resolveInput } }) }
63
+ const item = { id: '1', name: 'old' }
64
+
65
+ await executeFieldResolveInputHooks(
66
+ { name: 'john' },
67
+ { name: 'john' },
68
+ fields,
69
+ 'update',
70
+ mockContext,
71
+ 'User',
72
+ item,
73
+ )
74
+
75
+ expect(resolveInput).toHaveBeenCalledTimes(1)
76
+ expect(resolveInput).toHaveBeenCalledWith(
77
+ expect.objectContaining({
78
+ listKey: 'User',
79
+ fieldKey: 'name',
80
+ operation: 'update',
81
+ inputData: { name: 'john' },
82
+ item,
83
+ resolvedData: { name: 'john' },
84
+ context: mockContext,
85
+ }),
86
+ )
87
+ })
88
+
89
+ it('should skip fields that are not present in the data', async () => {
90
+ const resolveInput = vi.fn(({ resolvedData, fieldKey }) => resolvedData[fieldKey])
91
+ const fields = { name: text({ hooks: { resolveInput } }) }
92
+
93
+ const result = await executeFieldResolveInputHooks(
94
+ { other: 'value' },
95
+ { other: 'value' },
96
+ fields,
97
+ 'create',
98
+ mockContext,
99
+ 'User',
100
+ )
101
+
102
+ expect(resolveInput).not.toHaveBeenCalled()
103
+ expect(result).toEqual({ other: 'value' })
104
+ })
105
+ })
106
+
107
+ describe('executeFieldValidateHooks', () => {
108
+ it('should not throw when no field hook is defined', async () => {
109
+ const fields = { name: text() }
110
+
111
+ await expect(
112
+ executeFieldValidateHooks(
113
+ { name: 'john' },
114
+ { name: 'john' },
115
+ fields,
116
+ 'create',
117
+ mockContext,
118
+ 'User',
119
+ ),
120
+ ).resolves.toBeUndefined()
121
+ })
122
+
123
+ it('should accumulate errors from multiple fields and throw a ValidationError', async () => {
124
+ const fields = {
125
+ name: text({
126
+ hooks: {
127
+ validate: ({ addValidationError }) => {
128
+ addValidationError('name is invalid')
129
+ },
130
+ },
131
+ }),
132
+ email: text({
133
+ hooks: {
134
+ validate: ({ addValidationError }) => {
135
+ addValidationError('email is invalid')
136
+ },
137
+ },
138
+ }),
139
+ }
140
+
141
+ let caught: unknown
142
+ try {
143
+ await executeFieldValidateHooks(
144
+ { name: 'x', email: 'y' },
145
+ { name: 'x', email: 'y' },
146
+ fields,
147
+ 'create',
148
+ mockContext,
149
+ 'User',
150
+ )
151
+ } catch (error) {
152
+ caught = error
153
+ }
154
+
155
+ expect(caught).toBeInstanceOf(ValidationError)
156
+ const validationError = caught as ValidationError
157
+ expect(validationError.errors).toEqual(['name is invalid', 'email is invalid'])
158
+ expect(validationError.fieldErrors).toEqual({
159
+ name: 'name is invalid',
160
+ email: 'email is invalid',
161
+ })
162
+ })
163
+
164
+ it('should not throw when no validation errors are added', async () => {
165
+ const validate = vi.fn()
166
+ const fields = { name: text({ hooks: { validate } }) }
167
+
168
+ await expect(
169
+ executeFieldValidateHooks(
170
+ { name: 'john' },
171
+ { name: 'john' },
172
+ fields,
173
+ 'create',
174
+ mockContext,
175
+ 'User',
176
+ ),
177
+ ).resolves.toBeUndefined()
178
+ expect(validate).toHaveBeenCalledTimes(1)
179
+ })
180
+
181
+ it('should pass delete-shaped arguments for delete operations', async () => {
182
+ const validate = vi.fn()
183
+ const fields = { name: text({ hooks: { validate } }) }
184
+ const item = { id: '1', name: 'john' }
185
+
186
+ await executeFieldValidateHooks(
187
+ undefined,
188
+ undefined,
189
+ fields,
190
+ 'delete',
191
+ mockContext,
192
+ 'User',
193
+ item,
194
+ )
195
+
196
+ expect(validate).toHaveBeenCalledWith(
197
+ expect.objectContaining({
198
+ listKey: 'User',
199
+ fieldKey: 'name',
200
+ operation: 'delete',
201
+ item,
202
+ context: mockContext,
203
+ }),
204
+ )
205
+ })
206
+
207
+ it('should support the deprecated validateInput hook', async () => {
208
+ const fields = {
209
+ name: text({
210
+ hooks: {
211
+ validateInput: ({ addValidationError }) => {
212
+ addValidationError('legacy error')
213
+ },
214
+ },
215
+ }),
216
+ }
217
+
218
+ await expect(
219
+ executeFieldValidateHooks(
220
+ { name: 'x' },
221
+ { name: 'x' },
222
+ fields,
223
+ 'create',
224
+ mockContext,
225
+ 'User',
226
+ ),
227
+ ).rejects.toBeInstanceOf(ValidationError)
228
+ })
229
+ })
230
+
231
+ describe('executeFieldBeforeOperationHooks', () => {
232
+ it('should do nothing when no field hook is defined', async () => {
233
+ const fields = { name: text() }
234
+
235
+ await expect(
236
+ executeFieldBeforeOperationHooks(
237
+ { name: 'john' },
238
+ { name: 'john' },
239
+ fields,
240
+ 'create',
241
+ mockContext,
242
+ 'User',
243
+ ),
244
+ ).resolves.toBeUndefined()
245
+ })
246
+
247
+ it('should call the hook with create-shaped arguments', async () => {
248
+ const beforeOperation = vi.fn()
249
+ const fields = { name: text({ hooks: { beforeOperation } }) }
250
+
251
+ await executeFieldBeforeOperationHooks(
252
+ { name: 'john' },
253
+ { name: 'john' },
254
+ fields,
255
+ 'create',
256
+ mockContext,
257
+ 'User',
258
+ )
259
+
260
+ expect(beforeOperation).toHaveBeenCalledWith(
261
+ expect.objectContaining({
262
+ listKey: 'User',
263
+ fieldKey: 'name',
264
+ operation: 'create',
265
+ inputData: { name: 'john' },
266
+ resolvedData: { name: 'john' },
267
+ context: mockContext,
268
+ }),
269
+ )
270
+ })
271
+
272
+ it('should skip fields not present in resolvedData for create/update', async () => {
273
+ const beforeOperation = vi.fn()
274
+ const fields = { name: text({ hooks: { beforeOperation } }) }
275
+
276
+ await executeFieldBeforeOperationHooks(
277
+ { other: 'value' },
278
+ { other: 'value' },
279
+ fields,
280
+ 'update',
281
+ mockContext,
282
+ 'User',
283
+ )
284
+
285
+ expect(beforeOperation).not.toHaveBeenCalled()
286
+ })
287
+
288
+ it('should run the hook for delete even when the field is not in resolvedData', async () => {
289
+ const beforeOperation = vi.fn()
290
+ const fields = { name: text({ hooks: { beforeOperation } }) }
291
+ const item = { id: '1', name: 'john' }
292
+
293
+ await executeFieldBeforeOperationHooks({}, {}, fields, 'delete', mockContext, 'User', item)
294
+
295
+ expect(beforeOperation).toHaveBeenCalledWith(
296
+ expect.objectContaining({
297
+ listKey: 'User',
298
+ fieldKey: 'name',
299
+ operation: 'delete',
300
+ item,
301
+ context: mockContext,
302
+ }),
303
+ )
304
+ })
305
+ })
306
+
307
+ describe('executeFieldAfterOperationHooks', () => {
308
+ it('should do nothing when no field hook is defined', async () => {
309
+ const fields = { name: text() }
310
+
311
+ await expect(
312
+ executeFieldAfterOperationHooks(
313
+ { id: '1', name: 'john' },
314
+ { name: 'john' },
315
+ { name: 'john' },
316
+ fields,
317
+ 'create',
318
+ mockContext,
319
+ 'User',
320
+ ),
321
+ ).resolves.toBeUndefined()
322
+ })
323
+
324
+ it('should call the hook with create-shaped arguments', async () => {
325
+ const afterOperation = vi.fn()
326
+ const fields = { name: text({ hooks: { afterOperation } }) }
327
+ const item = { id: '1', name: 'john' }
328
+
329
+ await executeFieldAfterOperationHooks(
330
+ item,
331
+ { name: 'john' },
332
+ { name: 'john' },
333
+ fields,
334
+ 'create',
335
+ mockContext,
336
+ 'User',
337
+ )
338
+
339
+ expect(afterOperation).toHaveBeenCalledWith(
340
+ expect.objectContaining({
341
+ listKey: 'User',
342
+ fieldKey: 'name',
343
+ operation: 'create',
344
+ inputData: { name: 'john' },
345
+ item,
346
+ resolvedData: { name: 'john' },
347
+ context: mockContext,
348
+ }),
349
+ )
350
+ })
351
+
352
+ it('should pass originalItem for update operations', async () => {
353
+ const afterOperation = vi.fn()
354
+ const fields = { name: text({ hooks: { afterOperation } }) }
355
+ const updated = { id: '1', name: 'new' }
356
+ const originalItem = { id: '1', name: 'old' }
357
+
358
+ await executeFieldAfterOperationHooks(
359
+ updated,
360
+ { name: 'new' },
361
+ { name: 'new' },
362
+ fields,
363
+ 'update',
364
+ mockContext,
365
+ 'User',
366
+ originalItem,
367
+ )
368
+
369
+ expect(afterOperation).toHaveBeenCalledWith(
370
+ expect.objectContaining({
371
+ listKey: 'User',
372
+ fieldKey: 'name',
373
+ operation: 'update',
374
+ inputData: { name: 'new' },
375
+ originalItem,
376
+ item: updated,
377
+ resolvedData: { name: 'new' },
378
+ context: mockContext,
379
+ }),
380
+ )
381
+ })
382
+
383
+ it('should pass delete-shaped arguments for delete operations', async () => {
384
+ const afterOperation = vi.fn()
385
+ const fields = { name: text({ hooks: { afterOperation } }) }
386
+ const originalItem = { id: '1', name: 'john' }
387
+
388
+ await executeFieldAfterOperationHooks(
389
+ originalItem,
390
+ undefined,
391
+ undefined,
392
+ fields,
393
+ 'delete',
394
+ mockContext,
395
+ 'User',
396
+ originalItem,
397
+ )
398
+
399
+ expect(afterOperation).toHaveBeenCalledWith(
400
+ expect.objectContaining({
401
+ listKey: 'User',
402
+ fieldKey: 'name',
403
+ operation: 'delete',
404
+ originalItem,
405
+ context: mockContext,
406
+ }),
407
+ )
408
+ })
409
+ })
410
+ })
@@ -580,7 +580,7 @@ describe('Field Types', () => {
580
580
 
581
581
  expect(field.resultExtension).toBeDefined()
582
582
  expect(field.resultExtension?.outputType).toBe(
583
- "import('@opensaas/stack-core').HashedPassword",
583
+ "import('@opensaas/stack-core/internal').HashedPassword",
584
584
  )
585
585
  })
586
586
  })
@@ -0,0 +1,233 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { hookPipeline } from '../src/context/hook-pipeline.js'
3
+ import { ValidationError } from '../src/hooks/index.js'
4
+ import { text } from '../src/fields/index.js'
5
+ import type { ListConfig } from '../src/config/types.js'
6
+ import type { AccessContext } from '../src/access/types.js'
7
+
8
+ /**
9
+ * Unit tests for the Hook Pipeline — the module that owns the transform+validate
10
+ * span: list `resolveInput` → field `resolveInput` → list `validate` → field
11
+ * `validate` → built-in field rules (`validateFieldRules`). These drive
12
+ * `hookPipeline.run` directly (spy hooks + a list config), so the span order and
13
+ * the resolvedData threading become the test surface.
14
+ *
15
+ * Asserted here:
16
+ * - the four hook phases + field rules run in the documented order;
17
+ * - a list/field `validate` hook calling `addValidationError` THROWS
18
+ * ValidationError (validation is never silent);
19
+ * - a built-in field rule failure (isRequired) THROWS ValidationError;
20
+ * - on success the pipeline returns the transformed `resolvedData`.
21
+ */
22
+
23
+ // Shared ordered log of phase events for order assertions.
24
+ let events: string[]
25
+
26
+ /**
27
+ * Build a minimal AccessContext for the pipeline.
28
+ */
29
+ function makeContext(): AccessContext {
30
+ return {
31
+ session: { userId: 'u1' },
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
+ prisma: {} as any,
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ db: {} as any,
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ storage: {} as any,
38
+ plugins: {},
39
+ _isSudo: false,
40
+ _resolveOutputCounter: { depth: 0 },
41
+ }
42
+ }
43
+
44
+ /**
45
+ * A list config with a spy on every span hook, recording its phase into
46
+ * `events`. `title` is a required field so we can exercise built-in field rules.
47
+ */
48
+ function makeListConfig(opts?: {
49
+ listValidateError?: string
50
+ fieldValidateError?: string
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ }): ListConfig<any> {
53
+ return {
54
+ fields: {
55
+ title: text({
56
+ validation: { isRequired: true },
57
+ hooks: {
58
+ resolveInput: async ({ resolvedData, fieldKey }) => {
59
+ events.push('field:resolveInput')
60
+ // Uppercase the value so we can assert resolvedData threads through.
61
+ const value = resolvedData[fieldKey]
62
+ return typeof value === 'string' ? value.toUpperCase() : value
63
+ },
64
+ validate: async ({ addValidationError }) => {
65
+ events.push('field:validate')
66
+ if (opts?.fieldValidateError) addValidationError(opts.fieldValidateError)
67
+ },
68
+ },
69
+ }),
70
+ },
71
+ access: {
72
+ operation: { query: () => true, create: () => true, update: () => true },
73
+ },
74
+ hooks: {
75
+ resolveInput: async ({ resolvedData }) => {
76
+ events.push('list:resolveInput')
77
+ return resolvedData
78
+ },
79
+ validate: async ({ addValidationError }) => {
80
+ events.push('list:validate')
81
+ if (opts?.listValidateError) addValidationError(opts.listValidateError)
82
+ },
83
+ },
84
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
85
+ } as ListConfig<any>
86
+ }
87
+
88
+ beforeEach(() => {
89
+ events = []
90
+ })
91
+
92
+ describe('Hook Pipeline — span order', () => {
93
+ it('runs create span phases in order: list resolveInput → field resolveInput → list validate → field validate → field rules', async () => {
94
+ const listConfig = makeListConfig()
95
+ const context = makeContext()
96
+
97
+ const { resolvedData } = await hookPipeline.run({
98
+ operation: 'create',
99
+ listName: 'Post',
100
+ listConfig,
101
+ inputData: { title: 'hi' },
102
+ item: undefined,
103
+ context,
104
+ })
105
+
106
+ expect(events).toEqual([
107
+ 'list:resolveInput',
108
+ 'field:resolveInput',
109
+ 'list:validate',
110
+ 'field:validate',
111
+ ])
112
+ expect(events.indexOf('list:resolveInput')).toBeLessThan(events.indexOf('field:resolveInput'))
113
+ expect(events.indexOf('field:resolveInput')).toBeLessThan(events.indexOf('list:validate'))
114
+ expect(events.indexOf('list:validate')).toBeLessThan(events.indexOf('field:validate'))
115
+ // resolvedData threads through the field resolveInput transform.
116
+ expect(resolvedData).toEqual({ title: 'HI' })
117
+ })
118
+
119
+ it('runs update span phases in order and passes the existing item through', async () => {
120
+ const listConfig = makeListConfig()
121
+ const context = makeContext()
122
+ const existing = { id: '1', title: 'old' }
123
+
124
+ const { resolvedData } = await hookPipeline.run({
125
+ operation: 'update',
126
+ listName: 'Post',
127
+ listConfig,
128
+ inputData: { title: 'new' },
129
+ item: existing,
130
+ context,
131
+ })
132
+
133
+ expect(events).toEqual([
134
+ 'list:resolveInput',
135
+ 'field:resolveInput',
136
+ 'list:validate',
137
+ 'field:validate',
138
+ ])
139
+ expect(resolvedData).toEqual({ title: 'NEW' })
140
+ })
141
+ })
142
+
143
+ describe('Hook Pipeline — validation throws (NOT silent)', () => {
144
+ it('throws ValidationError when a list validate hook calls addValidationError', async () => {
145
+ const listConfig = makeListConfig({ listValidateError: 'list says no' })
146
+ const context = makeContext()
147
+
148
+ await expect(
149
+ hookPipeline.run({
150
+ operation: 'create',
151
+ listName: 'Post',
152
+ listConfig,
153
+ inputData: { title: 'hi' },
154
+ item: undefined,
155
+ context,
156
+ }),
157
+ ).rejects.toBeInstanceOf(ValidationError)
158
+ // field:validate and field rules never run after a list validate failure.
159
+ expect(events).not.toContain('field:validate')
160
+ })
161
+
162
+ it('throws ValidationError when a field validate hook calls addValidationError', async () => {
163
+ const listConfig = makeListConfig({ fieldValidateError: 'field says no' })
164
+ const context = makeContext()
165
+
166
+ await expect(
167
+ hookPipeline.run({
168
+ operation: 'create',
169
+ listName: 'Post',
170
+ listConfig,
171
+ inputData: { title: 'hi' },
172
+ item: undefined,
173
+ context,
174
+ }),
175
+ ).rejects.toBeInstanceOf(ValidationError)
176
+ expect(events).toContain('field:validate')
177
+ })
178
+
179
+ it('throws ValidationError when a built-in field rule (isRequired) fails', async () => {
180
+ const listConfig = makeListConfig()
181
+ const context = makeContext()
182
+
183
+ await expect(
184
+ hookPipeline.run({
185
+ operation: 'create',
186
+ listName: 'Post',
187
+ listConfig,
188
+ inputData: {}, // title is required but absent
189
+ item: undefined,
190
+ context,
191
+ }),
192
+ ).rejects.toBeInstanceOf(ValidationError)
193
+ // The validate hooks ran before the rules threw.
194
+ expect(events).toContain('list:validate')
195
+ expect(events).toContain('field:validate')
196
+ })
197
+ })
198
+
199
+ describe('Hook Pipeline — resolvedData threading', () => {
200
+ it('returns the transformed resolvedData merged with list-level resolveInput changes', async () => {
201
+ const context = makeContext()
202
+ // List resolveInput injects an extra field; field resolveInput uppercases title.
203
+ const listConfig = {
204
+ fields: {
205
+ title: text({
206
+ hooks: {
207
+ resolveInput: async ({ resolvedData, fieldKey }) => {
208
+ const value = resolvedData[fieldKey]
209
+ return typeof value === 'string' ? value.toUpperCase() : value
210
+ },
211
+ },
212
+ }),
213
+ slug: text(),
214
+ },
215
+ access: { operation: { query: () => true, create: () => true } },
216
+ hooks: {
217
+ resolveInput: async ({ resolvedData }) => ({ ...resolvedData, slug: 'auto-slug' }),
218
+ },
219
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
220
+ } as ListConfig<any>
221
+
222
+ const { resolvedData } = await hookPipeline.run({
223
+ operation: 'create',
224
+ listName: 'Post',
225
+ listConfig,
226
+ inputData: { title: 'hello' },
227
+ item: undefined,
228
+ context,
229
+ })
230
+
231
+ expect(resolvedData).toEqual({ title: 'HELLO', slug: 'auto-slug' })
232
+ })
233
+ })