Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions apps/server/src/storage/blob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,33 @@ import { AwsClient } from "aws4fetch";
import type { Context } from "hono";
import type { Env } from "../types/env";
import { ErrorCode, isStorageError } from "../types/error";
import type { CacheProvider } from "./cache";
import { createStorageError } from "./storage";

export class BlobStorageProvider {
export interface BlobStorageProvider {
addBlob(blobId: string, data: ArrayBuffer, size: number): Promise<string>;
getBlobUrl(path: string): Promise<string>;
removeBlob(path: string): Promise<void>;
moveBlob(sourcePath: string, destinationPath: string): Promise<string>;
deletePath(prefix: string): Promise<void>;
}

export class R2BlobStorageProvider implements BlobStorageProvider {
private readonly storage: R2Bucket;
private readonly aws: AwsClient;
private readonly accountId: string;
private readonly bucketName: string;

constructor(private readonly ctx: Context<Env>) {
private readonly cacheKeys = {
blobUrl: (path: string) => `blob-url:${path}`,
};

constructor(
private readonly ctx: Context<Env>,
private readonly cache: CacheProvider,
) {
this.storage = ctx.env.STORAGE_BUCKET;
this.accountId = ctx.env.ACCOUNT_ID;
this.bucketName = ctx.env.R2_BUCKET_NAME;

this.aws = new AwsClient({
accessKeyId: ctx.env.R2_ACCESS_KEY_ID,
secretAccessKey: ctx.env.R2_SECRET_ACCESS_KEY,
Expand Down Expand Up @@ -43,6 +57,12 @@ export class BlobStorageProvider {
}

async getBlobUrl(path: string): Promise<string> {
const cacheKey = this.cacheKeys.blobUrl(path);
const cachedUrl = await this.cache.get(cacheKey);
if (cachedUrl) {
return cachedUrl;
}

try {
const object = await this.storage.head(path);
if (!object) {
Expand All @@ -67,6 +87,7 @@ export class BlobStorageProvider {
},
);

await this.cache.set(cacheKey, signed.url, 1800);
return signed.url;
} catch (error) {
if (isStorageError(error) && error.code === ErrorCode.NotFound) {
Expand Down
32 changes: 32 additions & 0 deletions apps/server/src/storage/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export interface CacheProvider {
get(key: string): Promise<string | null>;
set(key: string, value: string, ttl?: number): Promise<void>;
del(key: string): Promise<void>;
}
interface CacheItem {
value: string;
expiresAt?: number;
}

export class InMemoryCacheProvider implements CacheProvider {
private store = new Map<string, CacheItem>();

async get(key: string): Promise<string | null> {
const item = this.store.get(key);
if (!item) return null;
if (item.expiresAt && item.expiresAt < Date.now()) {
this.store.delete(key);
return null;
}
return item.value;
}

async set(key: string, value: string, ttl?: number): Promise<void> {
const expiresAt = ttl ? Date.now() + ttl * 1000 : undefined;
this.store.set(key, { value, expiresAt });
}

async del(key: string): Promise<void> {
this.store.delete(key);
}
}
51 changes: 42 additions & 9 deletions apps/server/src/storage/d1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,25 @@ import type {
PackageHashToBlobInfoMap,
} from "../types/schemas";
import { generateKey } from "../utils/security";
import { BlobStorageProvider } from "./blob";
import type { BlobStorageProvider } from "./blob";
import type { CacheProvider } from "./cache";
import { type StorageProvider, createStorageError } from "./storage";

export class D1StorageProvider implements StorageProvider {
private readonly db: DrizzleD1Database<typeof schema>;
private readonly blob: BlobStorageProvider;

constructor(private readonly ctx: Context<Env>) {
this.db = drizzle(ctx.env.DB, { schema });
this.blob = new BlobStorageProvider(ctx);
private readonly cacheKeys = {
package: (accountId: string, appId: string, deploymentId: string) =>
`package:${accountId}:${appId}:${deploymentId}`,
deployment: (accountId: string, appId: string, deploymentId: string) =>
`deployment:${accountId}:${appId}:${deploymentId}`,
};

constructor(
private readonly ctx: Context<Env>,
private readonly cache: CacheProvider,
private readonly blob: BlobStorageProvider,
) {
this.db = drizzle(this.ctx.env.DB, { schema });
}

// Helper methods
Expand Down Expand Up @@ -547,6 +556,12 @@ export class D1StorageProvider implements StorageProvider {
appId: string,
deploymentId: string,
): Promise<Deployment> {
const cacheKey = this.cacheKeys.deployment(accountId, appId, deploymentId);
const cachedDeployment = await this.cache.get(cacheKey);
if (cachedDeployment) {
return JSON.parse(cachedDeployment);
}

const deployment = await this.db.query.deployment.findFirst({
where: and(
eq(schema.deployment.id, deploymentId),
Expand Down Expand Up @@ -588,6 +603,7 @@ export class D1StorageProvider implements StorageProvider {
} satisfies Package;
}

this.cache.set(cacheKey, JSON.stringify(returningDeployment), 300);
return returningDeployment;
}

Expand Down Expand Up @@ -762,6 +778,9 @@ export class D1StorageProvider implements StorageProvider {
uploadTime: pkg.uploadTime,
});

const cacheKey = this.cacheKeys.package(accountId, appId, deploymentId);
await this.cache.del(cacheKey);

return {
...pkg,
label,
Expand All @@ -780,7 +799,6 @@ export class D1StorageProvider implements StorageProvider {
isDisabled: pkg.isDisabled,
})
.where(eq(schema.packages.packageHash, pkg.packageHash));

return pkg;
}

Expand All @@ -789,6 +807,12 @@ export class D1StorageProvider implements StorageProvider {
appId: string,
deploymentId: string,
): Promise<Package[]> {
const cacheKey = this.cacheKeys.package(accountId, appId, deploymentId);
const cachedPackages = await this.cache.get(cacheKey);
if (cachedPackages) {
return JSON.parse(cachedPackages);
}

const packages = await this.db.query.packages.findMany({
where: and(
eq(schema.packages.deploymentId, deploymentId),
Expand All @@ -797,17 +821,20 @@ export class D1StorageProvider implements StorageProvider {
orderBy: (packages, { asc }) => [asc(packages.uploadTime)],
});

return Promise.all(
const result = await Promise.all(
packages.map(async (p) => ({
...this.mapPackageFromDB(p),
blobUrl: await this.blob.getBlobUrl(p.blobPath),
// Use empty string as default for manifestBlobUrl
manifestBlobUrl: p.manifestBlobPath
? await this.blob.getBlobUrl(p.manifestBlobPath)
: "",
diffPackageMap: await this.getPackageDiffs(p.id),
})),
);

// Cache the result for 5 minutes
await this.cache.set(cacheKey, JSON.stringify(result), 300);
return result;
}

async getPackageHistoryFromDeploymentKey(
Expand Down Expand Up @@ -843,6 +870,9 @@ export class D1StorageProvider implements StorageProvider {
.set({ deletedAt: Date.now() })
.where(eq(schema.packages.deploymentId, deploymentId));

const cacheKey = this.cacheKeys.package(accountId, appId, deploymentId);
await this.cache.del(cacheKey);

// Insert new history
for (const [index, pkg] of history.entries()) {
const id = generateKey();
Expand Down Expand Up @@ -890,6 +920,9 @@ export class D1StorageProvider implements StorageProvider {
.set(schema.packages)
.where(eq(schema.packages.deploymentId, deploymentId));

const cacheKey = this.cacheKeys.package(accountId, appId, deploymentId);
await this.cache.del(cacheKey);

// Delete all package blobs
// await this.blob.deletePath(`apps/${appId}/deployments/${deploymentId}`);
}
Expand Down
26 changes: 25 additions & 1 deletion apps/server/src/storage/factory.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,40 @@
import type { Context } from "hono";
import type { Env } from "../types/env";
import type { BlobStorageProvider } from "./blob";
import { R2BlobStorageProvider } from "./blob";
import { type CacheProvider, InMemoryCacheProvider } from "./cache";
import { D1StorageProvider } from "./d1";
import type { StorageProvider } from "./storage";

let storageInstance: StorageProvider | null = null;
let lastContext: Context<Env> | null = null;
let cacheInstance: CacheProvider | null = null;
let blobInstance: BlobStorageProvider | null = null;

export function getStorageProvider(ctx: Context<Env>): StorageProvider {
// If context changed or no instance exists, create new instance
if (!storageInstance || ctx !== lastContext) {
storageInstance = new D1StorageProvider(ctx);
const cache = getCacheProvider(ctx);
const blob = getBlobProvider(ctx, cache);
storageInstance = new D1StorageProvider(ctx, cache, blob);
lastContext = ctx;
}
return storageInstance;
}

export function getCacheProvider(ctx: Context<Env>): CacheProvider {
if (!cacheInstance) {
cacheInstance = new InMemoryCacheProvider();
}
return cacheInstance;
}

export function getBlobProvider(
ctx: Context<Env>,
cache: CacheProvider,
): BlobStorageProvider {
if (!blobInstance || ctx !== lastContext) {
blobInstance = new R2BlobStorageProvider(ctx, cache);
}
return blobInstance;
}
41 changes: 41 additions & 0 deletions apps/server/test/storage/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { InMemoryCacheProvider } from "../../src/storage/cache";
import { describe, it, expect, beforeEach } from "vitest";
import { vi } from "vitest";

describe("Cache", () => {
let cache: InMemoryCacheProvider;
beforeEach(() => {
vi.clearAllMocks();
cache = new InMemoryCacheProvider();
});

it("should be able to set and get a value", async () => {
await cache.set("test", "test");
expect(await cache.get("test")).toBe("test");
});

it("should be able to delete a value", async () => {
await cache.set("test", "test");
await cache.del("test");
expect(await cache.get("test")).toBeNull();
});

it("should be able to set a value with an expiration time", async () => {
await cache.set("test", "test", 1);
expect(await cache.get("test")).toBe("test");
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(await cache.get("test")).toBeNull();
});

it("should be inable to get a value that has expired", async () => {
await cache.set("test", "test", 1);
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(await cache.get("test")).toBeNull();
});

it("should be inable to get a value deleted", async () => {
await cache.set("test", "test");
await cache.del("test");
expect(await cache.get("test")).toBeNull();
});
});
Loading