@opensaas/stack-storage 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.
@@ -0,0 +1,248 @@
1
+ /** Ordered list of image parts, matching Keystone's column layout. */
2
+ export const IMAGE_COLUMN_PARTS = [
3
+ 'url',
4
+ 'width',
5
+ 'height',
6
+ 'filesize',
7
+ 'contentType',
8
+ 'contentDisposition',
9
+ 'pathname',
10
+ ];
11
+ /** Ordered list of file parts, matching Keystone's column layout. */
12
+ export const FILE_COLUMN_PARTS = ['filename', 'filesize', 'url'];
13
+ /** The Prisma scalar type each image part is stored as. */
14
+ const IMAGE_PART_PRISMA_TYPE = {
15
+ url: 'String',
16
+ width: 'Int',
17
+ height: 'Int',
18
+ filesize: 'Int',
19
+ contentType: 'String',
20
+ contentDisposition: 'String',
21
+ pathname: 'String',
22
+ };
23
+ /** The Prisma scalar type each file part is stored as. */
24
+ const FILE_PART_PRISMA_TYPE = {
25
+ filename: 'String',
26
+ filesize: 'Int',
27
+ url: 'String',
28
+ };
29
+ /**
30
+ * Build the default per-part column-name map for an image field, following
31
+ * Keystone's `<field>_<part>` convention.
32
+ *
33
+ * @example
34
+ * keystoneImageColumnMap('image') // { url: 'image_url', width: 'image_width', ... }
35
+ */
36
+ export function keystoneImageColumnMap(fieldName) {
37
+ return {
38
+ url: `${fieldName}_url`,
39
+ width: `${fieldName}_width`,
40
+ height: `${fieldName}_height`,
41
+ filesize: `${fieldName}_filesize`,
42
+ contentType: `${fieldName}_contentType`,
43
+ contentDisposition: `${fieldName}_contentDisposition`,
44
+ pathname: `${fieldName}_pathname`,
45
+ };
46
+ }
47
+ /**
48
+ * Build the default per-part column-name map for a file field.
49
+ *
50
+ * @example
51
+ * keystoneFileColumnMap('file') // { filename: 'file_filename', filesize: 'file_filesize', url: 'file_url' }
52
+ */
53
+ export function keystoneFileColumnMap(fieldName) {
54
+ return {
55
+ filename: `${fieldName}_filename`,
56
+ filesize: `${fieldName}_filesize`,
57
+ url: `${fieldName}_url`,
58
+ };
59
+ }
60
+ /**
61
+ * Resolve the effective image column map: defaults from {@link keystoneImageColumnMap},
62
+ * with any caller-supplied per-part overrides applied on top.
63
+ */
64
+ export function resolveImageColumnMap(fieldName, overrides) {
65
+ return { ...keystoneImageColumnMap(fieldName), ...overrides };
66
+ }
67
+ /**
68
+ * Resolve the effective file column map.
69
+ */
70
+ export function resolveFileColumnMap(fieldName, overrides) {
71
+ return { ...keystoneFileColumnMap(fieldName), ...overrides };
72
+ }
73
+ /**
74
+ * The Prisma model field name carrying a given image part's column. We name the
75
+ * model field after the physical column itself (i.e. `<field>_url`) so the
76
+ * generated property and its `@map` line up with the live Keystone column.
77
+ */
78
+ function imagePartFieldName(map, part) {
79
+ return map[part];
80
+ }
81
+ function filePartFieldName(map, part) {
82
+ return map[part];
83
+ }
84
+ /**
85
+ * Describe the physical columns an image field emits in multi-column mode.
86
+ * Each descriptor's `name` is the Prisma model field name and `map` is the
87
+ * physical column it maps to (here they are identical so the schema reads
88
+ * naturally over the live columns).
89
+ */
90
+ export function imageColumnDescriptors(map) {
91
+ return IMAGE_COLUMN_PARTS.map((part) => ({
92
+ name: imagePartFieldName(map, part),
93
+ type: IMAGE_PART_PRISMA_TYPE[part],
94
+ map: map[part],
95
+ }));
96
+ }
97
+ /**
98
+ * Describe the physical columns a file field emits in multi-column mode.
99
+ */
100
+ export function fileColumnDescriptors(map) {
101
+ return FILE_COLUMN_PARTS.map((part) => ({
102
+ name: filePartFieldName(map, part),
103
+ type: FILE_PART_PRISMA_TYPE[part],
104
+ map: map[part],
105
+ }));
106
+ }
107
+ /** The physical column field names an image field owns (for read stripping). */
108
+ export function imageColumnNames(map) {
109
+ return IMAGE_COLUMN_PARTS.map((part) => imagePartFieldName(map, part));
110
+ }
111
+ /** The physical column field names a file field owns (for read stripping). */
112
+ export function fileColumnNames(map) {
113
+ return FILE_COLUMN_PARTS.map((part) => filePartFieldName(map, part));
114
+ }
115
+ /** Coerce an unknown column value to a string, or `null` when absent/empty. */
116
+ function asString(value) {
117
+ if (value === null || value === undefined)
118
+ return null;
119
+ if (typeof value === 'string')
120
+ return value;
121
+ return String(value);
122
+ }
123
+ /** Coerce an unknown column value to a number, or `null` when absent/invalid. */
124
+ function asNumber(value) {
125
+ if (value === null || value === undefined)
126
+ return null;
127
+ if (typeof value === 'number' && Number.isFinite(value))
128
+ return value;
129
+ if (typeof value === 'string' && value.trim() !== '') {
130
+ const n = Number(value);
131
+ return Number.isFinite(n) ? n : null;
132
+ }
133
+ if (typeof value === 'bigint')
134
+ return Number(value);
135
+ return null;
136
+ }
137
+ /**
138
+ * Assemble the per-part image columns from a database row into an
139
+ * {@link ImageMetadata}.
140
+ *
141
+ * Returns `null` when the row carries no image at all (the `url` column is the
142
+ * authoritative presence signal — Keystone leaves every part NULL for an empty
143
+ * image). Partially-populated rows (e.g. only `image_url`) still assemble into a
144
+ * valid object, with missing scalar parts defaulted (width/height/size → 0).
145
+ *
146
+ * `storageProvider` is supplied by the caller (the field knows which provider it
147
+ * is bound to); legacy columns do not carry it.
148
+ */
149
+ export function assembleImageMetadata(row, map, storageProvider) {
150
+ const url = asString(row[map.url]);
151
+ if (url === null || url === '') {
152
+ return null;
153
+ }
154
+ const pathname = asString(row[map.pathname]);
155
+ const contentType = asString(row[map.contentType]);
156
+ const contentDisposition = asString(row[map.contentDisposition]);
157
+ // Keystone stores the storage key in `pathname`; fall back to the URL when
158
+ // absent so `filename` is always populated.
159
+ const filename = pathname ?? url;
160
+ const metadata = {
161
+ filename,
162
+ originalFilename: filename,
163
+ url,
164
+ mimeType: contentType ?? 'application/octet-stream',
165
+ size: asNumber(row[map.filesize]) ?? 0,
166
+ width: asNumber(row[map.width]) ?? 0,
167
+ height: asNumber(row[map.height]) ?? 0,
168
+ uploadedAt: '',
169
+ storageProvider,
170
+ };
171
+ // Preserve the Keystone-only content disposition (no first-class metadata
172
+ // field) so a round-trip back to columns does not lose it.
173
+ if (contentDisposition !== null) {
174
+ metadata.metadata = { contentDisposition };
175
+ }
176
+ return metadata;
177
+ }
178
+ /**
179
+ * Split an {@link ImageMetadata} (or `null`) back into the per-part image
180
+ * columns for writing. A `null`/`undefined` metadata clears every column.
181
+ */
182
+ export function splitImageMetadata(metadata, map) {
183
+ if (metadata === null || metadata === undefined) {
184
+ return {
185
+ [map.url]: null,
186
+ [map.width]: null,
187
+ [map.height]: null,
188
+ [map.filesize]: null,
189
+ [map.contentType]: null,
190
+ [map.contentDisposition]: null,
191
+ [map.pathname]: null,
192
+ };
193
+ }
194
+ const contentDisposition = metadata.metadata && typeof metadata.metadata.contentDisposition === 'string'
195
+ ? metadata.metadata.contentDisposition
196
+ : null;
197
+ return {
198
+ [map.url]: metadata.url,
199
+ [map.width]: metadata.width ?? null,
200
+ [map.height]: metadata.height ?? null,
201
+ [map.filesize]: metadata.size ?? null,
202
+ [map.contentType]: metadata.mimeType ?? null,
203
+ [map.contentDisposition]: contentDisposition,
204
+ // Keystone's pathname holds the storage key (our `filename`).
205
+ [map.pathname]: metadata.filename ?? null,
206
+ };
207
+ }
208
+ /**
209
+ * Assemble the per-part file columns from a database row into a
210
+ * {@link FileMetadata}. Returns `null` when no file is present.
211
+ */
212
+ export function assembleFileMetadata(row, map, storageProvider) {
213
+ const url = asString(row[map.url]);
214
+ const filename = asString(row[map.filename]);
215
+ // Either a URL or a filename signals a present file.
216
+ if ((url === null || url === '') && (filename === null || filename === '')) {
217
+ return null;
218
+ }
219
+ const resolvedFilename = filename ?? url ?? '';
220
+ return {
221
+ filename: resolvedFilename,
222
+ originalFilename: resolvedFilename,
223
+ url: url ?? '',
224
+ mimeType: 'application/octet-stream',
225
+ size: asNumber(row[map.filesize]) ?? 0,
226
+ uploadedAt: '',
227
+ storageProvider,
228
+ };
229
+ }
230
+ /**
231
+ * Split a {@link FileMetadata} (or `null`) back into the per-part file columns
232
+ * for writing. A `null`/`undefined` metadata clears every column.
233
+ */
234
+ export function splitFileMetadata(metadata, map) {
235
+ if (metadata === null || metadata === undefined) {
236
+ return {
237
+ [map.filename]: null,
238
+ [map.filesize]: null,
239
+ [map.url]: null,
240
+ };
241
+ }
242
+ return {
243
+ [map.filename]: metadata.filename ?? null,
244
+ [map.filesize]: metadata.size ?? null,
245
+ [map.url]: metadata.url ?? null,
246
+ };
247
+ }
248
+ //# sourceMappingURL=multi-column.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"multi-column.js","sourceRoot":"","sources":["../../src/utils/multi-column.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opensaas/stack-storage",
3
- "version": "0.20.1",
3
+ "version": "0.22.0",
4
4
  "description": "File and image upload field types with pluggable storage providers for OpenSaas Stack",
