From b4cdbb49dd57eaee650f98758d676b7266085356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=87=E1=85=A1=E1=86=A8=E1=84=8C=E1=85=AE=E1=86=AB?= =?UTF-8?q?=E1=84=92=E1=85=A1?= Date: Mon, 21 Apr 2025 10:50:48 +0900 Subject: [PATCH 01/13] =?UTF-8?q?add:=20local=20cache=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/storage/cache.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 apps/server/src/storage/cache.ts 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); + } +} From 2e28ac7120c14aff5986eb6f5fbb80f3db4f26ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=87=E1=85=A1=E1=86=A8=E1=84=8C=E1=85=AE=E1=86=AB?= =?UTF-8?q?=E1=84=92=E1=85=A1?= Date: Mon, 21 Apr 2025 10:51:30 +0900 Subject: [PATCH 02/13] =?UTF-8?q?add:=20cacheProvider=20=EC=8B=B1=EA=B8=80?= =?UTF-8?q?=ED=86=A4=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20D1StorageProvider?= =?UTF-8?q?=EC=97=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/storage/factory.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/server/src/storage/factory.ts b/apps/server/src/storage/factory.ts index 8de33c1..3dde4d8 100644 --- a/apps/server/src/storage/factory.ts +++ b/apps/server/src/storage/factory.ts @@ -2,15 +2,24 @@ import type { Context } from "hono"; import type { Env } from "../types/env"; import { D1StorageProvider } from "./d1"; import type { StorageProvider } from "./storage"; +import { InMemoryCacheProvider, type CacheProvider } from "./cache"; let storageInstance: StorageProvider | null = null; let lastContext: Context | null = null; +let cacheInstance: CacheProvider | 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); + storageInstance = new D1StorageProvider(ctx, getCacheProvider(ctx)); lastContext = ctx; } return storageInstance; } + +export function getCacheProvider(ctx: Context): CacheProvider { + if (!cacheInstance) { + cacheInstance = new InMemoryCacheProvider(); + } + return cacheInstance; +} From 1940ab466df9e2c2348f2f420c157ac962d187a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=87=E1=85=A1=E1=86=A8=E1=84=8C=E1=85=AE=E1=86=AB?= =?UTF-8?q?=E1=84=92=E1=85=A1?= Date: Mon, 21 Apr 2025 10:52:05 +0900 Subject: [PATCH 03/13] =?UTF-8?q?add:=20getPackageHistory=EC=97=90=20cache?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/storage/d1.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/server/src/storage/d1.ts b/apps/server/src/storage/d1.ts index 703f5e4..b3dd243 100644 --- a/apps/server/src/storage/d1.ts +++ b/apps/server/src/storage/d1.ts @@ -18,14 +18,20 @@ import type { import { generateKey } from "../utils/security"; import { BlobStorageProvider } from "./blob"; import { type StorageProvider, createStorageError } from "./storage"; +import { type CacheProvider } from "./cache"; export class D1StorageProvider implements StorageProvider { private readonly db: DrizzleD1Database; private readonly blob: BlobStorageProvider; + private readonly cache: CacheProvider; - constructor(private readonly ctx: Context) { + constructor( + private readonly ctx: Context, + cacheProvider: CacheProvider, + ) { this.db = drizzle(ctx.env.DB, { schema }); this.blob = new BlobStorageProvider(ctx); + this.cache = cacheProvider; } // Helper methods @@ -789,6 +795,12 @@ export class D1StorageProvider implements StorageProvider { appId: string, deploymentId: string, ): Promise { + const cacheKey = `package:history:${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 +809,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( From 1dd88903d6b625ed7e4745b502a9b8eaa3e876f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=87=E1=85=A1=E1=86=A8=E1=84=8C=E1=85=AE=E1=86=AB?= =?UTF-8?q?=E1=84=92=E1=85=A1?= Date: Mon, 21 Apr 2025 11:36:02 +0900 Subject: [PATCH 04/13] =?UTF-8?q?add:=20cache=5Fkey=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EA=B4=80=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20package=20=EC=88=98=EC=A0=95=EB=90=98?= =?UTF-8?q?=EC=97=88=EC=9D=84=EB=95=8C=20invalidate=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/storage/d1.ts | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/server/src/storage/d1.ts b/apps/server/src/storage/d1.ts index b3dd243..b2b0c44 100644 --- a/apps/server/src/storage/d1.ts +++ b/apps/server/src/storage/d1.ts @@ -18,20 +18,26 @@ import type { import { generateKey } from "../utils/security"; import { BlobStorageProvider } from "./blob"; import { type StorageProvider, createStorageError } from "./storage"; -import { type CacheProvider } from "./cache"; +import type { CacheProvider } from "./cache"; export class D1StorageProvider implements StorageProvider { private readonly db: DrizzleD1Database; private readonly blob: BlobStorageProvider; private readonly cache: CacheProvider; + 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, cacheProvider: CacheProvider, ) { this.db = drizzle(ctx.env.DB, { schema }); - this.blob = new BlobStorageProvider(ctx); this.cache = cacheProvider; + this.blob = new BlobStorageProvider(ctx, cacheProvider); } // Helper methods @@ -553,6 +559,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), @@ -569,6 +581,7 @@ export class D1StorageProvider implements StorageProvider { if (!deployment) { throw createStorageError(ErrorCode.NotFound, "Deployment not found"); } + this.cache.set(cacheKey, JSON.stringify(deployment), 300); const returningDeployment: Deployment = { id: deployment.id, @@ -768,6 +781,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, @@ -786,7 +802,6 @@ export class D1StorageProvider implements StorageProvider { isDisabled: pkg.isDisabled, }) .where(eq(schema.packages.packageHash, pkg.packageHash)); - return pkg; } @@ -795,7 +810,7 @@ export class D1StorageProvider implements StorageProvider { appId: string, deploymentId: string, ): Promise { - const cacheKey = `package:history:${appId}:${deploymentId}`; + const cacheKey = this.cacheKeys.package(accountId, appId, deploymentId); const cachedPackages = await this.cache.get(cacheKey); if (cachedPackages) { return JSON.parse(cachedPackages); @@ -858,6 +873,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(); @@ -905,6 +923,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}`); } From 6eb62519975eb034230cd62f2f885deeb8acab6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=87=E1=85=A1=E1=86=A8=E1=84=8C=E1=85=AE=E1=86=AB?= =?UTF-8?q?=E1=84=92=E1=85=A1?= Date: Mon, 21 Apr 2025 11:36:19 +0900 Subject: [PATCH 05/13] =?UTF-8?q?add:=20blob=EC=97=90=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/storage/blob.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/server/src/storage/blob.ts b/apps/server/src/storage/blob.ts index 8d41581..9fba66d 100644 --- a/apps/server/src/storage/blob.ts +++ b/apps/server/src/storage/blob.ts @@ -3,17 +3,26 @@ import type { Context } from "hono"; import type { Env } from "../types/env"; import { ErrorCode, isStorageError } from "../types/error"; import { createStorageError } from "./storage"; +import type { CacheProvider } from "./cache"; export class 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}`, + }; + private readonly cache: CacheProvider; + + constructor( + private readonly ctx: Context, + private readonly cacheProvider: CacheProvider, + ) { this.storage = ctx.env.STORAGE_BUCKET; this.accountId = ctx.env.ACCOUNT_ID; this.bucketName = ctx.env.R2_BUCKET_NAME; + this.cache = cacheProvider; this.aws = new AwsClient({ accessKeyId: ctx.env.R2_ACCESS_KEY_ID, @@ -43,6 +52,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 +82,7 @@ export class BlobStorageProvider { }, ); + await this.cache.set(cacheKey, signed.url, 1800); return signed.url; } catch (error) { if (isStorageError(error) && error.code === ErrorCode.NotFound) { From b5ca284ca9d8c5af1fadc28a21bc69df958950c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=87=E1=85=A1=E1=86=A8=E1=84=8C=E1=85=AE=E1=86=AB?= =?UTF-8?q?=E1=84=92=E1=85=A1?= Date: Mon, 21 Apr 2025 11:41:46 +0900 Subject: [PATCH 06/13] =?UTF-8?q?add:=20cache=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/test/storage/cache.test.ts | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 apps/server/test/storage/cache.test.ts diff --git a/apps/server/test/storage/cache.test.ts b/apps/server/test/storage/cache.test.ts new file mode 100644 index 0000000..c673916 --- /dev/null +++ b/apps/server/test/storage/cache.test.ts @@ -0,0 +1,38 @@ +import { InMemoryCacheProvider } from "../../src/storage/cache"; +import { describe, it, expect } from "vitest"; +describe("Cache", () => { + it("should be able to set and get a value", async () => { + const cache = new InMemoryCacheProvider(); + await cache.set("test", "test"); + expect(await cache.get("test")).toBe("test"); + }); + + it("should be able to delete a value", async () => { + const cache = new InMemoryCacheProvider(); + 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 () => { + const cache = new InMemoryCacheProvider(); + 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 () => { + const cache = new InMemoryCacheProvider(); + await cache.set("test", "test", 0.01); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(await cache.get("test")).toBeNull(); + }); + + it("should be inable to get a value deleted", async () => { + const cache = new InMemoryCacheProvider(); + await cache.set("test", "test"); + await cache.del("test"); + expect(await cache.get("test")).toBeNull(); + }); +}); \ No newline at end of file From dcf769fffcee816c80b49550fb5ebfca1742ed15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=87=E1=85=A1=E1=86=A8=E1=84=8C=E1=85=AE=E1=86=AB?= =?UTF-8?q?=E1=84=92=E1=85=A1?= Date: Mon, 21 Apr 2025 12:01:03 +0900 Subject: [PATCH 07/13] =?UTF-8?q?fix:=20blob=20->=20d1=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/storage/blob.ts | 5 +---- apps/server/src/storage/d1.ts | 9 +++------ apps/server/src/storage/factory.ts | 16 +++++++++++++++- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/server/src/storage/blob.ts b/apps/server/src/storage/blob.ts index 9fba66d..06e0ba9 100644 --- a/apps/server/src/storage/blob.ts +++ b/apps/server/src/storage/blob.ts @@ -13,17 +13,14 @@ export class BlobStorageProvider { private readonly cacheKeys = { blobUrl: (path: string) => `blob-url:${path}`, }; - private readonly cache: CacheProvider; constructor( private readonly ctx: Context, - private readonly cacheProvider: CacheProvider, + private readonly cache: CacheProvider, ) { this.storage = ctx.env.STORAGE_BUCKET; this.accountId = ctx.env.ACCOUNT_ID; this.bucketName = ctx.env.R2_BUCKET_NAME; - this.cache = cacheProvider; - this.aws = new AwsClient({ accessKeyId: ctx.env.R2_ACCESS_KEY_ID, secretAccessKey: ctx.env.R2_SECRET_ACCESS_KEY, diff --git a/apps/server/src/storage/d1.ts b/apps/server/src/storage/d1.ts index b2b0c44..d474074 100644 --- a/apps/server/src/storage/d1.ts +++ b/apps/server/src/storage/d1.ts @@ -16,14 +16,12 @@ import type { PackageHashToBlobInfoMap, } from "../types/schemas"; import { generateKey } from "../utils/security"; -import { BlobStorageProvider } from "./blob"; +import type { BlobStorageProvider } from "./blob"; import { type StorageProvider, createStorageError } from "./storage"; import type { CacheProvider } from "./cache"; export class D1StorageProvider implements StorageProvider { private readonly db: DrizzleD1Database; - private readonly blob: BlobStorageProvider; - private readonly cache: CacheProvider; private readonly cacheKeys = { package: (accountId: string, appId: string, deploymentId: string) => `package:${accountId}:${appId}:${deploymentId}`, @@ -33,11 +31,10 @@ export class D1StorageProvider implements StorageProvider { constructor( private readonly ctx: Context, - cacheProvider: CacheProvider, + private readonly cache: CacheProvider, + private readonly blob: BlobStorageProvider, ) { this.db = drizzle(ctx.env.DB, { schema }); - this.cache = cacheProvider; - this.blob = new BlobStorageProvider(ctx, cacheProvider); } // Helper methods diff --git a/apps/server/src/storage/factory.ts b/apps/server/src/storage/factory.ts index 3dde4d8..f1683ce 100644 --- a/apps/server/src/storage/factory.ts +++ b/apps/server/src/storage/factory.ts @@ -3,15 +3,19 @@ import type { Env } from "../types/env"; import { D1StorageProvider } from "./d1"; import type { StorageProvider } from "./storage"; import { InMemoryCacheProvider, type CacheProvider } from "./cache"; +import { BlobStorageProvider } from "./blob"; 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, getCacheProvider(ctx)); + const cache = getCacheProvider(ctx); + const blob = getBlobProvider(ctx, cache); + storageInstance = new D1StorageProvider(ctx, cache, blob); lastContext = ctx; } return storageInstance; @@ -23,3 +27,13 @@ export function getCacheProvider(ctx: Context): CacheProvider { } return cacheInstance; } + +export function getBlobProvider( + ctx: Context, + cache: CacheProvider, +): BlobStorageProvider { + if (!blobInstance || ctx !== lastContext) { + blobInstance = new BlobStorageProvider(ctx, cache); + } + return blobInstance; +} From 54a8d9751dd313aa9bf00ceb4014eafdce570b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=87=E1=85=A1=E1=86=A8=E1=84=8C=E1=85=AE=E1=86=AB?= =?UTF-8?q?=E1=84=92=E1=85=A1?= Date: Mon, 21 Apr 2025 12:52:01 +0900 Subject: [PATCH 08/13] =?UTF-8?q?add:=20blob=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/storage/blob.ts | 10 +++++++++- apps/server/src/storage/factory.ts | 5 +++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/server/src/storage/blob.ts b/apps/server/src/storage/blob.ts index 06e0ba9..1d22306 100644 --- a/apps/server/src/storage/blob.ts +++ b/apps/server/src/storage/blob.ts @@ -5,7 +5,15 @@ import { ErrorCode, isStorageError } from "../types/error"; import { createStorageError } from "./storage"; import type { CacheProvider } from "./cache"; -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; diff --git a/apps/server/src/storage/factory.ts b/apps/server/src/storage/factory.ts index f1683ce..311a2d3 100644 --- a/apps/server/src/storage/factory.ts +++ b/apps/server/src/storage/factory.ts @@ -3,7 +3,8 @@ import type { Env } from "../types/env"; import { D1StorageProvider } from "./d1"; import type { StorageProvider } from "./storage"; import { InMemoryCacheProvider, type CacheProvider } from "./cache"; -import { BlobStorageProvider } from "./blob"; +import type { BlobStorageProvider } from "./blob"; +import { R2BlobStorageProvider } from "./blob"; let storageInstance: StorageProvider | null = null; let lastContext: Context | null = null; @@ -33,7 +34,7 @@ export function getBlobProvider( cache: CacheProvider, ): BlobStorageProvider { if (!blobInstance || ctx !== lastContext) { - blobInstance = new BlobStorageProvider(ctx, cache); + blobInstance = new R2BlobStorageProvider(ctx, cache); } return blobInstance; } From 60cbdd315cb4a1a741443092db7757ef97e9bc04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=87=E1=85=A1=E1=86=A8=E1=84=8C=E1=85=AE=E1=86=AB?= =?UTF-8?q?=E1=84=92=E1=85=A1?= Date: Mon, 21 Apr 2025 15:14:37 +0900 Subject: [PATCH 09/13] =?UTF-8?q?add:=20mock=20blob=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/test/storage/mock-blob.ts | 75 +++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 apps/server/test/storage/mock-blob.ts 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 From 9bd02249ba6a42a8d120b5c7fe5d57417ece8b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=87=E1=85=A1=E1=86=A8=E1=84=8C=E1=85=AE=E1=86=AB?= =?UTF-8?q?=E1=84=92=E1=85=A1?= Date: Mon, 21 Apr 2025 15:14:56 +0900 Subject: [PATCH 10/13] =?UTF-8?q?add:=20d1=20cache=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/test/storage/d1.test.ts | 156 ++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 apps/server/test/storage/d1.test.ts 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)); + }); +}); From 00c02ae3c305aab262ed39ecd72d8d2dd0b4c36c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=87=E1=85=A1=E1=86=A8=E1=84=8C=E1=85=AE=E1=86=AB?= =?UTF-8?q?=E1=84=92=E1=85=A1?= Date: Mon, 21 Apr 2025 15:16:01 +0900 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20=EB=B0=98=ED=99=98=20=EA=B0=92?= =?UTF-8?q?=EC=97=90=20cache=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/storage/d1.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/storage/d1.ts b/apps/server/src/storage/d1.ts index d474074..0f2d005 100644 --- a/apps/server/src/storage/d1.ts +++ b/apps/server/src/storage/d1.ts @@ -34,7 +34,7 @@ export class D1StorageProvider implements StorageProvider { private readonly cache: CacheProvider, private readonly blob: BlobStorageProvider, ) { - this.db = drizzle(ctx.env.DB, { schema }); + this.db = drizzle(this.ctx.env.DB, { schema }); } // Helper methods @@ -578,7 +578,7 @@ export class D1StorageProvider implements StorageProvider { if (!deployment) { throw createStorageError(ErrorCode.NotFound, "Deployment not found"); } - this.cache.set(cacheKey, JSON.stringify(deployment), 300); + const returningDeployment: Deployment = { id: deployment.id, @@ -604,6 +604,7 @@ export class D1StorageProvider implements StorageProvider { } satisfies Package; } + this.cache.set(cacheKey, JSON.stringify(returningDeployment), 300); return returningDeployment; } From 12adba2e9c59f0bc0799eb49d303df434c1e5215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=87=E1=85=A1=E1=86=A8=E1=84=8C=E1=85=AE=E1=86=AB?= =?UTF-8?q?=E1=84=92=E1=85=A1?= Date: Mon, 21 Apr 2025 15:16:09 +0900 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/test/storage/cache.test.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/server/test/storage/cache.test.ts b/apps/server/test/storage/cache.test.ts index c673916..7aaf935 100644 --- a/apps/server/test/storage/cache.test.ts +++ b/apps/server/test/storage/cache.test.ts @@ -1,21 +1,26 @@ import { InMemoryCacheProvider } from "../../src/storage/cache"; -import { describe, it, expect } from "vitest"; +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 () => { - const cache = new InMemoryCacheProvider(); await cache.set("test", "test"); expect(await cache.get("test")).toBe("test"); }); it("should be able to delete a value", async () => { - const cache = new InMemoryCacheProvider(); 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 () => { - const cache = new InMemoryCacheProvider(); await cache.set("test", "test", 1); expect(await cache.get("test")).toBe("test"); await new Promise((resolve) => setTimeout(resolve, 1500)); @@ -23,14 +28,12 @@ describe("Cache", () => { }); it("should be inable to get a value that has expired", async () => { - const cache = new InMemoryCacheProvider(); - await cache.set("test", "test", 0.01); - await new Promise((resolve) => setTimeout(resolve, 10)); + 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 () => { - const cache = new InMemoryCacheProvider(); await cache.set("test", "test"); await cache.del("test"); expect(await cache.get("test")).toBeNull(); From 52bf1ca37036ed1a2f8acf58125a70fed1c311f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=87=E1=85=A1=E1=86=A8=E1=84=8C=E1=85=AE=E1=86=AB?= =?UTF-8?q?=E1=84=92=E1=85=A1?= Date: Mon, 21 Apr 2025 15:28:23 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix:=20biome=20check=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/storage/blob.ts | 2 +- apps/server/src/storage/d1.ts | 3 +-- apps/server/src/storage/factory.ts | 6 +++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/server/src/storage/blob.ts b/apps/server/src/storage/blob.ts index 1d22306..05a3810 100644 --- a/apps/server/src/storage/blob.ts +++ b/apps/server/src/storage/blob.ts @@ -2,8 +2,8 @@ import { AwsClient } from "aws4fetch"; import type { Context } from "hono"; import type { Env } from "../types/env"; import { ErrorCode, isStorageError } from "../types/error"; -import { createStorageError } from "./storage"; import type { CacheProvider } from "./cache"; +import { createStorageError } from "./storage"; export interface BlobStorageProvider { addBlob(blobId: string, data: ArrayBuffer, size: number): Promise; diff --git a/apps/server/src/storage/d1.ts b/apps/server/src/storage/d1.ts index 0f2d005..adc895d 100644 --- a/apps/server/src/storage/d1.ts +++ b/apps/server/src/storage/d1.ts @@ -17,8 +17,8 @@ import type { } from "../types/schemas"; import { generateKey } from "../utils/security"; import type { BlobStorageProvider } from "./blob"; -import { type StorageProvider, createStorageError } from "./storage"; import type { CacheProvider } from "./cache"; +import { type StorageProvider, createStorageError } from "./storage"; export class D1StorageProvider implements StorageProvider { private readonly db: DrizzleD1Database; @@ -578,7 +578,6 @@ export class D1StorageProvider implements StorageProvider { if (!deployment) { throw createStorageError(ErrorCode.NotFound, "Deployment not found"); } - const returningDeployment: Deployment = { id: deployment.id, diff --git a/apps/server/src/storage/factory.ts b/apps/server/src/storage/factory.ts index 311a2d3..655fe30 100644 --- a/apps/server/src/storage/factory.ts +++ b/apps/server/src/storage/factory.ts @@ -1,10 +1,10 @@ import type { Context } from "hono"; import type { Env } from "../types/env"; -import { D1StorageProvider } from "./d1"; -import type { StorageProvider } from "./storage"; -import { InMemoryCacheProvider, type CacheProvider } from "./cache"; 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;