diff --git a/apps/server/src/storage/blob.ts b/apps/server/src/storage/blob.ts index 8d41581..05a3810 100644 --- a/apps/server/src/storage/blob.ts +++ b/apps/server/src/storage/blob.ts @@ -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; + getBlobUrl(path: string): Promise; + removeBlob(path: string): Promise; + moveBlob(sourcePath: string, destinationPath: string): Promise; + deletePath(prefix: string): Promise; +} + +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) { + private readonly cacheKeys = { + blobUrl: (path: string) => `blob-url:${path}`, + }; + + constructor( + private readonly ctx: Context, + 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, @@ -43,6 +57,12 @@ export class BlobStorageProvider { } async getBlobUrl(path: string): Promise { + 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) { @@ -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) { diff --git a/apps/server/src/storage/cache.ts b/apps/server/src/storage/cache.ts new file mode 100644 index 0000000..e97e923 --- /dev/null +++ b/apps/server/src/storage/cache.ts @@ -0,0 +1,32 @@ +export interface CacheProvider { + get(key: string): Promise; + set(key: string, value: string, ttl?: number): Promise; + del(key: string): Promise; +} +interface CacheItem { + value: string; + expiresAt?: number; +} + +export class InMemoryCacheProvider implements CacheProvider { + private store = new Map(); + + async get(key: string): Promise { + 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 { + const expiresAt = ttl ? Date.now() + ttl * 1000 : undefined; + this.store.set(key, { value, expiresAt }); + } + + async del(key: string): Promise { + this.store.delete(key); + } +} diff --git a/apps/server/src/storage/d1.ts b/apps/server/src/storage/d1.ts index 703f5e4..adc895d 100644 --- a/apps/server/src/storage/d1.ts +++ b/apps/server/src/storage/d1.ts @@ -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; - private readonly blob: BlobStorageProvider; - - constructor(private readonly ctx: Context) { - 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, + private readonly cache: CacheProvider, + private readonly blob: BlobStorageProvider, + ) { + this.db = drizzle(this.ctx.env.DB, { schema }); } // Helper methods @@ -547,6 +556,12 @@ export class D1StorageProvider implements StorageProvider { appId: string, deploymentId: string, ): Promise { + 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), @@ -588,6 +603,7 @@ export class D1StorageProvider implements StorageProvider { } satisfies Package; } + this.cache.set(cacheKey, JSON.stringify(returningDeployment), 300); return returningDeployment; } @@ -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, @@ -780,7 +799,6 @@ export class D1StorageProvider implements StorageProvider { isDisabled: pkg.isDisabled, }) .where(eq(schema.packages.packageHash, pkg.packageHash)); - return pkg; } @@ -789,6 +807,12 @@ export class D1StorageProvider implements StorageProvider { appId: string, deploymentId: string, ): Promise { + 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), @@ -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( @@ -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(); @@ -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}`); } diff --git a/apps/server/src/storage/factory.ts b/apps/server/src/storage/factory.ts index 8de33c1..655fe30 100644 --- a/apps/server/src/storage/factory.ts +++ b/apps/server/src/storage/factory.ts @@ -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 | null = null; +let cacheInstance: CacheProvider | null = null; +let blobInstance: BlobStorageProvider | null = null; export function getStorageProvider(ctx: Context): 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): CacheProvider { + if (!cacheInstance) { + cacheInstance = new InMemoryCacheProvider(); + } + return cacheInstance; +} + +export function getBlobProvider( + ctx: Context, + cache: CacheProvider, +): BlobStorageProvider { + if (!blobInstance || ctx !== lastContext) { + blobInstance = new R2BlobStorageProvider(ctx, cache); + } + return blobInstance; +} diff --git a/apps/server/test/storage/cache.test.ts b/apps/server/test/storage/cache.test.ts new file mode 100644 index 0000000..7aaf935 --- /dev/null +++ b/apps/server/test/storage/cache.test.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/apps/server/test/storage/d1.test.ts b/apps/server/test/storage/d1.test.ts new file mode 100644 index 0000000..fc8c6aa --- /dev/null +++ b/apps/server/test/storage/d1.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { D1StorageProvider } from "../../src/storage/d1"; +import { InMemoryCacheProvider } from "../../src/storage/cache"; +import type { Context } from "hono"; +import type { Env } from "../../src/types/env"; +import * as schema from "../../src/db/schema"; +import { cleanupDatabase, getTestDb } from "../utils/db"; +import { + createTestAccount, + createTestApp, + createTestDeployment, + createTestPackage, +} from "../utils/fixtures"; +import { env } from "cloudflare:test"; +import { MockBlobStorageProvider } from "./mock-blob"; + +describe("D1StorageProvider Cache", () => { + let storage: D1StorageProvider; + let mockCtx: Context; + let mockCache: InMemoryCacheProvider; + let mockBlob: MockBlobStorageProvider; + let db: ReturnType; + let account: ReturnType; + let app: ReturnType; + + beforeEach(async () => { + db = getTestDb(); + await cleanupDatabase(); + + // Create test data + account = createTestAccount(); + await db.insert(schema.account).values(account); + + app = createTestApp(); + await db.insert(schema.app).values(app); + + // Add owner collaborator + await db.insert(schema.collaborator).values({ + appId: app.id, + accountId: account.id, + permission: "Owner", + }); + + mockCtx = { + env: { + DB: env.DB, + }, + } as unknown as Context; + + mockCache = new InMemoryCacheProvider(); + mockBlob = new MockBlobStorageProvider(mockCtx, mockCache); + storage = new D1StorageProvider(mockCtx, mockCache, mockBlob); + console.log(typeof mockCtx.env.DB.prepare); + }); + + afterEach(async () => { + await cleanupDatabase(); + }); + + it("should cache and return deployment data", async () => { + const deployment = createTestDeployment(app.id); + await db.insert(schema.deployment).values(deployment); + const packageHistory = createTestPackage(deployment.id); + await db.insert(schema.packages).values(packageHistory); + + const result = await storage.getDeployment( + account.id, + app.id, + deployment.id, + ); + + expect(result.id).toBe(deployment.id); + expect( + await mockCache.get( + `deployment:${account.id}:${app.id}:${deployment.id}`, + ), + ).toBe(JSON.stringify(result)); + }); + + it("should cache and return package history data", async () => { + const deployment = createTestDeployment(app.id); + await db.insert(schema.deployment).values(deployment); + const packageHistory = createTestPackage(deployment.id); + await db.insert(schema.packages).values(packageHistory); + + const result = await storage.getPackageHistory( + account.id, + app.id, + deployment.id, + ); + + expect(result.length).toBe(1); + expect( + await mockCache.get(`package:${account.id}:${app.id}:${deployment.id}`), + ).toBe(JSON.stringify(result)); + }); + + it("should invalidate cache when committing new package", async () => { + const deployment = createTestDeployment(app.id); + await db.insert(schema.deployment).values(deployment); + const packageHistory = createTestPackage(deployment.id); + await db.insert(schema.packages).values(packageHistory); + + // First get to populate cache + await storage.getPackageHistory(account.id, app.id, deployment.id); + expect( + await mockCache.get(`package:${account.id}:${app.id}:${deployment.id}`), + ).toBeDefined(); + + // Create test blob data + const blobId = "test-blob-id"; + const blobData = new ArrayBuffer(1024); + const blobUrl = await mockBlob.addBlob(blobId, blobData, 1024); + + // Commit new package + const newPackage = { + appVersion: "1.0.0", + description: "Test package", + isDisabled: false, + isMandatory: false, + rollout: 100, + size: 1024, + packageHash: "test-hash", + uploadTime: Date.now(), + blobUrl, + manifestBlobUrl: "", + diffPackageMap: {}, + }; + + await storage.commitPackage(account.id, app.id, deployment.id, newPackage); + + // Cache should be invalidated + expect( + await mockCache.get(`package:${account.id}:${app.id}:${deployment.id}`), + ).toBeNull(); + }); + + it("should cache and return package history data from deployment key", async () => { + const deployment = createTestDeployment(app.id); + await db.insert(schema.deployment).values(deployment); + const packageHistory = createTestPackage(deployment.id); + await db.insert(schema.packages).values(packageHistory); + + // First get to populate cache + await storage.getPackageHistory(account.id, app.id, deployment.id); + + const result = await storage.getPackageHistoryFromDeploymentKey( + deployment.key, + ); + + expect(result.length).toBe(1); + expect( + await mockCache.get(`package:${account.id}:${app.id}:${deployment.id}`), + ).toBe(JSON.stringify(result)); + }); +}); diff --git a/apps/server/test/storage/mock-blob.ts b/apps/server/test/storage/mock-blob.ts new file mode 100644 index 0000000..93aaed6 --- /dev/null +++ b/apps/server/test/storage/mock-blob.ts @@ -0,0 +1,75 @@ +import { Context } from "hono"; +import type { BlobStorageProvider } from "../../src/storage/blob"; +import type { CacheProvider } from "../../src/storage/cache"; +import { Env } from "../../src/types/env"; + +export class MockBlobStorageProvider implements BlobStorageProvider { + private readonly store = new Map(); + private readonly urls = new Map(); + + constructor( + private readonly ctx: Context, + private readonly cache: CacheProvider, + ) {} + + async addBlob( + blobId: string, + data: ArrayBuffer, + size: number, + ): Promise { + this.store.set(blobId, data); + + const url = `https://mock-storage.com/${blobId}`; + this.urls.set(blobId, url); + return url; + } + + async getBlobUrl(path: string): Promise { + const url = this.urls.get(path) || `https://mock-storage.com/${path}`; + this.urls.set(path, url); + return url; + } + + async removeBlob(path: string): Promise { + this.store.delete(path); + this.urls.delete(path); + } + + async moveBlob(sourceUrl: string, targetPath: string): Promise { + // Extract blobId from URL or path + const sourceBlobId = sourceUrl.startsWith("https://") + ? sourceUrl.split("/").pop() + : sourceUrl; + if (!sourceBlobId) { + throw new Error(`Invalid source URL: ${sourceUrl}`); + } + + const data = this.store.get(sourceBlobId); + if (!data) { + throw new Error(`Blob not found: ${sourceBlobId}`); + } + + const targetBlobId = targetPath.split("/").pop(); + if (!targetBlobId) { + throw new Error(`Invalid target path: ${targetPath}`); + } + + console.log("targetBlobId", targetBlobId); + + this.store.set(targetBlobId, data); + this.store.delete(sourceBlobId); + + const url = `https://mock-storage.com/${targetBlobId}`; + this.urls.set(targetBlobId, url); + return url; + } + + async deletePath(prefix: string): Promise { + for (const [key] of this.store) { + if (key.startsWith(prefix)) { + this.store.delete(key); + this.urls.delete(key); + } + } + } +} \ No newline at end of file