@ultimabase/files 0.0.0-stage → 0.1.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/CHANGELOG.md +13 -0
- package/README.md +4 -2
- package/dist/field.d.ts +89 -0
- package/dist/field.d.ts.map +1 -0
- package/dist/field.js +123 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/lifecycle.d.ts +50 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +192 -0
- package/dist/memory.d.ts +16 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +27 -0
- package/dist/model.d.ts +41 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +28 -0
- package/dist/plugin.d.ts +39 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +80 -0
- package/dist/routes.d.ts +4 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +32 -0
- package/dist/s3.d.ts +17 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +99 -0
- package/dist/shape.d.ts +17 -0
- package/dist/shape.d.ts.map +1 -0
- package/dist/shape.js +59 -0
- package/dist/sigv4.d.ts +18 -0
- package/dist/sigv4.d.ts.map +1 -0
- package/dist/sigv4.js +49 -0
- package/dist/standard.d.ts +21 -0
- package/dist/standard.d.ts.map +1 -0
- package/dist/standard.js +1 -0
- package/dist/storage.d.ts +30 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +16 -0
- package/dist/upload.d.ts +19 -0
- package/dist/upload.d.ts.map +1 -0
- package/dist/upload.js +206 -0
- package/dist/values.d.ts +81 -0
- package/dist/values.d.ts.map +1 -0
- package/dist/values.js +125 -0
- package/package.json +46 -3
- package/src/field.ts +236 -0
- package/src/index.ts +22 -0
- package/src/lifecycle.ts +270 -0
- package/src/memory.ts +46 -0
- package/src/model.ts +67 -0
- package/src/plugin.ts +144 -0
- package/src/routes.ts +42 -0
- package/src/s3.ts +141 -0
- package/src/shape.ts +83 -0
- package/src/sigv4.ts +81 -0
- package/src/standard.ts +15 -0
- package/src/storage.ts +46 -0
- package/src/upload.ts +282 -0
- package/src/values.ts +213 -0
package/src/upload.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// An upload (D-152): its field, its size and its type checked, its pending
|
|
2
|
+
// `FilesBlob` row inserted as its caller, then its object stored. When it
|
|
3
|
+
// fails, its objects and its rows are deleted.
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { fileTypeFromBuffer } from 'file-type'
|
|
6
|
+
import type { Identity, JsonValue } from 'ultimabase'
|
|
7
|
+
import { isMaster, PublicError } from 'ultimabase'
|
|
8
|
+
import type { FileInput, FileOptions, Inline } from './field.ts'
|
|
9
|
+
import { defaultMaxSize } from './field.ts'
|
|
10
|
+
import { bytesOf } from './memory.ts'
|
|
11
|
+
import { FilesBlob, hour, M } from './model.ts'
|
|
12
|
+
import type { FileBody } from './storage.ts'
|
|
13
|
+
import { settled } from './storage.ts'
|
|
14
|
+
import { runningOf } from './values.ts'
|
|
15
|
+
|
|
16
|
+
/** What an upload says of its file. */
|
|
17
|
+
export interface UploadOptions {
|
|
18
|
+
/** The field the file is for: `Post.cover`. */
|
|
19
|
+
readonly for: string
|
|
20
|
+
readonly name: string
|
|
21
|
+
/** The declared `Content-Type`, read only when the bytes say none. */
|
|
22
|
+
readonly type?: string | undefined
|
|
23
|
+
readonly as?: Identity | undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** How many first bytes the type is read from, as `file-type` reads them. */
|
|
27
|
+
const sampleSize = 4100
|
|
28
|
+
/** An inline value's JSON, at most. */
|
|
29
|
+
const inlineSize = 2048
|
|
30
|
+
|
|
31
|
+
const invalid = (path: string, message: string): PublicError =>
|
|
32
|
+
new PublicError('VALIDATION_FAILED', 'Invalid upload.', {
|
|
33
|
+
details: { issues: [{ path: [path], message }] },
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
function fieldOf(target: string): FileOptions {
|
|
37
|
+
const [model = '', field = ''] = target.split('.')
|
|
38
|
+
const options = runningOf().fields.get(model)?.get(field)
|
|
39
|
+
if (options === undefined) {
|
|
40
|
+
const message =
|
|
41
|
+
target === ''
|
|
42
|
+
? 'Expected a file field: Model.field.'
|
|
43
|
+
: `${target} is not a file field.`
|
|
44
|
+
throw invalid('for', message)
|
|
45
|
+
}
|
|
46
|
+
return options
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function concat(chunks: readonly Uint8Array[], length: number): Uint8Array {
|
|
50
|
+
const bytes = new Uint8Array(length)
|
|
51
|
+
let at = 0
|
|
52
|
+
for (const chunk of chunks) {
|
|
53
|
+
bytes.set(chunk, at)
|
|
54
|
+
at += chunk.length
|
|
55
|
+
}
|
|
56
|
+
return bytes
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The first bytes of a body, and the body to store, whole again. */
|
|
60
|
+
async function sampled(
|
|
61
|
+
body: FileBody,
|
|
62
|
+
): Promise<{ readonly sample: Uint8Array; readonly body: FileBody }> {
|
|
63
|
+
if (body instanceof Uint8Array) return { sample: body, body }
|
|
64
|
+
const reader = body.getReader()
|
|
65
|
+
const chunks: Uint8Array[] = []
|
|
66
|
+
let length = 0
|
|
67
|
+
while (length < sampleSize) {
|
|
68
|
+
const { done, value } = await reader.read()
|
|
69
|
+
if (done) {
|
|
70
|
+
const whole = concat(chunks, length)
|
|
71
|
+
return { sample: whole, body: whole }
|
|
72
|
+
}
|
|
73
|
+
chunks.push(value)
|
|
74
|
+
length += value.length
|
|
75
|
+
}
|
|
76
|
+
const sample = concat(chunks, length)
|
|
77
|
+
const rest = new ReadableStream<Uint8Array>({
|
|
78
|
+
start: (controller) => {
|
|
79
|
+
controller.enqueue(sample)
|
|
80
|
+
},
|
|
81
|
+
pull: async (controller) => {
|
|
82
|
+
const { done, value } = await reader.read()
|
|
83
|
+
if (done) controller.close()
|
|
84
|
+
else controller.enqueue(value)
|
|
85
|
+
},
|
|
86
|
+
cancel: (reason) => reader.cancel(reason),
|
|
87
|
+
})
|
|
88
|
+
return { sample, body: rest }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const matches = (accept: readonly string[], type: string): boolean =>
|
|
92
|
+
accept.some((pattern) =>
|
|
93
|
+
pattern.endsWith('/*')
|
|
94
|
+
? type.startsWith(pattern.slice(0, -1))
|
|
95
|
+
: pattern === type,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
/** The type a file is stored with, and whether it is served as a download. */
|
|
99
|
+
async function typeOf(
|
|
100
|
+
sample: Uint8Array,
|
|
101
|
+
declared: string | undefined,
|
|
102
|
+
accept: readonly string[] | undefined,
|
|
103
|
+
): Promise<{ readonly type: string; readonly attachment: boolean }> {
|
|
104
|
+
const detected = (await fileTypeFromBuffer(sample))?.mime
|
|
105
|
+
if (
|
|
106
|
+
detected !== undefined &&
|
|
107
|
+
(accept === undefined || matches(accept, detected))
|
|
108
|
+
) {
|
|
109
|
+
return { type: detected, attachment: detected === 'image/svg+xml' }
|
|
110
|
+
}
|
|
111
|
+
const [media = ''] = (declared ?? '').split(';')
|
|
112
|
+
const type = media.trim().toLowerCase()
|
|
113
|
+
if (detected === undefined && accept === undefined) {
|
|
114
|
+
return {
|
|
115
|
+
type: type === '' ? 'application/octet-stream' : type,
|
|
116
|
+
attachment: true,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// An undetected type is taken as declared only when accept lists it.
|
|
120
|
+
if (detected === undefined && accept?.includes(type) === true) {
|
|
121
|
+
return { type, attachment: true }
|
|
122
|
+
}
|
|
123
|
+
throw new PublicError(
|
|
124
|
+
'UNSUPPORTED_MEDIA_TYPE',
|
|
125
|
+
`${detected ?? (type === '' ? 'An undetected type' : type)} is not accepted.`,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A variant's own type, served as a download unless its bytes say it. */
|
|
130
|
+
async function attachmentOf(body: Uint8Array, type: string): Promise<boolean> {
|
|
131
|
+
const detected = (await fileTypeFromBuffer(body))?.mime
|
|
132
|
+
return detected !== type || type === 'image/svg+xml'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function inlineValue(
|
|
136
|
+
key: string,
|
|
137
|
+
given: Inline,
|
|
138
|
+
file: FileInput,
|
|
139
|
+
): Promise<JsonValue> {
|
|
140
|
+
const run = typeof given === 'function' ? given : given.run
|
|
141
|
+
let value: unknown = await run(file)
|
|
142
|
+
if (typeof given !== 'function') {
|
|
143
|
+
const result = await given.schema['~standard'].validate(value)
|
|
144
|
+
if (result.issues !== undefined) {
|
|
145
|
+
throw new TypeError(`The inline ${key} does not match its schema.`)
|
|
146
|
+
}
|
|
147
|
+
value = result.value
|
|
148
|
+
}
|
|
149
|
+
const json = JSON.stringify(value)
|
|
150
|
+
if (
|
|
151
|
+
json === undefined ||
|
|
152
|
+
new TextEncoder().encode(json).length > inlineSize
|
|
153
|
+
) {
|
|
154
|
+
throw new TypeError(`The inline ${key} is not JSON of 2 KB at most.`)
|
|
155
|
+
}
|
|
156
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- JSON.parse gives JSON.
|
|
157
|
+
return JSON.parse(json) as JsonValue
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const uploaderOf = (caller: Identity): string | null =>
|
|
161
|
+
isMaster(caller) ? 'master' : caller.kind === 'user' ? caller.user.id : null
|
|
162
|
+
|
|
163
|
+
/** What a buffered upload made: its inline values and its variants. */
|
|
164
|
+
async function madeOf(
|
|
165
|
+
options: FileOptions,
|
|
166
|
+
file: FileInput,
|
|
167
|
+
): Promise<{
|
|
168
|
+
readonly inline: { readonly [key: string]: JsonValue }
|
|
169
|
+
readonly variants: readonly (readonly [string, Uint8Array, string])[]
|
|
170
|
+
}> {
|
|
171
|
+
const inline = Object.entries(options.inline ?? {}).map(
|
|
172
|
+
async ([key, given]) => [key, await inlineValue(key, given, file)] as const,
|
|
173
|
+
)
|
|
174
|
+
const variants = Object.entries(options.variants ?? {}).map(
|
|
175
|
+
async ([key, variant]) => {
|
|
176
|
+
const { body, type } = await variant(file)
|
|
177
|
+
return [key, body, type] as const
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
const [values, made] = await Promise.all([
|
|
181
|
+
Promise.all(inline),
|
|
182
|
+
Promise.all(variants),
|
|
183
|
+
])
|
|
184
|
+
return { inline: Object.fromEntries(values), variants: made }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Stores a file for `options.for`, as `POST /files` does, and gives its id,
|
|
189
|
+
* which a write of that field takes. `size` is the body's length in bytes.
|
|
190
|
+
*/
|
|
191
|
+
export async function upload(
|
|
192
|
+
body: FileBody,
|
|
193
|
+
size: number,
|
|
194
|
+
options: UploadOptions,
|
|
195
|
+
): Promise<{ readonly id: string }> {
|
|
196
|
+
const { name, as: caller = { kind: 'anonymous' } } = options
|
|
197
|
+
const field = fieldOf(options.for)
|
|
198
|
+
if (name === '') throw invalid('name', 'Expected a file name.')
|
|
199
|
+
const maxSize = field.maxSize ?? defaultMaxSize
|
|
200
|
+
if (size > maxSize) {
|
|
201
|
+
throw new PublicError(
|
|
202
|
+
'PAYLOAD_TOO_LARGE',
|
|
203
|
+
`The file is over ${maxSize} bytes.`,
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
const buffered = field.inline !== undefined || field.variants !== undefined
|
|
207
|
+
const read = buffered
|
|
208
|
+
? { sample: await bytesOf(body), body: undefined }
|
|
209
|
+
: await sampled(body)
|
|
210
|
+
const { type, attachment } = await typeOf(
|
|
211
|
+
read.sample,
|
|
212
|
+
options.type,
|
|
213
|
+
field.accept,
|
|
214
|
+
)
|
|
215
|
+
const { storage, logger } = runningOf()
|
|
216
|
+
const prefix = field.public === true ? 'public' : 'private'
|
|
217
|
+
const keyOf = () => `${prefix}/${randomUUID()}`
|
|
218
|
+
const uploadedBy = uploaderOf(caller)
|
|
219
|
+
const deleteAfter = new Date(Date.now() + hour)
|
|
220
|
+
const blob = { field: options.for, uploadedBy, state: 'pending', deleteAfter }
|
|
221
|
+
const main = await FilesBlob.create(
|
|
222
|
+
{ data: { ...blob, key: keyOf(), name, type, size } },
|
|
223
|
+
{ as: caller },
|
|
224
|
+
)
|
|
225
|
+
const keys = [main.key]
|
|
226
|
+
const ids = [main.id]
|
|
227
|
+
try {
|
|
228
|
+
if (read.body !== undefined) {
|
|
229
|
+
await storage.put(main.key, read.body, { type, size, attachment })
|
|
230
|
+
return { id: main.id }
|
|
231
|
+
}
|
|
232
|
+
const bytes = read.sample
|
|
233
|
+
if (bytes.length !== size) {
|
|
234
|
+
throw invalid('size', `The file has ${bytes.length} bytes, not ${size}.`)
|
|
235
|
+
}
|
|
236
|
+
const made = await madeOf(field, { body: bytes, type, name })
|
|
237
|
+
const variants = await settled(
|
|
238
|
+
made.variants.map(async ([key, variantBody, variantType]) => {
|
|
239
|
+
const row = await FilesBlob.create(
|
|
240
|
+
{
|
|
241
|
+
data: {
|
|
242
|
+
...blob,
|
|
243
|
+
key: keyOf(),
|
|
244
|
+
name: key,
|
|
245
|
+
type: variantType,
|
|
246
|
+
size: variantBody.length,
|
|
247
|
+
variantOf: main.id,
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
M,
|
|
251
|
+
)
|
|
252
|
+
keys.push(row.key)
|
|
253
|
+
ids.push(row.id)
|
|
254
|
+
return { row, body: variantBody }
|
|
255
|
+
}),
|
|
256
|
+
)
|
|
257
|
+
await settled([
|
|
258
|
+
storage.put(main.key, bytes, { type, size, attachment }),
|
|
259
|
+
...variants.map(async ({ row, body: variantBody }) =>
|
|
260
|
+
storage.put(row.key, variantBody, {
|
|
261
|
+
type: row.type,
|
|
262
|
+
size: row.size,
|
|
263
|
+
attachment: await attachmentOf(variantBody, row.type),
|
|
264
|
+
}),
|
|
265
|
+
),
|
|
266
|
+
])
|
|
267
|
+
await FilesBlob.update(
|
|
268
|
+
{ where: { id: main.id }, data: { inline: made.inline } },
|
|
269
|
+
M,
|
|
270
|
+
)
|
|
271
|
+
return { id: main.id }
|
|
272
|
+
} catch (error) {
|
|
273
|
+
// The sweep deletes what this cannot: the rows expire within the hour.
|
|
274
|
+
try {
|
|
275
|
+
await Promise.all(keys.map((key) => storage.delete(key)))
|
|
276
|
+
await FilesBlob.deleteMany({ where: { id: { in: ids } } }, M)
|
|
277
|
+
} catch (cleanup) {
|
|
278
|
+
logger.warn({ err: cleanup }, 'A failed upload was not cleaned up.')
|
|
279
|
+
}
|
|
280
|
+
throw error
|
|
281
|
+
}
|
|
282
|
+
}
|
package/src/values.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// A file field's values (D-152): the copy of each file's metadata the row
|
|
2
|
+
// stores, the value code reads, and the ids a write takes until the plugin's
|
|
3
|
+
// hooks resolve them.
|
|
4
|
+
import type { CallerRule, JsonValue, Logger } from 'ultimabase'
|
|
5
|
+
import type { FileOptions } from './field.ts'
|
|
6
|
+
import type { FileStorage } from './storage.ts'
|
|
7
|
+
|
|
8
|
+
/** A variant as a client reads it. */
|
|
9
|
+
export interface FileVariant {
|
|
10
|
+
readonly type: string
|
|
11
|
+
readonly size: number
|
|
12
|
+
readonly url: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** What a file field's value holds. */
|
|
16
|
+
export interface FileValue<
|
|
17
|
+
I extends object = { readonly [key: string]: JsonValue | null },
|
|
18
|
+
V extends object = { readonly [key: string]: FileVariant | null },
|
|
19
|
+
> {
|
|
20
|
+
readonly id: string
|
|
21
|
+
readonly name: string
|
|
22
|
+
readonly type: string
|
|
23
|
+
readonly size: number
|
|
24
|
+
/** Stable for a public file, presigned for one hour for a private one. */
|
|
25
|
+
readonly url: string
|
|
26
|
+
readonly inline: I
|
|
27
|
+
readonly variants: V
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The copy of a variant's metadata the row stores. */
|
|
31
|
+
export interface StoredVariant {
|
|
32
|
+
readonly key: string
|
|
33
|
+
readonly type: string
|
|
34
|
+
readonly size: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The copy of a file's metadata the row stores; `FilesBlob` is the truth. */
|
|
38
|
+
export interface StoredFile {
|
|
39
|
+
readonly id: string
|
|
40
|
+
readonly key: string
|
|
41
|
+
readonly name: string
|
|
42
|
+
readonly type: string
|
|
43
|
+
readonly size: number
|
|
44
|
+
readonly inline: { readonly [key: string]: JsonValue }
|
|
45
|
+
readonly variants: { readonly [key: string]: StoredVariant }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What a field's options say of its value's keys. */
|
|
49
|
+
export interface Keys {
|
|
50
|
+
readonly inline: readonly string[]
|
|
51
|
+
readonly variants: readonly string[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** How a write changes a field's files, before the hooks resolve it. */
|
|
55
|
+
export type Operation = 'set' | 'push' | 'pushUnique' | 'remove'
|
|
56
|
+
|
|
57
|
+
/** The ids a write gives, which the plugin's hooks resolve to files. */
|
|
58
|
+
export class Pending {
|
|
59
|
+
readonly operation: Operation
|
|
60
|
+
readonly ids: readonly string[]
|
|
61
|
+
constructor(operation: Operation, ids: readonly string[]) {
|
|
62
|
+
this.operation = operation
|
|
63
|
+
this.ids = ids
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What the running application gives the plugin. */
|
|
68
|
+
export interface Running {
|
|
69
|
+
readonly storage: FileStorage
|
|
70
|
+
/** Who may upload; Master always may. */
|
|
71
|
+
readonly upload: CallerRule
|
|
72
|
+
/** Each model's file fields, with their options. */
|
|
73
|
+
readonly fields: ReadonlyMap<string, ReadonlyMap<string, FileOptions>>
|
|
74
|
+
readonly logger: Logger
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The running application's: one runs at a time in a process.
|
|
78
|
+
let running: Running | undefined
|
|
79
|
+
|
|
80
|
+
export function useRunning(given: Running | undefined): void {
|
|
81
|
+
running = given
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function runningOf(): Running {
|
|
85
|
+
if (running === undefined) throw new Error('filesPlugin has not started.')
|
|
86
|
+
return running
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Each value code holds, with the copy the row stores. */
|
|
90
|
+
const copies = new WeakMap<object, StoredFile>()
|
|
91
|
+
|
|
92
|
+
/** The value code reads of a file, its URLs signed now. */
|
|
93
|
+
export function fileValue(stored: StoredFile, keys: Keys): FileValue {
|
|
94
|
+
const { storage } = runningOf()
|
|
95
|
+
const variants: { [key: string]: FileVariant | null } = {}
|
|
96
|
+
for (const key of keys.variants) {
|
|
97
|
+
const variant = stored.variants[key]
|
|
98
|
+
variants[key] =
|
|
99
|
+
variant === undefined
|
|
100
|
+
? null
|
|
101
|
+
: Object.freeze({
|
|
102
|
+
type: variant.type,
|
|
103
|
+
size: variant.size,
|
|
104
|
+
url: storage.url(variant.key),
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
const inline: { [key: string]: JsonValue | null } = {}
|
|
108
|
+
for (const key of keys.inline) inline[key] = stored.inline[key] ?? null
|
|
109
|
+
const value: FileValue = Object.freeze({
|
|
110
|
+
id: stored.id,
|
|
111
|
+
name: stored.name,
|
|
112
|
+
type: stored.type,
|
|
113
|
+
size: stored.size,
|
|
114
|
+
url: storage.url(stored.key),
|
|
115
|
+
inline: Object.freeze(inline),
|
|
116
|
+
variants: Object.freeze(variants),
|
|
117
|
+
})
|
|
118
|
+
copies.set(value, stored)
|
|
119
|
+
return value
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether a value is a file the plugin resolved. */
|
|
123
|
+
export const isFile = (value: unknown): value is FileValue =>
|
|
124
|
+
typeof value === 'object' && value !== null && copies.has(value)
|
|
125
|
+
|
|
126
|
+
/** The copy the row stores of a value code holds. */
|
|
127
|
+
export function copyOf(value: unknown, where: string): StoredFile {
|
|
128
|
+
const copy =
|
|
129
|
+
typeof value === 'object' && value !== null ? copies.get(value) : undefined
|
|
130
|
+
if (copy === undefined) {
|
|
131
|
+
throw new TypeError(
|
|
132
|
+
`${where}: a file field stores the files filesPlugin resolved, written as ids.`,
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
return copy
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const isRecord = (value: unknown): value is { [key: string]: unknown } =>
|
|
139
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
140
|
+
|
|
141
|
+
const isString = (value: unknown): value is string => typeof value === 'string'
|
|
142
|
+
const isSize = (value: unknown): value is number =>
|
|
143
|
+
typeof value === 'number' && Number.isInteger(value) && value >= 0
|
|
144
|
+
|
|
145
|
+
function variantsOf(
|
|
146
|
+
value: unknown,
|
|
147
|
+
): { readonly [key: string]: StoredVariant } | undefined {
|
|
148
|
+
if (!isRecord(value)) return undefined
|
|
149
|
+
const variants: { [key: string]: StoredVariant } = {}
|
|
150
|
+
for (const [name, variant] of Object.entries(value)) {
|
|
151
|
+
if (!isRecord(variant)) return undefined
|
|
152
|
+
const { key, type, size } = variant
|
|
153
|
+
if (!isString(key) || !isString(type) || !isSize(size)) return undefined
|
|
154
|
+
variants[name] = { key, type, size }
|
|
155
|
+
}
|
|
156
|
+
return variants
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A stored copy, read back from the row; any other value is a bug. */
|
|
160
|
+
export function storedFile(value: unknown, where: string): StoredFile {
|
|
161
|
+
if (isRecord(value)) {
|
|
162
|
+
const { id, key, name, type, size, inline } = value
|
|
163
|
+
const variants = variantsOf(value['variants'])
|
|
164
|
+
if (
|
|
165
|
+
isString(id) &&
|
|
166
|
+
isString(key) &&
|
|
167
|
+
isString(name) &&
|
|
168
|
+
isString(type) &&
|
|
169
|
+
isSize(size) &&
|
|
170
|
+
isRecord(inline) &&
|
|
171
|
+
variants !== undefined
|
|
172
|
+
) {
|
|
173
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the row's JSON column holds JSON.
|
|
174
|
+
const json = inline as { readonly [key: string]: JsonValue }
|
|
175
|
+
return { id, key, name, type, size, inline: json, variants }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
throw new TypeError(`${where}: the row holds a file that is not one.`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A copy as the row's JSON column stores it. */
|
|
182
|
+
export const storedJson = (copy: StoredFile): JsonValue => ({
|
|
183
|
+
id: copy.id,
|
|
184
|
+
key: copy.key,
|
|
185
|
+
name: copy.name,
|
|
186
|
+
type: copy.type,
|
|
187
|
+
size: copy.size,
|
|
188
|
+
inline: copy.inline,
|
|
189
|
+
variants: Object.fromEntries(
|
|
190
|
+
Object.entries(copy.variants).map(([name, { key, type, size }]) => [
|
|
191
|
+
name,
|
|
192
|
+
{ key, type, size },
|
|
193
|
+
]),
|
|
194
|
+
),
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
/** A value as a client reads it. */
|
|
198
|
+
export const fileJson = (value: FileValue): JsonValue => ({
|
|
199
|
+
id: value.id,
|
|
200
|
+
name: value.name,
|
|
201
|
+
type: value.type,
|
|
202
|
+
size: value.size,
|
|
203
|
+
url: value.url,
|
|
204
|
+
inline: { ...value.inline },
|
|
205
|
+
variants: Object.fromEntries(
|
|
206
|
+
Object.entries(value.variants).map(([name, variant]) => [
|
|
207
|
+
name,
|
|
208
|
+
variant === null
|
|
209
|
+
? null
|
|
210
|
+
: { type: variant.type, size: variant.size, url: variant.url },
|
|
211
|
+
]),
|
|
212
|
+
),
|
|
213
|
+
})
|