5
5
  "type": "module",
6
6
  "exports": {
@@ -30,12 +30,12 @@
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/mime-types": "^3.0.1",
33
- "@types/node": "^24.12.0",
33
+ "@types/node": "^25.9.1",
34
34
  "@types/react": "^19.2.14",
35
35
  "@vitest/coverage-v8": "^4.0.18",
36
36
  "typescript": "^5.9.3",
37
37
  "vitest": "^4.1.0",
38
- "@opensaas/stack-core": "0.20.1"
38
+ "@opensaas/stack-core": "0.22.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@opensaas/stack-core": "^0"
@@ -108,7 +108,11 @@ export type StorageConfig = Record<string, BaseStorageConfig | LocalStorageConfi
108
108
  * Re-export metadata types from core package
109
109
  * These types are now defined in @opensaas/stack-core to avoid circular dependencies
110
110
  */
111
- export type { FileMetadata, ImageMetadata, ImageTransformationResult } from '@opensaas/stack-core'
111
+ export type {
112
+ FileMetadata,
113
+ ImageMetadata,
114
+ ImageTransformationResult,
115
+ } from '@opensaas/stack-core/internal'
112
116
 
113
117
  /**
114
118
  * Configuration for image transformations
@@ -1,8 +1,99 @@
1
- import type { BaseFieldConfig, TypeInfo } from '@opensaas/stack-core'
1
+ import type {
2
+ BaseFieldConfig,
3
+ TypeInfo,
4
+ MultiColumnPrismaResult,
5
+ } from '@opensaas/stack-core/extend'
2
6
  import { z } from 'zod'
3
7
  import type { ComponentType } from 'react'
4
8
  import type { FileMetadata, ImageMetadata, ImageTransformationConfig } from '../config/types.js'
5
9
  import type { FileValidationOptions } from '../utils/upload.js'
10
+ import {
11
+ assembleFileMetadata,
12
+ assembleImageMetadata,
13
+ fileColumnDescriptors,
14
+ fileColumnNames,
15
+ imageColumnDescriptors,
16
+ imageColumnNames,
17
+ resolveFileColumnMap,
18
+ resolveImageColumnMap,
19
+ splitFileMetadata,
20
+ splitImageMetadata,
21
+ type FileColumnMap,
22
+ type ImageColumnMap,
23
+ } from '../utils/multi-column.js'
24
+
25
+ /**
26
+ * Multi-column (Keystone-parity) database mode for image()/file() fields.
27
+ *
28
+ * The default backing for image()/file() is a single `Json?` column. A
29
+ * migrating project that already has a Keystone database can instead set
30
+ * `columns: 'keystone'` to map the field onto the existing per-part columns in
31
+ * place — no destructive migration, no re-upload of existing assets. The
32
+ * per-part column names (used in `@map`) follow Keystone's `<field>_<part>`
33
+ * convention and can be overridden individually. See ADR-0006.
34
+ */
35
+ export interface ImageDbConfig {
36
+ /** Custom database column name for single-Json? mode (unused in multi-column mode). */
37
+ map?: string
38
+ /** Override DB-level nullability for single-Json? mode. */
39
+ isNullable?: boolean
40
+ /** Override the native database type for single-Json? mode. */
41
+ nativeType?: string
42
+ /**
43
+ * Enable multi-column mode by setting `'keystone'`. Per-part column-name
44
+ * overrides may be supplied; any omitted part falls back to the Keystone
45
+ * default `<field>_<part>`.
46
+ */
47
+ columns?: 'keystone' | { mode: 'keystone'; map?: Partial<ImageColumnMap> }
48
+ }
49
+
50
+ /**
51
+ * Multi-column (Keystone-parity) database mode for file() fields.
52
+ */
53
+ export interface FileDbConfig {
54
+ /** Custom database column name for single-Json? mode (unused in multi-column mode). */
55
+ map?: string
56
+ /** Override DB-level nullability for single-Json? mode. */
57
+ isNullable?: boolean
58
+ /** Override the native database type for single-Json? mode. */
59
+ nativeType?: string
60
+ /**
61
+ * Enable multi-column mode by setting `'keystone'`. Per-part column-name
62
+ * overrides may be supplied; any omitted part falls back to the Keystone
63
+ * default `<field>_<part>`.
64
+ */
65
+ columns?: 'keystone' | { mode: 'keystone'; map?: Partial<FileColumnMap> }
66
+ }
67
+
68
+ /** Whether a field-level `db.columns` option requests multi-column mode. */
69
+ function isMultiColumn(columns: ImageDbConfig['columns'] | FileDbConfig['columns']): boolean {
70
+ return columns === 'keystone' || (typeof columns === 'object' && columns?.mode === 'keystone')
71
+ }
72
+
73
+ /** Extract any per-part `@map` overrides from a `db.columns` option. */
74
+ function imageColumnOverrides(
75
+ columns: ImageDbConfig['columns'],
76
+ ): Partial<ImageColumnMap> | undefined {
77
+ return typeof columns === 'object' ? columns.map : undefined
78
+ }
79
+
80
+ function fileColumnOverrides(columns: FileDbConfig['columns']): Partial<FileColumnMap> | undefined {
81
+ return typeof columns === 'object' ? columns.map : undefined
82
+ }
83
+
84
+ /**
85
+ * Detect a File-like input (something we should upload). An already-shaped
86
+ * metadata value or populated multi-column row is authoritative and must never
87
+ * trigger an upload (the no-re-upload guarantee — see ADR-0006).
88
+ */
89
+ function isFileLike(value: unknown): value is File {
90
+ return (
91
+ typeof value === 'object' &&
92
+ value !== null &&
93
+ 'arrayBuffer' in value &&
94
+ typeof (value as { arrayBuffer?: unknown }).arrayBuffer === 'function'
95
+ )
96
+ }
6
97
 
7
98
  /**
8
99
  * File field configuration
@@ -19,6 +110,12 @@ export interface FileFieldConfig<
19
110
  cleanupOnDelete?: boolean
20
111
  /** Automatically delete old file from storage when replaced with new file */
21
112
  cleanupOnReplace?: boolean
113
+ /**
114
+ * Database configuration. By default a file is backed by a single `Json?`
115
+ * column; set `db.columns: 'keystone'` to map onto existing Keystone per-part
116
+ * columns in place (non-destructive migration). See ADR-0006.
117
+ */
118
+ db?: FileDbConfig
22
119
  /** UI options */
23
120
  ui?: {
24
121
  /** Custom component to use for rendering this field */
@@ -53,6 +150,12 @@ export interface ImageFieldConfig<
53
150
  cleanupOnDelete?: boolean
54
151
  /** Automatically delete old file from storage when replaced with new file */
55
152
  cleanupOnReplace?: boolean
153
+ /**
154
+ * Database configuration. By default an image is backed by a single `Json?`
155
+ * column; set `db.columns: 'keystone'` to map onto existing Keystone per-part
156
+ * columns in place (non-destructive migration). See ADR-0006.
157
+ */
158
+ db?: ImageDbConfig
56
159
  /** UI options */
57
160
  ui?: {
58
161
  /** Custom component to use for rendering this field */
@@ -97,37 +200,47 @@ export function file<TTypeInfo extends TypeInfo = TypeInfo>(
97
200
  ): FileFieldConfig<TTypeInfo> {
98
201
  const { hooks: userHooks, ...restOptions } = options
99
202
 
203
+ // Multi-column (Keystone-parity) mode maps onto existing per-part columns
204
+ // instead of a single Json? column. The column map is resolved lazily per
205
+ // field name so default `<field>_<part>` names line up with the live columns.
206
+ const multiColumn = isMultiColumn(options.db?.columns)
207
+ const columnMapFor = (fieldName: string): FileColumnMap =>
208
+ resolveFileColumnMap(fieldName, fileColumnOverrides(options.db?.columns))
209
+
100
210
  const fieldConfig: FileFieldConfig<TTypeInfo> = {
101
211
  type: 'file',
102
212
  ...restOptions,
103
213
 
104
- // Override Prisma's Json type with FileMetadata | null in context.db types
214
+ // Override Prisma's Json type with FileMetadata | null in context.db types.
215
+ // Multi-column mode adds the same logical field back via TransformedFields
216
+ // while the raw per-part columns are stripped from the payload.
105
217
  resultExtension: {
106
218
  outputType: "import('@opensaas/stack-storage').FileMetadata | null",
107
219
  },
108
220
 
109
221
  hooks: {
222
+ // Keystone-compliant field resolveInput args: the field value lives at
223
+ // `resolvedData[fieldKey]`. See FieldResolveInputHookArgs in core.
110
224
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks are generic and resolved at runtime
111
- resolveInput: async ({ inputValue, context, item, fieldName }: any) => {
225
+ resolveInput: async ({ resolvedData, fieldKey, context, item }: any) => {
226
+ const inputValue = resolvedData?.[fieldKey]
227
+
112
228
  // If null/undefined, return as-is (deletion or no change)
113
229
  if (inputValue === null || inputValue === undefined) {
114
230
  return inputValue
115
231
  }
116
232
 
117
- // If already FileMetadata, keep existing (edit mode - no new file uploaded)
233
+ // If already FileMetadata, keep existing (edit mode - no new file
234
+ // uploaded). An existing metadata value is AUTHORITATIVE and must never
235
+ // re-upload. See ADR-0006.
118
236
  if (typeof inputValue === 'object' && 'filename' in inputValue && 'url' in inputValue) {
119
237
  return inputValue as FileMetadata
120
238
  }
121
239
 
122
- // If File object, upload it
123
- // Check if it's a File-like object (has arrayBuffer method)
124
- if (
125
- typeof inputValue === 'object' &&
126
- 'arrayBuffer' in inputValue &&
127
- typeof (inputValue as { arrayBuffer?: unknown }).arrayBuffer === 'function'
128
- ) {
240
+ // Only a File-like input triggers an upload.
241
+ if (isFileLike(inputValue)) {
129
242
  // Convert File to buffer
130
- const fileObj = inputValue as File
243
+ const fileObj = inputValue
131
244
  const arrayBuffer = await fileObj.arrayBuffer()
132
245
  const buffer = Buffer.from(arrayBuffer)
133
246
 
@@ -137,8 +250,8 @@ export function file<TTypeInfo extends TypeInfo = TypeInfo>(
137
250
  })) as FileMetadata
138
251
 
139
252
  // If cleanupOnReplace is enabled and there was an old file, delete it
140
- if (fieldConfig.cleanupOnReplace && item && fieldName) {
141
- const oldMetadata = item[fieldName] as FileMetadata | null
253
+ if (fieldConfig.cleanupOnReplace && item && fieldKey) {
254
+ const oldMetadata = item[fieldKey] as FileMetadata | null
142
255
  if (oldMetadata && oldMetadata.filename) {
143
256
  try {
144
257
  await context.storage.deleteFile(oldMetadata.storageProvider, oldMetadata.filename)
@@ -157,12 +270,12 @@ export function file<TTypeInfo extends TypeInfo = TypeInfo>(
157
270
  },
158
271
 
159
272
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks are generic and resolved at runtime
160
- afterOperation: async ({ operation, item, fieldName, context }: any) => {
161
- // Only cleanup on delete if enabled
273
+ afterOperation: async ({ operation, originalItem, fieldKey, context }: any) => {
274
+ // Only cleanup on delete if enabled. The deleted row is `originalItem`.
162
275
  if (operation === 'delete' && fieldConfig.cleanupOnDelete) {
163
- const fileMetadata = item[fieldName] as FileMetadata | null
276
+ const fileMetadata = originalItem?.[fieldKey] as FileMetadata | null
164
277
 
165
- if (fileMetadata && fileMetadata.filename) {
278
+ if (fileMetadata && typeof fileMetadata === 'object' && fileMetadata.filename) {
166
279
  try {
167
280
  await context.storage.deleteFile(fileMetadata.storageProvider, fileMetadata.filename)
168
281
  } catch (error) {
@@ -194,7 +307,7 @@ export function file<TTypeInfo extends TypeInfo = TypeInfo>(
194
307
  },
195
308
 
196
309
  getPrismaType: (_fieldName: string) => {
197
- // Store as JSON in database
310
+ // Store as JSON in database (single-column default mode).
198
311
  return { type: 'Json', modifiers: '?' }
199
312
  },
200
313
 
@@ -217,6 +330,26 @@ export function file<TTypeInfo extends TypeInfo = TypeInfo>(
217
330
  },
218
331
  }
219
332
 
333
+ // Multi-column emission + assemble/split. Only attached in multi-column mode
334
+ // so the single-Json? default is completely unaffected.
335
+ if (multiColumn) {
336
+ fieldConfig.getPrismaColumns = (fieldName: string): MultiColumnPrismaResult[] => {
337
+ const map = columnMapFor(fieldName)
338
+ return fileColumnDescriptors(map).map((col) => ({
339
+ name: col.name,
340
+ type: col.type,
341
+ modifiers: '?',
342
+ map: col.map,
343
+ }))
344
+ }
345
+ fieldConfig.getColumnNames = (fieldName: string): string[] =>
346
+ fileColumnNames(columnMapFor(fieldName))
347
+ fieldConfig.assembleColumns = (fieldName: string, row: Record<string, unknown>): unknown =>
348
+ assembleFileMetadata(row, columnMapFor(fieldName), fieldConfig.storage)
349
+ fieldConfig.splitColumns = (fieldName: string, value: unknown): Record<string, unknown> =>
350
+ splitFileMetadata((value ?? null) as FileMetadata | null, columnMapFor(fieldName))
351
+ }
352
+
220
353
  return fieldConfig
221
354
  }
222
355
 
@@ -247,24 +380,38 @@ export function image<TTypeInfo extends TypeInfo = TypeInfo>(
247
380
  ): ImageFieldConfig<TTypeInfo> {
248
381
  const { hooks: userHooks, ...restOptions } = options
249
382
 
383
+ // Multi-column (Keystone-parity) mode maps onto existing per-part columns
384
+ // instead of a single Json? column. See ADR-0006.
385
+ const multiColumn = isMultiColumn(options.db?.columns)
386
+ const columnMapFor = (fieldName: string): ImageColumnMap =>
387
+ resolveImageColumnMap(fieldName, imageColumnOverrides(options.db?.columns))
388
+
250
389
  const fieldConfig: ImageFieldConfig<TTypeInfo> = {
251
390
  type: 'image',
252
391
  ...restOptions,
253
392
 
254
- // Override Prisma's Json type with ImageMetadata | null in context.db types
393
+ // Override Prisma's Json type with ImageMetadata | null in context.db types.
394
+ // Multi-column mode adds the same logical field back via TransformedFields
395
+ // while the raw per-part columns are stripped from the payload.
255
396
  resultExtension: {
256
397
  outputType: "import('@opensaas/stack-storage').ImageMetadata | null",
257
398
  },
258
399
 
259
400
  hooks: {
401
+ // Keystone-compliant field resolveInput args: the field value lives at
402
+ // `resolvedData[fieldKey]`. See FieldResolveInputHookArgs in core.
260
403
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks are generic and resolved at runtime
261
- resolveInput: async ({ inputValue, context, item, fieldName }: any) => {
404
+ resolveInput: async ({ resolvedData, fieldKey, context, item }: any) => {
405
+ const inputValue = resolvedData?.[fieldKey]
406
+
262
407
  // If null/undefined, return as-is (deletion or no change)
263
408
  if (inputValue === null || inputValue === undefined) {
264
409
  return inputValue
265
410
  }
266
411
 
267
- // If already ImageMetadata, keep existing (edit mode - no new file uploaded)
412
+ // If already ImageMetadata, keep existing (edit mode - no new file
413
+ // uploaded). An existing metadata value is AUTHORITATIVE and must never
414
+ // re-upload. See ADR-0006.
268
415
  if (
269
416
  typeof inputValue === 'object' &&
270
417
  'filename' in inputValue &&
@@ -275,15 +422,10 @@ export function image<TTypeInfo extends TypeInfo = TypeInfo>(
275
422
  return inputValue as ImageMetadata
276
423
  }
277
424
 
278
- // If File object, upload it
279
- // Check if it's a File-like object (has arrayBuffer method)
280
- if (
281
- typeof inputValue === 'object' &&
282
- 'arrayBuffer' in inputValue &&
283
- typeof (inputValue as { arrayBuffer?: unknown }).arrayBuffer === 'function'
284
- ) {
425
+ // Only a File-like input triggers an upload.
426
+ if (isFileLike(inputValue)) {
285
427
  // Convert File to buffer
286
- const fileObj = inputValue as File
428
+ const fileObj = inputValue
287
429
  const arrayBuffer = await fileObj.arrayBuffer()
288
430
  const buffer = Buffer.from(arrayBuffer)
289
431
 
@@ -299,8 +441,8 @@ export function image<TTypeInfo extends TypeInfo = TypeInfo>(
299
441
  )) as ImageMetadata
300
442
 
301
443
  // If cleanupOnReplace is enabled and there was an old file, delete it
302
- if (fieldConfig.cleanupOnReplace && item && fieldName) {
303
- const oldMetadata = item[fieldName] as ImageMetadata | null
444
+ if (fieldConfig.cleanupOnReplace && item && fieldKey) {
445
+ const oldMetadata = item[fieldKey] as ImageMetadata | null
304
446
  if (oldMetadata && oldMetadata.filename) {
305
447
  try {
306
448
  await context.storage.deleteImage(oldMetadata)
@@ -319,12 +461,12 @@ export function image<TTypeInfo extends TypeInfo = TypeInfo>(
319
461
  },
320
462
 
321
463
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks are generic and resolved at runtime
322
- afterOperation: async ({ operation, item, fieldName, context }: any) => {
323
- // Only cleanup on delete if enabled
464
+ afterOperation: async ({ operation, originalItem, fieldKey, context }: any) => {
465
+ // Only cleanup on delete if enabled. The deleted row is `originalItem`.
324
466
  if (operation === 'delete' && fieldConfig.cleanupOnDelete) {
325
- const imageMetadata = item[fieldName] as ImageMetadata | null
467
+ const imageMetadata = originalItem?.[fieldKey] as ImageMetadata | null
326
468
 
327
- if (imageMetadata && imageMetadata.filename) {
469
+ if (imageMetadata && typeof imageMetadata === 'object' && imageMetadata.filename) {
328
470
  try {
329
471
  await context.storage.deleteImage(imageMetadata)
330
472
  } catch (error) {
@@ -392,5 +534,25 @@ export function image<TTypeInfo extends TypeInfo = TypeInfo>(
392
534
  },
393
535
  }
394
536
 
537
+ // Multi-column emission + assemble/split. Only attached in multi-column mode
538
+ // so the single-Json? default is completely unaffected.
539
+ if (multiColumn) {
540
+ fieldConfig.getPrismaColumns = (fieldName: string): MultiColumnPrismaResult[] => {
541
+ const map = columnMapFor(fieldName)
542
+ return imageColumnDescriptors(map).map((col) => ({
543
+ name: col.name,
544
+ type: col.type,
545
+ modifiers: '?',
546
+ map: col.map,
547
+ }))
548
+ }
549
+ fieldConfig.getColumnNames = (fieldName: string): string[] =>
550
+ imageColumnNames(columnMapFor(fieldName))
551
+ fieldConfig.assembleColumns = (fieldName: string, row: Record<string, unknown>): unknown =>
552
+ assembleImageMetadata(row, columnMapFor(fieldName), fieldConfig.storage)
553
+ fieldConfig.splitColumns = (fieldName: string, value: unknown): Record<string, unknown> =>
554
+ splitImageMetadata((value ?? null) as ImageMetadata | null, columnMapFor(fieldName))
555
+ }
556
+
395
557
  return fieldConfig
396
558
  }
@@ -1,2 +1,3 @@
1
1
  export * from './image.js'
2
2
  export * from './upload.js'
3
+ export * from './multi-column.js'