Files
edgeever/apps/api/src/self-hosted-storage-adapter.ts
T

150 lines
4.1 KiB
TypeScript

import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { dirname, resolve, sep } from "node:path";
import type {
BlobObjectAdapter,
BlobStoreAdapter,
DatabaseAdapter,
RelationalDatabaseDialect,
StorageAdapter,
} from "./storage-contract";
/** Small subset of Bun's SQLite API needed by the D1-compatible adapter. */
export type SqliteDatabaseLike = {
query: (sql: string) => {
all: (...bindings: unknown[]) => unknown[];
get: (...bindings: unknown[]) => unknown;
run: (...bindings: unknown[]) => unknown;
};
transaction: (callback: () => void) => () => unknown;
};
export const SELF_HOSTED_DATABASE_DIALECT: RelationalDatabaseDialect = "sqlite";
class SqlitePreparedStatement {
constructor(
private readonly sqlite: SqliteDatabaseLike,
readonly sql: string,
readonly bindings: unknown[] = [],
) {}
bind(...bindings: unknown[]) {
return new SqlitePreparedStatement(this.sqlite, this.sql, bindings);
}
async all() {
return {
results: this.sqlite.query(this.sql).all(...this.bindings),
success: true,
meta: {},
};
}
async first<T = Record<string, unknown>>() {
return (this.sqlite.query(this.sql).get(...this.bindings) as T | null | undefined) ?? null;
}
async run() {
this.sqlite.query(this.sql).run(...this.bindings);
return { success: true, meta: {} };
}
}
class SqliteDatabaseAdapter {
constructor(private readonly sqlite: SqliteDatabaseLike) {}
prepare(sql: string) {
return new SqlitePreparedStatement(this.sqlite, sql);
}
async batch(statements: SqlitePreparedStatement[]) {
const results: unknown[] = [];
this.sqlite.transaction(() => {
for (const statement of statements) {
results.push(this.sqlite.query(statement.sql).run(...statement.bindings));
}
})();
return results;
}
}
const safeObjectPath = (rootDirectory: string, objectKey: string) => {
const root = resolve(rootDirectory);
const target = resolve(root, objectKey);
if (target !== root && !target.startsWith(`${root}${sep}`)) {
throw new Error("Invalid resource object key");
}
return target;
};
const createLocalBlobStore = (rootDirectory: string): BlobStoreAdapter => ({
async get(objectKey): Promise<BlobObjectAdapter | null> {
const target = safeObjectPath(rootDirectory, objectKey);
try {
const bytes = await readFile(target);
return {
body: new Response(bytes).body as ReadableStream<Uint8Array>,
size: bytes.byteLength,
writeHttpMetadata: (headers) => {
headers.set("Content-Length", String(bytes.byteLength));
},
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error;
}
},
async put(objectKey, value) {
const target = safeObjectPath(rootDirectory, objectKey);
await mkdir(dirname(target), { recursive: true });
if (value instanceof Uint8Array) {
await writeFile(target, value);
return;
}
if (value instanceof ArrayBuffer) {
await writeFile(target, new Uint8Array(value));
return;
}
if (value instanceof Blob) {
await writeFile(target, new Uint8Array(await value.arrayBuffer()));
return;
}
throw new Error("Unsupported local resource payload");
},
async delete(objectKeys) {
const keys = Array.isArray(objectKeys) ? objectKeys : [objectKeys];
await Promise.all(keys.map(async (objectKey) => {
try {
await unlink(safeObjectPath(rootDirectory, objectKey));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
}));
},
});
/**
* Creates the first self-hosted adapter: SQLite-compatible database plus a
* filesystem-backed attachment store. The caller owns SQLite initialization
* and migration execution so Bun is not imported into the Worker bundle.
*/
export const createSelfHostedStorageAdapter = (
sqlite: SqliteDatabaseLike,
resourcesDirectory: string,
): StorageAdapter => ({
db: new SqliteDatabaseAdapter(sqlite) as unknown as DatabaseAdapter,
resources: createLocalBlobStore(resourcesDirectory),
});