@opensaas/stack-storage 0.21.0 → 0.23.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,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
+ }