From 001d6bd4db2c56af8fea988aa2f5586741081b81 Mon Sep 17 00:00:00 2001 From: Michel Smola Date: Sun, 27 Sep 2026 00:28:21 +0200 Subject: [PATCH 1/3] feat: s3lfscache --- .changeset/tired-sites-shave.md | 5 + compose.yml | 6 + packages/toapi-cache/package.json | 11 +- packages/toapi-cache/src/s3-lfs-cache.test.ts | 267 +++++++++++++++ packages/toapi-cache/src/s3-lfs-cache.ts | 105 ++++++ packages/toapi-cache/tsconfig.json | 4 +- pnpm-lock.yaml | 309 +++++++++++++++++- 7 files changed, 698 insertions(+), 9 deletions(-) create mode 100644 .changeset/tired-sites-shave.md create mode 100644 packages/toapi-cache/src/s3-lfs-cache.test.ts create mode 100644 packages/toapi-cache/src/s3-lfs-cache.ts diff --git a/.changeset/tired-sites-shave.md b/.changeset/tired-sites-shave.md new file mode 100644 index 00000000..77764488 --- /dev/null +++ b/.changeset/tired-sites-shave.md @@ -0,0 +1,5 @@ +--- +"@toapi/cache": minor +--- + +Add S3LfsCache to efficiently cache large files diff --git a/compose.yml b/compose.yml index bb19c474..84bd1451 100644 --- a/compose.yml +++ b/compose.yml @@ -9,3 +9,9 @@ services: POSTGRES_PASSWORD: postgres ports: - "5432:5432" + s3: + image: ghcr.io/shyim/local-s3:latest + environment: + S3_ACCOUNT_ADMIN: toapi:toapi-secret + ports: + - "9000:9000" diff --git a/packages/toapi-cache/package.json b/packages/toapi-cache/package.json index 54a3c24a..ff3f6d8b 100644 --- a/packages/toapi-cache/package.json +++ b/packages/toapi-cache/package.json @@ -41,18 +41,20 @@ "test": "vitest run" }, "devDependencies": { + "@aws-sdk/client-s3": "^3.1141.0", + "@redis/client": "^6.0.0", "@types/node": "^25.0.3", "@types/pg": "^8.0.0", - "vitest": "^5.0.0", + "pg": "^8.0.0", "typescript": "^7.0.0", - "@redis/client": "^6.0.0", - "pg": "^8.0.0" + "vitest": "^5.0.0" }, "repository": { "type": "git", "url": "git+https://github.com/toapi-js/toapi.git" }, "peerDependencies": { + "@aws-sdk/client-s3": "^3.1141.0", "@redis/client": "^5.10.0 || ^6.0.0", "pg": "^8.0.0" }, @@ -62,6 +64,9 @@ }, "pg": { "optional": true + }, + "@aws-sdk/client-s3": { + "optional": true } } } diff --git a/packages/toapi-cache/src/s3-lfs-cache.test.ts b/packages/toapi-cache/src/s3-lfs-cache.test.ts new file mode 100644 index 00000000..7cfb3178 --- /dev/null +++ b/packages/toapi-cache/src/s3-lfs-cache.test.ts @@ -0,0 +1,267 @@ +import { randomUUID } from "node:crypto"; +import { + CreateBucketCommand, + GetObjectCommand, + HeadObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { InMemoryCache } from "./in-memory-cache.js"; +import { S3LfsCache } from "./s3-lfs-cache.js"; + +const S3_URL = process.env.S3_URL ?? "http://localhost:9000"; +const S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID ?? "toapi"; +const S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY ?? "toapi-secret"; +const BUCKET = "toapi-cache-test"; +const CUTOFF = 16; + +// Skip the suite when no S3 server is reachable (e.g. local runs without +// `docker compose up`). CI provides an S3 service so it always runs there. +async function isS3Available(url: string): Promise { + try { + await fetch(url, { signal: AbortSignal.timeout(1000) }); + return true; + } catch { + return false; + } +} + +const s3Available = await isS3Available(S3_URL); + +function bytes(length: number, seed = 0) { + return Uint8Array.from({ length }, (_, i) => (i + seed) % 256); +} + +describe.skipIf(!s3Available)("S3LfsCache", () => { + const client = new S3Client({ + endpoint: S3_URL, + region: "us-east-1", + forcePathStyle: true, + credentials: { + accessKeyId: S3_ACCESS_KEY_ID, + secretAccessKey: S3_SECRET_ACCESS_KEY, + }, + }); + + let base: InMemoryCache; + let sut: S3LfsCache; + // The bucket outlives a test run, so every test gets its own key namespace. + let prefix: string; + + beforeAll(async () => { + try { + await client.send(new CreateBucketCommand({ Bucket: BUCKET })); + } catch (error) { + if ( + !(error instanceof Error) || + (error.name !== "BucketAlreadyOwnedByYou" && + error.name !== "BucketAlreadyExists") + ) { + throw error; + } + } + }); + + beforeEach(() => { + base = new InMemoryCache(); + sut = new S3LfsCache(base, client, { bucket: BUCKET, cutoffBytes: CUTOFF }); + prefix = `${randomUUID()}/`; + }); + + async function getObject(key: string) { + const response = await client.send( + new GetObjectCommand({ Bucket: BUCKET, Key: key }), + ); + return response.Body?.transformToByteArray(); + } + + async function objectExists(key: string) { + try { + await client.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key })); + return true; + } catch (error) { + if (error instanceof Error && error.name === "NotFound") return false; + throw error; + } + } + + test("returns null for unknown keys", async () => { + expect(await sut.get(`${prefix}missing`)).toEqual(null); + }); + + test("basic store and retrieve", async () => { + const key = `${prefix}test`; + + await sut.set({ + key, + data: { foo: 1, bar: "baz" }, + ttl: 1000, + tags: [], + }); + + expect(await sut.get(key)).toEqual({ + data: { foo: 1, bar: "baz" }, + attachment: null, + }); + expect(await objectExists(key)).toBe(false); + }); + + test("keeps small attachments in the base cache", async () => { + const key = `${prefix}small`; + const attachment = bytes(CUTOFF - 1); + + await sut.set({ key, attachment, ttl: 1000, tags: [] }); + + expect(await sut.get(key)).toEqual({ data: null, attachment }); + expect((await base.get(key))?.attachment).toEqual(attachment); + expect(await objectExists(key)).toBe(false); + }); + + test("keeps attachments of exactly cutoffBytes in the base cache", async () => { + const key = `${prefix}boundary`; + const attachment = bytes(CUTOFF); + + await sut.set({ key, attachment, ttl: 1000, tags: [] }); + + expect(await sut.get(key)).toEqual({ data: null, attachment }); + expect(await objectExists(key)).toBe(false); + }); + + test("offloads large attachments to s3", async () => { + const key = `${prefix}large`; + const attachment = bytes(CUTOFF * 64); + + await sut.set({ key, attachment, ttl: 1000, tags: [] }); + + expect(await sut.get(key)).toEqual({ data: null, attachment }); + expect(await getObject(key)).toEqual(attachment); + expect((await base.get(key))?.attachment).toBeNull(); + }); + + test("store both data and large attachment", async () => { + const key = `${prefix}both`; + const attachment = bytes(CUTOFF * 64); + + await sut.set({ + key, + data: { message: "hello" }, + attachment, + ttl: 1000, + tags: ["tag1"], + }); + + expect(await sut.get(key)).toEqual({ + data: { message: "hello" }, + attachment, + }); + }); + + test("does not mutate the input entry", async () => { + const attachment = bytes(CUTOFF * 64); + const input = { + key: `${prefix}input`, + data: { message: "hello" }, + attachment, + ttl: 1000, + tags: [], + }; + + await sut.set(input); + + expect(input.data).toEqual({ message: "hello" }); + expect(input.attachment).toBe(attachment); + }); + + test("overwriting a key returns the latest attachment", async () => { + const key = `${prefix}overwrite`; + + await sut.set({ key, attachment: bytes(CUTOFF * 64), ttl: 1000, tags: [] }); + await sut.set({ + key, + attachment: bytes(CUTOFF * 32, 7), + ttl: 1000, + tags: [], + }); + + expect((await sut.get(key))?.attachment).toEqual(bytes(CUTOFF * 32, 7)); + }); + + test("overwriting a large attachment with a small one", async () => { + const key = `${prefix}shrink`; + + await sut.set({ key, attachment: bytes(CUTOFF * 64), ttl: 1000, tags: [] }); + await sut.set({ key, attachment: bytes(4), ttl: 1000, tags: [] }); + + expect(await sut.get(key)).toEqual({ data: null, attachment: bytes(4) }); + }); + + test("expire by ttl", async () => { + const key = `${prefix}ttl`; + + await sut.set({ + key, + data: { foo: 1 }, + attachment: bytes(CUTOFF * 64), + ttl: 1, + tags: [], + }); + + // margin over the 1s TTL to avoid a boundary race on loaded CI runners + await new Promise((resolve) => setTimeout(resolve, 1500)); + + expect(await sut.get(key)).toEqual(null); + }); + + test("expire by tags", async () => { + const first = `${prefix}first`; + const second = `${prefix}second`; + + await sut.set({ + key: first, + data: { foo: 1 }, + attachment: bytes(CUTOFF * 64), + ttl: 1000, + tags: ["tag1", "tag2"], + }); + await sut.set({ + key: second, + data: { foo: 2 }, + attachment: bytes(CUTOFF * 64, 1), + ttl: 1000, + tags: ["tag2", "tag3"], + }); + + await sut.invalidate(["tag1"]); + + expect(await sut.get(first)).toEqual(null); + expect(await sut.get(second)).toEqual({ + data: { foo: 2 }, + attachment: bytes(CUTOFF * 64, 1), + }); + + await sut.delete(["tag2"]); + + expect(await sut.get(second)).toEqual(null); + }); + + test("forwards invalidations to subscribers", async () => { + const calls: string[][] = []; + const unsubscribe = sut.subscribe((tags) => { + calls.push(tags); + }); + + await sut.invalidate(["tag1"]); + unsubscribe(); + await sut.invalidate(["tag2"]); + + expect(calls).toEqual([["tag1"]]); + }); + + test("ignores base entries not written by S3LfsCache", async () => { + const key = `${prefix}foreign`; + + await base.set({ key, data: { foo: 1 }, ttl: 1000, tags: [] }); + + expect(await sut.get(key)).toEqual(null); + }); +}); diff --git a/packages/toapi-cache/src/s3-lfs-cache.ts b/packages/toapi-cache/src/s3-lfs-cache.ts new file mode 100644 index 00000000..b1434d42 --- /dev/null +++ b/packages/toapi-cache/src/s3-lfs-cache.ts @@ -0,0 +1,105 @@ +import { + GetObjectCommand, + PutObjectCommand, + type S3Client, +} from "@aws-sdk/client-s3"; +import type { Cache, CacheEntry, Json, Subscription } from "./index.js"; + +interface Options { + cutoffBytes?: number; + bucket?: string; +} + +interface ObjectReference { + k: string; + v: string | null; +} + +interface Data { + p: Json | null; + a: ObjectReference | null; +} + +export class S3LfsCache implements Cache { + bucket?: string; + cutoffBytes: number; + + constructor( + private base: Cache, + private client: S3Client, + options?: Options, + ) { + this.bucket = options?.bucket; + this.cutoffBytes = options?.cutoffBytes ?? 1_000_000; + } + + async get(key: string) { + const entry = await this.base.get(key); + + const data = entry?.data; + if (!data || typeof data !== "object" || !("p" in data) || !("a" in data)) + return null; + + entry.data = data.p; + const attachment = data.a; + + if ( + attachment && + typeof attachment === "object" && + attachment !== null && + "k" in attachment && + "v" in attachment + ) { + const cmd = new GetObjectCommand({ + Bucket: this.bucket, + Key: attachment.k?.toString(), + VersionId: attachment.v?.toString() ?? undefined, + }); + const response = await this.client.send(cmd); + + if (!response.Body) return null; + + entry.attachment = await response.Body.transformToByteArray(); + } + + return entry; + } + + async set(input: CacheEntry & { key: string; ttl: number; tags: string[] }) { + const data: Data = { + p: input.data ?? null, + a: null, + }; + + input = { + ...input, + data: data as any, + }; + + if (input.attachment && input.attachment.byteLength > this.cutoffBytes) { + const cmd = new PutObjectCommand({ + Bucket: this.bucket, + Key: input.key, + Body: input.attachment, + }); + const response = await this.client.send(cmd); + input.attachment = null; + data.a = { + k: input.key, + v: response.VersionId ?? null, + }; + } + + await this.base.set(input); + } + + async delete(tags: string[], meta?: { clientId?: string }) { + await this.invalidate(tags, meta); + } + async invalidate(tags: string[], meta?: { clientId?: string }) { + await this.base.invalidate(tags); + } + subscribe(callback: Subscription) { + return this.base.subscribe(callback); + } +} diff --git a/packages/toapi-cache/tsconfig.json b/packages/toapi-cache/tsconfig.json index 9b336234..9b687706 100644 --- a/packages/toapi-cache/tsconfig.json +++ b/packages/toapi-cache/tsconfig.json @@ -28,8 +28,8 @@ "outDir": "./dist", "declarationDir": "./dist", "declaration": true, - "declarationMap": true + "declarationMap": true, }, "include": ["src"], - "exclude": ["**/*.test.ts"] + "exclude": ["**/*.test.ts"], } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54217710..50104cc7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -367,7 +367,7 @@ importers: version: 1.63.0 '@tailwindcss/vite': specifier: ^4 - version: 4.3.3(vite@8.3.1(@types/node@25.9.8)) + version: 4.3.3(vite@8.3.1(@types/node@25.9.8)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) '@types/node': specifier: ^25.0.3 version: 25.9.8 @@ -417,6 +417,9 @@ importers: packages/toapi-cache: devDependencies: + '@aws-sdk/client-s3': + specifier: ^3.1141.0 + version: 3.1141.0 '@redis/client': specifier: ^6.0.0 version: 6.2.1 @@ -752,6 +755,78 @@ packages: resolution: {integrity: sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==} engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + '@aws-sdk/checksums@3.1001.1': + resolution: {integrity: sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1141.0': + resolution: {integrity: sha512-uOVH37xGLenAdJkCPCin/JJG2PgWrFcSsDnQ9+C9Zq8N9Oalo5ol4xmn5fG28iWAlA/b/9boQZgHbMh+UsIhcg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.978.1': + resolution: {integrity: sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.72': + resolution: {integrity: sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.74': + resolution: {integrity: sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.17': + resolution: {integrity: sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.79': + resolution: {integrity: sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.84': + resolution: {integrity: sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.72': + resolution: {integrity: sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.16': + resolution: {integrity: sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.78': + resolution: {integrity: sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.77': + resolution: {integrity: sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.46': + resolution: {integrity: sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.47': + resolution: {integrity: sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1138.0': + resolution: {integrity: sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.6': + resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.41': + resolution: {integrity: sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -1548,6 +1623,30 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@smithy/core@3.35.0': + resolution: {integrity: sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.8.0': + resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.12.1': + resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.7.4': + resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.19.0': + resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==} + engines: {node: '>=18.0.0'} + '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -1978,6 +2077,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + buffer-image-size@0.6.4: resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} engines: {node: '>=4.0'} @@ -3433,6 +3535,171 @@ snapshots: is-docker: 4.0.0 package-manager-detector: 1.8.0 + '@aws-sdk/checksums@3.1001.1': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1141.0': + dependencies: + '@aws-sdk/checksums': 3.1001.1 + '@aws-sdk/core': 3.978.1 + '@aws-sdk/credential-provider-node': 3.972.84 + '@aws-sdk/middleware-sdk-s3': 3.972.77 + '@aws-sdk/signature-v4-multi-region': 3.996.47 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/core@3.978.1': + dependencies: + '@aws-sdk/types': 3.974.6 + '@aws-sdk/xml-builder': 3.972.41 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.35.0 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.72': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.74': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.17': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/credential-provider-env': 3.972.72 + '@aws-sdk/credential-provider-http': 3.972.74 + '@aws-sdk/credential-provider-login': 3.972.79 + '@aws-sdk/credential-provider-process': 3.972.72 + '@aws-sdk/credential-provider-sso': 3.973.16 + '@aws-sdk/credential-provider-web-identity': 3.972.78 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.79': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.84': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.72 + '@aws-sdk/credential-provider-http': 3.972.74 + '@aws-sdk/credential-provider-ini': 3.973.17 + '@aws-sdk/credential-provider-process': 3.972.72 + '@aws-sdk/credential-provider-sso': 3.973.16 + '@aws-sdk/credential-provider-web-identity': 3.972.78 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.72': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.16': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/token-providers': 3.1138.0 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.78': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.77': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/signature-v4-multi-region': 3.996.47 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.46': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/signature-v4-multi-region': 3.996.47 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.47': + dependencies: + '@aws-sdk/types': 3.974.6 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1138.0': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.6': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.41': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -4067,6 +4334,39 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@smithy/core@3.35.0': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.5.2': + dependencies: + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.8.0': + dependencies: + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.12.1': + dependencies: + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.7.4': + dependencies: + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/types@4.19.0': + dependencies: + tslib: 2.8.1 + '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 @@ -4128,7 +4428,7 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(vite@8.3.1(@types/node@25.9.8))': + '@tailwindcss/vite@4.3.3(vite@8.3.1(@types/node@25.9.8)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 @@ -4507,6 +4807,8 @@ snapshots: boolbase@1.0.0: {} + bowser@2.14.1: {} + buffer-image-size@0.6.4: dependencies: '@types/node': 25.9.8 @@ -5637,8 +5939,7 @@ snapshots: trough@2.2.0: {} - tslib@2.8.1: - optional: true + tslib@2.8.1: {} typescript@5.9.3: {} From 460364d58fc3591f43518ac7a64309d248721a61 Mon Sep 17 00:00:00 2001 From: Michel Smola Date: Sun, 27 Sep 2026 00:32:16 +0200 Subject: [PATCH 2/3] feat: NoLfsCache --- packages/toapi-cache/src/no-lfs-cache.ts | 43 ++++++++++++++++++++++++ packages/toapi-cache/src/s3-lfs-cache.ts | 8 ++--- 2 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 packages/toapi-cache/src/no-lfs-cache.ts diff --git a/packages/toapi-cache/src/no-lfs-cache.ts b/packages/toapi-cache/src/no-lfs-cache.ts new file mode 100644 index 00000000..6abbe04b --- /dev/null +++ b/packages/toapi-cache/src/no-lfs-cache.ts @@ -0,0 +1,43 @@ +import { + GetObjectCommand, + PutObjectCommand, + type S3Client, +} from "@aws-sdk/client-s3"; +import type { Cache, CacheEntry, Json, Subscription } from "./index.js"; + +interface Options { + cutoffBytes?: number; +} + +export class NoLfsCache implements Cache { + cutoffBytes: number; + + constructor( + private base: Cache, + options?: Options, + ) { + this.cutoffBytes = options?.cutoffBytes ?? 1_000_000; + } + + get(key: string) { + return this.base.get(key); + } + + set(input: CacheEntry & { key: string; ttl: number; tags: string[] }) { + if (input.attachment && input.attachment.byteLength > this.cutoffBytes) { + return Promise.resolve(); + } + + return this.base.set(input); + } + + delete(tags: string[], meta?: { clientId?: string }) { + return this.invalidate(tags, meta); + } + invalidate(tags: string[], meta?: { clientId?: string }) { + return this.base.invalidate(tags); + } + subscribe(callback: Subscription) { + return this.base.subscribe(callback); + } +} diff --git a/packages/toapi-cache/src/s3-lfs-cache.ts b/packages/toapi-cache/src/s3-lfs-cache.ts index b1434d42..394cdfbe 100644 --- a/packages/toapi-cache/src/s3-lfs-cache.ts +++ b/packages/toapi-cache/src/s3-lfs-cache.ts @@ -93,11 +93,11 @@ export class S3LfsCache implements Cache { await this.base.set(input); } - async delete(tags: string[], meta?: { clientId?: string }) { - await this.invalidate(tags, meta); + delete(tags: string[], meta?: { clientId?: string }) { + return this.invalidate(tags, meta); } - async invalidate(tags: string[], meta?: { clientId?: string }) { - await this.base.invalidate(tags); + invalidate(tags: string[], meta?: { clientId?: string }) { + return this.base.invalidate(tags); } subscribe(callback: Subscription) { return this.base.subscribe(callback); From 5f59ab885ff394a261dd18ad318db86f0e2c4f92 Mon Sep 17 00:00:00 2001 From: Michel Smola Date: Sun, 27 Sep 2026 00:36:54 +0200 Subject: [PATCH 3/3] docs: lfs cache options --- website/src/content/docs/cache/index.md | 11 ++- .../docs/cache/reference/no-lfs-cache.md | 54 ++++++++++++ .../docs/cache/reference/s3-lfs-cache.md | 88 +++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 website/src/content/docs/cache/reference/no-lfs-cache.md create mode 100644 website/src/content/docs/cache/reference/s3-lfs-cache.md diff --git a/website/src/content/docs/cache/index.md b/website/src/content/docs/cache/index.md index 812b2294..922bb4b8 100644 --- a/website/src/content/docs/cache/index.md +++ b/website/src/content/docs/cache/index.md @@ -13,7 +13,7 @@ It is the reference cache implementation for the Toapi stack and is usually used npm install @toapi/cache ``` -The Redis and Postgres backends need an extra peer dependency (`@redis/client` or `pg` respectively). Both are optional — install only the one you use. +The Redis and Postgres backends need an extra peer dependency (`@redis/client` or `pg` respectively), and `S3LfsCache` needs `@aws-sdk/client-s3`. All of them are optional — install only the ones you use. ## Backends @@ -24,6 +24,13 @@ All backends implement the same [`Cache`](/toapi/cache/reference/cache/) interfa - **[`RedisCache`](/toapi/cache/reference/redis-cache/)** — Redis-backed distributed cache with pub/sub support. The right choice for multi-host deployments. - **[`PostgresCache`](/toapi/cache/reference/postgres-cache/)** — PostgreSQL-backed distributed cache using `LISTEN`/`NOTIFY`. For multi-host deployments that already run Postgres and would rather not add Redis. +## Large Attachments + +These wrap one of the backends above and change how entries with large attachments are handled: + +- **[`S3LfsCache`](/toapi/cache/reference/s3-lfs-cache/)** — Stores attachments above a size cutoff in an S3 bucket and keeps everything else in the wrapped cache. +- **[`NoLfsCache`](/toapi/cache/reference/no-lfs-cache/)** — Doesn't cache entries with attachments above a size cutoff. + ## Quick Start ```ts @@ -70,3 +77,5 @@ See [Caching](/toapi/server/reference/caching/) for the full picture. - [FilesystemCache](/toapi/cache/reference/filesystem-cache/) — SQLite file-based backend. - [RedisCache](/toapi/cache/reference/redis-cache/) — Redis-backed distributed backend. - [PostgresCache](/toapi/cache/reference/postgres-cache/) — PostgreSQL-backed distributed backend. +- [S3LfsCache](/toapi/cache/reference/s3-lfs-cache/) — offloads large attachments to S3. +- [NoLfsCache](/toapi/cache/reference/no-lfs-cache/) — skips caching large attachments. diff --git a/website/src/content/docs/cache/reference/no-lfs-cache.md b/website/src/content/docs/cache/reference/no-lfs-cache.md new file mode 100644 index 00000000..bc50fb60 --- /dev/null +++ b/website/src/content/docs/cache/reference/no-lfs-cache.md @@ -0,0 +1,54 @@ +--- +title: "NoLfsCache" +description: "A Cache wrapper for @toapi/cache that skips caching entries with large attachments." +--- + +`NoLfsCache` wraps another [`Cache`](/toapi/cache/reference/cache/) and doesn't cache entries whose attachment is larger than a cutoff. Everything else is passed through to the wrapped cache unchanged. + +```ts +import { NoLfsCache } from "@toapi/cache/no-lfs-cache"; +``` + +## Constructor + +```ts +new NoLfsCache(base: Cache, options?: NoLfsCacheOptions) +``` + +- **Parameters**: + - `base`: The cache that all other operations are forwarded to. + - `options`: Optional [`NoLfsCacheOptions`](#nolfscacheoptions). + +### `NoLfsCacheOptions` + +```ts +interface NoLfsCacheOptions { + cutoffBytes?: number; +} +``` + +- `cutoffBytes`: Entries with an attachment larger than this are not cached. Defaults to `1_000_000` (1 MB). + +## Usage + +```ts +import { createClient } from "@redis/client"; +import { NoLfsCache } from "@toapi/cache/no-lfs-cache"; +import { RedisCache } from "@toapi/cache/redis-cache"; + +const redis = createClient(); +await redis.connect(); + +const cache = new NoLfsCache(new RedisCache(redis), { cutoffBytes: 500_000 }); +``` + +## How It Works + +`set` silently returns without writing when the attachment exceeds `cutoffBytes`. `get`, `delete` / `invalidate` and `subscribe` are forwarded to the base cache. + +## When to Use + +- Keeping large binary responses out of a memory-bound cache such as Redis. +- When large attachments are rare or cheap to regenerate, so caching them isn't worth it. + +To cache large attachments in S3 instead, use [`S3LfsCache`](/toapi/cache/reference/s3-lfs-cache/). diff --git a/website/src/content/docs/cache/reference/s3-lfs-cache.md b/website/src/content/docs/cache/reference/s3-lfs-cache.md new file mode 100644 index 00000000..44033289 --- /dev/null +++ b/website/src/content/docs/cache/reference/s3-lfs-cache.md @@ -0,0 +1,88 @@ +--- +title: "S3LfsCache" +description: "A Cache wrapper for @toapi/cache that offloads large attachments to S3 while keeping entries and tags in another backend." +--- + +`S3LfsCache` wraps another [`Cache`](/toapi/cache/reference/cache/) and moves large attachments into an S3 bucket ("large file storage"). Data, small attachments, tags and invalidation stay in the wrapped cache, so Redis or Postgres don't have to hold multi-megabyte blobs. + +```ts +import { S3LfsCache } from "@toapi/cache/s3-lfs-cache"; +``` + +:::note +Requires the `@aws-sdk/client-s3` package as a peer dependency. +::: + +## Constructor + +```ts +new S3LfsCache(base: Cache, client: S3Client, options?: S3LfsCacheOptions) +``` + +- **Parameters**: + - `base`: The cache that stores entries, tags and small attachments, and handles invalidation and subscriptions. Any backend works, e.g. [`RedisCache`](/toapi/cache/reference/redis-cache/) or [`PostgresCache`](/toapi/cache/reference/postgres-cache/). + - `client`: An `S3Client` from `@aws-sdk/client-s3`. Its lifecycle is owned by the caller. + - `options`: Optional [`S3LfsCacheOptions`](#s3lfscacheoptions). + +### `S3LfsCacheOptions` + +```ts +interface S3LfsCacheOptions { + bucket?: string; + cutoffBytes?: number; +} +``` + +- `bucket`: The bucket that large attachments are written to. Set this: S3 requests fail without a bucket. +- `cutoffBytes`: Attachments larger than this are stored in S3. Attachments of this size or smaller stay in the base cache. Defaults to `1_000_000` (1 MB). + +## Usage + +```ts +import { S3Client } from "@aws-sdk/client-s3"; +import { createClient } from "@redis/client"; +import { RedisCache } from "@toapi/cache/redis-cache"; +import { S3LfsCache } from "@toapi/cache/s3-lfs-cache"; + +const redis = createClient(); +await redis.connect(); + +const cache = new S3LfsCache(new RedisCache(redis), new S3Client(), { + bucket: "my-app-cache", +}); + +await cache.set({ + key: "report:2026", + data: { name: "report.pdf" }, + attachment: largePdfBytes, + ttl: 3600, + tags: ["reports"], +}); + +const entry = await cache.get("report:2026"); +// { data: { name: "report.pdf" }, attachment: Uint8Array(...) } +``` + +For S3-compatible servers such as MinIO or [local-s3](https://github.com/shyim/local-s3), pass `endpoint` and `forcePathStyle: true` to the `S3Client`. + +## How It Works + +### Storage + +Every entry written through `S3LfsCache` is stored in the base cache with its data wrapped in an envelope that records where the attachment lives: + +- If the attachment is larger than `cutoffBytes`, it is uploaded to S3 under the cache key, and the base entry stores a reference to that object (including the `VersionId` if the bucket has versioning enabled). The base entry's own attachment is `null`. +- Otherwise the attachment is stored in the base cache as usual. + +`get` reads the base entry and, if it references an S3 object, downloads the object and returns it as the attachment. Entries in the base cache that were not written by `S3LfsCache` are treated as a miss. + +### Invalidation + +`delete` / `invalidate` and `subscribe` are forwarded to the base cache. Invalidating an entry removes its reference, so the S3 object becomes unreachable, but **the object itself is not deleted**. Configure a lifecycle rule on the bucket that expires objects after your longest TTL to clean them up. + +## When to Use + +- Responses with large binary attachments (files, images, generated documents) that would put too much load on the base cache. +- Deployments that already use S3 or an S3-compatible store. + +To skip caching large attachments entirely instead, use [`NoLfsCache`](/toapi/cache/reference/no-lfs-cache/).