@opensaas/stack-core 0.20.1 → 0.22.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 (136) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +334 -0
  3. package/CLAUDE.md +29 -11
  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 +178 -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/access/multi-column-read-write.test.d.ts +2 -0
  29. package/dist/access/multi-column-read-write.test.d.ts.map +1 -0
  30. package/dist/access/multi-column-read-write.test.js +149 -0
  31. package/dist/access/multi-column-read-write.test.js.map +1 -0
  32. package/dist/config/index.d.ts +1 -1
  33. package/dist/config/index.d.ts.map +1 -1
  34. package/dist/config/types.d.ts +334 -5
  35. package/dist/config/types.d.ts.map +1 -1
  36. package/dist/context/hook-pipeline.d.ts +49 -0
  37. package/dist/context/hook-pipeline.d.ts.map +1 -0
  38. package/dist/context/hook-pipeline.js +75 -0
  39. package/dist/context/hook-pipeline.js.map +1 -0
  40. package/dist/context/index.d.ts.map +1 -1
  41. package/dist/context/index.js +30 -462
  42. package/dist/context/index.js.map +1 -1
  43. package/dist/context/nested-operations.d.ts.map +1 -1
  44. package/dist/context/nested-operations.js +72 -68
  45. package/dist/context/nested-operations.js.map +1 -1
  46. package/dist/context/write-pipeline.d.ts +158 -0
  47. package/dist/context/write-pipeline.d.ts.map +1 -0
  48. package/dist/context/write-pipeline.js +306 -0
  49. package/dist/context/write-pipeline.js.map +1 -0
  50. package/dist/extend.d.ts +3 -0
  51. package/dist/extend.d.ts.map +1 -0
  52. package/dist/extend.js +10 -0
  53. package/dist/extend.js.map +1 -0
  54. package/dist/fields/format-prisma-default.d.ts +35 -0
  55. package/dist/fields/format-prisma-default.d.ts.map +1 -0
  56. package/dist/fields/format-prisma-default.js +52 -0
  57. package/dist/fields/format-prisma-default.js.map +1 -0
  58. package/dist/fields/format-prisma-default.test.d.ts +2 -0
  59. package/dist/fields/format-prisma-default.test.d.ts.map +1 -0
  60. package/dist/fields/format-prisma-default.test.js +54 -0
  61. package/dist/fields/format-prisma-default.test.js.map +1 -0
  62. package/dist/fields/index.d.ts +1 -0
  63. package/dist/fields/index.d.ts.map +1 -1
  64. package/dist/fields/index.js +267 -18
  65. package/dist/fields/index.js.map +1 -1
  66. package/dist/fields/select.test.js +85 -0
  67. package/dist/fields/select.test.js.map +1 -1
  68. package/dist/fields/text-keystone-compat.test.d.ts +2 -0
  69. package/dist/fields/text-keystone-compat.test.d.ts.map +1 -0
  70. package/dist/fields/text-keystone-compat.test.js +93 -0
  71. package/dist/fields/text-keystone-compat.test.js.map +1 -0
  72. package/dist/hooks/index.d.ts +20 -0
  73. package/dist/hooks/index.d.ts.map +1 -1
  74. package/dist/hooks/index.js +246 -0
  75. package/dist/hooks/index.js.map +1 -1
  76. package/dist/index.d.ts +6 -8
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +25 -9
  79. package/dist/index.js.map +1 -1
  80. package/dist/index.test.d.ts +2 -0
  81. package/dist/index.test.d.ts.map +1 -0
  82. package/dist/index.test.js +33 -0
  83. package/dist/index.test.js.map +1 -0
  84. package/dist/internal.d.ts +8 -0
  85. package/dist/internal.d.ts.map +1 -0
  86. package/dist/internal.js +16 -0
  87. package/dist/internal.js.map +1 -0
  88. package/dist/mcp/handler.js +0 -1
  89. package/dist/mcp/handler.js.map +1 -1
  90. package/dist/validation/field-config.d.ts +55 -0
  91. package/dist/validation/field-config.d.ts.map +1 -0
  92. package/dist/validation/field-config.js +100 -0
  93. package/dist/validation/field-config.js.map +1 -0
  94. package/dist/validation/field-config.test.d.ts +2 -0
  95. package/dist/validation/field-config.test.d.ts.map +1 -0
  96. package/dist/validation/field-config.test.js +159 -0
  97. package/dist/validation/field-config.test.js.map +1 -0
  98. package/package.json +11 -3
  99. package/src/access/access-filter.ts +97 -0
  100. package/src/access/engine.ts +13 -396
  101. package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
  102. package/src/access/field-access.ts +159 -0
  103. package/src/access/field-visibility.ts +269 -0
  104. package/src/access/index.ts +7 -4
  105. package/src/access/multi-column-read-write.test.ts +255 -0
  106. package/src/config/index.ts +3 -0
  107. package/src/config/types.ts +342 -4
  108. package/src/context/hook-pipeline.ts +160 -0
  109. package/src/context/index.ts +29 -667
  110. package/src/context/nested-operations.ts +142 -111
  111. package/src/context/write-pipeline.ts +543 -0
  112. package/src/extend.ts +19 -0
  113. package/src/fields/format-prisma-default.test.ts +64 -0
  114. package/src/fields/format-prisma-default.ts +67 -0
  115. package/src/fields/index.ts +375 -20
  116. package/src/fields/select.test.ts +99 -0
  117. package/src/fields/text-keystone-compat.test.ts +126 -0
  118. package/src/hooks/index.ts +270 -0
  119. package/src/index.test.ts +50 -0
  120. package/src/index.ts +35 -82
  121. package/src/internal.ts +49 -0
  122. package/src/mcp/handler.ts +0 -2
  123. package/src/validation/field-config.test.ts +199 -0
  124. package/src/validation/field-config.ts +145 -0
  125. package/tests/access-relationships.test.ts +4 -4
  126. package/tests/access.test.ts +1 -1
  127. package/tests/field-hooks.test.ts +410 -0
  128. package/tests/field-types.test.ts +1 -1
  129. package/tests/hook-pipeline.test.ts +233 -0
  130. package/tests/nested-operation-registry.test.ts +206 -0
  131. package/tests/write-pipeline.test.ts +588 -0
  132. package/tsconfig.tsbuildinfo +1 -1
  133. package/vitest.config.ts +43 -1
  134. package/dist/access/engine.test.d.ts +0 -2
  135. package/dist/access/engine.test.d.ts.map +0 -1
  136. package/dist/access/engine.test.js.map +0 -1
