@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/field.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// `file(options)` (D-152): a field of one file or a list of them, stored as a
|
|
2
|
+
// JSON copy of their metadata. A write gives ids, which the plugin's hooks
|
|
3
|
+
// resolve; a read gives each file's URL, signed through the running storage.
|
|
4
|
+
import type { Field, JsonValue } from 'ultimabase'
|
|
5
|
+
import type { StandardSchema } from './standard.ts'
|
|
6
|
+
import { defineFieldType } from 'ultimabase'
|
|
7
|
+
import { shapeOf } from './shape.ts'
|
|
8
|
+
import type { FileValue, FileVariant, Keys, Operation } from './values.ts'
|
|
9
|
+
import {
|
|
10
|
+
copyOf,
|
|
11
|
+
fileJson,
|
|
12
|
+
fileValue,
|
|
13
|
+
Pending,
|
|
14
|
+
storedFile,
|
|
15
|
+
storedJson,
|
|
16
|
+
} from './values.ts'
|
|
17
|
+
|
|
18
|
+
/** What `inline` and `variants` are made from, at upload. */
|
|
19
|
+
export interface FileInput {
|
|
20
|
+
readonly body: Uint8Array
|
|
21
|
+
readonly type: string
|
|
22
|
+
readonly name: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Computes one inline value: JSON of 2 KB at most. */
|
|
26
|
+
export type InlineRun = (file: FileInput) => JsonValue | Promise<JsonValue>
|
|
27
|
+
/** An inline value; with its `schema`, the transports type it. */
|
|
28
|
+
export type Inline =
|
|
29
|
+
InlineRun | { readonly run: InlineRun; readonly schema: StandardSchema }
|
|
30
|
+
|
|
31
|
+
/** Another file made from the upload: a thumbnail. */
|
|
32
|
+
export type Variant = (file: FileInput) => MadeVariant | Promise<MadeVariant>
|
|
33
|
+
export interface MadeVariant {
|
|
34
|
+
readonly body: Uint8Array
|
|
35
|
+
readonly type: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What `file()` takes (D-152). */
|
|
39
|
+
export interface FileOptions {
|
|
40
|
+
readonly description?: string
|
|
41
|
+
/** A single file that may be `null`. */
|
|
42
|
+
readonly optional?: boolean
|
|
43
|
+
/** A list of files, `[]` when empty. */
|
|
44
|
+
readonly list?: boolean
|
|
45
|
+
/** In bytes, 50 MB by default. */
|
|
46
|
+
readonly maxSize?: number
|
|
47
|
+
/** MIME types or patterns: `['image/*', 'application/pdf']`; any by default. */
|
|
48
|
+
readonly accept?: readonly string[]
|
|
49
|
+
/** Served to everyone at a stable URL; presigned for one hour otherwise. */
|
|
50
|
+
readonly public?: boolean
|
|
51
|
+
readonly inline?: { readonly [key: string]: Inline }
|
|
52
|
+
readonly variants?: { readonly [key: string]: Variant }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type InlineOf<T> = T extends {
|
|
56
|
+
readonly schema: StandardSchema<unknown, infer O>
|
|
57
|
+
}
|
|
58
|
+
? O
|
|
59
|
+
: T extends (...args: never[]) => infer R
|
|
60
|
+
? Awaited<R>
|
|
61
|
+
: never
|
|
62
|
+
type Inlines<O> = O extends { readonly inline: infer I }
|
|
63
|
+
? { readonly [K in keyof I]: InlineOf<I[K]> | null }
|
|
64
|
+
: { readonly [K in never]: never }
|
|
65
|
+
type Variants<O> = O extends { readonly variants: infer V }
|
|
66
|
+
? { readonly [K in keyof V]: FileVariant | null }
|
|
67
|
+
: { readonly [K in never]: never }
|
|
68
|
+
/** The value of a field with options `O`. */
|
|
69
|
+
export type FileOf<O> = FileValue<Inlines<O>, Variants<O>>
|
|
70
|
+
type ValueOf<O> = O extends { readonly list: true }
|
|
71
|
+
? readonly FileOf<O>[]
|
|
72
|
+
: FileOf<O>
|
|
73
|
+
type Ids = string | readonly string[]
|
|
74
|
+
/** A list's write: the new list, or one operation, as kernel lists (D-149). */
|
|
75
|
+
type ListInput =
|
|
76
|
+
| readonly string[]
|
|
77
|
+
| {
|
|
78
|
+
readonly [K in Operation]: { readonly [P in K]: Ids } & {
|
|
79
|
+
readonly [P in Exclude<Operation, K>]?: never
|
|
80
|
+
}
|
|
81
|
+
}[Operation]
|
|
82
|
+
type InputOf<O> = O extends { readonly list: true } ? ListInput : string
|
|
83
|
+
|
|
84
|
+
/** A file field: read as files, written as ids. */
|
|
85
|
+
export type FileField<O> = Field<
|
|
86
|
+
ValueOf<O>,
|
|
87
|
+
O,
|
|
88
|
+
'files',
|
|
89
|
+
never,
|
|
90
|
+
InputOf<O>,
|
|
91
|
+
true,
|
|
92
|
+
Field<JsonValue, O, never, never, JsonValue, true>
|
|
93
|
+
>
|
|
94
|
+
|
|
95
|
+
/** 50 MB. */
|
|
96
|
+
export const defaultMaxSize: number = 50 * 1024 * 1024
|
|
97
|
+
const operations: readonly Operation[] = ['set', 'push', 'pushUnique', 'remove']
|
|
98
|
+
|
|
99
|
+
export const keysOf = (options: FileOptions): Keys => ({
|
|
100
|
+
inline: Object.keys(options.inline ?? {}),
|
|
101
|
+
variants: Object.keys(options.variants ?? {}),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
/** Ids, each once; `undefined` when the value is not a list of them. */
|
|
105
|
+
function idsOf(value: unknown, unique: boolean): readonly string[] | undefined {
|
|
106
|
+
const ids: unknown[] = Array.isArray(value) ? value : [value]
|
|
107
|
+
if (!ids.every((id): id is string => typeof id === 'string' && id !== '')) {
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
if (unique && new Set(ids).size !== ids.length) return undefined
|
|
111
|
+
return ids
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The ids a write gives, as the hooks resolve them. */
|
|
115
|
+
function pendingOf(value: unknown, list: boolean): Pending | string {
|
|
116
|
+
if (!list) {
|
|
117
|
+
return typeof value === 'string' && value !== ''
|
|
118
|
+
? new Pending('set', [value])
|
|
119
|
+
: 'Expected a file id.'
|
|
120
|
+
}
|
|
121
|
+
const expected =
|
|
122
|
+
'Expected a list of file ids, each once, or one of set, push, pushUnique, remove.'
|
|
123
|
+
if (Array.isArray(value)) {
|
|
124
|
+
const ids = idsOf(value, true)
|
|
125
|
+
return ids === undefined ? expected : new Pending('set', ids)
|
|
126
|
+
}
|
|
127
|
+
if (typeof value !== 'object' || value === null) return expected
|
|
128
|
+
const given = Object.entries(value).filter(([, ids]) => ids !== undefined)
|
|
129
|
+
const [entry] = given
|
|
130
|
+
const operation = operations.find((name) => name === entry?.[0])
|
|
131
|
+
if (given.length !== 1 || operation === undefined) return expected
|
|
132
|
+
const ids = idsOf(entry?.[1], operation === 'set' || operation === 'push')
|
|
133
|
+
return ids === undefined ? expected : new Pending(operation, ids)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const schemaOf = (options: FileOptions): StandardSchema<unknown, Pending> => ({
|
|
137
|
+
'~standard': {
|
|
138
|
+
version: 1,
|
|
139
|
+
vendor: 'ultimabase-files',
|
|
140
|
+
validate: (value) => {
|
|
141
|
+
const pending = pendingOf(value, options.list === true)
|
|
142
|
+
return typeof pending === 'string'
|
|
143
|
+
? { issues: [{ message: pending }] }
|
|
144
|
+
: { value: pending }
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
type Value = FileValue | readonly FileValue[] | Pending
|
|
150
|
+
|
|
151
|
+
const isList = (value: Value): value is readonly FileValue[] =>
|
|
152
|
+
Array.isArray(value)
|
|
153
|
+
|
|
154
|
+
const fileType = defineFieldType({
|
|
155
|
+
plugin: 'files',
|
|
156
|
+
options: [
|
|
157
|
+
'optional',
|
|
158
|
+
'list',
|
|
159
|
+
'maxSize',
|
|
160
|
+
'accept',
|
|
161
|
+
'public',
|
|
162
|
+
'inline',
|
|
163
|
+
'variants',
|
|
164
|
+
],
|
|
165
|
+
stored: (f, options: FileOptions) =>
|
|
166
|
+
options.list === true
|
|
167
|
+
? f.json({ default: [] })
|
|
168
|
+
: options.optional === true
|
|
169
|
+
? f.json({ optional: true })
|
|
170
|
+
: f.json(),
|
|
171
|
+
decode: (stored, options): Value => {
|
|
172
|
+
const keys = keysOf(options)
|
|
173
|
+
const where = 'A file field'
|
|
174
|
+
return Array.isArray(stored)
|
|
175
|
+
? stored.map((item) => fileValue(storedFile(item, where), keys))
|
|
176
|
+
: fileValue(storedFile(stored, where), keys)
|
|
177
|
+
},
|
|
178
|
+
encode: (value: Value): JsonValue => {
|
|
179
|
+
const where = 'A file field'
|
|
180
|
+
if (value instanceof Pending) copyOf(value, where)
|
|
181
|
+
return isList(value)
|
|
182
|
+
? value.map((item) => storedJson(copyOf(item, where)))
|
|
183
|
+
: storedJson(copyOf(value, where))
|
|
184
|
+
},
|
|
185
|
+
schema: (options: FileOptions): StandardSchema<unknown, Value> =>
|
|
186
|
+
schemaOf(options),
|
|
187
|
+
toJSON: (value): JsonValue => {
|
|
188
|
+
if (value instanceof Pending) return null
|
|
189
|
+
return isList(value) ? value.map((item) => fileJson(item)) : fileJson(value)
|
|
190
|
+
},
|
|
191
|
+
shape: (options) => shapeOf(options),
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
function checked(options: FileOptions): void {
|
|
195
|
+
const { list, optional, maxSize, accept } = options
|
|
196
|
+
if (list === true && optional === true) {
|
|
197
|
+
throw new Error('A file list is empty, never null: list or optional.')
|
|
198
|
+
}
|
|
199
|
+
if (maxSize !== undefined && (!Number.isInteger(maxSize) || maxSize <= 0)) {
|
|
200
|
+
throw new Error('A file field’s maxSize is a number of bytes.')
|
|
201
|
+
}
|
|
202
|
+
if (accept?.some((type) => !/^[\w.+-]+\/(\*|[\w.+-]+)$/.test(type))) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
'A file field’s accept lists MIME types or patterns: image/png, image/*.',
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
for (const [key, inline] of Object.entries(options.inline ?? {})) {
|
|
208
|
+
if (typeof inline !== 'function' && typeof inline?.run !== 'function') {
|
|
209
|
+
throw new Error(`A file field’s inline ${key} is a function.`)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const [key, variant] of Object.entries(options.variants ?? {})) {
|
|
213
|
+
if (typeof variant !== 'function') {
|
|
214
|
+
throw new Error(`A file field’s variant ${key} is a function.`)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A field of files (D-152): written as ids from `POST /files` or
|
|
221
|
+
* `files.upload`, read with each file's URL.
|
|
222
|
+
*/
|
|
223
|
+
export function file(): FileField<{}>
|
|
224
|
+
// Neither a default for `O` nor inference from where the field goes: either
|
|
225
|
+
// would leave the functions of `inline` and `variants` untyped.
|
|
226
|
+
export function file<const O extends FileOptions>(
|
|
227
|
+
options: O,
|
|
228
|
+
): FileField<NoInfer<O>>
|
|
229
|
+
export function file(options?: FileOptions): FileField<FileOptions> {
|
|
230
|
+
const given: FileOptions = options ?? {}
|
|
231
|
+
checked(given)
|
|
232
|
+
const field =
|
|
233
|
+
given.list === true ? fileType({ ...given, default: [] }) : fileType(given)
|
|
234
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the field's options give its value and its input.
|
|
235
|
+
return field as unknown as FileField<FileOptions>
|
|
236
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// `@ultimabase/files` (D-152): file fields stored in an S3-compatible
|
|
2
|
+
// storage, uploaded with `POST /files`, and never an orphan.
|
|
3
|
+
export type {
|
|
4
|
+
FileField,
|
|
5
|
+
FileInput,
|
|
6
|
+
FileOf,
|
|
7
|
+
FileOptions,
|
|
8
|
+
Inline,
|
|
9
|
+
InlineRun,
|
|
10
|
+
MadeVariant,
|
|
11
|
+
Variant,
|
|
12
|
+
} from './field.ts'
|
|
13
|
+
export type { Checked } from './lifecycle.ts'
|
|
14
|
+
export type { MemoryFiles, MemoryObject } from './memory.ts'
|
|
15
|
+
export type { Files, FilesOptions, UploadArgs } from './plugin.ts'
|
|
16
|
+
export type { S3Options } from './s3.ts'
|
|
17
|
+
export type { FileBody, FileStorage, PutOptions } from './storage.ts'
|
|
18
|
+
export type { FileValue, FileVariant } from './values.ts'
|
|
19
|
+
export { file } from './field.ts'
|
|
20
|
+
export { memoryFiles } from './memory.ts'
|
|
21
|
+
export { files, filesPlugin } from './plugin.ts'
|
|
22
|
+
export { s3 } from './s3.ts'
|
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// A model's files through its writes (D-152): ids resolved to files before
|
|
2
|
+
// the write, files attached and released in its transaction, released
|
|
3
|
+
// objects deleted after its commit, and the sweep for what a crash left.
|
|
4
|
+
import type { Caller } from 'ultimabase'
|
|
5
|
+
import { isMaster, PublicError } from 'ultimabase'
|
|
6
|
+
import type { FileOptions } from './field.ts'
|
|
7
|
+
import { keysOf } from './field.ts'
|
|
8
|
+
import { FilesBlob, M } from './model.ts'
|
|
9
|
+
import { settled } from './storage.ts'
|
|
10
|
+
import type { FileValue, StoredVariant } from './values.ts'
|
|
11
|
+
import { fileValue, isFile, Pending, runningOf, storedFile } from './values.ts'
|
|
12
|
+
|
|
13
|
+
type Blob = typeof FilesBlob extends {
|
|
14
|
+
findMany(...args: never[]): Promise<(infer R)[]>
|
|
15
|
+
}
|
|
16
|
+
? R
|
|
17
|
+
: never
|
|
18
|
+
type BlobWhere = NonNullable<Parameters<typeof FilesBlob.findMany>[0]>['where']
|
|
19
|
+
|
|
20
|
+
/** What a hook gives of the write. */
|
|
21
|
+
interface Facts {
|
|
22
|
+
readonly name: string
|
|
23
|
+
readonly caller: Caller
|
|
24
|
+
readonly record: object
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const fieldsOf = (model: string): ReadonlyMap<string, FileOptions> =>
|
|
28
|
+
runningOf().fields.get(model) ?? new Map<string, FileOptions>()
|
|
29
|
+
|
|
30
|
+
const invalid = (field: string, message: string): PublicError =>
|
|
31
|
+
new PublicError('VALIDATION_FAILED', 'Invalid data.', {
|
|
32
|
+
details: { issues: [{ path: [field], message }] },
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
/** The files a field's value holds. */
|
|
36
|
+
function heldOf(value: unknown): readonly FileValue[] {
|
|
37
|
+
const items: readonly unknown[] = Array.isArray(value) ? value : [value]
|
|
38
|
+
return items.filter(isFile)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const idsOf = (record: object, field: string): readonly string[] =>
|
|
42
|
+
heldOf(Reflect.get(record, field)).map(({ id }) => id)
|
|
43
|
+
|
|
44
|
+
const idOf = (record: object): string => String(Reflect.get(record, 'id'))
|
|
45
|
+
|
|
46
|
+
/** Whether `caller` may attach a pending blob: its uploader, or Master. */
|
|
47
|
+
function usable(blob: Blob, target: string, caller: Caller): boolean {
|
|
48
|
+
const { state, field, variantOf, deleteAfter, uploadedBy } = blob
|
|
49
|
+
return (
|
|
50
|
+
state === 'pending' &&
|
|
51
|
+
field === target &&
|
|
52
|
+
variantOf === null &&
|
|
53
|
+
deleteAfter !== null &&
|
|
54
|
+
deleteAfter.getTime() > Date.now() &&
|
|
55
|
+
(isMaster(caller) ||
|
|
56
|
+
uploadedBy === null ||
|
|
57
|
+
(caller.kind === 'user' && uploadedBy === caller.user.id))
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The pending files of `ids`, each one the caller may attach to `target`. */
|
|
62
|
+
async function pendingFiles(
|
|
63
|
+
target: string,
|
|
64
|
+
options: FileOptions,
|
|
65
|
+
ids: readonly string[],
|
|
66
|
+
caller: Caller,
|
|
67
|
+
): Promise<ReadonlyMap<string, FileValue>> {
|
|
68
|
+
if (ids.length === 0) return new Map()
|
|
69
|
+
const field = target.slice(target.indexOf('.') + 1)
|
|
70
|
+
const where = { id: { in: ids } }
|
|
71
|
+
const blobs = await FilesBlob.findMany({ where }, M)
|
|
72
|
+
const missing = ids.find(
|
|
73
|
+
(id) =>
|
|
74
|
+
!blobs.some((blob) => blob.id === id && usable(blob, target, caller)),
|
|
75
|
+
)
|
|
76
|
+
if (missing !== undefined) {
|
|
77
|
+
throw invalid(field, `${missing} is not a file uploaded for ${target}.`)
|
|
78
|
+
}
|
|
79
|
+
const made = await FilesBlob.findMany({ where: { variantOf: where.id } }, M)
|
|
80
|
+
const keys = keysOf(options)
|
|
81
|
+
return new Map(
|
|
82
|
+
blobs.map((blob) => {
|
|
83
|
+
const variants: { [key: string]: StoredVariant } = {}
|
|
84
|
+
for (const { variantOf, name, key, type, size } of made) {
|
|
85
|
+
if (variantOf === blob.id) variants[name] = { key, type, size }
|
|
86
|
+
}
|
|
87
|
+
const { id, key, name, type, size, inline } = blob
|
|
88
|
+
const copy = { id, key, name, type, size, inline: inline ?? {}, variants }
|
|
89
|
+
return [id, fileValue(storedFile(copy, 'FilesBlob'), keys)] as const
|
|
90
|
+
}),
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A field's files after a write's operation on the ones it holds. */
|
|
95
|
+
async function filesOf(
|
|
96
|
+
target: string,
|
|
97
|
+
options: FileOptions,
|
|
98
|
+
pending: Pending,
|
|
99
|
+
held: readonly FileValue[],
|
|
100
|
+
caller: Caller,
|
|
101
|
+
): Promise<readonly FileValue[]> {
|
|
102
|
+
const { operation, ids } = pending
|
|
103
|
+
const holds = new Map(held.map((file) => [file.id, file]))
|
|
104
|
+
if (operation === 'remove') return held.filter(({ id }) => !ids.includes(id))
|
|
105
|
+
const field = target.slice(target.indexOf('.') + 1)
|
|
106
|
+
const taken = ids.find((id) => holds.has(id))
|
|
107
|
+
if (operation === 'push' && taken !== undefined) {
|
|
108
|
+
throw invalid(field, `The list holds ${taken} already.`)
|
|
109
|
+
}
|
|
110
|
+
const given =
|
|
111
|
+
operation === 'pushUnique'
|
|
112
|
+
? [...new Set(ids)].filter((id) => !holds.has(id))
|
|
113
|
+
: ids
|
|
114
|
+
const fresh = given.filter((id) => !holds.has(id))
|
|
115
|
+
const found = await pendingFiles(target, options, fresh, caller)
|
|
116
|
+
const listed = given.flatMap((id) => {
|
|
117
|
+
const file = holds.get(id) ?? found.get(id)
|
|
118
|
+
return file === undefined ? [] : [file]
|
|
119
|
+
})
|
|
120
|
+
return operation === 'set' ? listed : [...held, ...listed]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `beforeCreate` and `beforeUpdate`: each file field's ids become its
|
|
125
|
+
* files, computed on the row the update locked.
|
|
126
|
+
*/
|
|
127
|
+
export async function resolve(facts: {
|
|
128
|
+
readonly name: string
|
|
129
|
+
readonly caller: Caller
|
|
130
|
+
readonly data: object
|
|
131
|
+
readonly record?: object
|
|
132
|
+
}): Promise<void> {
|
|
133
|
+
const { name, caller, data, record } = facts
|
|
134
|
+
for (const [field, options] of fieldsOf(name)) {
|
|
135
|
+
const value: unknown = Reflect.get(data, field)
|
|
136
|
+
if (!(value instanceof Pending)) continue
|
|
137
|
+
const held = record === undefined ? [] : heldOf(Reflect.get(record, field))
|
|
138
|
+
const target = `${name}.${field}`
|
|
139
|
+
const files = await filesOf(target, options, value, held, caller)
|
|
140
|
+
Reflect.set(data, field, options.list === true ? files : files[0])
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const attached = (name: string, field: string, record: object): string =>
|
|
145
|
+
`${name}.${field}:${idOf(record)}`
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `afterCreate` and `afterUpdate`: the files the row gained are attached,
|
|
149
|
+
* with their variants, and the ones it lost released.
|
|
150
|
+
*/
|
|
151
|
+
export async function attach(
|
|
152
|
+
facts: Facts & { readonly previous?: object },
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const { name, record, previous } = facts
|
|
155
|
+
for (const field of fieldsOf(name).keys()) {
|
|
156
|
+
const now = idsOf(record, field)
|
|
157
|
+
const before = previous === undefined ? [] : idsOf(previous, field)
|
|
158
|
+
const gained = now.filter((id) => !before.includes(id))
|
|
159
|
+
const lost = before.filter((id) => !now.includes(id))
|
|
160
|
+
const attachedTo = attached(name, field, record)
|
|
161
|
+
const data = { state: 'attached', attachedTo, deleteAfter: null }
|
|
162
|
+
if (gained.length > 0) {
|
|
163
|
+
const ids = { in: gained }
|
|
164
|
+
const where = {
|
|
165
|
+
id: ids,
|
|
166
|
+
state: 'pending',
|
|
167
|
+
deleteAfter: { gt: new Date() },
|
|
168
|
+
}
|
|
169
|
+
const { count } = await FilesBlob.updateMany({ where, data }, M)
|
|
170
|
+
// The sweep released it, or another row attached it, since.
|
|
171
|
+
if (count !== gained.length) {
|
|
172
|
+
throw invalid(field, 'A file expired or was attached elsewhere.')
|
|
173
|
+
}
|
|
174
|
+
await FilesBlob.updateMany({ where: { variantOf: ids }, data }, M)
|
|
175
|
+
}
|
|
176
|
+
if (lost.length > 0) {
|
|
177
|
+
const where = { OR: [{ id: { in: lost } }, { variantOf: { in: lost } }] }
|
|
178
|
+
await FilesBlob.updateMany({ where, data: { state: 'released' } }, M)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const heldBy = (name: string, record: object): NonNullable<BlobWhere> => ({
|
|
184
|
+
attachedTo: {
|
|
185
|
+
in: [...fieldsOf(name).keys()].map((field) =>
|
|
186
|
+
attached(name, field, record),
|
|
187
|
+
),
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
/** `afterDelete`: every file of the row, released. */
|
|
192
|
+
export async function release({ name, record }: Facts): Promise<void> {
|
|
193
|
+
const where = heldBy(name, record)
|
|
194
|
+
await FilesBlob.updateMany({ where, data: { state: 'released' } }, M)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Deletes the released blobs `where` names: their objects, then their rows.
|
|
199
|
+
* A failure keeps the rows, which the sweep deletes later.
|
|
200
|
+
*/
|
|
201
|
+
async function purge(where: NonNullable<BlobWhere>): Promise<void> {
|
|
202
|
+
const { storage, logger } = runningOf()
|
|
203
|
+
try {
|
|
204
|
+
const blobs = await FilesBlob.findMany(
|
|
205
|
+
{ where: { AND: [where, { state: 'released' }] } },
|
|
206
|
+
M,
|
|
207
|
+
)
|
|
208
|
+
if (blobs.length === 0) return
|
|
209
|
+
await settled(blobs.map(({ key }) => storage.delete(key)))
|
|
210
|
+
const ids = blobs.map(({ id }) => id)
|
|
211
|
+
await FilesBlob.deleteMany({ where: { id: { in: ids } } }, M)
|
|
212
|
+
} catch (error) {
|
|
213
|
+
logger.warn({ err: error }, 'Released files were not deleted yet.')
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** `afterUpdateCommit`: the objects of the files the row lost, deleted. */
|
|
218
|
+
export async function deleteLost(
|
|
219
|
+
facts: Facts & { readonly previous: object },
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
const { name, record, previous } = facts
|
|
222
|
+
const lost = [...fieldsOf(name).keys()].flatMap((field) => {
|
|
223
|
+
const now = idsOf(record, field)
|
|
224
|
+
return idsOf(previous, field).filter((id) => !now.includes(id))
|
|
225
|
+
})
|
|
226
|
+
if (lost.length === 0) return
|
|
227
|
+
await purge({ OR: [{ id: { in: lost } }, { variantOf: { in: lost } }] })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** `afterDeleteCommit`: the objects of a deleted row's files, deleted. */
|
|
231
|
+
export async function deleteHeld({ name, record }: Facts): Promise<void> {
|
|
232
|
+
await purge(heldBy(name, record))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The sweep: expired pending blobs are released, so no write attaches them,
|
|
237
|
+
* then every released blob is deleted, what a crash left included.
|
|
238
|
+
*/
|
|
239
|
+
export async function sweep(): Promise<void> {
|
|
240
|
+
const where = { state: 'pending', deleteAfter: { lt: new Date() } }
|
|
241
|
+
await FilesBlob.updateMany({ where, data: { state: 'released' } }, M)
|
|
242
|
+
await purge({})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** What `files.check` found. */
|
|
246
|
+
export interface Checked {
|
|
247
|
+
/** Objects without a row, deleted with `delete: true`. */
|
|
248
|
+
readonly objects: readonly string[]
|
|
249
|
+
/** Rows whose object is missing: an upload under way, or a lost object. */
|
|
250
|
+
readonly rows: readonly string[]
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Compares the storage's keys with `FilesBlob`. */
|
|
254
|
+
export async function check(options?: {
|
|
255
|
+
readonly delete?: boolean
|
|
256
|
+
}): Promise<Checked> {
|
|
257
|
+
const { storage } = runningOf()
|
|
258
|
+
// The keys first: an upload inserts its row before its object, so an
|
|
259
|
+
// object listed without a row is never one under way.
|
|
260
|
+
const keys = await storage.keys()
|
|
261
|
+
const blobs = await FilesBlob.findMany({ select: { key: true } }, M)
|
|
262
|
+
const held = new Set(blobs.map(({ key }) => key))
|
|
263
|
+
const stored = new Set(keys)
|
|
264
|
+
const objects = keys.filter((key) => !held.has(key))
|
|
265
|
+
const rows = blobs.map(({ key }) => key).filter((key) => !stored.has(key))
|
|
266
|
+
if (options?.delete === true) {
|
|
267
|
+
await settled(objects.map((key) => storage.delete(key)))
|
|
268
|
+
}
|
|
269
|
+
return { objects, rows }
|
|
270
|
+
}
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// `memoryFiles()` (D-152): a storage in memory, which tests use, so that no
|
|
2
|
+
// test writes to a real storage.
|
|
3
|
+
import type { FileBody, FileStorage } from './storage.ts'
|
|
4
|
+
import { isPublic } from './storage.ts'
|
|
5
|
+
|
|
6
|
+
/** An object `memoryFiles()` holds. */
|
|
7
|
+
export interface MemoryObject {
|
|
8
|
+
readonly body: Uint8Array
|
|
9
|
+
readonly type: string
|
|
10
|
+
readonly attachment: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A storage in memory, its objects readable by tests. */
|
|
14
|
+
export interface MemoryFiles extends FileStorage {
|
|
15
|
+
readonly objects: ReadonlyMap<string, MemoryObject>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The bytes of a body, read to its end. */
|
|
19
|
+
export async function bytesOf(body: FileBody): Promise<Uint8Array> {
|
|
20
|
+
if (body instanceof Uint8Array) return body
|
|
21
|
+
return new Uint8Array(await new Response(body).arrayBuffer())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A storage in memory: its URLs are `memory://files/<key>`, never served. */
|
|
25
|
+
export function memoryFiles(): MemoryFiles {
|
|
26
|
+
const objects = new Map<string, MemoryObject>()
|
|
27
|
+
return {
|
|
28
|
+
objects,
|
|
29
|
+
start: () => {},
|
|
30
|
+
put: async (key, body, { type, size, attachment }) => {
|
|
31
|
+
const bytes = await bytesOf(body)
|
|
32
|
+
if (bytes.length !== size) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`memoryFiles: ${key} has ${bytes.length} bytes, not ${size}.`,
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
objects.set(key, { body: bytes, type, attachment })
|
|
38
|
+
},
|
|
39
|
+
delete: async (key) => {
|
|
40
|
+
objects.delete(key)
|
|
41
|
+
},
|
|
42
|
+
keys: async () => [...objects.keys()],
|
|
43
|
+
url: (key) =>
|
|
44
|
+
`memory://files/${key}${isPublic(key) ? '' : '?expires=3600'}`,
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// `FilesBlob` (D-152): one row for each object in the storage, a file or one
|
|
2
|
+
// of its variants. Its row is the truth; a file field stores a copy.
|
|
3
|
+
import type { Field, ModelOf, ScalarOptions } from 'ultimabase'
|
|
4
|
+
import { defineModel, master } from 'ultimabase'
|
|
5
|
+
import { runningOf } from './values.ts'
|
|
6
|
+
|
|
7
|
+
export const M: { readonly as: typeof master } = { as: master }
|
|
8
|
+
|
|
9
|
+
/** One hour, a pending blob's life. */
|
|
10
|
+
export const hour: number = 60 * 60 * 1000
|
|
11
|
+
|
|
12
|
+
/** A blob's state: uploaded, held by a row, or to delete. */
|
|
13
|
+
export type BlobState = 'pending' | 'attached' | 'released'
|
|
14
|
+
|
|
15
|
+
type Optional<V> = Field<V | null, { readonly optional: true }>
|
|
16
|
+
|
|
17
|
+
type BlobFields = {
|
|
18
|
+
/** `public/…` or `private/…`, the object's key in the storage. */
|
|
19
|
+
readonly key: Field<string, { readonly unique: true }>
|
|
20
|
+
/** The file's name; a variant's own, `thumb`. */
|
|
21
|
+
readonly name: Field<string, ScalarOptions<string>>
|
|
22
|
+
readonly type: Field<string, ScalarOptions<string>>
|
|
23
|
+
readonly size: Field<number, { readonly integer: true }>
|
|
24
|
+
/** `Post.cover`. */
|
|
25
|
+
readonly field: Field<string, ScalarOptions<string>>
|
|
26
|
+
/** The uploading user's id, `master`, or `null` for an anonymous caller. */
|
|
27
|
+
readonly uploadedBy: Optional<string>
|
|
28
|
+
readonly state: Field<string, ScalarOptions<string>>
|
|
29
|
+
/** `Post.cover:<id>`, once attached. */
|
|
30
|
+
readonly attachedTo: Optional<string>
|
|
31
|
+
/** A variant's file. */
|
|
32
|
+
readonly variantOf: Optional<string>
|
|
33
|
+
readonly inline: Field<
|
|
34
|
+
unknown,
|
|
35
|
+
{ readonly optional: true },
|
|
36
|
+
never,
|
|
37
|
+
never,
|
|
38
|
+
unknown,
|
|
39
|
+
true
|
|
40
|
+
>
|
|
41
|
+
/** When a pending blob expires. */
|
|
42
|
+
readonly deleteAfter: Optional<Date>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const FilesBlob: ModelOf<'FilesBlob', BlobFields, []> = defineModel({
|
|
46
|
+
name: 'FilesBlob',
|
|
47
|
+
fields: (f) => ({
|
|
48
|
+
key: f.string({ unique: true }),
|
|
49
|
+
name: f.string(),
|
|
50
|
+
type: f.string(),
|
|
51
|
+
size: f.number({ integer: true }),
|
|
52
|
+
field: f.string(),
|
|
53
|
+
uploadedBy: f.string({ optional: true }),
|
|
54
|
+
state: f.string(),
|
|
55
|
+
attachedTo: f.string({ optional: true }),
|
|
56
|
+
variantOf: f.string({ optional: true }),
|
|
57
|
+
inline: f.json({ optional: true }),
|
|
58
|
+
deleteAfter: f.dateTime({ optional: true }),
|
|
59
|
+
}),
|
|
60
|
+
indexes: [
|
|
61
|
+
{ fields: ['state', 'deleteAfter'] },
|
|
62
|
+
{ fields: ['variantOf'] },
|
|
63
|
+
{ fields: ['attachedTo'] },
|
|
64
|
+
],
|
|
65
|
+
// An upload creates its row as its caller: the kernel gives the rule the
|
|
66
|
+
// whole caller, and Master always passes. The rest stays Master's.
|
|
67
|
+
}).with({ access: { create: ({ caller }) => runningOf().upload({ caller }) } })
|