@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +61 -0
- package/dist/config/types.d.ts +1 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/fields/index.d.ts +61 -1
- 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 +3 -3
- package/src/config/types.ts +5 -1
- 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,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure `metadata ↔ columns` mapper for the multi-column (Keystone-parity) mode
|
|
3
|
+
* of the `image()` / `file()` fields.
|
|
4
|
+
*
|
|
5
|
+
* Keystone stores an image across seven per-part columns and a file across
|
|
6
|
+
* three. To adopt a live Keystone database without a destructive migration (see
|
|
7
|
+
* ADR-0006), the storage fields can map onto those existing columns in place,
|
|
8
|
+
* assembling them into an {@link ImageMetadata} / {@link FileMetadata} on read
|
|
9
|
+
* and splitting a metadata value back into columns on write.
|
|
10
|
+
*
|
|
11
|
+
* This module is intentionally pure: no I/O, no storage-provider calls, no
|
|
12
|
+
* dependency on the field builders. It is unit-tested in isolation
|
|
13
|
+
* (`multi-column.test.ts`) and the read/write hooks call it.
|
|
14
|
+
*/
|
|
15
|
+
import type { FileMetadata, ImageMetadata } from '../config/types.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The logical "parts" of a Keystone image field. Each maps to one physical
|
|
19
|
+
* column (whose physical name is configurable via `@map`).
|
|
20
|
+
*/
|
|
21
|
+
export type ImageColumnPart =
|
|
22
|
+
| 'url'
|
|
23
|
+
| 'width'
|
|
24
|
+
| 'height'
|
|
25
|
+
| 'filesize'
|
|
26
|
+
| 'contentType'
|
|
27
|
+
| 'contentDisposition'
|
|
28
|
+
| 'pathname'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The logical "parts" of a Keystone file field.
|
|
32
|
+
*/
|
|
33
|
+
export type FileColumnPart = 'filename' | 'filesize' | 'url'
|
|
34
|
+
|
|
35
|
+
/** Ordered list of image parts, matching Keystone's column layout. */
|
|
36
|
+
export const IMAGE_COLUMN_PARTS: readonly ImageColumnPart[] = [
|
|
37
|
+
'url',
|
|
38
|
+
'width',
|
|
39
|
+
'height',
|
|
40
|
+
'filesize',
|
|
41
|
+
'contentType',
|
|
42
|
+
'contentDisposition',
|
|
43
|
+
'pathname',
|
|
44
|
+
] as const
|
|
45
|
+
|
|
46
|
+
/** Ordered list of file parts, matching Keystone's column layout. */
|
|
47
|
+
export const FILE_COLUMN_PARTS: readonly FileColumnPart[] = ['filename', 'filesize', 'url'] as const
|
|
48
|
+
|
|
49
|
+
/** The Prisma scalar type each image part is stored as. */
|
|
50
|
+
const IMAGE_PART_PRISMA_TYPE: Record<ImageColumnPart, 'String' | 'Int'> = {
|
|
51
|
+
url: 'String',
|
|
52
|
+
width: 'Int',
|
|
53
|
+
height: 'Int',
|
|
54
|
+
filesize: 'Int',
|
|
55
|
+
contentType: 'String',
|
|
56
|
+
contentDisposition: 'String',
|
|
57
|
+
pathname: 'String',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The Prisma scalar type each file part is stored as. */
|
|
61
|
+
const FILE_PART_PRISMA_TYPE: Record<FileColumnPart, 'String' | 'Int'> = {
|
|
62
|
+
filename: 'String',
|
|
63
|
+
filesize: 'Int',
|
|
64
|
+
url: 'String',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Map of image part → physical column name (the value used in `@map`).
|
|
69
|
+
* Defaults follow Keystone's `<field>_<part>` naming.
|
|
70
|
+
*/
|
|
71
|
+
export type ImageColumnMap = Record<ImageColumnPart, string>
|
|
72
|
+
|
|
73
|
+
/** Map of file part → physical column name. */
|
|
74
|
+
export type FileColumnMap = Record<FileColumnPart, string>
|
|
75
|
+
|
|
76
|
+
/** A single physical column to emit for a multi-column field. */
|
|
77
|
+
export interface MultiColumnDescriptor {
|
|
78
|
+
/** The Prisma model field name (the property carrying `@map`). */
|
|
79
|
+
name: string
|
|
80
|
+
/** The Prisma scalar type. */
|
|
81
|
+
type: 'String' | 'Int'
|
|
82
|
+
/** The physical column name used in `@map`. */
|
|
83
|
+
map: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build the default per-part column-name map for an image field, following
|
|
88
|
+
* Keystone's `<field>_<part>` convention.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* keystoneImageColumnMap('image') // { url: 'image_url', width: 'image_width', ... }
|
|
92
|
+
*/
|
|
93
|
+
export function keystoneImageColumnMap(fieldName: string): ImageColumnMap {
|
|
94
|
+
return {
|
|
95
|
+
url: `${fieldName}_url`,
|
|
96
|
+
width: `${fieldName}_width`,
|
|
97
|
+
height: `${fieldName}_height`,
|
|
98
|
+
filesize: `${fieldName}_filesize`,
|
|
99
|
+
contentType: `${fieldName}_contentType`,
|
|
100
|
+
contentDisposition: `${fieldName}_contentDisposition`,
|
|
101
|
+
pathname: `${fieldName}_pathname`,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build the default per-part column-name map for a file field.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* keystoneFileColumnMap('file') // { filename: 'file_filename', filesize: 'file_filesize', url: 'file_url' }
|
|
110
|
+
*/
|
|
111
|
+
export function keystoneFileColumnMap(fieldName: string): FileColumnMap {
|
|
112
|
+
return {
|
|
113
|
+
filename: `${fieldName}_filename`,
|
|
114
|
+
filesize: `${fieldName}_filesize`,
|
|
115
|
+
url: `${fieldName}_url`,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve the effective image column map: defaults from {@link keystoneImageColumnMap},
|
|
121
|
+
* with any caller-supplied per-part overrides applied on top.
|
|
122
|
+
*/
|
|
123
|
+
export function resolveImageColumnMap(
|
|
124
|
+
fieldName: string,
|
|
125
|
+
overrides?: Partial<ImageColumnMap>,
|
|
126
|
+
): ImageColumnMap {
|
|
127
|
+
return { ...keystoneImageColumnMap(fieldName), ...overrides }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the effective file column map.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveFileColumnMap(
|
|
134
|
+
fieldName: string,
|
|
135
|
+
overrides?: Partial<FileColumnMap>,
|
|
136
|
+
): FileColumnMap {
|
|
137
|
+
return { ...keystoneFileColumnMap(fieldName), ...overrides }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The Prisma model field name carrying a given image part's column. We name the
|
|
142
|
+
* model field after the physical column itself (i.e. `<field>_url`) so the
|
|
143
|
+
* generated property and its `@map` line up with the live Keystone column.
|
|
144
|
+
*/
|
|
145
|
+
function imagePartFieldName(map: ImageColumnMap, part: ImageColumnPart): string {
|
|
146
|
+
return map[part]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function filePartFieldName(map: FileColumnMap, part: FileColumnPart): string {
|
|
150
|
+
return map[part]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Describe the physical columns an image field emits in multi-column mode.
|
|
155
|
+
* Each descriptor's `name` is the Prisma model field name and `map` is the
|
|
156
|
+
* physical column it maps to (here they are identical so the schema reads
|
|
157
|
+
* naturally over the live columns).
|
|
158
|
+
*/
|
|
159
|
+
export function imageColumnDescriptors(map: ImageColumnMap): MultiColumnDescriptor[] {
|
|
160
|
+
return IMAGE_COLUMN_PARTS.map((part) => ({
|
|
161
|
+
name: imagePartFieldName(map, part),
|
|
162
|
+
type: IMAGE_PART_PRISMA_TYPE[part],
|
|
163
|
+
map: map[part],
|
|
164
|
+
}))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Describe the physical columns a file field emits in multi-column mode.
|
|
169
|
+
*/
|
|
170
|
+
export function fileColumnDescriptors(map: FileColumnMap): MultiColumnDescriptor[] {
|
|
171
|
+
return FILE_COLUMN_PARTS.map((part) => ({
|
|
172
|
+
name: filePartFieldName(map, part),
|
|
173
|
+
type: FILE_PART_PRISMA_TYPE[part],
|
|
174
|
+
map: map[part],
|
|
175
|
+
}))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The physical column field names an image field owns (for read stripping). */
|
|
179
|
+
export function imageColumnNames(map: ImageColumnMap): string[] {
|
|
180
|
+
return IMAGE_COLUMN_PARTS.map((part) => imagePartFieldName(map, part))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The physical column field names a file field owns (for read stripping). */
|
|
184
|
+
export function fileColumnNames(map: FileColumnMap): string[] {
|
|
185
|
+
return FILE_COLUMN_PARTS.map((part) => filePartFieldName(map, part))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Coerce an unknown column value to a string, or `null` when absent/empty. */
|
|
189
|
+
function asString(value: unknown): string | null {
|
|
190
|
+
if (value === null || value === undefined) return null
|
|
191
|
+
if (typeof value === 'string') return value
|
|
192
|
+
return String(value)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Coerce an unknown column value to a number, or `null` when absent/invalid. */
|
|
196
|
+
function asNumber(value: unknown): number | null {
|
|
197
|
+
if (value === null || value === undefined) return null
|
|
198
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
199
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
200
|
+
const n = Number(value)
|
|
201
|
+
return Number.isFinite(n) ? n : null
|
|
202
|
+
}
|
|
203
|
+
if (typeof value === 'bigint') return Number(value)
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Assemble the per-part image columns from a database row into an
|
|
209
|
+
* {@link ImageMetadata}.
|
|
210
|
+
*
|
|
211
|
+
* Returns `null` when the row carries no image at all (the `url` column is the
|
|
212
|
+
* authoritative presence signal — Keystone leaves every part NULL for an empty
|
|
213
|
+
* image). Partially-populated rows (e.g. only `image_url`) still assemble into a
|
|
214
|
+
* valid object, with missing scalar parts defaulted (width/height/size → 0).
|
|
215
|
+
*
|
|
216
|
+
* `storageProvider` is supplied by the caller (the field knows which provider it
|
|
217
|
+
* is bound to); legacy columns do not carry it.
|
|
218
|
+
*/
|
|
219
|
+
export function assembleImageMetadata(
|
|
220
|
+
row: Record<string, unknown>,
|
|
221
|
+
map: ImageColumnMap,
|
|
222
|
+
storageProvider: string,
|
|
223
|
+
): ImageMetadata | null {
|
|
224
|
+
const url = asString(row[map.url])
|
|
225
|
+
if (url === null || url === '') {
|
|
226
|
+
return null
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const pathname = asString(row[map.pathname])
|
|
230
|
+
const contentType = asString(row[map.contentType])
|
|
231
|
+
const contentDisposition = asString(row[map.contentDisposition])
|
|
232
|
+
|
|
233
|
+
// Keystone stores the storage key in `pathname`; fall back to the URL when
|
|
234
|
+
// absent so `filename` is always populated.
|
|
235
|
+
const filename = pathname ?? url
|
|
236
|
+
|
|
237
|
+
const metadata: ImageMetadata = {
|
|
238
|
+
filename,
|
|
239
|
+
originalFilename: filename,
|
|
240
|
+
url,
|
|
241
|
+
mimeType: contentType ?? 'application/octet-stream',
|
|
242
|
+
size: asNumber(row[map.filesize]) ?? 0,
|
|
243
|
+
width: asNumber(row[map.width]) ?? 0,
|
|
244
|
+
height: asNumber(row[map.height]) ?? 0,
|
|
245
|
+
uploadedAt: '',
|
|
246
|
+
storageProvider,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Preserve the Keystone-only content disposition (no first-class metadata
|
|
250
|
+
// field) so a round-trip back to columns does not lose it.
|
|
251
|
+
if (contentDisposition !== null) {
|
|
252
|
+
metadata.metadata = { contentDisposition }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return metadata
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Split an {@link ImageMetadata} (or `null`) back into the per-part image
|
|
260
|
+
* columns for writing. A `null`/`undefined` metadata clears every column.
|
|
261
|
+
*/
|
|
262
|
+
export function splitImageMetadata(
|
|
263
|
+
metadata: ImageMetadata | null | undefined,
|
|
264
|
+
map: ImageColumnMap,
|
|
265
|
+
): Record<string, string | number | null> {
|
|
266
|
+
if (metadata === null || metadata === undefined) {
|
|
267
|
+
return {
|
|
268
|
+
[map.url]: null,
|
|
269
|
+
[map.width]: null,
|
|
270
|
+
[map.height]: null,
|
|
271
|
+
[map.filesize]: null,
|
|
272
|
+
[map.contentType]: null,
|
|
273
|
+
[map.contentDisposition]: null,
|
|
274
|
+
[map.pathname]: null,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const contentDisposition =
|
|
279
|
+
metadata.metadata && typeof metadata.metadata.contentDisposition === 'string'
|
|
280
|
+
? metadata.metadata.contentDisposition
|
|
281
|
+
: null
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
[map.url]: metadata.url,
|
|
285
|
+
[map.width]: metadata.width ?? null,
|
|
286
|
+
[map.height]: metadata.height ?? null,
|
|
287
|
+
[map.filesize]: metadata.size ?? null,
|
|
288
|
+
[map.contentType]: metadata.mimeType ?? null,
|
|
289
|
+
[map.contentDisposition]: contentDisposition,
|
|
290
|
+
// Keystone's pathname holds the storage key (our `filename`).
|
|
291
|
+
[map.pathname]: metadata.filename ?? null,
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Assemble the per-part file columns from a database row into a
|
|
297
|
+
* {@link FileMetadata}. Returns `null` when no file is present.
|
|
298
|
+
*/
|
|
299
|
+
export function assembleFileMetadata(
|
|
300
|
+
row: Record<string, unknown>,
|
|
301
|
+
map: FileColumnMap,
|
|
302
|
+
storageProvider: string,
|
|
303
|
+
): FileMetadata | null {
|
|
304
|
+
const url = asString(row[map.url])
|
|
305
|
+
const filename = asString(row[map.filename])
|
|
306
|
+
|
|
307
|
+
// Either a URL or a filename signals a present file.
|
|
308
|
+
if ((url === null || url === '') && (filename === null || filename === '')) {
|
|
309
|
+
return null
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const resolvedFilename = filename ?? url ?? ''
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
filename: resolvedFilename,
|
|
316
|
+
originalFilename: resolvedFilename,
|
|
317
|
+
url: url ?? '',
|
|
318
|
+
mimeType: 'application/octet-stream',
|
|
319
|
+
size: asNumber(row[map.filesize]) ?? 0,
|
|
320
|
+
uploadedAt: '',
|
|
321
|
+
storageProvider,
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Split a {@link FileMetadata} (or `null`) back into the per-part file columns
|
|
327
|
+
* for writing. A `null`/`undefined` metadata clears every column.
|
|
328
|
+
*/
|
|
329
|
+
export function splitFileMetadata(
|
|
330
|
+
metadata: FileMetadata | null | undefined,
|
|
331
|
+
map: FileColumnMap,
|
|
332
|
+
): Record<string, string | number | null> {
|
|
333
|
+
if (metadata === null || metadata === undefined) {
|
|
334
|
+
return {
|
|
335
|
+
[map.filename]: null,
|
|
336
|
+
[map.filesize]: null,
|
|
337
|
+
[map.url]: null,
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
[map.filename]: metadata.filename ?? null,
|
|
343
|
+
[map.filesize]: metadata.size ?? null,
|
|
344
|
+
[map.url]: metadata.url ?? null,
|
|
345
|
+
}
|
|
346
|
+
}
|