@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
@@ -4,261 +4,18 @@ import {
4
4
  checkAccess,
5
5
  mergeFilters,
6
6
  filterReadableFields,
7
- filterWritableFields,
8
7
  buildIncludeWithAccessControl,
9
8
  } from '../access/index.js'
10
- import {
11
- executeResolveInput,
12
- executeValidate,
13
- executeBeforeOperation,
14
- executeAfterOperation,
15
- validateFieldRules,
16
- ValidationError,
17
- DatabaseError,
18
- } from '../hooks/index.js'
19
- import { processNestedOperations } from './nested-operations.js'
9
+ import { ValidationError, DatabaseError } from '../hooks/index.js'
20
10
  import { getDbKey } from '../lib/case-utils.js'
21
11
  import type { PrismaClientLike } from '../access/types.js'
22
- import type { FieldConfig } from '../config/types.js'
23
12
  import { buildInclude, pickFields, isFragment } from '../query/index.js'
24
-
25
- /**
26
- * Execute field-level resolveInput hooks
27
- * Allows fields to transform their input values before database write
28
- */
29
- async function executeFieldResolveInputHooks(
30
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
- inputData: Record<string, any>,
32
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
- resolvedData: Record<string, any>,
34
- fields: Record<string, FieldConfig>,
35
- operation: 'create' | 'update',
36
- context: AccessContext,
37
- listKey: string,
38
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
- item?: any,
40
- ): Promise<Record<string, unknown>> {
41
- let result = { ...resolvedData }
42
- console.log(
43
- 'Executing field resolveInput hooks for list:',
44
- listKey,
45
- 'operation:',
46
- operation,
47
- 'inputData:',
48
- inputData,
49
- 'resolvedData before field hooks:',
50
- resolvedData,
51
- )
52
-
53
- for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
54
- // Skip if field not in data
55
- if (!(fieldKey in result)) continue
56
-
57
- // Skip if no hooks defined
58
- if (!fieldConfig.hooks?.resolveInput) continue
59
-
60
- // Execute field hook
61
- // Type assertion is safe here because hooks are typed correctly in field definitions
62
- // and we're working with runtime values that match those types
63
-
64
- const transformedValue = await fieldConfig.hooks.resolveInput({
65
- listKey,
66
- fieldKey,
67
- operation,
68
- inputData,
69
- item,
70
- resolvedData: { ...result }, // Pass a copy to avoid mutation affecting recorded args
71
- context,
72
- } as Parameters<typeof fieldConfig.hooks.resolveInput>[0])
73
-
74
- // Create new object with updated field to avoid mutating the passed reference
75
- result = { ...result, [fieldKey]: transformedValue }
76
- }
77
-
78
- return result
79
- }
80
-
81
- /**
82
- * Execute field-level validate hooks
83
- * Allows fields to perform custom validation after resolveInput but before database write
84
- */
85
- async function executeFieldValidateHooks(
86
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
87
- inputData: Record<string, any> | undefined,
88
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
89
- resolvedData: Record<string, any> | undefined,
90
- fields: Record<string, FieldConfig>,
91
- operation: 'create' | 'update' | 'delete',
92
- context: AccessContext,
93
- listKey: string,
94
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
- item?: any,
96
- ): Promise<void> {
97
- const errors: string[] = []
98
- const fieldErrors: Record<string, string> = {}
99
-
100
- const addValidationError = (fieldKey: string) => (msg: string) => {
101
- errors.push(msg)
102
- fieldErrors[fieldKey] = msg
103
- }
104
-
105
- for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
106
- // Support both 'validate' (new) and 'validateInput' (deprecated) for backwards compatibility
107
- const validateHook = fieldConfig.hooks?.validate ?? fieldConfig.hooks?.validateInput
108
- if (!validateHook) continue
109
-
110
- // Execute field hook
111
- // Type assertion is safe here because hooks are typed correctly in field definitions
112
- if (operation === 'delete') {
113
- await validateHook({
114
- listKey,
115
- fieldKey,
116
- operation: 'delete',
117
- item,
118
- context,
119
- addValidationError: addValidationError(fieldKey),
120
- } as Parameters<typeof validateHook>[0])
121
- } else if (operation === 'create') {
122
- await validateHook({
123
- listKey,
124
- fieldKey,
125
- operation: 'create',
126
- inputData,
127
- item: undefined,
128
- resolvedData,
129
- context,
130
- addValidationError: addValidationError(fieldKey),
131
- } as Parameters<typeof validateHook>[0])
132
- } else {
133
- // operation === 'update'
134
- await validateHook({
135
- listKey,
136
- fieldKey,
137
- operation: 'update',
138
- inputData,
139
- item,
140
- resolvedData,
141
- context,
142
- addValidationError: addValidationError(fieldKey),
143
- } as Parameters<typeof validateHook>[0])
144
- }
145
- }
146
-
147
- if (errors.length > 0) {
148
- throw new ValidationError(errors, fieldErrors)
149
- }
150
- }
151
-
152
- /**
153
- * Execute field-level beforeOperation hooks (side effects only)
154
- * Allows fields to perform side effects before database write
155
- */
156
- async function executeFieldBeforeOperationHooks(
157
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
158
- inputData: Record<string, any>,
159
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
160
- resolvedData: Record<string, any>,
161
- fields: Record<string, FieldConfig>,
162
- operation: 'create' | 'update' | 'delete',
163
- context: AccessContext,
164
- listKey: string,
165
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
166
- item?: any,
167
- ): Promise<void> {
168
- for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
169
- // Skip if no hooks defined
170
- if (!fieldConfig.hooks?.beforeOperation) continue
171
- // Skip if field not in data (for create/update)
172
- if (operation !== 'delete' && !(fieldKey in resolvedData)) continue
173
-
174
- // Execute field hook (side effects only, no return value used)
175
- // Type assertion is safe here because hooks are typed correctly in field definitions
176
- if (operation === 'delete') {
177
- await fieldConfig.hooks.beforeOperation({
178
- listKey,
179
- fieldKey,
180
- operation: 'delete',
181
- item,
182
- context,
183
- } as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
184
- } else if (operation === 'create') {
185
- await fieldConfig.hooks.beforeOperation({
186
- listKey,
187
- fieldKey,
188
- operation: 'create',
189
- inputData,
190
- resolvedData,
191
- context,
192
- } as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
193
- } else {
194
- // operation === 'update'
195
- await fieldConfig.hooks.beforeOperation({
196
- listKey,
197
- fieldKey,
198
- operation: 'update',
199
- inputData,
200
- item,
201
- resolvedData,
202
- context,
203
- } as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
204
- }
205
- }
206
- }
207
-
208
- /**
209
- * Execute field-level afterOperation hooks (side effects only)
210
- * Allows fields to perform side effects after database operations
211
- */
212
- async function executeFieldAfterOperationHooks(
213
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
214
- item: any,
215
- inputData: Record<string, unknown> | undefined,
216
- resolvedData: Record<string, unknown> | undefined,
217
- fields: Record<string, FieldConfig>,
218
- operation: 'create' | 'update' | 'delete',
219
- context: AccessContext,
220
- listKey: string,
221
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
222
- originalItem?: any,
223
- ): Promise<void> {
224
- for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
225
- // Skip if no hooks defined
226
- if (!fieldConfig.hooks?.afterOperation) continue
227
-
228
- // Execute field hook (side effects only, no return value used)
229
- if (operation === 'delete') {
230
- await fieldConfig.hooks.afterOperation({
231
- listKey,
232
- fieldKey,
233
- operation: 'delete',
234
- originalItem,
235
- context,
236
- } as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
237
- } else if (operation === 'create') {
238
- await fieldConfig.hooks.afterOperation({
239
- listKey,
240
- fieldKey,
241
- operation: 'create',
242
- inputData,
243
- item,
244
- resolvedData,
245
- context,
246
- } as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
247
- } else {
248
- // operation === 'update'
249
- await fieldConfig.hooks.afterOperation({
250
- listKey,
251
- fieldKey,
252
- operation: 'update',
253
- inputData,
254
- originalItem,
255
- item,
256
- resolvedData,
257
- context,
258
- } as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
259
- }
260
- }
261
- }
13
+ import {
14
+ runWritePipeline,
15
+ createWriteStrategy,
16
+ updateWriteStrategy,
17
+ deleteWriteStrategy,
18
+ } from './write-pipeline.js'
262
19
 
263
20
  export type ServerActionProps =
264
21
  | { listKey: string; action: 'create'; data: Record<string, unknown> }
@@ -457,7 +214,7 @@ export function getContext<
457
214
  findMany: findManyOp,
458
215
  create: createOp,
459
216
  update: updateOp,
460
- delete: createDelete(listName, listConfig, prisma, context),
217
+ delete: createDelete(listName, listConfig, prisma, context, config),
461
218
  count: createCount(listName, listConfig, prisma, context),
462
219
  createMany: createCreateMany(listName, listConfig, prisma, context, config, createOp),
463
220
  updateMany: createUpdateMany(
@@ -812,163 +569,18 @@ function createCreate<TPrisma extends PrismaClientLike>(
812
569
  context: AccessContext<TPrisma>,
813
570
  config: OpenSaasConfig,
814
571
  ) {
572
+ // Thin adapter over the Write Pipeline: pick the create strategy, run the
573
+ // canonical secured write sequence, return its result.
815
574
  return async (args: { data: Record<string, unknown> }) => {
816
- // 0. Check singleton constraint (enforce even in sudo mode)
817
- if (isSingletonList(listConfig)) {
818
- // Access Prisma model dynamically - required because model names are generated at runtime
819
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
820
- const model = (prisma as any)[getDbKey(listName)]
821
- const existingCount = await model.count()
822
-
823
- if (existingCount > 0) {
824
- throw new ValidationError(
825
- [`Cannot create: ${listName} is a singleton list with an existing record`],
826
- {},
827
- )
828
- }
829
- }
830
-
831
- // 1. Check create access (skip if sudo mode)
832
- if (!context._isSudo) {
833
- const createAccess = listConfig.access?.operation?.create
834
- const accessResult = await checkAccess(createAccess, {
835
- session: context.session,
836
- context,
837
- })
838
-
839
- if (accessResult === false) {
840
- return null
841
- }
842
- }
843
-
844
- // 2. Execute list-level resolveInput hook
845
- let resolvedData = await executeResolveInput(listConfig.hooks, {
846
- listKey: listName,
847
- operation: 'create',
848
- inputData: args.data,
849
- resolvedData: args.data,
850
- item: undefined,
851
- context,
852
- })
853
-
854
- // 2.5. Execute field-level resolveInput hooks (e.g., hash passwords)
855
- resolvedData = await executeFieldResolveInputHooks(
856
- args.data,
857
- resolvedData,
858
- listConfig.fields,
859
- 'create',
860
- context,
575
+ return runWritePipeline({
861
576
  listName,
862
- )
863
-
864
- // 3. Execute list-level validate hook
865
- await executeValidate(listConfig.hooks, {
866
- listKey: listName,
867
- operation: 'create',
868
- inputData: args.data,
869
- resolvedData,
870
- item: undefined,
871
- context,
872
- })
873
-
874
- // 3.5. Execute field-level validate hooks
875
- await executeFieldValidateHooks(
876
- args.data,
877
- resolvedData,
878
- listConfig.fields,
879
- 'create',
577
+ listConfig,
578
+ prisma,
880
579
  context,
881
- listName,
882
- )
883
-
884
- // 4. Field validation (isRequired, length, etc.)
885
- const validation = validateFieldRules(resolvedData, listConfig.fields, 'create')
886
- if (validation.errors.length > 0) {
887
- throw new ValidationError(validation.errors, validation.fieldErrors)
888
- }
889
-
890
- // 5. Filter writable fields (field-level access control, skip if sudo mode)
891
- const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'create', {
892
- session: context.session,
893
- context: { ...context, _isSudo: context._isSudo },
894
- inputData: args.data,
895
- })
896
-
897
- // 5.5. Process nested relationship operations
898
- const data = await processNestedOperations(
899
- filteredData,
900
- listConfig.fields,
901
580
  config,
902
- { ...context, prisma },
903
- 'create',
904
- )
905
-
906
- // 6. Execute field-level beforeOperation hooks (side effects only)
907
- await executeFieldBeforeOperationHooks(
908
- args.data,
909
- resolvedData,
910
- listConfig.fields,
911
- 'create',
912
- context,
913
- listName,
914
- )
915
-
916
- // 7. Execute list-level beforeOperation hook
917
- await executeBeforeOperation(listConfig.hooks, {
918
- listKey: listName,
919
- operation: 'create',
920
581
  inputData: args.data,
921
- resolvedData,
922
- context,
582
+ strategy: createWriteStrategy(listName, listConfig, context),
923
583
  })
924
-
925
- // 8. Execute database create
926
- // Access Prisma model dynamically - required because model names are generated at runtime
927
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
928
- const model = (prisma as any)[getDbKey(listName)]
929
- // Singleton lists use Int @id with value always 1 (matching Keystone 6 behaviour)
930
- const createData = isSingletonList(listConfig) ? { id: 1, ...data } : data
931
- const item = await model.create({
932
- data: createData,
933
- })
934
-
935
- // 9. Execute list-level afterOperation hook
936
- await executeAfterOperation(listConfig.hooks, {
937
- listKey: listName,
938
- operation: 'create',
939
- inputData: args.data,
940
- item,
941
- resolvedData,
942
- context,
943
- })
944
-
945
- // 10. Execute field-level afterOperation hooks (side effects only)
946
- await executeFieldAfterOperationHooks(
947
- item,
948
- args.data,
949
- resolvedData,
950
- listConfig.fields,
951
- 'create',
952
- context,
953
- listName,
954
- undefined, // originalItem is undefined for create operations
955
- )
956
-
957
- // 11. Filter readable fields and apply resolveOutput hooks (including nested relationships)
958
- // Pass sudo flag through context to skip field-level access checks
959
- const filtered = await filterReadableFields(
960
- item,
961
- listConfig.fields,
962
- {
963
- session: context.session,
964
- context: { ...context, _isSudo: context._isSudo },
965
- },
966
- config,
967
- 0,
968
- listName,
969
- )
970
-
971
- return filtered
972
584
  }
973
585
  }
974
586
 
@@ -1009,174 +621,18 @@ function createUpdate<TPrisma extends PrismaClientLike>(
1009
621
  context: AccessContext<TPrisma>,
1010
622
  config: OpenSaasConfig,
1011
623
  ) {
624
+ // Thin adapter over the Write Pipeline: pick the update strategy, run the
625
+ // canonical secured write sequence, return its result.
1012
626
  return async (args: { where: { id: string }; data: Record<string, unknown> }) => {
1013
- // 1. Fetch the item to pass to access control and hooks
1014
- // Access Prisma model dynamically - required because model names are generated at runtime
1015
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1016
- const model = (prisma as any)[getDbKey(listName)]
1017
- const item = await model.findUnique({
1018
- where: args.where,
1019
- })
1020
-
1021
- if (!item) {
1022
- return null
1023
- }
1024
-
1025
- // 2. Check update access (skip if sudo mode)
1026
- if (!context._isSudo) {
1027
- const updateAccess = listConfig.access?.operation?.update
1028
- const accessResult = await checkAccess(updateAccess, {
1029
- session: context.session,
1030
- item,
1031
- context,
1032
- })
1033
-
1034
- if (accessResult === false) {
1035
- return null
1036
- }
1037
-
1038
- // If access returns a filter, check if item matches
1039
- if (typeof accessResult === 'object') {
1040
- const matchesFilter = await model.findFirst({
1041
- where: mergeFilters(args.where, accessResult),
1042
- })
1043
-
1044
- if (!matchesFilter) {
1045
- return null
1046
- }
1047
- }
1048
- }
1049
-
1050
- // 3. Execute list-level resolveInput hook
1051
- let resolvedData = await executeResolveInput(listConfig.hooks, {
1052
- listKey: listName,
1053
- operation: 'update',
1054
- inputData: args.data,
1055
- resolvedData: args.data,
1056
- item,
1057
- context,
1058
- })
1059
-
1060
- // 3.5. Execute field-level resolveInput hooks (e.g., hash passwords)
1061
- resolvedData = await executeFieldResolveInputHooks(
1062
- args.data,
1063
- resolvedData,
1064
- listConfig.fields,
1065
- 'update',
1066
- context,
627
+ return runWritePipeline({
1067
628
  listName,
1068
- item,
1069
- )
1070
-
1071
- // 4. Execute list-level validate hook
1072
- await executeValidate(listConfig.hooks, {
1073
- listKey: listName,
1074
- operation: 'update',
1075
- inputData: args.data,
1076
- resolvedData,
1077
- item,
629
+ listConfig,
630
+ prisma,
1078
631
  context,
1079
- })
1080
-
1081
- // 4.5. Execute field-level validate hooks
1082
- await executeFieldValidateHooks(
1083
- args.data,
1084
- resolvedData,
1085
- listConfig.fields,
1086
- 'update',
1087
- context,
1088
- listName,
1089
- item,
1090
- )
1091
-
1092
- // 5. Field validation (isRequired, length, etc.)
1093
- const validation = validateFieldRules(resolvedData, listConfig.fields, 'update')
1094
- if (validation.errors.length > 0) {
1095
- throw new ValidationError(validation.errors, validation.fieldErrors)
1096
- }
1097
-
1098
- // 6. Filter writable fields (field-level access control, skip if sudo mode)
1099
- const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'update', {
1100
- session: context.session,
1101
- item,
1102
- context: { ...context, _isSudo: context._isSudo },
1103
- inputData: args.data,
1104
- })
1105
-
1106
- // 6.5. Process nested relationship operations
1107
- const data = await processNestedOperations(
1108
- filteredData,
1109
- listConfig.fields,
1110
632
  config,
1111
- { ...context, prisma },
1112
- 'update',
1113
- )
1114
-
1115
- // 7. Execute field-level beforeOperation hooks (side effects only)
1116
- await executeFieldBeforeOperationHooks(
1117
- args.data,
1118
- resolvedData,
1119
- listConfig.fields,
1120
- 'update',
1121
- context,
1122
- listName,
1123
- item,
1124
- )
1125
-
1126
- // 8. Execute list-level beforeOperation hook
1127
- await executeBeforeOperation(listConfig.hooks, {
1128
- listKey: listName,
1129
- operation: 'update',
1130
633
  inputData: args.data,
1131
- item,
1132
- resolvedData,
1133
- context,
634
+ strategy: updateWriteStrategy(listConfig, context, args.where),
1134
635
  })
1135
-
1136
- // 9. Execute database update
1137
- const updated = await model.update({
1138
- where: args.where,
1139
- data,
1140
- })
1141
-
1142
- // 10. Execute list-level afterOperation hook
1143
- await executeAfterOperation(listConfig.hooks, {
1144
- listKey: listName,
1145
- operation: 'update',
1146
- inputData: args.data,
1147
- originalItem: item, // item is the original item before the update
1148
- item: updated,
1149
- resolvedData,
1150
- context,
1151
- })
1152
-
1153
- // 11. Execute field-level afterOperation hooks (side effects only)
1154
- await executeFieldAfterOperationHooks(
1155
- updated,
1156
- args.data,
1157
- resolvedData,
1158
- listConfig.fields,
1159
- 'update',
1160
- context,
1161
- listName,
1162
- item, // item is the original item before the update
1163
- )
1164
-
1165
- // 12. Filter readable fields and apply resolveOutput hooks (including nested relationships)
1166
- // Pass sudo flag through context to skip field-level access checks
1167
- const filtered = await filterReadableFields(
1168
- updated,
1169
- listConfig.fields,
1170
- {
1171
- session: context.session,
1172
- context: { ...context, _isSudo: context._isSudo },
1173
- },
1174
- config,
1175
- 0,
1176
- listName,
1177
- )
1178
-
1179
- return filtered
1180
636
  }
1181
637
  }
1182
638
 
@@ -1220,114 +676,20 @@ function createDelete<TPrisma extends PrismaClientLike>(
1220
676
  listConfig: ListConfig<any>,
1221
677
  prisma: TPrisma,
1222
678
  context: AccessContext<TPrisma>,
679
+ config: OpenSaasConfig,
1223
680
  ) {
681
+ // Thin adapter over the Write Pipeline: pick the delete strategy, run the
682
+ // canonical secured write sequence, return its result.
1224
683
  return async (args: { where: { id: string } }) => {
1225
- // 0. Check singleton constraint (enforce even in sudo mode)
1226
- if (isSingletonList(listConfig)) {
1227
- throw new ValidationError([`Cannot delete: ${listName} is a singleton list`], {})
1228
- }
1229
-
1230
- // 1. Fetch the item to pass to access control and hooks
1231
- // Access Prisma model dynamically - required because model names are generated at runtime
1232
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1233
- const model = (prisma as any)[getDbKey(listName)]
1234
- const item = await model.findUnique({
1235
- where: args.where,
1236
- })
1237
-
1238
- if (!item) {
1239
- return null
1240
- }
1241
-
1242
- // 2. Check delete access (skip if sudo mode)
1243
- if (!context._isSudo) {
1244
- const deleteAccess = listConfig.access?.operation?.delete
1245
- const accessResult = await checkAccess(deleteAccess, {
1246
- session: context.session,
1247
- item,
1248
- context,
1249
- })
1250
-
1251
- if (accessResult === false) {
1252
- return null
1253
- }
1254
-
1255
- // If access returns a filter, check if item matches
1256
- if (typeof accessResult === 'object') {
1257
- const matchesFilter = await model.findFirst({
1258
- where: mergeFilters(args.where, accessResult),
1259
- })
1260
-
1261
- if (!matchesFilter) {
1262
- return null
1263
- }
1264
- }
1265
- }
1266
-
1267
- // 3. Execute list-level validate hook
1268
- await executeValidate(listConfig.hooks, {
1269
- listKey: listName,
1270
- operation: 'delete',
1271
- item,
1272
- context,
1273
- })
1274
-
1275
- // 3.5. Execute field-level validate hooks
1276
- await executeFieldValidateHooks(
1277
- undefined,
1278
- undefined,
1279
- listConfig.fields,
1280
- 'delete',
1281
- context,
684
+ return runWritePipeline({
1282
685
  listName,
1283
- item,
1284
- )
1285
-
1286
- // 4. Execute field-level beforeOperation hooks (side effects only)
1287
- await executeFieldBeforeOperationHooks(
1288
- {},
1289
- {},
1290
- listConfig.fields,
1291
- 'delete',
1292
- context,
1293
- listName,
1294
- item,
1295
- )
1296
-
1297
- // 5. Execute list-level beforeOperation hook
1298
- await executeBeforeOperation(listConfig.hooks, {
1299
- listKey: listName,
1300
- operation: 'delete',
1301
- item,
1302
- context,
1303
- })
1304
-
1305
- // 6. Execute database delete
1306
- const deleted = await model.delete({
1307
- where: args.where,
1308
- })
1309
-
1310
- // 7. Execute list-level afterOperation hook
1311
- await executeAfterOperation(listConfig.hooks, {
1312
- listKey: listName,
1313
- operation: 'delete',
1314
- originalItem: item, // item is the original item before deletion
686
+ listConfig,
687
+ prisma,
1315
688
  context,
689
+ config,
690
+ inputData: undefined,
691
+ strategy: deleteWriteStrategy(listName, listConfig, context, args.where),
1316
692
  })
1317
-
1318
- // 8. Execute field-level afterOperation hooks (side effects only)
1319
- await executeFieldAfterOperationHooks(
1320
- deleted,
1321
- undefined,
1322
- undefined,
1323
- listConfig.fields,
1324
- 'delete',
1325
- context,
1326
- listName,
1327
- item, // item is the original item before deletion
1328
- )
1329
-
1330
- return deleted
1331
693
  }
1332
694
  }
1333
695