@@ -0,0 +1,588 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import {
3
+ runWritePipeline,
4
+ createWriteStrategy,
5
+ updateWriteStrategy,
6
+ deleteWriteStrategy,
7
+ } from '../src/context/write-pipeline.js'
8
+ import { ValidationError } from '../src/hooks/index.js'
9
+ import { text } from '../src/fields/index.js'
10
+ import type { OpenSaasConfig, ListConfig } from '../src/config/types.js'
11
+ import type { AccessContext, PrismaClientLike } from '../src/access/types.js'
12
+
13
+ /**
14
+ * Unit tests for the Write Pipeline — the single module that owns the canonical
15
+ * secured write sequence. These drive the pipeline directly through its
16
+ * interface (fake Prisma model + spy hooks + a list config), which is the whole
17
+ * point of the deepening: the phase order becomes the test surface.
18
+ *
19
+ * Asserted once, across create/update/delete:
20
+ * - phases run in the documented order;
21
+ * - access denial / missing target / filter non-match short-circuit to `null`
22
+ * BEFORE the DB call and BEFORE beforeOperation;
23
+ * - validation failure throws ValidationError and never hits the DB;
24
+ * - sudo bypasses access + writable filtering;
25
+ * - afterOperation sees the persisted row and the correct originalItem.
26
+ */
27
+
28
+ // Shared ordered log of phase events for order assertions.
29
+ let events: string[]
30
+
31
+ /**
32
+ * Build a fake Prisma model whose methods log their calls. The pipeline
33
+ * resolves the model dynamically via getDbKey('Post') -> 'post'.
34
+ */
35
+ function makeFakePrisma(overrides?: {
36
+ existing?: Record<string, unknown> | null
37
+ filterMatch?: Record<string, unknown> | null
38
+ created?: Record<string, unknown>
39
+ updated?: Record<string, unknown>
40
+ deleted?: Record<string, unknown>
41
+ count?: number
42
+ }) {
43
+ const created = overrides?.created ?? { id: '1', title: 'created' }
44
+ const updated = overrides?.updated ?? { id: '1', title: 'updated' }
45
+ const deleted = overrides?.deleted ?? { id: '1', title: 'deleted' }
46
+
47
+ const post = {
48
+ findUnique: vi.fn(async () => {
49
+ events.push('db:findUnique')
50
+ return overrides?.existing ?? null
51
+ }),
52
+ findFirst: vi.fn(async () => {
53
+ events.push('db:findFirst')
54
+ return overrides?.filterMatch ?? null
55
+ }),
56
+ count: vi.fn(async () => {
57
+ events.push('db:count')
58
+ return overrides?.count ?? 0
59
+ }),
60
+ create: vi.fn(async () => {
61
+ events.push('db:create')
62
+ return created
63
+ }),
64
+ update: vi.fn(async () => {
65
+ events.push('db:update')
66
+ return updated
67
+ }),
68
+ delete: vi.fn(async () => {
69
+ events.push('db:delete')
70
+ return deleted
71
+ }),
72
+ }
73
+
74
+ return { prisma: { post } as unknown as PrismaClientLike, post }
75
+ }
76
+
77
+ /**
78
+ * Build a minimal AccessContext for the pipeline.
79
+ */
80
+ function makeContext(opts?: { isSudo?: boolean }): AccessContext {
81
+ return {
82
+ session: { userId: 'u1' },
83
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
84
+ prisma: {} as any,
85
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
86
+ db: {} as any,
87
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
88
+ storage: {} as any,
89
+ plugins: {},
90
+ _isSudo: opts?.isSudo ?? false,
91
+ _resolveOutputCounter: { depth: 0 },
92
+ }
93
+ }
94
+
95
+ /**
96
+ * A list config with a spy on every hook, recording its phase into `events`.
97
+ * `title` is a required field so we can exercise built-in field rules.
98
+ */
99
+ function makeListConfig(opts?: {
100
+ operationAccess?: {
101
+ create?: () => boolean | Record<string, unknown>
102
+ update?: () => boolean | Record<string, unknown>
103
+ delete?: () => boolean | Record<string, unknown>
104
+ }
105
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
+ }): ListConfig<any> {
107
+ return {
108
+ fields: {
109
+ // Use the real text() builder so built-in field rules (isRequired) and
110
+ // getZodSchema actually exist, while spying on each hook phase.
111
+ title: text({
112
+ validation: { isRequired: true },
113
+ hooks: {
114
+ resolveInput: async ({ resolvedData, fieldKey }) => {
115
+ events.push('field:resolveInput')
116
+ return resolvedData[fieldKey]
117
+ },
118
+ validate: async () => {
119
+ events.push('field:validate')
120
+ },
121
+ beforeOperation: async () => {
122
+ events.push('field:beforeOperation')
123
+ },
124
+ afterOperation: async () => {
125
+ events.push('field:afterOperation')
126
+ },
127
+ },
128
+ }),
129
+ },
130
+ access: {
131
+ operation: {
132
+ query: () => true,
133
+ create: opts?.operationAccess?.create ?? (() => true),
134
+ update: opts?.operationAccess?.update ?? (() => true),
135
+ delete: opts?.operationAccess?.delete ?? (() => true),
136
+ },
137
+ },
138
+ hooks: {
139
+ resolveInput: async ({ resolvedData }) => {
140
+ events.push('list:resolveInput')
141
+ return resolvedData
142
+ },
143
+ validate: async () => {
144
+ events.push('list:validate')
145
+ },
146
+ beforeOperation: async () => {
147
+ events.push('list:beforeOperation')
148
+ },
149
+ afterOperation: async () => {
150
+ events.push('list:afterOperation')
151
+ },
152
+ },
153
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
154
+ } as ListConfig<any>
155
+ }
156
+
157
+ function makeConfig(listConfig: ListConfig<unknown>): OpenSaasConfig {
158
+ return {
159
+ db: { provider: 'sqlite' },
160
+ lists: { Post: listConfig },
161
+ } as unknown as OpenSaasConfig
162
+ }
163
+
164
+ beforeEach(() => {
165
+ events = []
166
+ })
167
+
168
+ describe('Write Pipeline — phase order', () => {
169
+ it('runs create phases in the documented order (resolveInput → validate → beforeOp → DB → afterOp → Field Visibility)', async () => {
170
+ const { prisma, post } = makeFakePrisma({ created: { id: '1', title: 'hi' } })
171
+ const listConfig = makeListConfig()
172
+ const context = makeContext()
173
+
174
+ const result = await runWritePipeline({
175
+ listName: 'Post',
176
+ listConfig,
177
+ prisma,
178
+ context,
179
+ config: makeConfig(listConfig),
180
+ inputData: { title: 'hi' },
181
+ strategy: createWriteStrategy('Post', listConfig, context),
182
+ })
183
+
184
+ expect(result).toEqual({ id: '1', title: 'hi' })
185
+ // The DB call must come AFTER all input/validate/before hooks and BEFORE after hooks.
186
+ expect(events).toEqual([
187
+ 'list:resolveInput',
188
+ 'field:resolveInput',
189
+ 'list:validate',
190
+ 'field:validate',
191
+ 'field:beforeOperation',
192
+ 'list:beforeOperation',
193
+ 'db:create',
194
+ 'list:afterOperation',
195
+ 'field:afterOperation',
196
+ ])
197
+ // resolveInput strictly precedes validate, which precedes the DB write.
198
+ expect(events.indexOf('list:resolveInput')).toBeLessThan(events.indexOf('list:validate'))
199
+ expect(events.indexOf('list:validate')).toBeLessThan(events.indexOf('db:create'))
200
+ expect(events.indexOf('db:create')).toBeLessThan(events.indexOf('list:afterOperation'))
201
+ expect(post.create).toHaveBeenCalledTimes(1)
202
+ })
203
+
204
+ it('runs update phases in the documented order, fetching the target first', async () => {
205
+ const existing = { id: '1', title: 'old' }
206
+ const { prisma, post } = makeFakePrisma({ existing, updated: { id: '1', title: 'new' } })
207
+ const listConfig = makeListConfig()
208
+ const context = makeContext()
209
+
210
+ const result = await runWritePipeline({
211
+ listName: 'Post',
212
+ listConfig,
213
+ prisma,
214
+ context,
215
+ config: makeConfig(listConfig),
216
+ inputData: { title: 'new' },
217
+ strategy: updateWriteStrategy(listConfig, context, { id: '1' }),
218
+ })
219
+
220
+ expect(result).toEqual({ id: '1', title: 'new' })
221
+ expect(events).toEqual([
222
+ 'db:findUnique',
223
+ 'list:resolveInput',
224
+ 'field:resolveInput',
225
+ 'list:validate',
226
+ 'field:validate',
227
+ 'field:beforeOperation',
228
+ 'list:beforeOperation',
229
+ 'db:update',
230
+ 'list:afterOperation',
231
+ 'field:afterOperation',
232
+ ])
233
+ expect(post.update).toHaveBeenCalledTimes(1)
234
+ })
235
+
236
+ it('runs delete phases in the documented order, SKIPPING the input-shaping phases', async () => {
237
+ const existing = { id: '1', title: 'doomed' }
238
+ const { prisma, post } = makeFakePrisma({ existing, deleted: existing })
239
+ const listConfig = makeListConfig()
240
+ const context = makeContext()
241
+
242
+ const result = await runWritePipeline({
243
+ listName: 'Post',
244
+ listConfig,
245
+ prisma,
246
+ context,
247
+ config: makeConfig(listConfig),
248
+ inputData: undefined,
249
+ strategy: deleteWriteStrategy('Post', listConfig, context, { id: '1' }),
250
+ })
251
+
252
+ expect(result).toEqual(existing)
253
+ // Delete runs only validate/field-validate — NO resolveInput.
254
+ expect(events).toEqual([
255
+ 'db:findUnique',
256
+ 'list:validate',
257
+ 'field:validate',
258
+ 'field:beforeOperation',
259
+ 'list:beforeOperation',
260
+ 'db:delete',
261
+ 'list:afterOperation',
262
+ 'field:afterOperation',
263
+ ])
264
+ expect(events).not.toContain('list:resolveInput')
265
+ expect(events).not.toContain('field:resolveInput')
266
+ expect(post.delete).toHaveBeenCalledTimes(1)
267
+ })
268
+ })
269
+
270
+ describe('Write Pipeline — short-circuit to null (silent failure)', () => {
271
+ it('create: access denied short-circuits to null before DB and before beforeOperation', async () => {
272
+ const { prisma, post } = makeFakePrisma()
273
+ const listConfig = makeListConfig({ operationAccess: { create: () => false } })
274
+ const context = makeContext()
275
+
276
+ const result = await runWritePipeline({
277
+ listName: 'Post',
278
+ listConfig,
279
+ prisma,
280
+ context,
281
+ config: makeConfig(listConfig),
282
+ inputData: { title: 'hi' },
283
+ strategy: createWriteStrategy('Post', listConfig, context),
284
+ })
285
+
286
+ expect(result).toBeNull()
287
+ expect(post.create).not.toHaveBeenCalled()
288
+ expect(events).not.toContain('list:beforeOperation')
289
+ expect(events).not.toContain('list:resolveInput')
290
+ })
291
+
292
+ it('update: missing target short-circuits to null before access, hooks, and DB', async () => {
293
+ const { prisma, post } = makeFakePrisma({ existing: null })
294
+ const listConfig = makeListConfig()
295
+ const context = makeContext()
296
+
297
+ const result = await runWritePipeline({
298
+ listName: 'Post',
299
+ listConfig,
300
+ prisma,
301
+ context,
302
+ config: makeConfig(listConfig),
303
+ inputData: { title: 'new' },
304
+ strategy: updateWriteStrategy(listConfig, context, { id: 'missing' }),
305
+ })
306
+
307
+ expect(result).toBeNull()
308
+ expect(post.update).not.toHaveBeenCalled()
309
+ expect(events).toEqual(['db:findUnique'])
310
+ })
311
+
312
+ it('update: filter non-match short-circuits to null before DB and beforeOperation', async () => {
313
+ const existing = { id: '1', title: 'old' }
314
+ // filterMatch null => the access filter does not match the target row.
315
+ const { prisma, post } = makeFakePrisma({ existing, filterMatch: null })
316
+ const listConfig = makeListConfig({
317
+ operationAccess: { update: () => ({ authorId: 'someone-else' }) },
318
+ })
319
+ const context = makeContext()
320
+
321
+ const result = await runWritePipeline({
322
+ listName: 'Post',
323
+ listConfig,
324
+ prisma,
325
+ context,
326
+ config: makeConfig(listConfig),
327
+ inputData: { title: 'new' },
328
+ strategy: updateWriteStrategy(listConfig, context, { id: '1' }),
329
+ })
330
+
331
+ expect(result).toBeNull()
332
+ expect(post.update).not.toHaveBeenCalled()
333
+ // Resolution ran findUnique then findFirst (the filter re-check), then bailed.
334
+ expect(events).toEqual(['db:findUnique', 'db:findFirst'])
335
+ })
336
+
337
+ it('update: a filter that matches the target proceeds through the full pipeline', async () => {
338
+ const existing = { id: '1', title: 'old' }
339
+ const { prisma, post } = makeFakePrisma({
340
+ existing,
341
+ filterMatch: existing,
342
+ updated: { id: '1', title: 'new' },
343
+ })
344
+ const listConfig = makeListConfig({
345
+ operationAccess: { update: () => ({ authorId: 'u1' }) },
346
+ })
347
+ const context = makeContext()
348
+
349
+ const result = await runWritePipeline({
350
+ listName: 'Post',
351
+ listConfig,
352
+ prisma,
353
+ context,
354
+ config: makeConfig(listConfig),
355
+ inputData: { title: 'new' },
356
+ strategy: updateWriteStrategy(listConfig, context, { id: '1' }),
357
+ })
358
+
359
+ expect(result).toEqual({ id: '1', title: 'new' })
360
+ expect(post.findFirst).toHaveBeenCalledTimes(1)
361
+ expect(post.update).toHaveBeenCalledTimes(1)
362
+ })
363
+
364
+ it('delete: access denied short-circuits to null before DB', async () => {
365
+ const existing = { id: '1', title: 'x' }
366
+ const { prisma, post } = makeFakePrisma({ existing })
367
+ const listConfig = makeListConfig({ operationAccess: { delete: () => false } })
368
+ const context = makeContext()
369
+
370
+ const result = await runWritePipeline({
371
+ listName: 'Post',
372
+ listConfig,
373
+ prisma,
374
+ context,
375
+ config: makeConfig(listConfig),
376
+ inputData: undefined,
377
+ strategy: deleteWriteStrategy('Post', listConfig, context, { id: '1' }),
378
+ })
379
+
380
+ expect(result).toBeNull()
381
+ expect(post.delete).not.toHaveBeenCalled()
382
+ expect(events).not.toContain('list:beforeOperation')
383
+ })
384
+ })
385
+
386
+ describe('Write Pipeline — validation throws (NOT silent)', () => {
387
+ it('create: a missing required field throws ValidationError and never reaches the DB', async () => {
388
+ const { prisma, post } = makeFakePrisma()
389
+ const listConfig = makeListConfig()
390
+ const context = makeContext()
391
+
392
+ await expect(
393
+ runWritePipeline({
394
+ listName: 'Post',
395
+ listConfig,
396
+ prisma,
397
+ context,
398
+ config: makeConfig(listConfig),
399
+ inputData: {}, // title is required but absent
400
+ strategy: createWriteStrategy('Post', listConfig, context),
401
+ }),
402
+ ).rejects.toBeInstanceOf(ValidationError)
403
+
404
+ expect(post.create).not.toHaveBeenCalled()
405
+ // Built-in field rules run AFTER the validate hooks and BEFORE beforeOperation.
406
+ expect(events).toContain('list:validate')
407
+ expect(events).not.toContain('list:beforeOperation')
408
+ })
409
+ })
410
+
411
+ describe('Write Pipeline — sudo mode', () => {
412
+ it('create: sudo skips operation-level access checks', async () => {
413
+ const accessSpy = vi.fn(() => false)
414
+ const { prisma, post } = makeFakePrisma({ created: { id: '1', title: 'hi' } })
415
+ const listConfig = makeListConfig({ operationAccess: { create: accessSpy } })
416
+ const context = makeContext({ isSudo: true })
417
+
418
+ const result = await runWritePipeline({
419
+ listName: 'Post',
420
+ listConfig,
421
+ prisma,
422
+ context,
423
+ config: makeConfig(listConfig),
424
+ inputData: { title: 'hi' },
425
+ strategy: createWriteStrategy('Post', listConfig, context),
426
+ })
427
+
428
+ // Access denied would normally null this out; sudo bypasses the check.
429
+ expect(result).toEqual({ id: '1', title: 'hi' })
430
+ expect(accessSpy).not.toHaveBeenCalled()
431
+ expect(post.create).toHaveBeenCalledTimes(1)
432
+ })
433
+
434
+ it('update: sudo skips access and skips the filter re-check', async () => {
435
+ const accessSpy = vi.fn(() => ({ authorId: 'someone-else' }))
436
+ const existing = { id: '1', title: 'old' }
437
+ const { prisma, post } = makeFakePrisma({
438
+ existing,
439
+ filterMatch: null,
440
+ updated: { id: '1', title: 'new' },
441
+ })
442
+ const listConfig = makeListConfig({ operationAccess: { update: accessSpy } })
443
+ const context = makeContext({ isSudo: true })
444
+
445
+ const result = await runWritePipeline({
446
+ listName: 'Post',
447
+ listConfig,
448
+ prisma,
449
+ context,
450
+ config: makeConfig(listConfig),
451
+ inputData: { title: 'new' },
452
+ strategy: updateWriteStrategy(listConfig, context, { id: '1' }),
453
+ })
454
+
455
+ expect(result).toEqual({ id: '1', title: 'new' })
456
+ expect(accessSpy).not.toHaveBeenCalled()
457
+ expect(post.findFirst).not.toHaveBeenCalled() // no filter re-check under sudo
458
+ expect(post.update).toHaveBeenCalledTimes(1)
459
+ })
460
+
461
+ it('create: sudo skips writable-field filtering (denied field still written)', async () => {
462
+ // A field whose create access is false would normally be stripped by
463
+ // filterWritableFields; under sudo it must survive into the DB payload.
464
+ const captured: Record<string, unknown>[] = []
465
+ const post = {
466
+ findUnique: vi.fn(),
467
+ findFirst: vi.fn(),
468
+ count: vi.fn(async () => 0),
469
+ create: vi.fn(async (args: { data: Record<string, unknown> }) => {
470
+ captured.push(args.data)
471
+ return { id: '1', ...args.data }
472
+ }),
473
+ update: vi.fn(),
474
+ delete: vi.fn(),
475
+ }
476
+ const prisma = { post } as unknown as PrismaClientLike
477
+
478
+ const listConfig = {
479
+ fields: {
480
+ title: { type: 'text' },
481
+ locked: { type: 'text', access: { create: async () => false } },
482
+ },
483
+ access: { operation: { query: () => true, create: () => true } },
484
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
485
+ } as unknown as ListConfig<any>
486
+ const context = makeContext({ isSudo: true })
487
+
488
+ const result = await runWritePipeline({
489
+ listName: 'Post',
490
+ listConfig,
491
+ prisma,
492
+ context,
493
+ config: makeConfig(listConfig),
494
+ inputData: { title: 'hi', locked: 'secret' },
495
+ strategy: createWriteStrategy('Post', listConfig, context),
496
+ })
497
+
498
+ expect(captured[0]).toEqual({ title: 'hi', locked: 'secret' })
499
+ expect(result).toMatchObject({ locked: 'secret' })
500
+ })
501
+ })
502
+
503
+ describe('Write Pipeline — afterOperation originalItem', () => {
504
+ it('create: afterOperation receives the persisted row and undefined originalItem', async () => {
505
+ const persisted = { id: '1', title: 'hi' }
506
+ const { prisma } = makeFakePrisma({ created: persisted })
507
+ const afterOp = vi.fn()
508
+ const listConfig = {
509
+ fields: { title: { type: 'text' } },
510
+ access: { operation: { query: () => true, create: () => true } },
511
+ hooks: { afterOperation: afterOp },
512
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
513
+ } as unknown as ListConfig<any>
514
+ const context = makeContext()
515
+
516
+ await runWritePipeline({
517
+ listName: 'Post',
518
+ listConfig,
519
+ prisma,
520
+ context,
521
+ config: makeConfig(listConfig),
522
+ inputData: { title: 'hi' },
523
+ strategy: createWriteStrategy('Post', listConfig, context),
524
+ })
525
+
526
+ expect(afterOp).toHaveBeenCalledTimes(1)
527
+ const arg = afterOp.mock.calls[0][0]
528
+ expect(arg.operation).toBe('create')
529
+ expect(arg.item).toEqual(persisted)
530
+ expect('originalItem' in arg).toBe(false)
531
+ })
532
+
533
+ it('update: afterOperation receives the persisted row and the original row as originalItem', async () => {
534
+ const existing = { id: '1', title: 'old' }
535
+ const updated = { id: '1', title: 'new' }
536
+ const { prisma } = makeFakePrisma({ existing, updated })
537
+ const afterOp = vi.fn()
538
+ const listConfig = {
539
+ fields: { title: { type: 'text' } },
540
+ access: { operation: { query: () => true, update: () => true } },
541
+ hooks: { afterOperation: afterOp },
542
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
543
+ } as unknown as ListConfig<any>
544
+ const context = makeContext()
545
+
546
+ await runWritePipeline({
547
+ listName: 'Post',
548
+ listConfig,
549
+ prisma,
550
+ context,
551
+ config: makeConfig(listConfig),
552
+ inputData: { title: 'new' },
553
+ strategy: updateWriteStrategy(listConfig, context, { id: '1' }),
554
+ })
555
+
556
+ const arg = afterOp.mock.calls[0][0]
557
+ expect(arg.operation).toBe('update')
558
+ expect(arg.item).toEqual(updated)
559
+ expect(arg.originalItem).toEqual(existing)
560
+ })
561
+
562
+ it('delete: afterOperation receives the original row as originalItem', async () => {
563
+ const existing = { id: '1', title: 'doomed' }
564
+ const { prisma } = makeFakePrisma({ existing, deleted: existing })
565
+ const afterOp = vi.fn()
566
+ const listConfig = {
567
+ fields: { title: { type: 'text' } },
568
+ access: { operation: { query: () => true, delete: () => true } },
569
+ hooks: { afterOperation: afterOp },
570
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
571
+ } as unknown as ListConfig<any>
572
+ const context = makeContext()
573
+
574
+ await runWritePipeline({
575
+ listName: 'Post',
576
+ listConfig,
577
+ prisma,
578
+ context,
579
+ config: makeConfig(listConfig),
580
+ inputData: undefined,
581
+ strategy: deleteWriteStrategy('Post', listConfig, context, { id: '1' }),
582
+ })
583
+
584
+ const arg = afterOp.mock.calls[0][0]
585
+ expect(arg.operation).toBe('delete')
586
+ expect(arg.originalItem).toEqual(existing)
587
+ })
588
+ })