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 ca54c4d..346a431 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,41 @@ # utils Small helpers, one subpath export each, optional peers. + +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/utils +``` + +| Import | What it is | +| --- | --- | +| `@workerstack/utils/lru-cache` | Bounded LRU cache with soft and hard TTLs for stale-while-revalidate | +| `@workerstack/utils/tiered-cache` | Isolate LRU → Workers KV → source cache on a TTL preset, with request bypass and tracing | + +```ts +import { LruCache } from "@workerstack/utils/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/utils/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..c82ed94 --- /dev/null +++ b/package.json @@ -0,0 +1,62 @@ +{ + "name": "@workerstack/utils", + "version": "0.0.0", + "description": "Small helpers, one subpath export each, optional peers", + "keywords": [ + "cache", + "lru", + "utils", + "workerstack" + ], + "homepage": "https://github.com/workerstack/utils#readme", + "bugs": { + "url": "https://github.com/workerstack/utils/issues" + }, + "license": "MIT", + "author": "Lovro Žagar", + "repository": { + "type": "git", + "url": "git+https://github.com/workerstack/utils.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/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; +}; 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; +}; 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;