@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/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # @ultimabase/files
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+ - ultimabase@0.1.1
9
+ - @ultimabase/http@0.1.1
10
+
11
+ ## 0.1.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 0e885c4: The first release: the kernel, its adapters, transports and plugins, the testing helpers and the starter.
16
+
17
+ ### Patch Changes
18
+
19
+ - Updated dependencies [0e885c4]
20
+ - @ultimabase/http@0.1.0
21
+ - ultimabase@0.1.0
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @ultimabase/files
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Files for UltimaBase, in S3 or any S3-compatible storage. A client uploads a file, then writes its id in a row as any other value; a replaced or deleted file leaves the storage after the commit, so no object is left without a row.
4
+
5
+ Documentation: [Files](https://ultimabase.dev/docs/files).
@@ -0,0 +1,89 @@
1
+ import type { Field, JsonValue } from 'ultimabase';
2
+ import type { StandardSchema } from './standard.ts';
3
+ import type { FileValue, FileVariant, Keys, Operation } from './values.ts';
4
+ /** What `inline` and `variants` are made from, at upload. */
5
+ export interface FileInput {
6
+ readonly body: Uint8Array;
7
+ readonly type: string;
8
+ readonly name: string;
9
+ }
10
+ /** Computes one inline value: JSON of 2 KB at most. */
11
+ export type InlineRun = (file: FileInput) => JsonValue | Promise<JsonValue>;
12
+ /** An inline value; with its `schema`, the transports type it. */
13
+ export type Inline = InlineRun | {
14
+ readonly run: InlineRun;
15
+ readonly schema: StandardSchema;
16
+ };
17
+ /** Another file made from the upload: a thumbnail. */
18
+ export type Variant = (file: FileInput) => MadeVariant | Promise<MadeVariant>;
19
+ export interface MadeVariant {
20
+ readonly body: Uint8Array;
21
+ readonly type: string;
22
+ }
23
+ /** What `file()` takes (D-152). */
24
+ export interface FileOptions {
25
+ readonly description?: string;
26
+ /** A single file that may be `null`. */
27
+ readonly optional?: boolean;
28
+ /** A list of files, `[]` when empty. */
29
+ readonly list?: boolean;
30
+ /** In bytes, 50 MB by default. */
31
+ readonly maxSize?: number;
32
+ /** MIME types or patterns: `['image/*', 'application/pdf']`; any by default. */
33
+ readonly accept?: readonly string[];
34
+ /** Served to everyone at a stable URL; presigned for one hour otherwise. */
35
+ readonly public?: boolean;
36
+ readonly inline?: {
37
+ readonly [key: string]: Inline;
38
+ };
39
+ readonly variants?: {
40
+ readonly [key: string]: Variant;
41
+ };
42
+ }
43
+ type InlineOf<T> = T extends {
44
+ readonly schema: StandardSchema<unknown, infer O>;
45
+ } ? O : T extends (...args: never[]) => infer R ? Awaited<R> : never;
46
+ type Inlines<O> = O extends {
47
+ readonly inline: infer I;
48
+ } ? {
49
+ readonly [K in keyof I]: InlineOf<I[K]> | null;
50
+ } : {
51
+ readonly [K in never]: never;
52
+ };
53
+ type Variants<O> = O extends {
54
+ readonly variants: infer V;
55
+ } ? {
56
+ readonly [K in keyof V]: FileVariant | null;
57
+ } : {
58
+ readonly [K in never]: never;
59
+ };
60
+ /** The value of a field with options `O`. */
61
+ export type FileOf<O> = FileValue<Inlines<O>, Variants<O>>;
62
+ type ValueOf<O> = O extends {
63
+ readonly list: true;
64
+ } ? readonly FileOf<O>[] : FileOf<O>;
65
+ type Ids = string | readonly string[];
66
+ /** A list's write: the new list, or one operation, as kernel lists (D-149). */
67
+ type ListInput = readonly string[] | {
68
+ readonly [K in Operation]: {
69
+ readonly [P in K]: Ids;
70
+ } & {
71
+ readonly [P in Exclude<Operation, K>]?: never;
72
+ };
73
+ }[Operation];
74
+ type InputOf<O> = O extends {
75
+ readonly list: true;
76
+ } ? ListInput : string;
77
+ /** A file field: read as files, written as ids. */
78
+ export type FileField<O> = Field<ValueOf<O>, O, 'files', never, InputOf<O>, true, Field<JsonValue, O, never, never, JsonValue, true>>;
79
+ /** 50 MB. */
80
+ export declare const defaultMaxSize: number;
81
+ export declare const keysOf: (options: FileOptions) => Keys;
82
+ /**
83
+ * A field of files (D-152): written as ids from `POST /files` or
84
+ * `files.upload`, read with each file's URL.
85
+ */
86
+ export declare function file(): FileField<{}>;
87
+ export declare function file<const O extends FileOptions>(options: O): FileField<NoInfer<O>>;
88
+ export {};
89
+ //# sourceMappingURL=field.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"field.d.ts","sourceRoot":"","sources":["../src/field.ts"],"names":[],"mappings":"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"}
package/dist/field.js ADDED
@@ -0,0 +1,123 @@
1
+ import { defineFieldType } from 'ultimabase';
2
+ import { shapeOf } from './shape.js';
3
+ import { copyOf, fileJson, fileValue, Pending, storedFile, storedJson, } from './values.js';
4
+ /** 50 MB. */
5
+ export const defaultMaxSize = 50 * 1024 * 1024;
6
+ const operations = ['set', 'push', 'pushUnique', 'remove'];
7
+ export const keysOf = (options) => ({
8
+ inline: Object.keys(options.inline ?? {}),
9
+ variants: Object.keys(options.variants ?? {}),
10
+ });
11
+ /** Ids, each once; `undefined` when the value is not a list of them. */
12
+ function idsOf(value, unique) {
13
+ const ids = Array.isArray(value) ? value : [value];
14
+ if (!ids.every((id) => typeof id === 'string' && id !== '')) {
15
+ return undefined;
16
+ }
17
+ if (unique && new Set(ids).size !== ids.length)
18
+ return undefined;
19
+ return ids;
20
+ }
21
+ /** The ids a write gives, as the hooks resolve them. */
22
+ function pendingOf(value, list) {
23
+ if (!list) {
24
+ return typeof value === 'string' && value !== ''
25
+ ? new Pending('set', [value])
26
+ : 'Expected a file id.';
27
+ }
28
+ const expected = 'Expected a list of file ids, each once, or one of set, push, pushUnique, remove.';
29
+ if (Array.isArray(value)) {
30
+ const ids = idsOf(value, true);
31
+ return ids === undefined ? expected : new Pending('set', ids);
32
+ }
33
+ if (typeof value !== 'object' || value === null)
34
+ return expected;
35
+ const given = Object.entries(value).filter(([, ids]) => ids !== undefined);
36
+ const [entry] = given;
37
+ const operation = operations.find((name) => name === entry?.[0]);
38
+ if (given.length !== 1 || operation === undefined)
39
+ return expected;
40
+ const ids = idsOf(entry?.[1], operation === 'set' || operation === 'push');
41
+ return ids === undefined ? expected : new Pending(operation, ids);
42
+ }
43
+ const schemaOf = (options) => ({
44
+ '~standard': {
45
+ version: 1,
46
+ vendor: 'ultimabase-files',
47
+ validate: (value) => {
48
+ const pending = pendingOf(value, options.list === true);
49
+ return typeof pending === 'string'
50
+ ? { issues: [{ message: pending }] }
51
+ : { value: pending };
52
+ },
53
+ },
54
+ });
55
+ const isList = (value) => Array.isArray(value);
56
+ const fileType = defineFieldType({
57
+ plugin: 'files',
58
+ options: [
59
+ 'optional',
60
+ 'list',
61
+ 'maxSize',
62
+ 'accept',
63
+ 'public',
64
+ 'inline',
65
+ 'variants',
66
+ ],
67
+ stored: (f, options) => options.list === true
68
+ ? f.json({ default: [] })
69
+ : options.optional === true
70
+ ? f.json({ optional: true })
71
+ : f.json(),
72
+ decode: (stored, options) => {
73
+ const keys = keysOf(options);
74
+ const where = 'A file field';
75
+ return Array.isArray(stored)
76
+ ? stored.map((item) => fileValue(storedFile(item, where), keys))
77
+ : fileValue(storedFile(stored, where), keys);
78
+ },
79
+ encode: (value) => {
80
+ const where = 'A file field';
81
+ if (value instanceof Pending)
82
+ copyOf(value, where);
83
+ return isList(value)
84
+ ? value.map((item) => storedJson(copyOf(item, where)))
85
+ : storedJson(copyOf(value, where));
86
+ },
87
+ schema: (options) => schemaOf(options),
88
+ toJSON: (value) => {
89
+ if (value instanceof Pending)
90
+ return null;
91
+ return isList(value) ? value.map((item) => fileJson(item)) : fileJson(value);
92
+ },
93
+ shape: (options) => shapeOf(options),
94
+ });
95
+ function checked(options) {
96
+ const { list, optional, maxSize, accept } = options;
97
+ if (list === true && optional === true) {
98
+ throw new Error('A file list is empty, never null: list or optional.');
99
+ }
100
+ if (maxSize !== undefined && (!Number.isInteger(maxSize) || maxSize <= 0)) {
101
+ throw new Error('A file field’s maxSize is a number of bytes.');
102
+ }
103
+ if (accept?.some((type) => !/^[\w.+-]+\/(\*|[\w.+-]+)$/.test(type))) {
104
+ throw new Error('A file field’s accept lists MIME types or patterns: image/png, image/*.');
105
+ }
106
+ for (const [key, inline] of Object.entries(options.inline ?? {})) {
107
+ if (typeof inline !== 'function' && typeof inline?.run !== 'function') {
108
+ throw new Error(`A file field’s inline ${key} is a function.`);
109
+ }
110
+ }
111
+ for (const [key, variant] of Object.entries(options.variants ?? {})) {
112
+ if (typeof variant !== 'function') {
113
+ throw new Error(`A file field’s variant ${key} is a function.`);
114
+ }
115
+ }
116
+ }
117
+ export function file(options) {
118
+ const given = options ?? {};
119
+ checked(given);
120
+ const field = given.list === true ? fileType({ ...given, default: [] }) : fileType(given);
121
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the field's options give its value and its input.
122
+ return field;
123
+ }
@@ -0,0 +1,12 @@
1
+ export type { FileField, FileInput, FileOf, FileOptions, Inline, InlineRun, MadeVariant, Variant, } from './field.ts';
2
+ export type { Checked } from './lifecycle.ts';
3
+ export type { MemoryFiles, MemoryObject } from './memory.ts';
4
+ export type { Files, FilesOptions, UploadArgs } from './plugin.ts';
5
+ export type { S3Options } from './s3.ts';
6
+ export type { FileBody, FileStorage, PutOptions } from './storage.ts';
7
+ export type { FileValue, FileVariant } from './values.ts';
8
+ export { file } from './field.ts';
9
+ export { memoryFiles } from './memory.ts';
10
+ export { files, filesPlugin } from './plugin.ts';
11
+ export { s3 } from './s3.ts';
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,YAAY,EACX,SAAS,EACT,SAAS,EACT,MAAM,EACN,WAAW,EACX,MAAM,EACN,SAAS,EACT,WAAW,EACX,OAAO,GACP,MAAM,YAAY,CAAA;AACnB,YAAY,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAC7C,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC5D,YAAY,EAAE,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAClE,YAAY,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AACxC,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AACrE,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACzD,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AACjC,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACzC,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAChD,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { file } from './field.js';
2
+ export { memoryFiles } from './memory.js';
3
+ export { files, filesPlugin } from './plugin.js';
4
+ export { s3 } from './s3.js';
@@ -0,0 +1,50 @@
1
+ import type { Caller } from 'ultimabase';
2
+ /** What a hook gives of the write. */
3
+ interface Facts {
4
+ readonly name: string;
5
+ readonly caller: Caller;
6
+ readonly record: object;
7
+ }
8
+ /**
9
+ * `beforeCreate` and `beforeUpdate`: each file field's ids become its
10
+ * files, computed on the row the update locked.
11
+ */
12
+ export declare function resolve(facts: {
13
+ readonly name: string;
14
+ readonly caller: Caller;
15
+ readonly data: object;
16
+ readonly record?: object;
17
+ }): Promise<void>;
18
+ /**
19
+ * `afterCreate` and `afterUpdate`: the files the row gained are attached,
20
+ * with their variants, and the ones it lost released.
21
+ */
22
+ export declare function attach(facts: Facts & {
23
+ readonly previous?: object;
24
+ }): Promise<void>;
25
+ /** `afterDelete`: every file of the row, released. */
26
+ export declare function release({ name, record }: Facts): Promise<void>;
27
+ /** `afterUpdateCommit`: the objects of the files the row lost, deleted. */
28
+ export declare function deleteLost(facts: Facts & {
29
+ readonly previous: object;
30
+ }): Promise<void>;
31
+ /** `afterDeleteCommit`: the objects of a deleted row's files, deleted. */
32
+ export declare function deleteHeld({ name, record }: Facts): Promise<void>;
33
+ /**
34
+ * The sweep: expired pending blobs are released, so no write attaches them,
35
+ * then every released blob is deleted, what a crash left included.
36
+ */
37
+ export declare function sweep(): Promise<void>;
38
+ /** What `files.check` found. */
39
+ export interface Checked {
40
+ /** Objects without a row, deleted with `delete: true`. */
41
+ readonly objects: readonly string[];
42
+ /** Rows whose object is missing: an upload under way, or a lost object. */
43
+ readonly rows: readonly string[];
44
+ }
45
+ /** Compares the storage's keys with `FilesBlob`. */
46
+ export declare function check(options?: {
47
+ readonly delete?: boolean;
48
+ }): Promise<Checked>;
49
+ export {};
50
+ //# sourceMappingURL=lifecycle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../src/lifecycle.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAgBxC,sCAAsC;AACtC,UAAU,KAAK;IACd,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACvB;AAkGD;;;GAGG;AACH,wBAAsB,OAAO,CAAC,KAAK,EAAE;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CACxB,GAAG,OAAO,CAAC,IAAI,CAAC,CAUhB;AAKD;;;GAGG;AACH,wBAAsB,MAAM,CAC3B,KAAK,EAAE,KAAK,GAAG;IAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAC3C,OAAO,CAAC,IAAI,CAAC,CA4Bf;AAUD,sDAAsD;AACtD,wBAAsB,OAAO,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAGpE;AAsBD,2EAA2E;AAC3E,wBAAsB,UAAU,CAC/B,KAAK,EAAE,KAAK,GAAG;IAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC1C,OAAO,CAAC,IAAI,CAAC,CAQf;AAED,0EAA0E;AAC1E,wBAAsB,UAAU,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAEvE;AAED;;;GAGG;AACH,wBAAsB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAI3C;AAED,gCAAgC;AAChC,MAAM,WAAW,OAAO;IACvB,0DAA0D;IAC1D,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;IACnC,2EAA2E;IAC3E,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;CAChC;AAED,oDAAoD;AACpD,wBAAsB,KAAK,CAAC,OAAO,CAAC,EAAE;IACrC,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CACzB,GAAG,OAAO,CAAC,OAAO,CAAC,CAcnB"}
@@ -0,0 +1,192 @@
1
+ import { isMaster, PublicError } from 'ultimabase';
2
+ import { keysOf } from './field.js';
3
+ import { FilesBlob, M } from './model.js';
4
+ import { settled } from './storage.js';
5
+ import { fileValue, isFile, Pending, runningOf, storedFile } from './values.js';
6
+ const fieldsOf = (model) => runningOf().fields.get(model) ?? new Map();
7
+ const invalid = (field, message) => new PublicError('VALIDATION_FAILED', 'Invalid data.', {
8
+ details: { issues: [{ path: [field], message }] },
9
+ });
10
+ /** The files a field's value holds. */
11
+ function heldOf(value) {
12
+ const items = Array.isArray(value) ? value : [value];
13
+ return items.filter(isFile);
14
+ }
15
+ const idsOf = (record, field) => heldOf(Reflect.get(record, field)).map(({ id }) => id);
16
+ const idOf = (record) => String(Reflect.get(record, 'id'));
17
+ /** Whether `caller` may attach a pending blob: its uploader, or Master. */
18
+ function usable(blob, target, caller) {
19
+ const { state, field, variantOf, deleteAfter, uploadedBy } = blob;
20
+ return (state === 'pending' &&
21
+ field === target &&
22
+ variantOf === null &&
23
+ deleteAfter !== null &&
24
+ deleteAfter.getTime() > Date.now() &&
25
+ (isMaster(caller) ||
26
+ uploadedBy === null ||
27
+ (caller.kind === 'user' && uploadedBy === caller.user.id)));
28
+ }
29
+ /** The pending files of `ids`, each one the caller may attach to `target`. */
30
+ async function pendingFiles(target, options, ids, caller) {
31
+ if (ids.length === 0)
32
+ return new Map();
33
+ const field = target.slice(target.indexOf('.') + 1);
34
+ const where = { id: { in: ids } };
35
+ const blobs = await FilesBlob.findMany({ where }, M);
36
+ const missing = ids.find((id) => !blobs.some((blob) => blob.id === id && usable(blob, target, caller)));
37
+ if (missing !== undefined) {
38
+ throw invalid(field, `${missing} is not a file uploaded for ${target}.`);
39
+ }
40
+ const made = await FilesBlob.findMany({ where: { variantOf: where.id } }, M);
41
+ const keys = keysOf(options);
42
+ return new Map(blobs.map((blob) => {
43
+ const variants = {};
44
+ for (const { variantOf, name, key, type, size } of made) {
45
+ if (variantOf === blob.id)
46
+ variants[name] = { key, type, size };
47
+ }
48
+ const { id, key, name, type, size, inline } = blob;
49
+ const copy = { id, key, name, type, size, inline: inline ?? {}, variants };
50
+ return [id, fileValue(storedFile(copy, 'FilesBlob'), keys)];
51
+ }));
52
+ }
53
+ /** A field's files after a write's operation on the ones it holds. */
54
+ async function filesOf(target, options, pending, held, caller) {
55
+ const { operation, ids } = pending;
56
+ const holds = new Map(held.map((file) => [file.id, file]));
57
+ if (operation === 'remove')
58
+ return held.filter(({ id }) => !ids.includes(id));
59
+ const field = target.slice(target.indexOf('.') + 1);
60
+ const taken = ids.find((id) => holds.has(id));
61
+ if (operation === 'push' && taken !== undefined) {
62
+ throw invalid(field, `The list holds ${taken} already.`);
63
+ }
64
+ const given = operation === 'pushUnique'
65
+ ? [...new Set(ids)].filter((id) => !holds.has(id))
66
+ : ids;
67
+ const fresh = given.filter((id) => !holds.has(id));
68
+ const found = await pendingFiles(target, options, fresh, caller);
69
+ const listed = given.flatMap((id) => {
70
+ const file = holds.get(id) ?? found.get(id);
71
+ return file === undefined ? [] : [file];
72
+ });
73
+ return operation === 'set' ? listed : [...held, ...listed];
74
+ }
75
+ /**
76
+ * `beforeCreate` and `beforeUpdate`: each file field's ids become its
77
+ * files, computed on the row the update locked.
78
+ */
79
+ export async function resolve(facts) {
80
+ const { name, caller, data, record } = facts;
81
+ for (const [field, options] of fieldsOf(name)) {
82
+ const value = Reflect.get(data, field);
83
+ if (!(value instanceof Pending))
84
+ continue;
85
+ const held = record === undefined ? [] : heldOf(Reflect.get(record, field));
86
+ const target = `${name}.${field}`;
87
+ const files = await filesOf(target, options, value, held, caller);
88
+ Reflect.set(data, field, options.list === true ? files : files[0]);
89
+ }
90
+ }
91
+ const attached = (name, field, record) => `${name}.${field}:${idOf(record)}`;
92
+ /**
93
+ * `afterCreate` and `afterUpdate`: the files the row gained are attached,
94
+ * with their variants, and the ones it lost released.
95
+ */
96
+ export async function attach(facts) {
97
+ const { name, record, previous } = facts;
98
+ for (const field of fieldsOf(name).keys()) {
99
+ const now = idsOf(record, field);
100
+ const before = previous === undefined ? [] : idsOf(previous, field);
101
+ const gained = now.filter((id) => !before.includes(id));
102
+ const lost = before.filter((id) => !now.includes(id));
103
+ const attachedTo = attached(name, field, record);
104
+ const data = { state: 'attached', attachedTo, deleteAfter: null };
105
+ if (gained.length > 0) {
106
+ const ids = { in: gained };
107
+ const where = {
108
+ id: ids,
109
+ state: 'pending',
110
+ deleteAfter: { gt: new Date() },
111
+ };
112
+ const { count } = await FilesBlob.updateMany({ where, data }, M);
113
+ // The sweep released it, or another row attached it, since.
114
+ if (count !== gained.length) {
115
+ throw invalid(field, 'A file expired or was attached elsewhere.');
116
+ }
117
+ await FilesBlob.updateMany({ where: { variantOf: ids }, data }, M);
118
+ }
119
+ if (lost.length > 0) {
120
+ const where = { OR: [{ id: { in: lost } }, { variantOf: { in: lost } }] };
121
+ await FilesBlob.updateMany({ where, data: { state: 'released' } }, M);
122
+ }
123
+ }
124
+ }
125
+ const heldBy = (name, record) => ({
126
+ attachedTo: {
127
+ in: [...fieldsOf(name).keys()].map((field) => attached(name, field, record)),
128
+ },
129
+ });
130
+ /** `afterDelete`: every file of the row, released. */
131
+ export async function release({ name, record }) {
132
+ const where = heldBy(name, record);
133
+ await FilesBlob.updateMany({ where, data: { state: 'released' } }, M);
134
+ }
135
+ /**
136
+ * Deletes the released blobs `where` names: their objects, then their rows.
137
+ * A failure keeps the rows, which the sweep deletes later.
138
+ */
139
+ async function purge(where) {
140
+ const { storage, logger } = runningOf();
141
+ try {
142
+ const blobs = await FilesBlob.findMany({ where: { AND: [where, { state: 'released' }] } }, M);
143
+ if (blobs.length === 0)
144
+ return;
145
+ await settled(blobs.map(({ key }) => storage.delete(key)));
146
+ const ids = blobs.map(({ id }) => id);
147
+ await FilesBlob.deleteMany({ where: { id: { in: ids } } }, M);
148
+ }
149
+ catch (error) {
150
+ logger.warn({ err: error }, 'Released files were not deleted yet.');
151
+ }
152
+ }
153
+ /** `afterUpdateCommit`: the objects of the files the row lost, deleted. */
154
+ export async function deleteLost(facts) {
155
+ const { name, record, previous } = facts;
156
+ const lost = [...fieldsOf(name).keys()].flatMap((field) => {
157
+ const now = idsOf(record, field);
158
+ return idsOf(previous, field).filter((id) => !now.includes(id));
159
+ });
160
+ if (lost.length === 0)
161
+ return;
162
+ await purge({ OR: [{ id: { in: lost } }, { variantOf: { in: lost } }] });
163
+ }
164
+ /** `afterDeleteCommit`: the objects of a deleted row's files, deleted. */
165
+ export async function deleteHeld({ name, record }) {
166
+ await purge(heldBy(name, record));
167
+ }
168
+ /**
169
+ * The sweep: expired pending blobs are released, so no write attaches them,
170
+ * then every released blob is deleted, what a crash left included.
171
+ */
172
+ export async function sweep() {
173
+ const where = { state: 'pending', deleteAfter: { lt: new Date() } };
174
+ await FilesBlob.updateMany({ where, data: { state: 'released' } }, M);
175
+ await purge({});
176
+ }
177
+ /** Compares the storage's keys with `FilesBlob`. */
178
+ export async function check(options) {
179
+ const { storage } = runningOf();
180
+ // The keys first: an upload inserts its row before its object, so an
181
+ // object listed without a row is never one under way.
182
+ const keys = await storage.keys();
183
+ const blobs = await FilesBlob.findMany({ select: { key: true } }, M);
184
+ const held = new Set(blobs.map(({ key }) => key));
185
+ const stored = new Set(keys);
186
+ const objects = keys.filter((key) => !held.has(key));
187
+ const rows = blobs.map(({ key }) => key).filter((key) => !stored.has(key));
188
+ if (options?.delete === true) {
189
+ await settled(objects.map((key) => storage.delete(key)));
190
+ }
191
+ return { objects, rows };
192
+ }
@@ -0,0 +1,16 @@
1
+ import type { FileBody, FileStorage } from './storage.ts';
2
+ /** An object `memoryFiles()` holds. */
3
+ export interface MemoryObject {
4
+ readonly body: Uint8Array;
5
+ readonly type: string;
6
+ readonly attachment: boolean;
7
+ }
8
+ /** A storage in memory, its objects readable by tests. */
9
+ export interface MemoryFiles extends FileStorage {
10
+ readonly objects: ReadonlyMap<string, MemoryObject>;
11
+ }
12
+ /** The bytes of a body, read to its end. */
13
+ export declare function bytesOf(body: FileBody): Promise<Uint8Array>;
14
+ /** A storage in memory: its URLs are `memory://files/<key>`, never served. */
15
+ export declare function memoryFiles(): MemoryFiles;
16
+ //# sourceMappingURL=memory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAGzD,uCAAuC;AACvC,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAA;CAC5B;AAED,0DAA0D;AAC1D,MAAM,WAAW,WAAY,SAAQ,WAAW;IAC/C,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,YAAY,CAAC,CAAA;CACnD;AAED,4CAA4C;AAC5C,wBAAsB,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,CAAC,CAGjE;AAED,8EAA8E;AAC9E,wBAAgB,WAAW,IAAI,WAAW,CAqBzC"}
package/dist/memory.js ADDED
@@ -0,0 +1,27 @@
1
+ import { isPublic } from './storage.js';
2
+ /** The bytes of a body, read to its end. */
3
+ export async function bytesOf(body) {
4
+ if (body instanceof Uint8Array)
5
+ return body;
6
+ return new Uint8Array(await new Response(body).arrayBuffer());
7
+ }
8
+ /** A storage in memory: its URLs are `memory://files/<key>`, never served. */
9
+ export function memoryFiles() {
10
+ const objects = new Map();
11
+ return {
12
+ objects,
13
+ start: () => { },
14
+ put: async (key, body, { type, size, attachment }) => {
15
+ const bytes = await bytesOf(body);
16
+ if (bytes.length !== size) {
17
+ throw new Error(`memoryFiles: ${key} has ${bytes.length} bytes, not ${size}.`);
18
+ }
19
+ objects.set(key, { body: bytes, type, attachment });
20
+ },
21
+ delete: async (key) => {
22
+ objects.delete(key);
23
+ },
24
+ keys: async () => [...objects.keys()],
25
+ url: (key) => `memory://files/${key}${isPublic(key) ? '' : '?expires=3600'}`,
26
+ };
27
+ }
@@ -0,0 +1,41 @@
1
+ import type { Field, ModelOf, ScalarOptions } from 'ultimabase';
2
+ import { master } from 'ultimabase';
3
+ export declare const M: {
4
+ readonly as: typeof master;
5
+ };
6
+ /** One hour, a pending blob's life. */
7
+ export declare const hour: number;
8
+ /** A blob's state: uploaded, held by a row, or to delete. */
9
+ export type BlobState = 'pending' | 'attached' | 'released';
10
+ type Optional<V> = Field<V | null, {
11
+ readonly optional: true;
12
+ }>;
13
+ type BlobFields = {
14
+ /** `public/…` or `private/…`, the object's key in the storage. */
15
+ readonly key: Field<string, {
16
+ readonly unique: true;
17
+ }>;
18
+ /** The file's name; a variant's own, `thumb`. */
19
+ readonly name: Field<string, ScalarOptions<string>>;
20
+ readonly type: Field<string, ScalarOptions<string>>;
21
+ readonly size: Field<number, {
22
+ readonly integer: true;
23
+ }>;
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<unknown, {
34
+ readonly optional: true;
35
+ }, never, never, unknown, true>;
36
+ /** When a pending blob expires. */
37
+ readonly deleteAfter: Optional<Date>;
38
+ };
39
+ export declare const FilesBlob: ModelOf<'FilesBlob', BlobFields, []>;
40
+ export {};
41
+ //# sourceMappingURL=model.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model.d.ts","sourceRoot":"","sources":["../src/model.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC/D,OAAO,EAAe,MAAM,EAAE,MAAM,YAAY,CAAA;AAGhD,eAAO,MAAM,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,EAAE,OAAO,MAAM,CAAA;CAAmB,CAAA;AAE/D,uCAAuC;AACvC,eAAO,MAAM,IAAI,EAAE,MAAuB,CAAA;AAE1C,6DAA6D;AAC7D,MAAM,MAAM,SAAS,GAAG,SAAS,GAAG,UAAU,GAAG,UAAU,CAAA;AAE3D,KAAK,QAAQ,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,GAAG,IAAI,EAAE;IAAE,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAA;CAAE,CAAC,CAAA;AAE/D,KAAK,UAAU,GAAG;IACjB,kEAAkE;IAClE,QAAQ,CAAC,GAAG,EAAE,KAAK,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAA;KAAE,CAAC,CAAA;IACtD,iDAAiD;IACjD,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACnD,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACnD,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,MAAM,EAAE;QAAE,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAA;KAAE,CAAC,CAAA;IACxD,oBAAoB;IACpB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACpD,4EAA4E;IAC5E,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAA;IACrC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAA;IACpD,wCAAwC;IACxC,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAA;IACrC,wBAAwB;IACxB,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAA;IACpC,QAAQ,CAAC,MAAM,EAAE,KAAK,CACrB,OAAO,EACP;QAAE,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAA;KAAE,EAC3B,KAAK,EACL,KAAK,EACL,OAAO,EACP,IAAI,CACJ,CAAA;IACD,mCAAmC;IACnC,QAAQ,CAAC,WAAW,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAA;CACpC,CAAA;AAED,eAAO,MAAM,SAAS,EAAE,OAAO,CAAC,WAAW,EAAE,UAAU,EAAE,EAAE,CAsBoB,CAAA"}
package/dist/model.js ADDED
@@ -0,0 +1,28 @@
1
+ import { defineModel, master } from 'ultimabase';
2
+ import { runningOf } from './values.js';
3
+ export const M = { as: master };
4
+ /** One hour, a pending blob's life. */
5
+ export const hour = 60 * 60 * 1000;
6
+ export const FilesBlob = defineModel({
7
+ name: 'FilesBlob',
8
+ fields: (f) => ({
9
+ key: f.string({ unique: true }),
10
+ name: f.string(),
11
+ type: f.string(),
12
+ size: f.number({ integer: true }),
13
+ field: f.string(),
14
+ uploadedBy: f.string({ optional: true }),
15
+ state: f.string(),
16
+ attachedTo: f.string({ optional: true }),
17
+ variantOf: f.string({ optional: true }),
18
+ inline: f.json({ optional: true }),
19
+ deleteAfter: f.dateTime({ optional: true }),
20
+ }),
21
+ indexes: [
22
+ { fields: ['state', 'deleteAfter'] },
23
+ { fields: ['variantOf'] },
24
+ { fields: ['attachedTo'] },
25
+ ],
26
+ // An upload creates its row as its caller: the kernel gives the rule the
27
+ // whole caller, and Master always passes. The rest stays Master's.
28
+ }).with({ access: { create: ({ caller }) => runningOf().upload({ caller }) } });