From 9e3c081e5063cb6f24f4479eaed18c82b543178b Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 27 Mar 2025 20:41:42 +0100 Subject: [PATCH] reorganized storage adapter and added test suites for adapter and fields (#124) * reorganized storage adapter and added test suites for adapter and fields * added build command in ci pipeline * updated workflow to also run node tests * updated workflow: try with separate tasks * updated workflow: try with separate tasks * updated workflow: added tsx as dev dependency * updated workflow: try with find instead of glob --- .github/workflows/test.yml | 12 +- app/__test__/app/repro.spec.ts | 2 +- .../auth/strategies/OAuthStrategy.spec.ts | 7 +- .../cache/CloudflareKvCache.native-spec.ts | 57 ------ app/__test__/core/cache/MemoryCache.spec.ts | 15 -- app/__test__/core/cache/cache-test-suite.ts | 84 --------- .../data/specs/fields/BooleanField.spec.ts | 4 +- .../data/specs/fields/DateField.spec.ts | 4 +- .../data/specs/fields/EnumField.spec.ts | 9 +- app/__test__/data/specs/fields/Field.spec.ts | 4 +- .../data/specs/fields/FieldIndex.spec.ts | 10 +- .../data/specs/fields/JsonField.spec.ts | 4 +- .../data/specs/fields/JsonSchemaField.spec.ts | 5 +- .../data/specs/fields/NumberField.spec.ts | 4 +- .../data/specs/fields/TextField.spec.ts | 4 +- app/__test__/media/MediaController.spec.ts | 2 +- .../media/StorageR2Adapter.native-spec.ts | 26 ++- .../adapters/StorageCloudinaryAdapter.spec.ts | 63 ------- .../adapters/StorageLocalAdapter.spec.ts | 47 ----- .../media/adapters/StorageS3Adapter.spec.ts | 109 ----------- app/__test__/modules/AppMedia.spec.ts | 2 +- app/build.ts | 10 +- app/package.json | 10 + .../adapter/cloudflare/StorageR2Adapter.ts | 9 +- app/src/adapter/node/index.ts | 6 +- .../StorageLocalAdapter.native-spec.ts | 17 ++ .../node/storage/StorageLocalAdapter.spec.ts | 14 ++ .../node/storage}/StorageLocalAdapter.ts | 15 +- app/src/adapter/node/test.ts | 75 ++++++++ .../core/cache/adapters/CloudflareKvCache.ts | 127 ------------- app/src/core/cache/adapters/MemoryCache.ts | 139 -------------- app/src/core/cache/cache-interface.ts | 178 ------------------ app/src/core/test/index.ts | 48 +++++ .../data/fields/field-test-suite.ts} | 12 +- app/src/data/fields/index.ts | 2 + app/src/media/index.ts | 14 +- app/src/media/storage/Storage.ts | 21 +-- app/src/media/storage/StorageAdapter.ts | 37 ++++ .../adapters/StorageLocalAdapter/index.ts | 5 - .../storage/adapters/adapter-test-suite.ts | 79 ++++++++ .../StorageCloudinaryAdapter.spec.ts | 53 ++++++ .../StorageCloudinaryAdapter.ts | 63 ++++++- .../adapters/s3/StorageS3Adapter.spec.ts | 50 +++++ .../adapters/{ => s3}/StorageS3Adapter.ts | 23 ++- bun.lock | 64 ++++++- 45 files changed, 605 insertions(+), 940 deletions(-) delete mode 100644 app/__test__/core/cache/CloudflareKvCache.native-spec.ts delete mode 100644 app/__test__/core/cache/MemoryCache.spec.ts delete mode 100644 app/__test__/core/cache/cache-test-suite.ts delete mode 100644 app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts delete mode 100644 app/__test__/media/adapters/StorageLocalAdapter.spec.ts delete mode 100644 app/__test__/media/adapters/StorageS3Adapter.spec.ts create mode 100644 app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts create mode 100644 app/src/adapter/node/storage/StorageLocalAdapter.spec.ts rename app/src/{media/storage/adapters/StorageLocalAdapter => adapter/node/storage}/StorageLocalAdapter.ts (91%) create mode 100644 app/src/adapter/node/test.ts delete mode 100644 app/src/core/cache/adapters/CloudflareKvCache.ts delete mode 100644 app/src/core/cache/adapters/MemoryCache.ts delete mode 100644 app/src/core/cache/cache-interface.ts create mode 100644 app/src/core/test/index.ts rename app/{__test__/data/specs/fields/inc.ts => src/data/fields/field-test-suite.ts} (95%) create mode 100644 app/src/media/storage/StorageAdapter.ts delete mode 100644 app/src/media/storage/adapters/StorageLocalAdapter/index.ts create mode 100644 app/src/media/storage/adapters/adapter-test-suite.ts create mode 100644 app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.spec.ts rename app/src/media/storage/adapters/{ => cloudinary}/StorageCloudinaryAdapter.ts (72%) create mode 100644 app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts rename app/src/media/storage/adapters/{ => s3}/StorageS3Adapter.ts (88%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9f032d0..1030c9a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,6 +21,14 @@ jobs: working-directory: ./app run: bun install - - name: Run tests + - name: Build working-directory: ./app - run: bun run test \ No newline at end of file + run: bun run build:ci + + - name: Run Bun tests + working-directory: ./app + run: bun run test:bun + + - name: Run Node tests + working-directory: ./app + run: npm run test:node \ No newline at end of file diff --git a/app/__test__/app/repro.spec.ts b/app/__test__/app/repro.spec.ts index 7b69376..b27aa51 100644 --- a/app/__test__/app/repro.spec.ts +++ b/app/__test__/app/repro.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createApp, registries } from "../../src"; import * as proto from "../../src/data/prototype"; -import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter"; +import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; describe("repros", async () => { /** diff --git a/app/__test__/auth/strategies/OAuthStrategy.spec.ts b/app/__test__/auth/strategies/OAuthStrategy.spec.ts index 93ceae0..becd783 100644 --- a/app/__test__/auth/strategies/OAuthStrategy.spec.ts +++ b/app/__test__/auth/strategies/OAuthStrategy.spec.ts @@ -3,8 +3,10 @@ import { OAuthStrategy } from "../../../src/auth/authenticate/strategies"; const ALL_TESTS = !!process.env.ALL_TESTS; +// @todo: add mock response describe("OAuthStrategy", async () => { - const strategy = new OAuthStrategy({ + return; + /*const strategy = new OAuthStrategy({ type: "oidc", client: { client_id: process.env.OAUTH_CLIENT_ID!, @@ -21,6 +23,7 @@ describe("OAuthStrategy", async () => { const server = Bun.serve({ fetch: async (req) => { + console.log("req", req.method, req.url); const url = new URL(req.url); if (url.pathname === "/auth/google/callback") { console.log("req", req); @@ -42,5 +45,5 @@ describe("OAuthStrategy", async () => { console.log("request", request); await new Promise((resolve) => setTimeout(resolve, 100000)); - }); + });*/ }); diff --git a/app/__test__/core/cache/CloudflareKvCache.native-spec.ts b/app/__test__/core/cache/CloudflareKvCache.native-spec.ts deleted file mode 100644 index d5f0812..0000000 --- a/app/__test__/core/cache/CloudflareKvCache.native-spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import * as assert from "node:assert/strict"; -import { createWriteStream } from "node:fs"; -import { after, beforeEach, describe, test } from "node:test"; -import { Miniflare } from "miniflare"; -import { - CloudflareKVCacheItem, - CloudflareKVCachePool, -} from "../../../src/core/cache/adapters/CloudflareKvCache"; -import { runTests } from "./cache-test-suite"; - -// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480 -console.log = async (message: any) => { - const tty = createWriteStream("/dev/tty"); - const msg = typeof message === "string" ? message : JSON.stringify(message, null, 2); - return tty.write(`${msg}\n`); -}; - -describe("CloudflareKv", async () => { - let mf: Miniflare; - runTests({ - createCache: async () => { - if (mf) { - await mf.dispose(); - } - - mf = new Miniflare({ - modules: true, - script: "export default { async fetch() { return new Response(null); } }", - kvNamespaces: ["TEST"], - }); - const kv = await mf.getKVNamespace("TEST"); - return new CloudflareKVCachePool(kv as any); - }, - createItem: (key, value) => new CloudflareKVCacheItem(key, value), - tester: { - test, - beforeEach, - expect: (actual?: any) => { - return { - toBe(expected: any) { - assert.equal(actual, expected); - }, - toEqual(expected: any) { - assert.deepEqual(actual, expected); - }, - toBeUndefined() { - assert.equal(actual, undefined); - }, - }; - }, - }, - }); - - after(async () => { - await mf?.dispose(); - }); -}); diff --git a/app/__test__/core/cache/MemoryCache.spec.ts b/app/__test__/core/cache/MemoryCache.spec.ts deleted file mode 100644 index d78a5d1..0000000 --- a/app/__test__/core/cache/MemoryCache.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test"; -import { MemoryCache, MemoryCacheItem } from "../../../src/core/cache/adapters/MemoryCache"; -import { runTests } from "./cache-test-suite"; - -describe("MemoryCache", () => { - runTests({ - createCache: async () => new MemoryCache(), - createItem: (key, value) => new MemoryCacheItem(key, value), - tester: { - test, - beforeEach, - expect, - }, - }); -}); diff --git a/app/__test__/core/cache/cache-test-suite.ts b/app/__test__/core/cache/cache-test-suite.ts deleted file mode 100644 index 251dfde..0000000 --- a/app/__test__/core/cache/cache-test-suite.ts +++ /dev/null @@ -1,84 +0,0 @@ -//import { beforeEach as bunBeforeEach, expect as bunExpect, test as bunTest } from "bun:test"; -import type { ICacheItem, ICachePool } from "../../../src/core/cache/cache-interface"; - -export type TestOptions = { - createCache: () => Promise; - createItem: (key: string, value: any) => ICacheItem; - tester: { - test: (name: string, fn: () => Promise) => void; - beforeEach: (fn: () => Promise) => void; - expect: (actual?: any) => { - toBe(expected: any): void; - toEqual(expected: any): void; - toBeUndefined(): void; - }; - }; -}; - -export function runTests({ createCache, createItem, tester }: TestOptions) { - let cache: ICachePool; - const { test, beforeEach, expect } = tester; - - beforeEach(async () => { - cache = await createCache(); - }); - - test("getItem returns correct item", async () => { - const item = createItem("key1", "value1"); - await cache.save(item); - const retrievedItem = await cache.get("key1"); - expect(retrievedItem.value()).toEqual(item.value()); - }); - - test("getItem returns new item when key does not exist", async () => { - const retrievedItem = await cache.get("key1"); - expect(retrievedItem.key()).toEqual("key1"); - expect(retrievedItem.value()).toBeUndefined(); - }); - - test("getItems returns correct items", async () => { - const item1 = createItem("key1", "value1"); - const item2 = createItem("key2", "value2"); - await cache.save(item1); - await cache.save(item2); - const retrievedItems = await cache.getMany(["key1", "key2"]); - expect(retrievedItems.get("key1")?.value()).toEqual(item1.value()); - expect(retrievedItems.get("key2")?.value()).toEqual(item2.value()); - }); - - test("hasItem returns true when item exists and is a hit", async () => { - const item = createItem("key1", "value1"); - await cache.save(item); - expect(await cache.has("key1")).toBe(true); - }); - - test("clear and deleteItem correctly clear the cache and delete items", async () => { - const item = createItem("key1", "value1"); - await cache.save(item); - - if (cache.supports().clear) { - await cache.clear(); - } else { - await cache.delete("key1"); - } - - expect(await cache.has("key1")).toBe(false); - }); - - test("save correctly saves items to the cache", async () => { - const item = createItem("key1", "value1"); - await cache.save(item); - expect(await cache.has("key1")).toBe(true); - }); - - test("putItem correctly puts items in the cache ", async () => { - await cache.put("key1", "value1", { ttl: 60 }); - const item = await cache.get("key1"); - expect(item.value()).toEqual("value1"); - expect(item.hit()).toBe(true); - }); - - /*test("commit returns true", async () => { - expect(await cache.commit()).toBe(true); - });*/ -} diff --git a/app/__test__/data/specs/fields/BooleanField.spec.ts b/app/__test__/data/specs/fields/BooleanField.spec.ts index 7ed5036..a061e1f 100644 --- a/app/__test__/data/specs/fields/BooleanField.spec.ts +++ b/app/__test__/data/specs/fields/BooleanField.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; import { BooleanField } from "../../../../src/data"; -import { runBaseFieldTests, transformPersist } from "./inc"; +import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite"; describe("[data] BooleanField", async () => { - runBaseFieldTests(BooleanField, { defaultValue: true, schemaType: "boolean" }); + fieldTestSuite({ expect, test }, BooleanField, { defaultValue: true, schemaType: "boolean" }); test("transformRetrieve", async () => { const field = new BooleanField("test"); diff --git a/app/__test__/data/specs/fields/DateField.spec.ts b/app/__test__/data/specs/fields/DateField.spec.ts index 3e29bf0..d578843 100644 --- a/app/__test__/data/specs/fields/DateField.spec.ts +++ b/app/__test__/data/specs/fields/DateField.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; import { DateField } from "../../../../src/data"; -import { runBaseFieldTests } from "./inc"; +import { fieldTestSuite } from "data/fields/field-test-suite"; describe("[data] DateField", async () => { - runBaseFieldTests(DateField, { defaultValue: new Date(), schemaType: "date" }); + fieldTestSuite({ expect, test }, DateField, { defaultValue: new Date(), schemaType: "date" }); // @todo: add datefield tests test("week", async () => { diff --git a/app/__test__/data/specs/fields/EnumField.spec.ts b/app/__test__/data/specs/fields/EnumField.spec.ts index 2187bee..066dd88 100644 --- a/app/__test__/data/specs/fields/EnumField.spec.ts +++ b/app/__test__/data/specs/fields/EnumField.spec.ts @@ -1,13 +1,15 @@ import { describe, expect, test } from "bun:test"; import { EnumField } from "../../../../src/data"; -import { runBaseFieldTests, transformPersist } from "./inc"; +import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite"; function options(strings: string[]) { return { type: "strings", values: strings }; } describe("[data] EnumField", async () => { - runBaseFieldTests( + fieldTestSuite( + { expect, test }, + // @ts-ignore EnumField, { defaultValue: "a", schemaType: "text" }, { options: options(["a", "b", "c"]) }, @@ -15,11 +17,13 @@ describe("[data] EnumField", async () => { test("yields if default value is not a valid option", async () => { expect( + // @ts-ignore () => new EnumField("test", { options: options(["a", "b"]), default_value: "c" }), ).toThrow(); }); test("transformPersist (config)", async () => { + // @ts-ignore const field = new EnumField("test", { options: options(["a", "b", "c"]) }); expect(transformPersist(field, null)).resolves.toBeUndefined(); @@ -29,6 +33,7 @@ describe("[data] EnumField", async () => { test("transformRetrieve", async () => { const field = new EnumField("test", { + // @ts-ignore options: options(["a", "b", "c"]), default_value: "a", required: true, diff --git a/app/__test__/data/specs/fields/Field.spec.ts b/app/__test__/data/specs/fields/Field.spec.ts index 82ba9de..d5fec44 100644 --- a/app/__test__/data/specs/fields/Field.spec.ts +++ b/app/__test__/data/specs/fields/Field.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { Default, stripMark } from "../../../../src/core/utils"; import { baseFieldConfigSchema, Field } from "../../../../src/data/fields/Field"; -import { runBaseFieldTests } from "./inc"; +import { fieldTestSuite } from "data/fields/field-test-suite"; describe("[data] Field", async () => { class FieldSpec extends Field { @@ -19,7 +19,7 @@ describe("[data] Field", async () => { }); }); - runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" }); + fieldTestSuite({ expect, test }, FieldSpec, { defaultValue: "test", schemaType: "text" }); test("default config", async () => { const config = Default(baseFieldConfigSchema, {}); diff --git a/app/__test__/data/specs/fields/FieldIndex.spec.ts b/app/__test__/data/specs/fields/FieldIndex.spec.ts index 8f1590c..0dd656c 100644 --- a/app/__test__/data/specs/fields/FieldIndex.spec.ts +++ b/app/__test__/data/specs/fields/FieldIndex.spec.ts @@ -1,19 +1,13 @@ import { describe, expect, test } from "bun:test"; import { Type } from "../../../../src/core/utils"; -import { - Entity, - EntityIndex, - type EntityManager, - Field, - type SchemaResponse, -} from "../../../../src/data"; +import { Entity, EntityIndex, Field } from "../../../../src/data"; class TestField extends Field { protected getSchema(): any { return Type.Any(); } - schema(em: EntityManager): SchemaResponse { + override schema() { return undefined as any; } } diff --git a/app/__test__/data/specs/fields/JsonField.spec.ts b/app/__test__/data/specs/fields/JsonField.spec.ts index dff15a1..0bc0d3b 100644 --- a/app/__test__/data/specs/fields/JsonField.spec.ts +++ b/app/__test__/data/specs/fields/JsonField.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from "bun:test"; import { JsonField } from "../../../../src/data"; -import { runBaseFieldTests, transformPersist } from "./inc"; +import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite"; describe("[data] JsonField", async () => { const field = new JsonField("test"); - runBaseFieldTests(JsonField, { + fieldTestSuite({ expect, test }, JsonField, { defaultValue: { a: 1 }, sampleValues: ["string", { test: 1 }, 1], schemaType: "text", diff --git a/app/__test__/data/specs/fields/JsonSchemaField.spec.ts b/app/__test__/data/specs/fields/JsonSchemaField.spec.ts index f9f2f54..7770098 100644 --- a/app/__test__/data/specs/fields/JsonSchemaField.spec.ts +++ b/app/__test__/data/specs/fields/JsonSchemaField.spec.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from "bun:test"; import { JsonSchemaField } from "../../../../src/data"; -import { runBaseFieldTests } from "./inc"; +import { fieldTestSuite } from "data/fields/field-test-suite"; describe("[data] JsonSchemaField", async () => { - runBaseFieldTests(JsonSchemaField, { defaultValue: {}, schemaType: "text" }); + // @ts-ignore + fieldTestSuite({ expect, test }, JsonSchemaField, { defaultValue: {}, schemaType: "text" }); // @todo: add JsonSchemaField tests }); diff --git a/app/__test__/data/specs/fields/NumberField.spec.ts b/app/__test__/data/specs/fields/NumberField.spec.ts index 6708449..e46c075 100644 --- a/app/__test__/data/specs/fields/NumberField.spec.ts +++ b/app/__test__/data/specs/fields/NumberField.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { NumberField } from "../../../../src/data"; -import { runBaseFieldTests, transformPersist } from "./inc"; +import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite"; describe("[data] NumberField", async () => { test("transformPersist (config)", async () => { @@ -15,5 +15,5 @@ describe("[data] NumberField", async () => { expect(transformPersist(field2, 10000)).resolves.toBe(10000); }); - runBaseFieldTests(NumberField, { defaultValue: 12, schemaType: "integer" }); + fieldTestSuite({ expect, test }, NumberField, { defaultValue: 12, schemaType: "integer" }); }); diff --git a/app/__test__/data/specs/fields/TextField.spec.ts b/app/__test__/data/specs/fields/TextField.spec.ts index fe83767..47d1bc3 100644 --- a/app/__test__/data/specs/fields/TextField.spec.ts +++ b/app/__test__/data/specs/fields/TextField.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { TextField } from "../../../../src/data"; -import { runBaseFieldTests, transformPersist } from "./inc"; +import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite"; describe("[data] TextField", async () => { test("transformPersist (config)", async () => { @@ -11,5 +11,5 @@ describe("[data] TextField", async () => { expect(transformPersist(field, "abc")).resolves.toBe("abc"); }); - runBaseFieldTests(TextField, { defaultValue: "abc", schemaType: "text" }); + fieldTestSuite({ expect, test }, TextField, { defaultValue: "abc", schemaType: "text" }); }); diff --git a/app/__test__/media/MediaController.spec.ts b/app/__test__/media/MediaController.spec.ts index 3584317..f55591b 100644 --- a/app/__test__/media/MediaController.spec.ts +++ b/app/__test__/media/MediaController.spec.ts @@ -4,7 +4,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { createApp, registries } from "../../src"; import { mergeObject, randomString } from "../../src/core/utils"; import type { TAppMediaConfig } from "../../src/media/media-schema"; -import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter"; +import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper"; beforeAll(() => { diff --git a/app/__test__/media/StorageR2Adapter.native-spec.ts b/app/__test__/media/StorageR2Adapter.native-spec.ts index 64c7a9f..4e3b95b 100644 --- a/app/__test__/media/StorageR2Adapter.native-spec.ts +++ b/app/__test__/media/StorageR2Adapter.native-spec.ts @@ -1,7 +1,10 @@ -import * as assert from "node:assert/strict"; -import { createWriteStream } from "node:fs"; +import { createWriteStream, readFileSync } from "node:fs"; import { test } from "node:test"; import { Miniflare } from "miniflare"; +import { StorageR2Adapter } from "adapter/cloudflare/StorageR2Adapter"; +import { adapterTestSuite } from "media"; +import { nodeTestRunner } from "adapter/node"; +import path from "node:path"; // https://github.com/nodejs/node/issues/44372#issuecomment-1736530480 console.log = async (message: any) => { @@ -10,25 +13,20 @@ console.log = async (message: any) => { return tty.write(`${msg}\n`); }; -test("what", async () => { +test("StorageR2Adapter", async () => { const mf = new Miniflare({ modules: true, script: "export default { async fetch() { return new Response(null); } }", r2Buckets: ["BUCKET"], }); - const bucket = await mf.getR2Bucket("BUCKET"); - console.log(await bucket.put("count", "1")); + const bucket = (await mf.getR2Bucket("BUCKET")) as unknown as R2Bucket; + const adapter = new StorageR2Adapter(bucket); - const object = await bucket.get("count"); - if (object) { - /*const headers = new Headers(); - object.writeHttpMetadata(headers); - headers.set("etag", object.httpEtag);*/ - console.log("yo -->", await object.text()); - - assert.strictEqual(await object.text(), "1"); - } + const basePath = path.resolve(import.meta.dirname, "../_assets"); + const buffer = readFileSync(path.join(basePath, "image.png")); + const file = new File([buffer], "image.png", { type: "image/png" }); + await adapterTestSuite(nodeTestRunner, adapter, file); await mf.dispose(); }); diff --git a/app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts b/app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts deleted file mode 100644 index 9cac2e4..0000000 --- a/app/__test__/media/adapters/StorageCloudinaryAdapter.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { randomString } from "../../../src/core/utils"; -import { StorageCloudinaryAdapter } from "../../../src/media"; - -import { config } from "dotenv"; -const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` }); -const { - CLOUDINARY_CLOUD_NAME, - CLOUDINARY_API_KEY, - CLOUDINARY_API_SECRET, - CLOUDINARY_UPLOAD_PRESET, -} = dotenvOutput.parsed!; - -const ALL_TESTS = !!process.env.ALL_TESTS; - -describe.skipIf(ALL_TESTS)("StorageCloudinaryAdapter", () => { - if (ALL_TESTS) return; - - const adapter = new StorageCloudinaryAdapter({ - cloud_name: CLOUDINARY_CLOUD_NAME as string, - api_key: CLOUDINARY_API_KEY as string, - api_secret: CLOUDINARY_API_SECRET as string, - upload_preset: CLOUDINARY_UPLOAD_PRESET as string, - }); - - const file = Bun.file(`${import.meta.dir}/icon.png`); - const _filename = randomString(10); - const filename = `${_filename}.png`; - - test("object exists", async () => { - expect(await adapter.objectExists("7fCTBi6L8c.png")).toBeTrue(); - process.exit(); - }); - - test("puts object", async () => { - expect(await adapter.objectExists(filename)).toBeFalse(); - - const result = await adapter.putObject(filename, file); - console.log("result", result); - expect(result).toBeDefined(); - expect(result?.name).toBe(filename); - }); - - test("object exists", async () => { - await Bun.sleep(10000); - const one = await adapter.objectExists(_filename); - const two = await adapter.objectExists(filename); - expect(await adapter.objectExists(filename)).toBeTrue(); - }); - - test("object meta", async () => { - const result = await adapter.getObjectMeta(filename); - console.log("objectMeta:result", result); - expect(result).toBeDefined(); - expect(result.type).toBe("image/png"); - expect(result.size).toBeGreaterThan(0); - }); - - test("list objects", async () => { - const result = await adapter.listObjects(); - console.log("listObjects:result", result); - }); -}); diff --git a/app/__test__/media/adapters/StorageLocalAdapter.spec.ts b/app/__test__/media/adapters/StorageLocalAdapter.spec.ts deleted file mode 100644 index b23f84d..0000000 --- a/app/__test__/media/adapters/StorageLocalAdapter.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { randomString } from "../../../src/core/utils"; -import { StorageLocalAdapter } from "../../../src/media/storage/adapters/StorageLocalAdapter"; -import { assetsPath, assetsTmpPath } from "../../helper"; - -describe("StorageLocalAdapter", () => { - const adapter = new StorageLocalAdapter({ - path: assetsTmpPath, - }); - - const file = Bun.file(`${assetsPath}/image.png`); - const _filename = randomString(10); - const filename = `${_filename}.png`; - - let objects = 0; - - test("puts an object", async () => { - objects = (await adapter.listObjects()).length; - expect(await adapter.putObject(filename, file as unknown as File)).toBeString(); - }); - - test("lists objects", async () => { - expect((await adapter.listObjects()).length).toBe(objects + 1); - }); - - test("file exists", async () => { - expect(await adapter.objectExists(filename)).toBeTrue(); - }); - - test("gets an object", async () => { - const res = await adapter.getObject(filename, new Headers()); - expect(res.ok).toBeTrue(); - // @todo: check the content - }); - - test("gets object meta", async () => { - expect(await adapter.getObjectMeta(filename)).toEqual({ - type: file.type, // image/png - size: file.size, - }); - }); - - test("deletes an object", async () => { - expect(await adapter.deleteObject(filename)).toBeUndefined(); - expect(await adapter.objectExists(filename)).toBeFalse(); - }); -}); diff --git a/app/__test__/media/adapters/StorageS3Adapter.spec.ts b/app/__test__/media/adapters/StorageS3Adapter.spec.ts deleted file mode 100644 index 7b4a0a4..0000000 --- a/app/__test__/media/adapters/StorageS3Adapter.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { randomString } from "../../../src/core/utils"; -import { StorageS3Adapter } from "../../../src/media"; - -import { config } from "dotenv"; -//import { enableFetchLogging } from "../../helper"; -const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` }); -const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } = - dotenvOutput.parsed!; - -// @todo: mock r2/s3 responses for faster tests -const ALL_TESTS = !!process.env.ALL_TESTS; -console.log("ALL_TESTS?", ALL_TESTS); - -/* -// @todo: preparation to mock s3 calls + replace fast-xml-parser -let cleanup: () => void; -beforeAll(async () => { - cleanup = await enableFetchLogging(); -}); -afterAll(() => { - cleanup(); -}); */ - -describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => { - if (ALL_TESTS) return; - - const versions = [ - [ - "r2", - new StorageS3Adapter({ - access_key: R2_ACCESS_KEY as string, - secret_access_key: R2_SECRET_ACCESS_KEY as string, - url: R2_URL as string, - }), - ], - [ - "s3", - new StorageS3Adapter({ - access_key: AWS_ACCESS_KEY as string, - secret_access_key: AWS_SECRET_KEY as string, - url: AWS_S3_URL as string, - }), - ], - ] as const; - - const _conf = { - adapters: ["r2", "s3"], - tests: [ - "listObjects", - "putObject", - "objectExists", - "getObject", - "deleteObject", - "getObjectMeta", - ], - }; - - const file = Bun.file(`${import.meta.dir}/icon.png`); - const filename = `${randomString(10)}.png`; - - // single (dev) - //_conf = { adapters: [/*"r2",*/ "s3"], tests: [/*"putObject",*/ "listObjects"] }; - - function disabled(test: (typeof _conf.tests)[number]) { - return !_conf.tests.includes(test); - } - - // @todo: add mocked fetch for faster tests - describe.each(versions)("StorageS3Adapter for %s", async (name, adapter) => { - if (!_conf.adapters.includes(name) || ALL_TESTS) { - console.log("Skipping", name); - return; - } - - let objects = 0; - - test.skipIf(disabled("putObject"))("puts an object", async () => { - objects = (await adapter.listObjects()).length; - expect(await adapter.putObject(filename, file as any)).toBeString(); - }); - - test.skipIf(disabled("listObjects"))("lists objects", async () => { - expect((await adapter.listObjects()).length).toBe(objects + 1); - }); - - test.skipIf(disabled("objectExists"))("file exists", async () => { - expect(await adapter.objectExists(filename)).toBeTrue(); - }); - - test.skipIf(disabled("getObject"))("gets an object", async () => { - const res = await adapter.getObject(filename, new Headers()); - expect(res.ok).toBeTrue(); - // @todo: check the content - }); - - test.skipIf(disabled("getObjectMeta"))("gets object meta", async () => { - expect(await adapter.getObjectMeta(filename)).toEqual({ - type: file.type, // image/png - size: file.size, - }); - }); - - test.skipIf(disabled("deleteObject"))("deletes an object", async () => { - expect(await adapter.deleteObject(filename)).toBeUndefined(); - expect(await adapter.objectExists(filename)).toBeFalse(); - }); - }); -}); diff --git a/app/__test__/modules/AppMedia.spec.ts b/app/__test__/modules/AppMedia.spec.ts index 1423fd6..8e6b5b2 100644 --- a/app/__test__/modules/AppMedia.spec.ts +++ b/app/__test__/modules/AppMedia.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createApp, registries } from "../../src"; import { em, entity, text } from "../../src/data"; -import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter"; +import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { AppMedia } from "../../src/modules"; import { moduleTestSuite } from "./module-test-suite"; diff --git a/app/build.ts b/app/build.ts index 1720b2c..56dc1dc 100644 --- a/app/build.ts +++ b/app/build.ts @@ -54,7 +54,7 @@ function banner(title: string) { } // collection of always-external packages -const external = ["bun:test", "@libsql/client"] as const; +const external = ["bun:test", "node:test", "node:assert/strict", "@libsql/client"] as const; /** * Building backend and general API @@ -65,7 +65,13 @@ async function buildApi() { minify, sourcemap, watch, - entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"], + entry: [ + "src/index.ts", + "src/core/index.ts", + "src/core/utils/index.ts", + "src/data/index.ts", + "src/media/index.ts", + ], outDir: "dist", external: [...external], metafile: true, diff --git a/app/package.json b/app/package.json index 3c517a0..2f26ad3 100644 --- a/app/package.json +++ b/app/package.json @@ -16,9 +16,13 @@ "scripts": { "dev": "vite", "test": "ALL_TESTS=1 bun test --bail", + "test:all": "bun run test && bun run test:node", + "test:bun": "ALL_TESTS=1 bun test --bail", + "test:node": "tsx --test $(find . -type f -name '*.native-spec.ts')", "test:coverage": "ALL_TESTS=1 bun test --bail --coverage", "build": "NODE_ENV=production bun run build.ts --minify --types", "build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli", + "build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts", "build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify", "build:static": "vite build", "watch": "bun run build.ts --types --watch", @@ -101,6 +105,7 @@ "tailwindcss-animate": "^1.0.7", "tsc-alias": "^1.8.11", "tsup": "^8.4.0", + "tsx": "^4.19.3", "vite": "^6.2.1", "vite-tsconfig-paths": "^5.1.4", "wouter": "^3.6.0" @@ -156,6 +161,11 @@ "import": "./dist/cli/index.js", "require": "./dist/cli/index.cjs" }, + "./media": { + "types": "./dist/types/media/index.d.ts", + "import": "./dist/media/index.js", + "require": "./dist/media/index.cjs" + }, "./adapter/cloudflare": { "types": "./dist/types/adapter/cloudflare/index.d.ts", "import": "./dist/adapter/cloudflare/index.js", diff --git a/app/src/adapter/cloudflare/StorageR2Adapter.ts b/app/src/adapter/cloudflare/StorageR2Adapter.ts index 5432e79..6020d92 100644 --- a/app/src/adapter/cloudflare/StorageR2Adapter.ts +++ b/app/src/adapter/cloudflare/StorageR2Adapter.ts @@ -1,7 +1,8 @@ import { registries } from "bknd"; import { isDebug } from "bknd/core"; import { StringEnum, Type } from "bknd/utils"; -import type { FileBody, StorageAdapter } from "media/storage/Storage"; +import type { FileBody } from "media/storage/Storage"; +import { StorageAdapter } from "media/storage/StorageAdapter"; import { guess } from "media/storage/mime-types-tiny"; import { getBindings } from "./bindings"; @@ -47,8 +48,10 @@ export function registerMedia(env: Record) { * Adapter for R2 storage * @todo: add tests (bun tests won't work, need node native tests) */ -export class StorageR2Adapter implements StorageAdapter { - constructor(private readonly bucket: R2Bucket) {} +export class StorageR2Adapter extends StorageAdapter { + constructor(private readonly bucket: R2Bucket) { + super(); + } getName(): string { return "r2"; diff --git a/app/src/adapter/node/index.ts b/app/src/adapter/node/index.ts index 5d71d8c..e047040 100644 --- a/app/src/adapter/node/index.ts +++ b/app/src/adapter/node/index.ts @@ -1,11 +1,9 @@ import { registries } from "bknd"; -import { - type LocalAdapterConfig, - StorageLocalAdapter, -} from "../../media/storage/adapters/StorageLocalAdapter"; +import { type LocalAdapterConfig, StorageLocalAdapter } from "./storage/StorageLocalAdapter"; export * from "./node.adapter"; export { StorageLocalAdapter, type LocalAdapterConfig }; +export { nodeTestRunner } from "./test"; export function registerLocalMediaAdapter() { registries.media.register("local", StorageLocalAdapter); diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts b/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts new file mode 100644 index 0000000..2177ce8 --- /dev/null +++ b/app/src/adapter/node/storage/StorageLocalAdapter.native-spec.ts @@ -0,0 +1,17 @@ +import { describe } from "node:test"; +import { StorageLocalAdapter, nodeTestRunner } from "adapter/node"; +import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +describe("StorageLocalAdapter (node)", async () => { + const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets"); + const buffer = readFileSync(path.join(basePath, "image.png")); + const file = new File([buffer], "image.png", { type: "image/png" }); + + const adapter = new StorageLocalAdapter({ + path: path.join(basePath, "tmp"), + }); + + await adapterTestSuite(nodeTestRunner, adapter, file); +}); diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts b/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts new file mode 100644 index 0000000..ea76d9c --- /dev/null +++ b/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts @@ -0,0 +1,14 @@ +import { describe, test, expect } from "bun:test"; +import { StorageLocalAdapter } from "./StorageLocalAdapter"; +// @ts-ignore +import { assetsPath, assetsTmpPath } from "../../../../__test__/helper"; +import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; + +describe("StorageLocalAdapter (bun)", async () => { + const adapter = new StorageLocalAdapter({ + path: assetsTmpPath, + }); + + const file = Bun.file(`${assetsPath}/image.png`); + await adapterTestSuite({ test, expect }, adapter, file); +}); diff --git a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts b/app/src/adapter/node/storage/StorageLocalAdapter.ts similarity index 91% rename from app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts rename to app/src/adapter/node/storage/StorageLocalAdapter.ts index 9a1a21c..12b91ce 100644 --- a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts +++ b/app/src/adapter/node/storage/StorageLocalAdapter.ts @@ -1,13 +1,7 @@ import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; -import { type Static, Type, isFile, parse } from "core/utils"; -import type { - FileBody, - FileListObject, - FileMeta, - FileUploadPayload, - StorageAdapter, -} from "../../Storage"; -import { guess } from "../../mime-types-tiny"; +import { type Static, Type, isFile, parse } from "bknd/utils"; +import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media"; +import { StorageAdapter, guessMimeType as guess } from "bknd/media"; export const localAdapterConfig = Type.Object( { @@ -17,10 +11,11 @@ export const localAdapterConfig = Type.Object( ); export type LocalAdapterConfig = Static; -export class StorageLocalAdapter implements StorageAdapter { +export class StorageLocalAdapter extends StorageAdapter { private config: LocalAdapterConfig; constructor(config: any) { + super(); this.config = parse(localAdapterConfig, config); } diff --git a/app/src/adapter/node/test.ts b/app/src/adapter/node/test.ts new file mode 100644 index 0000000..e2c3922 --- /dev/null +++ b/app/src/adapter/node/test.ts @@ -0,0 +1,75 @@ +import nodeAssert from "node:assert/strict"; +import { test } from "node:test"; +import type { Matcher, Test, TestFn, TestRunner } from "core/test"; + +const nodeTestMatcher = (actual: T, parentFailMsg?: string) => + ({ + toEqual: (expected: T, failMsg = parentFailMsg) => { + nodeAssert.deepEqual(actual, expected, failMsg); + }, + toBe: (expected: T, failMsg = parentFailMsg) => { + nodeAssert.strictEqual(actual, expected, failMsg); + }, + toBeString: (failMsg = parentFailMsg) => { + nodeAssert.strictEqual(typeof actual, "string", failMsg); + }, + toBeUndefined: (failMsg = parentFailMsg) => { + nodeAssert.strictEqual(actual, undefined, failMsg); + }, + toBeDefined: (failMsg = parentFailMsg) => { + nodeAssert.notStrictEqual(actual, undefined, failMsg); + }, + toBeOneOf: (expected: T | Array | Iterable, failMsg = parentFailMsg) => { + const e = Array.isArray(expected) ? expected : [expected]; + nodeAssert.ok(e.includes(actual), failMsg); + }, + }) satisfies Matcher; + +const nodeTestResolverProxy = ( + actual: Promise, + handler: { resolve?: any; reject?: any }, +) => { + return new Proxy( + {}, + { + get: (_, prop) => { + if (prop === "then") { + return actual.then(handler.resolve, handler.reject); + } + return actual; + }, + }, + ) as Matcher>; +}; + +function nodeTest(label: string, fn: TestFn, options?: any) { + return test(label, fn as any); +} +nodeTest.if = (condition: boolean): Test => { + if (condition) { + return nodeTest; + } + return (() => {}) as any; +}; +nodeTest.skip = (label: string, fn: TestFn) => { + return test.skip(label, fn as any); +}; +nodeTest.skipIf = (condition: boolean): Test => { + if (condition) { + return (() => {}) as any; + } + return nodeTest; +}; + +export const nodeTestRunner: TestRunner = { + test: nodeTest, + expect: (actual?: T, failMsg?: string) => ({ + ...nodeTestMatcher(actual, failMsg), + resolves: nodeTestResolverProxy(actual as Promise, { + resolve: (r) => nodeTestMatcher(r, failMsg), + }), + rejects: nodeTestResolverProxy(actual as Promise, { + reject: (r) => nodeTestMatcher(r, failMsg), + }), + }), +}; diff --git a/app/src/core/cache/adapters/CloudflareKvCache.ts b/app/src/core/cache/adapters/CloudflareKvCache.ts deleted file mode 100644 index 61b71f3..0000000 --- a/app/src/core/cache/adapters/CloudflareKvCache.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { ICacheItem, ICachePool } from "../cache-interface"; - -export class CloudflareKVCachePool implements ICachePool { - constructor(private namespace: KVNamespace) {} - - supports = () => ({ - metadata: true, - clear: false, - }); - - async get(key: string): Promise> { - const result = await this.namespace.getWithMetadata(key); - const hit = result.value !== null && typeof result.value !== "undefined"; - // Assuming metadata is not supported directly; - // you may adjust if Cloudflare KV supports it in future. - return new CloudflareKVCacheItem(key, result.value ?? undefined, hit, result.metadata) as any; - } - - async getMany(keys: string[] = []): Promise>> { - const items = new Map>(); - await Promise.all( - keys.map(async (key) => { - const item = await this.get(key); - items.set(key, item); - }), - ); - return items; - } - - async has(key: string): Promise { - const data = await this.namespace.get(key); - return data !== null; - } - - async clear(): Promise { - // Cloudflare KV does not support clearing all keys in one operation - return false; - } - - async delete(key: string): Promise { - await this.namespace.delete(key); - return true; - } - - async deleteMany(keys: string[]): Promise { - const results = await Promise.all(keys.map((key) => this.delete(key))); - return results.every((result) => result); - } - - async save(item: CloudflareKVCacheItem): Promise { - await this.namespace.put(item.key(), (await item.value()) as string, { - expirationTtl: item._expirationTtl, - metadata: item.metadata(), - }); - - return true; - } - - async put( - key: string, - value: any, - options?: { ttl?: number; expiresAt?: Date; metadata?: Record }, - ): Promise { - const item = new CloudflareKVCacheItem(key, value, true, options?.metadata); - - if (options?.expiresAt) item.expiresAt(options.expiresAt); - if (options?.ttl) item.expiresAfter(options.ttl); - - return await this.save(item); - } -} - -export class CloudflareKVCacheItem implements ICacheItem { - _expirationTtl: number | undefined; - - constructor( - private _key: string, - private data: Data | undefined, - private _hit: boolean = false, - private _metadata: Record = {}, - ) {} - - key(): string { - return this._key; - } - - value(): Data | undefined { - if (this.data) { - try { - return JSON.parse(this.data as string); - } catch (e) {} - } - - return this.data ?? undefined; - } - - metadata(): Record { - return this._metadata; - } - - hit(): boolean { - return this._hit; - } - - set(value: Data, metadata: Record = {}): this { - this.data = value; - this._metadata = metadata; - return this; - } - - expiresAt(expiration: Date | null): this { - // Cloudflare KV does not support specific date expiration; calculate ttl instead. - if (expiration) { - const now = new Date(); - const ttl = (expiration.getTime() - now.getTime()) / 1000; - return this.expiresAfter(Math.max(0, Math.floor(ttl))); - } - return this.expiresAfter(null); - } - - expiresAfter(time: number | null): this { - // Dummy implementation as Cloudflare KV requires setting expiration during PUT operation. - // This method will be effectively implemented in the Cache Pool save methods. - this._expirationTtl = time ?? undefined; - return this; - } -} diff --git a/app/src/core/cache/adapters/MemoryCache.ts b/app/src/core/cache/adapters/MemoryCache.ts deleted file mode 100644 index 5cf36b2..0000000 --- a/app/src/core/cache/adapters/MemoryCache.ts +++ /dev/null @@ -1,139 +0,0 @@ -import type { ICacheItem, ICachePool } from "../cache-interface"; - -export class MemoryCache implements ICachePool { - private cache: Map> = new Map(); - private maxSize?: number; - - constructor(options?: { maxSize?: number }) { - this.maxSize = options?.maxSize; - } - - supports = () => ({ - metadata: true, - clear: true, - }); - - async get(key: string): Promise> { - if (!this.cache.has(key)) { - // use undefined to denote a miss initially - return new MemoryCacheItem(key, undefined!); - } - return this.cache.get(key)!; - } - - async getMany(keys: string[] = []): Promise>> { - const items = new Map>(); - for (const key of keys) { - items.set(key, await this.get(key)); - } - return items; - } - - async has(key: string): Promise { - return this.cache.has(key) && this.cache.get(key)!.hit(); - } - - async clear(): Promise { - this.cache.clear(); - return true; - } - - async delete(key: string): Promise { - return this.cache.delete(key); - } - - async deleteMany(keys: string[]): Promise { - let success = true; - for (const key of keys) { - if (!this.delete(key)) { - success = false; - } - } - return success; - } - - async save(item: MemoryCacheItem): Promise { - this.checkSizeAndPurge(); - this.cache.set(item.key(), item); - return true; - } - - async put( - key: string, - value: Data, - options: { expiresAt?: Date; ttl?: number; metadata?: Record } = {}, - ): Promise { - const item = await this.get(key); - item.set(value, options.metadata || {}); - if (options.expiresAt) { - item.expiresAt(options.expiresAt); - } else if (typeof options.ttl === "number") { - item.expiresAfter(options.ttl); - } - return this.save(item); - } - - private checkSizeAndPurge(): void { - if (!this.maxSize) return; - - if (this.cache.size >= this.maxSize) { - // Implement logic to purge items, e.g., LRU (Least Recently Used) - // For simplicity, clear the oldest item inserted - const keyToDelete = this.cache.keys().next().value; - this.cache.delete(keyToDelete!); - } - } -} - -export class MemoryCacheItem implements ICacheItem { - private _key: string; - private _value: Data | undefined; - private expiration: Date | null = null; - private _metadata: Record = {}; - - constructor(key: string, value: Data, metadata: Record = {}) { - this._key = key; - this.set(value, metadata); - } - - key(): string { - return this._key; - } - - metadata(): Record { - return this._metadata; - } - - value(): Data | undefined { - return this._value; - } - - hit(): boolean { - if (this.expiration !== null && new Date() > this.expiration) { - return false; - } - return this.value() !== undefined; - } - - set(value: Data, metadata: Record = {}): this { - this._value = value; - this._metadata = metadata; - return this; - } - - expiresAt(expiration: Date | null): this { - this.expiration = expiration; - return this; - } - - expiresAfter(time: number | null): this { - if (typeof time === "number") { - const expirationDate = new Date(); - expirationDate.setSeconds(expirationDate.getSeconds() + time); - this.expiration = expirationDate; - } else { - this.expiration = null; - } - return this; - } -} diff --git a/app/src/core/cache/cache-interface.ts b/app/src/core/cache/cache-interface.ts deleted file mode 100644 index c6e099e..0000000 --- a/app/src/core/cache/cache-interface.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * CacheItem defines an interface for interacting with objects inside a cache. - * based on https://www.php-fig.org/psr/psr-6/ - */ -export interface ICacheItem { - /** - * Returns the key for the current cache item. - * - * The key is loaded by the Implementing Library, but should be available to - * the higher level callers when needed. - * - * @returns The key string for this cache item. - */ - key(): string; - - /** - * Retrieves the value of the item from the cache associated with this object's key. - * - * The value returned must be identical to the value originally stored by set(). - * - * If isHit() returns false, this method MUST return null. Note that null - * is a legitimate cached value, so the isHit() method SHOULD be used to - * differentiate between "null value was found" and "no value was found." - * - * @returns The value corresponding to this cache item's key, or undefined if not found. - */ - value(): Data | undefined; - - /** - * Retrieves the metadata of the item from the cache associated with this object's key. - */ - metadata(): Record; - - /** - * Confirms if the cache item lookup resulted in a cache hit. - * - * Note: This method MUST NOT have a race condition between calling isHit() - * and calling get(). - * - * @returns True if the request resulted in a cache hit. False otherwise. - */ - hit(): boolean; - - /** - * Sets the value represented by this cache item. - * - * The value argument may be any item that can be serialized by PHP, - * although the method of serialization is left up to the Implementing - * Library. - * - * @param value The serializable value to be stored. - * @param metadata The metadata to be associated with the item. - * @returns The invoked object. - */ - set(value: Data, metadata?: Record): this; - - /** - * Sets the expiration time for this cache item. - * - * @param expiration The point in time after which the item MUST be considered expired. - * If null is passed explicitly, a default value MAY be used. If none is set, - * the value should be stored permanently or for as long as the - * implementation allows. - * @returns The called object. - */ - expiresAt(expiration: Date | null): this; - - /** - * Sets the expiration time for this cache item. - * - * @param time The period of time from the present after which the item MUST be considered - * expired. An integer parameter is understood to be the time in seconds until - * expiration. If null is passed explicitly, a default value MAY be used. - * If none is set, the value should be stored permanently or for as long as the - * implementation allows. - * @returns The called object. - */ - expiresAfter(time: number | null): this; -} - -/** - * CachePool generates CacheItem objects. - * based on https://www.php-fig.org/psr/psr-6/ - */ -export interface ICachePool { - supports(): { - metadata: boolean; - clear: boolean; - }; - - /** - * Returns a Cache Item representing the specified key. - * This method must always return a CacheItemInterface object, even in case of - * a cache miss. It MUST NOT return null. - * - * @param key The key for which to return the corresponding Cache Item. - * @throws Error If the key string is not a legal value an Error MUST be thrown. - * @returns The corresponding Cache Item. - */ - get(key: string): Promise>; - - /** - * Returns a traversable set of cache items. - * - * @param keys An indexed array of keys of items to retrieve. - * @throws Error If any of the keys in keys are not a legal value an Error MUST be thrown. - * @returns A traversable collection of Cache Items keyed by the cache keys of - * each item. A Cache item will be returned for each key, even if that - * key is not found. However, if no keys are specified then an empty - * traversable MUST be returned instead. - */ - getMany(keys?: string[]): Promise>>; - - /** - * Confirms if the cache contains specified cache item. - * - * Note: This method MAY avoid retrieving the cached value for performance reasons. - * This could result in a race condition with CacheItemInterface.get(). To avoid - * such situation use CacheItemInterface.isHit() instead. - * - * @param key The key for which to check existence. - * @throws Error If the key string is not a legal value an Error MUST be thrown. - * @returns True if item exists in the cache, false otherwise. - */ - has(key: string): Promise; - - /** - * Deletes all items in the pool. - * @returns True if the pool was successfully cleared. False if there was an error. - */ - clear(): Promise; - - /** - * Removes the item from the pool. - * - * @param key The key to delete. - * @throws Error If the key string is not a legal value an Error MUST be thrown. - * @returns True if the item was successfully removed. False if there was an error. - */ - delete(key: string): Promise; - - /** - * Removes multiple items from the pool. - * - * @param keys An array of keys that should be removed from the pool. - * @throws Error If any of the keys in keys are not a legal value an Error MUST be thrown. - * @returns True if the items were successfully removed. False if there was an error. - */ - deleteMany(keys: string[]): Promise; - - /** - * Persists a cache item immediately. - * - * @param item The cache item to save. - * @returns True if the item was successfully persisted. False if there was an error. - */ - save(item: ICacheItem): Promise; - - /** - * Persists any deferred cache items. - * @returns True if all not-yet-saved items were successfully saved or there were none. False otherwise. - */ - put( - key: string, - value: any, - options?: { expiresAt?: Date; metadata?: Record }, - ): Promise; - put( - key: string, - value: any, - options?: { ttl?: number; metadata?: Record }, - ): Promise; - put( - key: string, - value: any, - options?: ({ ttl?: number } | { expiresAt?: Date }) & { metadata?: Record }, - ): Promise; -} diff --git a/app/src/core/test/index.ts b/app/src/core/test/index.ts new file mode 100644 index 0000000..22485ef --- /dev/null +++ b/app/src/core/test/index.ts @@ -0,0 +1,48 @@ +export type Matcher = { + toEqual: (expected: T, failMsg?: string) => void; + toBe: (expected: T, failMsg?: string) => void; + toBeUndefined: (failMsg?: string) => void; + toBeString: (failMsg?: string) => void; + toBeOneOf: (expected: T | Array | Iterable, failMsg?: string) => void; + toBeDefined: (failMsg?: string) => void; +}; +export type TestFn = (() => void | Promise) | ((done: (err?: unknown) => void) => void); +export interface Test { + (label: string, fn: TestFn, options?: any): void; + if: (condition: boolean) => (label: string, fn: TestFn, options?: any) => void; + skip: (label: string, fn: () => void) => void; + skipIf: (condition: boolean) => (label: string, fn: TestFn) => void; +} +export type TestRunner = { + test: Test; + expect: ( + actual?: T, + failMsg?: string, + ) => Matcher & { + resolves: Matcher>; + rejects: Matcher>; + }; +}; + +export async function retry( + fn: () => Promise, + condition: (result: T) => boolean, + retries: number, + delay: number, +): Promise { + let lastError: Error | null = null; + for (let i = 0; i < retries; i++) { + try { + const result = await fn(); + if (condition(result)) { + return result; + } else { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } catch (error) { + lastError = error as Error; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw lastError; +} diff --git a/app/__test__/data/specs/fields/inc.ts b/app/src/data/fields/field-test-suite.ts similarity index 95% rename from app/__test__/data/specs/fields/inc.ts rename to app/src/data/fields/field-test-suite.ts index ff2d00e..4a2394d 100644 --- a/app/__test__/data/specs/fields/inc.ts +++ b/app/src/data/fields/field-test-suite.ts @@ -1,7 +1,7 @@ -import { expect, test } from "bun:test"; +import type { BaseFieldConfig, Field, TActionContext } from "data"; import type { ColumnDataType } from "kysely"; import { omit } from "lodash-es"; -import type { BaseFieldConfig, Field, TActionContext } from "../../../../src/data"; +import type { TestRunner } from "core/test"; type ConstructableField = new (name: string, config?: Partial) => Field; @@ -15,11 +15,13 @@ export function transformPersist(field: Field, value: any, context?: TActionCont return field.transformPersist(value, undefined as any, context as any); } -export function runBaseFieldTests( +export function fieldTestSuite( + testRunner: TestRunner, fieldClass: ConstructableField, config: FieldTestConfig, _requiredConfig: any = {}, ) { + const { test, expect } = testRunner; const noConfigField = new fieldClass("no_config", _requiredConfig); const fillable = new fieldClass("fillable", { ..._requiredConfig, fillable: true }); const required = new fieldClass("required", { ..._requiredConfig, required: true }); @@ -76,9 +78,9 @@ export function runBaseFieldTests( const isPrimitive = (v) => ["string", "number"].includes(typeof v); for (const value of config.sampleValues!) { // "form" - expect(isPrimitive(noConfigField.getValue(value, "form"))).toBeTrue(); + expect(isPrimitive(noConfigField.getValue(value, "form"))).toBe(true); // "table" - expect(isPrimitive(noConfigField.getValue(value, "table"))).toBeTrue(); + expect(isPrimitive(noConfigField.getValue(value, "table"))).toBe(true); // "read" // "submit" } diff --git a/app/src/data/fields/index.ts b/app/src/data/fields/index.ts index 58a34f8..92f7c02 100644 --- a/app/src/data/fields/index.ts +++ b/app/src/data/fields/index.ts @@ -53,3 +53,5 @@ export const FieldClassMap = { json: { schema: jsonFieldConfigSchema, field: JsonField }, jsonschema: { schema: jsonSchemaFieldConfigSchema, field: JsonSchemaField }, } as const; + +export { fieldTestSuite } from "./field-test-suite"; diff --git a/app/src/media/index.ts b/app/src/media/index.ts index d131fa2..a49a4f9 100644 --- a/app/src/media/index.ts +++ b/app/src/media/index.ts @@ -1,22 +1,24 @@ -import type { TObject, TString } from "@sinclair/typebox"; +import type { TObject } from "@sinclair/typebox"; import { type Constructor, Registry } from "core"; //export { MIME_TYPES } from "./storage/mime-types"; export { guess as guessMimeType } from "./storage/mime-types-tiny"; export { Storage, - type StorageAdapter, type FileMeta, type FileListObject, type StorageConfig, + type FileBody, + type FileUploadPayload, } from "./storage/Storage"; -import type { StorageAdapter } from "./storage/Storage"; +import { StorageAdapter } from "./storage/StorageAdapter"; import { type CloudinaryConfig, StorageCloudinaryAdapter, -} from "./storage/adapters/StorageCloudinaryAdapter"; -import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/StorageS3Adapter"; +} from "./storage/adapters/cloudinary/StorageCloudinaryAdapter"; +import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/s3/StorageS3Adapter"; +export { StorageAdapter }; export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig }; export * as StorageEvents from "./storage/events"; @@ -45,3 +47,5 @@ export const Adapters = { schema: StorageCloudinaryAdapter.prototype.getSchema(), }, } as const; + +export { adapterTestSuite } from "./storage/adapters/adapter-test-suite"; diff --git a/app/src/media/storage/Storage.ts b/app/src/media/storage/Storage.ts index 2df3451..ae66070 100644 --- a/app/src/media/storage/Storage.ts +++ b/app/src/media/storage/Storage.ts @@ -1,9 +1,10 @@ import { type EmitsEvents, EventManager } from "core/events"; -import { type TSchema, isFile, detectImageDimensions } from "core/utils"; +import { isFile, detectImageDimensions } from "core/utils"; import { isMimeType } from "media/storage/mime-types-tiny"; import * as StorageEvents from "./events"; import type { FileUploadedEventData } from "./events"; import { $console } from "core"; +import type { StorageAdapter } from "./StorageAdapter"; export type FileListObject = { key: string; @@ -19,24 +20,6 @@ export type FileUploadPayload = { etag: string; }; -export interface StorageAdapter { - /** - * The unique name of the storage adapter - */ - getName(): string; - - // @todo: method requires limit/offset parameters - listObjects(prefix?: string): Promise; - putObject(key: string, body: FileBody): Promise; - deleteObject(key: string): Promise; - objectExists(key: string): Promise; - getObject(key: string, headers: Headers): Promise; - getObjectUrl(key: string): string; - getObjectMeta(key: string): Promise; - getSchema(): TSchema | undefined; - toJSON(secrets?: boolean): any; -} - export type StorageConfig = { body_max_size?: number; }; diff --git a/app/src/media/storage/StorageAdapter.ts b/app/src/media/storage/StorageAdapter.ts new file mode 100644 index 0000000..09aa957 --- /dev/null +++ b/app/src/media/storage/StorageAdapter.ts @@ -0,0 +1,37 @@ +import type { FileListObject, FileMeta } from "media"; +import type { FileBody, FileUploadPayload } from "media/storage/Storage"; +import type { TSchema } from "@sinclair/typebox"; + +const SYMBOL = Symbol.for("bknd:storage"); + +export abstract class StorageAdapter { + constructor() { + this[SYMBOL] = true; + } + + /** + * This is a helper function to manage Connection classes + * coming from different places + * @param conn + */ + static isAdapter(conn: unknown): conn is StorageAdapter { + if (!conn) return false; + return conn[SYMBOL] === true; + } + + /** + * The unique name of the storage adapter + */ + abstract getName(): string; + + // @todo: method requires limit/offset parameters + abstract listObjects(prefix?: string): Promise; + abstract putObject(key: string, body: FileBody): Promise; + abstract deleteObject(key: string): Promise; + abstract objectExists(key: string): Promise; + abstract getObject(key: string, headers: Headers): Promise; + abstract getObjectUrl(key: string): string; + abstract getObjectMeta(key: string): Promise; + abstract getSchema(): TSchema | undefined; + abstract toJSON(secrets?: boolean): any; +} diff --git a/app/src/media/storage/adapters/StorageLocalAdapter/index.ts b/app/src/media/storage/adapters/StorageLocalAdapter/index.ts deleted file mode 100644 index a3f1804..0000000 --- a/app/src/media/storage/adapters/StorageLocalAdapter/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - StorageLocalAdapter, - type LocalAdapterConfig, - localAdapterConfig, -} from "./StorageLocalAdapter"; diff --git a/app/src/media/storage/adapters/adapter-test-suite.ts b/app/src/media/storage/adapters/adapter-test-suite.ts new file mode 100644 index 0000000..88aa7f7 --- /dev/null +++ b/app/src/media/storage/adapters/adapter-test-suite.ts @@ -0,0 +1,79 @@ +import { retry, type TestRunner } from "core/test"; +import type { StorageAdapter } from "media"; +import { randomString } from "core/utils"; +import type { BunFile } from "bun"; + +export async function adapterTestSuite( + testRunner: TestRunner, + adapter: StorageAdapter, + file: File | BunFile, + opts?: { + retries?: number; + retryTimeout?: number; + skipExistsAfterDelete?: boolean; + }, +) { + const { test, expect } = testRunner; + const options = { + retries: opts?.retries ?? 1, + retryTimeout: opts?.retryTimeout ?? 1000, + }; + + let objects = 0; + const _filename = randomString(10); + const filename = `${_filename}.png`; + + await test("puts an object", async () => { + objects = (await adapter.listObjects()).length; + const result = await adapter.putObject(filename, file as unknown as File); + expect(result).toBeDefined(); + const type = typeof result; + expect(type).toBeOneOf(["string", "object"]); + if (typeof result === "object") { + expect(Object.keys(result).sort()).toEqual(["etag", "meta", "name"]); + expect(result.meta.type).toBe(file.type); + } + }); + + await test("lists objects", async () => { + const length = await retry( + () => adapter.listObjects().then((res) => res.length), + (length) => length > objects, + options.retries, + options.retryTimeout, + ); + + expect(length).toBe(objects + 1); + }); + + await test("file exists", async () => { + expect(await adapter.objectExists(filename)).toBe(true); + }); + + await test("gets an object", async () => { + const res = await adapter.getObject(filename, new Headers()); + expect(res.ok).toBe(true); + // @todo: check the content + }); + + await test("gets object meta", async () => { + expect(await adapter.getObjectMeta(filename)).toEqual({ + type: file.type, // image/png + size: file.size, + }); + }); + + await test("deletes an object", async () => { + expect(await adapter.deleteObject(filename)).toBeUndefined(); + + if (opts?.skipExistsAfterDelete !== true) { + const exists = await retry( + () => adapter.objectExists(filename), + (res) => res === false, + options.retries, + options.retryTimeout, + ); + expect(exists).toBe(false); + } + }); +} diff --git a/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.spec.ts b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.spec.ts new file mode 100644 index 0000000..207e030 --- /dev/null +++ b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { StorageCloudinaryAdapter } from "./StorageCloudinaryAdapter"; +import { config } from "dotenv"; +// @ts-ignore +import { assetsPath, assetsTmpPath } from "../../../../../__test__/helper"; +import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; + +const dotenvOutput = config({ path: `${import.meta.dir}/.env` }); +const { + CLOUDINARY_CLOUD_NAME, + CLOUDINARY_API_KEY, + CLOUDINARY_API_SECRET, + CLOUDINARY_UPLOAD_PRESET, +} = dotenvOutput.parsed!; + +const ALL_TESTS = !!process.env.ALL_TESTS; + +describe.skipIf(ALL_TESTS)("StorageCloudinaryAdapter", async () => { + if (ALL_TESTS) return; + + const adapter = new StorageCloudinaryAdapter({ + cloud_name: CLOUDINARY_CLOUD_NAME as string, + api_key: CLOUDINARY_API_KEY as string, + api_secret: CLOUDINARY_API_SECRET as string, + upload_preset: CLOUDINARY_UPLOAD_PRESET as string, + }); + + const file = Bun.file(`${assetsPath}/image.png`) as unknown as File; + + test("hash", async () => { + expect( + await adapter.generateSignature( + { + eager: "w_400,h_300,c_pad|w_260,h_200,c_crop", + public_id: "sample_image", + timestamp: 1315060510, + }, + "abcd", + ), + ).toEqual({ + signature: "bfd09f95f331f558cbd1320e67aa8d488770583e", + timestamp: 1315060510, + }); + }); + + await adapterTestSuite({ test, expect }, adapter, file, { + // eventual consistency + retries: 20, + retryTimeout: 1000, + // result is cached from cloudinary + skipExistsAfterDelete: true, + }); +}); diff --git a/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts similarity index 72% rename from app/src/media/storage/adapters/StorageCloudinaryAdapter.ts rename to app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts index 43509f7..71ee9e6 100644 --- a/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts +++ b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts @@ -1,6 +1,7 @@ -import { pickHeaders } from "core/utils"; +import { hash, pickHeaders } from "core/utils"; import { type Static, Type, parse } from "core/utils"; -import type { FileBody, FileListObject, FileMeta, StorageAdapter } from "../Storage"; +import type { FileBody, FileListObject, FileMeta } from "../../Storage"; +import { StorageAdapter } from "../../StorageAdapter"; export const cloudinaryAdapterConfig = Type.Object( { @@ -53,10 +54,11 @@ type CloudinaryListObjectsResponse = { }; // @todo: add signed uploads -export class StorageCloudinaryAdapter implements StorageAdapter { +export class StorageCloudinaryAdapter extends StorageAdapter { private config: CloudinaryConfig; constructor(config: CloudinaryConfig) { + super(); this.config = parse(cloudinaryAdapterConfig, config); } @@ -126,6 +128,11 @@ export class StorageCloudinaryAdapter implements StorageAdapter { }; } + /** + * https://cloudinary.com/documentation/admin_api#search_for_resources + * Cloudinary implements eventual consistency: Search results reflect any changes made to assets within a few seconds after the change + * @param prefix + */ async listObjects(prefix?: string): Promise { const result = await fetch( `https://api.cloudinary.com/v1_1/${this.config.cloud_name}/resources/search`, @@ -133,6 +140,7 @@ export class StorageCloudinaryAdapter implements StorageAdapter { method: "GET", headers: { Accept: "application/json", + "Cache-Control": "no-cache", ...this.getAuthorizationHeader(), }, }, @@ -143,18 +151,22 @@ export class StorageCloudinaryAdapter implements StorageAdapter { } const data = (await result.json()) as CloudinaryListObjectsResponse; - return data.resources.map((item) => ({ + const items = data.resources.map((item) => ({ key: item.public_id, last_modified: new Date(item.uploaded_at), size: item.bytes, })); + return items; } private async headObject(key: string) { const url = this.getObjectUrl(key); return await fetch(url, { - method: "GET", + method: "HEAD", headers: { + "Cache-Control": "no-cache, no-store, must-revalidate", + Pragma: "no-cache", + Expires: "0", Range: "bytes=0-1", }, }); @@ -196,6 +208,22 @@ export class StorageCloudinaryAdapter implements StorageAdapter { return objectUrl; } + async generateSignature(params: Record, secret?: string) { + const timestamp = params.timestamp ?? Math.floor(Date.now() / 1000); + const content = Object.entries({ ...params, timestamp }) + .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) + .map(([key, value]) => `${key}=${value}`) + .join("&"); + + const signature = await hash.sha1(content + (secret ?? this.config.api_secret)); + return { signature, timestamp }; + } + + // get public_id as everything before the last "." + filenameToPublicId(key: string): string { + return key.split(".").slice(0, -1).join("."); + } + async getObject(key: string, headers: Headers): Promise { const res = await fetch(this.getObjectUrl(key), { method: "GET", @@ -211,13 +239,30 @@ export class StorageCloudinaryAdapter implements StorageAdapter { async deleteObject(key: string): Promise { const type = this.guessType(key) ?? "image"; - const formData = new FormData(); - formData.append("public_ids[]", key); + const public_id = this.filenameToPublicId(key); + const { timestamp, signature } = await this.generateSignature({ + public_id, + }); - await fetch(`https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/`, { - method: "DELETE", + const formData = new FormData(); + formData.append("public_id", public_id); + formData.append("timestamp", String(timestamp)); + formData.append("signature", signature); + formData.append("api_key", this.config.api_key); + + const url = `https://api.cloudinary.com/v1_1/${this.config.cloud_name}/${type}/destroy`; + const res = await fetch(url, { + headers: { + Accept: "application/json", + "Cache-Control": "no-cache", + ...this.getAuthorizationHeader(), + }, + method: "POST", body: formData, }); + if (!res.ok) { + throw new Error(`Failed to delete object: ${res.status} ${res.statusText}`); + } } toJSON(secrets?: boolean) { diff --git a/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts b/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts new file mode 100644 index 0000000..a23744b --- /dev/null +++ b/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts @@ -0,0 +1,50 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { StorageS3Adapter } from "./StorageS3Adapter"; + +import { config } from "dotenv"; +import { adapterTestSuite } from "media"; +import { assetsPath } from "../../../../../__test__/helper"; +//import { enableFetchLogging } from "../../helper"; +const dotenvOutput = config({ path: `${import.meta.dir}/.env` }); +const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } = + dotenvOutput.parsed!; + +const ALL_TESTS = !!process.env.ALL_TESTS; + +/* +// @todo: preparation to mock s3 calls + replace fast-xml-parser +let cleanup: () => void; +beforeAll(async () => { + cleanup = await enableFetchLogging(); +}); +afterAll(() => { + cleanup(); +}); */ + +describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => { + if (ALL_TESTS) return; + + const versions = [ + [ + "r2", + new StorageS3Adapter({ + access_key: R2_ACCESS_KEY as string, + secret_access_key: R2_SECRET_ACCESS_KEY as string, + url: R2_URL as string, + }), + ], + [ + "s3", + new StorageS3Adapter({ + access_key: AWS_ACCESS_KEY as string, + secret_access_key: AWS_SECRET_KEY as string, + url: AWS_S3_URL as string, + }), + ], + ] as const; + const file = Bun.file(`${assetsPath}/image.png`) as unknown as File; + + describe.each(versions)("%s", async (_name, adapter) => { + await adapterTestSuite({ test, expect }, adapter, file); + }); +}); diff --git a/app/src/media/storage/adapters/StorageS3Adapter.ts b/app/src/media/storage/adapters/s3/StorageS3Adapter.ts similarity index 88% rename from app/src/media/storage/adapters/StorageS3Adapter.ts rename to app/src/media/storage/adapters/s3/StorageS3Adapter.ts index 960b73d..4154126 100644 --- a/app/src/media/storage/adapters/StorageS3Adapter.ts +++ b/app/src/media/storage/adapters/s3/StorageS3Adapter.ts @@ -9,7 +9,8 @@ import type { import { AwsClient, isDebug } from "core"; import { type Static, Type, isFile, parse, pickHeaders2 } from "core/utils"; import { transform } from "lodash-es"; -import type { FileBody, FileListObject, StorageAdapter } from "../Storage"; +import type { FileBody, FileListObject } from "../../Storage"; +import { StorageAdapter } from "../../StorageAdapter"; export const s3AdapterConfig = Type.Object( { @@ -32,11 +33,13 @@ export const s3AdapterConfig = Type.Object( export type S3AdapterConfig = Static; -export class StorageS3Adapter extends AwsClient implements StorageAdapter { +export class StorageS3Adapter extends StorageAdapter { readonly #config: S3AdapterConfig; + readonly client: AwsClient; constructor(config: S3AdapterConfig) { - super( + super(); + this.client = new AwsClient( { accessKeyId: config.access_key, secretAccessKey: config.secret_access_key, @@ -58,10 +61,10 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter { return s3AdapterConfig; } - override getUrl(path: string = "", searchParamsObj: Record = {}): string { + getUrl(path: string = "", searchParamsObj: Record = {}): string { let url = this.getObjectUrl("").slice(0, -1); if (path.length > 0) url += `/${path}`; - return super.getUrl(url, searchParamsObj); + return this.client.getUrl(url, searchParamsObj); } /** @@ -82,7 +85,7 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter { }; const url = this.getUrl("", params); - const res = await this.fetchJson<{ ListBucketResult: ListObjectsV2Output }>(url, { + const res = await this.client.fetchJson<{ ListBucketResult: ListObjectsV2Output }>(url, { method: "GET", }); @@ -115,7 +118,7 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter { params: Omit = {}, ) { const url = this.getUrl(key, {}); - const res = await this.fetch(url, { + const res = await this.client.fetch(url, { method: "PUT", body, headers: isFile(body) @@ -139,7 +142,7 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter { params: Pick = {}, ) { const url = this.getUrl(key, {}); - return await this.fetch(url, { + return await this.client.fetch(url, { method: "HEAD", headers: { Range: "bytes=0-1", @@ -175,7 +178,7 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter { */ async getObject(key: string, headers: Headers): Promise { const url = this.getUrl(key); - const res = await this.fetch(url, { + const res = await this.client.fetch(url, { method: "GET", headers: pickHeaders2(headers, [ "if-none-match", @@ -201,7 +204,7 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter { params: Omit = {}, ): Promise { const url = this.getUrl(key, params); - const res = await this.fetch(url, { + const res = await this.client.fetch(url, { method: "DELETE", }); } diff --git a/bun.lock b/bun.lock index f0fda3c..e4334ae 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "app": { "name": "bknd", - "version": "0.10.2", + "version": "0.10.3-rc.1", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1", @@ -99,6 +99,7 @@ "tailwindcss-animate": "^1.0.7", "tsc-alias": "^1.8.11", "tsup": "^8.4.0", + "tsx": "^4.19.3", "vite": "^6.2.1", "vite-tsconfig-paths": "^5.1.4", "wouter": "^3.6.0", @@ -125,7 +126,6 @@ "version": "0.5.1", "devDependencies": { "@types/bun": "latest", - "bknd": "workspace:*", "tsdx": "^0.14.1", "typescript": "^5.0.0", }, @@ -1202,7 +1202,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], - "@types/bun": ["@types/bun@1.2.6", "", { "dependencies": { "bun-types": "1.2.6" } }, "sha512-fY9CAmTdJH1Llx7rugB0FpgWK2RKuHCs3g2cFDYXUutIy1QGiPQxKkGY8owhfZ4MXWNfxwIbQLChgH5gDsY7vw=="], + "@types/bun": ["@types/bun@1.2.8", "", { "dependencies": { "bun-types": "1.2.7" } }, "sha512-t8L1RvJVUghW5V+M/fL3Thbxcs0HwNsXsnTEBEfEVqGteiJToOlZ/fyOEaR1kZsNqnu+3XA4RI/qmnX4w6+S+w=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], @@ -2130,6 +2130,8 @@ "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + "get-tsconfig": ["get-tsconfig@4.10.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A=="], + "get-uri": ["get-uri@6.0.4", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ=="], "get-value": ["get-value@2.0.6", "", {}, "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="], @@ -3102,6 +3104,8 @@ "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "resolve-url": ["resolve-url@0.2.1", "", {}, "sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg=="], "resq": ["resq@1.11.0", "", { "dependencies": { "fast-deep-equal": "^2.0.1" } }, "sha512-G10EBz+zAAy3zUd/CDoBbXRL6ia9kOo3xRHrMDsHljI0GDkhYlyjwoCx5+3eCC4swi1uCoZQhskuJkj7Gp57Bw=="], @@ -3466,6 +3470,8 @@ "tsutils": ["tsutils@3.21.0", "", { "dependencies": { "tslib": "^1.8.1" }, "peerDependencies": { "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" } }, "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA=="], + "tsx": ["tsx@4.19.3", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], "turbo-stream": ["turbo-stream@2.4.0", "", {}, "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g=="], @@ -4006,7 +4012,7 @@ "@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], - "@types/bun/bun-types": ["bun-types@1.2.6", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-FbCKyr5KDiPULUzN/nm5oqQs9nXCHD8dVc64BArxJadCvbNzAI6lUWGh9fSJZWeDIRD38ikceBU8Kj/Uh+53oQ=="], + "@types/bun/bun-types": ["bun-types@1.2.7", "", { "dependencies": { "@types/node": "*", "@types/ws": "*" } }, "sha512-P4hHhk7kjF99acXqKvltyuMQ2kf/rzIw3ylEDpCxDS9Xa0X0Yp/gJu/vDCucmWpiur5qJ0lwB2bWzOXa2GlHqA=="], "@types/jest/jest-diff": ["jest-diff@25.5.0", "", { "dependencies": { "chalk": "^3.0.0", "diff-sequences": "^25.2.6", "jest-get-type": "^25.2.6", "pretty-format": "^25.5.0" } }, "sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A=="], @@ -4592,6 +4598,8 @@ "tsup/esbuild": ["esbuild@0.25.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.1", "@esbuild/android-arm": "0.25.1", "@esbuild/android-arm64": "0.25.1", "@esbuild/android-x64": "0.25.1", "@esbuild/darwin-arm64": "0.25.1", "@esbuild/darwin-x64": "0.25.1", "@esbuild/freebsd-arm64": "0.25.1", "@esbuild/freebsd-x64": "0.25.1", "@esbuild/linux-arm": "0.25.1", "@esbuild/linux-arm64": "0.25.1", "@esbuild/linux-ia32": "0.25.1", "@esbuild/linux-loong64": "0.25.1", "@esbuild/linux-mips64el": "0.25.1", "@esbuild/linux-ppc64": "0.25.1", "@esbuild/linux-riscv64": "0.25.1", "@esbuild/linux-s390x": "0.25.1", "@esbuild/linux-x64": "0.25.1", "@esbuild/netbsd-arm64": "0.25.1", "@esbuild/netbsd-x64": "0.25.1", "@esbuild/openbsd-arm64": "0.25.1", "@esbuild/openbsd-x64": "0.25.1", "@esbuild/sunos-x64": "0.25.1", "@esbuild/win32-arm64": "0.25.1", "@esbuild/win32-ia32": "0.25.1", "@esbuild/win32-x64": "0.25.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ=="], + "tsx/esbuild": ["esbuild@0.25.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.1", "@esbuild/android-arm": "0.25.1", "@esbuild/android-arm64": "0.25.1", "@esbuild/android-x64": "0.25.1", "@esbuild/darwin-arm64": "0.25.1", "@esbuild/darwin-x64": "0.25.1", "@esbuild/freebsd-arm64": "0.25.1", "@esbuild/freebsd-x64": "0.25.1", "@esbuild/linux-arm": "0.25.1", "@esbuild/linux-arm64": "0.25.1", "@esbuild/linux-ia32": "0.25.1", "@esbuild/linux-loong64": "0.25.1", "@esbuild/linux-mips64el": "0.25.1", "@esbuild/linux-ppc64": "0.25.1", "@esbuild/linux-riscv64": "0.25.1", "@esbuild/linux-s390x": "0.25.1", "@esbuild/linux-x64": "0.25.1", "@esbuild/netbsd-arm64": "0.25.1", "@esbuild/netbsd-x64": "0.25.1", "@esbuild/openbsd-arm64": "0.25.1", "@esbuild/openbsd-x64": "0.25.1", "@esbuild/sunos-x64": "0.25.1", "@esbuild/win32-arm64": "0.25.1", "@esbuild/win32-ia32": "0.25.1", "@esbuild/win32-x64": "0.25.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ=="], + "unenv/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "union-value/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], @@ -4996,6 +5004,54 @@ "tsup/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.1", "", { "os": "android", "cpu": "arm" }, "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.1", "", { "os": "android", "cpu": "arm64" }, "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.1", "", { "os": "android", "cpu": "x64" }, "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.1", "", { "os": "linux", "cpu": "arm" }, "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.1", "", { "os": "linux", "cpu": "none" }, "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.1", "", { "os": "linux", "cpu": "none" }, "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.1", "", { "os": "linux", "cpu": "none" }, "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.1", "", { "os": "none", "cpu": "x64" }, "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg=="], + "unset-value/has-value/has-values": ["has-values@0.1.4", "", {}, "sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ=="], "unset-value/has-value/isobject": ["isobject@2.1.0", "", { "dependencies": { "isarray": "1.0.0" } }, "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA=="],