@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,269 @@
1
+ import type { Session, AccessContext } from './types.js'
2
+ import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
3
+ import { getRelatedListConfig } from './engine.js'
4
+ import { checkFieldAccess } from './field-access.js'
5
+
6
+ /**
7
+ * Field Visibility — phase 2 of the two-phase read (post-query).
8
+ *
9
+ * This module runs after the database query against the returned rows. It
10
+ * strips fields the session cannot read (via the canonical `checkFieldAccess`
11
+ * evaluator in `field-access.ts`), runs `resolveOutput` hooks, and computes
12
+ * virtual fields. None of this can move into phase 1: virtual fields are
13
+ * computed in JavaScript and field access can depend on the fetched row.
14
+ *
15
+ * Phase 1 (pre-query row/relation scoping) lives in `access-filter.ts`. See
16
+ * `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
17
+ * glossary in `CONTEXT.md`.
18
+ */
19
+
20
+ /**
21
+ * Runtime type for resolveOutput hooks
22
+ * Used when we need to call hooks generically without knowing the specific field type
23
+ * Supports both sync and async implementations
24
+ */
25
+ type ResolveOutputHookRuntime = (args: {
26
+ operation: 'query'
27
+ value: unknown
28
+ item: Record<string, unknown>
29
+ listKey: string
30
+ fieldName: string
31
+ context: AccessContext
32
+ }) => unknown | Promise<unknown>
33
+
34
+ type FieldVisibilityArgs = {
35
+ session: Session | null
36
+ context: AccessContext & { _isSudo?: boolean }
37
+ }
38
+
39
+ /**
40
+ * The core Field Visibility step for a single field: check read access and, if
41
+ * granted, produce the output value by running any `resolveOutput` hook.
42
+ *
43
+ * This is the single place the "check read access → skip if denied →
44
+ * resolveOutput" sequence lives. Both the regular-field branch and the
45
+ * virtual-field branch of `filterReadableFields` call it, so the sequence is
46
+ * never duplicated. Returns `{ readable: false }` when the field must be omitted
47
+ * from the result.
48
+ *
49
+ * `accessItem` is the row used to evaluate field access; `hookItem` is the
50
+ * object passed to the hook as `item` (these differ for virtual fields, which
51
+ * see the already-filtered output so they can read sibling fields).
52
+ */
53
+ async function resolveReadableFieldValue(params: {
54
+ fieldConfig: FieldConfig | undefined
55
+ fieldName: string
56
+ value: unknown
57
+ accessItem: Record<string, unknown>
58
+ hookItem: Record<string, unknown>
59
+ listKey: string | undefined
60
+ args: FieldVisibilityArgs
61
+ }): Promise<{ readable: false } | { readable: true; value: unknown }> {
62
+ const { fieldConfig, fieldName, value, accessItem, hookItem, listKey, args } = params
63
+
64
+ // Check field access (checkFieldAccess already handles sudo mode)
65
+ const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
66
+ ...args,
67
+ item: accessItem,
68
+ })
69
+
70
+ if (!canRead) {
71
+ return { readable: false }
72
+ }
73
+
74
+ // Apply resolveOutput hook if present
75
+ if (fieldConfig?.hooks?.resolveOutput && listKey) {
76
+ // Cast to runtime type for generic execution
77
+ // At runtime, the hook will receive the correct value type for the field
78
+ const hook = fieldConfig.hooks.resolveOutput as unknown as ResolveOutputHookRuntime
79
+ // Increment depth counter to prevent infinite loops from hooks making DB queries
80
+ // that include relationships back to the same entity
81
+ args.context._resolveOutputCounter.depth++
82
+ try {
83
+ // Use Promise.resolve() to handle both sync and async hooks
84
+ const resolved = await Promise.resolve(
85
+ hook({
86
+ value,
87
+ operation: 'query',
88
+ fieldName,
89
+ listKey,
90
+ item: hookItem,
91
+ context: args.context,
92
+ }),
93
+ )
94
+ return { readable: true, value: resolved }
95
+ } finally {
96
+ args.context._resolveOutputCounter.depth--
97
+ }
98
+ }
99
+
100
+ return { readable: true, value }
101
+ }
102
+
103
+ /**
104
+ * Filter fields from an object based on read access
105
+ * Recursively applies access control to nested relationships
106
+ */
107
+ export async function filterReadableFields<T extends Record<string, unknown>>(
108
+ item: T,
109
+ fieldConfigs: Record<string, FieldConfig>,
110
+ args: {
111
+ session: Session | null
112
+ context: AccessContext & { _isSudo?: boolean }
113
+ },
114
+ config?: OpenSaasConfig,
115
+ depth: number = 0,
116
+ listKey?: string,
117
+ ): Promise<Partial<T>> {
118
+ const filtered: Record<string, unknown> = {}
119
+ const MAX_DEPTH = 5 // Prevent infinite recursion
120
+
121
+ // Multi-column fields (e.g. storage image()/file() in Keystone-parity mode)
122
+ // back several physical columns rather than one. Before the per-field pass,
123
+ // assemble each such field's logical value from its raw columns and remove the
124
+ // raw columns from the working row, so only the assembled value is exposed
125
+ // (the raw per-part columns never leak). The assembled value then flows
126
+ // through the normal read-access + resolveOutput path under the field's own
127
+ // key. See ADR-0006.
128
+ const workingItem: Record<string, unknown> = { ...item }
129
+ for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
130
+ if (!fieldConfig.assembleColumns || !fieldConfig.getColumnNames) continue
131
+ const columnNames = fieldConfig.getColumnNames(fieldName)
132
+ // Only assemble when the raw columns are present in the row (i.e. they were
133
+ // selected); otherwise leave the field absent from the result.
134
+ const hasAnyColumn = columnNames.some((name) => name in workingItem)
135
+ if (!hasAnyColumn) continue
136
+ const assembled = fieldConfig.assembleColumns(fieldName, workingItem)
137
+ for (const name of columnNames) {
138
+ delete workingItem[name]
139
+ }
140
+ workingItem[fieldName] = assembled
141
+ }
142
+
143
+ // Process existing fields from the database result
144
+ for (const [fieldName, value] of Object.entries(workingItem)) {
145
+ const fieldConfig = fieldConfigs[fieldName]
146
+
147
+ // Always include id, createdAt, updatedAt
148
+ if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
149
+ filtered[fieldName] = value
150
+ continue
151
+ }
152
+
153
+ // Handle relationship fields - recursively filter fields within related items
154
+ // Note: Access control filtering is now done at database level via buildIncludeWithAccessControl
155
+ // This only handles field-level access (hiding sensitive fields)
156
+ if (
157
+ config &&
158
+ fieldConfig?.type === 'relationship' &&
159
+ 'ref' in fieldConfig &&
160
+ fieldConfig.ref &&
161
+ value !== null &&
162
+ value !== undefined &&
163
+ depth < MAX_DEPTH
164
+ ) {
165
+ // Gate the relationship on read access before recursing.
166
+ const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
167
+ ...args,
168
+ item: workingItem,
169
+ })
170
+
171
+ if (!canRead) {
172
+ continue
173
+ }
174
+
175
+ const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
176
+
177
+ if (relatedConfig) {
178
+ // For many relationships (arrays) - recursively filter fields in each item
179
+ // The recursive call already handles applying resolveOutput hooks
180
+ if (Array.isArray(value)) {
181
+ filtered[fieldName] = await Promise.all(
182
+ value.map((relatedItem) =>
183
+ filterReadableFields(
184
+ relatedItem,
185
+ relatedConfig.listConfig.fields,
186
+ args,
187
+ config,
188
+ depth + 1,
189
+ relatedConfig.listName,
190
+ ),
191
+ ),
192
+ )
193
+ }
194
+ // For single relationships (objects) - recursively filter fields
195
+ // The recursive call already handles applying resolveOutput hooks
196
+ else if (typeof value === 'object') {
197
+ filtered[fieldName] = await filterReadableFields(
198
+ value as Record<string, unknown>,
199
+ relatedConfig.listConfig.fields,
200
+ args,
201
+ config,
202
+ depth + 1,
203
+ relatedConfig.listName,
204
+ )
205
+ }
206
+ } else {
207
+ // Related config not found, include the value as-is
208
+ filtered[fieldName] = value
209
+ }
210
+ continue
211
+ }
212
+
213
+ // Non-relationship field (or relationship without an includable value):
214
+ // check read access and apply resolveOutput via the shared helper.
215
+ const result = await resolveReadableFieldValue({
216
+ fieldConfig,
217
+ fieldName,
218
+ value,
219
+ accessItem: workingItem,
220
+ hookItem: workingItem,
221
+ listKey,
222
+ args,
223
+ })
224
+
225
+ if (result.readable) {
226
+ filtered[fieldName] = result.value
227
+ }
228
+ }
229
+
230
+ // Process virtual fields - compute values from other fields
231
+ // Virtual fields don't exist in the database result, so we need to compute them separately
232
+ for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
233
+ // Skip if already processed (from database result)
234
+ if (fieldName in filtered) {
235
+ continue
236
+ }
237
+
238
+ // Only process virtual fields
239
+ if (!fieldConfig.virtual) {
240
+ continue
241
+ }
242
+
243
+ // Virtual fields must have a resolveOutput hook to compute their value;
244
+ // without one there is nothing to add to the result.
245
+ if (!(fieldConfig.hooks?.resolveOutput && listKey)) {
246
+ // Still evaluate read access to preserve any access-fn side effects.
247
+ await checkFieldAccess(fieldConfig.access, 'read', { ...args, item: workingItem })
248
+ continue
249
+ }
250
+
251
+ // Check read access and compute the value via the shared helper. Virtual
252
+ // fields see the already-filtered item so they can read sibling fields.
253
+ const result = await resolveReadableFieldValue({
254
+ fieldConfig,
255
+ fieldName,
256
+ value: undefined, // Virtual fields don't have a database value
257
+ accessItem: workingItem,
258
+ hookItem: filtered,
259
+ listKey,
260
+ args,
261
+ })
262
+
263
+ if (result.readable) {
264
+ filtered[fieldName] = result.value
265
+ }
266
+ }
267
+
268
+ return filtered as Partial<T>
269
+ }
@@ -11,14 +11,17 @@ export type {
11
11
  AugmentedFindUnique,
12
12
  FindManyQueryArgs,
13
13
  } from './types.js'
