alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
105 lines (88 loc) • 3.21 kB
text/typescript
import { $inject, Alepha, type FileLike } from "alepha";
import { CryptoProvider } from "alepha/crypto";
import { currentTenantAtom } from "alepha/security";
import { FileDetector, FileSystemProvider } from "alepha/system";
import { FileNotFoundError } from "../errors/FileNotFoundError.ts";
import type { FileStorageProvider } from "./FileStorageProvider.ts";
interface StoredFile {
buffer: Buffer;
name: string;
type: string;
size: number;
}
export class MemoryFileStorageProvider implements FileStorageProvider {
public readonly files: Record<string, StoredFile> = {};
protected readonly alepha = $inject(Alepha);
protected readonly fileSystem = $inject(FileSystemProvider);
protected readonly fileDetector = $inject(FileDetector);
protected readonly crypto = $inject(CryptoProvider);
public async upload(
bucketName: string,
file: FileLike,
fileId?: string,
): Promise<string> {
fileId ??= this.createId();
// Consume the stream and store as buffer so downloads are repeatable
const chunks: Uint8Array[] = [];
for await (const chunk of file.stream() as AsyncIterable<Uint8Array>) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
this.files[this.key(bucketName, fileId)] = {
buffer,
name: file.name,
type: file.type,
size: file.size,
};
return fileId;
}
public async download(bucketName: string, fileId: string): Promise<FileLike> {
const fileKey = this.key(bucketName, fileId);
const stored = this.files[fileKey];
if (!stored) {
throw new FileNotFoundError(`File with ID ${fileId} not found.`);
}
// Create a fresh FileLike with a new stream from the stored buffer
return this.fileSystem.createFile({
stream: new Blob([new Uint8Array(stored.buffer)]).stream(),
name: stored.name,
type: stored.type,
size: stored.size,
});
}
public async exists(bucketName: string, fileId: string): Promise<boolean> {
return this.key(bucketName, fileId) in this.files;
}
public async delete(bucketName: string, fileId: string): Promise<void> {
const fileKey = this.key(bucketName, fileId);
if (!(fileKey in this.files)) {
throw new FileNotFoundError(`File with ID ${fileId} not found.`);
}
delete this.files[fileKey];
}
public async deleteMany(
bucketName: string,
fileIds: string[],
): Promise<void> {
for (const id of fileIds) {
delete this.files[this.key(bucketName, id)];
}
}
public async list(bucketName: string): Promise<string[]> {
const prefix = this.key(bucketName, "");
return Object.keys(this.files)
.filter((key) => key.startsWith(prefix))
.map((key) => key.slice(prefix.length));
}
/**
* In-memory key, tenant-scoped when a tenant is active (`currentTenantAtom`),
* mirroring the R2/Local/S3 layout: `{tenantId}/{bucket}/{fileId}`.
*/
protected key(bucket: string, fileId = ""): string {
const tenantId = this.alepha.store.get(currentTenantAtom)?.id;
return tenantId ? `${tenantId}/${bucket}/${fileId}` : `${bucket}/${fileId}`;
}
protected createId(): string {
return this.crypto.randomUUID();
}
}