@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/dist/upload.js ADDED
@@ -0,0 +1,206 @@
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 { isMaster, PublicError } from 'ultimabase';
7
+ import { defaultMaxSize } from './field.js';
8
+ import { bytesOf } from './memory.js';
9
+ import { FilesBlob, hour, M } from './model.js';
10
+ import { settled } from './storage.js';
11
+ import { runningOf } from './values.js';
12
+ /** How many first bytes the type is read from, as `file-type` reads them. */
13
+ const sampleSize = 4100;
14
+ /** An inline value's JSON, at most. */
15
+ const inlineSize = 2048;
16
+ const invalid = (path, message) => new PublicError('VALIDATION_FAILED', 'Invalid upload.', {
17
+ details: { issues: [{ path: [path], message }] },
18
+ });
19
+ function fieldOf(target) {
20
+ const [model = '', field = ''] = target.split('.');
21
+ const options = runningOf().fields.get(model)?.get(field);
22
+ if (options === undefined) {
23
+ const message = target === ''
24
+ ? 'Expected a file field: Model.field.'
25
+ : `${target} is not a file field.`;
26
+ throw invalid('for', message);
27
+ }
28
+ return options;
29
+ }
30
+ function concat(chunks, length) {
31
+ const bytes = new Uint8Array(length);
32
+ let at = 0;
33
+ for (const chunk of chunks) {
34
+ bytes.set(chunk, at);
35
+ at += chunk.length;
36
+ }
37
+ return bytes;
38
+ }
39
+ /** The first bytes of a body, and the body to store, whole again. */
40
+ async function sampled(body) {
41
+ if (body instanceof Uint8Array)
42
+ return { sample: body, body };
43
+ const reader = body.getReader();
44
+ const chunks = [];
45
+ let length = 0;
46
+ while (length < sampleSize) {
47
+ const { done, value } = await reader.read();
48
+ if (done) {
49
+ const whole = concat(chunks, length);
50
+ return { sample: whole, body: whole };
51
+ }
52
+ chunks.push(value);
53
+ length += value.length;
54
+ }
55
+ const sample = concat(chunks, length);
56
+ const rest = new ReadableStream({
57
+ start: (controller) => {
58
+ controller.enqueue(sample);
59
+ },
60
+ pull: async (controller) => {
61
+ const { done, value } = await reader.read();
62
+ if (done)
63
+ controller.close();
64
+ else
65
+ controller.enqueue(value);
66
+ },
67
+ cancel: (reason) => reader.cancel(reason),
68
+ });
69
+ return { sample, body: rest };
70
+ }
71
+ const matches = (accept, type) => accept.some((pattern) => pattern.endsWith('/*')
72
+ ? type.startsWith(pattern.slice(0, -1))
73
+ : pattern === type);
74
+ /** The type a file is stored with, and whether it is served as a download. */
75
+ async function typeOf(sample, declared, accept) {
76
+ const detected = (await fileTypeFromBuffer(sample))?.mime;
77
+ if (detected !== undefined &&
78
+ (accept === undefined || matches(accept, detected))) {
79
+ return { type: detected, attachment: detected === 'image/svg+xml' };
80
+ }
81
+ const [media = ''] = (declared ?? '').split(';');
82
+ const type = media.trim().toLowerCase();
83
+ if (detected === undefined && accept === undefined) {
84
+ return {
85
+ type: type === '' ? 'application/octet-stream' : type,
86
+ attachment: true,
87
+ };
88
+ }
89
+ // An undetected type is taken as declared only when accept lists it.
90
+ if (detected === undefined && accept?.includes(type) === true) {
91
+ return { type, attachment: true };
92
+ }
93
+ throw new PublicError('UNSUPPORTED_MEDIA_TYPE', `${detected ?? (type === '' ? 'An undetected type' : type)} is not accepted.`);
94
+ }
95
+ /** A variant's own type, served as a download unless its bytes say it. */
96
+ async function attachmentOf(body, type) {
97
+ const detected = (await fileTypeFromBuffer(body))?.mime;
98
+ return detected !== type || type === 'image/svg+xml';
99
+ }
100
+ async function inlineValue(key, given, file) {
101
+ const run = typeof given === 'function' ? given : given.run;
102
+ let value = await run(file);
103
+ if (typeof given !== 'function') {
104
+ const result = await given.schema['~standard'].validate(value);
105
+ if (result.issues !== undefined) {
106
+ throw new TypeError(`The inline ${key} does not match its schema.`);
107
+ }
108
+ value = result.value;
109
+ }
110
+ const json = JSON.stringify(value);
111
+ if (json === undefined ||
112
+ new TextEncoder().encode(json).length > inlineSize) {
113
+ throw new TypeError(`The inline ${key} is not JSON of 2 KB at most.`);
114
+ }
115
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- JSON.parse gives JSON.
116
+ return JSON.parse(json);
117
+ }
118
+ const uploaderOf = (caller) => isMaster(caller) ? 'master' : caller.kind === 'user' ? caller.user.id : null;
119
+ /** What a buffered upload made: its inline values and its variants. */
120
+ async function madeOf(options, file) {
121
+ const inline = Object.entries(options.inline ?? {}).map(async ([key, given]) => [key, await inlineValue(key, given, file)]);
122
+ const variants = Object.entries(options.variants ?? {}).map(async ([key, variant]) => {
123
+ const { body, type } = await variant(file);
124
+ return [key, body, type];
125
+ });
126
+ const [values, made] = await Promise.all([
127
+ Promise.all(inline),
128
+ Promise.all(variants),
129
+ ]);
130
+ return { inline: Object.fromEntries(values), variants: made };
131
+ }
132
+ /**
133
+ * Stores a file for `options.for`, as `POST /files` does, and gives its id,
134
+ * which a write of that field takes. `size` is the body's length in bytes.
135
+ */
136
+ export async function upload(body, size, options) {
137
+ const { name, as: caller = { kind: 'anonymous' } } = options;
138
+ const field = fieldOf(options.for);
139
+ if (name === '')
140
+ throw invalid('name', 'Expected a file name.');
141
+ const maxSize = field.maxSize ?? defaultMaxSize;
142
+ if (size > maxSize) {
143
+ throw new PublicError('PAYLOAD_TOO_LARGE', `The file is over ${maxSize} bytes.`);
144
+ }
145
+ const buffered = field.inline !== undefined || field.variants !== undefined;
146
+ const read = buffered
147
+ ? { sample: await bytesOf(body), body: undefined }
148
+ : await sampled(body);
149
+ const { type, attachment } = await typeOf(read.sample, options.type, field.accept);
150
+ const { storage, logger } = runningOf();
151
+ const prefix = field.public === true ? 'public' : 'private';
152
+ const keyOf = () => `${prefix}/${randomUUID()}`;
153
+ const uploadedBy = uploaderOf(caller);
154
+ const deleteAfter = new Date(Date.now() + hour);
155
+ const blob = { field: options.for, uploadedBy, state: 'pending', deleteAfter };
156
+ const main = await FilesBlob.create({ data: { ...blob, key: keyOf(), name, type, size } }, { as: caller });
157
+ const keys = [main.key];
158
+ const ids = [main.id];
159
+ try {
160
+ if (read.body !== undefined) {
161
+ await storage.put(main.key, read.body, { type, size, attachment });
162
+ return { id: main.id };
163
+ }
164
+ const bytes = read.sample;
165
+ if (bytes.length !== size) {
166
+ throw invalid('size', `The file has ${bytes.length} bytes, not ${size}.`);
167
+ }
168
+ const made = await madeOf(field, { body: bytes, type, name });
169
+ const variants = await settled(made.variants.map(async ([key, variantBody, variantType]) => {
170
+ const row = await FilesBlob.create({
171
+ data: {
172
+ ...blob,
173
+ key: keyOf(),
174
+ name: key,
175
+ type: variantType,
176
+ size: variantBody.length,
177
+ variantOf: main.id,
178
+ },
179
+ }, M);
180
+ keys.push(row.key);
181
+ ids.push(row.id);
182
+ return { row, body: variantBody };
183
+ }));
184
+ await settled([
185
+ storage.put(main.key, bytes, { type, size, attachment }),
186
+ ...variants.map(async ({ row, body: variantBody }) => storage.put(row.key, variantBody, {
187
+ type: row.type,
188
+ size: row.size,
189
+ attachment: await attachmentOf(variantBody, row.type),
190
+ })),
191
+ ]);
192
+ await FilesBlob.update({ where: { id: main.id }, data: { inline: made.inline } }, M);
193
+ return { id: main.id };
194
+ }
195
+ catch (error) {
196
+ // The sweep deletes what this cannot: the rows expire within the hour.
197
+ try {
198
+ await Promise.all(keys.map((key) => storage.delete(key)));
199
+ await FilesBlob.deleteMany({ where: { id: { in: ids } } }, M);
200
+ }
201
+ catch (cleanup) {
202
+ logger.warn({ err: cleanup }, 'A failed upload was not cleaned up.');
203
+ }
204
+ throw error;
205
+ }
206
+ }
@@ -0,0 +1,81 @@
1
+ import type { CallerRule, JsonValue, Logger } from 'ultimabase';
2
+ import type { FileOptions } from './field.ts';
3
+ import type { FileStorage } from './storage.ts';
4
+ /** A variant as a client reads it. */
5
+ export interface FileVariant {
6
+ readonly type: string;
7
+ readonly size: number;
8
+ readonly url: string;
9
+ }
10
+ /** What a file field's value holds. */
11
+ export interface FileValue<I extends object = {
12
+ readonly [key: string]: JsonValue | null;
13
+ }, V extends object = {
14
+ readonly [key: string]: FileVariant | null;
15
+ }> {
16
+ readonly id: string;
17
+ readonly name: string;
18
+ readonly type: string;
19
+ readonly size: number;
20
+ /** Stable for a public file, presigned for one hour for a private one. */
21
+ readonly url: string;
22
+ readonly inline: I;
23
+ readonly variants: V;
24
+ }
25
+ /** The copy of a variant's metadata the row stores. */
26
+ export interface StoredVariant {
27
+ readonly key: string;
28
+ readonly type: string;
29
+ readonly size: number;
30
+ }
31
+ /** The copy of a file's metadata the row stores; `FilesBlob` is the truth. */
32
+ export interface StoredFile {
33
+ readonly id: string;
34
+ readonly key: string;
35
+ readonly name: string;
36
+ readonly type: string;
37
+ readonly size: number;
38
+ readonly inline: {
39
+ readonly [key: string]: JsonValue;
40
+ };
41
+ readonly variants: {
42
+ readonly [key: string]: StoredVariant;
43
+ };
44
+ }
45
+ /** What a field's options say of its value's keys. */
46
+ export interface Keys {
47
+ readonly inline: readonly string[];
48
+ readonly variants: readonly string[];
49
+ }
50
+ /** How a write changes a field's files, before the hooks resolve it. */
51
+ export type Operation = 'set' | 'push' | 'pushUnique' | 'remove';
52
+ /** The ids a write gives, which the plugin's hooks resolve to files. */
53
+ export declare class Pending {
54
+ readonly operation: Operation;
55
+ readonly ids: readonly string[];
56
+ constructor(operation: Operation, ids: readonly string[]);
57
+ }
58
+ /** What the running application gives the plugin. */
59
+ export interface Running {
60
+ readonly storage: FileStorage;
61
+ /** Who may upload; Master always may. */
62
+ readonly upload: CallerRule;
63
+ /** Each model's file fields, with their options. */
64
+ readonly fields: ReadonlyMap<string, ReadonlyMap<string, FileOptions>>;
65
+ readonly logger: Logger;
66
+ }
67
+ export declare function useRunning(given: Running | undefined): void;
68
+ export declare function runningOf(): Running;
69
+ /** The value code reads of a file, its URLs signed now. */
70
+ export declare function fileValue(stored: StoredFile, keys: Keys): FileValue;
71
+ /** Whether a value is a file the plugin resolved. */
72
+ export declare const isFile: (value: unknown) => value is FileValue;
73
+ /** The copy the row stores of a value code holds. */
74
+ export declare function copyOf(value: unknown, where: string): StoredFile;
75
+ /** A stored copy, read back from the row; any other value is a bug. */
76
+ export declare function storedFile(value: unknown, where: string): StoredFile;
77
+ /** A copy as the row's JSON column stores it. */
78
+ export declare const storedJson: (copy: StoredFile) => JsonValue;
79
+ /** A value as a client reads it. */
80
+ export declare const fileJson: (value: FileValue) => JsonValue;
81
+ //# sourceMappingURL=values.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"values.d.ts","sourceRoot":"","sources":["../src/values.ts"],"names":[],"mappings":"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"}
package/dist/values.js ADDED
@@ -0,0 +1,125 @@
1
+ /** The ids a write gives, which the plugin's hooks resolve to files. */
2
+ export class Pending {
3
+ operation;
4
+ ids;
5
+ constructor(operation, ids) {
6
+ this.operation = operation;
7
+ this.ids = ids;
8
+ }
9
+ }
10
+ // The running application's: one runs at a time in a process.
11
+ let running;
12
+ export function useRunning(given) {
13
+ running = given;
14
+ }
15
+ export function runningOf() {
16
+ if (running === undefined)
17
+ throw new Error('filesPlugin has not started.');
18
+ return running;
19
+ }
20
+ /** Each value code holds, with the copy the row stores. */
21
+ const copies = new WeakMap();
22
+ /** The value code reads of a file, its URLs signed now. */
23
+ export function fileValue(stored, keys) {
24
+ const { storage } = runningOf();
25
+ const variants = {};
26
+ for (const key of keys.variants) {
27
+ const variant = stored.variants[key];
28
+ variants[key] =
29
+ variant === undefined
30
+ ? null
31
+ : Object.freeze({
32
+ type: variant.type,
33
+ size: variant.size,
34
+ url: storage.url(variant.key),
35
+ });
36
+ }
37
+ const inline = {};
38
+ for (const key of keys.inline)
39
+ inline[key] = stored.inline[key] ?? null;
40
+ const value = Object.freeze({
41
+ id: stored.id,
42
+ name: stored.name,
43
+ type: stored.type,
44
+ size: stored.size,
45
+ url: storage.url(stored.key),
46
+ inline: Object.freeze(inline),
47
+ variants: Object.freeze(variants),
48
+ });
49
+ copies.set(value, stored);
50
+ return value;
51
+ }
52
+ /** Whether a value is a file the plugin resolved. */
53
+ export const isFile = (value) => typeof value === 'object' && value !== null && copies.has(value);
54
+ /** The copy the row stores of a value code holds. */
55
+ export function copyOf(value, where) {
56
+ const copy = typeof value === 'object' && value !== null ? copies.get(value) : undefined;
57
+ if (copy === undefined) {
58
+ throw new TypeError(`${where}: a file field stores the files filesPlugin resolved, written as ids.`);
59
+ }
60
+ return copy;
61
+ }
62
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
63
+ const isString = (value) => typeof value === 'string';
64
+ const isSize = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 0;
65
+ function variantsOf(value) {
66
+ if (!isRecord(value))
67
+ return undefined;
68
+ const variants = {};
69
+ for (const [name, variant] of Object.entries(value)) {
70
+ if (!isRecord(variant))
71
+ return undefined;
72
+ const { key, type, size } = variant;
73
+ if (!isString(key) || !isString(type) || !isSize(size))
74
+ return undefined;
75
+ variants[name] = { key, type, size };
76
+ }
77
+ return variants;
78
+ }
79
+ /** A stored copy, read back from the row; any other value is a bug. */
80
+ export function storedFile(value, where) {
81
+ if (isRecord(value)) {
82
+ const { id, key, name, type, size, inline } = value;
83
+ const variants = variantsOf(value['variants']);
84
+ if (isString(id) &&
85
+ isString(key) &&
86
+ isString(name) &&
87
+ isString(type) &&
88
+ isSize(size) &&
89
+ isRecord(inline) &&
90
+ variants !== undefined) {
91
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the row's JSON column holds JSON.
92
+ const json = inline;
93
+ return { id, key, name, type, size, inline: json, variants };
94
+ }
95
+ }
96
+ throw new TypeError(`${where}: the row holds a file that is not one.`);
97
+ }
98
+ /** A copy as the row's JSON column stores it. */
99
+ export const storedJson = (copy) => ({
100
+ id: copy.id,
101
+ key: copy.key,
102
+ name: copy.name,
103
+ type: copy.type,
104
+ size: copy.size,
105
+ inline: copy.inline,
106
+ variants: Object.fromEntries(Object.entries(copy.variants).map(([name, { key, type, size }]) => [
107
+ name,
108
+ { key, type, size },
109
+ ])),
110
+ });
111
+ /** A value as a client reads it. */
112
+ export const fileJson = (value) => ({
113
+ id: value.id,
114
+ name: value.name,
115
+ type: value.type,
116
+ size: value.size,
117
+ url: value.url,
118
+ inline: { ...value.inline },
119
+ variants: Object.fromEntries(Object.entries(value.variants).map(([name, variant]) => [
120
+ name,
121
+ variant === null
122
+ ? null
123
+ : { type: variant.type, size: variant.size, url: variant.url },
124
+ ])),
125
+ });
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@ultimabase/files",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Files for UltimaBase: uploads to S3-compatible storage, file fields, variants, and no orphan ever.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "homepage": "https://ultimabase.dev/docs/files",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/MrEkinox/ultimabase.git",
11
+ "directory": "packages/files"
12
+ },
13
+ "engines": {
14
+ "node": ">=24.7.0"
15
+ },
16
+ "exports": {
17
+ ".": "./dist/index.js"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "src",
22
+ "!src/**/*.test.ts",
23
+ "!src/test"
24
+ ],
25
+ "scripts": {
26
+ "prepack": "rm -rf dist && yarn run -T tsc --project tsconfig.build.json"
27
+ },
28
+ "publishConfig": {
29
+ "exports": {
30
+ ".": "./dist/index.js"
31
+ }
32
+ },
33
+ "dependencies": {
34
+ "file-type": "22.2.0"
35
+ },
36
+ "peerDependencies": {
37
+ "@ultimabase/http": "^0.1.1",
38
+ "ultimabase": "^0.1.1"
39
+ },
40
+ "devDependencies": {
41
+ "@types/pg": "8.23.1",
42
+ "@ultimabase/http": "0.1.1",
43
+ "@ultimabase/postgres": "0.1.1",
44
+ "pg": "8.23.1",
45
+ "ultimabase": "0.1.1",
46
+ "vite": "8.3.4",
47
+ "vitest": "5.0.5"
48
+ }
6
49
  }