@opensaas/stack-core 0.12.1 → 0.14.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 (46) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +291 -0
  3. package/README.md +6 -3
  4. package/dist/access/engine.d.ts +2 -0
  5. package/dist/access/engine.d.ts.map +1 -1
  6. package/dist/access/engine.js +8 -6
  7. package/dist/access/engine.js.map +1 -1
  8. package/dist/access/engine.test.js +4 -0
  9. package/dist/access/engine.test.js.map +1 -1
  10. package/dist/access/types.d.ts +31 -4
  11. package/dist/access/types.d.ts.map +1 -1
  12. package/dist/config/index.d.ts +12 -10
  13. package/dist/config/index.d.ts.map +1 -1
  14. package/dist/config/index.js +37 -1
  15. package/dist/config/index.js.map +1 -1
  16. package/dist/config/types.d.ts +341 -82
  17. package/dist/config/types.d.ts.map +1 -1
  18. package/dist/context/index.d.ts.map +1 -1
  19. package/dist/context/index.js +330 -60
  20. package/dist/context/index.js.map +1 -1
  21. package/dist/context/nested-operations.d.ts.map +1 -1
  22. package/dist/context/nested-operations.js +38 -25
  23. package/dist/context/nested-operations.js.map +1 -1
  24. package/dist/hooks/index.d.ts +45 -7
  25. package/dist/hooks/index.d.ts.map +1 -1
  26. package/dist/hooks/index.js +10 -4
  27. package/dist/hooks/index.js.map +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/access/engine.test.ts +4 -0
  33. package/src/access/engine.ts +10 -7
  34. package/src/access/types.ts +45 -4
  35. package/src/config/index.ts +65 -9
  36. package/src/config/types.ts +402 -91
  37. package/src/context/index.ts +421 -82
  38. package/src/context/nested-operations.ts +40 -25
  39. package/src/hooks/index.ts +66 -14
  40. package/src/index.ts +11 -0
  41. package/tests/access.test.ts +28 -28
  42. package/tests/config.test.ts +20 -3
  43. package/tests/nested-access-and-hooks.test.ts +8 -3
  44. package/tests/singleton.test.ts +329 -0
  45. package/tests/sudo.test.ts +2 -13
  46. package/tsconfig.tsbuildinfo +1 -1
@@ -3,7 +3,7 @@ import type { AccessContext } from '../access/types.js'
3
3
  import { checkAccess, filterWritableFields, getRelatedListConfig } from '../access/index.js'
4
4
  import {
5
5
  executeResolveInput,
6
- executeValidateInput,
6
+ executeValidate,
7
7
  validateFieldRules,
8
8
  ValidationError,
9
9
  } from '../hooks/index.js'
@@ -15,7 +15,9 @@ import { getDbKey } from '../lib/case-utils.js'
15
15
  */
16
16
  async function executeFieldResolveInputHooks(
17
17
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
- data: Record<string, any>,
18
+ inputData: Record<string, any>,
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ resolvedData: Record<string, any>,
19
21
  fields: Record<string, FieldConfig>,
20
22
  operation: 'create' | 'update',
21
23
  context: AccessContext,
@@ -23,26 +25,28 @@ async function executeFieldResolveInputHooks(
23
25
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
26
  item?: any,
25
27
  ): Promise<Record<string, unknown>> {
26
- const result = { ...data }
28
+ let result = { ...resolvedData }
27
29
 
28
- for (const [fieldName, fieldConfig] of Object.entries(fields)) {
30
+ for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
29
31
  // Skip if field not in data
30
- if (!(fieldName in result)) continue
32
+ if (!(fieldKey in result)) continue
31
33
 
32
34
  // Skip if no hooks defined
33
35
  if (!fieldConfig.hooks?.resolveInput) continue
34
36
 
35
37
  // Execute field hook
36
38
  const transformedValue = await fieldConfig.hooks.resolveInput({
37
- inputValue: result[fieldName],
38
- operation,
39
- fieldName,
40
39
  listKey,
40
+ fieldKey,
41
+ operation,
42
+ inputData,
41
43
  item,
44
+ resolvedData: { ...result }, // Pass a copy to avoid mutation affecting recorded args
42
45
  context,
43
- })
46
+ } as Parameters<typeof fieldConfig.hooks.resolveInput>[0])
44
47
 
45
- result[fieldName] = transformedValue
48
+ // Create new object with updated field to avoid mutating the passed reference
49
+ result = { ...result, [fieldKey]: transformedValue }
46
50
  }