14
+ // Operation-level access primitives and shared ref-parsing helper.
14
15
  export {
15
16
  checkAccess,
16
17
  mergeFilters,
17
- checkFieldAccess,
18
- filterReadableFields,
19
- filterWritableFields,
20
18
  isBoolean,
21
19
  isPrismaFilter,
22
20
  getRelatedListConfig,
23
- buildIncludeWithAccessControl,
24
21
  } from './engine.js'
22
+ // Canonical field-level access evaluation (shared by read and write paths).
23
+ export { checkFieldAccess, filterWritableFields } from './field-access.js'
24
+ // Phase 1 — Access Filter (pre-query row/relation scoping).
25
+ export { buildIncludeWithAccessControl } from './access-filter.js'
26
+ // Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
27
+ export { filterReadableFields } from './field-visibility.js'
@@ -0,0 +1,255 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { filterReadableFields } from './field-visibility.js'
3
+ import { executeFieldResolveInputHooks } from '../hooks/index.js'
4
+ import type { FieldConfig } from '../config/types.js'
5
+ import type { AccessContext, FieldAccess } from './types.js'
6
+
7
+ /**
8
+ * Generic core wiring for multi-column fields (the contract storage
9
+ * image()/file() use in Keystone-parity mode — see ADR-0006). These tests are
10
+ * field-agnostic: they assert that ANY field implementing
11
+ * getColumnNames/assembleColumns/splitColumns is assembled on read (raw columns
12
+ * stripped) and split on write.
13
+ */
14
+
15
+ // A minimal multi-column field: two physical columns `m_url` and `m_size`
16
+ // assembled into `{ url, size }` and split back. Optionally carries field-level
17
+ // access so we can lock the write-access gate around the split.
18
+ function multiColumnField(access?: FieldAccess): FieldConfig {
19
+ const COLUMNS = ['m_url', 'm_size']
20
+ return {
21
+ type: 'multiColumn',
22
+ access,
23
+ getColumnNames: () => COLUMNS,
24
+ assembleColumns: (_fieldName: string, row: Record<string, unknown>) => {
25
+ const url = row.m_url
26
+ if (url === null || url === undefined || url === '') return null
27
+ return { url, size: row.m_size ?? 0 }
28
+ },
29
+ splitColumns: (_fieldName: string, value: unknown) => {
30
+ if (value === null || value === undefined) {
31
+ return { m_url: null, m_size: null }
32
+ }
33
+ const v = value as { url?: unknown; size?: unknown }
34
+ return { m_url: v.url ?? null, m_size: v.size ?? null }
35
+ },
36
+ // Field's own resolveInput is identity here (the value is authoritative).
37
+ hooks: {
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic test hook
39
+ resolveInput: async ({ resolvedData, fieldKey }: any) => resolvedData?.[fieldKey],
40
+ },
41
+ } as unknown as FieldConfig
42
+ }
43
+
44
+ function makeContext(overrides: { isSudo?: boolean } = {}): AccessContext {
45
+ return {
46
+ session: null,
47
+ _isSudo: overrides.isSudo ?? false,
48
+ _resolveOutputCounter: { depth: 0 },
49
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
50
+ } as any
51
+ }
52
+
53
+ describe('multi-column read assembly (filterReadableFields)', () => {
54
+ const fields = { media: multiColumnField() }
55
+
56
+ it('assembles the per-part columns into the logical field and strips the raw columns', async () => {
57
+ const row = { id: 'a', m_url: 'https://x/y.jpg', m_size: 99, title: 'hi' }
58
+ const result = await filterReadableFields(
59
+ row,
60
+ // title is a plain field
61
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- inline field configs
62
+ { ...fields, title: { type: 'text' } as any },
63
+ { session: null, context: makeContext() },
64
+ undefined,
65
+ 0,
66
+ 'Post',
67
+ )
68
+ expect(result).toEqual({ id: 'a', title: 'hi', media: { url: 'https://x/y.jpg', size: 99 } })
69
+ // Raw columns must NOT leak.
70
+ expect('m_url' in result).toBe(false)
71
+ expect('m_size' in result).toBe(false)
72
+ })
73
+
74
+ it('assembles a partially-populated row (only m_url present)', async () => {
75
+ const row = { id: 'b', m_url: 'https://x/only.jpg' }
76
+ const result = await filterReadableFields(
77
+ row,
78
+ fields,
79
+ { session: null, context: makeContext() },
80
+ undefined,
81
+ 0,
82
+ 'Post',
83
+ )
84
+ expect(result).toEqual({ id: 'b', media: { url: 'https://x/only.jpg', size: 0 } })
85
+ })
86
+
87
+ it('yields a null logical value when the columns are empty', async () => {
88
+ const row = { id: 'c', m_url: null, m_size: null }
89
+ const result = await filterReadableFields(
90
+ row,
91
+ fields,
92
+ { session: null, context: makeContext() },
93
+ undefined,
94
+ 0,
95
+ 'Post',
96
+ )
97
+ expect(result).toEqual({ id: 'c', media: null })
98
+ })
99
+
100
+ it('leaves the field absent when its columns were not selected', async () => {
101
+ const row = { id: 'd', title: 'no media columns' }
102
+ const result = await filterReadableFields(
103
+ row,
104
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- inline field configs
105
+ { ...fields, title: { type: 'text' } as any },
106
+ { session: null, context: makeContext() },
107
+ undefined,
108
+ 0,
109
+ 'Post',
110
+ )
111
+ expect(result).toEqual({ id: 'd', title: 'no media columns' })
112
+ expect('media' in result).toBe(false)
113
+ })
114
+ })
115
+
116
+ describe('multi-column write split (executeFieldResolveInputHooks)', () => {
117
+ const fields = { media: multiColumnField() }
118
+
119
+ it('splits the logical value into per-part columns and removes the logical key', async () => {
120
+ const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
121
+ const result = await executeFieldResolveInputHooks(
122
+ inputData,
123
+ { ...inputData },
124
+ fields,
125
+ 'create',
126
+ makeContext(),
127
+ 'Post',
128
+ )
129
+ expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 })
130
+ expect('media' in result).toBe(false)
131
+ })
132
+
133
+ it('splitting null clears all per-part columns', async () => {
134
+ const inputData = { media: null }
135
+ const result = await executeFieldResolveInputHooks(
136
+ inputData,
137
+ { ...inputData },
138
+ fields,
139
+ 'update',
140
+ makeContext(),
141
+ 'Post',
142
+ )
143
+ expect(result).toEqual({ m_url: null, m_size: null })
144
+ })
145
+
146
+ it('does not touch the columns when the logical field is absent from the write', async () => {
147
+ const inputData = { title: 'no media in payload' }
148
+ const result = await executeFieldResolveInputHooks(
149
+ inputData,
150
+ { ...inputData },
151
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- inline field configs
152
+ { ...fields, title: { type: 'text' } as any },
153
+ 'update',
154
+ makeContext(),
155
+ 'Post',
156
+ )
157
+ expect(result).toEqual({ title: 'no media in payload' })
158
+ expect('m_url' in result).toBe(false)
159
+ })
160
+ })
161
+
162
+ describe('multi-column write split respects field-level write access', () => {
163
+ it('does NOT write any per-part columns when update access is denied', async () => {
164
+ const fields = { media: multiColumnField({ update: () => false }) }
165
+ const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
166
+ const result = await executeFieldResolveInputHooks(
167
+ inputData,
168
+ { ...inputData },
169
+ fields,
170
+ 'update',
171
+ makeContext(),
172
+ 'Post',
173
+ )
174
+ // The logical key is dropped (it is not a real column) AND none of its
175
+ // per-part columns are written — identical to how filterWritableFields
176
+ // drops a denied single-column field.
177
+ expect(result).toEqual({})
178
+ expect('media' in result).toBe(false)
179
+ expect('m_url' in result).toBe(false)
180
+ expect('m_size' in result).toBe(false)
181
+ })
182
+
183
+ it('does NOT write any per-part columns when create access is denied', async () => {
184
+ const fields = { media: multiColumnField({ create: () => false }) }
185
+ const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
186
+ const result = await executeFieldResolveInputHooks(
187
+ inputData,
188
+ { ...inputData },
189
+ fields,
190
+ 'create',
191
+ makeContext(),
192
+ 'Post',
193
+ )
194
+ expect(result).toEqual({})
195
+ expect('m_url' in result).toBe(false)
196
+ })
197
+
198
+ it('still splits/writes the columns when write access is granted', async () => {
199
+ const fields = { media: multiColumnField({ update: () => true, create: () => true }) }
200
+ const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
201
+ const result = await executeFieldResolveInputHooks(
202
+ inputData,
203
+ { ...inputData },
204
+ fields,
205
+ 'update',
206
+ makeContext(),
207
+ 'Post',
208
+ )
209
+ expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 })
210
+ expect('media' in result).toBe(false)
211
+ })
212
+
213
+ it('denying the OTHER operation does not block the write (update field, create op)', async () => {
214
+ // A field that denies `update` must still be writable on `create`.
215
+ const fields = { media: multiColumnField({ update: () => false }) }
216
+ const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
217
+ const result = await executeFieldResolveInputHooks(
218
+ inputData,
219
+ { ...inputData },
220
+ fields,
221
+ 'create',
222
+ makeContext(),
223
+ 'Post',
224
+ )
225
+ expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 })
226
+ })
227
+
228
+ it('sudo bypasses the field-access gate and still splits', async () => {
229
+ const fields = { media: multiColumnField({ update: () => false }) }
230
+ const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
231
+ const result = await executeFieldResolveInputHooks(
232
+ inputData,
233
+ { ...inputData },
234
+ fields,
235
+ 'update',
236
+ makeContext({ isSudo: true }),
237
+ 'Post',
238
+ )
239
+ expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 })
240
+ })
241
+
242
+ it('a multi-column field WITHOUT field-level access splits exactly as before', async () => {
243
+ const fields = { media: multiColumnField() }
244
+ const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
245
+ const result = await executeFieldResolveInputHooks(
246
+ inputData,
247
+ { ...inputData },
248
+ fields,
249
+ 'update',
250
+ makeContext(),
251
+ 'Post',
252
+ )
253
+ expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 })
254
+ })
255
+ })
@@ -122,6 +122,7 @@ export function list<TTypeInfo extends import('./types.js').TypeInfo>(
122
122
  // Re-export all types
123
123
  export type {
124
124
  OpenSaasConfig,
125
+ OutputConfig,
125
126
  ListConfig,
126
127
  ListConfigInput,
127
128
  ListAccessControl,
@@ -134,6 +135,8 @@ export type {
134
135
  PasswordField,
135
136
  SelectField,
136
137
  RelationshipField,
138
+ PrismaRelationResult,
139
+ MultiColumnPrismaResult,
137
140
  JsonField,
138
141
  VirtualField,
139
142
  TypeDescriptor,