@ultimabase/files 0.0.0-stage → 0.1.1

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +4 -2
  3. package/dist/field.d.ts +89 -0
  4. package/dist/field.d.ts.map +1 -0
  5. package/dist/field.js +123 -0
  6. package/dist/index.d.ts +12 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +4 -0
  9. package/dist/lifecycle.d.ts +50 -0
  10. package/dist/lifecycle.d.ts.map +1 -0
  11. package/dist/lifecycle.js +192 -0
  12. package/dist/memory.d.ts +16 -0
  13. package/dist/memory.d.ts.map +1 -0
  14. package/dist/memory.js +27 -0
  15. package/dist/model.d.ts +41 -0
  16. package/dist/model.d.ts.map +1 -0
  17. package/dist/model.js +28 -0
  18. package/dist/plugin.d.ts +39 -0
  19. package/dist/plugin.d.ts.map +1 -0
  20. package/dist/plugin.js +80 -0
  21. package/dist/routes.d.ts +4 -0
  22. package/dist/routes.d.ts.map +1 -0
  23. package/dist/routes.js +32 -0
  24. package/dist/s3.d.ts +17 -0
  25. package/dist/s3.d.ts.map +1 -0
  26. package/dist/s3.js +99 -0
  27. package/dist/shape.d.ts +17 -0
  28. package/dist/shape.d.ts.map +1 -0
  29. package/dist/shape.js +59 -0
  30. package/dist/sigv4.d.ts +18 -0
  31. package/dist/sigv4.d.ts.map +1 -0
  32. package/dist/sigv4.js +49 -0
  33. package/dist/standard.d.ts +21 -0
  34. package/dist/standard.d.ts.map +1 -0
  35. package/dist/standard.js +1 -0
  36. package/dist/storage.d.ts +30 -0
  37. package/dist/storage.d.ts.map +1 -0
  38. package/dist/storage.js +16 -0
  39. package/dist/upload.d.ts +19 -0
  40. package/dist/upload.d.ts.map +1 -0
  41. package/dist/upload.js +206 -0
  42. package/dist/values.d.ts +81 -0
  43. package/dist/values.d.ts.map +1 -0
  44. package/dist/values.js +125 -0
  45. package/package.json +46 -3
  46. package/src/field.ts +236 -0
  47. package/src/index.ts +22 -0
  48. package/src/lifecycle.ts +270 -0
  49. package/src/memory.ts +46 -0
  50. package/src/model.ts +67 -0
  51. package/src/plugin.ts +144 -0
  52. package/src/routes.ts +42 -0
  53. package/src/s3.ts +141 -0
  54. package/src/shape.ts +83 -0
  55. package/src/sigv4.ts +81 -0
  56. package/src/standard.ts +15 -0
  57. package/src/storage.ts +46 -0
  58. package/src/upload.ts +282 -0
  59. 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
+ })