47
51
 
48
52
  return result
@@ -83,17 +87,7 @@ async function processNestedCreate(
83
87
  }
84
88
  }
85
89
 
86
- // 2. Execute list-level resolveInput hook
87
- let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
88
- operation: 'create',
89
- resolvedData: item,
90
- item: undefined,
91
- context,
92
- })
93
-
94
- // 2.5. Execute field-level resolveInput hooks
95
- // We need to get the list name for this related config
96
- // Since we don't have it directly, we'll need to find it from the config
90
+ // 2. Get the list name for this related config
97
91
  let relatedListName = ''
98
92
  for (const [listKey, listCfg] of Object.entries(config.lists)) {
99
93
  if (listCfg === relatedListConfig) {
@@ -102,7 +96,19 @@ async function processNestedCreate(
102
96
  }
103
97
  }
104
98
 
99
+ // 3. Execute list-level resolveInput hook
100
+ let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
101
+ listKey: relatedListName,
102
+ operation: 'create',
103
+ inputData: item,
104
+ resolvedData: item,
105
+ item: undefined,
106
+ context,
107
+ })
108
+
109
+ // 4. Execute field-level resolveInput hooks
105
110
  resolvedData = await executeFieldResolveInputHooks(
111
+ item,
106
112
  resolvedData,
107
113
  relatedListConfig.fields,
108
114
  'create',
@@ -110,9 +116,11 @@ async function processNestedCreate(
110
116
  relatedListName,
111
117
  )
112
118
 
113
- // 3. Execute validateInput hook
114
- await executeValidateInput(relatedListConfig.hooks, {
119
+ // 5. Execute validate hook
120
+ await executeValidate(relatedListConfig.hooks, {
121
+ listKey: relatedListName,
115
122
  operation: 'create',
123
+ inputData: item,
116
124
  resolvedData,
117
125
  item: undefined,
118
126
  context,
@@ -132,6 +140,7 @@ async function processNestedCreate(
132
140
  {
133
141
  session: context.session,
134
142
  context,
143
+ inputData: item,
135
144
  },
136
145
  )
137
146
 
@@ -257,7 +266,9 @@ async function processNestedUpdate(
257
266
  // Execute list-level resolveInput hook
258
267
  const updateData = (update as Record<string, unknown>).data as Record<string, unknown>
259
268
  let resolvedData = await executeResolveInput(relatedListConfig.hooks, {
269
+ listKey: relatedListName,
260
270
  operation: 'update',
271
+ inputData: updateData,
261
272
  resolvedData: updateData,
262
273
  item,
263
274
  context,
@@ -265,6 +276,7 @@ async function processNestedUpdate(
265
276
 
266
277
  // Execute field-level resolveInput hooks
267
278
  resolvedData = await executeFieldResolveInputHooks(
279
+ updateData,
268
280
  resolvedData,
269
281
  relatedListConfig.fields,
270
282
  'update',
@@ -273,9 +285,11 @@ async function processNestedUpdate(
273
285
  item,
274
286
  )
275
287
 
276
- // Execute validateInput hook
277
- await executeValidateInput(relatedListConfig.hooks, {
288
+ // Execute validate hook
289
+ await executeValidate(relatedListConfig.hooks, {
290
+ listKey: relatedListName,
278
291
  operation: 'update',
292
+ inputData: updateData,
279
293
  resolvedData,
280
294
  item,
281
295
  context,
@@ -296,6 +310,7 @@ async function processNestedUpdate(
296
310
  session: context.session,
297
311
  item,
298
312
  context,
313
+ inputData: updateData,
299
314
  },
300
315
  )
301
316
 
@@ -46,13 +46,17 @@ export async function executeResolveInput<
46
46
  hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
47
47
  args:
48
48
  | {
49
+ listKey: string
49
50
  operation: 'create'
51
+ inputData: TCreateInput
50
52
  resolvedData: TCreateInput
51
53
  item: undefined
52
54
  context: AccessContext
53
55
  }
54
56
  | {
57
+ listKey: string
55
58
  operation: 'update'
59
+ inputData: TUpdateInput
56
60
  resolvedData: TUpdateInput
57
61
  item: TOutput
58
62
  context: AccessContext
@@ -67,10 +71,10 @@ export async function executeResolveInput<
67
71
  }
68
72
 
69
73
  /**
70
- * Execute validateInput hook
74
+ * Execute validate hook (supports both 'validate' and deprecated 'validateInput')
71
75
  * Allows custom validation logic
72
76
  */
73
- export async function executeValidateInput<
77
+ export async function executeValidate<
74
78
  TOutput = Record<string, unknown>,
75
79
  TCreateInput = Record<string, unknown>,
76
80
  TUpdateInput = Record<string, unknown>,
@@ -78,19 +82,31 @@ export async function executeValidateInput<
78
82
  hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
79
83
  args:
80
84
  | {
85
+ listKey: string
81
86
  operation: 'create'
87
+ inputData: TCreateInput
82
88
  resolvedData: TCreateInput
83
89
  item: undefined
84
90
  context: AccessContext
85
91
  }
86
92
  | {
93
+ listKey: string
87
94
  operation: 'update'
95
+ inputData: TUpdateInput
88
96
  resolvedData: TUpdateInput
89
97
  item: TOutput
90
98
  context: AccessContext
99
+ }
100
+ | {
101
+ listKey: string
102
+ operation: 'delete'
103
+ item: TOutput
104
+ context: AccessContext
91
105
  },
92
106
  ): Promise<void> {
93
- if (!hooks?.validateInput) {
107
+ // Support both 'validate' (new) and 'validateInput' (deprecated) for backwards compatibility
108
+ const validateHook = hooks?.validate || hooks?.validateInput
109
+ if (!validateHook) {
94
110
  return
95
111
  }
96
112
 
@@ -100,29 +116,50 @@ export async function executeValidateInput<
100
116
  errors.push(msg)
101
117
  }
102
118
 
103
- await hooks.validateInput({
119
+ await validateHook({
104
120
  ...args,
105
121
  addValidationError,
106
- })
122
+ } as Parameters<typeof validateHook>[0])
107
123
 
108
124
  if (errors.length > 0) {
109
125
  throw new ValidationError(errors)
110
126
  }
111
127
  }
112
128
 
129
+ /**
130
+ * @deprecated Use executeValidate instead. This alias is provided for backwards compatibility.
131
+ */
132
+ export const executeValidateInput = executeValidate
133
+
113
134
  /**
114
135
  * Execute beforeOperation hook
115
136
  * Runs before database operation (cannot modify data)
116
137
  */
117
- export async function executeBeforeOperation<TOutput = Record<string, unknown>>(
118
- hooks: Hooks<TOutput> | undefined,
138
+ export async function executeBeforeOperation<
139
+ TOutput = Record<string, unknown>,
140
+ TCreateInput = Record<string, unknown>,
141
+ TUpdateInput = Record<string, unknown>,
142
+ >(
143
+ hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
119
144
  args:
120
145
  | {
146
+ listKey: string
121
147
  operation: 'create'
148
+ inputData: TCreateInput
149
+ resolvedData: TCreateInput
122
150
  context: AccessContext
123
151
  }
124
152
  | {
125
- operation: 'update' | 'delete'
153
+ listKey: string
154
+ operation: 'update'
155
+ inputData: TUpdateInput
156
+ item: TOutput
157
+ resolvedData: TUpdateInput
158
+ context: AccessContext
159
+ }
160
+ | {
161
+ listKey: string
162
+ operation: 'delete'
126
163
  item: TOutput
127
164
  context: AccessContext
128
165
  },
@@ -131,25 +168,40 @@ export async function executeBeforeOperation<TOutput = Record<string, unknown>>(
131
168
  return
132
169
  }
133
170
 
134
- await hooks.beforeOperation(args)
171
+ await hooks.beforeOperation(args as Parameters<typeof hooks.beforeOperation>[0])
135
172
  }
136
173
 
137
174
  /**
138
175
  * Execute afterOperation hook
139
176
  * Runs after database operation
140
177
  */
141
- export async function executeAfterOperation<TOutput = Record<string, unknown>>(
142
- hooks: Hooks<TOutput> | undefined,
178
+ export async function executeAfterOperation<
179
+ TOutput = Record<string, unknown>,
180
+ TCreateInput = Record<string, unknown>,
181
+ TUpdateInput = Record<string, unknown>,
182
+ >(
183
+ hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
143
184
  args:
144
185
  | {
186
+ listKey: string
145
187
  operation: 'create'
188
+ inputData: TCreateInput
146
189
  item: TOutput
147
- originalItem: undefined
190
+ resolvedData: TCreateInput
148
191
  context: AccessContext
149
192
  }
150
193
  | {
151
- operation: 'update' | 'delete'
194
+ listKey: string
195
+ operation: 'update'
196
+ inputData: TUpdateInput
197
+ originalItem: TOutput
152
198
  item: TOutput
199
+ resolvedData: TUpdateInput
200
+ context: AccessContext
201
+ }
202
+ | {
203
+ listKey: string
204
+ operation: 'delete'
153
205
  originalItem: TOutput
154
206
  context: AccessContext
155
207
  },
@@ -158,7 +210,7 @@ export async function executeAfterOperation<TOutput = Record<string, unknown>>(
158
210
  return
159
211
  }
160
212
 
161
- await hooks.afterOperation(args)
213
+ await hooks.afterOperation(args as Parameters<typeof hooks.afterOperation>[0])
162
214
  }
163
215
 
164
216
  /**
package/src/index.ts CHANGED
@@ -38,6 +38,17 @@ export type {
38
38
  Plugin,
39
39
  PluginContext,
40
40
  GeneratedFiles,
41
+ // List-level hook argument types
42
+ ResolveInputHookArgs,
43
+ ValidateHookArgs,
44
+ BeforeOperationHookArgs,
45
+ AfterOperationHookArgs,
46
+ // Field-level hook argument types
47
+ FieldResolveInputHookArgs,
48
+ FieldValidateHookArgs,
49
+ FieldBeforeOperationHookArgs,
50
+ FieldAfterOperationHookArgs,
51
+ FieldResolveOutputHookArgs,
41
52
  } from './config/index.js'
42
53
 
43
54
  // Access control
@@ -192,60 +192,57 @@ describe('Access Control', () => {
192
192
  expect(result).toBe(true)
193
193
  })
194
194
 
195
- it('should check filter match when operation returns filter', async () => {
196
- const item = { userId: '123' }
195
+ it('should receive inputData for create operations', async () => {
196
+ const inputData = { title: 'Test', authorId: '123' }
197
197
  const fieldAccess: FieldAccess = {
198
- read: vi.fn(async () => ({ userId: '123' })),
198
+ create: vi.fn(async ({ inputData: data }) => {
199
+ // Field access can validate inputData
200
+ return data?.authorId === '123'
201
+ }),
199
202
  }
200
203
 
201
- const result = await checkFieldAccess(fieldAccess, 'read', {
204
+ const result = await checkFieldAccess(fieldAccess, 'create', {
202
205
  session: null,
203
- item,
204
206
  context: mockContext,
207
+ inputData,
205
208
  })
206
209
 
207
210
  expect(result).toBe(true)
211
+ expect(fieldAccess.create).toHaveBeenCalledWith(expect.objectContaining({ inputData }))
208
212
  })
209
213
 
210
- it('should deny access when filter does not match', async () => {
211
- const item = { userId: '456' }
212
- const fieldAccess: FieldAccess = {
213
- read: vi.fn(async () => ({ userId: '123' })),
214
- }
215
-
216
- const result = await checkFieldAccess(fieldAccess, 'read', {
217
- session: null,
218
- item,
219
- context: mockContext,
220
- })
221
-
222
- expect(result).toBe(false)
223
- })
224
-
225
- it('should work with equals condition', async () => {
226
- const item = { status: 'active' }
214
+ it('should receive inputData for update operations', async () => {
215
+ const inputData = { title: 'Updated', authorId: '123' }
216
+ const item = { id: '1', authorId: '123' }
227
217
  const fieldAccess: FieldAccess = {
228
- read: vi.fn(async () => ({ status: { equals: 'active' } })),
218
+ update: vi.fn(async ({ inputData: data, item: existingItem }) => {
219
+ // Field access can validate inputData and check existing item
220
+ return data?.authorId === existingItem?.authorId
221
+ }),
229
222
  }
230
223
 
231
- const result = await checkFieldAccess(fieldAccess, 'read', {
224
+ const result = await checkFieldAccess(fieldAccess, 'update', {
232
225
  session: null,
233
226
  item,
234
227
  context: mockContext,
228
+ inputData,
235
229
  })
236
230
 
237
231
  expect(result).toBe(true)
232
+ expect(fieldAccess.update).toHaveBeenCalledWith(expect.objectContaining({ inputData, item }))
238
233
  })
239
234
 
240
- it('should work with not condition', async () => {
241
- const item = { status: 'active' }
235
+ it('should not receive inputData for read operations', async () => {
242
236
  const fieldAccess: FieldAccess = {
243
- read: vi.fn(async () => ({ status: { not: 'deleted' } })),
237
+ read: vi.fn(async ({ inputData }) => {
238
+ // inputData should be undefined for read operations
239
+ expect(inputData).toBeUndefined()
240
+ return true
241
+ }),
244
242
  }
245
243
 
246
244
  const result = await checkFieldAccess(fieldAccess, 'read', {
247
245
  session: null,
248
- item,
249
246
  context: mockContext,
250
247
  })
251
248
 
@@ -487,12 +484,15 @@ describe('Access Control', () => {
487
484
  session: { userId: '123' },
488
485
  item,
489
486
  context: mockContext,
487
+ inputData: data,
490
488
  })
491
489
 
492
490
  expect(accessFn).toHaveBeenCalledWith({
493
491
  session: { userId: '123' },
494
492
  item,
495
493
  context: mockContext,
494
+ inputData: data,
495
+ operation: 'update',
496
496
  })
497
497
  })
498
498
  })
@@ -76,7 +76,7 @@ describe('config helpers', () => {
76
76
  })
77
77
 
78
78
  describe('list', () => {
79
- it('should return the same list config', () => {
79
+ it('should return normalized list config', () => {
80
80
  const testList: ListConfig = {
81
81
  fields: {
82
82
  name: { type: 'text' },
@@ -85,7 +85,9 @@ describe('config helpers', () => {
85
85
  }
86
86
 
87
87
  const result = list(testList)
88
- expect(result).toBe(testList)
88
+ // list() normalizes access control, so it creates a new object
89
+ expect(result.fields).toEqual(testList.fields)
90
+ expect(result.access).toBeUndefined()
89
91
  })
90
92
 
91
93
  it('should support text fields', () => {
@@ -161,7 +163,7 @@ describe('config helpers', () => {
161
163
  expect(testList.fields.author.type).toBe('relationship')
162
164
  })
163
165
 
164
- it('should support access control', () => {
166
+ it('should support access control object form', () => {
165
167
  const testList = list({
166
168
  fields: { name: { type: 'text' } },
167
169
  access: {
@@ -177,6 +179,21 @@ describe('config helpers', () => {
177
179
  expect(testList.access?.operation).toBeDefined()
178
180
  })
179
181
 
182
+ it('should support access control function shorthand', () => {
183
+ const isAdmin = () => true
184
+ const testList = list({
185
+ fields: { name: { type: 'text' } },
186
+ access: isAdmin,
187
+ })
188
+
189
+ // Function shorthand should be normalized to object form
190
+ expect(testList.access?.operation).toBeDefined()
191
+ expect(testList.access?.operation?.query).toBe(isAdmin)
192
+ expect(testList.access?.operation?.create).toBe(isAdmin)
193
+ expect(testList.access?.operation?.update).toBe(isAdmin)
194
+ expect(testList.access?.operation?.delete).toBe(isAdmin)
195
+ })
196
+
180
197
  it('should support hooks', () => {
181
198
  const testList = list({
182
199
  fields: { name: { type: 'text' } },
@@ -50,7 +50,10 @@ describe('Nested Operations - Access Control and Hooks', () => {
50
50
 
51
51
  describe('Nested Create Operations', () => {
52
52
  it('should run hooks and access control for nested create', async () => {
53
- const userResolveInputHook = vi.fn(async ({ inputValue }) => inputValue?.toUpperCase())
53
+ const userResolveInputHook = vi.fn(async ({ resolvedData, fieldKey }) => {
54
+ const value = resolvedData[fieldKey]
55
+ return typeof value === 'string' ? value.toUpperCase() : value
56
+ })
54
57
  const userListResolveInputHook = vi.fn(async ({ resolvedData }) => resolvedData)
55
58
  const userValidateInputHook = vi.fn(async () => {})
56
59
  const postResolveInputHook = vi.fn(async ({ resolvedData }) => resolvedData)
@@ -140,9 +143,11 @@ describe('Nested Operations - Access Control and Hooks', () => {
140
143
 
141
144
  expect(userResolveInputHook).toHaveBeenCalledWith(
142
145
  expect.objectContaining({
143
- inputValue: 'john',
146
+ fieldKey: 'name',
144
147
  operation: 'create',
145
- fieldName: 'name',
148
+ resolvedData: expect.objectContaining({
149
+ name: 'john',
150
+ }),
146
151
  }),
147
152
  )
148
153