@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,397 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { image, file } from '../src/fields/index.js'
|
|
3
|
+
import type { ImageMetadata, FileMetadata } from '../src/config/types.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Field-level behaviour of image()/file() in multi-column (Keystone-parity)
|
|
7
|
+
* mode, plus the no-re-upload guarantee in BOTH modes. See ADR-0006 / issue #477.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A File-like stub with an arrayBuffer() method (triggers an upload). */
|
|
11
|
+
function fakeFile(bytes = [1, 2, 3]): File {
|
|
12
|
+
return {
|
|
13
|
+
name: 'photo.png',
|
|
14
|
+
type: 'image/png',
|
|
15
|
+
arrayBuffer: async () => new Uint8Array(bytes).buffer,
|
|
16
|
+
} as unknown as File
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A context whose storage records every upload call so we can assert on it. */
|
|
20
|
+
function makeContext() {
|
|
21
|
+
const uploadImage = vi.fn(
|
|
22
|
+
async (): Promise<ImageMetadata> => ({
|
|
23
|
+
filename: 'new.png',
|
|
24
|
+
originalFilename: 'photo.png',
|
|
25
|
+
url: '/uploads/new.png',
|
|
26
|
+
mimeType: 'image/png',
|
|
27
|
+
size: 3,
|
|
28
|
+
width: 10,
|
|
29
|
+
height: 10,
|
|
30
|
+
uploadedAt: new Date().toISOString(),
|
|
31
|
+
storageProvider: 'images',
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
const uploadFile = vi.fn(
|
|
35
|
+
async (): Promise<FileMetadata> => ({
|
|
36
|
+
filename: 'new.pdf',
|
|
37
|
+
originalFilename: 'doc.pdf',
|
|
38
|
+
url: '/uploads/new.pdf',
|
|
39
|
+
mimeType: 'application/pdf',
|
|
40
|
+
size: 3,
|
|
41
|
+
uploadedAt: new Date().toISOString(),
|
|
42
|
+
storageProvider: 'documents',
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
return {
|
|
46
|
+
context: { storage: { uploadImage, uploadFile, deleteImage: vi.fn(), deleteFile: vi.fn() } },
|
|
47
|
+
uploadImage,
|
|
48
|
+
uploadFile,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('image() / file() multi-column mode', () => {
|
|
53
|
+
describe('single-Json? default is unchanged', () => {
|
|
54
|
+
it('image() default has no multi-column methods and emits Json?', () => {
|
|
55
|
+
const field = image({ storage: 'images' })
|
|
56
|
+
expect(field.getPrismaColumns).toBeUndefined()
|
|
57
|
+
expect(field.getColumnNames).toBeUndefined()
|
|
58
|
+
expect(field.assembleColumns).toBeUndefined()
|
|
59
|
+
expect(field.splitColumns).toBeUndefined()
|
|
60
|
+
expect(field.getPrismaType?.('image')).toEqual({ type: 'Json', modifiers: '?' })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('file() default has no multi-column methods and emits Json?', () => {
|
|
64
|
+
const field = file({ storage: 'documents' })
|
|
65
|
+
expect(field.getPrismaColumns).toBeUndefined()
|
|
66
|
+
expect(field.getPrismaType?.('doc')).toEqual({ type: 'Json', modifiers: '?' })
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('multi-column emission', () => {
|
|
71
|
+
it('image() in keystone mode emits seven @map-ped nullable columns', () => {
|
|
72
|
+
const field = image({ storage: 'images', db: { columns: 'keystone' } })
|
|
73
|
+
const columns = field.getPrismaColumns?.('image')
|
|
74
|
+
expect(columns).toHaveLength(7)
|
|
75
|
+
expect(columns?.every((c) => c.modifiers === '?')).toBe(true)
|
|
76
|
+
expect(columns?.map((c) => c.map)).toEqual([
|
|
77
|
+
'image_url',
|
|
78
|
+
'image_width',
|
|
79
|
+
'image_height',
|
|
80
|
+
'image_filesize',
|
|
81
|
+
'image_contentType',
|
|
82
|
+
'image_contentDisposition',
|
|
83
|
+
'image_pathname',
|
|
84
|
+
])
|
|
85
|
+
expect(field.getColumnNames?.('image')).toHaveLength(7)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('file() in keystone mode emits three @map-ped nullable columns', () => {
|
|
89
|
+
const field = file({ storage: 'documents', db: { columns: 'keystone' } })
|
|
90
|
+
const columns = field.getPrismaColumns?.('doc')
|
|
91
|
+
expect(columns).toHaveLength(3)
|
|
92
|
+
expect(columns?.every((c) => c.modifiers === '?')).toBe(true)
|
|
93
|
+
expect(columns?.map((c) => c.map)).toEqual(['doc_filename', 'doc_filesize', 'doc_url'])
|
|
94
|
+
// filesize is Int; filename/url are String.
|
|
95
|
+
const byMap = Object.fromEntries((columns ?? []).map((c) => [c.map, c.type]))
|
|
96
|
+
expect(byMap.doc_filesize).toBe('Int')
|
|
97
|
+
expect(byMap.doc_filename).toBe('String')
|
|
98
|
+
expect(byMap.doc_url).toBe('String')
|
|
99
|
+
expect(field.getColumnNames?.('doc')).toEqual(['doc_filename', 'doc_filesize', 'doc_url'])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('file() per-part @map names are configurable', () => {
|
|
103
|
+
const field = file({
|
|
104
|
+
storage: 'documents',
|
|
105
|
+
db: { columns: { mode: 'keystone', map: { url: 'doc_href' } } },
|
|
106
|
+
})
|
|
107
|
+
const maps = field.getPrismaColumns?.('doc')?.map((c) => c.map)
|
|
108
|
+
expect(maps).toContain('doc_href')
|
|
109
|
+
// Un-overridden parts keep Keystone defaults.
|
|
110
|
+
expect(maps).toContain('doc_filename')
|
|
111
|
+
expect(maps).not.toContain('doc_url')
|
|
112
|
+
expect(field.getColumnNames?.('doc')).toContain('doc_href')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('per-part @map names are configurable', () => {
|
|
116
|
+
const field = image({
|
|
117
|
+
storage: 'images',
|
|
118
|
+
db: { columns: { mode: 'keystone', map: { url: 'image_link', pathname: 'image_key' } } },
|
|
119
|
+
})
|
|
120
|
+
const columns = field.getPrismaColumns?.('image')
|
|
121
|
+
const maps = columns?.map((c) => c.map)
|
|
122
|
+
expect(maps).toContain('image_link')
|
|
123
|
+
expect(maps).toContain('image_key')
|
|
124
|
+
// Un-overridden parts keep Keystone defaults.
|
|
125
|
+
expect(maps).toContain('image_width')
|
|
126
|
+
expect(field.getColumnNames?.('image')).toContain('image_link')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('assemble (read) and split (write)', () => {
|
|
131
|
+
it('image() assembles its columns and a url-only row yields a valid value', () => {
|
|
132
|
+
const field = image({ storage: 'images', db: { columns: 'keystone' } })
|
|
133
|
+
const full = field.assembleColumns?.('image', {
|
|
134
|
+
image_url: '/u/a.jpg',
|
|
135
|
+
image_width: 100,
|
|
136
|
+
image_height: 50,
|
|
137
|
+
image_filesize: 1024,
|
|
138
|
+
image_contentType: 'image/jpeg',
|
|
139
|
+
image_pathname: 'a.jpg',
|
|
140
|
+
}) as ImageMetadata
|
|
141
|
+
expect(full.url).toBe('/u/a.jpg')
|
|
142
|
+
expect(full.width).toBe(100)
|
|
143
|
+
expect(full.storageProvider).toBe('images')
|
|
144
|
+
|
|
145
|
+
const partial = field.assembleColumns?.('image', {
|
|
146
|
+
image_url: '/u/only.jpg',
|
|
147
|
+
}) as ImageMetadata
|
|
148
|
+
expect(partial.url).toBe('/u/only.jpg')
|
|
149
|
+
expect(partial.width).toBe(0)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('image() split writes back into the per-part columns', () => {
|
|
153
|
+
const field = image({ storage: 'images', db: { columns: 'keystone' } })
|
|
154
|
+
const meta: ImageMetadata = {
|
|
155
|
+
filename: 'a.jpg',
|
|
156
|
+
originalFilename: 'a.jpg',
|
|
157
|
+
url: '/u/a.jpg',
|
|
158
|
+
mimeType: 'image/jpeg',
|
|
159
|
+
size: 1024,
|
|
160
|
+
width: 100,
|
|
161
|
+
height: 50,
|
|
162
|
+
uploadedAt: '',
|
|
163
|
+
storageProvider: 'images',
|
|
164
|
+
}
|
|
165
|
+
const columns = field.splitColumns?.('image', meta)
|
|
166
|
+
expect(columns).toMatchObject({
|
|
167
|
+
image_url: '/u/a.jpg',
|
|
168
|
+
image_width: 100,
|
|
169
|
+
image_height: 50,
|
|
170
|
+
image_filesize: 1024,
|
|
171
|
+
image_contentType: 'image/jpeg',
|
|
172
|
+
image_pathname: 'a.jpg',
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('file() assembles a fully-populated row into FileMetadata', () => {
|
|
177
|
+
const field = file({ storage: 'documents', db: { columns: 'keystone' } })
|
|
178
|
+
const meta = field.assembleColumns?.('doc', {
|
|
179
|
+
doc_filename: 'report.pdf',
|
|
180
|
+
doc_filesize: 4096,
|
|
181
|
+
doc_url: 'https://cdn/report.pdf',
|
|
182
|
+
}) as FileMetadata
|
|
183
|
+
expect(meta.url).toBe('https://cdn/report.pdf')
|
|
184
|
+
expect(meta.filename).toBe('report.pdf')
|
|
185
|
+
expect(meta.size).toBe(4096)
|
|
186
|
+
expect(meta.storageProvider).toBe('documents')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('file() assembles a partial row (only doc_url) into a valid value', () => {
|
|
190
|
+
const field = file({ storage: 'documents', db: { columns: 'keystone' } })
|
|
191
|
+
const meta = field.assembleColumns?.('doc', {
|
|
192
|
+
doc_url: 'https://cdn/only.pdf',
|
|
193
|
+
}) as FileMetadata
|
|
194
|
+
expect(meta.url).toBe('https://cdn/only.pdf')
|
|
195
|
+
// Missing filename falls back to the URL; missing filesize defaults to 0.
|
|
196
|
+
expect(meta.filename).toBe('https://cdn/only.pdf')
|
|
197
|
+
expect(meta.size).toBe(0)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('file() assembles an empty (all-NULL) row to null', () => {
|
|
201
|
+
const field = file({ storage: 'documents', db: { columns: 'keystone' } })
|
|
202
|
+
expect(
|
|
203
|
+
field.assembleColumns?.('doc', { doc_url: null, doc_filename: null, doc_filesize: null }),
|
|
204
|
+
).toBeNull()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('file() split writes back into the per-part columns', () => {
|
|
208
|
+
const field = file({ storage: 'documents', db: { columns: 'keystone' } })
|
|
209
|
+
const meta: FileMetadata = {
|
|
210
|
+
filename: 'report.pdf',
|
|
211
|
+
originalFilename: 'report.pdf',
|
|
212
|
+
url: 'https://cdn/report.pdf',
|
|
213
|
+
mimeType: 'application/pdf',
|
|
214
|
+
size: 4096,
|
|
215
|
+
uploadedAt: '',
|
|
216
|
+
storageProvider: 'documents',
|
|
217
|
+
}
|
|
218
|
+
expect(field.splitColumns?.('doc', meta)).toEqual({
|
|
219
|
+
doc_filename: 'report.pdf',
|
|
220
|
+
doc_filesize: 4096,
|
|
221
|
+
doc_url: 'https://cdn/report.pdf',
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('file() round-trips a row through assemble → split with custom @map names', () => {
|
|
226
|
+
const field = file({
|
|
227
|
+
storage: 'documents',
|
|
228
|
+
db: { columns: { mode: 'keystone', map: { url: 'doc_href' } } },
|
|
229
|
+
})
|
|
230
|
+
const row = { doc_filename: 'a.bin', doc_filesize: 12, doc_href: 'https://cdn/a.bin' }
|
|
231
|
+
const meta = field.assembleColumns?.('doc', row)
|
|
232
|
+
expect((meta as FileMetadata).url).toBe('https://cdn/a.bin')
|
|
233
|
+
// Split back lands on the overridden physical column, not doc_url.
|
|
234
|
+
expect(field.splitColumns?.('doc', meta)).toEqual(row)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('file() split of null clears all columns', () => {
|
|
238
|
+
const field = file({ storage: 'documents', db: { columns: 'keystone' } })
|
|
239
|
+
expect(field.splitColumns?.('doc', null)).toEqual({
|
|
240
|
+
doc_filename: null,
|
|
241
|
+
doc_filesize: null,
|
|
242
|
+
doc_url: null,
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
describe('field-level write access is preserved alongside the column split', () => {
|
|
248
|
+
// The core write pipeline gates the multi-column split on the field's own
|
|
249
|
+
// write access (so a denied multi-column field writes NONE of its per-part
|
|
250
|
+
// columns — see packages/core/.../multi-column-read-write.test.ts). For that
|
|
251
|
+
// gate to fire, the field builder must surface the user-provided `access` on
|
|
252
|
+
// the config (the same object key the single-column path reads). These tests
|
|
253
|
+
// lock that wiring for the real image()/file() fields. See ADR-0006 / #477.
|
|
254
|
+
it('image() in multi-column mode carries user access AND splitColumns together', () => {
|
|
255
|
+
const denyUpdate = () => false
|
|
256
|
+
const field = image({
|
|
257
|
+
storage: 'images',
|
|
258
|
+
db: { columns: 'keystone' },
|
|
259
|
+
access: { update: denyUpdate },
|
|
260
|
+
})
|
|
261
|
+
expect(field.access?.update).toBe(denyUpdate)
|
|
262
|
+
// The gate has both inputs it needs: the access object and the splitter.
|
|
263
|
+
expect(typeof field.splitColumns).toBe('function')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('file() in multi-column mode carries user access AND splitColumns together', () => {
|
|
267
|
+
const denyCreate = () => false
|
|
268
|
+
const field = file({
|
|
269
|
+
storage: 'documents',
|
|
270
|
+
db: { columns: 'keystone' },
|
|
271
|
+
access: { create: denyCreate },
|
|
272
|
+
})
|
|
273
|
+
expect(field.access?.create).toBe(denyCreate)
|
|
274
|
+
expect(typeof field.splitColumns).toBe('function')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('single-column image() still carries access and has no splitColumns', () => {
|
|
278
|
+
const denyUpdate = () => false
|
|
279
|
+
const field = image({ storage: 'images', access: { update: denyUpdate } })
|
|
280
|
+
expect(field.access?.update).toBe(denyUpdate)
|
|
281
|
+
expect(field.splitColumns).toBeUndefined()
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
describe('no-re-upload guarantee', () => {
|
|
286
|
+
it('image() does NOT upload when given an existing ImageMetadata (single-Json mode)', async () => {
|
|
287
|
+
const field = image({ storage: 'images' })
|
|
288
|
+
const { context, uploadImage } = makeContext()
|
|
289
|
+
const existing: ImageMetadata = {
|
|
290
|
+
filename: 'a.jpg',
|
|
291
|
+
originalFilename: 'a.jpg',
|
|
292
|
+
url: '/u/a.jpg',
|
|
293
|
+
mimeType: 'image/jpeg',
|
|
294
|
+
size: 1,
|
|
295
|
+
width: 1,
|
|
296
|
+
height: 1,
|
|
297
|
+
uploadedAt: '',
|
|
298
|
+
storageProvider: 'images',
|
|
299
|
+
}
|
|
300
|
+
const result = await field.hooks?.resolveInput?.({
|
|
301
|
+
listKey: 'Post',
|
|
302
|
+
fieldKey: 'image',
|
|
303
|
+
operation: 'update',
|
|
304
|
+
inputData: { image: existing },
|
|
305
|
+
item: { image: existing },
|
|
306
|
+
resolvedData: { image: existing },
|
|
307
|
+
context,
|
|
308
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
309
|
+
} as any)
|
|
310
|
+
expect(uploadImage).not.toHaveBeenCalled()
|
|
311
|
+
expect(result).toBe(existing)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('image() does NOT upload when given an existing ImageMetadata (multi-column mode)', async () => {
|
|
315
|
+
const field = image({ storage: 'images', db: { columns: 'keystone' } })
|
|
316
|
+
const { context, uploadImage } = makeContext()
|
|
317
|
+
const existing: ImageMetadata = {
|
|
318
|
+
filename: 'a.jpg',
|
|
319
|
+
originalFilename: 'a.jpg',
|
|
320
|
+
url: '/u/a.jpg',
|
|
321
|
+
mimeType: 'image/jpeg',
|
|
322
|
+
size: 1,
|
|
323
|
+
width: 1,
|
|
324
|
+
height: 1,
|
|
325
|
+
uploadedAt: '',
|
|
326
|
+
storageProvider: 'images',
|
|
327
|
+
}
|
|
328
|
+
const result = await field.hooks?.resolveInput?.({
|
|
329
|
+
listKey: 'Post',
|
|
330
|
+
fieldKey: 'image',
|
|
331
|
+
operation: 'create',
|
|
332
|
+
inputData: { image: existing },
|
|
333
|
+
item: undefined,
|
|
334
|
+
resolvedData: { image: existing },
|
|
335
|
+
context,
|
|
336
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
337
|
+
} as any)
|
|
338
|
+
expect(uploadImage).not.toHaveBeenCalled()
|
|
339
|
+
expect(result).toBe(existing)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('image() DOES upload when given a File-like input', async () => {
|
|
343
|
+
const field = image({ storage: 'images', db: { columns: 'keystone' } })
|
|
344
|
+
const { context, uploadImage } = makeContext()
|
|
345
|
+
const result = await field.hooks?.resolveInput?.({
|
|
346
|
+
listKey: 'Post',
|
|
347
|
+
fieldKey: 'image',
|
|
348
|
+
operation: 'create',
|
|
349
|
+
inputData: { image: fakeFile() },
|
|
350
|
+
item: undefined,
|
|
351
|
+
resolvedData: { image: fakeFile() },
|
|
352
|
+
context,
|
|
353
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
354
|
+
} as any)
|
|
355
|
+
expect(uploadImage).toHaveBeenCalledTimes(1)
|
|
356
|
+
expect((result as ImageMetadata).url).toBe('/uploads/new.png')
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('file() does NOT upload existing metadata but DOES upload a File-like input', async () => {
|
|
360
|
+
const field = file({ storage: 'documents', db: { columns: 'keystone' } })
|
|
361
|
+
const { context, uploadFile } = makeContext()
|
|
362
|
+
const existing: FileMetadata = {
|
|
363
|
+
filename: 'd.pdf',
|
|
364
|
+
originalFilename: 'd.pdf',
|
|
365
|
+
url: '/u/d.pdf',
|
|
366
|
+
mimeType: 'application/pdf',
|
|
367
|
+
size: 1,
|
|
368
|
+
uploadedAt: '',
|
|
369
|
+
storageProvider: 'documents',
|
|
370
|
+
}
|
|
371
|
+
const kept = await field.hooks?.resolveInput?.({
|
|
372
|
+
listKey: 'Post',
|
|
373
|
+
fieldKey: 'doc',
|
|
374
|
+
operation: 'update',
|
|
375
|
+
inputData: { doc: existing },
|
|
376
|
+
item: { doc: existing },
|
|
377
|
+
resolvedData: { doc: existing },
|
|
378
|
+
context,
|
|
379
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
380
|
+
} as any)
|
|
381
|
+
expect(uploadFile).not.toHaveBeenCalled()
|
|
382
|
+
expect(kept).toBe(existing)
|
|
383
|
+
|
|
384
|
+
await field.hooks?.resolveInput?.({
|
|
385
|
+
listKey: 'Post',
|
|
386
|
+
fieldKey: 'doc',
|
|
387
|
+
operation: 'create',
|
|
388
|
+
inputData: { doc: fakeFile() },
|
|
389
|
+
item: undefined,
|
|
390
|
+
resolvedData: { doc: fakeFile() },
|
|
391
|
+
context,
|
|
392
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
393
|
+
} as any)
|
|
394
|
+
expect(uploadFile).toHaveBeenCalledTimes(1)
|
|
395
|
+
})
|
|
396
|
+
})
|
|
397
|
+
})
|