@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/plugin.ts ADDED
@@ -0,0 +1,144 @@
1
+ // `filesPlugin({ storage, access })` (D-152): the plugin of the `file` field
2
+ // type, which runs its hooks on every model holding a file field, owns
3
+ // `FilesBlob`, sweeps what a crash left and serves `POST /files`.
4
+ import type { Http } from '@ultimabase/http'
5
+ import { httpPlugin } from '@ultimabase/http'
6
+ import type {
7
+ CallerRule,
8
+ ExposedModel,
9
+ Identity,
10
+ PluginOf,
11
+ PluginTypes,
12
+ } from 'ultimabase'
13
+ import { definePlugin } from 'ultimabase'
14
+ import manifest from '../package.json' with { type: 'json' }
15
+ import type { FileOptions } from './field.ts'
16
+ import type { Checked } from './lifecycle.ts'
17
+ import {
18
+ attach,
19
+ check,
20
+ deleteHeld,
21
+ deleteLost,
22
+ release,
23
+ resolve,
24
+ sweep,
25
+ } from './lifecycle.ts'
26
+ import { FilesBlob } from './model.ts'
27
+ import type { FileBody, FileStorage } from './storage.ts'
28
+ import { uploads } from './routes.ts'
29
+ import { upload } from './upload.ts'
30
+ import { runningOf, useRunning } from './values.ts'
31
+
32
+ /** What `filesPlugin` takes (D-152). */
33
+ export interface FilesOptions {
34
+ readonly storage: FileStorage
35
+ readonly access?: {
36
+ /** A signed-in user by default; Master always may. */
37
+ readonly upload?: CallerRule
38
+ }
39
+ }
40
+
41
+ /** The files plugin's types (D-57). */
42
+ export interface Files extends PluginTypes {
43
+ readonly name: 'files'
44
+ readonly appOptions: FilesOptions
45
+ readonly requires: Http
46
+ }
47
+
48
+ /** Ten minutes, between two sweeps. */
49
+ const sweepEvery = 10 * 60 * 1000
50
+
51
+ /** Each model's file fields, with the options `file()` checked. */
52
+ function fieldsOf(
53
+ models: readonly ExposedModel[],
54
+ ): ReadonlyMap<string, ReadonlyMap<string, FileOptions>> {
55
+ const fields = new Map<string, ReadonlyMap<string, FileOptions>>()
56
+ for (const model of models) {
57
+ const own = Object.entries(model.fieldTypes).flatMap(([field, type]) =>
58
+ type.plugin === 'files'
59
+ ? [[field, type.options as FileOptions] as const]
60
+ : [],
61
+ )
62
+ if (own.length > 0) fields.set(model.schema.name, new Map(own))
63
+ }
64
+ return fields
65
+ }
66
+
67
+ const signedIn: CallerRule = ({ caller }) => caller.kind === 'user'
68
+
69
+ // The sweep's timer, while the application runs.
70
+ let timer: NodeJS.Timeout | undefined
71
+
72
+ /** Sweeps, then again every ten minutes; a failed sweep is logged. */
73
+ async function sweeping(): Promise<void> {
74
+ try {
75
+ await sweep()
76
+ } catch (error) {
77
+ runningOf().logger.warn({ err: error }, 'The files sweep failed.')
78
+ }
79
+ }
80
+
81
+ const definition = definePlugin<Files>({
82
+ name: 'files',
83
+ manifest,
84
+ requires: { http: httpPlugin },
85
+ models: [FilesBlob],
86
+ api: ({ plugins }) => {
87
+ plugins.http.mount(uploads(), { bodyLimit: false })
88
+ },
89
+ hooks: {
90
+ beforeCreate: (facts) => resolve(facts),
91
+ beforeUpdate: (facts) => resolve(facts),
92
+ afterCreate: (facts) => attach(facts),
93
+ afterUpdate: (facts) => attach(facts),
94
+ afterDelete: (facts) => release(facts),
95
+ afterUpdateCommit: (facts) => deleteLost(facts),
96
+ afterDeleteCommit: (facts) => deleteHeld(facts),
97
+ },
98
+ start: async ({ options, models, logger }) => {
99
+ const { storage, access } = options
100
+ storage.start()
101
+ const fields = fieldsOf(models)
102
+ useRunning({ storage, upload: access?.upload ?? signedIn, fields, logger })
103
+ await sweeping()
104
+ timer = setInterval(() => void sweeping(), sweepEvery)
105
+ timer.unref()
106
+ },
107
+ stop: () => {
108
+ clearInterval(timer)
109
+ timer = undefined
110
+ useRunning(undefined)
111
+ },
112
+ })
113
+
114
+ /** Files for the application's file fields; it requires `httpPlugin()`. */
115
+ export const filesPlugin: PluginOf<Files> = definition.plugin
116
+
117
+ /** What `files.upload` takes. */
118
+ export interface UploadArgs {
119
+ /** The field the file is for: `Post.cover`. */
120
+ readonly for: string
121
+ readonly name: string
122
+ readonly as?: Identity
123
+ }
124
+
125
+ /** The running files plugin, from server code (D-152). */
126
+ export const files: {
127
+ /** Stores a file as `POST /files` does, and gives its id. */
128
+ upload(
129
+ body: Uint8Array | Blob,
130
+ args: UploadArgs,
131
+ ): Promise<{ readonly id: string }>
132
+ /** Compares the storage's keys with `FilesBlob`. */
133
+ check(options?: { readonly delete?: boolean }): Promise<Checked>
134
+ } = {
135
+ upload: (body, args) => {
136
+ const given: FileBody = body instanceof Uint8Array ? body : body.stream()
137
+ const type = body instanceof Uint8Array ? undefined : body.type
138
+ return upload(given, body instanceof Uint8Array ? body.length : body.size, {
139
+ ...args,
140
+ type,
141
+ })
142
+ },
143
+ check: (options) => check(options),
144
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,42 @@
1
+ // `POST /files?for=Post.cover&name=photo.jpg` (D-152): the file as the raw
2
+ // body, which its field's `maxSize` limits, not the HTTP plugin (D-153).
3
+ import type { Routes } from '@ultimabase/http'
4
+ import { routes } from '@ultimabase/http'
5
+ import { PublicError } from 'ultimabase'
6
+ import { upload } from './upload.ts'
7
+
8
+ /** An upload's own refusals, which the HTTP plugin does not know. */
9
+ const statuses: ReadonlyMap<string, 413 | 415> = new Map([
10
+ ['PAYLOAD_TOO_LARGE', 413],
11
+ ['UNSUPPORTED_MEDIA_TYPE', 415],
12
+ ] as const)
13
+
14
+ /** The upload route, which the plugin mounts without the body limit. */
15
+ export const uploads = (): Routes =>
16
+ routes().post('/files', async (c) => {
17
+ const length = c.req.header('content-length')
18
+ if (length === undefined) {
19
+ return c.json(
20
+ { code: 'LENGTH_REQUIRED', message: 'Content-Length is required.' },
21
+ 411,
22
+ )
23
+ }
24
+ try {
25
+ const { id } = await upload(
26
+ c.req.raw.body ?? new Uint8Array(),
27
+ Number(length),
28
+ {
29
+ for: c.req.query('for') ?? '',
30
+ name: c.req.query('name') ?? '',
31
+ type: c.req.header('content-type'),
32
+ as: c.var.caller,
33
+ },
34
+ )
35
+ return c.json({ id }, 201)
36
+ } catch (error) {
37
+ if (!(error instanceof PublicError)) throw error
38
+ const status = statuses.get(error.code)
39
+ if (status === undefined) throw error
40
+ return c.json({ code: error.code, message: error.message }, status)
41
+ }
42
+ })
package/src/s3.ts ADDED
@@ -0,0 +1,141 @@
1
+ // `s3()` (D-152): any S3-compatible storage (AWS, DigitalOcean Spaces,
2
+ // MinIO, SeaweedFS), path-style, each request presigned with SigV4.
3
+ import type { Signer } from './sigv4.ts'
4
+ import { presign } from './sigv4.ts'
5
+ import type { FileStorage } from './storage.ts'
6
+ import { isPublic } from './storage.ts'
7
+
8
+ /** What `s3()` takes; the first five are required, read when the application starts. */
9
+ export interface S3Options {
10
+ readonly bucket: string | undefined
11
+ readonly region: string | undefined
12
+ /** `https://fra1.digitaloceanspaces.com`. */
13
+ readonly endpoint: string | undefined
14
+ readonly accessKeyId: string | undefined
15
+ readonly secretAccessKey: string | undefined
16
+ /** Sends `x-amz-acl: public-read` with a public file, which Spaces needs. */
17
+ readonly publicAcl?: boolean
18
+ /** The base of a public file's URL, a CDN; the storage's own otherwise. */
19
+ readonly publicUrl?: string
20
+ }
21
+
22
+ const required = [
23
+ 'bucket',
24
+ 'region',
25
+ 'endpoint',
26
+ 'accessKeyId',
27
+ 'secretAccessKey',
28
+ ] as const
29
+
30
+ type Config = Signer & { readonly bucket: string; readonly base: string }
31
+
32
+ const trimmed = (url: string): string => url.replace(/\/+$/, '')
33
+
34
+ /** The options checked: a missing one stops startup, naming it. */
35
+ function configOf(options: S3Options): Config {
36
+ const missing = required.filter((key) => !options[key])
37
+ if (missing.length > 0) {
38
+ throw new Error(`s3() needs ${missing.join(', ')}: undefined or empty.`)
39
+ }
40
+ const { bucket = '', region = '', endpoint = '' } = options
41
+ const { accessKeyId = '', secretAccessKey = '' } = options
42
+ const base = `${trimmed(endpoint)}/${encodeURIComponent(bucket)}`
43
+ return { bucket, region, accessKeyId, secretAccessKey, base }
44
+ }
45
+
46
+ /** Sent unchanged when the storage answers otherwise. */
47
+ async function answered(
48
+ response: Response,
49
+ what: string,
50
+ expected: readonly number[] = [200],
51
+ ): Promise<Response> {
52
+ if (expected.includes(response.status)) return response
53
+ const text = await response.text()
54
+ throw new Error(`s3: ${what} failed with ${response.status}: ${text}`)
55
+ }
56
+
57
+ const entities: { readonly [entity: string]: string } = {
58
+ amp: '&',
59
+ lt: '<',
60
+ gt: '>',
61
+ quot: '"',
62
+ apos: "'",
63
+ }
64
+ const decoded = (text: string): string =>
65
+ text.replaceAll(
66
+ /&(amp|lt|gt|quot|apos);/g,
67
+ (entity, name: string) => entities[name] ?? entity,
68
+ )
69
+
70
+ /** The keys of one `ListObjectsV2` page, and the token of the next. */
71
+ function pageOf(xml: string): {
72
+ readonly keys: readonly string[]
73
+ readonly next: string | undefined
74
+ } {
75
+ const keys = [...xml.matchAll(/<Key>([^<]*)<\/Key>/g)].map(([, key = '']) =>
76
+ decoded(key),
77
+ )
78
+ const truncated = xml.includes('<IsTruncated>true</IsTruncated>')
79
+ const token = /<NextContinuationToken>([^<]*)</.exec(xml)?.[1]
80
+ return { keys, next: truncated && token ? decoded(token) : undefined }
81
+ }
82
+
83
+ /** An S3-compatible storage; its options are checked when the application starts. */
84
+ export function s3(options: S3Options): FileStorage {
85
+ let config: Config | undefined
86
+ const configured = (): Config => (config ??= configOf(options))
87
+ const objectUrl = (key: string) => new URL(`${configured().base}/${key}`)
88
+ const signed = (
89
+ method: string,
90
+ url: URL,
91
+ expires: number,
92
+ headers: { readonly [name: string]: string } = {},
93
+ ) => presign(configured(), { method, url, expires, headers })
94
+ return {
95
+ start: () => {
96
+ configured()
97
+ },
98
+ put: async (key, body, { type, size, attachment }) => {
99
+ const headers: Record<string, string> = { 'content-type': type }
100
+ if (attachment) headers['content-disposition'] = 'attachment'
101
+ if (isPublic(key) && options.publicAcl === true) {
102
+ headers['x-amz-acl'] = 'public-read'
103
+ }
104
+ const url = signed('PUT', objectUrl(key), 900, headers)
105
+ const response = await fetch(url, {
106
+ method: 'PUT',
107
+ headers: { ...headers, 'content-length': String(size) },
108
+ body,
109
+ duplex: 'half',
110
+ })
111
+ await answered(response, `PUT ${key}`)
112
+ },
113
+ delete: async (key) => {
114
+ const url = signed('DELETE', objectUrl(key), 900)
115
+ const response = await fetch(url, { method: 'DELETE' })
116
+ await answered(response, `DELETE ${key}`, [200, 204, 404])
117
+ },
118
+ keys: async () => {
119
+ const keys: string[] = []
120
+ let next: string | undefined
121
+ do {
122
+ const list = new URL(configured().base)
123
+ list.searchParams.set('list-type', '2')
124
+ if (next !== undefined)
125
+ list.searchParams.set('continuation-token', next)
126
+ const response = await fetch(signed('GET', list, 900))
127
+ const page = pageOf(await (await answered(response, 'list')).text())
128
+ keys.push(...page.keys)
129
+ next = page.next
130
+ } while (next !== undefined)
131
+ return keys
132
+ },
133
+ url: (key) => {
134
+ if (!isPublic(key)) return signed('GET', objectUrl(key), 3600)
135
+ const { publicUrl } = options
136
+ return publicUrl === undefined
137
+ ? objectUrl(key).href
138
+ : `${trimmed(publicUrl)}/${key}`
139
+ },
140
+ }
141
+ }
package/src/shape.ts ADDED
@@ -0,0 +1,83 @@
1
+ // A file field's shape (D-152): the JSON Schema of what clients read, which
2
+ // REST and GraphQL type it with. It validates nothing: the plugin made the
3
+ // value.
4
+ import type { StandardSchema } from './standard.ts'
5
+ import type { FileOptions } from './field.ts'
6
+
7
+ type Node = { readonly [key: string]: unknown }
8
+ type Exported = {
9
+ readonly jsonSchema: {
10
+ readonly input: () => Node
11
+ readonly output: () => Node
12
+ }
13
+ }
14
+
15
+ const isNode = (value: unknown): value is Node =>
16
+ typeof value === 'object' && value !== null && !Array.isArray(value)
17
+
18
+ const string = { type: 'string' }
19
+ const size = { type: 'integer', minimum: 0 }
20
+ const nullable = (node: Node): Node =>
21
+ Object.keys(node).length === 0 ? node : { anyOf: [node, { type: 'null' }] }
22
+
23
+ const closed = (properties: { readonly [key: string]: Node }): Node => ({
24
+ type: 'object',
25
+ properties,
26
+ required: Object.keys(properties),
27
+ additionalProperties: false,
28
+ })
29
+
30
+ /** An inline value's JSON Schema, from its schema's; any JSON without one. */
31
+ function inlineOf(inline: unknown): Node {
32
+ if (!isNode(inline) || !isNode(inline['schema'])) return {}
33
+ const standard = inline['schema']['~standard']
34
+ const exporter = isNode(standard) ? standard['jsonSchema'] : undefined
35
+ const output = isNode(exporter) ? exporter['output'] : undefined
36
+ if (typeof output !== 'function') return {}
37
+ const node: unknown = Reflect.apply(output, exporter, [
38
+ { target: 'draft-2020-12' },
39
+ ])
40
+ if (!isNode(node)) return {}
41
+ const { $schema: _, ...rest } = node
42
+ return rest
43
+ }
44
+
45
+ function fileNode(options: FileOptions): Node {
46
+ const inline = Object.entries(options.inline ?? {}).map(
47
+ ([key, given]) => [key, nullable(inlineOf(given))] as const,
48
+ )
49
+ const variant = closed({ type: string, size, url: string })
50
+ const variants = Object.keys(options.variants ?? {}).map(
51
+ (key) => [key, nullable(variant)] as const,
52
+ )
53
+ return closed({
54
+ id: string,
55
+ name: string,
56
+ type: string,
57
+ size,
58
+ url: string,
59
+ inline: closed(Object.fromEntries(inline)),
60
+ variants: closed(Object.fromEntries(variants)),
61
+ })
62
+ }
63
+
64
+ function nodeOf(options: FileOptions): Node {
65
+ const node = fileNode(options)
66
+ if (options.list === true) return { type: 'array', items: node }
67
+ return options.optional === true ? nullable(node) : node
68
+ }
69
+
70
+ /** What a field with `options` gives clients, as a Standard Schema. */
71
+ export const shapeOf = (
72
+ options: FileOptions,
73
+ ): StandardSchema & { readonly '~standard': Exported } => ({
74
+ '~standard': {
75
+ version: 1,
76
+ vendor: 'ultimabase-files',
77
+ validate: (value) => ({ value }),
78
+ jsonSchema: {
79
+ input: () => nodeOf(options),
80
+ output: () => nodeOf(options),
81
+ },
82
+ },
83
+ })
package/src/sigv4.ts ADDED
@@ -0,0 +1,81 @@
1
+ // AWS Signature Version 4, query presigning (D-152): on `node:crypto`, which
2
+ // is synchronous, so a read signs its URLs while it decodes. The payload is
3
+ // unsigned, as presigned URLs are.
4
+ import { createHash, createHmac } from 'node:crypto'
5
+
6
+ export interface Signer {
7
+ readonly region: string
8
+ readonly accessKeyId: string
9
+ readonly secretAccessKey: string
10
+ }
11
+
12
+ export interface Presigned {
13
+ readonly method: string
14
+ readonly url: URL
15
+ /** The headers the request sends and signs, `host` added. */
16
+ readonly headers: { readonly [name: string]: string }
17
+ /** In seconds. */
18
+ readonly expires: number
19
+ }
20
+
21
+ /** RFC 3986, as SigV4 encodes: everything but `A-Za-z0-9-_.~`. */
22
+ const encoded = (value: string): string =>
23
+ encodeURIComponent(value).replaceAll(
24
+ /[!'()*]/g,
25
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
26
+ )
27
+
28
+ const hmac = (key: string | Buffer, value: string): Buffer =>
29
+ createHmac('sha256', key).update(value).digest()
30
+
31
+ /** `20130524T000000Z`. */
32
+ const stampOf = (now: Date): string =>
33
+ now
34
+ .toISOString()
35
+ .replaceAll(/[-:]/g, '')
36
+ .replace(/\.\d{3}/, '')
37
+
38
+ /** The URL that runs the request until it expires, signed now. */
39
+ export function presign(signer: Signer, request: Presigned): string {
40
+ const { method, url, expires } = request
41
+ const stamp = stampOf(new Date())
42
+ const day = stamp.slice(0, 8)
43
+ const scope = `${day}/${signer.region}/s3/aws4_request`
44
+ const headers = new Map(
45
+ Object.entries({ ...request.headers, host: url.host }).map(
46
+ ([name, value]) => [name.toLowerCase(), value.trim()],
47
+ ),
48
+ )
49
+ const names = [...headers.keys()].toSorted()
50
+ const signed = names.join(';')
51
+ const query = new URLSearchParams(url.search)
52
+ query.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256')
53
+ query.set('X-Amz-Credential', `${signer.accessKeyId}/${scope}`)
54
+ query.set('X-Amz-Date', stamp)
55
+ query.set('X-Amz-Expires', String(expires))
56
+ query.set('X-Amz-SignedHeaders', signed)
57
+ const canonicalQuery = [...query]
58
+ .map(([key, value]) => [encoded(key), encoded(value)])
59
+ .toSorted(([a = ''], [b = '']) => (a < b ? -1 : a > b ? 1 : 0))
60
+ .map(([key, value]) => `${key}=${value}`)
61
+ .join('&')
62
+ const path = url.pathname
63
+ .split('/')
64
+ .map((part) => encoded(decodeURIComponent(part)))
65
+ const canonical = [
66
+ method,
67
+ path.join('/'),
68
+ canonicalQuery,
69
+ names.map((name) => `${name}:${headers.get(name)}\n`).join(''),
70
+ signed,
71
+ 'UNSIGNED-PAYLOAD',
72
+ ].join('\n')
73
+ const digest = createHash('sha256').update(canonical).digest('hex')
74
+ const toSign = ['AWS4-HMAC-SHA256', stamp, scope, digest].join('\n')
75
+ const key = ['s3', 'aws4_request'].reduce(
76
+ hmac,
77
+ hmac(hmac(`AWS4${signer.secretAccessKey}`, day), signer.region),
78
+ )
79
+ const signature = createHmac('sha256', key).update(toSign).digest('hex')
80
+ return `${url.origin}${path.join('/')}?${canonicalQuery}&X-Amz-Signature=${signature}`
81
+ }
@@ -0,0 +1,15 @@
1
+ // Standard Schema (standardschema.dev), the part this package reads. Its
2
+ // specification asks for the interface to be copied, not imported.
3
+ export interface StandardSchema<I = unknown, O = I> {
4
+ readonly '~standard': {
5
+ readonly version: 1
6
+ readonly vendor: string
7
+ readonly validate: (
8
+ value: unknown,
9
+ ) => SchemaResult<O> | Promise<SchemaResult<O>>
10
+ readonly types?: { readonly input: I; readonly output: O } | undefined
11
+ }
12
+ }
13
+ type SchemaResult<O> =
14
+ | { readonly value: O; readonly issues?: undefined }
15
+ | { readonly issues: readonly { readonly message: string }[] }
package/src/storage.ts ADDED
@@ -0,0 +1,46 @@
1
+ // What `filesPlugin` stores files in (D-152): an S3-compatible storage, or
2
+ // memory for tests. A key starts with `public/` or `private/`, which says
3
+ // how the object is served.
4
+
5
+ /** A file's bytes: streamed from a request, or held in memory. */
6
+ export type FileBody = ReadableStream<Uint8Array> | Uint8Array
7
+
8
+ /** How an object is stored and served. */
9
+ export interface PutOptions {
10
+ readonly type: string
11
+ /** The body's length in bytes, which the storage checks. */
12
+ readonly size: number
13
+ /** Served as a download, so a browser never runs it: SVG, undetected types. */
14
+ readonly attachment: boolean
15
+ }
16
+
17
+ /** A storage adapter: `s3()` or `memoryFiles()`. */
18
+ export interface FileStorage {
19
+ /** Checks its options when the application starts. */
20
+ start(): void
21
+ put(key: string, body: FileBody, options: PutOptions): Promise<void>
22
+ /** Deletes an object; a missing one is not an error. */
23
+ delete(key: string): Promise<void>
24
+ /** Every key it stores. */
25
+ keys(): Promise<readonly string[]>
26
+ /** A public object's stable URL, a private one's presigned for one hour. */
27
+ url(key: string): string
28
+ }
29
+
30
+ /** Whether a key is served to everyone. */
31
+ export const isPublic = (key: string): boolean => key.startsWith('public/')
32
+
33
+ /**
34
+ * Each promise ended, then the first failure thrown: a put still running
35
+ * would store an object after its cleanup.
36
+ */
37
+ export async function settled<T>(
38
+ promises: readonly Promise<T>[],
39
+ ): Promise<T[]> {
40
+ const results = await Promise.allSettled(promises)
41
+ const failed = results.find((result) => result.status === 'rejected')
42
+ if (failed !== undefined) throw failed.reason
43
+ return results.flatMap((result) =>
44
+ result.status === 'fulfilled' ? [result.value] : [],
45
+ )
46
+ }