@basaltkit/files 1.1.1 → 1.2.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/dist/files.d.ts +35 -2
- package/dist/files.js +50 -14
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plugin.js +5 -2
- package/package.json +1 -1
package/dist/files.d.ts
CHANGED
|
@@ -48,6 +48,12 @@ export interface UploadInput {
|
|
|
48
48
|
uploadedBy?: string;
|
|
49
49
|
metadata?: Record<string, unknown>;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Store key every record is filed under when the app has no tenancy at all.
|
|
53
|
+
* The {@link FileStore} contract is tenant-keyed, so a single-tenant app still
|
|
54
|
+
* needs one stable key — it just shouldn't have to invent it.
|
|
55
|
+
*/
|
|
56
|
+
export declare const SINGLE_TENANT_SCOPE = "default";
|
|
51
57
|
/**
|
|
52
58
|
* Upload pipeline over a storage {@link Disk}: validates size/type, enforces a
|
|
53
59
|
* per-tenant quota, writes the bytes, records metadata, and emits hooks. Every
|
|
@@ -55,6 +61,13 @@ export interface UploadInput {
|
|
|
55
61
|
* context so files are isolated whether called from a request or a job.
|
|
56
62
|
*/
|
|
57
63
|
export declare class Files {
|
|
64
|
+
/**
|
|
65
|
+
* Whether the host app registered `@basaltkit/tenancy`. `filesPlugin` wires
|
|
66
|
+
* this to the container's `'tenancy:active'` metadata marker — a signal,
|
|
67
|
+
* not an import, so this generic package never depends on the opt-in SaaS
|
|
68
|
+
* layer. Defaults to `false` (single-tenant).
|
|
69
|
+
*/
|
|
70
|
+
private readonly tenancyActive;
|
|
58
71
|
private readonly disk;
|
|
59
72
|
private readonly store;
|
|
60
73
|
private readonly hooks;
|
|
@@ -62,7 +75,14 @@ export declare class Files {
|
|
|
62
75
|
private readonly maxTotalBytes;
|
|
63
76
|
private readonly checkQuota;
|
|
64
77
|
private readonly now;
|
|
65
|
-
constructor(options: FilesOptions
|
|
78
|
+
constructor(options: FilesOptions,
|
|
79
|
+
/**
|
|
80
|
+
* Whether the host app registered `@basaltkit/tenancy`. `filesPlugin` wires
|
|
81
|
+
* this to the container's `'tenancy:active'` metadata marker — a signal,
|
|
82
|
+
* not an import, so this generic package never depends on the opt-in SaaS
|
|
83
|
+
* layer. Defaults to `false` (single-tenant).
|
|
84
|
+
*/
|
|
85
|
+
tenancyActive?: () => boolean);
|
|
66
86
|
upload(content: Buffer, input: UploadInput): Promise<FileRecord>;
|
|
67
87
|
get(id: string, tenantId?: string): Promise<FileRecord | null>;
|
|
68
88
|
list(tenantId?: string): Promise<FileRecord[]>;
|
|
@@ -87,7 +107,20 @@ export declare class Files {
|
|
|
87
107
|
}, tenantId?: string): Promise<FileRecord>;
|
|
88
108
|
private validate;
|
|
89
109
|
private enforceQuota;
|
|
110
|
+
/**
|
|
111
|
+
* The tenant a call is scoped to, or `undefined` when the app has no tenancy.
|
|
112
|
+
*
|
|
113
|
+
* With `@basaltkit/tenancy` registered an unresolvable tenant is an error: an
|
|
114
|
+
* unscoped read or write would cross tenants. Without it there is no tenant
|
|
115
|
+
* dimension and nothing to cross.
|
|
116
|
+
*/
|
|
90
117
|
private tenant;
|
|
91
|
-
/**
|
|
118
|
+
/** The {@link FileStore} key: the tenant, or {@link SINGLE_TENANT_SCOPE}. */
|
|
119
|
+
private scope;
|
|
120
|
+
/**
|
|
121
|
+
* Runs a storage op in the resolved tenant's context so the disk scopes
|
|
122
|
+
* correctly. With no tenancy there is no tenant context to synthesize — the
|
|
123
|
+
* disk keeps its own (unscoped) default, so paths match plain `@basaltkit/storage`.
|
|
124
|
+
*/
|
|
92
125
|
private inTenant;
|
|
93
126
|
}
|
package/dist/files.js
CHANGED
|
@@ -36,6 +36,12 @@ export class FileTenantRequiredError extends BasaltError {
|
|
|
36
36
|
}
|
|
37
37
|
const matchesType = (contentType, allowed) => allowed.some((a) => a === contentType || (a.endsWith('/*') && contentType.startsWith(a.slice(0, -1))));
|
|
38
38
|
const storagePath = (id) => `files/${id}`;
|
|
39
|
+
/**
|
|
40
|
+
* Store key every record is filed under when the app has no tenancy at all.
|
|
41
|
+
* The {@link FileStore} contract is tenant-keyed, so a single-tenant app still
|
|
42
|
+
* needs one stable key — it just shouldn't have to invent it.
|
|
43
|
+
*/
|
|
44
|
+
export const SINGLE_TENANT_SCOPE = 'default';
|
|
39
45
|
/**
|
|
40
46
|
* Upload pipeline over a storage {@link Disk}: validates size/type, enforces a
|
|
41
47
|
* per-tenant quota, writes the bytes, records metadata, and emits hooks. Every
|
|
@@ -43,6 +49,7 @@ const storagePath = (id) => `files/${id}`;
|
|
|
43
49
|
* context so files are isolated whether called from a request or a job.
|
|
44
50
|
*/
|
|
45
51
|
export class Files {
|
|
52
|
+
tenancyActive;
|
|
46
53
|
disk;
|
|
47
54
|
store;
|
|
48
55
|
hooks;
|
|
@@ -50,7 +57,15 @@ export class Files {
|
|
|
50
57
|
maxTotalBytes;
|
|
51
58
|
checkQuota;
|
|
52
59
|
now;
|
|
53
|
-
constructor(options
|
|
60
|
+
constructor(options,
|
|
61
|
+
/**
|
|
62
|
+
* Whether the host app registered `@basaltkit/tenancy`. `filesPlugin` wires
|
|
63
|
+
* this to the container's `'tenancy:active'` metadata marker — a signal,
|
|
64
|
+
* not an import, so this generic package never depends on the opt-in SaaS
|
|
65
|
+
* layer. Defaults to `false` (single-tenant).
|
|
66
|
+
*/
|
|
67
|
+
tenancyActive = () => false) {
|
|
68
|
+
this.tenancyActive = tenancyActive;
|
|
54
69
|
this.disk = options.disk;
|
|
55
70
|
this.store = options.store ?? new MemoryFileStore();
|
|
56
71
|
this.hooks = options.hooks;
|
|
@@ -63,16 +78,17 @@ export class Files {
|
|
|
63
78
|
}
|
|
64
79
|
async upload(content, input) {
|
|
65
80
|
const tenantId = this.tenant(input.tenantId);
|
|
81
|
+
const scope = tenantId ?? SINGLE_TENANT_SCOPE;
|
|
66
82
|
const size = content.length;
|
|
67
83
|
this.validate(input.contentType, size);
|
|
68
|
-
await this.enforceQuota(
|
|
84
|
+
await this.enforceQuota(scope, size);
|
|
69
85
|
const id = randomUUID();
|
|
70
86
|
const path = storagePath(id);
|
|
71
87
|
const checksum = createHash('sha256').update(content).digest('hex');
|
|
72
88
|
await this.inTenant(tenantId, () => this.disk.put(path, content, { contentType: input.contentType }));
|
|
73
89
|
const record = {
|
|
74
90
|
id,
|
|
75
|
-
tenantId,
|
|
91
|
+
tenantId: scope,
|
|
76
92
|
name: input.name,
|
|
77
93
|
contentType: input.contentType,
|
|
78
94
|
size,
|
|
@@ -87,14 +103,14 @@ export class Files {
|
|
|
87
103
|
return record;
|
|
88
104
|
}
|
|
89
105
|
async get(id, tenantId) {
|
|
90
|
-
return this.store.find(this.
|
|
106
|
+
return this.store.find(this.scope(tenantId), id);
|
|
91
107
|
}
|
|
92
108
|
async list(tenantId) {
|
|
93
|
-
return this.store.list(this.
|
|
109
|
+
return this.store.list(this.scope(tenantId));
|
|
94
110
|
}
|
|
95
111
|
async download(id, tenantId) {
|
|
96
112
|
const resolved = this.tenant(tenantId);
|
|
97
|
-
const record = await this.store.find(resolved, id);
|
|
113
|
+
const record = await this.store.find(resolved ?? SINGLE_TENANT_SCOPE, id);
|
|
98
114
|
if (!record)
|
|
99
115
|
throw new FileNotFoundError();
|
|
100
116
|
const content = await this.inTenant(resolved, () => this.disk.get(record.path));
|
|
@@ -108,23 +124,24 @@ export class Files {
|
|
|
108
124
|
*/
|
|
109
125
|
async temporaryUrl(id, expiresIn, tenantId, options = {}) {
|
|
110
126
|
const resolved = this.tenant(tenantId);
|
|
111
|
-
const record = await this.store.find(resolved, id);
|
|
127
|
+
const record = await this.store.find(resolved ?? SINGLE_TENANT_SCOPE, id);
|
|
112
128
|
if (!record)
|
|
113
129
|
throw new FileNotFoundError();
|
|
114
130
|
return this.inTenant(resolved, () => this.disk.temporaryUrl(record.path, expiresIn, options));
|
|
115
131
|
}
|
|
116
132
|
async delete(id, tenantId) {
|
|
117
133
|
const resolved = this.tenant(tenantId);
|
|
118
|
-
const
|
|
134
|
+
const scope = resolved ?? SINGLE_TENANT_SCOPE;
|
|
135
|
+
const record = await this.store.find(scope, id);
|
|
119
136
|
if (!record)
|
|
120
137
|
return;
|
|
121
138
|
await this.inTenant(resolved, () => this.disk.delete(record.path));
|
|
122
|
-
await this.store.delete(
|
|
123
|
-
await this.hooks?.emit('file:deleted', { tenantId:
|
|
139
|
+
await this.store.delete(scope, id);
|
|
140
|
+
await this.hooks?.emit('file:deleted', { tenantId: scope, id });
|
|
124
141
|
}
|
|
125
142
|
/** Records the result of an out-of-band scan (antivirus, moderation, …). */
|
|
126
143
|
async markScanned(id, result, tenantId) {
|
|
127
|
-
const resolved = this.
|
|
144
|
+
const resolved = this.scope(tenantId);
|
|
128
145
|
const record = await this.store.find(resolved, id);
|
|
129
146
|
if (!record)
|
|
130
147
|
throw new FileNotFoundError();
|
|
@@ -149,14 +166,33 @@ export class Files {
|
|
|
149
166
|
}
|
|
150
167
|
await this.checkQuota?.(tenantId, size);
|
|
151
168
|
}
|
|
169
|
+
/**
|
|
170
|
+
* The tenant a call is scoped to, or `undefined` when the app has no tenancy.
|
|
171
|
+
*
|
|
172
|
+
* With `@basaltkit/tenancy` registered an unresolvable tenant is an error: an
|
|
173
|
+
* unscoped read or write would cross tenants. Without it there is no tenant
|
|
174
|
+
* dimension and nothing to cross.
|
|
175
|
+
*/
|
|
152
176
|
tenant(explicit) {
|
|
153
177
|
const id = explicit ?? tryCtx()?.['tenant']?.id;
|
|
154
|
-
if (
|
|
178
|
+
if (id)
|
|
179
|
+
return id;
|
|
180
|
+
if (this.tenancyActive())
|
|
155
181
|
throw new FileTenantRequiredError();
|
|
156
|
-
return
|
|
182
|
+
return undefined;
|
|
157
183
|
}
|
|
158
|
-
/**
|
|
184
|
+
/** The {@link FileStore} key: the tenant, or {@link SINGLE_TENANT_SCOPE}. */
|
|
185
|
+
scope(explicit) {
|
|
186
|
+
return this.tenant(explicit) ?? SINGLE_TENANT_SCOPE;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Runs a storage op in the resolved tenant's context so the disk scopes
|
|
190
|
+
* correctly. With no tenancy there is no tenant context to synthesize — the
|
|
191
|
+
* disk keeps its own (unscoped) default, so paths match plain `@basaltkit/storage`.
|
|
192
|
+
*/
|
|
159
193
|
inTenant(tenantId, fn) {
|
|
194
|
+
if (tenantId === undefined)
|
|
195
|
+
return fn();
|
|
160
196
|
return runWithContext({ tenant: { id: tenantId } }, fn);
|
|
161
197
|
}
|
|
162
198
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { Files, DEFAULT_MAX_FILE_SIZE, FileTooLargeError, FileTypeNotAllowedError, StorageQuotaExceededError, FileNotFoundError, FileTenantRequiredError, type FilesOptions, type FileValidation, type UploadInput, } from './files.js';
|
|
1
|
+
export { Files, DEFAULT_MAX_FILE_SIZE, FileTooLargeError, FileTypeNotAllowedError, StorageQuotaExceededError, FileNotFoundError, FileTenantRequiredError, SINGLE_TENANT_SCOPE, type FilesOptions, type FileValidation, type UploadInput, } from './files.js';
|
|
2
2
|
export { MemoryFileStore, type FileRecord, type FileStore, type FilePatch, } from './store.js';
|
|
3
3
|
export { filesPlugin, fileRoutes, FILES, type FilesPluginOptions } from './plugin.js';
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { Files, DEFAULT_MAX_FILE_SIZE, FileTooLargeError, FileTypeNotAllowedError, StorageQuotaExceededError, FileNotFoundError, FileTenantRequiredError, } from './files.js';
|
|
1
|
+
export { Files, DEFAULT_MAX_FILE_SIZE, FileTooLargeError, FileTypeNotAllowedError, StorageQuotaExceededError, FileNotFoundError, FileTenantRequiredError, SINGLE_TENANT_SCOPE, } from './files.js';
|
|
2
2
|
export { MemoryFileStore, } from './store.js';
|
|
3
3
|
export { filesPlugin, fileRoutes, FILES } from './plugin.js';
|
package/dist/plugin.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createToken, ctx, definePlugin } from '@basaltkit/core';
|
|
1
|
+
import { createToken, ctx, definePlugin, ensureMetadata } from '@basaltkit/core';
|
|
2
2
|
import { STORAGE } from '@basaltkit/storage';
|
|
3
3
|
import { route } from '@basaltkit/http';
|
|
4
4
|
import { z } from 'zod';
|
|
@@ -8,6 +8,9 @@ export function filesPlugin(options) {
|
|
|
8
8
|
return definePlugin({
|
|
9
9
|
name: 'basalt:files',
|
|
10
10
|
register({ container, hooks }) {
|
|
11
|
+
// 'tenancy:active' is tenancyPlugin's marker: how a generic package
|
|
12
|
+
// learns the app is multi-tenant without importing @basaltkit/tenancy.
|
|
13
|
+
const metadata = ensureMetadata(container);
|
|
11
14
|
container.singleton(FILES, () => {
|
|
12
15
|
const disk = typeof options.disk === 'string' ? container.get(STORAGE).disk(options.disk) : options.disk;
|
|
13
16
|
return new Files({
|
|
@@ -17,7 +20,7 @@ export function filesPlugin(options) {
|
|
|
17
20
|
...(options.validate ? { validate: options.validate } : {}),
|
|
18
21
|
...(options.maxTotalBytes !== undefined ? { maxTotalBytes: options.maxTotalBytes } : {}),
|
|
19
22
|
...(options.checkQuota ? { checkQuota: options.checkQuota } : {}),
|
|
20
|
-
});
|
|
23
|
+
}, () => metadata.get('tenancy:active').length > 0);
|
|
21
24
|
});
|
|
22
25
|
},
|
|
23
26
|
});
|