From c3b078828e58c19badeb50d5f53df6fcadebfb52 Mon Sep 17 00:00:00 2001 From: lovrozagar Date: Sat, 14 Nov 2026 18:45:41 +0100 Subject: [PATCH 1/3] chore: set up package with per-utility subpath exports Co-authored-by: davidzagar --- .github/workflows/ci.yml | 31 +++++ .gitignore | 4 + .oxfmtrc.jsonc | 4 + .oxlintrc.json | 26 ++++ LICENSE | 21 +++ README.md | 38 +++++ bun.lock | 292 +++++++++++++++++++++++++++++++++++++++ bunfig.toml | 3 + package.json | 61 ++++++++ tsconfig.build.json | 13 ++ tsconfig.json | 26 ++++ vitest.config.ts | 9 ++ 12 files changed, 528 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .oxfmtrc.jsonc create mode 100644 .oxlintrc.json create mode 100644 LICENSE create mode 100644 bun.lock create mode 100644 bunfig.toml create mode 100644 package.json create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..70b9967 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: test + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: package.json + - run: bun install --frozen-lockfile + - run: bun run fmt:check + - run: bun run lint + - run: bun run typecheck + - run: bun run test + - run: bun run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ab7d35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +coverage/ +.tmp/ diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc new file mode 100644 index 0000000..013a949 --- /dev/null +++ b/.oxfmtrc.jsonc @@ -0,0 +1,4 @@ +{ + "printWidth": 120, + "ignorePatterns": ["dist/**", "coverage/**", "*.lock", "**/*.md"], +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..9fe6de8 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,26 @@ +{ + "plugins": ["eslint", "typescript", "unicorn", "oxc", "import", "vitest"], + "categories": { + "correctness": "error", + "suspicious": "error" + }, + "ignorePatterns": ["dist/**", "coverage/**"], + "rules": { + "eslint/max-lines": ["error", { "max": 200, "skipBlankLines": true, "skipComments": true }], + "eslint/no-console": "error", + "import/no-cycle": "error", + "import/no-default-export": "error", + "typescript/no-explicit-any": "error", + "typescript/no-non-null-assertion": "error" + }, + "overrides": [ + { + "files": ["**/*.config.ts"], + "rules": { "import/no-default-export": "off" } + }, + { + "files": ["**/*.test.ts"], + "rules": { "eslint/max-lines": "off", "unicorn/consistent-function-scoping": "off" } + } + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..28637d9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lovro Žagar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 962d5f8..96b9e97 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,41 @@ # ui Fast, SSR-ready UI library built on Solid, TanStack Form and Tailwind. + +Every utility is its own subpath export: import the one you need, the rest is never loaded and is tree-shaken from your bundle. + +```sh +bun add @workerstack/ui +``` + +| Import | What it is | +| --- | --- | +| `@workerstack/ui/lru-cache` | Bounded LRU cache with soft and hard TTLs for stale-while-revalidate | +| `@workerstack/ui/tiered-cache` | Isolate LRU → Workers KV → source cache on a TTL preset, with request bypass and tracing | + +```ts +import { LruCache } from "@workerstack/ui/lru-cache"; + +const cache = new LruCache({ maxSize: 100, ttlMs: 30_000, hardTtlMs: 60_000 }); +cache.set("a", 1); +cache.getWithMeta("a"); // { value: 1, stale: false } +``` + +```ts +import { TieredCache } from "@workerstack/ui/tiered-cache"; + +const users = new TieredCache({ name: "users", preset: "STANDARD", maxSize: 500, kv: { prefix: "user:" } }); + +const user = await users.get(id, { background: ctx.waitUntil.bind(ctx) }, { kv: env.CACHE, fetch: () => db.user(id) }); +``` + +## Development + +```sh +bun install +bun run fmt:check && bun run lint && bun run typecheck && bun run test && bun run build +``` + +## License + +MIT diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..3cceaf4 --- /dev/null +++ b/bun.lock @@ -0,0 +1,292 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "@workerstack/ui", + "devDependencies": { + "oxfmt": "0.78.0", + "oxlint": "1.93.0", + "typescript": "7.0.2", + "vitest": "4.1.10", + }, + }, + }, + "packages": { + "@emnapi/core": ["@emnapi/core@2.0.0-beta.2", "", { "dependencies": { "@emnapi/wasi-threads": "2.2.0", "tslib": "^2.4.0" } }, "sha512-MzCVoWtcg4Pm2B3je5mMvYMvj3MmBKw+fU7vbsJc9/iT8ip6/eYIHo/Dsm/mr+wsHB6SEyJDuxB09Yy6r/8+4A=="], + + "@emnapi/runtime": ["@emnapi/runtime@2.0.0-beta.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-JcrSPzPvZuNZvJqPTF9Fw25LbOJ8avzFVlaf2grmH4Cku0oGP47tRgqPZadcfj3YoZMHkFVnCYFbQM8I+i5QEw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@2.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-JP7OYH38Q7+pyTAe14raZHLQIK7BDKbdwji+Xi3pcd/6QkJYQH3SX4wu6EplGrGJWRepoNzedfm0G3B9Z4e9Rw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || 2.0.0-beta.2", "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2" } }, "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw=="], + + "@oxc-project/types": ["@oxc-project/types@0.156.0", "", {}, "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw=="], + + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-1x9NPGQG5WMOOT8spRP4hPbeqwB/P9ayxFo4EUDSle6mLmtYRqE6suXgHr93/S2nnszy77LlIa8klkKdWtHbMw=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.78.0", "", { "os": "android", "cpu": "arm64" }, "sha512-1muYaW9SiPEq8NIPGX0B20oUwCx6HWiCsZem/2jU7UsOzoXLT4YxWq6ybdhwKSKl0PyAfvyMyPF7FsI1To7AdA=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.78.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jl4KJdo8HrVpLmcSYS/7oDXPH8Hzd6VrzgUFRAbY0j6cSRjcDkUnpuAStuNLoqvMtZEi7Rdy68BBriCgWJcy2g=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.78.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-RwOhQgAQi1qV3TeuTfrgGkCWEL3eDFIql/lo4rt2xpGHqyolKa+W5cFbb+M9IvwMwQbplii5Gbv1ZQQPTJlNug=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.78.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-MAXydj01mLjMUlnmo7sYVm+7kdBdU9Nwaux2Q1f9oLNqt3CCdego1oMoZ30qDyZQGQXfVJZEU1yRV7mSzwKDVA=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-rz6FVZRiWsq3O2YQgCoqZRwaQQaPQ70NBks4+KINjjPJOjBTd8g/iq6ynS97ode+qKGlhXtpc/xCl8LHzfOjyQ=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-lIK0XJi4JP1FnhuLjR/JWhQHGghZc+ghFNnbZTQC8kxsmrkW3ex8CeF7fp0FT+rEP0Nm6voCYkasL8poVWA9sQ=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.78.0", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-o2LJIHfW3ZADOjDVk5c7MM1M8CiAG43GTc4mlJAK23fOHgru4CvnqWedCoCXpIsKokoRdEa7XdQywPGZv1BAiA=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.78.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-G+lUaQk94Vo+4byyd8X2+pPXtd7wObpdbvBC8xyozZD0ROSVBI3XpNNR6HECkJq4Q51AwLuJG81f4x+4eqvSrw=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.78.0", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-r76PAZf9/ZgF/iDRaIf8nLsr4x6ZHe/j2QLmWUjoQ46Sa11qMO68a5u5iux3y8vLzqYTS/xtKO9PjjMfyk5png=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.78.0", "", { "os": "linux", "cpu": "none", "libc": "glibc" }, "sha512-SQiXTt/c4aVoRXkJvhf4Y/sA1XYe3XjSMDbdX5PiAssBuHERoGtncQXz0KKWkXPejWTKlAL4mZSQZdHOCiw7TA=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.78.0", "", { "os": "linux", "cpu": "none", "libc": "musl" }, "sha512-VNB3wFUx/rAqwVNW+uyXxb+c0HUrmcS51UPkFMDWJY95c6Tck6u6HOyVLsqiYoC8kh2mGlwanYmjb/R2McvN0A=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.78.0", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-1nbI0Y7SXwx2f4mJmKt58MaDdjAcoaYM90pBMGdcZCsTW2taFW1/MUi3uQGRPAN2n+3PldCdO9m+D6x+gU/heg=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.78.0", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-I2z2ISPjrl3CSRF5fmQkNnoh46UVU1GjM1V0+COJyNZcj0SDNM4DrPP2/gXCX38YJ1P2BL6sF2VdsJ+pEYEbJQ=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.78.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-YHt5nKKFxT4SruI57FHnEhGRSDuoFqNoAM3moYCClg70mjjS39gBBbdzaZv+N0otdzvQVuHTPu3jFu5tpqC3XQ=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.78.0", "", { "os": "none", "cpu": "arm64" }, "sha512-WH21lBMU+LYzF7aMeozTZlyc44oa2F+I5OtQ7RwuidCDoAxgTyN1s34rx0PBYrio56+2G88U9VOnYJsyV7VMHQ=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.78.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-uNQqHJrtHIiSl8pMbhDktmGpA1hqwldWNO8kbNpNFhVt+BYk4C3Ge8nlJgkRtGjtMam0+542G5UQJJG6lLePbg=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.78.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-W6omURRT39EHBPH4Z8sUPIOzWVjMbhCCCWcfT0sa6aeC+vOXNyBAJmpQRh5//KfPDX96jJlNith9i4N65wxB+Q=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.78.0", "", { "os": "win32", "cpu": "x64" }, "sha512-r9rWKeDVc7lIrj0Us9kP08W7FudYAoWyEdO40qnKk20bHiTbW/z2v4LoIxxUENQyFfApbeV2Gl9XsEgY/tY1dA=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.93.0", "", { "os": "android", "cpu": "arm" }, "sha512-KKw70pbD5Pvq3G2lJ+WQ71XPMjCmg37rkSe2+mDqCRP3PoTSdDMuEi4UOnE8JBtyNM7bek61EC2s0Jt7S7u+Sg=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.93.0", "", { "os": "android", "cpu": "arm64" }, "sha512-fvr1fTUZGelwRxKDt64pf28aL1WaWgviwz3X+E/FcRYvrlEahH0NECRz/c1SY+K+KWmZk5uAf6h1kQnyBkp/2A=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.93.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VdvSN9JRf4Es6ZzoAdvEeh8tgKdw8L4H82WmA8MMPVfPdZGCwE/nI27Z1HrRHYQhPxOh4kmnxQEioSUFrOvDaw=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.93.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-QR1V890Ml0YGRG0F/iV0K9igG+pOhe+VWd+aZnxY849rRHZf3F2gEoQks5+GVHf105X9Mh/6HdShTP5juekG2g=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.93.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jDfIbi30zZaKi+rTvig3sge+Xhi8pFFSgHxb0jzovc4qh8ETm/irKV9c7N/JsuwnYc5sbvf4IC8w1vQQ0IL+BA=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.93.0", "", { "os": "linux", "cpu": "arm" }, "sha512-qtxZ714ND/koFgVH00QgxlzZiIbRPmguaPhnHRU5DFFtrtEdOrSnZD4U/H2AzXrXkZ59eQbMBnJwbum5XohrIQ=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.93.0", "", { "os": "linux", "cpu": "arm" }, "sha512-5pJ9NjMvH7WMUqXB6bTTOSemBm39w2cod27Hu3+Yom/GhbTeNXlYcpDx1o4CgBP6fhp0YHG66TNdVaUm6sHjsA=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.93.0", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-pRf+7jtbt2fHE23SX7qBxRgnBZicUgsoCK1PVIjnOk+t3a6UBkpt/GrKI99WlxsKUxftz/5nv/kfKvScmYywkw=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.93.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-zM8Fd/K8sqGvsBcJBZ3oVZ3ctGWbszA08IaGt5gFFF5RcunppAI/ZnAXbouABvnWeJ//S44KKlfPZnDDbeiphg=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.93.0", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-7qKCnvhZKDXJG2pdbLazd2hw4FxjJHbZOFrrbzyy5PdHfPLH9vnNUa1le01A8uItZuYKPBPncH+HCoEiFT4SgA=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.93.0", "", { "os": "linux", "cpu": "none", "libc": "glibc" }, "sha512-2pgulxC6G8V5wE750NJE/fRkf9aO6i6yoj10wyaR2EdiNtan4OemsOtsQ0ZTXGdY8Q3IoTmlND3GYvjJsvikYg=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.93.0", "", { "os": "linux", "cpu": "none", "libc": "musl" }, "sha512-yWsn6Gg2PnGG5jlyDz5+pHGiMTMiy4adaa4WRMzDgzB8efaShk1JMmTHIBSVLQMh0tYXRs4CN5uJCO79r4Mi4g=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.93.0", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-WCn5plwp/ezS2RDPQKp0/EXEVRr9/dDexUdRKmsMFqzNM5xBSZ1wbsi3PuMk1VfkMRnI5BuazCsziCWMXRDOlw=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.93.0", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-F2CGjb9LAkzH+IYTZSzd1klQ1vk5z6Utjax64WeLByCkYXbVDbsun+Oy2hLfW2MDSKCvW+jwy/9ld0EPgdeu+A=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.93.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-plf5DE2objcG8Tf7mXRuRa8tT8rzDkpjqXQtKoGsKHWkpgsialbON1+cCtoSEUTVbQUjXvuAm8vV9+3rR0yCBw=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.93.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Kp2yJrKvoTe9FfKdO/xwLNUbs2bYTMKx0+aoQD1s0h/Tx0kSgtHSdGmrk8tTKdluosjFbo88qYRIaNIX43AfFw=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.93.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-vigjcHCFsR6KjnWTEVfaCxcMYapEuaK9FZa1MpewwQ1S+Xud3v0okfkj1RySsD6RF1NRHIjSlQSalTnKIaj3nA=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.93.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-0Ot3BEe/AAr2/uQhe55r8CmPlperYk2mQmxdutOru7oU0kir6IiEVHb1TG3GORRlfra97lPHVDLhgmihiKzLrg=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.93.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZzzIIVs/Qkm8trUWYKF0OYub5ALlv50/3gsEZbKAvWgz+yNJX6uRmQ8ySPXU2Q0/Y9m4JCOjhpmC8XUYbPHX5Q=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.19", "", { "os": "android", "cpu": "arm" }, "sha512-e+Ebe0WT+9tbXmNKPIiC8YrWAHYwug3m6i4p3rZGGulfZri1jtI4HslT+OZnFf/OIHowWF71DTlHwXBP2SiA7A=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.19", "", { "os": "android", "cpu": "arm64" }, "sha512-hOombrTJ+szt2ciEzS7oCA78lMPBgF5UMQWCJIyNfnrI7GLl8HYoFqCnpL2gcPOaXMqryGCU5ExdQKyDDcC+yA=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XoMA2pwCKGTQzUjPgtRwkedH4WmXhdIPKT6l6oOxd1PUbNodGI6DcBekgN7EC//+x3pfBYX/qL1gNmZAqivw5g=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-zo8EsSSwMZCY/e+fRnGaX02YGgvJD0yvhNi/9hpGPA2MJ9ZF8dPCw75UGfpI6OpQ/myR/dIYmogRvqiZD0TrXg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.19", "", { "os": "freebsd", "cpu": "x64" }, "sha512-O8ISAOfI1nj7AuBPd30FSF47z7f7lzlk5poQVGgiikvQuGEBFYvW8ki+UEnT+YJPnNGjQzn304klt4O9FUE7mA=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.19", "", { "os": "linux", "cpu": "arm" }, "sha512-/lYCba5NO0yq4S0jGMH8QTBtkvQxoXjoQWzF5BcWp9MjB9jxVRD1GQDNofkVjcOj30DWyhLIN/4Oe60JKzaONw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.19", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-pm/apFMBnSbFb3O6nG5pHO1iqHhZrjGDFEjSm+ot9J17XXPvH9L90y5h+jgfHCE+Qdlsvy8qKEcU4VKxgjD+vA=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.19", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-K1SmQp3FFichn8oqrNIwvFoZ5wcvjC7goBzpaMNvLf9FZc+QB/QvLmnpJZgAAGHiD980tP8FZZnXk2K94rvlxg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.19", "", { "os": "linux", "cpu": "ppc64", "libc": "glibc" }, "sha512-I0xwD57jP/3A7+5UPZin2w8Y7XHEmEM5zqpYZOcQ45MlNW6tUJUziymV3yRr7rL4vwyfEcRsRksC4a7Kc08p5Q=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.19", "", { "os": "linux", "cpu": "s390x", "libc": "glibc" }, "sha512-BrtEHT24SNXj9HiXqy+s/kXiSiQGt6EKCPUlRirAoIioVxKGnbXBUEkbZASAei7iZJB0/Ynm+0JP00Ipx7AnSw=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.19", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-rm75/9DkHKeU2WnA8w4X94WGYgLlDCFCfkQC+4bguVJmC/jvDRv2B+2wLOYhyE0fVuv7//Ckk39OoOufyCu76Q=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.19", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-irh/4+iYFM4GAzpluDErgjz89bj6NDtqqMAG/6O1LG38Ed3NpyebzCpcVNkFW8uhy+/3/BocUvnXDR33CXWe9A=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.19", "", { "os": "none", "cpu": "arm64" }, "sha512-+KWTWNoPt+CfSq94CiceWu3yPE+QzkaG+phPyz9G60tmZLwfQkcA4AeksafzvJAw4pvg1mcd5a1TkUqVv0s4JA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.2.19", "", { "dependencies": { "@emnapi/core": "2.0.0-beta.2", "@emnapi/runtime": "2.0.0-beta.2", "@napi-rs/wasm-runtime": "1.2.6" }, "cpu": "none" }, "sha512-7EH+skOsl5qpWnK0vzamgJFhOv1cwxQpsxm8tI883K9fur5c27p8q87fzEO6BbBPabA2/+s/zR63/pLc137iOg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.19", "", { "os": "win32", "cpu": "arm64" }, "sha512-3Q1Yg1Q7tEvBWoZf5rFvG/GKZQPgwfru+p4wBajDfJFBs0SHCpL++dfTjW9gyAKd1T7ZpIYvdaUax7Pa/IhFVQ=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.19", "", { "os": "win32", "cpu": "x64" }, "sha512-y/sqRRc6d7hECzOFc5n3LSeqctmIp9mMjY1Kq4s14uLPd/LZ4z7i1zrW6fMtp2mPeuQIC4e6ULMcwQzMomqQag=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.1.1", "", {}, "sha512-glFIJDvLNDtJhT9qrMFWLPbvS+skxgt4L0K8Ei2YtnhPysAJnsI7nyxSJxKj4ADeKX0OLjC4ntRkwpLOoS3rwA=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.10", "", {}, "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], + + "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], + + "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], + + "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin", "hasInstallScript": true }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64", "libc": "glibc" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64", "libc": "glibc" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + + "oxfmt": ["oxfmt@0.78.0", "", { "dependencies": { "tinypool": "2.2.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.78.0", "@oxfmt/binding-android-arm64": "0.78.0", "@oxfmt/binding-darwin-arm64": "0.78.0", "@oxfmt/binding-darwin-x64": "0.78.0", "@oxfmt/binding-freebsd-x64": "0.78.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.78.0", "@oxfmt/binding-linux-arm-musleabihf": "0.78.0", "@oxfmt/binding-linux-arm64-gnu": "0.78.0", "@oxfmt/binding-linux-arm64-musl": "0.78.0", "@oxfmt/binding-linux-ppc64-gnu": "0.78.0", "@oxfmt/binding-linux-riscv64-gnu": "0.78.0", "@oxfmt/binding-linux-riscv64-musl": "0.78.0", "@oxfmt/binding-linux-s390x-gnu": "0.78.0", "@oxfmt/binding-linux-x64-gnu": "0.78.0", "@oxfmt/binding-linux-x64-musl": "0.78.0", "@oxfmt/binding-openharmony-arm64": "0.78.0", "@oxfmt/binding-win32-arm64-msvc": "0.78.0", "@oxfmt/binding-win32-ia32-msvc": "0.78.0", "@oxfmt/binding-win32-x64-msvc": "0.78.0" }, "peerDependencies": { "@astrojs/compiler-rs": "^0.5.0", "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["@astrojs/compiler-rs", "svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-uagX+70mKqh/YqxB6ON/lkxVPH6E1Z7pfP2x6XVRPPyiDCZtX6o1xIEeXAIXJ/2wA5ViX4RFuW+IAQn85yJdWg=="], + + "oxlint": ["oxlint@1.93.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.93.0", "@oxlint/binding-android-arm64": "1.93.0", "@oxlint/binding-darwin-arm64": "1.93.0", "@oxlint/binding-darwin-x64": "1.93.0", "@oxlint/binding-freebsd-x64": "1.93.0", "@oxlint/binding-linux-arm-gnueabihf": "1.93.0", "@oxlint/binding-linux-arm-musleabihf": "1.93.0", "@oxlint/binding-linux-arm64-gnu": "1.93.0", "@oxlint/binding-linux-arm64-musl": "1.93.0", "@oxlint/binding-linux-ppc64-gnu": "1.93.0", "@oxlint/binding-linux-riscv64-gnu": "1.93.0", "@oxlint/binding-linux-riscv64-musl": "1.93.0", "@oxlint/binding-linux-s390x-gnu": "1.93.0", "@oxlint/binding-linux-x64-gnu": "1.93.0", "@oxlint/binding-linux-x64-musl": "1.93.0", "@oxlint/binding-openharmony-arm64": "1.93.0", "@oxlint/binding-win32-arm64-msvc": "1.93.0", "@oxlint/binding-win32-ia32-msvc": "1.93.0", "@oxlint/binding-win32-x64-msvc": "1.93.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2005", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-19JMsQFX+YvEbcUTFQxjTwhvs0Ibg0D6CFneSLgpwnACWcY/6LjhpGpkPodh0T5aIT1pjMTLlYaeBFzaVgugTA=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss": ["postcss@8.5.33", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-RVHEU9/Pp1Fnjr/MpAJsZoADukCKhfIi15OunEYiGktIkYLkPVvC1sioWolGDS7XByMTx9m/IGbro0Js13zhnQ=="], + + "rolldown": ["rolldown@1.2.19", "", { "dependencies": { "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.19", "@rolldown/binding-android-arm64": "1.2.19", "@rolldown/binding-darwin-arm64": "1.2.19", "@rolldown/binding-darwin-x64": "1.2.19", "@rolldown/binding-freebsd-x64": "1.2.19", "@rolldown/binding-linux-arm-gnueabihf": "1.2.19", "@rolldown/binding-linux-arm64-gnu": "1.2.19", "@rolldown/binding-linux-arm64-musl": "1.2.19", "@rolldown/binding-linux-ppc64-gnu": "1.2.19", "@rolldown/binding-linux-s390x-gnu": "1.2.19", "@rolldown/binding-linux-x64-gnu": "1.2.19", "@rolldown/binding-linux-x64-musl": "1.2.19", "@rolldown/binding-openharmony-arm64": "1.2.19", "@rolldown/binding-wasm32-wasi": "1.2.19", "@rolldown/binding-win32-arm64-msvc": "1.2.19", "@rolldown/binding-win32-x64-msvc": "1.2.19" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-QIiV1J9d0pzN4jeeTH4sjPy0AZ6cAy2cG0ExeoE1+U8Rvj+hR3gYkokx0YbeUNGlZqiQJJ2utcETe0oKXwzsuA=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tinypool": ["tinypool@2.2.0", "", {}, "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ=="], + + "tinyrainbow": ["tinyrainbow@3.2.0", "", {}, "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A=="], + + "tslib": ["tslib@2.9.1", "", {}, "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "vite": ["vite@8.3.8", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.32", "rolldown": "~1.2.18", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-LzOn3h9rG/0vhLdXrV4yJ4QFA9eEoqCl+Yx+kGJd7aB9w6nfNgVHKSYwLPZUHrz4ZGa+t+kXjV9itvcHDLQouA=="], + + "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..67fa452 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,3 @@ +[install] +linker = "hoisted" +exact = true diff --git a/package.json b/package.json new file mode 100644 index 0000000..d76fc40 --- /dev/null +++ b/package.json @@ -0,0 +1,61 @@ +{ + "name": "@workerstack/ui", + "version": "0.0.0", + "description": "Fast, SSR-ready UI library built on Solid, TanStack Form and Tailwind", + "keywords": [ + "solid", + "ui", + "workerstack" + ], + "homepage": "https://github.com/workerstack/ui#readme", + "bugs": { + "url": "https://github.com/workerstack/ui/issues" + }, + "license": "MIT", + "author": "Lovro Žagar", + "repository": { + "type": "git", + "url": "git+https://github.com/workerstack/ui.git" + }, + "files": [ + "dist", + "!dist/**/*.map", + "README.md", + "LICENSE" + ], + "type": "module", + "sideEffects": false, + "exports": { + "./lru-cache": { + "types": "./dist/lru-cache/index.d.ts", + "default": "./dist/lru-cache/index.js" + }, + "./tiered-cache": { + "types": "./dist/tiered-cache/index.d.ts", + "default": "./dist/tiered-cache/index.js" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "fmt": "oxfmt", + "fmt:check": "oxfmt --check", + "lint": "oxlint --deny-warnings", + "prepublishOnly": "bun run build", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json" + }, + "devDependencies": { + "oxfmt": "0.78.0", + "oxlint": "1.93.0", + "typescript": "7.0.2", + "vitest": "4.1.10" + }, + "engines": { + "node": ">=22.12.0" + }, + "packageManager": "bun@1.4.4" +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..57cb522 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4ab9ac8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "declaration": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "isolatedDeclarations": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src", "vitest.config.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..7337d78 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig, type ViteUserConfig } from "vitest/config"; + +const config: ViteUserConfig = defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); + +export default config; From 48f1be63e59ef4f69da673048fa0929a7a981c76 Mon Sep 17 00:00:00 2001 From: davidzagar Date: Sat, 14 Nov 2026 18:45:41 +0100 Subject: [PATCH 2/3] feat(lru-cache): add LRU cache with soft and hard TTLs Co-authored-by: lovrozagar --- src/lru-cache/index.ts | 2 + src/lru-cache/lru-cache.test.ts | 273 +++++++++++++++++++++++++++++++ src/lru-cache/lru-cache.ts | 108 ++++++++++++ src/lru-cache/lru-cache.types.ts | 14 ++ 4 files changed, 397 insertions(+) create mode 100644 src/lru-cache/index.ts create mode 100644 src/lru-cache/lru-cache.test.ts create mode 100644 src/lru-cache/lru-cache.ts create mode 100644 src/lru-cache/lru-cache.types.ts diff --git a/src/lru-cache/index.ts b/src/lru-cache/index.ts new file mode 100644 index 0000000..edc31f1 --- /dev/null +++ b/src/lru-cache/index.ts @@ -0,0 +1,2 @@ +export { LruCache } from "./lru-cache.ts"; +export type { LruCacheOptions, LruMeta } from "./lru-cache.types.ts"; diff --git a/src/lru-cache/lru-cache.test.ts b/src/lru-cache/lru-cache.test.ts new file mode 100644 index 0000000..d52bde2 --- /dev/null +++ b/src/lru-cache/lru-cache.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from "vitest"; +import { LruCache } from "./lru-cache.ts"; + +describe("LruCache", () => { + describe("basic operations", () => { + it("should set and get values", () => { + const cache = new LruCache({ maxSize: 10 }); + + cache.set("a", 1); + cache.set("b", 2); + + expect(cache.get("a")).toBe(1); + expect(cache.get("b")).toBe(2); + }); + + it("should return undefined for missing keys", () => { + const cache = new LruCache({ maxSize: 10 }); + + expect(cache.get("missing")).toBeUndefined(); + }); + + it("should update existing keys", () => { + const cache = new LruCache({ maxSize: 10 }); + + cache.set("a", 1); + cache.set("a", 2); + + expect(cache.get("a")).toBe(2); + expect(cache.size).toBe(1); + }); + + it("should delete keys", () => { + const cache = new LruCache({ maxSize: 10 }); + + cache.set("a", 1); + expect(cache.delete("a")).toBe(true); + expect(cache.get("a")).toBeUndefined(); + expect(cache.delete("a")).toBe(false); + }); + + it("should clear all entries", () => { + const cache = new LruCache({ maxSize: 10 }); + + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + + cache.clear(); + + expect(cache.size).toBe(0); + expect(cache.get("a")).toBeUndefined(); + expect(cache.get("b")).toBeUndefined(); + expect(cache.get("c")).toBeUndefined(); + }); + + it("should track size correctly", () => { + const cache = new LruCache({ maxSize: 10 }); + + expect(cache.size).toBe(0); + + cache.set("a", 1); + expect(cache.size).toBe(1); + + cache.set("b", 2); + expect(cache.size).toBe(2); + + cache.delete("a"); + expect(cache.size).toBe(1); + + cache.clear(); + expect(cache.size).toBe(0); + }); + + it("should check key existence with has", () => { + const cache = new LruCache({ maxSize: 10 }); + + expect(cache.has("a")).toBe(false); + + cache.set("a", 1); + expect(cache.has("a")).toBe(true); + + cache.delete("a"); + expect(cache.has("a")).toBe(false); + }); + }); + + describe("LRU eviction", () => { + it("should evict oldest entry when max size reached", () => { + const cache = new LruCache({ maxSize: 3 }); + + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + + expect(cache.size).toBe(3); + + cache.set("d", 4); + + expect(cache.size).toBe(3); + expect(cache.get("a")).toBeUndefined(); /* oldest evicted */ + expect(cache.get("b")).toBe(2); + expect(cache.get("c")).toBe(3); + expect(cache.get("d")).toBe(4); + }); + + it("should update access order on get", () => { + const cache = new LruCache({ maxSize: 3 }); + + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + + /* access 'a' to make it most recently used */ + cache.get("a"); + + /* add new entry, should evict 'b' (oldest) not 'a' */ + cache.set("d", 4); + + expect(cache.get("a")).toBe(1); /* still exists */ + expect(cache.get("b")).toBeUndefined(); /* evicted */ + expect(cache.get("c")).toBe(3); + expect(cache.get("d")).toBe(4); + }); + + it("should update access order on set", () => { + const cache = new LruCache({ maxSize: 3 }); + + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + + /* update 'a' to make it most recently used */ + cache.set("a", 10); + + /* add new entry, should evict 'b' (oldest) */ + cache.set("d", 4); + + expect(cache.get("a")).toBe(10); /* still exists, updated */ + expect(cache.get("b")).toBeUndefined(); /* evicted */ + expect(cache.get("c")).toBe(3); + expect(cache.get("d")).toBe(4); + }); + + it("should handle single-entry cache", () => { + const cache = new LruCache({ maxSize: 1 }); + + cache.set("a", 1); + expect(cache.get("a")).toBe(1); + + cache.set("b", 2); + expect(cache.get("a")).toBeUndefined(); + expect(cache.get("b")).toBe(2); + }); + }); + + describe("TTL expiration", () => { + it("should expire entries after TTL", async () => { + const cache = new LruCache({ maxSize: 10, ttlMs: 50 }); + + cache.set("a", 1); + expect(cache.get("a")).toBe(1); + + await new Promise((r) => setTimeout(r, 60)); + + expect(cache.get("a")).toBeUndefined(); + }); + + it("should not expire entries before TTL", async () => { + const cache = new LruCache({ maxSize: 10, ttlMs: 100 }); + + cache.set("a", 1); + + await new Promise((r) => setTimeout(r, 50)); + expect(cache.get("a")).toBe(1); + }); + + it("should support per-key TTL override", async () => { + const cache = new LruCache({ maxSize: 10, ttlMs: 100 }); + + cache.set("default", 1); + cache.set("custom", 2, 50); + + await new Promise((r) => setTimeout(r, 60)); + + expect(cache.get("custom")).toBeUndefined(); /* expired after 50ms */ + expect(cache.get("default")).toBe(1); /* still valid, 100ms TTL */ + }); + + it("should remove expired entries on has check", async () => { + const cache = new LruCache({ maxSize: 10, ttlMs: 50 }); + + cache.set("a", 1); + expect(cache.has("a")).toBe(true); + + await new Promise((r) => setTimeout(r, 60)); + + expect(cache.has("a")).toBe(false); + expect(cache.size).toBe(0); + }); + + it("should handle cache without TTL", () => { + const cache = new LruCache({ maxSize: 10 }); + + cache.set("a", 1); + + /* immediately check - should still exist since no TTL */ + expect(cache.get("a")).toBe(1); + }); + }); + + describe("edge cases", () => { + it("should handle various key types", () => { + const cache = new LruCache({ maxSize: 10 }); + + cache.set(1, "number key"); + cache.set("string", "string key"); + + expect(cache.get(1)).toBe("number key"); + expect(cache.get("string")).toBe("string key"); + }); + + it("should handle object values", () => { + const cache = new LruCache({ maxSize: 10 }); + + const obj = { data: "test" }; + cache.set("key", obj); + + expect(cache.get("key")).toEqual(obj); + expect(cache.get("key")).toBe(obj); /* same reference */ + }); + + it("should handle null and undefined values", () => { + const cache = new LruCache({ maxSize: 10 }); + + cache.set("null", null); + cache.set("undefined", undefined); + + expect(cache.get("null")).toBeNull(); + expect(cache.get("undefined")).toBeUndefined(); + expect(cache.has("null")).toBe(true); + expect(cache.has("undefined")).toBe(true); + }); + + it("should handle rapid set/get operations", () => { + const cache = new LruCache({ maxSize: 100 }); + + for (let i = 0; i < 1000; i++) { + cache.set(i, i * 2); + } + + expect(cache.size).toBe(100); /* only last 100 */ + + for (let i = 900; i < 1000; i++) { + expect(cache.get(i)).toBe(i * 2); + } + + expect(cache.get(0)).toBeUndefined(); /* evicted */ + expect(cache.get(899)).toBeUndefined(); /* evicted */ + }); + + it("should handle empty cache operations", () => { + const cache = new LruCache({ maxSize: 10 }); + + expect(cache.get("missing")).toBeUndefined(); + expect(cache.has("missing")).toBe(false); + expect(cache.delete("missing")).toBe(false); + expect(cache.size).toBe(0); + + cache.clear(); + expect(cache.size).toBe(0); + }); + }); +}); diff --git a/src/lru-cache/lru-cache.ts b/src/lru-cache/lru-cache.ts new file mode 100644 index 0000000..fbd3e57 --- /dev/null +++ b/src/lru-cache/lru-cache.ts @@ -0,0 +1,108 @@ +import type { LruCacheOptions, LruEntry, LruMeta } from "./lru-cache.types.ts"; + +/** + * LRU (Least Recently Used) cache with optional TTL and stale-while-revalidate. + * Bounds cache size and auto-evicts expired entries. + * + * When `hardTtlMs` is set, `getWithMeta()` returns stale entries between soft and hard expiry, + * so callers can serve stale data while revalidating in the background. + */ +export class LruCache { + private readonly cache = new Map>(); + private readonly hardTtlMs: number | undefined; + private readonly maxSize: number; + private readonly ttlMs: number | undefined; + + constructor(options: LruCacheOptions) { + this.maxSize = options.maxSize; + this.ttlMs = options.ttlMs; + this.hardTtlMs = options.hardTtlMs; + } + + /** The value, or undefined when missing or past soft expiry. Marks the key most recently used. */ + get(key: K): V | undefined { + const entry = this.fresh(key); + if (entry === undefined) return undefined; + this.touch(key, entry); + return entry.value; + } + + /** + * The value with its staleness, for stale-while-revalidate: + * - before soft expiry: `{ value, stale: false }` + * - between soft and hard expiry: `{ value, stale: true }` + * - after hard expiry or not found: undefined + */ + getWithMeta(key: K): LruMeta | undefined { + const entry = this.cache.get(key); + if (entry === undefined) return undefined; + const now = Date.now(); + if (now > entry.hardExpiry) { + this.cache.delete(key); + return undefined; + } + this.touch(key, entry); + return { stale: now > entry.softExpiry, value: entry.value }; + } + + /** Set a value, evicting the least recently used entry when full. `ttlMs` overrides the soft TTL. */ + set(key: K, value: V, ttlMs?: number): void { + this.insert(key, value, { soft: ttlMs ?? this.ttlMs, storedAt: Date.now() }); + } + + /** + * Set a value read at `storedAt` (ms since epoch): its soft and hard expiry run from then, not + * from now, so a copy reloaded from another tier never gets a new window. + */ + setAt(key: K, value: V, storedAt: number): void { + this.insert(key, value, { soft: this.ttlMs, storedAt }); + } + + /** Delete a key; whether it was there. */ + delete(key: K): boolean { + return this.cache.delete(key); + } + + /** Remove every entry. */ + clear(): void { + this.cache.clear(); + } + + /** The number of entries, expired ones not yet evicted included. */ + get size(): number { + return this.cache.size; + } + + /** Whether the key is present and not past soft expiry. Does not mark it used. */ + has(key: K): boolean { + return this.fresh(key) !== undefined; + } + + /** The entry while it is before soft expiry; one past hard expiry is evicted. */ + private fresh(key: K): LruEntry | undefined { + const entry = this.cache.get(key); + if (entry === undefined) return undefined; + const now = Date.now(); + if (now <= entry.softExpiry) return entry; + if (now > entry.hardExpiry) this.cache.delete(key); + return undefined; + } + + /** Move to the end of the Map's insertion order: the most recently used. */ + private touch(key: K, entry: LruEntry): void { + this.cache.delete(key); + this.cache.set(key, entry); + } + + private insert(key: K, value: V, ttl: { soft: number | undefined; storedAt: number }): void { + this.cache.delete(key); + if (this.cache.size >= this.maxSize) { + /* the Map's first key is the least recently used */ + const oldest = this.cache.keys().next(); + if (oldest.done !== true) this.cache.delete(oldest.value); + } + const softExpiry = ttl.soft ? ttl.storedAt + ttl.soft : Number.POSITIVE_INFINITY; + const hardExpiry = this.hardTtlMs ? ttl.storedAt + this.hardTtlMs : softExpiry; + this.cache.set(key, { hardExpiry, softExpiry, value }); + } +} diff --git a/src/lru-cache/lru-cache.types.ts b/src/lru-cache/lru-cache.types.ts new file mode 100644 index 0000000..89f0964 --- /dev/null +++ b/src/lru-cache/lru-cache.types.ts @@ -0,0 +1,14 @@ +/** One cached value and when it stops being fresh (soft) and when it is dropped (hard), in ms since epoch. */ +export type LruEntry = { hardExpiry: number; softExpiry: number; value: V }; + +/** A value and whether it is past its soft TTL, for stale-while-revalidate. */ +export type LruMeta = { stale: boolean; value: V }; + +export type LruCacheOptions = { + /** Hard TTL: an entry is evicted after this, even by `getWithMeta()`. */ + hardTtlMs?: number; + /** Maximum number of entries. */ + maxSize: number; + /** Soft TTL: an entry is stale after this, and a miss for `get()`. */ + ttlMs?: number; +}; From e57b014ea14d962ba1479121e69d9b28cd5a6977 Mon Sep 17 00:00:00 2001 From: lovrozagar Date: Sat, 14 Nov 2026 18:45:41 +0100 Subject: [PATCH 3/3] feat(tiered-cache): add LRU, KV and source tiered cache Co-authored-by: davidzagar --- src/tiered-cache/asks-bypass.ts | 8 + src/tiered-cache/index.ts | 16 + src/tiered-cache/resolve-kv-ops.ts | 28 ++ src/tiered-cache/tiered-cache.constants.ts | 22 ++ src/tiered-cache/tiered-cache.test.ts | 328 +++++++++++++++++++++ src/tiered-cache/tiered-cache.ts | 147 +++++++++ src/tiered-cache/tiered-cache.types.ts | 78 +++++ 7 files changed, 627 insertions(+) create mode 100644 src/tiered-cache/asks-bypass.ts create mode 100644 src/tiered-cache/index.ts create mode 100644 src/tiered-cache/resolve-kv-ops.ts create mode 100644 src/tiered-cache/tiered-cache.constants.ts create mode 100644 src/tiered-cache/tiered-cache.test.ts create mode 100644 src/tiered-cache/tiered-cache.ts create mode 100644 src/tiered-cache/tiered-cache.types.ts diff --git a/src/tiered-cache/asks-bypass.ts b/src/tiered-cache/asks-bypass.ts new file mode 100644 index 0000000..6405ba2 --- /dev/null +++ b/src/tiered-cache/asks-bypass.ts @@ -0,0 +1,8 @@ +import { FLAG_ON, NO_CACHE } from "./tiered-cache.constants.ts"; + +/** Whether a request asks past the caches: `x-fresh: 1`, or a `Cache-Control` listing `no-cache`. */ +export function asksBypass(headers: { get(name: string): string | null }): boolean { + if (headers.get("x-fresh") === FLAG_ON) return true; + const directives = (headers.get("cache-control") ?? "").toLowerCase().split(","); + return directives.some((directive) => directive.trim() === NO_CACHE); +} diff --git a/src/tiered-cache/index.ts b/src/tiered-cache/index.ts new file mode 100644 index 0000000..fbda0f3 --- /dev/null +++ b/src/tiered-cache/index.ts @@ -0,0 +1,16 @@ +export { asksBypass } from "./asks-bypass.ts"; +export { CACHE_PRESETS } from "./tiered-cache.constants.ts"; +export { TieredCache } from "./tiered-cache.ts"; +export type { + CacheCtx, + CachePreset, + CachePresetTtl, + CacheRequest, + CacheTier, + CacheTraceEntry, + GetOptions, + InvalidateOptions, + SetOptions, + TieredCacheKv, + TieredCacheOptions, +} from "./tiered-cache.types.ts"; diff --git a/src/tiered-cache/resolve-kv-ops.ts b/src/tiered-cache/resolve-kv-ops.ts new file mode 100644 index 0000000..973b85e --- /dev/null +++ b/src/tiered-cache/resolve-kv-ops.ts @@ -0,0 +1,28 @@ +import { CACHE_PRESETS, KV_MAX_KEY_BYTES } from "./tiered-cache.constants.ts"; +import type { CachePreset, KvOps, Stored, TieredCacheKv } from "./tiered-cache.types.ts"; + +const utf8 = new TextEncoder(); + +/** + * KV operations for `key`, under `v2:` so values written before the stored-at envelope + * are never read. Keys past the KV limit (often user input such as a slug) skip L2. + */ +export function resolveKvOps(target: { + key: string; + ns: TieredCacheKv | undefined; + prefix: string | undefined; + preset: CachePreset; +}): KvOps | undefined { + const { key, ns, prefix, preset } = target; + if (ns === undefined || prefix === undefined) return undefined; + const fullKey = `${prefix}v2:${key}`; + if (utf8.encode(fullKey).length > KV_MAX_KEY_BYTES) return undefined; + const { hardS } = CACHE_PRESETS[preset]; + /* KV refuses a non-finite TTL: an IMMUTABLE row is written without one */ + const putOptions = Number.isFinite(hardS) ? { expirationTtl: hardS } : undefined; + return { + del: () => ns.delete(fullKey), + get: () => ns.get>(fullKey, "json"), + put: (stored) => ns.put(fullKey, JSON.stringify(stored), putOptions), + }; +} diff --git a/src/tiered-cache/tiered-cache.constants.ts b/src/tiered-cache/tiered-cache.constants.ts new file mode 100644 index 0000000..16c7c1b --- /dev/null +++ b/src/tiered-cache/tiered-cache.constants.ts @@ -0,0 +1,22 @@ +import type { CachePreset, CachePresetTtl } from "./tiered-cache.types.ts"; + +export const NO_CACHE = "no-cache"; +export const FLAG_ON = "1"; + +/** Workers KV rejects keys over 512 UTF-8 bytes (414). */ +export const KV_MAX_KEY_BYTES = 512; + +export const MS = 1000; + +/** + * How long a cache keeps a value, in seconds. Soft is when it is revalidated in the background; + * hard is the most stale an answer can ever be, since an entry past soft is still served once, to + * the read that revalidates it. SHORT's hard TTL is KV's 60 s floor. IMMUTABLE is for a value + * that never changes once it exists: no tier expires it, only an invalidation removes it. + */ +export const CACHE_PRESETS: Readonly> = { + IMMUTABLE: { hardS: Number.POSITIVE_INFINITY, softS: Number.POSITIVE_INFINITY }, + LONG: { hardS: 86_400, softS: 3600 }, + SHORT: { hardS: 60, softS: 30 }, + STANDARD: { hardS: 600, softS: 120 }, +}; diff --git a/src/tiered-cache/tiered-cache.test.ts b/src/tiered-cache/tiered-cache.test.ts new file mode 100644 index 0000000..0c0be9a --- /dev/null +++ b/src/tiered-cache/tiered-cache.test.ts @@ -0,0 +1,328 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { asksBypass, CACHE_PRESETS, type CacheRequest, TieredCache, type TieredCacheKv } from "./index.ts"; + +/** Mirrors Workers KV: keys over 512 UTF-8 bytes are rejected with 414. */ +function fakeKv() { + const store = new Map(); + const ttls = new Map(); + const check = (key: string) => { + const bytes = new TextEncoder().encode(key).length; + if (bytes > 512) { + throw new Error(`KV GET failed: 414 UTF-8 encoded length of ${bytes} exceeds key length limit of 512.`); + } + }; + const kv: TieredCacheKv = { + delete: async (key) => { + check(key); + store.delete(key); + }, + get: async (key) => { + check(key); + const raw = store.get(key); + return raw === undefined ? null : JSON.parse(raw); + }, + put: async (key, value, options) => { + check(key); + store.set(key, value); + ttls.set(key, options?.expirationTtl); + }, + }; + return { kv, store, ttls }; +} + +function ctx(cacheRequest?: CacheRequest) { + const pending: Promise[] = []; + return { + background: (p: Promise) => { + pending.push(p); + }, + pending, + ...(cacheRequest === undefined ? {} : { cacheRequest }), + }; +} + +const S = 1000; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(0); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe("cache presets", () => { + it("are SHORT 30/60 s, STANDARD 2/10 min, LONG 1/24 h and IMMUTABLE never, soft before hard", () => { + expect(CACHE_PRESETS).toEqual({ + IMMUTABLE: { hardS: Number.POSITIVE_INFINITY, softS: Number.POSITIVE_INFINITY }, + LONG: { hardS: 86_400, softS: 3600 }, + SHORT: { hardS: 60, softS: 30 }, + STANDARD: { hardS: 600, softS: 120 }, + }); + }); + + it("keep an IMMUTABLE value forever: no KV expiry, no revalidation from memory or KV", async () => { + const options = { + kv: { prefix: "imm:" }, + maxSize: 10, + name: "imm", + preset: "IMMUTABLE", + } as const; + const { kv, store, ttls } = fakeKv(); + let fetches = 0; + const fetch = async () => { + fetches += 1; + return "org_1"; + }; + const c = ctx(); + expect(await new TieredCache(options).get("p1", c, { fetch, kv })).toBe("org_1"); + await Promise.all(c.pending); + expect([...ttls.values()]).toEqual([undefined]); + expect(store.size).toBe(1); + vi.setSystemTime(400 * 86_400 * S); + const later = ctx(); + const memory = new TieredCache(options); + expect(await memory.get("p1", later, { fetch, kv })).toBe("org_1"); + expect(await memory.get("p1", later, { fetch, kv })).toBe("org_1"); + await Promise.all(later.pending); + expect(fetches).toBe(1); + }); + + it("keep a KV row for the hard TTL, under a v2 key holding when its value was read", async () => { + const cache = new TieredCache({ + kv: { prefix: "tmpl:" }, + maxSize: 10, + name: "template", + preset: "STANDARD", + }); + const { kv, store, ttls } = fakeKv(); + const c = ctx(); + await cache.get("t1", c, { fetch: async () => "row", kv }); + await Promise.all(c.pending); + expect(store.get("tmpl:v2:t1")).toBe('{"at":0,"v":"row"}'); + expect(ttls.get("tmpl:v2:t1")).toBe(600); + expect([cache.name, cache.preset]).toEqual(["template", "STANDARD"]); + }); +}); + +describe("TieredCache staleness bound", () => { + it("never lets a KV copy reloaded into another isolate outlive the hard TTL from when it was read", async () => { + const { kv } = fakeKv(); + const options = { + kv: { prefix: "tmpl:" }, + maxSize: 10, + name: "template", + preset: "STANDARD" as const, + }; + const first = new TieredCache(options); + const second = new TieredCache(options); + const c = ctx(); + await first.get("t1", c, { fetch: async () => "old", kv }); + await Promise.all(c.pending); + vi.setSystemTime(590 * S); + expect(await second.get("t1", c, { fetch: async () => "new", kv })).toBe("old"); + vi.setSystemTime(601 * S); + expect(await second.get("t1", c, { fetch: async () => "new", kv })).toBe("new"); + }); + + it("times a value from before its fetch, so one read before a write still dies at its own hard TTL", async () => { + const cache = new TieredCache({ + kv: { prefix: "tmpl:" }, + maxSize: 10, + name: "template", + preset: "STANDARD", + }); + const { kv, store } = fakeKv(); + const c = ctx(); + await cache.get("t1", c, { + fetch: async () => { + vi.setSystemTime(10 * S); + return "read before the write"; + }, + kv, + }); + await Promise.all(c.pending); + expect(JSON.parse(store.get("tmpl:v2:t1") as string).at).toBe(0); + vi.setSystemTime(600 * S + 1); + expect(await cache.get("t1", c, { fetch: async () => "fresh", kv })).toBe("fresh"); + }); + + it("bounds a value whose background KV delete never landed by the same hard TTL", async () => { + const { kv } = fakeKv(); + const options = { + kv: { prefix: "usr:" }, + maxSize: 10, + name: "user", + preset: "STANDARD" as const, + }; + const cache = new TieredCache(options); + const other = new TieredCache(options); + const c = ctx(); + await cache.get("u1", c, { fetch: async () => "old", kv }); + await Promise.all(c.pending); + const lost = { ...kv, delete: () => new Promise(() => {}) }; + void cache.invalidate("u1", { background: () => {}, kv: lost }); + vi.setSystemTime(601 * S); + expect(await other.get("u1", c, { fetch: async () => "new", kv })).toBe("new"); + }); +}); + +describe("TieredCache revalidation", () => { + it("serves a stale entry once and refreshes it from the source when KV's copy is stale too", async () => { + const cache = new TieredCache({ + kv: { prefix: "org:" }, + maxSize: 10, + name: "org", + preset: "STANDARD", + }); + const { kv, store } = fakeKv(); + const first = ctx(); + await cache.get("o1", first, { fetch: async () => "old", kv }); + await Promise.all(first.pending); + vi.setSystemTime(121 * S); + const stale = ctx(); + expect(await cache.get("o1", stale, { fetch: async () => "new", kv })).toBe("old"); + await Promise.all(stale.pending); + await Promise.all(stale.pending); + expect(JSON.parse(store.get("org:v2:o1") as string)).toEqual({ at: 121 * S, v: "new" }); + expect(await cache.get("o1", ctx(), { fetch: async () => "newer", kv })).toBe("new"); + }); +}); + +describe("asksBypass", () => { + it("reads x-fresh: 1 and a Cache-Control that lists no-cache, in any case or position", () => { + const of = (headers: Record) => asksBypass(new Headers(headers)); + expect(of({ "x-fresh": "1" })).toBe(true); + expect(of({ "cache-control": "max-age=0, No-Cache" })).toBe(true); + expect(of({ "cache-control": "no-cache" })).toBe(true); + }); + + it("reads nothing else as a bypass", () => { + const of = (headers: Record) => asksBypass(new Headers(headers)); + expect(of({})).toBe(false); + expect(of({ "x-fresh": "0" })).toBe(false); + expect(of({ "cache-control": "max-age=60" })).toBe(false); + expect(of({ "cache-control": "no-store" })).toBe(false); + }); +}); + +describe("TieredCache bypass and trace", () => { + it("asks the source past both tiers and refills them when the request may bypass, and traces each tier", async () => { + const cache = new TieredCache({ + kv: { prefix: "tmpl:" }, + maxSize: 10, + name: "template", + preset: "STANDARD", + }); + const { kv } = fakeKv(); + const plain: CacheRequest = { bypass: false, trace: [] }; + await cache.get("t1", ctx(plain), { fetch: async () => "v1", kv }); + vi.setSystemTime(5 * S); + await cache.get("t1", ctx(plain), { fetch: async () => "v2", kv }); + const bypass: CacheRequest = { bypass: true, trace: [] }; + expect(await cache.get("t1", ctx(bypass), { fetch: async () => "v2", kv })).toBe("v2"); + expect(plain.trace).toEqual([ + { cache: "template", stale: false, stored: true, storedAt: 0, tier: "origin" }, + { cache: "template", stale: false, stored: true, storedAt: 0, tier: "lru" }, + ]); + expect(bypass.trace).toEqual([{ cache: "template", stale: false, stored: true, storedAt: 5 * S, tier: "bypass" }]); + expect(await cache.get("t1", ctx(), { fetch: async () => "v3", kv })).toBe("v2"); + }); + + it("traces a KV answer, and a stale one past the soft TTL", async () => { + const { kv } = fakeKv(); + const options = { + kv: { prefix: "tmpl:" }, + maxSize: 10, + name: "template", + preset: "STANDARD" as const, + }; + const c = ctx(); + await new TieredCache(options).get("t1", c, { fetch: async () => "row", kv }); + await Promise.all(c.pending); + const cache = new TieredCache(options); + const fromKv: CacheRequest = { bypass: false, trace: [] }; + await cache.get("t1", ctx(fromKv), { fetch: async () => "row", kv }); + vi.setSystemTime(121 * S); + const stale: CacheRequest = { bypass: false, trace: [] }; + await cache.get("t1", ctx(stale), { fetch: async () => "row", kv }); + expect(fromKv.trace).toEqual([{ cache: "template", stale: false, stored: true, storedAt: 0, tier: "kv" }]); + expect(stale.trace).toEqual([{ cache: "template", stale: true, stored: true, storedAt: 0, tier: "lru" }]); + }); + + it("traces a source that found nothing as stored nowhere", async () => { + const cache = new TieredCache({ + kv: { prefix: "art:" }, + maxSize: 10, + name: "article", + preset: "LONG", + }); + const { kv } = fakeKv(); + const read: CacheRequest = { bypass: false, trace: [] }; + expect(await cache.get("missing", ctx(read), { fetch: async () => undefined, kv })).toBeUndefined(); + expect(read.trace).toEqual([{ cache: "article", stale: false, stored: false, storedAt: 0, tier: "origin" }]); + }); + + it("never bypasses a cache built without one, whatever the request may do", async () => { + const cache = new TieredCache({ + bypass: false, + kv: { prefix: "tpr:" }, + maxSize: 10, + name: "tierPricing", + preset: "LONG", + }); + const { kv } = fakeKv(); + await cache.get("all", ctx(), { fetch: async () => "catalog", kv }); + const asked: CacheRequest = { bypass: true, trace: [] }; + expect(await cache.get("all", ctx(asked), { fetch: async () => "stripe call", kv })).toBe("catalog"); + expect(asked.trace[0]?.tier).toBe("lru"); + }); +}); + +describe("TieredCache KV keys", () => { + it("serves a key longer than the KV limit from the source instead of throwing", async () => { + const cache = new TieredCache({ + kv: { prefix: "art:" }, + maxSize: 10, + name: "article", + preset: "LONG", + }); + const { kv, store } = fakeKv(); + const c = ctx(); + const longKey = `${".prose { color: var(--color-ink-2); } ".repeat(40)}:en`; + + await expect(cache.get(longKey, c, { fetch: async () => undefined, kv })).resolves.toBeUndefined(); + await expect(cache.get(longKey, c, { fetch: async () => "row", kv })).resolves.toBe("row"); + await Promise.all(c.pending); + expect(store.size).toBe(0); + await expect(cache.invalidate(longKey, { background: c.background, kv })).resolves.toBeUndefined(); + }); +}); + +describe("TieredCache without the isolate tier", () => { + /* Two isolates share KV and the source, never memory. */ + it("reads what another isolate wrote, so a user sees their own change on the next request", async () => { + const { kv } = fakeKv(); + let source = { dismissed: false }; + const options = { + isolate: false, + kv: { prefix: "obd:" }, + maxSize: 10, + name: "onboarding", + preset: "STANDARD" as const, + }; + const reader = new TieredCache<{ dismissed: boolean }>(options); + const writer = new TieredCache<{ dismissed: boolean }>(options); + const c = ctx(); + expect(await reader.get("u1", c, { fetch: async () => source, kv })).toEqual({ + dismissed: false, + }); + source = { dismissed: true }; + await writer.invalidate("u1", { background: c.background, kv }); + writer.set("u1", source, { background: c.background, kv }); + await Promise.all(c.pending); + expect(await reader.get("u1", c, { fetch: async () => source, kv })).toEqual({ + dismissed: true, + }); + }); +}); diff --git a/src/tiered-cache/tiered-cache.ts b/src/tiered-cache/tiered-cache.ts new file mode 100644 index 0000000..bb317e8 --- /dev/null +++ b/src/tiered-cache/tiered-cache.ts @@ -0,0 +1,147 @@ +import { LruCache } from "../lru-cache/lru-cache.ts"; +import { resolveKvOps } from "./resolve-kv-ops.ts"; +import { CACHE_PRESETS, MS } from "./tiered-cache.constants.ts"; +import type { + CacheCtx, + CachePreset, + CacheSource, + CacheTraceEntry, + GetOptions, + InvalidateOptions, + KvOps, + SetOptions, + Stored, + TieredCacheKv, + TieredCacheOptions, +} from "./tiered-cache.types.ts"; + +/** + * 3-tier cache: isolate LRU (SWR) → KV → fetch (DB), on a preset. + * + * Every value carries when it was read from the source, taken before the fetch, and expires from + * then in every tier: a KV copy reloaded into another isolate, or a read that raced a write, can + * never be served past `at + hard`. That makes the preset's hard TTL a true staleness bound. + */ +export class TieredCache { + readonly name: string; + readonly preset: CachePreset; + readonly bypass: boolean; + private readonly prefix: string | undefined; + private readonly isolate: boolean; + private readonly lru: LruCache>; + + constructor(options: TieredCacheOptions) { + const { hardS, softS } = CACHE_PRESETS[options.preset]; + this.name = options.name; + this.preset = options.preset; + this.bypass = options.bypass !== false; + this.prefix = options.kv?.prefix; + this.isolate = options.isolate !== false; + /* a non-finite TTL is none: the LRU then keeps the entry until it is evicted or invalidated */ + this.lru = new LruCache>({ + maxSize: options.maxSize, + ...(Number.isFinite(hardS) ? { hardTtlMs: hardS * MS } : {}), + ...(Number.isFinite(softS) ? { ttlMs: softS * MS } : {}), + }); + } + + /** 3-tier lookup; undefined when every tier misses. */ + async get(key: string, ctx: CacheCtx, options: GetOptions): Promise { + const kv = this.resolveKv(options.kv, key); + const bypassed = this.bypass && ctx.cacheRequest?.bypass === true; + if (!bypassed) { + const cached = await this.cached(key, ctx, { background: ctx.background, fetch: options.fetch, kv }); + if (cached !== undefined) return cached.v; + } + const stored = await this.load(key, { background: ctx.background, fetch: options.fetch, kv }); + this.trace(ctx, stored?.at ?? Date.now(), { + stale: false, + stored: stored !== undefined, + tier: bypassed ? "bypass" : "origin", + }); + return stored?.v; + } + + /** Remove from all tiers. Its KV delete runs in the background; await it only where a read must not race it. */ + invalidate(key: string, options?: InvalidateOptions): Promise { + this.lru.delete(key); + const kv = this.resolveKv(options?.kv, key); + if (options === undefined || kv === undefined) return Promise.resolve(); + const deleted = kv.del(); + options.background(deleted); + return deleted; + } + + /** Update all tiers with a value a write just returned. */ + set(key: string, value: V, options?: SetOptions): void { + const stored = { at: Date.now(), v: value }; + this.remember(key, stored); + const kv = this.resolveKv(options?.kv, key); + if (options !== undefined && kv !== undefined) options.background(kv.put(stored)); + } + + /** Current LRU size. */ + get size(): number { + return this.lru.size; + } + + /** The LRU's copy, else KV's while it is within the hard TTL; a stale one is revalidated behind it. */ + private async cached(key: string, ctx: CacheCtx, source: CacheSource): Promise | undefined> { + const inMemory = this.isolate ? this.lru.getWithMeta(key) : undefined; + const hit = inMemory?.value ?? (source.kv === undefined ? undefined : await this.fromKv(key, source.kv)); + if (hit === undefined) return undefined; + const stale = inMemory?.stale ?? this.isStale(hit); + if (stale) ctx.background(this.revalidate(key, source)); + this.trace(ctx, hit.at, { stale, stored: true, tier: inMemory === undefined ? "kv" : "lru" }); + return hit; + } + + private async fromKv(key: string, kv: KvOps): Promise | undefined> { + const stored = await kv.get(); + if (stored === null || !this.isAlive(stored)) return undefined; + this.remember(key, stored); + return stored; + } + + /** Background SWR: KV's copy while it is itself fresh, else the source. */ + private async revalidate(key: string, source: CacheSource): Promise { + const copy = source.kv === undefined ? null : await source.kv.get(); + if (copy !== null && this.isAlive(copy) && !this.isStale(copy)) { + this.remember(key, copy); + return; + } + const stored = await this.load(key, source); + if (stored === undefined) this.lru.delete(key); + } + + /** The source's value, timed from before the read, written to every tier. */ + private async load(key: string, source: CacheSource): Promise | undefined> { + const at = Date.now(); + const value = await source.fetch(); + if (value === undefined) return undefined; + const stored = { at, v: value }; + this.remember(key, stored); + if (source.kv !== undefined) source.background(source.kv.put(stored)); + return stored; + } + + private trace(ctx: CacheCtx, storedAt: number, read: Omit): void { + ctx.cacheRequest?.trace.push({ cache: this.name, ...read, storedAt }); + } + + private isAlive(stored: Stored): boolean { + return typeof stored.at === "number" && Date.now() - stored.at <= CACHE_PRESETS[this.preset].hardS * MS; + } + + private isStale(stored: Stored): boolean { + return Date.now() - stored.at > CACHE_PRESETS[this.preset].softS * MS; + } + + private remember(key: string, stored: Stored): void { + if (this.isolate) this.lru.setAt(key, stored, stored.at); + } + + private resolveKv(ns: TieredCacheKv | undefined, key: string): KvOps | undefined { + return resolveKvOps({ key, ns, prefix: this.prefix, preset: this.preset }); + } +} diff --git a/src/tiered-cache/tiered-cache.types.ts b/src/tiered-cache/tiered-cache.types.ts new file mode 100644 index 0000000..101fe7d --- /dev/null +++ b/src/tiered-cache/tiered-cache.types.ts @@ -0,0 +1,78 @@ +export type CachePreset = "IMMUTABLE" | "LONG" | "SHORT" | "STANDARD"; + +export type CachePresetTtl = { readonly hardS: number; readonly softS: number }; + +/** Where one read's answer came from: memory, KV, the source, or the source past both on a bypass. */ +export type CacheTier = "bypass" | "kv" | "lru" | "origin"; + +/** + * One cache read of a request, for `Cache-Status` and `Age`. + * `stored`: whether the answer is now in the cache (a source that found nothing stores nothing). + */ +export type CacheTraceEntry = { + cache: string; + stale: boolean; + stored: boolean; + storedAt: number; + tier: CacheTier; +}; + +/** + * What a request lets its cache reads do, decided once at the request's edge: whether it may skip + * every cache, and where the reads report. + */ +export type CacheRequest = { bypass: boolean; trace: CacheTraceEntry[] }; + +export type TieredCacheOptions = { + /** Named in each read's trace, and so in `Cache-Status`. */ + name: string; + preset: CachePreset; + /** Honor a request's bypass (default). Off for public reads no caller may force to the source. */ + bypass?: boolean; + /** + * Keep entries in the isolate's memory (default). Off for values a user changes and must see + * changed on their next request: another isolate's memory would still answer the old one. + */ + isolate?: boolean; + /** L2 KV, under this key prefix. Omit for memory-only caches. */ + kv?: { prefix: string }; + /** Maximum LRU entries. */ + maxSize: number; +}; + +/** The KV calls the cache makes (JSON values it wrote itself): a Workers `KVNamespace` is one. */ +export interface TieredCacheKv { + delete(key: string): Promise; + get(key: string, type: "json"): Promise; + put(key: string, value: string, options?: { expirationTtl?: number }): Promise; +} + +export type Background = (p: Promise) => void; + +export type CacheCtx = { background: Background; cacheRequest?: CacheRequest }; + +export type GetOptions = { + /** L3: source-of-truth fetch (DB query). Return undefined on miss. */ + fetch: () => Promise; + /** L2: KV namespace. Only used when the cache has a kv config. */ + kv?: TieredCacheKv; +}; + +export type SetOptions = { background: Background; kv?: TieredCacheKv }; + +export type InvalidateOptions = SetOptions; + +/** A value and when it was read from the source (ms since epoch), in memory and in KV alike. */ +export type Stored = { at: number; v: V }; + +export type KvOps = { + del: () => Promise; + get: () => Promise | null>; + put: (stored: Stored) => Promise; +}; + +export type CacheSource = { + background: Background; + fetch: () => Promise; + kv: KvOps | undefined; +};