Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,41 @@ jobs:
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
with:
use_oidc: true
files: packages/core/coverage/coverage-final.json,packages/redis/coverage/coverage-final.json,packages/memcache/coverage/coverage-final.json,packages/sqlite/coverage/coverage-final.json,packages/nestjs/coverage/coverage-final.json
files: packages/core/coverage/coverage-final.json,packages/redis/coverage/coverage-final.json,packages/memcache/coverage/coverage-final.json,packages/sqlite/coverage/coverage-final.json,packages/nestjs/coverage/coverage-final.json,packages/otel/coverage/coverage-final.json
flags: unit
fail_ci_if_error: true

# Guards the `engines: >=20` floor the published packages advertise. The
# required `test` job pins Node 22, and the repo's own toolchain cannot run
# any lower (pnpm 11 requires >= 22.13), so this builds on 22 and then loads
# the built output under the oldest and newest supported runtimes the way a
# consumer would — plain `node`, no pnpm. Kept as its own job so the
# required check names stay stable.
compat:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: ["20", "24"]
name: compat (node ${{ matrix.node-version }})
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6
with:
version: 11.10.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with:
node-version: "22"
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Switch to Node ${{ matrix.node-version }}
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with:
node-version: ${{ matrix.node-version }}
- name: Load the built packages on Node ${{ matrix.node-version }}
run: node scripts/smoke-test.mjs

functional:
runs-on: ubuntu-latest
strategy:
Expand Down
174 changes: 174 additions & 0 deletions ARCHITECTURE_REVIEW.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ const cache = new CacheManager({
],
});

// L1 miss → L2 hit → value returned + L1 backfilled with L1's own TTL
// L1 miss → L2 hit → value returned + L1 backfilled under L1's own TTL policy
const product = await cache.wrap(id, async () => api.getProduct(id));
```

Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# 1. Add a service definition below with `profiles: [backend-name]`
# 2. Expose the default port and add a health check
# 3. Add the corresponding env var to .env.example
# 4. Add a matrix entry in .github/workflows/functional-tests.yml
# 4. Add a matrix entry to the `functional` job in .github/workflows/ci.yml

services:
redis:
Expand Down
21 changes: 18 additions & 3 deletions docs/advanced-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const userCache = new CacheManager({
**How it works**:

- `wrap("42", factory)` → checks memory for `users:42` → checks Redis → calls factory
- On a Redis hit, memory is auto-backfilled with memory's own 30s TTL
- On a Redis hit, memory is auto-backfilled with memory's own 30s TTL (capped by whatever life the Redis entry has left)
- On a factory call, memory gets 30s TTL, Redis gets 5min TTL

### Three-Layer (Memory + Redis + Fallback)
Expand Down Expand Up @@ -334,6 +334,21 @@ const cache = new CacheManager({ layers, strictWrites: true });

`strictWrites` applies to direct `set`/`mset`/`delete`/`mdel` calls. `wrap()` is unaffected: it always returns the value your factory computed, even if caching that value fails — the write error still surfaces via `"error"` events. In a single-layer setup, any write failure means "every layer failed", so it throws.

## Miss latency and `wrapWrites`

By default `wrap()` resolves only after the computed value has been written to every layer, so a read issued right after it is guaranteed to see the value. The cost is that a slow layer adds its full write latency to every miss — and to every caller coalesced onto that miss. A Redis instance that is degraded rather than down is the case that hurts: it accepts writes, slowly, and each `wrap()` miss waits for them.

Set `wrapWrites: "background"` to resolve as soon as the factory does and let the layer writes settle afterwards:

```ts
const cache = new CacheManager({
layers: [memory, redis],
wrapWrites: "background", // default is "await"
});
```

The trade-off is a brief window where `wrap()` has returned but the value is not cached yet, so a request arriving inside that window recomputes it. Write failures still surface as `"error"` events either way. Keep the default when correctness depends on read-your-write behavior (a `wrap()` immediately followed by a `get()` on the same key); choose `"background"` when miss latency matters more.

## Value fidelity across layers

`MemoryAdapter` stores live references by default, while the Redis, SQLite, and Memcache adapters JSON round-trip values. A `Date` survives an L1 hit but comes back as an ISO string when L2 serves the same key. If you cache rich types in a multi-layer setup, either store plain JSON-safe data or set `new MemoryAdapter({ serialization: "json" })` for consistent shapes (this also prevents callers from mutating cached objects in place). Note that in `json` mode, non-serializable values (functions, circular references) throw at `set()` time and `undefined` is not stored.
Expand Down Expand Up @@ -456,7 +471,7 @@ This runs functional tests for all configured backends (Redis, Memcached, SQLite

### CI Workflow

Functional tests run automatically in GitHub Actions via `.github/workflows/functional-tests.yml`. Each backend is provisioned as a service container and tested in a separate matrix job, reporting results independently from the hermetic test suite.
Functional tests run automatically in GitHub Actions via the `functional` jobs in `.github/workflows/ci.yml`. Each backend is provisioned as a service container and tested in a separate matrix job, reporting results independently from the hermetic test suite.

### Adding a New Backend

Expand All @@ -467,5 +482,5 @@ To add functional tests for a new adapter (e.g., Postgres):
3. Add `test:functional` script to the adapter's `package.json`
4. Add a profile to `docker-compose.yml`
5. Add the env var (e.g., `POSTGRES_URL`) to `.env.example`
6. Add a matrix entry in `.github/workflows/functional-tests.yml`
6. Add a matrix entry to the `functional` job in `.github/workflows/ci.yml`
7. Add `test:functional:<adapter>` to the root `package.json`
Loading