@opensaas/stack-storage 0.21.0 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +33 -0
- package/dist/fields/index.d.ts +60 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +101 -29
- package/dist/fields/index.js.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/multi-column.d.ts +112 -0
- package/dist/utils/multi-column.d.ts.map +1 -0
- package/dist/utils/multi-column.js +248 -0
- package/dist/utils/multi-column.js.map +1 -0
- package/package.json +2 -2
- package/src/fields/index.ts +198 -36
- package/src/utils/index.ts +1 -0
- package/src/utils/multi-column.ts +346 -0
- package/tests/multi-column-fields.test.ts +397 -0
- package/tests/multi-column.test.ts +308 -0
|
@@ -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.
|
|
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": {
|
|
@@ -35,7 +35,7 @@
|
|
|
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.
|
|
38
|
+
"@opensaas/stack-core": "0.22.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"@opensaas/stack-core": "^0"
|
package/src/fields/index.ts
CHANGED
|
@@ -1,8 +1,99 @@
|
|
|
1
|
-
import type {
|
|
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 ({
|
|
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
|
|
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
|
-
//
|
|
123
|
-
|
|
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
|
|
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 &&
|
|
141
|
-
const oldMetadata = item[
|
|
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,
|
|
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 =
|
|
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 ({
|
|
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
|
|
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
|
-
//
|
|
279
|
-
|
|
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
|
|
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 &&
|
|
303
|
-
const oldMetadata = item[
|
|
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,
|
|
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 =
|
|
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
|
}
|
package/src/utils/index.ts
CHANGED