diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..370223c --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,51 @@ +name: Documentation + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docs-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: Build documentation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: 10.33.0 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: "24" + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - run: pnpm docs:build + - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + with: + path: docs/.vitepress/dist + + deploy: + name: Deploy documentation + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 + id: deployment diff --git a/.gitignore b/.gitignore index a32c984..682a6bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ coverage/ +docs/.vitepress/cache/ *.tgz .env .env.* diff --git a/AGENTS.md b/AGENTS.md index 7d05318..a33f33a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,8 @@ DialCache is a TypeScript caching library with explicit request-scoped enablemen ## Structure ```text +README.md # Landing page and documentation entry point +docs/ # User guides and API reference src/ index.ts # Public root entry point (barrel) dialcache.ts # Main DialCache API and cached-function wrapper @@ -47,6 +49,8 @@ test/ # Unit and Redis integration tests ## Conventions - Preserve strict TypeScript settings and public abstraction boundaries. +- Keep the README focused on evaluation and getting started. Document complete + feature behavior in `docs/` and link it from `docs/index.md`. - Keep Redis client-specific behavior in adapters; core code depends on `DialCacheRedisClient`. - Public exports belong in the root or an explicit integration entry point such as `src/node-redis.ts`, `src/prometheus.ts`, or `src/redis-protocol.ts`. - Use `corepack pnpm` for project commands. diff --git a/README.md b/README.md index 1dd0075..86c5380 100644 --- a/README.md +++ b/README.md @@ -4,39 +4,35 @@ [![Codecov](https://codecov.io/gh/lan17/DialCache/branch/main/graph/badge.svg)](https://codecov.io/gh/lan17/DialCache) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/lan17/DialCache/badge)](https://scorecard.dev/viewer/?uri=github.com/lan17/DialCache) -Fine-grained TypeScript caching with explicit enabled contexts, request-local memoization, process-local and Redis TTL caching, opt-in stale-on-error recovery, stable key construction, runtime rollout controls, request coalescing, adapter-based observability, and Redis watermark-based targeted invalidation. - -## Contents - -- [Install](#install) -- [Quick start](#quick-start) -- [How caching works](#how-caching-works) -- [Enabled context](#enabled-context) -- [Defining cached functions](#defining-cached-functions) - - [One-shot inline cache blocks](#one-shot-inline-cache-blocks) -- [Keys, ids, and extra dimensions](#keys-ids-and-extra-dimensions) -- [Runtime config and ramp controls](#runtime-config-and-ramp-controls) -- [Cache layers](#cache-layers) - - [Request-local cache](#request-local-cache) · [Process-local cache](#process-local-cache) · [Redis-backed TTL cache](#redis-backed-ttl-cache) · [Stale on source error](#stale-on-source-error) · [Remote read deadlines](#remote-read-deadlines-and-async-liveness) · [Serialization](#serialization) · [Compression](#compression) · [Shadow validation](#shadow-validation) -- [Cached-value ownership](#cached-value-ownership) -- [Targeted invalidation and watermarks](#targeted-invalidation-and-watermarks) -- [Request coalescing](#request-coalescing) - - [Fallback deadlines](#fallback-deadlines) · [Coalescing state](#coalescing-state) -- [Metrics](#metrics) -- [Maintainers](#maintainers) +DialCache is a TypeScript library that organizes caching into use cases, with +runtime control and observability for each one. + +- **Off by default:** caching runs only inside an `enable()` scope. +- **Multi-layer:** request-local → process-local → Redis. +- **Runtime policies per use case:** layers, TTLs, and rollout ramps. +- **Targeted invalidation:** one call per entity for its tracked Redis results. +- **Coalescing:** same-key reads share work when a cache layer is active. +- **Fail-open:** cache failures fall back to the loader. +- **Stale-on-error (opt-in):** retained Redis values for selected source errors. +- **Shadow validation (opt-in):** cache coherence checks through sampling. +- **Observability:** Prometheus and Datadog metrics, including miss reasons. + +[Documentation](https://lan17.github.io/DialCache/) +· [Getting started](https://lan17.github.io/DialCache/getting-started.html) +· [API reference](https://lan17.github.io/DialCache/api.html) ## Install ```bash -pnpm add dialcache +npm install dialcache # Choose a Redis client when using the remote layer: -pnpm add redis@~4.7.1 +npm install redis@~4.7.1 # or -pnpm add @valkey/valkey-glide@^2.0.0 +npm install @valkey/valkey-glide@^2.0.0 # Add a metrics client only when using its adapter: -pnpm add prom-client@^15.1.3 +npm install prom-client@^15.1.3 # or -pnpm add hot-shots@^17.0.0 +npm install hot-shots@^17.0.0 ``` DialCache requires Node.js with zstd support in `node:zlib`: 22.15.0 or newer @@ -44,983 +40,167 @@ within the 22.x line, or 23.8.0 and newer (23.0–23.7 lack zstd and are excluded). Production deployments should use a [currently supported LTS release](https://nodejs.org/en/about/previous-releases). -## Quick start - -```ts -import { DialCache, DialCacheKeyConfig } from "dialcache"; - -const dialcache = new DialCache(); - -const getUser = dialcache.cached( - (userId: string) => db.fetchUser(userId), - { - keyType: "user_id", - useCase: "GetUser", - cacheKey: (userId) => userId, - defaultConfig: DialCacheKeyConfig.enabled(60), - }, -); - -// Caching is OFF outside an enable() scope (see "Enabled context"), so this runs the fn uncached: -await getUser("123"); - -// Inside enable(), reads are cached: -const user = await dialcache.enable(() => getUser("123")); -``` - -## How caching works - -The wrapped function is the **fallback**: it runs whenever no active cache layer returns a value, whether because layers missed, were disabled, or failed open. - -When caching is enabled, reads flow through: - -```text -request-local cache -> process-local cache -> Redis cache -> fallback function -``` - -- Request-local hits return the value memoized in the current outermost `enable()` scope. -- Results from the lower chain are memoized request-locally when that layer is enabled. -- Process-local hits return immediately. -- Process-local misses try Redis and populate the process-local cache on a Redis hit. -- Redis misses call the fallback and attempt to write one complete Redis frame. When stale-on-error is opted in, the initial Redis read may retain a logically expired frame as a recovery candidate. An eligible fallback rejection can return that snapshot without another Redis read. An active untracked process-local layer may publish successful fallback results directly. For tracked keys, invocations that reach the Redis read/write path suppress direct process-local publication after fallback until a later validated Redis hit can warm it; local-only, remote-policy-disabled, and ramped-down paths remain governed by their local policy and may publish locally. -- Selected Redis keys can execute non-serving [shadow work](#shadow-validation) that validates hits and fills semantic misses, even before Redis is allowed to serve callers. -- Initial Redis read failures and timeouts are logged, counted in metrics, and fail open without attempting stale recovery. Redis write failures also fail open. `invalidateRemote` requires a configured Redis client; missing configuration and Redis failures are logged, counted, and rethrown so callers do not assume invalidation succeeded. -- Cache-key construction and config-provider failures also fail open and run the fallback uncached. -- A missing effective process-local/Redis TTL disables that layer by policy; a configured TTL with no ramp defaults to 100%. Disabled layers record a disabled reason and fall through to the next layer/fallback. - -Caching as a whole is only active inside an enabled context, described next. - -## Enabled context - -Caching is **off by default** and only active inside a `dialcache.enable(...)` scope. This is deliberate: it lets you turn caching **off in write paths** so a stale read can't be cached around a write. DialCache uses Node `AsyncLocalStorage` to keep enabled state scoped to the current asynchronous call chain. - -**Enable once at your request boundary** (e.g. a middleware that wraps read-request handling) so individual call sites don't each need it; wrap mutation handlers in `disable()`: - -```ts -await dialcache.enable(async () => { - await getUser("123"); // cached - - await dialcache.disable(async () => { - await updateUser("123", patch); // reads here are uncached - }); - - await getUser("123"); // cached again -}); -``` - -- Default is disabled — `cached()` and `getOrLoad()` calls made **outside** any `enable()` scope simply run their loader uncached (no error), so wrap your read paths to actually cache. -- Enabled state is async-scope-local, not process-global. -- Nested `enable` / `disable` scopes restore the previous behavior when the callback completes. Nested `enable()` calls reuse the outer request-local scope rather than creating a new one. - -## Defining cached functions - -Use `cached(fn, options)` for an extracted, reusable function. The wrapped callable has the same parameters and always returns a `Promise`. For a one-shot calculation that should remain inline, use [`getOrLoad()`](#one-shot-inline-cache-blocks). - -| Option | Required | Description | -| --- | --- | --- | -| `keyType` | yes | The kind of id the key addresses (e.g. `"user_id"`). Together with the id, the invalidation unit for tracked entries. | -| `useCase` | yes | Identifies the individual cache: part of the stored key and the metrics label. | -| `cacheKey` | yes | Selector over `fn`'s parameters; returns a bare id or `{ id, args }`. | -| `defaultConfig` | no | `DialCacheKeyConfig` baseline policy that runtime config overlays field by field (see [Runtime config](#runtime-config-and-ramp-controls)). | -| `serializer` | when the return type is not statically JSON-compatible | Per-function `Serializer` for Redis values (see [Serialization](#serialization)). | -| `shadowComparator` | no | Synchronous application-level equality for shadow validation; defaults to Node's strict deep equality. | -| `trackForInvalidation` | no (default `false`) | Opts this use case's Redis entries into watermark-based targeted invalidation. | -| `shouldAttemptStaleRecovery` | no | Synchronous source-error classifier for this use case; replaces the instance or built-in classifier (see [Stale on source error](#stale-on-source-error)). | -| `fallbackTimeoutMs` | no (default `60_000`) | Fallback deadline in milliseconds, at most 2,147,483,647; `null` disables it (see [Fallback deadlines](#fallback-deadlines)). | - -`cached()` validates `useCase` at registration: a duplicate within one `DialCache` instance throws `UseCaseIsAlreadyRegisteredError`. Both APIs reject the internal name `watermark` with `UseCaseNameIsReservedError`. - -### One-shot inline cache blocks - -`getOrLoad(load, options)` runs one zero-argument loader through the same policy, cache layers, coalescing, invalidation, metrics, serialization, deadlines, and fail-open behavior as `cached()`. It is useful when only part of a larger function should be cached and the loader needs to capture local values: - -```ts -// Reuse the caller-owned defaults; getOrLoad() snapshots them per invocation. -const profileCacheDefaults = DialCacheKeyConfig.enabled(60); - -const profile = await dialcache.getOrLoad( - async () => { - const user = await db.getUser(userId); - return renderProfile(user, locale); - }, - { - keyType: "user_id", - useCase: "BuildProfile", - key: { id: userId, args: { locale } }, - defaultConfig: profileCacheDefaults, - }, -); -``` - -The options match `cached()` except that the direct `key` replaces the `cacheKey` selector. `defaultConfig`, `fallbackTimeoutMs`, and the selected stale-recovery classifier are validated and captured for each invocation. Outside an enabled scope, DialCache invokes `load` directly without constructing a key, resolving runtime policy, or invoking the classifier. - -`getOrLoad()` does not register its `useCase`, so repeated calls should reuse one stable, deployment-defined name such as `"BuildProfile"`. Keep it bounded: never derive `useCase` from a user, request, id, or other high-cardinality input because it is part of both cache identity and metrics labels. Put those values in `key` instead. - -Every captured value that can change the result belongs in the bare id or `{ id, args }` key. Concurrent same-key calls may share one caller's in-flight loader and cached value, so all call sites for that identity must also agree on value meaning and serialization. Prefer `cached()` when a loader is reusable; prefer `getOrLoad()` when the calculation is intentionally local to one call site. - -## Keys, ids, and extra dimensions - -For `cached()`, the key comes from the required `cacheKey` selector whose parameters are inferred from `fn`. `getOrLoad()` accepts the same bare id or `{ id, args }` shape directly through `key`: - -The selected or direct key is the value identity contract. It must include every input dimension that can affect the returned value; otherwise distinct calls can reuse the same cached value or share the same in-flight fallback through default-on request coalescing. - -```ts -const searchPosts = dialcache.cached( - (userId: string, page: number, filter: string) => db.searchPosts(userId, page, filter), - { - keyType: "user_id", - useCase: "SearchPosts", - cacheKey: (userId, page, filter) => ({ id: userId, args: { page, filter } }), - defaultConfig: DialCacheKeyConfig.enabled(60), - }, -); -await dialcache.enable(() => searchPosts("u1", 2, "active")); -``` - -`DialCacheConfig.namespace` is the logical cache namespace and the first component of every key. It defaults to `"urn"`, producing keys such as `urn:user_id:123#GetUser`. Set a stable application-specific value when multiple applications may use the same Redis deployment: - -```ts -const dialcache = new DialCache({ - namespace: "users-api", - redis: { client: redisClient }, -}); -``` - -That produces Redis keys beginning with `users-api:...`, or `{users-api:...}` for invalidation-tracked values. `namespace` is DialCache's single cache-identity and key-partitioning setting: it participates in request-local, process-local, Redis, coalescing, deterministic ramp, invalidation, and metrics. It may not contain `{` or `}` because DialCache reserves those characters for Redis Cluster hash tags. Use a namespace to express any required application or environment separation, such as `production-users-api`. - -- **`keyType` + `id` is the invalidation unit for tracked Redis entries.** `dialcache.invalidateRemote("user_id", "123", futureBufferMs)` writes one watermark for that user; any `trackForInvalidation` Redis entry with the same `keyType` and `id` is refreshed across all `args` variants when Redis is read. `invalidateRemote` does not evict existing request-local or process-local entries (see [Targeted invalidation](#targeted-invalidation-and-watermarks)), and untracked Redis entries do not consult the watermark. `useCase` identifies the individual cache (it's the metrics label and part of the stored key). -- **`args` are part of the cache key** — different `args` produce different entries — but invalidation is by `id` only. -- **Scalar key equality is string-based.** Runtime type is not an identity dimension: for matching surrounding dimensions, numeric `1`, string `"1"`, and bigint `1n` identify the same key; argument values `null` and `"null"` also match. `-0` matches `0`, and an `undefined` argument is omitted. If a deployment changes the logical meaning represented by a scalar, change an explicit identity dimension such as `keyType`, `useCase`, or an argument name/value. -- **Non-key inputs** (for example a db handle) are parameters ignored by a `cacheKey` selector or values captured by a `getOrLoad()` loader. They still reach non-coalesced executions, but concurrent same-key cache misses share the leader's execution unless the use case disables coalescing, so do not omit values like auth context, locale, or cancellation behavior unless sharing one result is correct. -- **Methods:** pass `obj.method.bind(obj)` (or `(...a) => obj.method(...a)`) — a bare `obj.method` reference loses `this`. - -Changing the namespace value intentionally creates a cold-cache boundary across every layer. Old and new keyspaces do not share Redis values or invalidation watermarks. During an overlapping deployment, an invalidation handled by one version is invisible to the other, which can continue serving a stale tracked value until its value TTL expires. If remote invalidation correctness matters, a normal rolling deployment is unsafe: use a coordinated no-overlap cutover, or an operational bridge that prevents both versions from serving remote cache across mutations (for example, temporarily disable and clear remote caching during the transition). After the cutover, provision for fallback/refill load and allow old Redis keys to expire by TTL. - -## Runtime config and ramp controls - -Instance-wide behavior is set through the `DialCache` constructor: - -| `DialCacheConfig` option | Default | Description | -| --- | --- | --- | -| `namespace` | `"urn"` | Logical cache namespace and first key component (see [Keys, ids, and extra dimensions](#keys-ids-and-extra-dimensions)). | -| `redis` | none | `{ client: DialCacheRedisClient, readTimeoutMs?: number, serializer?: Serializer, compression?: CompressionConfig \| false }`; enables the Redis layer with a 50 ms default read deadline, an optional instance-default serializer, and default-on zstd payload compression (see [Redis-backed TTL cache](#redis-backed-ttl-cache), [Serialization](#serialization), and [Compression](#compression)). | -| `localMaxSize` | `10_000` | Global process-local entry cap; `0` disables process-local storage. Nonnegative safe integer. | -| `shadowMaxInFlight` | `1` | Maximum scheduled or active shadow jobs per `DialCache` instance, including uncancellable underlying work. Positive safe integer; excess work is dropped without queuing. | -| `cacheConfigProvider` | none | Resolves runtime config per enabled invocation as a sparse overlay on the function's `defaultConfig`; `null` applies no overrides. | -| `shouldAttemptStaleRecovery` | only `FallbackTimeoutError` | Synchronous instance-default source-error classifier. A per-use-case callback replaces it. | -| `metrics` | disabled | A `DialCacheMetricsAdapter` (see [Metrics](#metrics)). | -| `logger` | `console` | Receives operational cache failures and opted-in confirmed shadow mismatch warnings (`debug`, `warn`, `error`). Synchronous throws and rejections from returned promises or thenables are isolated without being awaited. | - -Per-invocation cache policy is a `DialCacheKeyConfig`: per-layer `ttlSec` and `ramp` maps keyed by `CacheLayer.LOCAL` (process-local) and `CacheLayer.REMOTE` (Redis), a `requestLocal` boolean, a `coalesce` boolean (see [Request coalescing](#request-coalescing)), an optional `staleOnErrorMaxAgeSec`, an optional `remoteReadTimeoutMs`, and an optional `shadow` group. `ShadowConfig` contains the independent shadow `ramp` percentage plus the default-off `logMismatches` control. - -Every cached definition or `getOrLoad()` invocation can provide an optional per-use-case `defaultConfig`. It is the baseline policy, and the `cacheConfigProvider` result is a sparse field-level overlay on that baseline. For cache enablement fields, precedence is runtime config, then `defaultConfig`, then DialCache's disabled baseline. For the remote-read deadline, precedence is runtime `remoteReadTimeoutMs`, `defaultConfig.remoteReadTimeoutMs`, `redis.readTimeoutMs`, then the 50 ms library default. - -The disabled baseline sets `requestLocal` to false, leaves the process-local and Redis TTLs and stale-on-error maximum unset, and sets `shadow.ramp` to 0% with mismatch logging false. A shared layer with no effective TTL is disabled by policy. When a shared layer has an effective TTL but no effective ramp, its ramp defaults to 100%. Shadow work remains disabled unless `shadow.ramp` is explicitly greater than zero. - -`DialCacheKeyConfig` preserves an omitted `requestLocal` as `undefined` so the overlay can distinguish omission from an explicit `false`; the effective value still defaults to false after resolution. An omitted `coalesce` is preserved the same way, and its effective value defaults to true, so request coalescing stays on unless a use case explicitly opts out. An omitted `staleOnErrorMaxAgeSec` inherits an earlier value in a sparse runtime overlay and otherwise leaves recovery off; `0` explicitly disables an inherited stale policy. - -A provider result of `null` (or defensive `undefined`) applies no overrides. An empty `DialCacheKeyConfig` and omitted runtime fields also inherit the baseline. Top-level fields, cache-layer leaves, and leaves inside `shadow` merge independently; an explicit `false` logging flag overrides an inherited `true`. Use explicit values to override inherited policy: `requestLocal: false` disables request-local caching, `staleOnErrorMaxAgeSec: 0` disables stale recovery, and a layer ramp of `0` disables that shared layer. `DialCacheKeyConfig.disabled()` is the complete new-cache-invocation kill switch in one call: request-local, stale recovery, and shadow work off, shadow logging off, and both shared layers ramped to 0. It leaves `coalesce` unset: with every layer off there is no in-flight sharing to disable, and a use case ramped back up at runtime coalesces again unless it explicitly opts out. It does not cancel already-admitted work, and explicit maintenance operations such as `invalidateRemote()` remain available. To stop new cache-invocation Redis reads and fills while preserving other runtime settings, explicitly set both `ramp.remote` and `shadow.ramp` to `0`; the remote ramp alone stops serving but does not override an inherited nonzero shadow ramp. - -DialCache validates `defaultConfig` when `cached()` registers a definition and whenever `getOrLoad()` is invoked: TTLs must be positive safe integers no greater than 31,536,000 seconds (a fixed 365-day duration), a positive stale-on-error maximum must be a safe integer in the same range and strictly greater than the remote TTL, remote-read deadlines must be positive safe integers within their documented limit, layer and shadow ramps must be finite percentages from 0 to 100, layer maps and `shadow` must be objects, and `requestLocal`, `coalesce`, and `shadow.logMismatches` must be booleans when present. `0` is the one valid non-positive stale maximum and explicitly disables recovery. Invalid defaults are rejected immediately. - -Each registration or one-shot invocation captures an immutable internal snapshot of `defaultConfig`; mutating the supplied config or its maps later does not change that operation's baseline. Runtime policy changes belong in the provider's returned overlay. - -Runtime TTL and ramp leaves are used as supplied instead of falling back to valid default leaves. A TTL outside the same 1-to-31,536,000-second range disables that layer with `invalid_ttl`; a nonnumeric, non-finite, or out-of-range ramp disables it with `invalid_ramp`. Valid ramps include both `0` and `100`. Other layers can still run, and invalid leaves also record a `config_resolution` error so provider garbage is alertable separately from intentional ramp-downs. A malformed runtime config object, layer-map shape, `requestLocal` value, `coalesce` value, explicit `remoteReadTimeoutMs`, or removed top-level `shadowRamp` fails config resolution for the invocation, records `config_error`, and executes the fallback uncached without attempting Redis. An invalid runtime `staleOnErrorMaxAgeSec` is narrower: DialCache records `config_resolution`, disables recovery for that invocation, and preserves an otherwise valid fresh Redis policy. The public `DialCacheKeyConfig` constructor and static defaults likewise reject `shadowRamp` immediately; migrate it to `shadow.ramp`. - -An invalid runtime `shadow.ramp` does not affect the cache result or disable an otherwise valid Redis policy. If normal traversal reaches an otherwise shadow-eligible Redis path, DialCache skips shadow work and records a `config_resolution` error. An invalid runtime `shadow.logMismatches` likewise preserves the cache result, Redis policy, shadow result, and shadow metric while suppressing the warning. DialCache validates this diagnostic leaf only after the metrics hook, exact-key cohort, and capacity gates admit shadow work, then records one remote `config_resolution` error for that admitted resolution. - -`cacheConfigProvider` is called for every enabled cache invocation before DialCache performs any cache lookup. Keep it cheap, cache any remote/config-store reads inside the provider, and avoid work that would erase the benefit of a cache hit. +## Usage ```ts import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; -const dialcache = new DialCache({ - cacheConfigProvider: async (key) => { - if (key.useCase === "GetUser") { - return new DialCacheKeyConfig({ - // Sparse override: inherit both TTLs and the local ramp from defaultConfig. - ramp: { [CacheLayer.REMOTE]: 25 }, - // Independently sample Redis keys for detached validation/fill. - shadow: { - ramp: 5, - // Emit one warning with a bounded key and native-JSON value strings. - logMismatches: true, - }, - // Can be changed by the provider at runtime for this use case. - remoteReadTimeoutMs: 35, - // Sparse override of the maximum retained age; use 0 to turn recovery off. - staleOnErrorMaxAgeSec: 1_800, - }); - } - return null; // apply no overrides; use the cached function's baseline - }, -}); - -const getUser = dialcache.cached((userId: string) => db.fetchUser(userId), { - keyType: "user_id", - useCase: "GetUser", - cacheKey: (userId) => userId, - defaultConfig: new DialCacheKeyConfig({ - // Omitted ramps default to 100% because these layers have TTLs. - ttlSec: { [CacheLayer.LOCAL]: 30, [CacheLayer.REMOTE]: 300 }, - // Keep Redis data for up to one hour and serve it only after a source error. - staleOnErrorMaxAgeSec: 3_600, - }), -}); -``` - -`ramp` values are percentages from 0 to 100. `0` disables the layer, `100` enables it, and intermediate values are deterministically sampled by cache key and layer, so the same key is consistently sampled in or out of a partial rollout across calls and instances. The assignment algorithm is owned by DialCache and remains stable across releases. Applications that need an externally coordinated cohort can use `cacheConfigProvider` to return a sparse per-key ramp override of `0` or `100`. DialCache fetches and resolves one config snapshot per enabled invocation. Provider errors do not activate defaults: they fail open, record `config_error`, and execute the fallback function uncached. - -`shadow.ramp` uses the same inclusive 0–100 percentage domain but is independent of cache-layer serving ramps. Omission and `0` disable shadow work; `100` selects every eligible Redis key; intermediate values assign each exact cache key to a stable shadow cohort across calls and instances. A valid remote policy can therefore use `ramp.remote: 0` with a nonzero `shadow.ramp` to exercise and populate Redis without serving from it. A nonzero value explicitly authorizes detached native writes after semantic shadow misses; later tracked reads remain watermark-aware, while untracked reads retain ordinary TTL-based last-writer-wins behavior. It does not create another `CacheLayer`, activate Redis without a valid remote TTL, or make a request-local/process-local hit continue to Redis. - -Remote serving and shadow sampling use independent deterministic cohorts. Equal partial percentages do not imply the same keys, so a partial shadow cohort does not guarantee that every key admitted by a later partial serving ramp was warmed or validated. Use `shadow: { ramp: 100 }` when every otherwise eligible invocation must exercise the non-serving Redis path before a serving-ramp increase. - -## Cache layers - -### Request-local cache - -Set `requestLocal: true` to memoize resolved values for the lifetime of the outermost `enable()` scope: - -```ts -import { DialCache, DialCacheKeyConfig } from "dialcache"; - const dialcache = new DialCache(); -const getUser = dialcache.cached( - (userId: string) => db.fetchUser(userId), - { - keyType: "user_id", - useCase: "GetUser", - cacheKey: (userId) => userId, - defaultConfig: new DialCacheKeyConfig({ requestLocal: true }), - }, -); -``` - -`requestLocal` is a runtime boolean rather than a TTL/ramp-controlled `CacheLayer`. The `cacheConfigProvider` can turn it on or off for each invocation. `DialCacheKeyConfig.enabled(ttlSec)` enables only process-local and Redis caching, so request-local caching must be selected explicitly. - -DialCache resolves the runtime config once per enabled invocation and uses it for the entire lookup. When the effective `requestLocal` value is false, the invocation skips request-local lookup and storage without deleting an entry already memoized in the scope. A later invocation that enables request-local caching can reuse that entry. - -The outermost `enable()` call owns the request-local lifetime, and nested `enable()` calls reuse that scope. Request-local state is allocated lazily, only when an invocation enables the layer, so scopes that use only process-local or Redis caching do not allocate it. - -Wrap the complete Node HTTP handler so the request-local scope matches the handler's lifetime: - -```ts -import { createServer } from "node:http"; - -const server = createServer((req, res) => { - void dialcache - .enable(async () => { - const user = await getUser(readUserId(req)); - res.setHeader("content-type", "application/json"); - res.end(JSON.stringify(user)); - }) - .catch((error: unknown) => handleRequestError(error, res)); -}); -``` - -Request-local storage has no capacity limit, eviction, or overflow mode. Entries are retained until the outermost `enable()` callback settles. Use it for short-lived scopes with bounded key cardinality; split long-running streams or large batch jobs into smaller scopes when necessary. - -### Process-local cache - -The process-local layer (`CacheLayer.LOCAL`) uses one LRU per `DialCache` instance. It keeps at most 10,000 entries by default across all use cases while retaining each entry's configured TTL. Set `localMaxSize` to a nonnegative safe integer to change the global entry cap; `0` disables process-local storage: - -```ts -const dialcache = new DialCache({ localMaxSize: 25_000 }); -``` - -The limit counts entries rather than estimating JavaScript object memory. Recently read entries stay resident ahead of less recently used entries when the limit is reached. -### Redis-backed TTL cache - -The Redis layer supports standalone Redis, Valkey, and Redis Cluster. Connect the underlying client, then wrap it with the bundled adapter: - -```ts -import { createClient } from "redis"; -import { DialCache } from "dialcache"; -import { createNodeRedisDialCacheClient } from "dialcache/node-redis"; - -const redisClient = createClient({ - url: process.env.REDIS_URL, - disableOfflineQueue: true, - commandsQueueMaxLength: 1_000, - socket: { connectTimeout: 2_000 }, -}); -await redisClient.connect(); - -const dialcache = new DialCache({ - namespace: "users-api", - redis: { - client: createNodeRedisDialCacheClient(redisClient), - // Optional instance default; omit to use DialCache's 50 ms default. - readTimeoutMs: 100, - }, -}); - -async function shutdown(): Promise { - // Stop new work and await every outstanding request-path call and invalidation first. - // Detached shadow work is best-effort and has no drain handle. - await redisClient.quit(); -} -``` - -`redis.client` is required when Redis is configured and accepts the semantic `DialCacheRedisClient` interface. `redis.readTimeoutMs` is optional and sets the instance default for remote reads; omit it to use 50 ms. Create and connect the underlying client before constructing `DialCache`. Node-redis users wrap that connected client with `createNodeRedisDialCacheClient` as shown above; the adapter issues native reads and writes and manages invalidation's `EVALSHA`/`EVAL` dispatch internally. The helper requires node-redis's promise API and does not support `legacyMode`, whose callback surface is incompatible with the required promise-based binary-command contract. - -Valkey GLIDE users pass an already-created standalone or cluster client and its -module namespace to the GLIDE adapter: - -```ts -import * as valkeyGlide from "@valkey/valkey-glide"; -import { DialCache } from "dialcache"; -import { createValkeyGlideDialCacheClient } from "dialcache/valkey-glide"; - -const glideClient = await valkeyGlide.GlideClient.createClient({ - addresses: [{ host: "127.0.0.1", port: 6379 }], - requestTimeout: 2_000, - advancedConfiguration: { connectionTimeout: 2_000 }, -}); -const redisClient = createValkeyGlideDialCacheClient(glideClient, valkeyGlide); -const dialcache = new DialCache({ - namespace: "users-api", - redis: { client: redisClient }, -}); - -function shutdown(): void { - // After draining request-path calls and invalidations, close GLIDE; the - // adapter is stateless. Detached shadow work is best-effort and has no - // drain handle. - glideClient.close(); +// The loader: the database or service read. +async function fetchUser(userId: string) { + console.log("Loading from source:", userId); + return { id: userId, name: "Ada" }; } -``` - -Pass the same GLIDE 2.x module namespace that created the client. The adapter -uses that namespace's `GlideClient` and `GlideClusterClient` identities, -the standalone `Batch` constructor, and `Decoder.Bytes` without importing a -GLIDE runtime itself. Cluster reads route `MGET` directly and do not require -`ClusterBatch`. The helper accepts a direct official client instance and -fails during construction when the client came from another module instance or -is hidden behind a forwarding wrapper, because it cannot safely infer that -wrapper's topology. Custom wrappers can implement `DialCacheRedisClient` -directly. - -The application owns the complete Redis lifecycle. It creates and connects the underlying client and passes the semantic adapter to DialCache. During shutdown, stop starting DialCache-backed work and await every promise returned by a cached function, `getOrLoad()`, or `invalidateRemote()`, including calls still running fallbacks that may later write Redis. A read that crossed DialCache's wait deadline may still be active inside the client, so use client-native telemetry and shutdown controls to drain or terminate that work before closing the connection. DialCache only borrows `redis.client`; it has no close or drain method and never disposes or closes caller resources. - -Awaiting those public promises does not drain detached shadow work. Shadow scheduling and deadline timers are unreferenced and completion is not guaranteed during shutdown; Redis operations, source reads, serializers, and asynchronous telemetry already started by shadow work remain caller-owned and may still be active. Stop new work before closing their dependencies and accept that an in-flight shadow fill may have been dispatched even if its final outcome is lost during teardown. DialCache does not add a shutdown hook or keep the process alive to deliver best-effort outcomes. -Neither adapter owns additional resources or native script handles, so the application simply closes the underlying client after draining work. - -Reads use native `GET` for untracked entries and one atomic `MGET` for each tracked value-and-watermark pair. The adapters validate and decode the returned frame in the Node process. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. Bundled adapters return misses as `RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }`, attaching `observedWatermarkMs` only when the same tracked snapshot carried a valid numeric watermark. Redis `nil` is `value_absent`; only a complete supported tracked frame rejected at or below the watermark is `watermark_fenced`; and short or unsupported frames, and malformed watermark metadata paired with a present frame, are `unclassified`. Cause remains independent from the fence, so an absent value can still carry `observedWatermarkMs` and suppress a later refill. Any unrecognized custom-adapter result is reported as `unclassified` and refilled normally. After a read settles, DialCache evaluates a decoded frame against the observing application's `Date.now()`. Without stale-on-error, the read accepts only nonnegative ages strictly below the effective remote TTL `F`. With a positive recovery maximum `M`, that same initial read is bounded by `M`: ages below `F` deserialize and serve normally, while ages from `F` through strictly below `M` remain raw as a possible source-error recovery candidate. Future-dated frames fail closed before deserialization and emit the bounded offset observation described under [Metrics](#metrics). A shadow confirmation read still observes a future offset but retains the payload only for supersession comparison; it can never serve that frame. - -Writes are native too, so the payload never crosses the Redis-to-Lua boundary. Conditional tracked refills follow the two-sample fence described under [targeted invalidation](#targeted-invalidation-and-watermarks): a preflight can avoid payload preparation, while an admitted fill uses a final dispatch-adjacent timestamp so serialization time does not consume its logical TTL. Misses without an observed watermark leave optional `RedisWriteRequest.createdAtMs` absent, preserving the adapter-side `Date.now()` sample immediately before dispatch. Every dispatched write issues one `SET valueKey frame PX cacheTtlMs` containing the complete version-1 frame; only tracked reads include the watermark key. With stale-on-error enabled, the requested physical TTL is `M` instead of `F`. `M` remains the configured logical recovery ceiling even when it exceeds one hour. Core separately caps every tracked Redis value's physical TTL at one hour, so such a tracked candidate may be evicted by expiry before it reaches logical age `M`; untracked Redis and local TTLs retain their configured limits. Each dispatched tracked write whose requested TTL exceeds that cap emits `error="tracked_ttl_clamped"`. The write never reads, creates, or extends a watermark. A dispatched frame can still be fenced if the watermark advances after the read snapshot. Same-key writes are ordinary Redis last-writer-wins operations, with no Lua, pipeline, or transaction on the write path. - -The network shape remains one top-level Redis command and one round trip per semantic read (`GET` or `MGET`) and one `SET` per dispatched write. Stale recovery reuses the frame returned by that initial command and never adds a second Redis read, including after a source rejection. Retaining a raw candidate instead consumes process memory until that source attempt settles, once per distinct in-flight key (same-key coalesced callers share it). Conditional refill suppression reuses the existing tracked `MGET` result and adds no command or round trip. DialCache does not call Redis `TIME` or maintain a Redis-clock offset. Use the maintainer benchmarks below to measure the target Redis/Valkey version, payload distribution, and high-cardinality in-flight memory exposure. - -Native commands retain Redis's wrong-type behavior. An untracked `GET` surfaces `WRONGTYPE`; tracked `MGET` represents a wrong-type member as a missing value. A wrong-type tracked value is therefore `value_absent`; when the paired watermark is a valid numeric string, the result also carries that independent refill fence, while an absent or wrong-type watermark yields the ordinary zero-baseline behavior. A wrong-type watermark is indistinguishable from an absent watermark to `MGET`; it does not prevent or alter native value writes. The next explicit invalidation replaces that wrong-type key with a valid string watermark. Other script read failures remain errors and cannot bypass the monotonic update. - -Node-redis forces tracked cluster commands to the slot primary. GLIDE uses an explicit primary route in cluster mode; in standalone mode it sends `MGET` through a one-command non-atomic batch because direct read commands follow the client's replica-read preference. Standalone batches use the primary, and `MGET` itself provides the atomic snapshot without consuming caller-owned `WATCH` state. The GLIDE helper distinguishes those modes from the direct client's runtime identity and rejects ambiguous clients instead of silently choosing a route. - -Invalidation is the only remaining Lua operation. Both adapters dispatch it as `EVALSHA` by the script source's SHA1 and retry a rejected dispatch once by re-sending the source as `EVAL`. The script is idempotent: its watermark advances monotonically and its TTL only widens, so a duplicate execution after an ambiguous failure is harmless. Reply-domain violations are deterministic and are not retried. If the retry also fails, GLIDE attaches the original rejection as `cause` when possible; node-redis surfaces the retry rejection unmodified because disconnect failures may be shared across callers. A healed retry is indistinguishable from first-attempt success in DialCache metrics. Monitor server-side `INFO commandstats` for unexpected `EVAL` volume or rejected `EVALSHA` calls. - -Command-restricted Redis ACLs must allow native `GET`, `MGET`, and `SET`, plus `EVALSHA` and `EVAL` for invalidation recovery. If script-invoked commands are checked separately, the invalidation script needs `GET`, `SET`, and `PTTL`. Redis `TIME`, `MULTI`, `EXEC`, `WATCH`, `UNLINK`, and `SCRIPT LOAD` are not used by DialCache. The integration matrix covers Redis 6.2 and Valkey 8. - -#### Stale on source error - -Stale-on-error is an opt-in Redis policy with two ages: - -- `F = ttlSec.remote` is the logical fresh lifetime. Every ordinary Redis read enforces `F`, regardless of whether recovery is enabled. -- `M = staleOnErrorMaxAgeSec` is the configured logical recovery-age ceiling. When configured, Redis requests physical retention through `M`, but ordinary reads still treat the frame as a miss at age `F`. `M` is not clipped for tracked use cases; their Redis value TTL is separately capped at one hour, so a retained frame can physically disappear before reaching `M`. - -`F` and `M` bound only Redis serving. Request-local and process-local hits occur earlier in the chain and follow their own scope or TTL lifetimes. A Redis frame can be nearly `F` old when it warms the process-local cache and then receive a full local TTL, so setting the local TTL to `F` or lower does not make `F` a strict end-to-end age limit. Disable those earlier layers when that global bound is required. - -With a positive `M`, the initial Redis command admits frames up to `M` and classifies them in the Node process. A frame with `0 <= age < F` is deserialized and served as a normal hit. A frame with `F <= age < M` records an `expired` Redis miss but retains its raw payload while DialCache calls the source of truth. A missing, future-dated, watermark-fenced, or `age >= M` frame is a miss with no retained candidate; the `age >= M` case also reports `expired`. A fresh frame that fails initial deserialization is likewise not eligible to be reconsidered as stale. - -If the source rejects, DialCache first applies a synchronous `shouldAttemptStaleRecovery(error)` classifier. Precedence is per-use-case `cached()`/`getOrLoad()` option, then the `DialCache` instance option, then the built-in policy. The built-in policy authorizes only `error instanceof FallbackTimeoutError`, whether the error came from this invocation's deadline or propagated from a nested/source operation. An override replaces the lower-precedence policy rather than composing with it, so an application override that should preserve timeout recovery must include that case itself. Custom predicates should narrowly admit transient, retriable infrastructure failures. They should deny authoritative domain outcomes such as auth, permission, entitlement, or revocation failures; deletion or not-found results; and validation or programmer errors. Use a per-use-case classifier when particular data requires a stricter policy than the instance default. A supplied policy must be a function; DialCache validates it at instance construction, cached-wrapper registration, or `getOrLoad()` invocation. During classification, a callback throw, thenable, or non-boolean result fails closed; DialCache consumes a rejecting thenable, logs the classifier failure, and preserves the original source rejection. Calls outside an enabled context never invoke the classifier. - -When the classifier returns `true`, DialCache consults only the frame retained from the initial read and issues no additional Redis command. It requires `0 <= Date.now() - createdAtMs < M` both before and after lazy deserialization/decompression, so crossing `M` during an asynchronous serializer load cannot serve. A valid candidate suppresses the source rejection for that caller. A missing or expired candidate, or a candidate that cannot deserialize/decompress, rethrows the exact original source rejection. - -```ts -import { CacheLayer, DialCacheKeyConfig, FallbackTimeoutError } from "dialcache"; - -const getUser = dialcache.cached((userId: string) => db.fetchUser(userId), { - keyType: "user_id", - useCase: "GetUserWithStaleRecovery", - cacheKey: (userId) => userId, +// The cached function. A drop-in replacement for fetchUser. +const getUser = dialcache.cached(fetchUser, { + keyType: "user_id", // Entity kind; with the id, the unit of invalidation. + useCase: "GetUser", // Operation name; part of the key and metric labels. + cacheKey: (userId) => userId, // Include every input that changes the result. defaultConfig: new DialCacheKeyConfig({ - ttlSec: { [CacheLayer.REMOTE]: 60 }, // F: normal reads serve for 60 seconds - staleOnErrorMaxAgeSec: 300, // M: recovery requires age < 5 minutes + ttlSec: { [CacheLayer.LOCAL]: 60 }, }), - // This replaces the built-in classifier, so retain its timeout case explicitly. - shouldAttemptStaleRecovery: (error) => - error instanceof FallbackTimeoutError || isRetriableDatabaseError(error), -}); -``` - -Omitting `staleOnErrorMaxAgeSec` keeps recovery off and inherits a configured default when used in a sparse runtime overlay. An explicit `0` disables it. A positive `M` requires an enabled remote TTL and must satisfy `0 < F < M <= 31_536_000`; invalid static defaults throw, while an invalid runtime overlay records `config_resolution`, disables only stale recovery for that invocation, and leaves valid ordinary Redis reads active. The effective config snapshot is fixed for the invocation: its initial read uses that snapshot's `F`, `M`, and remote-read deadline even if the provider changes while the source call is in flight. `cached()` captures its selected classifier when the wrapper is registered; `getOrLoad()` captures one for each invocation. - -The retained frame is a snapshot. For a tracked key, the initial primary-routed `MGET` atomically applies the watermark that existed with the value at read time, so an invalidation completed before that read fences the candidate. An invalidation completed after the read does not revoke the already-retained bytes. Concurrent refreshes, deletions, expiry, and eviction after the initial read are likewise not observed for either tracked or untracked candidates. Opting a tracked use case into stale recovery therefore permits recovery to weaken its usual strict freshness guarantee during this source-error path; applications that cannot tolerate that behavior should leave recovery disabled or deny the error in their classifier. - -Recovery does not write Redis, populate the process-local cache, schedule shadow validation, or emit the shadow value-age observation. When request-local caching is enabled, the recovered value is memoized only in that outer `enable()` scope. This prevents an outage response from becoming a new shared cache value. - -Default coalescing applies to the whole sequence, so same-key followers share one initial read, one retained candidate, one source rejection, and one recovery decision. With `coalesce: false`, each concurrent caller instead performs its own initial read, retains its own candidate, and runs its own source call and independent fallback deadline; request-local memoization can still serve later sequential calls after a recovered value settles. High-cardinality delayed source failures can therefore retain one raw payload per distinct in-flight key until the source settles. - -Each classifier-authorized recovery check emits exactly one optional `staleRecovery` outcome: `served`, `miss`, or `deserialization_error`. It does not add another ordinary `request`, `observeGet`, `miss`, or cache-read error: the initial Redis operation is the one caller-serving telemetry trail. `served` additionally emits the optional stale-recovery value-age observation, measured at actual return time in seconds; `miss` and `deserialization_error` emit no age. Existing fallback-duration and fallback-error telemetry still records the source rejection even when recovery serves. Recovery never adds a raw exception or key to metric labels. - -This feature keeps the existing frame-v1 Redis keys; it does not create a second stale key or change the TypeScript shape of `DialCacheRedisClient` or `RedisReadRequest`. Its timestamp behavior is nevertheless breaking for custom clients: ordinary untracked reads previously treated `createdAtMs` as informational, while they now require the real frame timestamp to satisfy logical `F` (and recovery uses it for `M`). Custom clients that returned a constant must return a valid epoch-millisecond stamp before upgrading. Roll out the DialCache version that enforces logical `F` everywhere before enabling writers that retain values to `M`. A pre-feature reader trusts physical expiry and could otherwise serve a retained frame normally between `F` and `M`. Once any write uses physical `M`, treat that keyspace as a downgrade barrier until every such key has expired or been explicitly removed; disabling recovery on current readers is safe because they still enforce `F`, but reintroducing an older reader is not. - -The `F` and `M` comparisons follow the [application-process clock contract](#targeted-invalidation-and-watermarks) used by all serving timestamps. Choose both ages with the deployment's observed clock skew in mind. Redis physical expiry bounds key storage and whether a read can acquire a candidate; it does not revoke a snapshot already retained by the process. That snapshot remains eligible only while the return-time check satisfies `age < M`, even if Redis expires, deletes, or evicts the key after the initial read. - -#### Remote read deadlines and async liveness - -DialCache bounds every active Redis read. The effective timeout is resolved per use case and per invocation: runtime `remoteReadTimeoutMs`, then `defaultConfig.remoteReadTimeoutMs`, then optional instance `redis.readTimeoutMs`, then 50 ms. Values must be positive safe integers no greater than 2,147,483,647. There is no unbounded escape hatch for remote reads. - -When the deadline expires, DialCache aborts the optional `RedisReadContext.signal`, records one `cache_read_timeout` error, logs a `RedisReadTimeoutError`, and starts the source fallback. Late read fulfillment or rejection is consumed and ignored. A read failure or timeout never triggers a post-fallback Redis write; an untracked active process-local miss may retain the source value, while a tracked key suppresses local publication because the failed read did not establish watermark safety. - -Same-key followers share the leader's remaining remote-read budget. The timer covers the one semantic Redis read, not config resolution, serializer load, fallback work, Redis writes, or invalidation. `fallbackTimeoutMs` starts separately when the source fallback begins. With stale-on-error enabled, the initial read may retain a raw candidate through that fallback; recovery reuses it and creates no second Redis-read budget. - -The bundled node-redis adapter passes the signal through per-command options, which can remove queued work where supported. Aborting after dispatch does not unsend a command or prove that Redis stopped executing it. GLIDE's current adapter commands have no per-invocation signal, so a read may continue after DialCache has fallen back. Keep client-native connection, retry, queue, and response budgets in place; they bound underlying resource lifetime while DialCache's deadline bounds caller wait time. - -Writes, invalidations, async `cacheConfigProvider` calls, and custom serializer methods still need finite application-owned budgets. Client support differs: GLIDE's `requestTimeout` bounds every command's reply wait, while node-redis has no per-command deadline — its queue and reconnect controls bound admission and dispatch only (see the invalidation-retry paragraph above), so bound node-redis mutations at the connection layer rather than per call. Do not put mutations behind a bare `Promise.race`: rejecting the outer promise neither removes queued work nor proves whether a dispatched mutation executed. - -#### Serialization - -The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. Writes accept serialized values as `string | Buffer`; reads return `RedisReadResult`, which is either a `DecodedRedisFrame` — the decoded `string | Buffer` payload plus the frame header's writer-client `createdAtMs` — or a `RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }`. The interface does not expose client commands or wire encodings. Narrow with the exported `isRedisReadMiss(result)` guard or `"kind" in result`; anything else is a decoded frame. A decoded frame's timestamp is correctness-relevant: custom clients must return the frame's valid nonnegative safe-integer epoch timestamp rather than a constant. `RedisWriteRequest` contains `valueKey`, `cacheTtlMs`, `value`, and optional `createdAtMs`, and `write()` returns `void`; watermark ownership remains exclusive to tracked reads and invalidation. Core supplies `createdAtMs` only after a miss carrying `observedWatermarkMs` passes both the preflight and final checks; the supplied value is the final dispatch-adjacent sample. A custom client that attaches a valid `observedWatermarkMs` to a tracked miss opts into the known-fenced refill optimization and must encode a supplied `RedisWriteRequest.createdAtMs` exactly so the final fence decision and stored frame cannot diverge. DialCache validates every adapter result at one boundary: a miss with an unknown reason, or anything that is neither a miss nor a frame (including `null`), is recorded as `unclassified` and refilled normally; a `watermark_fenced` claim without a valid fence is demoted to `unclassified`; a fence that is not a nonnegative safe integer, or that arrives on an untracked key, is dropped. - -The shared `encodeRedisFrame`, `decodeRedisReadResult`, and `decodeTrackedRedisReadResult` helpers, the `isRedisReadMiss` guard, the `validateRedisSetReply` and `validateRedisScriptInvalidationReply` reply helpers, the `ceilSupportedCacheTtlMs` TTL guard, and the invalidation Lua source are available from `dialcache/redis-protocol`. A custom write chooses `const createdAtMs = request.createdAtMs === undefined ? Date.now() : request.createdAtMs`, calls `encodeRedisFrame(request.value, createdAtMs)`, and sends one `SET valueKey frame PX cacheTtlMs` after validating the TTL. The fallback clock covers ordinary core writes and direct callers that omit the optional field; supplied values must not be resampled or replaced, and invalid runtime values must still be rejected by the frame encoder. A custom untracked read can pass its native reply to `decodeRedisReadResult`; a custom tracked read atomically obtains `[value, watermark]` from the primary and passes both replies to `decodeTrackedRedisReadResult`. Those helpers return the exact bounded reason and attach any valid observed fence. A missing watermark is treated as zero for valid frames; malformed numeric watermark metadata is `unclassified`, except that a native `nil` value remains `value_absent`. Invalidation passes `KEYS = [watermarkKey]` and `ARGV = [futureBufferMs, invalidatedAtMs]`, with one stable client timestamp reused across retries. Custom adapters can throw the root-exported `DialCacheRedisPayloadError`, `DialCacheRedisPayloadEncodingError`, and `DialCacheRedisProtocolError` classes to distinguish malformed replies, unsupported encodings, and reply-domain violations. DialCache records bounded `cache_read`, `cache_write`, or `invalidation` metrics by failure site. - -Decoded hits keep the existing `{ payload, createdAtMs }` shape. Logical-age rejections on caller-serving and initial shadow reads emit `expired`; future-time and deserialization rejections emit `unclassified`. Neither acquires a refill fence; conditional refills remain limited to adapter-level misses carrying a valid observed watermark. - -Redis values use a compact binary frame: - -```text -byte 1 format version: 1 -bytes 2-9 uint64 big-endian: writer application time in epoch milliseconds -byte 10 payload encoding (0 = UTF-8, 1 = raw binary) -bytes 11... serialized payload (optionally zstd-compressed; see Compression) -``` - -Adapters build complete frames in the Node process with `encodeRedisFrame` and decode them with Node buffer primitives. `RedisReadMiss` exists only across the in-process semantic adapter boundary; it is not a new Redis value. The version-1 value-envelope format, Redis value-key derivation, decimal watermark encoding, tracked `MGET`, and dispatched `SET` are unchanged. That wire compatibility does not make old tracked state safe to carry across the protocol cutover: old watermark lifetimes were derived for the old write protocol. - -Redis physical TTL bounds how long the stored key remains available to future reads. A completed read owns its returned frame, so later expiry, deletion, or eviction cannot revoke that in-process snapshot. The frame timestamp enforces logical `F`/`M` age, future-frame rejection, and shadow value-age observability. `payload` is produced by the operation's serializer, or by `JsonSerializer` by default. Custom serializers can return either `string` or `Buffer`. Payloads stored raw keep their exact serialized bytes: strings are stored as UTF-8 and Buffers byte-for-byte without base64 expansion, except that binary output beginning with a [compression envelope byte](#compression) (`0x00`–`0x02`) gains a one-byte escape prefix on the wire. Payloads at or above the compression threshold may instead be stored as a zstd envelope (see [Compression](#compression)), so wire bytes for large values are not the serializer's output. Adapters return the frame payload as-is; the envelope — including restoring a compressed string's representation before `serializer.load` — is interpreted by the core above them. - -DialCache uses native `JSON.stringify` and `JSON.parse` by default. There is no runtime validation pass, so the default adds no traversal beyond JSON serialization itself. A top-level `undefined` result is supported with an internal sentinel. - -When `serializer.load` rejects a Redis payload, DialCache records a `serialization_load` error, counts the read as a remote cache miss, runs the fallback, and attempts to replace the rejected payload. A validating custom serializer can therefore treat an incompatible cached value as a refreshable miss without adding a schema version to the cache key. - -`JsonSerializer` validates JSON syntax only. It cannot detect that a structurally valid payload came from an incompatible application value schema. Applications that keep the same `useCase` across deployments must keep default-JSON values backward compatible. For an incompatible change, either provide a serializer whose `load` method validates and rejects the old shape, or change `useCase` to isolate the new cache entries. On the caller-serving Redis path, mutually incompatible validating serializers in a mixed deployment can repeatedly reject and replace each other's values; correctness is preserved, but expect additional fallback and Redis-write load until the rollout converges. Shadow work reports a non-null payload that fails `load` as `deserialization_error` and never replaces it. - -When a cached function or inline loader's resolved return type is statically JSON-compatible, `serializer` remains optional. This includes JSON primitives, arrays, plain object/interface shapes, optional object fields, and a top-level `undefined`. Types known not to survive the default round trip require a typed `Serializer`: - -```ts -import { DialCache, type Serializer } from "dialcache"; - -const dialcache = new DialCache(); -const dateSerializer: Serializer = { - dump: (value) => value.toISOString(), - load: (value) => new Date(Buffer.isBuffer(value) ? value.toString("utf8") : value), -}; - -const getUpdatedAt = dialcache.cached( - (userId: string) => db.fetchUpdatedAt(userId), - { - keyType: "user_id", - useCase: "GetUpdatedAt", - cacheKey: (userId) => userId, - serializer: dateSerializer, - }, -); -``` - -The compile-time guard rejects known incompatible shapes such as `Date`, `Map`, `Set`, `bigint`, symbols, functions, Buffers, typed arrays, method-bearing class instances, required nested `undefined`, `unknown`, and `any`. It applies to every `cached()` declaration and `getOrLoad()` invocation because active layers are selected at runtime. A global Redis serializer is not parameterized by each returned type, so it cannot discharge this requirement; non-JSON operations must select a typed serializer. - -This guard is deliberately conservative and is not a proof of runtime data. TypeScript cannot detect non-finite numbers, cyclic/shared references, runtime getter or `toJSON` behavior, or data-only class instances that look like plain objects. Opaque, generic, or deeply recursive types may also require an explicit serializer. Providing `Serializer` (including an explicitly typed `JsonSerializer`) is a trusted caller assertion; DialCache does not serialize-and-deserialize again to validate it. - -#### Protocol cutover - -The old and new tracked-write protocols must not coexist. Before enabling this release for a namespace, stop and drain every old writer and invalidator plus in-flight fallbacks, shadow work, Redis client queues, and other operations that can still write tracked state. Then purge every tracked value — complete or placeholder — and every watermark in the affected namespace. A full namespace flush is the simplest option when the Redis deployment is dedicated; untracked complete values may be retained. - -Alternatively, keep all traffic disabled until every old tracked value and watermark has expired naturally. That is safe only when the maximum remaining lifetime of both classes is bounded, no watermark is persistent, and the wait covers the old release's maximum value TTL and future-buffer-derived watermark TTL. This library intentionally does not implement the external deployment gate. - -#### Compression - -DialCache transparently compresses serialized Redis payloads with zstd (level 3, via `node:zlib`) when they are at least 4096 serialized bytes, and stores the compressed form only when it is smaller than the raw stored form. Compression sits below the serializer and above the Redis client, so serializers, adapters, and the frame layout are unaffected. A refill already known to be fenced is rejected before `serializer.dump` reaches this layer, avoiding compression and large intermediate payload/frame allocation as well as the native `SET`. The first byte of a binary frame payload written by a release with payload compression is an envelope byte: `0x01` marks a compressed UTF-8 string and `0x02` compressed binary output, each followed by the zstd frame, while `0x00` is an escape prefix for raw binary serializer output whose own first byte is `0x00`–`0x02` (readers strip the prefix and never decompress it; the escape applies even with `compression: false`). Payloads below the threshold are otherwise stored byte-identical to earlier DialCache releases; only binary output beginning with an envelope byte gains the one-byte escape. - -Decompressed payloads are capped at 512 MiB, mirrored on the write side by refusing to compress anything larger, so no writable entry is unreadable and a corrupt or hostile entry cannot force a giant synchronous allocation. zstd runs synchronously on the event loop: at level 3 it stays cheaper than the adjacent `JSON.stringify`/`parse` at every size (~2 ms to compress 2 MiB), but cost rises steeply with level — measured ~250 ms for 1 MiB at level 19 and ~1.5 s for 2 MiB at level 22 — so treat high levels as an informed opt-in and watch the compression timer metric. - -Compression is on by default and configured per instance next to the serializer: - -```ts -const dialcache = new DialCache({ - redis: { - client: redisClient, - // Defaults shown; pass compression: false to store every payload uncompressed. - compression: { thresholdBytes: 4096, level: 3 }, - }, }); -``` - -Reads always decompress marked payloads regardless of this setting, so disabling compression never orphans previously written entries. The escape prefix makes decoding exact for every entry written by a release with the envelope, whatever bytes a custom serializer emits. Entries written by older releases have no envelope, which leaves a bounded residual until they expire: a legacy binary payload beginning `0x01`/`0x02` is handed back untouched when zstd rejects it (`fallback_raw`), but one whose remaining bytes parse as a zstd stream is misread, and a legacy payload whose first two bytes are both in `0x00`–`0x02` loses its first byte to the escape strip. If a custom binary serializer can emit such output, bump its use case or key type when upgrading so old entries are simply misses. - -Rolling upgrades and rollbacks degrade to misses, not errors, but are visible in metrics: during a mixed-fleet window, readers on releases without payload compression fail `serializer.load` on compressed entries, producing a transient `serialization_load` error spike (and shadow `deserialization_error` outcomes) plus refill churn until the fleet converges — expected noise, worth an alerting note. For a zero-noise upgrade with string/JSON serializers, deploy this release with `compression: false` first, then enable it once the fleet converges; binary serializers with envelope-colliding output still write escaped bytes in phase one, so rely on key versioning there instead. Rolling back to a release without the envelope degrades compressed and escaped entries to refreshable misses the same way, presuming `serializer.load` rejects the foreign bytes — a permissive binary decoder could misread an escaped payload instead, the same caveat as the forward residuals above. On runtimes without `node:zlib` zstd (Node below 22.15, and 23.0–23.7), ESM consumers cannot load the package at all (the import fails), while CommonJS consumers fail at construction when compression is enabled; `compression: false` is the working configuration there for CommonJS only. - -Each write records a bounded compression outcome (`compressed`, `below_threshold`, `not_smaller`, or `write_over_limit`) and, when compressed, a compressed-to-original size ratio; reads record `decompressed`, `fallback_raw`, or `read_over_limit` (`write_over_limit` is a capacity signal; `read_over_limit` a corruption/integrity signal). Compression and decompression latency is observed separately with an `operation` label (see [Metrics](#metrics)). Payload sizes are reported at both stages: the size histogram observes serializer output (pre-compression, the distribution to consult when tuning `thresholdBytes`), and the stored-size histogram observes what was actually written after compression and escaping — the difference between their sums is the bytes compression saved. - -#### Shadow validation - -Shadow mode runs a sampled, detached Redis path for tracked or untracked keys without allowing that path to serve the caller. A Redis hit is compared with the source of truth (SoT); a semantic Redis miss can be filled from the caller-accepted SoT value. Redis serving and shadow execution are independent, and shadowing is opt-in per use case through `shadow.ramp`: -```ts -import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; - -const dialcache = new DialCache({ - namespace: "users-api", - redis: { client: redisClient }, - // A bundled or custom adapter with shadowValidation support is required. - metrics, - // At most one scheduled or running shadow job by default; tune deliberately. - shadowMaxInFlight: 4, -}); +// In a service, wrap each request's reads in one enable() call. +await dialcache.enable(async () => { + await getUser("123"); // Loads from source and caches the result. + await getUser("123"); // Reuses the value for up to 60 seconds. -const getUser = dialcache.cached( - (userId: string) => db.fetchUser(userId), - { + // Inline form: a direct key instead of cacheKey, and no registration. + // Call sites that share a key share cached entries. + const inline = { keyType: "user_id", - useCase: "GetUser", - cacheKey: (userId) => userId, - // Optional: make Redis reads watermark-aware; writes remain complete-frame SETs. - trackForInvalidation: true, - // Optional: override strict deep equality with use-case semantics. - shadowComparator: (cached, source) => - cached.id === source.id && cached.version === source.version, - defaultConfig: new DialCacheKeyConfig({ - ttlSec: { [CacheLayer.REMOTE]: 300 }, - // Exercise and populate Redis without serving it to callers. - ramp: { [CacheLayer.REMOTE]: 0 }, - shadow: { - ramp: 5, - // Default-off warning with a bounded key and native-JSON value strings. - // Enable only after approving the logger and data-handling policy. - logMismatches: true, - }, - }), - }, -); -``` - -Shadow work is eligible only when a valid remote TTL/policy exists, its effective `shadow.ramp` selects the exact cache key, a configured metrics adapter implements `shadowValidation`, and capacity is available. Tracked and untracked Redis keys are both eligible; each keeps its existing read mode and uses the common complete-frame write path. Logging is supplemental to the metric; enabling `logMismatches` does not activate shadow work without the metrics hook. The bundled Prometheus and Datadog adapters implement that hook. There are two paths: - -- When remote serving is enabled and produces a Redis hit, DialCache retains the exact serialized payload that supplied the caller as `C0`. -- When the remote policy is valid but disabled specifically by `ramped_down`, DialCache starts a detached Redis read for `C0` using the key's existing tracked or untracked mode. Its result can be validated or used to decide whether a semantic miss may be filled, but can never supply the caller or populate an in-memory layer. - -A missing or invalid remote policy, config-provider failure, absent Redis client, disabled call, omitted metrics hook, zero/omitted shadow ramp, cohort exclusion, capacity rejection, or earlier request-local/process-local hit does not launch a shadow-only Redis path. Shadow work begins only if normal traversal reaches the Redis layer. A normally enabled remote miss already follows the caller's ordinary fallback-and-fill path and does not launch a duplicate shadow fill. - -On a served hit, DialCache returns the already-decoded cached value before starting the SoT read or any confirmation work. On a ramped-down path, the caller invokes and awaits its normal configured fallback exactly once and receives only that result; detached work reuses the same accepted `S` instead of calling the loader again. The caller never awaits shadow `C0`, comparison, confirmation `C1`, shadow serialization, or fill. Slow, failed, or timed-out shadow work cannot delay, reject, or change the caller result. - -The detached job uses this bounded algorithm: - -1. Obtain the original Redis payload as `C0` using the key's existing tracked or untracked read mode. -2. If `C0` is missing, wait for the caller's successfully accepted `S` after its configured fallback boundary. For a tracked miss carrying `observedWatermarkMs`, sample a preflight timestamp before serialization: emit `fill_fenced` and stop without invoking `serializer.dump`, compression, or Redis when that timestamp is at or before the observed watermark. Otherwise prepare the serialized payload, sample a final timestamp dispatch-adjacent, and recheck the watermark. Emit `fill_fenced` without dispatching `SET` if the final timestamp is at or before the watermark; otherwise attempt one normal Redis write using the resolved TTL and that exact final timestamp. For a miss without an observed fence, attempt the ordinary write without supplying a core timestamp, preserving adapter-side sampling. Before the whole-job deadline, emit `filled` when Redis accepts the write or `fill_error` when serialization or the write fails. A tracked fill can still be physically stored yet remain fenced if the watermark advances after `C0`. -3. If `C0` is non-null, obtain `S`, deserialize an isolated snapshot of `C0`, and run the default or custom semantic comparator. Any non-null `C0` is observation-only: DialCache never repairs or overwrites it, including when deserialization fails. -4. If `C0` and `S` match semantically, emit `match` without another Redis read. -5. Otherwise, reread Redis directly in the same mode as `C1`, bypassing request-local and process-local cache. -6. If `C1` is missing under the normal value/watermark protocol or differs byte-for-byte from `C0`, emit `superseded`; if it is identical, emit `mismatch`. A future-dated `C1` records its offset but remains available for this payload comparison, so a reader-clock step does not change the verdict. -7. If the confirmation read fails or reaches its Redis-read deadline, emit `confirmation_error`. - -Here a semantic miss means the Redis read returned a `RedisReadMiss` (or any result DialCache could not recognize); it does not include a non-null payload that later fails deserialization. A physical frame rejected by its watermark (for tracked reads), timestamp domain, logical-age check, or future-time check is therefore a miss. Only a miss with a valid observed fence can produce `fill_fenced`; its reason is independent (bundled adapters attach fences to `value_absent`, `watermark_fenced`, and `unclassified` misses, while `expired` is decided by DialCache after decoding and never carries a fence from a bundled adapter, though a custom adapter may pair any reason with a valid fence). Misses without a fence retain normal refill behavior. A caller fallback rejection or timeout never becomes accepted `S` and never starts the fill. - -Both detached Redis reads use the effective `remoteReadTimeoutMs` and the key's normal native `GET` or `MGET` protocol. The initial `C0` observation enforces the logical remote TTL `F`; the non-serving `C1` confirmation bypasses logical age solely to determine whether the original payload bytes were superseded. Every dispatched semantic-miss fill uses the same serializer, resolved physical TTL, complete-frame `SET`, and two-sample conditional-refill rules as the caller path; when stale-on-error is active, it requests physical retention through `M` while serving reads still enforce `F`. Misses without a valid observed fence preserve the ordinary adapter-side timestamp sample. Tracked `C0` and `C1` reads remain watermark-aware and are routed to primaries by the bundled adapters. The fill itself never issues another watermark read or mutates the watermark; `fill_fenced` is decided against the watermark from the original `C0` snapshot and adds no round trip. Untracked reads use the ordinary one-key read route, which has no shadow-specific primary guarantee. Strings compare exactly, Buffers compare by bytes, and string/Buffer pairs compare by their UTF-8 bytes. DialCache does not deserialize `C1`, compare it with `S`, or chase another version. - -The detached scheduler, Redis-read deadline timers, and overall shadow deadline timer are unreferenced, so they do not keep an otherwise idle process alive. Detachment is asynchronous work on the Node event loop, not a worker thread: synchronous source, serializer, or comparator work can still occupy the event loop after the request path has been released. - -Detached execution retains the original `cached()` argument references or `getOrLoad()` loader closure; DialCache cannot generically clone them. Treat object arguments, captured source-selection state, and the returned `S` as immutable, or snapshot them before invoking DialCache. Mutating them after the caller continues can compare or serialize a value that no longer corresponds to the already-built key. - -`shadowMaxInFlight` is a per-instance positive safe integer and defaults to `1`. It counts admitted jobs until shadow-owned Redis/source/serializer/comparator work settles, including detached reads or dispatched writes whose DialCache deadline already elapsed. The optional `C1` and semantic-miss fill remain in the original slot. On a ramped-down path, shadow work shares the caller's SoT promise; once the shadow deadline expires, the raw caller-owned loader may continue without retaining the shadow slot, including when `fallbackTimeoutMs` is `null`. DialCache also suppresses another job for the same exact key while shadow-owned work remains active. There is no queue: exact-key duplicates and work above the instance cap are dropped and reported as `dropped`. Separate instances have independent limits, so this is not a fleet-wide source-of-truth or Redis concurrency cap. - -Each job has one monotonic deadline across detached `C0`, the SoT result, serializer work, comparison, optional `C1`, and semantic-miss fill. Served-hit timing begins when its detached validation callback starts. On a ramped-down path, timing begins immediately before the caller's SoT invocation so synchronous source work that runs before admission still consumes the same budget. Each Redis read also has its effective read deadline. A finite `fallbackTimeoutMs` is reused as the overall shadow budget. When `fallbackTimeoutMs` is `null`, the normal fallback remains intentionally unbounded, but detached shadow work still uses a 60-second budget. Once timeout delivery marks a job abandoned, DialCache releases retained `C0` references and prevents later phases from starting. - -JavaScript promises and Redis writes do not provide a general cancellation or transaction boundary. Work already dispatched may continue and keeps the shadow slot until it settles. A write rejection, `fill_error`, or shadow `timeout` after dispatch does not prove that Redis was unchanged; the command may have executed before its result became unavailable. Conversely, `filled` means the semantic client returned success before the shadow deadline, not that the value is still present. `fill_fenced` is stronger but narrower evidence: one of DialCache's local fence checks skipped dispatch against the valid watermark observed by `C0`; it does not prove that Redis stayed unchanged afterward. Give dependencies finite native budgets and treat shadow outcomes as best-effort operational evidence. - -A `match` means application-level value equality: - -- By default, DialCache uses Node's `util.isDeepStrictEqual`. Plain-object property insertion order does not affect the result; values, array order, prototypes, constructors, Buffers, Maps, Sets, and other supported structures remain strictly compared. -- An optional typed `shadowComparator(cachedValue, sourceValue)` on `cached()` or `getOrLoad()` can define narrower domain equality, such as ignoring a volatile timestamp. It must synchronously return a boolean and must be deterministic, side-effect-free, non-mutating, and bounded. -- A comparator throw or non-boolean return is `comparison_error`, never `mismatch`. An accidental promise is not accepted as a comparison result; DialCache consumes its settlement while retaining the shadow slot, subject to the same detached deadline. - -DialCache retains the semantic frame returned by the Redis client — its `string | Buffer` payload and `createdAtMs` — but never exposes it to the comparator. After the source read completes, detached work calls the same effective serializer's `load` method to create an independent cached snapshot, then compares that snapshot with the raw value returned by the source loader. It does not reuse the cached object already returned to a served-hit caller, so caller mutation cannot contaminate validation. No payload copy, shadow deserialization, deep comparison, or hash is added to the served-hit request path. - -The effective serializer's `load` method therefore runs a second time for a sampled served hit and once in detached work for a shadow-only hit. It must be repeatable, non-mutating, and return independently usable values. On a semantic miss, its `dump` method may run after the caller has received `S`, so `S` must remain immutable through detached serialization. A custom `DialCacheRedisClient` must return an operation-owned frame whose string/Buffer payload contents remain stable after `read()` settles. Comparing the deserialized cached snapshot with the raw source value intentionally detects lossy serialization; use a custom comparator only when such normalization or ignored fields are valid use-case semantics. - -Shadow metrics use the bounded outcomes `match`, `mismatch`, `superseded`, `filled`, `fill_fenced`, `fill_error`, `redis_error`, `source_error`, `deserialization_error`, `comparison_error`, `confirmation_error`, `timeout`, and `dropped`. `redis_error` applies to the initial shadow-only `C0` read and `confirmation_error` applies to `C1`. A semantic `C0` miss is an ordinary `miss{layer="remote_shadow"}` and terminates with `filled`, `fill_fenced`, `fill_error`, `source_error`, or `timeout` rather than a second shadow outcome for the miss itself. Labels never contain cache ids, values, payloads, Redis keys, or raw exception text. - -A `match` or `mismatch` verdict additionally records the validated value's age through the optional `observeShadowValueAge` adapter hook: the observing process's epoch clock minus the writer-stamped frame's `createdAtMs` (the served `C0` frame, or the detached `C0` frame on a ramped-down path), in seconds, clamped at zero. The age is captured at verdict time, so a mismatch age lands one confirmation read after the comparison itself. A confirmed `mismatch` age therefore measures how long the stale value had been readable when validation caught it. Because writer and observer are different application processes, their clock offset makes the age coarse operational evidence rather than a precise measurement. Outcomes that deliver no verdict on a retained value — including `superseded`, `filled`, `fill_fenced`, and every error or timeout outcome — record no age. The hook does not gate shadow eligibility; only `shadowValidation` does. - -Confirmed-mismatch logging is separately opt-in through `shadow.logMismatches`; it never replaces the required `shadowValidation` metric or emits for a mismatch candidate that becomes `superseded`. The single warning contains `cacheNamespace`, `useCase`, `keyType`, `outcome: "mismatch"`, `cacheKey`, `cachedValueJson`, and `sourceValueJson`. `cacheKey` is the logical DialCache URN capped at 2 KiB, not the physical Redis storage key. DialCache independently applies native `JSON.stringify` to the deserialized cached snapshot and raw source value supplied to the comparator, then caps each resulting string at 8 KiB. A byte-clipped field ends in `...[truncated]`, counted inside its cap. If native JSON throws or returns `undefined`, the corresponding JSON field is `null`; the other side is still attempted. DialCache does not compute a textual diff or call the configured serializer again for logging. - -Mismatch logging is intentionally default-off. Logical URNs can contain ids and arguments, while cached and source values may contain secrets or personal data; truncation is not redaction. DialCache creates the JSON strings only after terminal mismatch confirmation and never passes the raw compared-value references to the logger. Native JSON semantics apply: getters and `toJSON` methods may run, unsupported values may be omitted or normalized, and cycles or `bigint` can make a field unavailable. Stringification is synchronous, and the 8 KiB caps apply only after `JSON.stringify` returns; they do not bound input traversal, hook execution, event-loop time, or the intermediate JSON string. Enable mismatch logging only for trusted, reasonably bounded values and with an approved logger, redaction, transport, access, and retention policy. - -The byte caps apply before logger framing or escaping, so they do not guarantee a final transport event below a sink-specific limit; the metadata fields are not size-clamped. A detail-construction failure degrades to the metadata-only warning. Logger throws and rejected promises or thenables remain isolated from cache and shadow correctness. - -Detached Redis reads, serializer loads/dumps, payload sizes, and read/write errors use the existing layer label with `layer="remote_shadow"`. This distinguishes non-serving Redis cost from caller-path `layer="remote"` telemetry without adding a metric or label. The established `observeGet{layer="remote"}` boundary includes caller-path deserialization, while `observeGet{layer="remote_shadow"}` ends when the deadline-bounded Redis read result settles; detached serializer work and any later raw-client settlement are outside that timer. The request-path read that supplied a served `C0` keeps `layer="remote"`, and a ramped-down caller keeps `disabled{layer="remote", reason="ramped_down"}`. No `disabled{layer="remote_shadow"}` event is emitted for ineligible or dropped work; `dropped` remains the terminal shadow outcome. Confirmation reads use the same `remote_shadow` value, with `superseded` or `confirmation_error` describing their role. - -The command amplification is bounded: a selected served hit adds one SoT read and adds `C1` only for a semantic mismatch candidate; a selected ramped-down hit adds detached `C0`, reuses the caller's existing SoT read, and likewise adds `C1` only for a candidate; a selected ramped-down miss adds detached `C0` and at most one fill in the key's existing mode. A fenced tracked fill adds no `SET`, and a preflight fence also avoids payload preparation; no path adds a fence-check command. `superseded` means only that the original observation could not be confirmed. `mismatch` means the exact `C0` payload survived another Redis read after the SoT disagreement; it is not a cross-system atomic snapshot or a guarantee that the mismatch persists. For an untracked key it is also not proof of primary freshness or invalidation safety. - -The initial `C0` read and later fill are not atomic. A miss with a valid observed watermark can suppress a fill against the `C0` fence regardless of the independent miss reason, but an allowed fill is still a normal overwrite, not a compare-and-set or write-if-still-missing operation: another writer can populate Redis after the semantic miss and be overwritten by the shadow fill, or a later invalidation can fence it. For tracked keys, the next atomic value-and-watermark read still rejects a frame whose client timestamp is at or before the watermark, so size `futureBufferMs` to cover the complete SoT, serialization, client queue, network, write interval, and fleet clock-skew budget when stale-serving protection matters. An untracked shadow fill has no such read fence and retains the ordinary TTL-based last-writer-wins contract; because it is detached, an older accepted source value may be written after a concurrent source mutation and remain until expiry. Shadow mode never repairs a frame returned as a decoded `C0`; a physical frame rejected by normal tracked-read semantics is a miss and may be overwritten with a fresh TTL unless its observed fence still rejects the refill. Shadow work never invalidates, evicts local state, or changes the value returned to the caller. - -A served-hit sample invokes the wrapped function or inline loader as an additional source read, so that loader must be safe to call for observation. A ramped-down sample reuses the caller's ordinary invocation and does not add another SoT call. - -For valid policies, shadow-specific source calls, cache-path Redis traffic, returned values, and metrics are unchanged when `shadow` is omitted or `shadow.ramp` is `0`. Shadow policy is grouped under `DialCacheKeyConfig.shadow`; consumers of the former flat ramp field must migrate to `shadow: { ramp }`. The public constructor, static defaults, and runtime provider results reject the removed field. `DialCacheKeyConfig.disabled()` explicitly disables the shadow ramp and mismatch logging. `shadowComparator` remains a typed `cached()` / `getOrLoad()` option because it defines stable use-case equality, while `shadowMaxInFlight` remains a per-instance concurrency limit. The semantic-miss bootstrap adds no additional ramp knob, Redis protocol operation, metric instrument, or label key; enabling shadowing authorizes the same Redis write described above. Untracked keys participate when they have a nonzero effective shadow ramp and an observable metrics hook. Exported unions include `remote_shadow` in `MetricLayer` and `superseded`, `filled`, `fill_fenced`, `fill_error`, `redis_error`, and `confirmation_error` in `ShadowValidationOutcome`. TypeScript consumers with exhaustive switches or `Record` values must include those cases, and dashboards restricted to `layer="remote"` intentionally exclude detached traffic. - -## Cached-value ownership - -Treat values returned by cached functions or `getOrLoad()` as immutable. DialCache does not clone or freeze values stored in request-local or process-local memory. Mutating a cached object can therefore be observed by later callers in the same request, callers in other requests that hit the process-local cache, or callers that coalesced onto the same in-flight result. - -This contract includes nested objects and arrays, `Map`, `Set`, `Buffer`, typed arrays, and class instances. Redis deserialization can produce a different reference from an in-memory hit, so reference identity is layer-dependent and is not part of the API contract; never rely on a specific layer cloning a value before mutation. - -If a caller needs a mutable value, copy it explicitly before changing it: - -```ts -const sharedUser = await getUser("123"); -const editableUser = structuredClone(sharedUser); -editableUser.displayName = "New name"; -``` - -Use a narrower copy when its semantics are sufficient; the ownership boundary is the caller's responsibility. - -## Targeted invalidation and watermarks - -Mutable Redis-backed use cases can opt into targeted invalidation by setting `trackForInvalidation: true` in the options and calling `dialcache.invalidateRemote(keyType, id, futureBufferMs)` after writes. `invalidateRemote` requires `DialCacheConfig.redis`; local-only caching remains supported, but this explicit remote maintenance operation rejects when Redis is absent. The buffer is an application-owned safety value; DialCache cannot choose a universally safe nonzero value: - -```ts -import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; -import { createNodeRedisDialCacheClient } from "dialcache/node-redis"; - -const dialcache = new DialCache({ - namespace: "users-api", - redis: { client: createNodeRedisDialCacheClient(redisClient) }, + useCase: "GetUserInline", + key: "456", + defaultConfig: new DialCacheKeyConfig({ ttlSec: { [CacheLayer.LOCAL]: 60 } }), + }; + await dialcache.getOrLoad(() => fetchUser("456"), inline); // Loads from source. + await dialcache.getOrLoad(() => fetchUser("456"), inline); // Reuses the value. }); -// Chosen from this application's clock-skew bound and measured worst-case source/fallback timings. -const USER_INVALIDATION_BUFFER_MS = 5_000; - -const getUser = dialcache.cached( - (userId: string) => db.fetchUser(userId), - { - keyType: "user_id", - useCase: "GetMutableUser", - cacheKey: (userId) => userId, - trackForInvalidation: true, - // Strongly invalidated mutable data should disable request-local and process-local caching. - defaultConfig: new DialCacheKeyConfig({ - ttlSec: { [CacheLayer.REMOTE]: 300 }, - ramp: { [CacheLayer.REMOTE]: 100 }, - }), - }, -); - -await updateUser("123", patch); -await dialcache.invalidateRemote("user_id", "123", USER_INVALIDATION_BUFFER_MS); +await getUser("123"); // Outside enable(): loads from source again. ``` -Invalidation writes a Redis watermark at `{encodedNamespace:encodedKeyType:encodedId}#watermark`. Tracked Redis cache entries use the same Redis Cluster hash tag, for example `{users-api:user_id:123}?locale=en#GetMutableUser:dialcache-frame-v1`, so the value key and watermark key live in the same slot. Key components are percent-encoded before joining so delimiters inside IDs or args cannot collide with delimiters in the key format. Components may not contain `{` or `}` because those characters would corrupt the hash tag. - -The internal `:dialcache-frame-v1` suffix identifies values written with DialCache's binary protocol. Watermarks are stored as decimal timestamps. - -A complete supported Redis frame with a positive safe-integer `createdAtMs` at or below a valid observed watermark is a `watermark_fenced` tracked miss. `invalidateRemote(keyType, id, futureBufferMs)` proposes the invalidating process's `Date.now()` plus the buffer, and Lua keeps the greater of that proposal and the existing watermark. A tracked read obtains value and watermark in one primary-routed `MGET`; a missing watermark is the zero baseline, malformed or out-of-range decimal state is `unclassified` for a present frame, while a `nil` value stays `value_absent`. A zero-stamped tracked frame is also `unclassified`, not evidence of watermark invalidation. Redis also returns `nil` for a wrong-type member of `MGET`, so a wrong-type watermark has the same zero-baseline behavior as an absent one until the next explicit invalidation repairs it. When the same snapshot contains a valid numeric watermark and an adapter-level semantic miss, the bundled adapter returns `RedisReadMiss { kind: "miss", reason, observedWatermarkMs }`. The reason remains independent: a missing value is `value_absent` while carrying the same refill fence, and only a complete supported frame actually rejected by the watermark is `watermark_fenced`. After the fallback succeeds, DialCache samples a preflight timestamp before serialization. If it is at or before `observedWatermarkMs`, fallback still returns normally but the replacement is skipped before serializer/compression/frame work and `SET`. Otherwise DialCache prepares the payload, samples a final dispatch-adjacent timestamp, and rechecks the same watermark. A final timestamp at or before the watermark suppresses `SET`; an admitted write encodes that exact final timestamp so serialization time does not consume the stored value's logical TTL. Misses without a valid observed fence retain normal refill behavior. Native `MGET` must still transfer the full stored frame before Node can apply the fence verdict, so a future window can repeatedly transfer a large fenced payload even when replacement work is suppressed. A tracked invocation that reaches the Redis read/write path does not publish its fallback directly to process-local cache; a later validated Redis hit may warm it. Local-only, remote-policy-disabled, and ramped-down paths remain governed by local policy, while request-local memoization remains unconditional. A ramped-out invocation without shadow work does not consult Redis; selected tracked shadow reads remain watermark-aware. - -All serving timestamps come from application-process epoch clocks; DialCache does not call Redis `TIME`, estimate an offset, or compensate for skew. Participating application nodes therefore need external clock synchronization and monitoring. Healthy managed node pools commonly stay close, but Kubernetes does not guarantee a maximum offset, and pauses or NTP faults can be much larger than normal millisecond-scale skew. Relative clock differences shift logical expiry early or late, while frames dated after a reader clock fail closed until that clock catches up. Operation durations and deadlines continue to use the monotonic `performance.now()` clock. - -Watermarks are invalidation state, not disposable cache entries. The Redis deployment must preserve them for their derived TTL: use `noeviction` or an equivalent guarantee for deployments that rely on the read-time fence, and choose persistence and failover guarantees appropriate to the application's consistency requirements. If a watermark is lost, a tracked read treats it as zero and may serve an existing frame that the lost watermark had fenced. Alert on memory headroom and rejected writes under `noeviction`; if another eviction policy is used, also alert on `evicted_keys`. Redis replication is asynchronous by default, and DialCache does not issue `WAIT` or provide strong consistency across failover. - -Tracked Redis value TTLs are capped at one hour; a dispatched write configured above the cap records `tracked_ttl_clamped`. Only invalidation creates or updates a watermark. Its TTL is `max(existing TTL, 2 hours, watermark - invalidatedAtMs + 1 hour + 1 minute)`; an existing persistent watermark stays persistent. Reads and writes never extend it. Under the documented clock-skew and in-flight-work bounds, this makes every finite watermark outlive every value it can fence, including a maximum future-buffer proposal. Raising the tracked-value cap or shrinking the watermark floor is a protocol transition that requires another no-overlap gate, drain, and purge; changing both constants in one new binary cannot lengthen watermarks already written by an older invalidator. - -`futureBufferMs` must be a nonnegative safe integer no greater than 31,536,000,000 (a fixed 365-day duration). The default is zero, but zero provides no stale-serving protection once a writer timestamp advances past the watermark. Every production invalidation should pass a named, application-owned nonzero value based on that application's measured or conservatively bounded timings; there is no universally safe library value. - -Let `Dmax` be the maximum elapsed time from invalidation sampling until a stale pre-mutation `SET` can become visible in Redis, `S` the maximum writer-clock lead over the invalidator across participating application nodes, and `M` an operational margin. Callers that require stale-serving protection must satisfy `futureBufferMs >= Dmax + S + M`. Bound `Dmax` through source visibility or replication lag, the remaining tail of any fallback that may observe the pre-mutation value, `serializer.dump`, Redis client queueing or reconnect/offline-queue delay, network transit, and Redis execution and visibility. An unbounded offline queue or retry path makes a finite `Dmax` impossible. Invalidate only after the source mutation commits. The dangerous skew direction is a fast writer and slow invalidator: an undersized buffer can let delayed stale work receive a timestamp above the watermark and remain readable until expiry or another invalidation. The reverse direction is conservative. Overestimating the buffer still increases fallback load and full-payload `MGET` transfer on hot large-value keys. While a candidate remains behind a valid observed watermark, bundled and correctly opted-in adapters now avoid serializer/compression work plus full-frame `SET`, replication, and AOF churn; that work resumes once the candidate advances beyond the observed fence, and misses without an observed fence continue the normal write path. The optimization does not delay or suppress returning fallback values to callers. - -The fixed one-minute watermark-TTL margin is retention slack after the covered visibility bound; it is not a substitute for `Dmax`. The operational contract should include the full post-sample dispatch tail in `Dmax` rather than relying on that slack. - -This is a read-time timing contract rather than a cancellation or acquisition fence. The buffer makes covered frames unreadable. A miss carrying an observed watermark can locally avoid a replacement already known to be fenced, but it does not cancel previously dispatched work, prevent a later watermark advance from fencing an allowed `SET`, or force fallback to read from an authoritative source. - -The version-1 value envelope, Redis keys, and decimal watermarks remain wire-format-compatible. The adapter contract and Lua surface are not source-compatible: `write()` is now void and has no `watermarkKey`; the stamp script, placeholder helpers, and `DialCacheRedisPlaceholderLostError` are removed; `dialcacheRedisScripts` and `DialCacheNodeRedisScripts` are removed because node-redis manages invalidation dispatch internally; `ValkeyGlideRuntime` no longer requires `ClusterBatch`; invalidation is the only script; `fill_blocked` is removed from `ShadowValidationOutcome`; and `tracked_ttl_clamped` is added to `MetricErrorKind`, so exhaustive switches and `Record` values must add it. The conditional-refill addition itself does not change the Redis wire format, key format, Lua surface, command types, or round-trip shape; it may suppress an otherwise-dispatched `SET`. `RedisReadResult` is now `DecodedRedisFrame | RedisReadMiss`: `null` and `RedisWatermarkMiss` are gone from the type, the legacy `decodeRedisFrame` and `decodeTrackedRedisFrame` helpers are removed, and `isRedisReadMiss` is exported from the root and `dialcache/redis-protocol`. Custom adapters return `RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }` for misses; at runtime DialCache still fails open on `null` or any unrecognized result by recording an `unclassified` miss and refilling normally, so an un-migrated adapter keeps working but loses reason precision and fenced-refill suppression. Code that compared a bundled adapter's `read()` result to `null` must switch to `isRedisReadMiss`. `RedisWriteRequest.createdAtMs` is optional for source compatibility and direct callers may omit it; adapters returning a typed miss with a valid observed watermark must honor a supplied value exactly. `fill_fenced` is added to `ShadowValidationOutcome`, so exhaustive switches and `Record` values must include it. The Prometheus future-timestamp histogram now uses a dedicated skew-oriented bucket schema, which is incompatible with an existing same-name collector registered with the former default buckets. Follow the externally gated [protocol cutover](#protocol-cutover) before deployment. Deploy the future-timestamp metric and external node-clock alerts first. The metric is only a workload-shaped smoke detector: it cannot detect co-skewed readers and writers, an ahead invalidator, watermark skew hidden by a fenced miss, or which node is wrong. +Results containing `Date`, `bigint`, or other non-JSON-compatible values need an +explicit [typed serializer](https://lan17.github.io/DialCache/redis.html#typed-serializer-requirement), +even when caching only in memory. Cached objects are shared references; copy +before modifying. -Targeted invalidation is remote-only and enforced by Redis watermarks. `invalidateRemote` does not evict existing request-local or process-local entries. Strongly invalidated mutable data should disable request-local and process-local caching (or use a very short process-local TTL only when stale reads are acceptable). +## Enabled scope -## Request coalescing - -DialCache coalesces in-flight work at the lifetime of the first active cache layer: - -- When request-local caching is enabled, same-key callers in one outermost `enable()` scope share request-scoped in-flight work before the request-local lookup. Its resolved value is then memoized for later sequential calls in that scope. -- When process-local or Redis caching is enabled, same-key callers share in-flight work within one `DialCache` instance before the first active shared layer. This is reported as `scope="process"`, still applies when request-local caching is off, and can combine leaders from separate request scopes using the same instance. +Caching is **off by default**. Outside an `enable()` scope, `cached()` and +`getOrLoad()` just run the loader. **Enable once at the request boundary**, such +as a middleware around read handlers, so call sites need no changes, and wrap +mutation handlers in `disable()` so a write path cannot cache a read it is about +to make stale: ```ts await dialcache.enable(async () => { - // Same cold key, concurrent calls: one fallback execution, one shared result. - const [a, b] = await Promise.all([getUser("456"), getUser("456")]); -}); -``` - -With Redis configured, an instance-scoped leader that misses the process-local cache runs one bounded Redis read and, on a normal miss, the fallback/cache write; followers share its remaining read budget and await the same result. On a shadow-selected served Redis hit, only that leader can schedule detached validation, so followers do not multiply source reads. Process-local-only misses share the leader's fallback/cache write. This protects Redis and the source of truth from a thundering herd on hot keys. - -Coalescing only applies when at least one cache layer is active and the use case's resolved `coalesce` policy has not disabled it. Calls outside `enable()` are true pass-through. Calls where request-local, process-local, and Redis serving are all disabled are uncached and uncoalesced, even if shadowing independently schedules detached Redis work; same-key shadow deduplication drops duplicate jobs but does not combine caller fallbacks. Because these calls were initially enabled, the fallback deadline below still applies. - -Because coalescing is keyed by the selected or direct key, concurrent calls with the same key share the leader's execution. Any function argument or captured value omitted from the key must be safe to share this way; include inputs such as locale, auth context, or cancellation behavior when they can change the returned value or whether the underlying loader should run separately. - -The per-use-case `coalesce` boolean (default true) turns this sharing off. `coalesce: false` in a `defaultConfig` or runtime overlay disables both scopes: same-key concurrent callers each perform their own layer reads with their own full remote-read budget, their own fallback with an independent [fallback deadline](#fallback-deadlines), and their own cache writes — request-local and process-local publication is last-writer-wins, every Redis write is one complete-frame last-writer-wins `SET`, and tracked Redis reads later apply their usual watermark fence. Request-local memoization of settled values still serves later sequential calls in the same scope. Use it when the key intentionally omits per-caller inputs that must not be shared, or when callers must not inherit a leader's failure, `FallbackTimeoutError`, or stale-recovery result. Disabling coalescing reintroduces the thundering-herd exposure described above, emits `request`/`miss`/latency metrics once per caller instead of once per flight, never emits `dialcache_coalesced_counter`, and keeps `getCoalescingState()` idle for that use case. With shadow work enabled, each un-coalesced caller may attempt to schedule detached validation; same-key shadow deduplication and `shadowMaxInFlight` still bound admitted jobs and drop the excess, but source reads are no longer combined. - -### Fallback deadlines - -Once an initially enabled invocation starts its fallback, DialCache applies a 60-second monotonic deadline by default. Set `fallbackTimeoutMs` once on a cached wrapper or on each `getOrLoad()` invocation to choose a positive integer deadline in milliseconds, up to 2,147,483,647, or set it to `null` to preserve an intentionally unbounded fallback: - -```ts -import { FallbackTimeoutError } from "dialcache"; - -const getUser = dialcache.cached( - (userId: string) => db.fetchUser(userId), - { - keyType: "user_id", - useCase: "GetUserWithDeadline", - cacheKey: (userId) => userId, - defaultConfig: DialCacheKeyConfig.enabled(60), - fallbackTimeoutMs: 2_000, - }, -); - -try { - await dialcache.enable(() => getUser("123")); -} catch (error) { - if (error instanceof FallbackTimeoutError) { - logger.warn("source lookup exceeded its DialCache budget", { - useCase: error.useCase, - timeoutMs: error.timeoutMs, - }); - } -} -``` - -The timer starts only when the fallback begins, including after a remote-read deadline has elapsed. When coalescing is enabled (the default), same-key followers share the process or request-local leader's remaining budget and timeout outcome; pass-through invocations where every layer is disabled, and callers whose use case disables `coalesce`, have independent timers. A timeout produces `FallbackTimeoutError`, which the built-in stale-recovery classifier authorizes when the initial Redis read retained a candidate within `M`; otherwise callers receive the error. Cache hits create no fallback timer. Calls that were initially outside an enabled context remain true pass-through and are not timed out, even when the operation configures `fallbackTimeoutMs`. - -Deadline delivery requires the JavaScript event loop to make progress. It cannot preempt a synchronous fallback prefix or other event-loop blocking, so rejection can arrive later than the configured duration; when control returns, DialCache checks the monotonic deadline before accepting the result. The deadline timer remains referenced until the fallback settles or times out. Consequently, an abandoned enabled fallback can keep an otherwise idle short-lived process alive until that deadline; shutdown code should drain outstanding DialCache work rather than discarding its promises. - -Timing out rejects the source attempt with `FallbackTimeoutError`; the DialCache chain either serves an authorized retained candidate or rejects with that exact error, then clears its flight normally. A later fallback resolution is ignored, so that timed-out invocation cannot become the accepted `S` for a shadow fill or proceed to ordinary serializer, Redis, or local-cache publication. The underlying function is not canceled and may continue its own I/O or side effects; give the source operation its own native timeout or `AbortSignal` whenever possible. `fallbackTimeoutMs: null` disables this guard and makes finite fallback settlement entirely application-owned. Use the `null` escape hatch only after intentionally accepting that liveness risk. It does not create an unbounded detached shadow operation: [shadow validation](#shadow-validation) still uses a 60-second whole-job budget. - -Timeout failures retain the bounded metrics classification `error="fallback"` with `in_fallback="true"`; the typed error provides the timeout details without adding high-cardinality labels. - -### Coalescing state + await getUser("123"); // Cached. -`getCoalescingState()` returns a detached, point-in-time snapshot of process-scoped flights owned by that `DialCache` instance: - -```ts -const state = dialcache.getCoalescingState(); - -state.process.activeLeaders; -state.process.activeFollowers; -state.process.oldestLeaderAgeMs; // null when idle -``` - -A leader is one exact cache key currently tracked by the instance-scoped coalescer. A follower is each later invocation that joined that pending leader; the initiating invocation is not counted as a follower. Followers remain counted until their leader settles because abandoning a JavaScript promise is not observable. Request-local flights are deliberately excluded because their lifecycle is bounded by the outer `enable()` scope. Use cases that disable `coalesce` never register process flights and never appear in the snapshot. `oldestLeaderAgeMs` uses a monotonic clock and is computed when the snapshot is requested. - -There is no library-wide flight cap or age-based replacement. A registry cap would bound only DialCache metadata while overflow or eviction could still create unbounded source work and unsafe duplicate publication. Finite operation deadlines provide eventual cleanup; application admission control and backpressure remain responsible for bounding simultaneous distinct-key work. Monitor leader count and oldest age to verify that those budgets hold in production. - -## Metrics - -Metrics are disabled unless a `DialCacheMetricsAdapter` is passed to the constructor. `new DialCache()` does not import a metrics backend, register collectors, or emit metrics. - -### Prometheus - -Install `prom-client` separately, create the registry your application owns, and pass the explicit Prometheus adapter to DialCache: + await dialcache.disable(async () => { + await updateUser("123", patch); // Reads in here go to the source. + }); -```bash -pnpm add prom-client@^15.1.3 + await getUser("123"); // Cached again; disable() evicts nothing. +}); ``` -```ts -import { Registry } from "prom-client"; -import { DialCache } from "dialcache"; -import { createPrometheusDialCacheMetrics } from "dialcache/prometheus"; +## Cache layers -const registry = new Registry(); -const dialcache = new DialCache({ - namespace: "users-api", - metrics: createPrometheusDialCacheMetrics({ - registry, - prefix: "myapp_", // myapp_dialcache_request_counter, etc. - }), -}); +Inside `enable()`, a call checks each active layer in order and stops at the +first hit. A miss at every layer runs the loader: -app.get("/metrics", async (_req, res) => { - res.type(registry.contentType).send(await registry.metrics()); -}); +```text +request-local → process-local → Redis / Valkey → loader ``` -The adapter requires a caller-owned `Registry`; it never uses the global default registry and does not clear or otherwise own the registry lifecycle. Multiple adapters with the same registry and prefix reuse existing collectors when their type, help, labels, histogram buckets, and exemplar mode match. Adapter construction fails before registering anything if a same-name collector has an incompatible schema; use a unique prefix or a separate registry to resolve the collision. In particular, `dialcache_miss_counter` now has five labels rather than four, so an old four-label collector under the same registry and prefix is incompatible. Old and new DialCache versions cannot share that in-process registry/prefix; the old-schema collision fails before any partial registration. - -The Prometheus adapter emits: - -| Metric | Type | Labels | Description | +| Layer | Shares values across | Lifetime | Typical use | | --- | --- | --- | --- | -| `dialcache_request_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache-layer requests that reached an enabled layer | -| `dialcache_miss_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer`, `reason` | Cache misses, classified by one required bounded reason | -| `dialcache_disabled_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer`, `reason` | Cache skips (`context`, `policy_disabled`, `invalid_ttl`, `invalid_ramp`, `ramped_down`, `config_error`) | -| `dialcache_error_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer`, `error`, `in_fallback` | Cache/fallback errors and the bounded `tracked_ttl_clamped` configuration signal | -| `dialcache_invalidation_counter` | Counter | `cache_namespace`, `key_type`, `layer` | Invalidation calls for the layers touched | -| `dialcache_coalesced_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `scope` | Coalesced requests split by `request_local` or `process` scope | -| `dialcache_shadow_validation_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `outcome` | Sampled Redis shadow-job outcomes | -| `dialcache_shadow_value_age_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `outcome` | Age in seconds of the validated Redis value at shadow verdict time, recorded for `match` and `mismatch` | -| `dialcache_future_timestamp_offset_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Positive offset in seconds for a valid frame dated after the observing process clock | -| `dialcache_stale_recovery_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `outcome` | Classifier-authorized stale-recovery checks: `served`, `miss`, or `deserialization_error` | -| `dialcache_stale_recovery_value_age_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `outcome` | Actual return-time age in seconds of a retained value, recorded only for `served` | -| `dialcache_compression_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer`, `outcome` | Payload compression outcomes: writes record `compressed`, `below_threshold`, `not_smaller`, or `write_over_limit`; reads record `decompressed`, `fallback_raw`, or `read_over_limit` | -| `dialcache_get_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache get latency in seconds | -| `dialcache_fallback_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Elapsed time until the underlying function settles or timeout rejection is delivered | -| `dialcache_serialization_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Redis serializer dump/load latency | -| `dialcache_size_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Serialized Redis payload size in bytes, before compression | -| `dialcache_stored_size_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Stored Redis payload size in bytes, after compression and escaping | -| `dialcache_compression_ratio_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Compressed-to-original payload size ratio for compressed writes | -| `dialcache_compression_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Payload compression and decompression latency in seconds | +| Request-local | Calls in one outer `enable()` scope | Until that scope settles | Avoid repeated reads within a request | +| Process-local | Requests using one `DialCache` instance | TTL, bounded by LRU capacity | Avoid repeated reads between requests | +| Remote | Application instances using the same Redis keyspace | TTL, with optional invalidation tracking | Reuse reads across processes | -`dialcache_miss_counter` remains one miss counter, not a parallel reason or compatibility counter. Its required `reason` is exactly one of: +Layers combine: a Redis hit can warm an active process-local cache, and an +active request-local layer memoizes what the layers below return. The +[read-path guide](https://lan17.github.io/DialCache/concepts.html) lists what is +stored after each kind of hit or miss. -| `reason` | Meaning | -| --- | --- | -| `value_absent` | The layer had no retrievable value, including never-populated, physically expired, evicted, Redis `nil`, and tracked-`MGET` wrong-type-as-null states. Request-local and local misses always report this reason. | -| `expired` | A complete supported frame with a valid, non-future timestamp was present, but its logical age against the reader's clock reached the effective remote TTL `F`. This includes `F <= age < M` frames retained as stale-on-error recovery candidates and frames at or beyond `M`; with stale-on-error configured it is the routine steady-state expiry reason. | -| `watermark_fenced` | A complete supported tracked frame with a positive safe-integer timestamp was rejected because it was at or before a valid observed invalidation watermark; this happens before deserialization and does not assert that the payload would deserialize. | -| `unclassified` | The miss was real but cannot be attributed to any prior category, including unrecognized custom-adapter results, short or unsupported frames, malformed watermark metadata paired with a present frame, invalid or future timestamps, and caller-side deserialization failures. | - -This is an intentional Prometheus schema migration. During a mixed-fleet rollout, old scraped miss series lack `reason` while new ones carry it. For existing total-miss queries and miss/request ratios, aggregate away `reason` and scrape labels on both sides—for example, `sum by (cache_namespace, use_case, key_type, layer) (rate(dialcache_miss_counter[5m])) / sum by (cache_namespace, use_case, key_type, layer) (rate(dialcache_request_counter[5m]))`. Reason-aware dashboards should group explicitly by `reason`. - -The future-timestamp histogram uses dedicated buckets from millisecond-scale skew through multi-hour clock faults. It records one positive offset after a valid frame is decoded. Caller-serving and initial shadow reads then reject that frame; confirmation reads retain it only for payload-equality classification. Invalid or non-finite timestamp values miss without entering histogram sums. The same future frame can be observed repeatedly. Alert against the deployment's allocated skew budget, not every millisecond-level sample. +When a cache layer is active, concurrent calls for the same key share work by +default. Request-local caching shares work within the outer `enable()` scope; +process-local and remote caching share it within one `DialCache` instance. +Set `coalesce: false` to opt out. The +[coalescing guide](https://lan17.github.io/DialCache/coalescing.html) covers the +results, errors, and deadlines a waiting caller inherits. -`policy_disabled` means that a process-local or Redis layer has no effective TTL after runtime overlays are applied. It is an intentional policy outcome, including the default when `defaultConfig` is omitted, rather than a configuration-loading failure. +## Changing policy at runtime -Every metric carries `cache_namespace`, including disabled-context, key-construction, coalescing, shadow-validation, stale-recovery, and invalidation paths that do not have a constructed key. Its value is `DialCacheConfig.namespace`, defaulting to `urn`. The `layer` label is `request_local`, `local` (process-local), `remote` (caller-serving Redis), or `remote_shadow` (detached, non-serving Redis work); `noop` means no cache layer was reached. Detached reads, serializer work, payload sizes, and Redis read/write errors use `remote_shadow`, while the dedicated bounded shadow `outcome` records the terminal job result. Stale recovery reuses caller-serving Redis work from the initial read, so its dedicated counter and value-age histogram need no `layer` label. The bounded `scope` label on `dialcache_coalesced_counter` distinguishes request-local from instance-scoped single-flight work. `scope="process"` coordinates calls only within one `DialCache` instance; separate instances in the same process do not share in-flight state. - -### Datadog - -Install `hot-shots` separately, create the DogStatsD client your application owns, and pass it to the Datadog adapter: - -```bash -pnpm add hot-shots@^17.0.0 -``` +Each use case's `defaultConfig` is its baseline; a `cacheConfigProvider` on the +instance overrides individual fields on every enabled call. This example starts +with local caching ramped to zero, then opens it to a 10% cohort of keys: ```ts -import StatsD from "hot-shots"; -import { DialCache } from "dialcache"; -import { createDatadogDialCacheMetrics } from "dialcache/datadog"; - -const dogStatsD = new StatsD({ - host: process.env.DD_AGENT_HOST, - globalTags: { service: "users-api", env: process.env.DD_ENV ?? "development" }, - errorHandler: (error) => logger.warn("DogStatsD error", { error }), +// The application's configuration system feeds this map. +const policies = new Map(); +const cache = new DialCache({ + cacheConfigProvider: (key) => policies.get(key.useCase) ?? null, }); -const dialcache = new DialCache({ - namespace: "users-api", // cache identity and cache_namespace tag - metrics: createDatadogDialCacheMetrics({ - client: dogStatsD, - observationMetricType: "distribution", - namespace: "dialcache", // metric-name prefix: dialcache.request.count, etc. +const readUser = cache.cached(fetchUser, { + keyType: "user_id", + useCase: "ReadUser", + cacheKey: (userId) => userId, + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 60 }, + ramp: { [CacheLayer.LOCAL]: 0 }, }), }); -// After outstanding cache operations finish during application shutdown: -dogStatsD.close(); -``` - -`hot-shots` is the supported and tested client, but the adapter depends only on the exported `DatadogDogStatsDClient` structural interface. DialCache does not import or install `hot-shots`, create a client, flush buffers, close sockets, or otherwise own the client lifecycle. - -`observationMetricType` is required. `"distribution"` is recommended when latency and size percentiles must aggregate across hosts; enable the desired distribution percentiles and aggregations in Datadog. Choose `"histogram"` when host-level histogram aggregation matches your existing Datadog setup. The choice applies uniformly to every observation metric, including durations, sizes, ratios, ages, and timestamp offsets. Both modes produce Datadog custom metrics. Distribution volume scales with unique tag-value combinations: Datadog counts five baseline aggregations per combination, and enabling percentile aggregations adds five more. Review [Datadog's custom-metrics billing guidance](https://docs.datadoghq.com/account_management/billing/custom_metrics/) before rollout. Do not send both types under the same namespace: when changing types, use a new namespace during migration so one metric identity never mixes histogram and distribution points. - -`DatadogMetricsOptions.namespace` is the metric-name namespace and defaults to `dialcache`. It is separate from `DialCacheConfig.namespace`, the logical cache namespace emitted as the `cache_namespace` tag. The Datadog metric namespace must start with a letter and contain only letters, numbers, underscores, and dot-separated non-empty segments. The adapter rejects invalid metric namespaces and final metric names longer than 200 characters rather than relying on client-side normalization. A `hot-shots` `prefix` is applied after the adapter constructs the name, so include that prefix when checking the final length and avoid combining it with the metric namespace accidentally. Client-level `globalTags` are appended by `hot-shots`; the table below lists the tags added by the adapter. - -The Datadog adapter emits exact increments of `1` for counters and preserves seconds and bytes without unit conversion: - -| Metric | Type | Tags | Description | -| --- | --- | --- | --- | -| `dialcache.request.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache-layer requests that reached an enabled layer | -| `dialcache.miss.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer`, `reason` | Cache misses, classified by one required bounded reason | -| `dialcache.disabled.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer`, `reason` | Cache skips by bounded reason | -| `dialcache.error.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer`, `error`, `in_fallback` | Cache/fallback errors and the bounded `tracked_ttl_clamped` configuration signal | -| `dialcache.invalidation.count` | Count | `cache_namespace`, `key_type`, `layer` | Invalidation calls for the layers touched | -| `dialcache.coalesced.count` | Count | `cache_namespace`, `use_case`, `key_type`, `scope` | Coalesced requests by sharing scope | -| `dialcache.shadow.count` | Count | `cache_namespace`, `use_case`, `key_type`, `outcome` | Sampled Redis shadow-job outcomes | -| `dialcache.shadow.value_age` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `outcome` | Age in seconds of the validated Redis value at shadow verdict time, recorded for `match` and `mismatch` | -| `dialcache.future_timestamp_offset` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Positive offset in seconds for a valid frame dated after the observing process clock | -| `dialcache.stale_recovery.count` | Count | `cache_namespace`, `use_case`, `key_type`, `outcome` | Classifier-authorized stale-recovery checks: `served`, `miss`, or `deserialization_error` | -| `dialcache.stale_recovery.value_age` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `outcome` | Actual return-time age in seconds of a retained value, recorded only for `served` | -| `dialcache.compression.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer`, `outcome` | Payload compression outcomes: writes record `compressed`, `below_threshold`, `not_smaller`, or `write_over_limit`; reads record `decompressed`, `fallback_raw`, or `read_over_limit` | -| `dialcache.get.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache get latency in seconds | -| `dialcache.fallback.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Elapsed time until the underlying function settles or timeout rejection is delivered | -| `dialcache.serialization.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Redis serializer dump/load latency in seconds | -| `dialcache.serialization.size` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Serialized Redis payload size in bytes, before compression | -| `dialcache.stored.size` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Stored Redis payload size in bytes, after compression and escaping | -| `dialcache.compression.ratio` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Compressed-to-original payload size ratio for compressed writes | -| `dialcache.compression.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Payload compression and decompression latency in seconds | - -`dialcache.miss.count` remains one metric and adds the same required bounded `reason` tag: `value_absent`, `expired`, `watermark_fenced`, or `unclassified`. During a mixed-fleet rollout, older points have no `reason` tag and newer points do; keep total-miss dashboards ungrouped by `reason`, and group by it only for the reason breakdown. The four bounded values increase steady-state custom-metric combinations by at most 4x per pre-existing miss-tag tuple (only `remote` and `remote_shadow` tuples can carry every reason; request-local and local misses are always `value_absent`), so account for that added cardinality and the temporary mixed tag sets in Datadog billing and monitor design. - -Observer throws and rejections from returned promises or thenables are isolated by DialCache's fail-open metrics boundary. Buffered transport failures that are not represented by a returned thenable happen outside that boundary, so configure the DogStatsD client's error handling and shutdown behavior as part of application ownership. - -### Error categories +// Use a 10% ramp and keep the baseline TTL. +policies.set("ReadUser", new DialCacheKeyConfig({ + ramp: { [CacheLayer.LOCAL]: 10 }, +})); -The `error` label reports where an operation failed rather than copying the thrown value's class or `Error.name`: +await cache.enable(() => readUser("123")); -| `error` | Meaning | -| --- | --- | -| `key_construction` | The cache-key selector or `DialCacheKey` construction failed | -| `config_resolution` | Runtime or layer configuration, or ramp resolution, failed | -| `cache_read` | A local-cache or Redis read failed | -| `cache_read_timeout` | A Redis read exceeded its effective remote-read deadline | -| `cache_write` | A local-cache or Redis write failed | -| `serialization_load` | Deserializing a Redis payload failed | -| `serialization_dump` | Serializing a value for Redis failed | -| `compression` | zstd compression failed while preparing a Redis write | -| `invalidation` | Writing an invalidation watermark failed | -| `fallback` | The wrapped application function failed or exceeded its DialCache deadline | -| `unknown` | Reserved for an otherwise unclassified future failure site | - -These values are defined by the backend-neutral core and are identical for every metrics adapter. Raw thrown values, error names, messages, timeout values, cache IDs, arguments, and Redis keys are never included in metric labels. Operational errors are still passed to the configured logger where the existing failure path logs them. `in_fallback` remains the explicit cache-plumbing-versus-application distinction. - -### Custom adapters - -For other telemetry backends, implement `DialCacheMetricsAdapter` and pass the adapter through `new DialCache({ metrics })`. Every backend-neutral label object exposes the logical namespace as camel-case `cacheNamespace`; adapters should map it to their backend's `cache_namespace` label/tag. This field is present even when no key or cache layer was reached. `miss()` now receives `MissMetricLabels`, which extends unchanged `CacheMetricLabels` with the required bounded `reason`; every other callback keeps its existing label shape. Custom adapters whose `miss` parameter is typed as the broader `CacheMetricLabels` can ignore the new property, but direct callers, exact label snapshots, exhaustive reason handling, and adapters that reject or forward unknown fields must migrate to accept or map it. Implement the optional `shadowValidation` method to enable shadow work as well as record its outcomes; omitting it leaves all shadow work disabled even when `shadow.ramp` is nonzero or mismatch logging is enabled. The optional `observeShadowValueAge` method records the validated value's age in seconds for `match` and `mismatch` outcomes; omitting it skips only that observation without affecting shadow eligibility. The optional `observeFutureTimestampOffset` method receives existing bounded cache labels plus the exact positive offset in seconds; omitting it does not change read decisions: serving and initial-shadow reads still miss, while confirmation reads still retain the frame for payload comparison. The optional `staleRecovery` method records one bounded terminal outcome for each classifier-authorized recovery check; omitting it disables only that observation, not recovery itself. The optional `observeStaleRecoveryValueAge` method records the actual return-time age in seconds only when recovery serves; omitting it skips only that observation. Every metrics callback is fire-and-forget: DialCache isolates synchronous throws and consumes rejections from returned promises or thenables, but never awaits or drains observer work. Miss classification adds no new refill-fencing paths and preserves serving eligibility, invalidation, stale-recovery policy, and shadow outcomes. Omit `metrics` to disable metrics. - -## Maintainers - -### Cache-path benchmark - -From a repository checkout, run the semantic microbenchmark after installing dependencies: - -```bash -pnpm benchmark:request-local -``` - -The command builds `dist` before reporting ten scenarios: sequential request-local hits, sequential process-local hits, enabled bounded fallbacks, request-local coalescing fan-out, process coalescing fan-out, remote-read-deadline coalescing fan-out, tracked Redis hits with shadow omitted, tracked Redis hits deterministically outside a partial shadow ramp, a ramped-down warm-hit confirmation, and a ramped-down semantic-miss fill. Both shadow scenarios prove that the caller completes before detached Redis work. The benchmark is a maintainer tool and is not included in the published package. It asserts fallback counts, Redis behavior, coalescing state, timer cleanup, returned values, exactly-once SoT reuse, and conditional confirmation/fill without applying a timing threshold. Override its work sizes with `DIALCACHE_BENCH_ITERATIONS` and `DIALCACHE_BENCH_FANOUT`. - -### Redis write benchmark - -With an otherwise idle Redis reachable at `REDIS_URL` (default `redis://127.0.0.1:6379`, e.g. `docker run --rm -p 6379:6379 redis:6.2`), measure the local build's write path. The benchmark resets global command statistics between cases, so use a disposable or dedicated instance: - -```bash -pnpm benchmark:redis-write +// Stop cache use and new shadow work for this use case. +policies.set("ReadUser", DialCacheKeyConfig.disabled()); ``` -The command builds `dist`, then runs sequential native writes at 100 B, 10 KiB, 100 KiB, and 1 MiB payloads. It reports `SET`, script, and `TIME` calls per operation, server-side `SET` cost from `INFO commandstats`, and client-side p50/p95 latency. Semantic assertions require exactly one `SET`, zero scripts, and zero `TIME` calls per write. Because operations are sequential, the benchmark validates command shape and single-operation latency; it does not measure saturated concurrent throughput or maximum write capacity. Like the cache-path benchmark it is a maintainer tool, is not part of the published package, and applies no timing threshold — absolute numbers depend on the machine, engine, and load, so compare runs only within one environment. Scale iteration counts with `DIALCACHE_BENCH_WRITE_SCALE`. +A ramp selects a stable set of keys, not a share of traffic: raising it adds +keys to the cohort, and lowering it removes keys without reshuffling the rest. +Policy changes apply to new calls only. They do not evict cached values, and +[a shorter TTL affects local and Redis entries differently](https://lan17.github.io/DialCache/configuration.html#changing-policy-on-a-running-service). -### Stale-on-error benchmark +Shadow validation uses sampling to check cache coherence: it compares Redis +values with the source in the background. It can also fill misses while remote +serving is ramped down. Callers do not wait for these checks or fills. Serving +and shadow ramps are independent; `disabled()` stops both for new calls without +cancelling work already admitted. -With Redis reachable at `REDIS_URL`, exercise the native-read design and a representative compressible payload: +[Runtime configuration](https://lan17.github.io/DialCache/configuration.html) +· [Shadow validation](https://lan17.github.io/DialCache/shadow-validation.html) -```bash -pnpm benchmark:stale-on-error -``` +## Reference -The benchmark warms isolated keys, verifies that physical retention uses `M`, and reports fresh end-to-end hits, native reads of a retained frame, end-to-end stale recovery, and same-key coalesced recovery. It asserts exactly one adapter read per stale-recovery flight. A separate high-cardinality scenario holds delayed source calls open with distinct incompressible raw payloads, then reports process memory before retention, while every candidate is retained, and after recovery. Run the built script with `node --expose-gc scripts/benchmark-stale-on-error.mjs` for less noisy memory snapshots. It snapshots `INFO commandstats` and network byte counters around each scenario without resetting shared server statistics, and reports command, server-CPU, network, and client-throughput signals per operation. Semantic assertions cover compression, exact source/recovery/read counts, and returned values; timing and memory remain informational with no pass/fail threshold. Override work sizes with `DIALCACHE_BENCH_STALE_ITERATIONS`, `DIALCACHE_BENCH_STALE_FANOUT`, `DIALCACHE_BENCH_STALE_PAYLOAD_BYTES`, `DIALCACHE_BENCH_STALE_MEMORY_KEYS`, `DIALCACHE_BENCH_STALE_MEMORY_PAYLOAD_BYTES`, and `DIALCACHE_BENCH_STALE_MEMORY_SOURCE_DELAY_MS`. +The [reference](https://lan17.github.io/DialCache/) covers setup, behavior, APIs, +and operational details. It can also be +[read as Markdown on GitHub](https://github.com/lan17/DialCache/tree/main/docs). -### Releasing - -Publishing starts by manually running the `Release` workflow from current `main`. After the package checks pass, Semantic Release selects the next version from Conventional Commits since the highest stable `vX.Y.Z` tag. While the package is pre-1.0, breaking changes bump minor — their `BREAKING CHANGE:` footers still drive full release notes without forcing 1.0.0 — `feat` bumps minor, and every other normal PR-title type (`fix`, `perf`, `docs`, `style`, `refactor`, `test`, `build`, `chore`, `ci`, and `revert`) bumps patch. The highest required bump wins. Major bumps return when 1.0.0 is cut; `release.config.mjs` implements this table and must change together with this section. - -The workflow opens a `release: ` PR whose only change is the matching `package.json` version. `release` is a reserved Conventional Commit type configured not to request another release, so the version-control commit does not cause an extra bump. GitHub marks workflow runs for a PR opened with `GITHUB_TOKEN` as approval-required; approve those runs, review the PR, and squash-merge it normally through the protected branch. - -The merge triggers the publish job. Before any release side effect, it verifies current `main`, the release commit subject, the one-file diff, the package version, the absent tag, and Semantic Release's independently calculated version and commit. It then reruns the package checks and asks Semantic Release to create the matching Git tag, publish the public npm package with provenance, and publish the GitHub release. - -The repository must enable **Allow GitHub Actions to create and approve pull requests** under Actions workflow permissions. This workflow uses that capability only to create the version PR; it never approves or merges one, and no ruleset bypass actor or persistent release credential is required. +| Task | Guide | +| --- | --- | +| Add caching to a service | [Getting started](https://lan17.github.io/DialCache/getting-started.html) | +| Understand what runs on a hit, miss, or error | [How DialCache works](https://lan17.github.io/DialCache/concepts.html) | +| Look up methods, options, and exports | [API reference](https://lan17.github.io/DialCache/api.html) | +| Set keys, layers, TTLs, and rollout policy | [Configuration](https://lan17.github.io/DialCache/configuration.html) | +| Connect Redis or Valkey; customize serialization | [Redis and Valkey](https://lan17.github.io/DialCache/redis.html) | +| Invalidate cached results when an entity changes | [Targeted invalidation](https://lan17.github.io/DialCache/invalidation.html) | +| Serve a retained value when the source fails | [Stale-on-error](https://lan17.github.io/DialCache/stale-on-error.html) | +| Validate cache coherence through sampling | [Shadow validation](https://lan17.github.io/DialCache/shadow-validation.html) | +| Understand shared work and deadlines | [Coalescing and liveness](https://lan17.github.io/DialCache/coalescing.html) | +| Build dashboards and diagnose misses | [Observability](https://lan17.github.io/DialCache/observability.html) | +| Upgrade, validate, or contribute | [Upgrading](https://lan17.github.io/DialCache/upgrading.html) · [Maintainer guide](https://lan17.github.io/DialCache/maintainers.html) | + +MIT licensed. See [LICENSE](https://github.com/lan17/DialCache/blob/main/LICENSE). diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts new file mode 100644 index 0000000..5613336 --- /dev/null +++ b/docs/.vitepress/config.mts @@ -0,0 +1,62 @@ +import { defineConfig } from "vitepress"; + +// Preserve Vite 6's targets except Safari 14, whose destructuring target is +// unsupported by the repository's patched esbuild. Apply to build and dev. +const browserTargets = ["es2020", "chrome87", "edge88", "firefox78", "safari14.1"]; + +export default defineConfig({ + title: "DialCache", + description: + "DialCache organizes caching into use cases, with runtime control and observability for each one. Reference for the read path, runtime policies, and the API.", + lang: "en-US", + base: "/DialCache/", + vite: { + build: { target: browserTargets }, + optimizeDeps: { esbuildOptions: { target: browserTargets } }, + }, + themeConfig: { + nav: [ + { text: "Documentation", link: "/" }, + { text: "API reference", link: "/api" }, + { text: "npm", link: "https://www.npmjs.com/package/dialcache" }, + ], + sidebar: [ + { + text: "Start here", + items: [ + { text: "Overview", link: "/" }, + { text: "Getting started", link: "/getting-started" }, + { text: "How DialCache works", link: "/concepts" }, + ], + }, + { + text: "Features and behavior", + items: [ + { text: "Configuration", link: "/configuration" }, + { text: "Redis and Valkey", link: "/redis" }, + { text: "Targeted invalidation", link: "/invalidation" }, + { text: "Stale-on-error", link: "/stale-on-error" }, + { text: "Shadow validation", link: "/shadow-validation" }, + { text: "Coalescing and liveness", link: "/coalescing" }, + { text: "Observability", link: "/observability" }, + ], + }, + { + text: "Reference and operations", + items: [ + { text: "API reference", link: "/api" }, + { text: "Upgrading", link: "/upgrading" }, + { text: "Maintainer guide", link: "/maintainers" }, + ], + }, + ], + outline: [2, 3], + search: { provider: "local" }, + socialLinks: [ + { icon: "github", link: "https://github.com/lan17/DialCache" }, + ], + editLink: { + pattern: "https://github.com/lan17/DialCache/edit/main/docs/:path", + }, + }, +}); diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..0df6d5b --- /dev/null +++ b/docs/api.md @@ -0,0 +1,248 @@ +# API reference + +[Documentation](index.md) + +Start with the `DialCache` instance, define a cache operation, then choose its +policy. The feature guides explain the behavior behind these tables; the +package's declarations provide the full generic signatures. + +## Imports + +| Entry point | Public surface | Guide | +| --- | --- | --- | +| `dialcache` | `DialCache`, configuration, keys, serializers, core errors, semantic Redis types, and metric types | This page | +| `dialcache/node-redis` | `createNodeRedisDialCacheClient` | [Redis](redis.md#node-redis) | +| `dialcache/valkey-glide` | `createValkeyGlideDialCacheClient`, `ValkeyGlideRuntime`, `ValkeyGlideScriptingClient` | [Valkey GLIDE](redis.md#valkey-glide) | +| `dialcache/prometheus` | `createPrometheusDialCacheMetrics`, `PrometheusDialCacheMetrics`, `PrometheusMetricsOptions` | [Prometheus](observability.md#prometheus) | +| `dialcache/datadog` | `createDatadogDialCacheMetrics`, `DatadogDialCacheMetrics`, `DatadogMetricsOptions`, `DatadogDogStatsDClient`, `DatadogObservationMetricType` | [Datadog](observability.md#datadog) | +| `dialcache/redis-protocol` | Frame codecs, semantic miss types and guard, invalidation Lua, and reply/TTL validators | [Wire protocol](redis.md#advanced-wire-protocol) | + +Optional integrations use their own import paths. The application installs and +owns the corresponding client or metrics registry. + +## Constructor + +`new DialCache(options?)` constructs one instance for each intended local-cache +and coalescing boundary. +With no options, it supports in-memory caching and uses a disabled baseline. +Enable a scope **and** configure at least one layer to store values. + +| Option | Default | Contract | +| --- | --- | --- | +| `namespace` | `"urn"` | Cache identity and metric namespace label; no `{` or `}` | +| `localMaxSize` | `10_000` | Nonnegative safe-integer LRU entry cap across all use cases; `0` disables storage | +| `redis` | Absent | `RedisConfig`: connected semantic client and optional read timeout, serializer, and compression policy | +| `cacheConfigProvider` | No overrides | `(key: DialCacheKey) => DialCacheKeyConfig \| null`, synchronously or via a Promise | +| `shouldAttemptStaleRecovery` | Accepts only `FallbackTimeoutError` | Synchronous `(error: unknown) => boolean`; an operation override replaces it | +| `shadowMaxInFlight` | `1` | Positive safe-integer cap on admitted shadow jobs; no queue | +| `metrics` | Absent | `DialCacheMetricsAdapter` | +| `logger` | `console` | `Logger`, the `debug`, `warn`, and `error` methods | + +See [Configuration](configuration.md) for validation and lifetime rules. + +### `RedisConfig` + +Pass this object as the constructor's `redis` option: + +| Field | Default | Contract | +| --- | --- | --- | +| `client` | Required | Connected `DialCacheRedisClient`; the application owns connection and shutdown | +| `readTimeoutMs` | `50` | Positive safe integer up to `2_147_483_647` ms; a use case's `remoteReadTimeoutMs` takes precedence | +| `serializer` | `JsonSerializer` | Instance-level Redis serializer; an operation's serializer takes precedence | +| `compression` | `{ thresholdBytes: 4_096, level: 3 }` | `CompressionConfig` or `false`; threshold is a positive safe integer, level is an integer from `1` through `22` | + +Providing a client makes the remote layer available; each operation still needs +a remote TTL and an enabled scope. See [client setup](redis.md), +[serialization](redis.md#serialization), and [compression](redis.md#compression). + +## Scope methods + +| Method | Return | Behavior | +| --- | --- | --- | +| `enable(fn)` | `Promise` | Enables caching while the callback and its awaited work run | +| `disable(fn)` | `Promise` | Runs a nested region uncached; does not evict values | +| `withEnabled(fn)` | `Promise` | Alias for `enable` | +| `withDisabled(fn)` | `Promise` | Alias for `disable` | +| `isEnabled()` | `boolean` | Whether the current asynchronous chain has a live enabled scope | + +Callbacks may return synchronously or asynchronously. Scope state is per +instance and asynchronous call chain. Nested scopes restore prior state. The +outermost `enable()` owns request-local state. New invocations become pass-through +after it closes; already admitted work can finish. An invocation still awaiting +its configuration provider bypasses caching after closure but retains its enabled +fallback deadline. See [Scope lifetime](configuration.md#enable-and-disable-scopes). + +`DialCacheContext` is the lower-level root export with `enable`, `disable`, and +`isEnabled`. A separately constructed context does not enable another +`DialCache` instance or attach a cache to it. + +## `cached` + +`cached(fn, options)` returns a `CachedFn`: the same parameter types with a +`Promise` of the resolved return value. Register each `useCase` once per instance. +Wrap a bound method or closure when the loader needs a receiver. + +```ts +const getUser = dialcache.cached(fetchUser, { + keyType: "user_id", + useCase: "GetUser", + cacheKey: (userId) => userId, + defaultConfig: new DialCacheKeyConfig({ requestLocal: true }), +}); +``` + +## `getOrLoad` + +`getOrLoad(load, options)` returns `Promise`. Its zero-argument loader may +be synchronous or asynchronous. Supply a direct `key` instead of `cacheKey`. +The method does not register a use case, so stable names can be reused at a call +site. All calls sharing an identity must agree on value meaning and serializer. + +```ts +const value = await dialcache.getOrLoad(() => fetchUser(userId), { + keyType: "user_id", + useCase: "InlineGetUser", + key: userId, + defaultConfig: new DialCacheKeyConfig({ requestLocal: true }), +}); +``` + +This snippet assumes an enclosing enabled scope; without one the loader runs +uncached. + +### Operation options + +`CachedOptions` and `GetOrLoadOptions` share these fields: + +| Option | Default | Contract | +| --- | --- | --- | +| `keyType` | Required | Entity kind; combines with id and namespace for tracked invalidation | +| `useCase` | Required | Stable operation name, cache-key component, and metric label; `"watermark"` is reserved | +| `cacheKey` / `key` | Required | Selector for `cached`, direct value for `getOrLoad`; returns/accepts `CacheKeySpec` | +| `defaultConfig` | Absent | Baseline `DialCacheKeyConfig`, snapshotted at registration or inline invocation | +| `serializer` | Effective JSON default | Typed `Serializer` required when the [JSON type guard](redis.md#typed-serializer-requirement) cannot establish compatibility, even for local-only declarations | +| `trackForInvalidation` | `false` | Use watermark-aware Redis reads for this operation | +| `fallbackTimeoutMs` | `60_000` | Positive safe integer up to `2_147_483_647` ms; `null` disables the source deadline | +| `shadowComparator` | Node strict deep equality | Synchronous, bounded `(cached, source) => boolean`; must not mutate its inputs | +| `shouldAttemptStaleRecovery` | Instance policy | Synchronous error classifier; replaces the lower-precedence policy | + +`CacheKeySpec` is a string, number, or bigint id, or `{ id, args? }`. Argument +values are string, number, bigint, boolean, `null`, or `undefined`; undefined +arguments are omitted. See [Key design](configuration.md#keys-ids-and-extra-dimensions). + +Static defaults, fallback timeout, and stale-recovery classifier are validated +and captured when registering `cached()` or invoking `getOrLoad()`. +The comparator is captured then; its execution and synchronous boolean result +are checked only when shadow comparison runs, with failures reported as +`comparison_error`. +Runtime policy is resolved per enabled invocation. These guarantees do not make +the entire caller-owned options object deeply immutable; keep definitions stable. + +## `DialCacheKeyConfig` + +`new DialCacheKeyConfig({...})` describes the baseline or a sparse runtime +overlay. Omission inherits; it does not turn an inherited field off. + +| Field | Effective default | Values | +| --- | --- | --- | +| `ttlSec.local`, `ttlSec.remote` | No TTL: layer off | Positive safe-integer seconds, at most `31_536_000` (365 days) | +| `ramp.local`, `ramp.remote` | `100` when a TTL exists | Key-selection threshold from `0` through `100`, not a share of traffic; `0` bypasses serving | +| `requestLocal` | `false` | Boolean; no TTL or ramp | +| `coalesce` | `true` | Boolean; affects request-local and process flights | +| `remoteReadTimeoutMs` | Instance setting, then `50` | Positive safe-integer milliseconds, at most `2_147_483_647`; cannot be unbounded | +| `staleOnErrorMaxAgeSec` | Off | Nonnegative safe-integer seconds; `0` disables; positive age must exceed remote TTL and be at most `31_536_000` | +| `shadow.ramp` | Off | Independent finite percentage from `0` through `100` | +| `shadow.logMismatches` | `false` | Boolean; controls diagnostic warning output | + +Use `CacheLayer.LOCAL` (`"local"`) and `CacheLayer.REMOTE` (`"remote"`) as map +keys. `LayerConfig` is a partial map; `ShadowConfig` describes the shadow group. +Tracked Redis physical retention has a separate one-hour cap. + +| Helper | Result | +| --- | --- | +| `DialCacheKeyConfig.enabled(ttlSec)` | Sets local and remote TTLs to the supplied value and both ramps to `100`; leaves request-local, shadow, and recovery unselected | +| `DialCacheKeyConfig.disabled()` | Disables request-local and recovery, sets both serving ramps and shadow ramp to `0`, and disables mismatch logging | + +The enabled helper does not create a Redis connection. The disabled helper is +an invocation policy, not cancellation or eviction. See +[overlay precedence](configuration.md#baseline-and-overlay-precedence). + +## `invalidateRemote` + +`invalidateRemote(keyType, id, futureBufferMs = 0): Promise` advances the +entity's Redis watermark. Call it after the source mutation commits. The id is +stringified and the buffer is a nonnegative safe integer, at most +`31_536_000_000` milliseconds. + +It affects tracked Redis entries across use cases and argument variants in the +same namespace. It does not evict in-memory or untracked Redis values, revoke +acquired snapshots, or clear in-flight work. See +[Independent fence checks](invalidation.md#independent-fence-checks) when each +invocation must observe invalidation separately. Missing +Redis configuration and mutation failures reject; the method works outside an +enabled scope. Choose the buffer from the +[clock and in-flight-work contract](invalidation.md#choosing-futurebufferms). + +## `getCoalescingState` + +`getCoalescingState(): CoalescingState` returns a point-in-time process-flight +snapshot for this instance: + +```ts +const { process } = dialcache.getCoalescingState(); +process.activeLeaders; // Number of distinct in-flight keys. +process.activeFollowers; // Callers waiting on those leaders. +process.oldestLeaderAgeMs; // Monotonic age, or null when idle. +``` + +The nested shape is `ProcessCoalescingState`. Request-local flights are excluded. +There is no method to clear a cache, cancel in-flight loads, cap coalesced +flights, or shut an instance down. +See [Coalescing state](coalescing.md#inspecting-process-scoped-flights). + +## Keys and serializers + +| Export | Purpose | +| --- | --- | +| `DialCacheKey`, `DialCacheKeyInit` | Construct an identity from string components and ordered string argument pairs; `toString()` returns its precomputed `urn` | +| `normalizeArgs(record)` | Drop undefined arguments, stringify scalar values, and sort names | +| `invalidationPrefix(namespace, keyType, id)` | Build an encoded tracked-entity prefix without braces | +| `redisClusterHashTag(value)` | Reject embedded braces and wrap the value in braces without encoding | +| `Serializer` | `dump(value)` returns `string \| Buffer`; `load(payload)` returns `T`; either may return a Promise | +| `JsonSerializer` | Default JSON codec, including top-level undefined support; both methods return Promises | + +`CachedValue` exposes a function's resolved result type. `ShadowComparator` +and `StaleRecoveryPredicate` name the corresponding synchronous callbacks. +See [Direct key construction](configuration.md#constructing-keys-directly) for +defaults, encoding, and validation, and [Serialization](redis.md#serialization) +for direct codec behavior, the compile-time guard, and round-trip limitations. + +## Errors + +| Root export | When it matters | +| --- | --- | +| `DialCacheError` | Base class of the four core errors below | +| `UseCaseIsAlreadyRegisteredError` | Duplicate `cached()` registration on an instance | +| `UseCaseNameIsReservedError` | Either operation API uses `"watermark"` | +| `FallbackTimeoutError` | Enabled source deadline; exposes `useCase` and `timeoutMs` | +| `RedisReadTimeoutError` | Remote wait deadline; exposes `useCase` and `timeoutMs`; serving reads log/count it before fallback, while shadow reads report a job outcome | +| `DialCacheRedisPayloadError` | Invalid raw Redis reply shape | +| `DialCacheRedisPayloadEncodingError` | Unsupported payload encoding in a frame | +| `DialCacheRedisProtocolError` | Invalid semantic mutation reply | + +The three Redis error classes extend `Error` directly. Core cache operations +usually absorb cache-path errors; direct adapter calls and explicit maintenance +can surface them. Invalid static options may throw `TypeError` or `RangeError`. +Source errors retain their original rejection value if recovery does not serve. + +## Custom integrations + +`RedisConfig`, `CompressionConfig`, `DialCacheRedisClient`, `RedisReadRequest`, +`RedisReadContext`, `RedisReadResult`, `RedisReadMiss`, `DecodedRedisFrame`, +`RedisWriteRequest`, `RedisInvalidationRequest`, and `RedisCachePayload` are root +types. Use `isRedisReadMiss` to discriminate reads. The complete semantic and +binary contracts are in [Redis and Valkey](redis.md#custom-client-contract). + +`DialCacheMetricsAdapter` and its label/outcome types are root exports. +[Observability](observability.md#custom-adapters) lists required and optional +hooks, bounded labels, and the effects of omitting optional hooks. diff --git a/docs/coalescing.md b/docs/coalescing.md new file mode 100644 index 0000000..998309c --- /dev/null +++ b/docs/coalescing.md @@ -0,0 +1,400 @@ +# Coalescing and liveness + +[Documentation](index.md) · [API reference](api.md) + +By default, DialCache shares same-key in-flight work within a request or a +`DialCache` instance, according to the active layers. A per-use-case policy can +disable that sharing. Each active remote read has a finite deadline. A separate +default deadline begins when an initially enabled invocation starts its fallback +loader. + +These mechanisms reduce duplicate source work. Their deadlines help flights +settle, but eventual cleanup still requires finite application-owned budgets +for every injected operation. They do not replace cross-process coordination, +source-native cancellation, admission control, or backpressure. + +Detached [shadow work](shadow-validation.md) has a separate instance-level +registry and capacity limit. It is not another coalescing scope. + +## Request coalescing + +DialCache has two sharing scopes. They can both participate in one call: + +| Active layers | Where same-key work is shared | +| --- | --- | +| Request-local only | Within one outermost `enable()` scope | +| Process-local or remote only | Within and across requests using one `DialCache` instance | +| Request-local plus a shared layer | Within each request first; each request-local miss can then join the instance's shared work | + +For example, two concurrent requests with both request-local and process-local +caching each perform their own request-local lookup. If both miss on the same +key, their lower-layer work can still coalesce into one process-local lookup +and one source call. Each request then memoizes the result in its own scope. + +### Request-local scope + +When request-local caching is active and coalescing is enabled, callers with the +same key in one outermost `enable()` scope share in-flight work before the +request-local lookup. + +The resolved value is memoized for later sequential calls in that scope. A +different outer request has a different request-local flight registry. + +### Process scope + +When process-local or remote caching is active and coalescing is enabled, +same-key callers share work within one `DialCache` instance before the first +active process-local or remote layer. + +This is reported as `scope="process"`, but it is instance-scoped: + +- separate requests using the same `DialCache` instance can share; +- separate `DialCache` instances in one process do not share; and +- separate processes or hosts do not share. + +```ts +await dialcache.enable(async () => { + // Same cold key and active process-local or remote layer: + // one fallback execution, one shared result. + const [first, second] = await Promise.all([ + getUser("456"), + getUser("456"), + ]); +}); +``` + +With a remote layer configured, an instance-scoped leader that misses +process-local cache performs one bounded Redis read. Followers share that read +and its remaining deadline. On a remote miss, the leader runs the fallback and +cache write; followers await that result. + +For a process-local-only miss, followers share the leader's fallback and local +write. This mitigates a thundering herd on one hot key within the instance. + +### What followers inherit + +Each enabled invocation resolves its own runtime config before it can join a +flight. Once it joins, it awaits the leader's result: it does not restart the +Redis read, run its own loader, or apply a separate source deadline. The leader +controls the shared cache path, serialization, writes, and stale-recovery +decision. Followers can therefore receive the leader's failure or recovered +stale value as well as a fresh result. + +A runtime change does not cancel or replace a flight already in progress. A +later invocation that is still eligible to coalesce can join that flight even +if its TTL or timeout differs. Turning all layers off bypasses it; setting +`coalesce: false` starts an independent cache path. Neither action cancels the +leader or removes values it may publish. + +This also matters for `getOrLoad()` calls with different closures or operation +options under one key. Keep their value meaning and serialization consistent, +and make execution independent when inheriting another caller's deadline, +failure, or cancellation behavior would be incorrect. + +`invalidateRemote()` does not clear existing flights. A caller arriving after +invalidation can join a leader that read Redis before invalidation, even when +only tracked remote caching is enabled. Use `coalesce: false` when each caller +needs its own watermark observation; see +[Independent fence checks](invalidation.md#independent-fence-checks). + +### Per-use-case opt-out + +`DialCacheKeyConfig.coalesce` is a sparse runtime boolean whose effective +default is `true`. Set it to `false` in a use case's `defaultConfig` or runtime +overlay to disable both request-local and process-scoped single-flight: + +```ts +import { CacheLayer, DialCacheKeyConfig } from "dialcache"; + +const getUser = dialcache.cached( + (userId: string) => db.fetchUser(userId), + { + keyType: "user_id", + useCase: "GetUserWithoutSingleFlight", + cacheKey: (userId) => userId, + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 60 }, + coalesce: false, + }), + }, +); +``` + +Concurrent same-key callers then each perform: + +- their own active-layer reads with a full independent remote-read budget; +- their own fallback, error, and fallback deadline when a fallback is needed; +- their own cache writes after a miss. + +Request-local and process-local publication is last-writer-wins. Each Redis +write is a complete-frame last-writer-wins `SET`; tracked reads apply the +watermark fence afterward. A settled +request-local value can still serve a later sequential call in the same outer +scope; the policy disables in-flight sharing, not memoization or cache hits. + +Runtime overlays can explicitly change the field in either direction. Omission +inherits the baseline and ultimately defaults to `true`. +`DialCacheKeyConfig.disabled()` deliberately leaves `coalesce` unset: with every +serving layer off there is no flight to share, and a later runtime ramp-up +coalesces again unless it explicitly opts out. + +The public constructor and static `defaultConfig` validation require a boolean +when the field is present. A malformed runtime value fails config resolution for +the whole invocation: DialCache warns, records `config_resolution` and +`config_error`, and executes the fallback uncached without touching Redis. + +Use the opt-out when executions with the same value identity must not inherit a +leader's failure, cancellation behavior, `FallbackTimeoutError`, or stale-recovery +result. It does not +make an incomplete cache key safe: if an input changes the returned value, put +it in the key or disable the affected cache layers. Disabling coalescing +reintroduces thundering-herd exposure, independent Redis load, and write races. + +An opted-out use case emits no +`coalesced` event, records request, miss, and latency observations once per +caller rather than once per flight, and does not register process state in +`getCoalescingState()`. + +## When calls do not coalesce + +Coalescing applies only when at least one cache layer is active and the resolved +`coalesce` policy is not `false`: + +- calls that start outside `enable()` are true pass-through; +- initially enabled calls with every layer disabled are uncached and + uncoalesced; +- a use case with `coalesce: false` keeps each caller's cache path independent; +- process-scoped work is never shared across `DialCache` instances. + +An initially enabled all-disabled call still receives the fallback deadline +described below. + +Layer activity follows resolved TTL/ramp policy. `localMaxSize: 0` disables +storage but does not bypass an otherwise active local layer, so concurrent calls +can still share a process flight. See [Process-local cache](configuration.md#process-local-cache). + +The full constructed cache key always defines cached-value identity. Include +locale, auth context, or any other input that can change the returned value, +regardless of the coalescing policy. + +When coalescing is enabled, that same key also defines execution identity: +concurrent calls with the same key share the leader's execution. Include +cancellation behavior and other execution-only inputs when they must differ by +key, or use `coalesce: false` when their results remain safe to cache under the +same value identity but their in-flight work must stay independent. + +### Shadow work does not enable caller coalescing + +Shadow admission does not make an otherwise all-disabled caller path +coalesced. + +When the remote layer is the only configured serving layer and its ramp +excludes a key, concurrent calls each run their own source fallback. Same-key +shadow jobs are deduplicated by admitting one and reporting the others as +`dropped`; callers do not join or await that job. + +A serving Redis hit reached through a process-scoped leader schedules at most +one shadow job for its coalesced followers. With `coalesce: false`, each caller +can attempt to schedule validation, but exact-key shadow deduplication admits at +most one concurrent job and reports the other attempts as `dropped`. + +`shadowMaxInFlight` limits scheduled or running shadow jobs across the +instance, independently of request-local and process-scoped flights. See +[Shadow validation and Redis bootstrap](shadow-validation.md) for the full +admission and lifecycle contract. + +## Stale recovery shares the flight + +An opted-in [stale-on-error](stale-on-error.md) path stays inside the same flight: +one initial Redis read, one retained candidate, one source attempt, and one +recovery decision. Followers share either the recovered value or original +rejection. With `coalesce: false`, each caller has an independent snapshot and +source deadline. + +## Fallback deadlines + +Once an initially enabled invocation begins its wrapped fallback, DialCache +applies a 60-second monotonic deadline by default. + +Set `fallbackTimeoutMs` on a cached wrapper or `getOrLoad()` invocation to +choose a positive integer deadline in milliseconds, up to 2,147,483,647. Set +it to `null` only when the application intentionally accepts an unbounded +fallback: + +```ts +import { DialCacheKeyConfig, FallbackTimeoutError } from "dialcache"; + +const getUser = dialcache.cached( + (userId: string) => db.fetchUser(userId), + { + keyType: "user_id", + useCase: "GetUserWithDeadline", + cacheKey: (userId) => userId, + defaultConfig: DialCacheKeyConfig.enabled(60), + fallbackTimeoutMs: 2_000, + }, +); + +try { + await dialcache.enable(() => getUser("123")); +} catch (error) { + if (error instanceof FallbackTimeoutError) { + logger.warn("source lookup exceeded its DialCache budget", { + useCase: error.useCase, + timeoutMs: error.timeoutMs, + }); + } + throw error; +} +``` + +### When the timer runs + +The timer starts only when the fallback begins: + +- same-key followers share the request-local or process leader's remaining + budget and source outcome, including any authorized stale recovery; +- callers with `coalesce: false` start independent fallback timers; +- a remote read failure or timeout starts the fallback timer only when the + source loader begins; +- enabled pass-through invocations where every layer is disabled have + independent timers; +- cache hits create no fallback timer; and +- calls that began outside an enabled context remain true pass-through and are + not timed out, even when the operation has `fallbackTimeoutMs`. + +### Application-owned budgets + +The source deadline is not a total-call timeout. Each operation has its own +settlement boundary: + +| Stage | Settlement budget | +| --- | --- | +| Runtime config provider | Application-owned; neither read nor source timer has started | +| Semantic Redis read | Resolved `remoteReadTimeoutMs`; see [Remote-read deadlines](redis.md#remote-read-deadlines-and-async-liveness) | +| Deserialize a cached value | Application-owned; outside the semantic-read timer | +| Source loader | `fallbackTimeoutMs`, starting when the source runs | +| Serialize and write the replacement | Application-owned; the source timer has already finished | +| Explicit `invalidateRemote()` | Application-owned; independent of enabled scopes | + +A pending serializer or Redis write can therefore keep a coalesced flight open +after the source succeeds. Give injected operations finite, resource-native +budgets for queueing, retries, and settlement. A separate application timeout +on the overall request can stop waiting, but does not by itself cancel this work. + +### Event-loop behavior + +Deadline delivery requires the JavaScript event loop to make progress. It +cannot preempt a synchronous fallback prefix or other event-loop blocking. +Rejection can therefore arrive later than the configured duration. + +When control returns, DialCache checks the monotonic deadline before accepting +the result. The timer remains referenced until the fallback settles or times +out. An abandoned enabled fallback can keep an otherwise idle short-lived +process alive until the deadline. + +Shutdown code should await outstanding caller-path DialCache promises rather +than discard them. This does not drain detached shadow jobs; see +[Redis lifecycle ownership](redis.md#lifecycle-ownership) for dependency +shutdown requirements. + +### Timeout does not cancel the source + +Timing out rejects the source attempt with `FallbackTimeoutError`. The chain +can then serve an authorized retained Redis candidate; otherwise it rejects +with that exact error. Its flight clears normally when the chain settles. + +A later source resolution is ignored. It cannot become an accepted shadow fill +value or proceed to ordinary serialization, Redis writes, or local publication. +Recovery may deserialize retained bytes and memoize its result request-locally; +it never publishes a new shared value. + +The underlying loader is not canceled and may continue its own I/O or side +effects. Give the source operation a native timeout or `AbortSignal` whenever +possible. + +`fallbackTimeoutMs: null` disables the guard and makes finite fallback +settlement entirely application-owned. Use that escape hatch only after +intentionally accepting the liveness risk. + +Timeout failures retain the bounded metrics classification +`error="fallback"` with `in_fallback="true"`. The typed error carries timeout +details without adding high-cardinality labels. + +A shared remote-read timeout emits one `cache_read_timeout` error for the +leader, not one per follower. With coalescing disabled, each caller owns its +read and can emit its own timeout error. + +### Shadow deadlines are separate + +A finite `fallbackTimeoutMs` also supplies the whole-job deadline for detached +shadow work. Setting it to `null` removes the caller fallback deadline, but +shadow work still uses the 60-second default. + +For a served Redis hit, the shadow clock starts when detached validation +begins. For a remote-ramped-down call, it starts before the caller's source +operation, so synchronous source work consumes the same budget. Shadow work +never delays or rejects the caller. + +The shadow scheduler and deadline timer are unreferenced. A deadline prevents +later serialization or write dispatch, but cannot cancel an already-started +source call, serializer, raw Redis read, or dispatched Redis write. + +Underlying shadow-owned work that has already started can retain a capacity +slot until it settles, even after the bounded outcome is reported. A +caller-owned source promise reused by a ramped-down shadow path is the +exception: by itself, it stops retaining that slot at the shadow deadline. + +## Inspecting process-scoped flights + +`getCoalescingState()` returns a point-in-time copy of caller-path +process-scoped flights owned by one `DialCache` instance: + +```ts +const state = dialcache.getCoalescingState(); + +state.process.activeLeaders; +state.process.activeFollowers; +state.process.oldestLeaderAgeMs; // null when idle +``` + +A leader is one exact cache key currently tracked by the instance-scoped +coalescer. A follower is each later invocation that joined that pending leader; +the initiating invocation is not counted as a follower. + +Followers remain counted until their leader's DialCache promise settles, +including by deadline rejection. The underlying source operation may continue +after that point. + +Request-local flights are deliberately excluded because their lifecycle is +bounded by the outer `enable()` scope. Shadow jobs are also excluded; they use +their own capacity registry and outcome metrics. +Use cases with `coalesce: false` never register process flights and therefore do +not appear in this state. +`oldestLeaderAgeMs` uses a monotonic clock and is computed when the snapshot is +requested. + +## Admission control remains application-owned + +There is no library-wide cap or age-based replacement for caller-path +request-local or process-scoped flights. `shadowMaxInFlight` bounds only +detached shadow jobs. + +A registry cap would bound only DialCache metadata. Overflow or eviction could +still create unbounded source work and unsafe duplicate publication. +DialCache's remote-read and fallback deadlines cover only those phases; +provider, serializer, and Redis-write settlement remains application-owned. +Admission control and backpressure remain responsible for bounding +simultaneous distinct-key work. + +Monitor: + +- active leader count; +- active follower count; +- oldest leader age; +- remote-read timeout errors; +- fallback deadline errors; and +- source concurrency and saturation. + +Use those signals to verify that application budgets and admission control hold +under production load. diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..5906583 --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,135 @@ +# How DialCache works + +[Documentation](index.md) · Next: [Configuration](configuration.md) + +DialCache is a read-through cache around an application function. The function +remains the source of truth. DialCache decides whether an invocation can reuse a +value and calls the function when it cannot. + +## The read path + +```text +Invocation + │ + ├─ outside enable() ───────────────────────────────► loader + │ + └─ enabled + build key → resolve runtime policy + │ + request-local → process-local → Redis / Valkey → loader + hit? hit? hit? + └───────────────┴────────────────┴────────► return value +``` + +Inactive layers are skipped. The first hit stops traversal, including any work +that would otherwise happen at lower layers. Same-key concurrent calls can +share work within the request before request-local lookup, and within the +instance before shared-layer lookup. With both kinds of storage enabled, +each request-local miss can join the same instance-wide flight. See +[Coalescing scopes](coalescing.md#request-coalescing). + +An enabled invocation resolves one policy snapshot before lookup. Defaults +belong to the reader; the runtime provider overrides individual fields. +Outside `enable()`, invocation skips key construction, runtime config, cache +access, coalescing, and the fallback deadline. Definition-time option validation +still happens when you create a wrapper or call `getOrLoad()`. + +## Three lifetimes + +| Layer | Scope | Retention | Control | +| --- | --- | --- | --- | +| Request-local | One outermost `enable()` scope | Until the scope settles; no capacity cap | `requestLocal` boolean | +| Process-local | One `DialCache` instance | Entry TTL and a shared LRU capacity | Local TTL and ramp; `localMaxSize` | +| Remote | Shared Redis keyspace | Physical expiry plus logical age checks; optional watermarks | Remote TTL and ramp; `redis.client` | + +Create a long-lived instance per intended local-cache and coalescing boundary. +Separate instances have independent LRUs, flights, and shadow capacity, even +when they use the same Redis server. + +## What gets stored after a miss? + +Successful results travel back through the layers that participated: + +| Path | Publication | +| --- | --- | +| Request-local miss | Memoizes a successful result from the lower chain, including recovered stale values | +| Process-local miss followed by a Redis hit | Warms process-local storage with the validated value | +| Local-only or remote-disabled path | An active process-local miss can store the successful loader result | +| Untracked Redis miss | Attempts a Redis write and can store locally | +| Tracked Redis read followed by fallback | Attempts an eligible Redis refill; suppresses direct process-local publication | +| Redis read failure or timeout | Calls the loader without a Redis refill; only untracked keys can publish the fallback locally | +| Stale-on-error recovery | Returns the retained snapshot without Redis or process-local publication | + +Successful `null`, `undefined`, `false`, `0`, and `""` results are cacheable values +in every layer, not misses. Redis still requires a serializer that can round-trip +the value; the default JSON codec supports all five. + +Tracked refills can be skipped when the initial read observed a watermark that +already fences the replacement timestamp. That optimization still returns the +loader result. It is explained in [Targeted invalidation](invalidation.md). + +## Identity governs reuse + +A key combines a namespace, entity kind and id, operation name, and optional +arguments. Include every dimension that can affect the returned value. + +The same identity governs both settled cache hits and in-flight sharing. A +missing tenant or locale can make callers reuse the wrong result. Turning off +coalescing does not correct an incomplete key. + +`cached()` registers a reusable operation once. `getOrLoad()` accepts an inline +loader and does not register its name. Both use the same read path. + +## Freshness boundaries + +A local hit does not consult Redis. Remote invalidation therefore does not +evict values already in request-local or process-local memory. A Redis hit can +also warm the local layer with a full local TTL; the remote TTL is not an +end-to-end maximum age across the chain. + +Changing policy does not evict old entries. In particular, a shorter local TTL +applies to new writes; existing local values retain their insertion TTL. Redis +reads classify frame age using the current remote policy. See +[Policy changes and existing entries](configuration.md#changing-policy-on-a-running-service). + +For each invocation to make its own invalidation-fence check, enable only tracked +remote caching and set `coalesce: false`. Otherwise, a caller can join work that +read Redis before invalidation and reuse that earlier observation. Invalidation +does not cancel existing flights. The watermark contract additionally depends +on bounded in-flight work, application clock skew, and preservation of watermark +state; see [Independent fence checks](invalidation.md#independent-fence-checks). + +Stale-on-error deliberately permits reuse of a snapshot acquired before the +source attempt. Later invalidation does not revoke that retained snapshot. +Leave recovery disabled when that behavior is unsuitable for the data. + +## Fail-open and liveness + +Cache-key, configuration, and cache I/O failures generally fall through to the +loader. Loader errors still reject unless an opted-in stale-recovery policy can +serve a retained value. Explicit `invalidateRemote()` failures reject. + +Synchronous loader throws and rejected loader promises are not memoized. Once a +failed flight settles, a later invocation can retry, including within the same +request-local scope. Successful stale recovery is the exception: it supplies a +value that can be memoized as described above. + +Fail-open describes error handling; it does not provide a deadline for every +dependency. DialCache bounds semantic Redis reads and enabled source fallbacks +separately. Configuration providers, serializers, Redis writes, and invalidation +need finite application-owned settlement budgets. A timeout stops DialCache from +accepting late results; it does not generally cancel underlying work. + +## Value ownership + +In-memory values and coalesced results are shared references. DialCache does not +clone or freeze them. Treat returned values as immutable; copy before mutation. +Redis deserialization may produce another reference, so reference identity is +not a stable API guarantee. + +## Where to go next + +- [Configuration](configuration.md) defines keys, defaults, overlays, and layers. +- [Coalescing and liveness](coalescing.md) explains flights and deadlines. +- [Redis and Valkey](redis.md) explains the remote layer and client contract. +- [API reference](api.md) provides method and option lookup. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..c927a56 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,576 @@ +# Configuration + +[Documentation](index.md) · [API reference](api.md) + +Configure DialCache at three levels: instance resources, operation defaults, +and a runtime overlay. An enabled scope permits caching; the resolved policy +decides which layers participate. + +| Level | Configure here | Lifetime | +| --- | --- | --- | +| Instance | Namespace, Redis client, LRU capacity, telemetry, shadow capacity | One `DialCache` instance | +| Operation | Key, serializer, invalidation tracking, deadlines, policy defaults | One registered reader or inline invocation | +| Runtime | Layer TTLs and ramps, request-local, coalescing, shadow, recovery age | One enabled invocation | + +Start with the [read-path overview](concepts.md) if these layers are new to you. +The [API reference](api.md) collects the public signatures and defaults; +[Redis and Valkey](redis.md) covers the remote layer in detail. + +## Defining cache operations + +Use `cached(fn, options)` to register a reusable reader once per instance. +The wrapper preserves its parameters and always returns a `Promise`. Each +registration needs a unique `useCase`; duplicates throw +`UseCaseIsAlreadyRegisteredError`. +Invalid static defaults or source timeouts fail before registration, so fixing +them and retrying can reuse the same name. + +Use `getOrLoad(load, options)` for a zero-argument loader that belongs at one call +site. It runs through the same cache path, but accepts a direct `key` instead +of a `cacheKey` selector and does not register the use case. Both APIs reject +the internal name `"watermark"` with `UseCaseNameIsReservedError`. + +The [operation options table](api.md#operation-options) covers serializers, +invalidation tracking, comparators, error classifiers, and source deadlines. +Prefer stable, deployment-defined use-case names such as `"BuildProfile"`. +Names are part of cache identity and metric labels; put user, request, and +entity dimensions in the key instead. + +For an inline loader, every captured value that can change the result belongs +in the key. All call sites for one identity must agree on value meaning and +serialization. With coalescing enabled, concurrent calls can also share one +caller's loader and its execution policy; see +[What followers inherit](coalescing.md#what-followers-inherit). + +Shadow work can run the loader after the caller has continued. Snapshot mutable +arguments or captured state before invoking the operation so that the detached +read still represents the selected key. + +## Enable and disable scopes + +DialCache performs cache work only inside an enabled asynchronous scope. Most +services create one instance and reuse it for the service process. Each +instance owns one process-local LRU, one process-coalescing registry, and one +shadow deduplication and capacity registry. Create separate instances only +when those resources should be isolated: + +| API | Behavior | +| --- | --- | +| `enable(fn)` | Enables caching for `fn` and the asynchronous work it awaits. The outermost call owns any request-local state. | +| `disable(fn)` | Temporarily restores pass-through behavior, commonly around nested mutation work. It does not evict existing values. | +| `isEnabled()` | Reports whether the current asynchronous call chain is inside a live enabled scope. | +| `withEnabled(fn)` | Exact alias for `enable(fn)`. | +| `withDisabled(fn)` | Exact alias for `disable(fn)`. | + +All five methods are instance-scoped. `enable()` and `disable()` always return a +`Promise`, including when their callback returns synchronously. Nested scopes +restore the previous state when their callbacks settle, and a nested +`enable()` inside `disable()` can opt a smaller read region back in. + +Enabled state follows Node's `AsyncLocalStorage`; it is not a process-global +flag. Once the outermost `enable()` callback settles, new invocations in detached +work that inherited the old context are pass-through. Closure does not cancel +already admitted cache operations: they can finish and publish to shared layers +under their normal policy and deadline rules, but cannot repopulate the closed +request-local state. An invocation still awaiting its configuration provider +when the scope closes skips cache lookup and runs its loader with the fallback +deadline it acquired while enabled. + +The root-exported `DialCacheContext` exposes the lower-level +`enable()`, `disable()`, and `isEnabled()` context primitive. It does not attach +itself to a `DialCache` instance or perform cache work. Most applications should +use the methods on `DialCache`. + +Keep mutation work outside the enabled boundary or inside `disable()`. Because +disabling does not evict existing values, mutable data still needs an +appropriate TTL or [targeted invalidation](invalidation.md) policy. + +## Keys, ids, and extra dimensions + +For `cached()`, the required `cacheKey` selector receives the wrapped +function's inferred parameters. `getOrLoad()` accepts the same bare id or +`{ id, args }` shape directly through `key`: + +```ts +const searchPosts = dialcache.cached( + (userId: string, page: number, filter: string) => + db.searchPosts(userId, page, filter), + { + keyType: "user_id", + useCase: "SearchPosts", + cacheKey: (userId, page, filter) => ({ + id: userId, + args: { page, filter }, + }), + defaultConfig: DialCacheKeyConfig.enabled(60), + }, +); + +await dialcache.enable(() => searchPosts("u1", 2, "active")); +``` + +The selected or direct key is the value-identity contract. It must include +every input dimension that can affect the returned value. Otherwise, distinct +calls can reuse the same cached value or share the same in-flight fallback +through request coalescing. + +### Namespace + +`DialCacheConfig.namespace` is the logical cache namespace and the first +component of every key. It defaults to `"urn"`, producing keys such as +`urn:user_id:123#GetUser`. + +Set a stable application-specific value when applications or environments may +share one Redis deployment: + +```ts +const dialcache = new DialCache({ + namespace: "production-users-api", + redis: { client: dialCacheRedisClient }, +}); +``` + +That produces Redis keys beginning with `production-users-api:...`, or +`{production-users-api:...}` for invalidation-tracked values. `namespace` is +DialCache's single cache-identity and key-partitioning setting. It participates +in request-local, process-local, Redis, coalescing, deterministic ramp, +invalidation, and metrics. + +A namespace may not contain `{` or `}` because DialCache reserves those +characters for Redis Cluster hash tags. + +### Identity rules + +- **`keyType` plus `id` is the invalidation unit for tracked Redis entries.** + `dialcache.invalidateRemote("user_id", "123", futureBufferMs)` writes one + watermark for that user. Any tracked Redis entry with the same `keyType` and + `id` is refreshed across all `args` variants when Redis is read. Untracked + entries do not consult the watermark. Invalidation does not evict existing + request-local or process-local entries. +- **`args` are part of the cache key.** Different arguments produce different + entries, but targeted invalidation is by id rather than by argument. +- **Components are encoded with `encodeURIComponent`.** Delimiters inside an + id, argument, or use case do not become structural separators. Namespace braces + always throw `TypeError`; tracked `keyType` and `id` also reject `{` and `}` + with `Error`. Untracked `keyType` and `id` may contain braces, which are encoded. + Automatic key-construction failures follow the normal + [fail-open path](concepts.md#fail-open-and-liveness). +- **Scalar equality is string-based.** For matching surrounding dimensions: + - numeric `1`, string `"1"`, and bigint `1n` identify the same key; and + - argument values `null` and `"null"` match, `-0` matches `0`, and an + `undefined` argument is omitted. + + If a deployment changes the logical meaning represented by a scalar, change + an explicit identity dimension such as `keyType`, `useCase`, or an argument + name or value. +- **Non-key inputs still reach the loader.** A database handle can be a normal + function parameter ignored by `cacheKey` or a value captured by a + `getOrLoad()` loader. Concurrent same-key misses share the leader's execution + unless the resolved policy explicitly sets `coalesce: false`. Do not omit + values such as `AbortSignal`, auth context, locale, or other request-scoped + inputs unless both sharing in-flight work and reusing a settled cache value + are correct. +- **Methods need a receiver.** Pass `obj.method.bind(obj)` or + `(...args) => obj.method(...args)`; a bare `obj.method` reference loses + `this`. + +### Constructing keys directly + +`cached()` and `getOrLoad()` stringify ids and normalize argument records for +you. Custom integrations can construct the same public shape with +`new DialCacheKey(init)`: + +| `DialCacheKeyInit` field | Default or requirement | +| --- | --- | +| `keyType`, `id`, `useCase` | Required strings | +| `namespace` | `"urn"` | +| `args` | Empty array; otherwise ordered, read-only `[string, string]` pairs | +| `defaultConfig`, `serializer` | `null` | +| `trackForInvalidation` | `false` | + +The direct constructor uses argument pairs in the supplied order. It does not +normalize or sort them. Use `normalizeArgs(record)` to omit undefined values, +convert the remaining scalar values with `String`, and sort names by JavaScript +string comparison: + +```ts +import { DialCacheKey, normalizeArgs } from "dialcache"; + +const key = new DialCacheKey({ + namespace: "app:prod", + keyType: "user_id", + id: "a/b", + useCase: "Read#User", + args: normalizeArgs({ z: 2, a: 1, omitted: undefined }), + trackForInvalidation: true, +}); + +key.prefix; // "{app%3Aprod:user_id:a%2Fb}" +key.toString(); // "{app%3Aprod:user_id:a%2Fb}?a=1&z=2#Read%23User" +``` + +`prefix` and `urn` are computed once; `toString()` returns `urn`. The constructor +retains supplied argument, config, and serializer references. Read-only types +do not deep-freeze these inputs; treat the key and its inputs as immutable. + +`invalidationPrefix(namespace, keyType, id)` validates the same tracked identity +components and returns the encoded prefix **without** braces. +`redisClusterHashTag(value)` rejects embedded braces and adds a literal pair of +braces; it does not encode the value. Neither helper adds arguments or a use case. + +### Changing a namespace + +Changing `namespace` intentionally creates a cold-cache boundary across every +layer. Old and new keyspaces do not share Redis values or invalidation +watermarks. + +During an overlapping deployment, an invalidation handled by one version is +invisible to the other. The other version can continue serving a stale tracked +value until its value TTL expires. If remote invalidation correctness matters, +a normal rolling namespace change is unsafe. + +Use a coordinated no-overlap cutover, or an operational bridge that prevents +both versions from serving remote cache across mutations. For example, +temporarily disable and clear remote caching during the transition. After the +cutover, provision for fallback and refill load, and allow old Redis keys to +expire by TTL. + +## Runtime config and ramp controls + +The constructor supplies shared resources and instance defaults. See +[`DialCacheConfig`](api.md#constructor) for its options. +`DialCacheKeyConfig` supplies the baseline and per-invocation overlay: layer +TTLs and ramps, request-local caching, coalescing, remote-read timeout, +stale-recovery age, and shadow policy. + +### Baseline and overlay precedence + +Each operation can supply a `defaultConfig`. The `cacheConfigProvider` result +is a sparse overlay: each supplied leaf replaces the baseline independently. + +```text +runtime field → defaultConfig field → DialCache disabled baseline +``` + +The disabled baseline has no local or remote TTL, request-local caching is off, +and shadow work is off. Coalescing defaults to `true`, but no flight exists +while all cache layers are inactive. A local or remote layer needs a TTL; once +it has one, omitting its ramp selects 100% of keys. + +A provider result of `null` (or defensive `undefined`), an empty config, and +omitted fields all inherit the baseline. Local and remote entries in `ttlSec` +and `ramp` merge separately. So do `shadow.ramp` and `shadow.logMismatches`: +`shadow: { ramp: 0 }` stops new shadow admission while preserving an inherited +logging preference. + +Use explicit values to turn inherited features off: + +| Overlay | Effect on the new invocation | +| --- | --- | +| `requestLocal: false` | Bypass request-local lookup and storage | +| `ramp: { [CacheLayer.LOCAL]: 0 }` | Bypass process-local serving | +| `ramp: { [CacheLayer.REMOTE]: 0 }` | Bypass remote serving; shadow admission stays independent | +| `shadow: { ramp: 0 }` | Stop new shadow work | +| `staleOnErrorMaxAgeSec: 0` | Disable stale recovery | +| `coalesce: false` | Give the caller an independent cache path and source deadline | +| `DialCacheKeyConfig.disabled()` | Disable request-local, stale recovery, and mismatch logging; set both serving ramps and the shadow ramp to `0` | + +The disabled helper leaves TTLs and `coalesce` unset. Inherited TTLs remain +available but inactive under its zero ramps. A later ramp-up coalesces unless +another leaf explicitly opts out. This helper does not cancel admitted work or +disable explicit maintenance such as `invalidateRemote()`. + +The remote-read deadline has additional fallbacks: + +```text +runtime remoteReadTimeoutMs + → defaultConfig.remoteReadTimeoutMs + → redis.readTimeoutMs + → 50 ms +``` + +It bounds the semantic Redis read and cannot be disabled. It does not include +config resolution, deserialization, the source call, or Redis writes; see +[Deadlines and application-owned budgets](coalescing.md). + +### Validation and snapshots + +Invalid instance options throw during construction. Invalid `defaultConfig` +leaves throw when `cached()` registers a definition or `getOrLoad()` is invoked. +The [API reference](api.md#dialcachekeyconfig) lists field types and bounds. + +`new DialCacheKeyConfig(...)` first validates object/map/group shapes, +`requestLocal`, `coalesce`, and `remoteReadTimeoutMs`, and copies the supplied +maps and shadow group. TTL, ramp, recovery-age, and shadow leaves are validated +later, at static-default capture or runtime resolution. Constructing a config +object alone therefore does not establish that all its leaves are valid. + +Each registration or inline invocation captures an immutable baseline snapshot, +including nested maps and shadow policy. Mutating the original config later +does not update that baseline. Use the provider for runtime changes. + +Invalid runtime policy fails open at the affected boundary: + +| Invalid input | Behavior | +| --- | --- | +| TTL or serving ramp leaf | Disable that layer with `invalid_ttl` or `invalid_ramp`; record `config_resolution`. Valid layers can continue. Values do not fall back to valid defaults and ramps are not clamped. | +| Config object, layer-map or shadow shape; `requestLocal`, `coalesce`, or `remoteReadTimeoutMs` | Fail resolution for the whole invocation; record `config_resolution` and `config_error`, then run the loader uncached. | +| `staleOnErrorMaxAgeSec` | Disable recovery and record `config_resolution`; a valid ordinary remote layer remains available. A positive age without a remote TTL is also an error. | +| `shadow.ramp` | Record remote `config_resolution` and skip shadow work when an eligible Redis path evaluates it; preserve valid serving layers. | +| `shadow.logMismatches` | Disable mismatch logging while preserving shadow work; record remote `config_resolution` only after the metrics hook, cohort, and capacity gates admit the job. | + +Validation of layer and shadow leaves depends on traversal: an earlier hit can +avoid evaluating lower-layer policy. Unknown runtime fields are generally +ignored, so validate external policy against your application's schema to catch +misspellings such as `ramp.remtoe`. + +The removed `shadowRamp` field is an exception. Static config rejects it with +`DialCacheKeyConfig.shadowRamp was replaced by "shadow.ramp"`; a provider result +containing it fails resolution for the whole invocation. + +### Provider behavior + +`cacheConfigProvider` is called for every enabled cache invocation before any +cache lookup. Keep it cheap, cache remote or config-store reads inside the +provider, and give asynchronous work a finite application-owned deadline. + +DialCache fetches and resolves one config snapshot per enabled invocation. +Provider errors do not activate defaults: they fail open, record +`config_error`, and execute the fallback uncached. + +This example assumes an application-provided `db` and a connected +`dialCacheRedisClient`; see [Redis setup](redis.md). + +```ts +import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; + +const dialcache = new DialCache({ + redis: { + client: dialCacheRedisClient, + readTimeoutMs: 75, + }, + cacheConfigProvider: async (key) => { + if (key.useCase === "GetUser") { + return new DialCacheKeyConfig({ + // Sparse override: inherit both TTLs and the local ramp. + ramp: { [CacheLayer.REMOTE]: 25 }, + // Per-use-case override of the instance's 75 ms read deadline. + remoteReadTimeoutMs: 35, + }); + } + return null; + }, +}); + +const getUser = dialcache.cached( + (userId: string) => db.fetchUser(userId), + { + keyType: "user_id", + useCase: "GetUser", + cacheKey: (userId) => userId, + defaultConfig: new DialCacheKeyConfig({ + // Omitted ramps default to 100% because these layers have TTLs. + ttlSec: { + [CacheLayer.LOCAL]: 30, + [CacheLayer.REMOTE]: 300, + }, + }), + }, +); +``` + +### Stable key cohorts + +Ramp values are thresholds from 0 to 100. `0` disables the layer, `100` enables +it for every key, and an intermediate value selects keys whose DialCache-owned +deterministic bucket for the full cache key and layer is below that threshold. + +For a fixed cache identity and layer, increasing a ramp only adds keys and +decreasing it only removes keys; it does not reshuffle existing membership. +Local and remote cohorts are layer-specific. + +Ramps select key cohorts, not requests or load, so a ramp of `10` does not +guarantee 10% of calls, especially for a small or skewed key population. +DialCache keeps the assignment stable across releases. + +Applications that need an externally coordinated cohort can use +`cacheConfigProvider` to return a sparse per-key ramp override of `0` or `100`. + +`shadow.ramp` selects its own stable exact-key cohort, independent of both +serving ramps. Shadowing additionally needs a valid remote TTL and a metrics +adapter with the outcome hook. `shadow.logMismatches` controls diagnostic +warnings separately and defaults to `false`. +[Shadow validation](shadow-validation.md) explains eligibility, comparison, +clean-miss fills, capacity, and the data-handling contract. + +### Changing policy on a running service + +New invocations resolve the current policy. A change does not evict existing +values or rewrite their stored expiration times: + +| Change | Existing entries and work | +| --- | --- | +| Lower or raise the local TTL | Existing local entries keep the TTL assigned when inserted. The new TTL applies to subsequent writes. Reads do not refresh that TTL. | +| Lower or raise the remote TTL | A new Redis read classifies the frame's age using the current remote TTL. The key's physical expiration stays as written; a longer policy does not extend it or restore an expired key. | +| Change the stale-recovery maximum age | A new Redis read uses the new age policy. Existing keys keep their physical retention; shorter recovery policy restricts reuse without deleting the key. | +| Set a serving ramp to `0` | Bypass that layer without evicting its entries. A later ramp-up can reuse values that remain valid. | +| Set `requestLocal: false` | Bypass the current request's memoized values without deleting them. Re-enabling it in that scope can reuse them. | +| Change TTLs, deadlines, or recovery while a flight is active | An eligible follower can still join the existing flight and inherit its leader's execution; admitted work is not reconfigured. | +| Return `DialCacheKeyConfig.disabled()` | Stop new cache use and shadow admission. Existing flights and detached jobs can finish and publish. | + +For example, reducing a local TTL from 60 seconds to 5 seconds does not make a +20-second-old local entry miss: it keeps its original 60-second lifetime. A +Redis frame of the same age is no longer fresh under a new 5-second remote TTL, +although a configured recovery policy may still admit it after a source failure. + +When an immediate freshness boundary matters, account for every active layer. +A local hit bypasses the new remote age policy and the invalidation watermark. +See [Freshness boundaries](concepts.md#freshness-boundaries) and +[What followers inherit](coalescing.md#what-followers-inherit). + +### Coalescing policy + +Coalescing defaults to `true` for both request-local and process-scoped work. +Set `coalesce: false` when callers sharing a value identity need independent +execution, deadlines, failures, or cancellation behavior. Cache hits and settled +request-local memoization still apply. The opt-out increases dependency load +and permits concurrent writes; see +[Coalescing and liveness](coalescing.md#per-use-case-opt-out) for the full contract. + +### Provider key input + +`cacheConfigProvider` receives the fully constructed, read-only `DialCacheKey` +for the invocation: + +| Field | Meaning | +| --- | --- | +| `namespace` | Logical application or environment namespace. | +| `keyType` and `id` | Primary identity. The selected id has already been converted to a string. | +| `args` | Secondary dimensions as normalized, name-sorted string pairs; entries whose value was `undefined` are omitted. | +| `useCase` | Stable operation name used in cache identity and metrics. | +| `prefix` | Encoded identity prefix, including a Redis Cluster hash tag when invalidation tracking is enabled. | +| `urn` | Complete encoded cache identity, including arguments and `useCase`. | +| `defaultConfig` | The operation's snapshotted baseline policy, or `null`. | +| `serializer` | The operation-specific serializer, or `null`. | +| `trackForInvalidation` | Whether the operation uses remote watermark tracking. | + +Use the identity fields to select policy; do not derive policy names or metric +dimensions from unbounded user input. The provider result remains a sparse +overlay and must not mutate the key. + +See [Constructing keys directly](#constructing-keys-directly) for the public +helpers and the difference between normalized provider keys and manually +supplied argument pairs. + +## Redis payload compression + +Compression is instance-wide write policy under `redis.compression`, rather +than a runtime use-case setting. The default uses zstd level 3 for serialized +payloads of at least 4,096 bytes, and selects compression only when it saves +space. `false` disables compression for new writes; reads still decode existing +compressed frames. + +See [Compression](redis.md#compression) for options, validation, synchronous +CPU cost, binary escaping, size limits, and mixed-version compatibility. The +[API table](api.md#redisconfig) provides the defaults in one place. + +## Request-local cache + +Set `requestLocal: true` to memoize resolved values for the lifetime of the +outermost `enable()` scope: + +```ts +const getUser = dialcache.cached( + (userId: string) => db.fetchUser(userId), + { + keyType: "user_id", + useCase: "GetUser", + cacheKey: (userId) => userId, + defaultConfig: new DialCacheKeyConfig({ requestLocal: true }), + }, +); +``` + +`requestLocal` is a runtime boolean rather than a TTL/ramp-controlled +`CacheLayer`. The provider can turn it on or off for each invocation. +`DialCacheKeyConfig.enabled(ttlSec)` enables only process-local and remote +caching, so request-local caching must be selected explicitly. + +The resolved config applies to the whole invocation. When `requestLocal` is +false, the invocation skips request-local lookup and storage without deleting a +value already memoized in the scope. A later invocation that enables it can +reuse that value. + +The outermost `enable()` call owns the request-local lifetime; nested `enable()` +calls reuse the same scope. State is allocated lazily, so scopes that use only +process-local or remote caching do not allocate it. + +Wrap the complete Node HTTP handler so the scope matches the request. Here, +`readUserId` and `handleRequestError` are application-provided functions: + +```ts +import { createServer } from "node:http"; + +const server = createServer((req, res) => { + void dialcache + .enable(async () => { + const user = await getUser(readUserId(req)); + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(user)); + }) + .catch((error: unknown) => handleRequestError(error, res)); +}); +``` + +Request-local storage has no capacity limit, eviction, or overflow mode. Values +are retained until the outermost callback settles. Use it for short-lived +scopes with bounded key cardinality. Split long-running streams or large batch +jobs into smaller scopes. + +## Process-local cache + +The process-local layer, `CacheLayer.LOCAL`, uses one LRU per `DialCache` +instance. It keeps at most 10,000 entries by default across all use cases while +retaining each entry's insertion TTL. Reading an entry updates its LRU +position but does not extend its TTL. + +Set `localMaxSize` to a nonnegative safe integer to change the global entry cap. +`0` disables process-local storage. With a valid local TTL and selected ramp, +that path still records misses and can coalesce concurrent calls within the +instance; sequential calls still miss this layer. Set the local ramp to `0` to +bypass the layer, or use `coalesce: false` to prevent in-flight sharing: + +```ts +const dialcache = new DialCache({ localMaxSize: 25_000 }); +``` + +The limit counts entries rather than estimating JavaScript object memory. +Recently read entries stay resident ahead of less recently used entries when +the limit is reached. + +## Cached-value ownership + +Treat values returned by cached functions or `getOrLoad()` as immutable. +DialCache does not clone or freeze values stored in request-local or +process-local memory. +Mutating a cached object can be observed by: + +- later callers in the same request; +- callers in other requests that hit the process-local cache; and +- callers that coalesced onto the same in-flight result. + +This contract includes nested objects and arrays, `Map`, `Set`, `Buffer`, typed +arrays, and class instances. Redis deserialization can produce a different +reference from an in-memory hit, so reference identity is layer-dependent and +is not part of the API contract. + +Copy a value explicitly before changing it: + +```ts +const sharedUser = await getUser("123"); +const editableUser = structuredClone(sharedUser); +editableUser.displayName = "New name"; +``` + +Use a narrower copy when its semantics are sufficient. The ownership boundary +remains the caller's responsibility. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..26fdfee --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,177 @@ +# Getting started + +[Documentation](index.md) · Next: [How DialCache works](concepts.md) + +Start with a process-local cache so you can see the behavior without running +Redis. Then choose a request boundary and connect runtime policy. + +## Install + +```bash +npm install dialcache +``` + +The supported Node.js range is `>=22.15.0 <23.0.0 || >=23.8.0`. +The package provides ESM and CommonJS entry points and TypeScript declarations. + +## Wrap a reader + +Create one long-lived instance for the service and register reusable readers +once. The function you wrap is the source loader: DialCache invokes it whenever +the active cache layers cannot supply a value. Save this as `example.mts`: + +```ts +import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; + +const dialcache = new DialCache(); +let sourceReads = 0; + +async function fetchUser(userId: string) { + sourceReads += 1; + return { id: userId, name: "Ada" }; +} + +const getUser = dialcache.cached(fetchUser, { + keyType: "user_id", + useCase: "GetUser", + cacheKey: (userId) => userId, + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 60 }, + }), +}); + +await dialcache.enable(async () => { + await getUser("123"); + await getUser("123"); +}); +console.log(sourceReads); // 1 + +await getUser("123"); +console.log(sourceReads); // 2: caching is off outside enable(). +``` + +Run it directly with Node; no TypeScript runner is needed: + +```bash +node --experimental-strip-types example.mts +``` + +It prints: + +```text +1 +2 +``` + +The `.mts` extension selects ESM, so top-level `await` works even in a project +that otherwise uses CommonJS. The flag removes TypeScript annotations; use your +project's TypeScript compiler for typechecking. Replace `fetchUser` with the +real read when integrating the example into your service. + +If that read returns `Date`, `bigint`, or other non-JSON-compatible values, +provide a [typed serializer](redis.md#typed-serializer-requirement). This is +required even when caching only in local memory; the linked example shows how +to preserve a `Date` through serialization. + +The wrapper preserves the input parameters and always returns a `Promise`. +`keyType` identifies the entity kind; `useCase` identifies the operation. +`cacheKey` selects the result's identity. Include every input that can change +the result, such as locale or tenant. + +The example enables only process-local storage, with a 60-second TTL and an +implicit 100% ramp. Its LRU holds up to 10,000 entries across all use cases on +the instance. Neither request-local nor remote caching is enabled here. + +## Choose the enabled scope + +Wrap read-request handling in `enable()` so nested readers inherit the policy +through Node's `AsyncLocalStorage`. Use `disable()` for nested uncached work: + +```ts +await dialcache.enable(async () => { + const cached = await getUser("123"); + const fromSource = await dialcache.disable(() => getUser("123")); + return { cached, fromSource }; +}); +``` + +Nested scopes restore the preceding state when they settle. `disable()` bypasses +caching; it does not remove old entries. After a mutation, freshness still +depends on the reader's TTL or [invalidation policy](invalidation.md). + +To memoize only within the outer enabled scope, use +`new DialCacheKeyConfig({ requestLocal: true })`. That storage has no TTL or +capacity limit and is released when the scope settles. Keep the scope and its +key count bounded. + +## Keep a calculation inline + +Use `getOrLoad()` when extracting a reusable reader would obscure the code: + +```ts +const userId = "456"; +const user = await dialcache.enable(() => + dialcache.getOrLoad(() => fetchUser(userId), { + keyType: "user_id", + useCase: "InlineGetUser", + key: userId, + defaultConfig: new DialCacheKeyConfig({ requestLocal: true }), + }), +); +``` + +It uses the same cache path as `cached()`. The direct `key` replaces the selector, +and the use case can be repeated at the call site without registration. + +## Introduce runtime policy + +Keep stable defaults next to the reader. A `cacheConfigProvider` can return +sparse overrides for each enabled invocation. The provider runs before cache +lookup, so keep it inexpensive and bound any asynchronous work it starts. + +```ts +const policies = new Map(); +const controlledCache = new DialCache({ + cacheConfigProvider: (key) => policies.get(key.useCase) ?? null, +}); + +const readUser = controlledCache.cached(fetchUser, { + keyType: "user_id", + useCase: "ReadUser", + cacheKey: (userId) => userId, + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.LOCAL]: 60 }, + ramp: { [CacheLayer.LOCAL]: 0 }, + }), +}); + +policies.set("ReadUser", new DialCacheKeyConfig({ + ramp: { [CacheLayer.LOCAL]: 10 }, +})); +await controlledCache.enable(() => readUser("123")); + +policies.set("ReadUser", DialCacheKeyConfig.disabled()); +``` + +The map stands in for your configuration system. The 10% ramp selects a stable +cohort of keys and inherits the 60-second TTL. It is not a traffic percentage. +The disabled overlay stops new cache use and shadow admission; it does not +cancel work already in flight. + +Changing a TTL also has different effects on existing local and Redis entries. +Read [Changing policy on a running service](configuration.md#changing-policy-on-a-running-service) +before using a runtime change to tighten freshness. + +## Add shared caching when needed + +Install a supported client, connect it, and pass its DialCache adapter in +`redis.client`. Add a remote TTL to each participating reader. Start its remote +serving ramp at zero while verifying the configuration and observability. + +[Redis and Valkey](redis.md) provides setup for node-redis and GLIDE, including +Cluster routing and connection ownership. [Observability](observability.md) +shows the optional Prometheus and Datadog integrations. + +For mutable data, read [Targeted invalidation](invalidation.md) before enabling +shared cache serving. For a rollout that compares Redis with the source first, +continue to [Shadow validation](shadow-validation.md). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..9609291 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,55 @@ +# DialCache documentation + +DialCache is a TypeScript library that organizes caching into use cases, with +runtime control and observability for each one. This reference explains the +system from the outside in: first the read path, then the policies that control +it, then individual APIs and integration contracts. + +## Start here + +1. [Getting started](getting-started.md) — run a small example, choose a scope, + and connect a reader to runtime policy. +2. [How DialCache works](concepts.md) — follow a call through enablement, cache + layers, coalescing, and the source loader. +3. [Configuration](configuration.md) — define identities, select layers, and + change TTLs and stable rollout cohorts. + +## Features and behavior + +| Topic | What it explains | +| --- | --- | +| [Redis and Valkey](redis.md) | Connect clients, understand native reads and writes, choose serializers and compression, and implement an adapter | +| [Targeted invalidation](invalidation.md) | Refresh tracked entities, size the future buffer, and understand clocks, watermarks, and local-cache boundaries | +| [Stale-on-error](stale-on-error.md) | Retain a Redis snapshot for selected source failures, choose age limits, and understand recovery races | +| [Shadow validation](shadow-validation.md) | Validate cache coherence by comparing sampled Redis values with the source | +| [Coalescing and liveness](coalescing.md) | Share same-key work, configure deadlines, and inspect in-flight state | +| [Observability](observability.md) | Set up Prometheus or Datadog, interpret metrics, and implement custom telemetry | + +## Reference and operations + +| Topic | What it explains | +| --- | --- | +| [API reference](api.md) | Public methods, operation options, configuration defaults, errors, and import paths | +| [Upgrading](upgrading.md) | Protocol cutovers, longer Redis retention, serializer compatibility, and metric migrations | +| [Maintainer guide](maintainers.md) | Local validation, documentation, benchmarks, and releases | + +## Find an answer + +| Question | Start here | +| --- | --- | +| Why is my loader still running? | [Enabled scopes](configuration.md#enable-and-disable-scopes), [layer policy](configuration.md#baseline-and-overlay-precedence), and [miss reasons](observability.md#miss-reasons) | +| Why did changing a TTL leave an old value in cache? | [Policy changes and existing entries](configuration.md#changing-policy-on-a-running-service) | +| Why can I still see a value after invalidation? | [In-memory publication](invalidation.md#in-memory-publication) and [recovery races](stale-on-error.md) | +| Why are callers sharing a timeout or result? | [What followers inherit](coalescing.md#what-followers-inherit) | +| What can still wait after the source deadline? | [Application-owned budgets](coalescing.md#application-owned-budgets) | +| Why is shadow validation doing no work? | [Shadow eligibility](shadow-validation.md#eligibility) and [custom metrics hooks](observability.md#custom-adapters) | + +The published site follows `main`, which may be ahead of the npm package. For an +installed version, consult its [release notes](https://github.com/lan17/DialCache/releases) +and the README or reference at the matching +[release tag](https://github.com/lan17/DialCache/tags). The package's TypeScript +declarations are the exact type source. + +[Project overview](https://github.com/lan17/DialCache#readme) +· [npm package](https://www.npmjs.com/package/dialcache) +· [Source code](https://github.com/lan17/DialCache) diff --git a/docs/invalidation.md b/docs/invalidation.md new file mode 100644 index 0000000..4557c35 --- /dev/null +++ b/docs/invalidation.md @@ -0,0 +1,279 @@ +# Targeted invalidation + +[Documentation](index.md) · [Redis and Valkey](redis.md) + +Use targeted invalidation when a source mutation should invalidate every tracked +Redis result for an entity. A single entity watermark covers all its tracked +use cases and argument variants in the same namespace, without scanning keys. + +Invalidation is remote-only. In-memory hits and callers joining an existing +flight can reuse a value without a new Redis read. For an independent watermark +observation on each invocation, disable request-local and process-local caching, +set `coalesce: false`, and leave stale recovery off. See +[Independent fence checks](#independent-fence-checks) for the exact boundary. + +## Configure a tracked use case + +Assuming `redisClient` is connected and `db` is your application data source: + +```ts +import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; +import { createNodeRedisDialCacheClient } from "dialcache/node-redis"; + +const dialcache = new DialCache({ + namespace: "users-api", + redis: { client: createNodeRedisDialCacheClient(redisClient) }, +}); + +const getUser = dialcache.cached( + (userId: string) => db.fetchUser(userId), + { + keyType: "user_id", + useCase: "GetMutableUser", + cacheKey: (userId) => userId, + trackForInvalidation: true, + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.REMOTE]: 300 }, + // No local layers here, so each invocation performs its own tracked read. + coalesce: false, + }), + }, +); + +// Example only: derive this from your own timing and clock-skew bounds. +const USER_INVALIDATION_BUFFER_MS = 5_000; + +await db.updateUser("123", patch); +await dialcache.invalidateRemote("user_id", "123", USER_INVALIDATION_BUFFER_MS); +const updated = await dialcache.enable(() => getUser("123")); +``` + +Call invalidation **after the source mutation commits**. It works outside an +`enable()` scope. It requires a configured Redis client and rejects if that +client is absent or the mutation fails. Handle that rejection as a failed +maintenance operation, even though ordinary cache I/O fails open. + +## Read and write behavior + +A watermark is an epoch-millisecond threshold. A tracked frame is readable only +when its writer timestamp is strictly greater than that threshold and the frame +also passes normal age and payload checks. + +```text +source mutation commits + ↓ +invalidateRemote → watermark = max(previous, invalidator time + buffer) + ↓ +next tracked read → atomic primary MGET(value, watermark) + ├─ frame timestamp > watermark → normal age check and cache hit + └─ frame timestamp ≤ watermark → miss → source loader +``` + +The bundled adapters route tracked reads to primaries so replica lag cannot hide +an invalidation. A missing watermark is the natural zero baseline. + +All value writes use one native `SET` of a complete frame stamped from the +application clock. They do not read, create, or extend watermarks. A write can +succeed physically while its frame remains unreadable under a watermark; +read-time fencing supplies that distinction. + +### Conditional refills + +An adapter-level tracked miss may carry `observedWatermarkMs` from the same +atomic read. After a successful fallback, DialCache uses that observation to avoid +writing a replacement already known to be fenced: + +1. Sample the application clock before serialization. If the sample is at or + below the observed watermark, skip payload preparation and the write. +2. Otherwise serialize and compress, then sample again immediately before + dispatch. If that final timestamp is at or below the watermark, skip `SET`. +3. Otherwise send the complete frame using that exact final timestamp. + +The final sample keeps serialization time out of the stored frame's logical +TTL. The first check avoids expensive serialization and compression when a fill +cannot yet clear the fence. Both checks reuse the original observation; neither +adds a Redis command. + +The miss **reason** is independent from the observed fence. An absent value can +carry a valid watermark and suppress a refill. A `watermark_fenced` miss can +later refill if the timestamp advances beyond that watermark. A miss without +an observed fence follows the normal write path. + +A fenced refill is skipped immediately; the call does not wait for the watermark +to pass. An admitted refill still awaits serialization and the Redis write +before returning the fallback value, so those operations need +[application-owned budgets](coalescing.md#application-owned-budgets). +The checks do not establish a transaction with a later invalidation: the +watermark can advance after the read and fence an admitted write. + +### In-memory publication + +If an invocation reaches the tracked Redis read/write path, its fallback is not +published directly to process-local memory. A later validated Redis hit may +warm that layer. Local-only, remote-policy-disabled, and ramped-down paths retain +their local publication behavior. + +Request-local memoization remains unconditional for successful results from the +lower chain. Existing process-local and request-local entries are not evicted. +A remote ramped-out invocation without shadow work does not consult Redis. + +### Shadow reads and fills + +Tracked shadow reads use the same primary snapshot and fence. Semantic shadow +misses apply the same two timestamp checks before filling. A skipped fill reports +`fill_fenced`; an accepted write reports `filled`, even though a later watermark +may fence it. Shadow fills remain ordinary overwrites, not compare-and-set. + +See [Shadow validation](shadow-validation.md) for admission, comparison, and +race boundaries. [Stale-on-error](stale-on-error.md) has a distinct snapshot +contract: invalidation after the initial read cannot revoke retained bytes. + +## Identity and Redis Cluster placement + +The invalidation unit is `(namespace, keyType, String(id))`. It covers all tracked +`useCase` and `args` variants of that entity. Untracked entries ignore the +watermark. + +```text +watermark: {users-api:user_id:123}#watermark +value: {users-api:user_id:123}?locale=en#GetMutableUser:dialcache-frame-v1 +``` + +The shared hash tag puts both keys in one Redis Cluster slot. Components are +percent-encoded so delimiters cannot collide with the format. Braces are +reserved and rejected. Values use the binary frame suffix; watermarks are +stored as decimal timestamps. + +A complete supported positive-timestamp frame rejected at or below a valid +watermark is `watermark_fenced`. A missing value is `value_absent`, even when +metadata is malformed. Malformed present watermark metadata paired with a +present frame is `unclassified`. These classifications are described in +[Observability](observability.md#miss-reasons). + +Redis `MGET` treats wrong-type members as absent. A wrong-type watermark therefore +acts like the zero baseline until explicit invalidation repairs it. Preserve +ownership of the keyspace; external writes can undermine the fence. + +## Application clock contract + +Writer timestamps, invalidation proposals, and logical ages use application +`Date.now()` clocks. DialCache does not query Redis `TIME`, calibrate an offset, +or compensate for skew. External clock synchronization and monitoring are part +of the deployment contract. + +Relative skew moves logical expiry earlier or later. Frames dated after the +reading process's clock fail closed before serving. The optional future-offset +metric reports observed positive offsets, but cannot establish fleet-wide clock +health: co-skewed readers and writers, an ahead invalidator, and frames hidden +by a watermark can escape detection. + +Elapsed operation durations and deadlines use the monotonic clock separately. + +## Choosing `futureBufferMs` + +The buffer covers stale work that can still become visible after invalidation. +The dangerous skew direction is a fast writer relative to a slow invalidator. + +```text +futureBufferMs ≥ Dmax + maximum writer-clock lead + operational margin +``` + +`Dmax` runs from invalidation sampling until a stale pre-mutation `SET` can become +visible in Redis. Include source visibility/replication lag, remaining fallback +work, serialization, compression, client queueing and reconnect delay, network +transit, and Redis execution. An unbounded offline queue or retry path makes a +finite bound impossible. + +The buffer is a nonnegative safe integer up to `31_536_000_000` milliseconds +(365 days). Its API default is zero for compatibility. Zero fences frames +stamped no later than invalidation, but provides no protection once delayed +stale work receives a later timestamp. Choose a named, application-owned value +from measured or conservative timing bounds; the example's five seconds is not +a universal recommendation. + +A larger buffer raises fallback load. Native `MGET` still transfers existing +fenced payloads even when replacement serialization and `SET` are skipped. +The buffer does not force the loader to read an authoritative source, cancel +in-flight operations, or stop an already-dispatched write. + +## Watermark lifetime + +DialCache caps tracked Redis value retention at **one hour**. Each dispatched write +configured above that cap records `tracked_ttl_clamped`; its logical policy is +not rewritten. Invalidation alone creates and updates watermarks. + +A finite watermark is retained for at least: + +```text +max(existing remaining TTL, + 2 hours, + watermark − invalidatedAtMs + 1 hour + 1 minute) +``` + +An existing persistent string watermark stays persistent. Reads and value writes +do not extend it. Under the clock and in-flight-work contract, the marker outlives +every value it can fence. The fixed minute is retention slack; it does not +replace a complete `Dmax` bound. + +Invalidation repairs malformed string watermarks from a zero baseline while +preserving a longer remaining TTL or persistence. A wrong-type key is instead +treated as absent and replaced with a finite, derived TTL, even if that key was +persistent. Other Redis read errors surface without replacing the prior state. + +Changing the tracked-value cap or watermark floor requires another coordinated +protocol transition: new constants cannot extend markers an older invalidator +already wrote. See [Upgrading](upgrading.md#tracked-protocol-cutover). + +## Watermark durability + +Watermarks are correctness state. If eviction, failover, restore, deletion, or an +external write removes a watermark, a tracked read can serve a previously fenced +value under the zero baseline. + +Use `noeviction` or an equivalent preservation guarantee when relying on the +fence. Monitor memory headroom and rejected writes, and select persistence and +failover behavior consistent with the application's requirements. DialCache does +not issue `WAIT` or provide strong consistency across Redis failover. + +## Failure behavior and telemetry + +Invalid buffer arguments fail before dispatch. Missing Redis configuration and +invalidation I/O failures are logged, recorded with `error="invalidation"`, and +rethrown. The operation metric uses `keyType` and namespace; it does not attach +an entity id to labels. + +Adapter retries reuse the original invalidation timestamp, preserve monotonicity, +and cannot shorten a longer/persistent string marker. Wrong-type repair follows +the exception above. A rejected dispatched mutation +can have executed, so an error does not prove absence of a watermark change. +See [Redis retries](redis.md#invalidation-retries-and-ambiguity). + +## Independent fence checks + +Invalidation changes what a subsequent tracked Redis read can accept. It does +not revoke a snapshot already read or cancel a caller-path flight. This matters +even with both in-memory layers and stale recovery disabled: + +1. A tracked Redis read acquires a valid cached value, then waits in an + asynchronous serializer. +2. A source mutation commits and `invalidateRemote()` completes. +3. A new same-key invocation joins that existing flight and receives the earlier + value without another Redis read. + +To keep later invocations from joining such work, set `coalesce: false` as in +the example above. With tracked remote caching active, request-local and +process-local caching off, and stale recovery off, a call starting after +invalidation performs its own tracked read or falls back to the source on cache +failure. Keep those effective settings in runtime overlays as well as defaults. + +Already-started invocations can still finish with their acquired snapshots. +The source must supply authoritative reads, and the clock, buffer, and watermark +durability requirements still apply. This policy does not cancel work or create +a transaction between the source mutation and Redis. + +## In-memory layers remain local + +If local reuse or recovery is acceptable, choose its scope and lifetime +explicitly: neither remote invalidation nor `disable()` revokes a value already +held in memory. Default coalescing also trades independent observations for +shared work, as described above. diff --git a/docs/maintainers.md b/docs/maintainers.md new file mode 100644 index 0000000..3a36a7a --- /dev/null +++ b/docs/maintainers.md @@ -0,0 +1,221 @@ +# Maintainer guide + +[Documentation](index.md) · [Upgrading](upgrading.md) + +This page covers repository validation, documentation maintenance, diagnostic +benchmarks, and the existing release process. + +## Validation + +Use the repository's pinned pnpm through Corepack: + +```bash +corepack pnpm install --frozen-lockfile +corepack pnpm check +corepack pnpm docs:build +corepack pnpm test:integration +``` + +`check` runs strict typechecking, unit tests with coverage, bundles/declarations, +and packed ESM/CJS consumer checks. Integration tests use Testcontainers and +require a Docker-compatible runtime for Redis, Valkey, and Redis Cluster. + +CI uses Node.js 24 for development and integration, then validates the packed +package and zstd at the exact 22.x consumer floor, Node.js 22.15.0. The published +engine range is `>=22.15.0 <23.0.0 || >=23.8.0`. + +Match validation to the changed contract. Public API and adapter changes need +packed-consumer coverage; Redis behavior needs real standalone/Cluster tests. +Pay particular attention to complete-frame writes, atomic tracked reads, +conditional refills, logical-age recovery, snapshot ownership, bounded telemetry, +and mixed-version transitions. Documentation-only changes need source checks, +example validation, and working links. + +## Maintaining the reference + +The README is the landing page. Keep evaluation, a runnable example, and links +there; put complete contracts in the feature guides. `docs/index.md` provides +the reading order, and `docs/api.md` collects public methods/options and routes +to behavior details. + +When behavior changes, update the relevant guide and its API table in the same +PR. Check defaults and bounds against source, and include any rollout or +compatibility implications in `docs/upgrading.md`. Keep examples explicit about +application-provided dependencies. + +The npm tarball contains `README.md` but not `docs/`. README links to the hosted +reference therefore use absolute URLs. Reference pages use relative Markdown +links so they work in a checkout, on GitHub, and in the documentation site. +Before publishing, check file/anchor targets and parse TypeScript examples. +`test:package` extracts the first TypeScript block from both the README and +getting-started guide, typechecks it against the installed tarball, and executes +it with the documented Node command. CI also runs this check at Node.js 22.15.0. +Keep those blocks self-contained; when their demonstrated output changes, +update the expectation in `scripts/test-package.mjs`. + +### Run the documentation site + +VitePress renders the Markdown in `docs/` with grouped navigation, page outlines, +syntax highlighting, and local search. Search runs in the browser using an index +built with the site; it needs no external service or credentials. + +```bash +corepack pnpm docs:dev +``` + +To check the production output, run: + +```bash +corepack pnpm docs:build +corepack pnpm docs:preview +``` + +Open the `/DialCache/` URL printed by the server. The build writes to +`docs/.vitepress/dist/` and fails on broken internal page links. Generated output +and the local build cache are ignored by Git. When adding a page, include it in +`docs/index.md` and the sidebar in `docs/.vitepress/config.mts`. + +The dependency overrides keep stable VitePress on patched Vite 6.4.x. The config +uses a Safari 14.1 target for builds and dependency optimization to remain +compatible with the repository's patched esbuild. Revisit this scoped override +and the targets when upgrading VitePress. + +### Publish to GitHub Pages + +The site is hosted at [lan17.github.io/DialCache](https://lan17.github.io/DialCache/). +In the repository's **Settings → Pages**, select **GitHub Actions** as the build +source. Keep `base: "/DialCache/"` in the VitePress config so links and assets work +under the project URL. + +The `Documentation` workflow builds every pull request. A push to `main` builds +and publishes the site through the `github-pages` environment; the workflow can +also be run manually from `main` to republish. Pull requests and manual runs from +other branches cannot upload a Pages artifact or deploy. The deployment job uses +GitHub's short-lived token and OIDC; no deployment secret is needed. + +The published reference follows `main` independently of npm releases. Use the +Markdown at a release tag when reading about an older installed version. + +## Cache-path benchmark + +From a repository checkout, run the semantic microbenchmark after installing +dependencies: + +```bash +corepack pnpm benchmark:request-local +``` + +The command builds `dist` before reporting ten scenarios: sequential +request-local hits, sequential process-local hits, enabled bounded fallbacks, +request-local coalescing fan-out, process coalescing fan-out, +remote-read-deadline coalescing fan-out, tracked Redis hits with shadow +omitted, tracked Redis hits deterministically outside a partial shadow ramp, a +ramped-down warm-hit confirmation, and a ramped-down semantic-miss fill. Both +shadow scenarios prove that the caller completes before detached Redis work. +The benchmark is a maintainer tool and is not included in the published +package. It asserts fallback counts, Redis behavior, coalescing state, timer +cleanup, returned values, exactly-once SoT reuse, and conditional +confirmation/fill without applying a timing threshold. Override its work sizes +with `DIALCACHE_BENCH_ITERATIONS` and `DIALCACHE_BENCH_FANOUT`. + +## Redis write benchmark + +With an otherwise idle Redis reachable at `REDIS_URL` (default +`redis://127.0.0.1:6379`, e.g. `docker run --rm -p 6379:6379 redis:6.2`), +measure the local build's write path. The benchmark resets global command +statistics between cases, so use a disposable or dedicated instance: + +```bash +corepack pnpm benchmark:redis-write +``` + +The command builds `dist`, then runs sequential native writes at 100 B, 10 +KiB, 100 KiB, and 1 MiB payloads. It reports `SET`, script, and `TIME` calls +per operation, server-side `SET` cost from `INFO commandstats`, and +client-side p50/p95 latency. Semantic assertions require exactly one `SET`, +zero scripts, and zero `TIME` calls per write. Because operations are +sequential, the benchmark validates command shape and single-operation +latency; it does not measure saturated concurrent throughput or maximum write +capacity. Like the cache-path benchmark it is a maintainer tool, is not part +of the published package, and applies no timing threshold — absolute numbers +depend on the machine, engine, and load, so compare runs only within one +environment. Scale iteration counts with `DIALCACHE_BENCH_WRITE_SCALE`. + +## Stale-on-error benchmark + +With Redis reachable at `REDIS_URL`, exercise the native-read design and a +representative compressible payload: + +```bash +corepack pnpm benchmark:stale-on-error +``` + +The benchmark warms isolated keys, verifies that physical retention uses `M`, +and reports fresh end-to-end hits, native reads of a retained frame, +end-to-end stale recovery, and same-key coalesced recovery. It asserts exactly +one adapter read per stale-recovery flight. A separate high-cardinality +scenario holds delayed source calls open with distinct incompressible raw +payloads, then reports process memory before retention, while every candidate +is retained, and after recovery. Run the built script with `node --expose-gc +scripts/benchmark-stale-on-error.mjs` for less noisy memory snapshots. It +snapshots `INFO commandstats` and network byte counters around each scenario +without resetting shared server statistics, and reports command, server-CPU, +network, and client-throughput signals per operation. Semantic assertions +cover compression, exact source/recovery/read counts, and returned values; +timing and memory remain informational with no pass/fail threshold. Override +work sizes with `DIALCACHE_BENCH_STALE_ITERATIONS`, +`DIALCACHE_BENCH_STALE_FANOUT`, `DIALCACHE_BENCH_STALE_PAYLOAD_BYTES`, +`DIALCACHE_BENCH_STALE_MEMORY_KEYS`, +`DIALCACHE_BENCH_STALE_MEMORY_PAYLOAD_BYTES`, and +`DIALCACHE_BENCH_STALE_MEMORY_SOURCE_DELAY_MS`. + +## Releasing + +Publishing starts by manually running the `Release` workflow from current +`main`. + +After the package checks pass, Semantic Release selects the next version from +Conventional Commits since the highest stable `vX.Y.Z` tag: + +- while the package is pre-1.0, breaking changes bump minor and retain their + `BREAKING CHANGE:` footers for full release notes; +- `feat` bumps minor; and +- every other normal PR-title type bumps patch. + +Patch types are `fix`, `perf`, `docs`, `style`, `refactor`, `test`, `build`, +`chore`, `ci`, and `revert`. The highest required bump wins. + +Major bumps resume when the project cuts 1.0.0. `release.config.mjs` implements +this policy; change it and this guide together so the documented release table +cannot drift from automation. + +The workflow opens a `release: ` pull request whose only change is the +matching `package.json` version. `release` is a reserved Conventional Commit +type configured not to request another release, so the version-control commit +does not cause an extra bump. + +GitHub marks workflow runs for a pull request opened with `GITHUB_TOKEN` as +approval-required. Approve those runs, review the pull request, and squash-merge +it normally through the protected branch. + +The merge triggers the publish job. Before any release side effect, it verifies: + +- current `main`; +- the release commit subject; +- the one-file diff; +- the package version; +- the absent tag; and +- Semantic Release's independently calculated version and commit. + +It then reruns the package checks and asks Semantic Release to: + +1. create the matching Git tag; +2. publish the public npm package with provenance; and +3. publish the GitHub release. + +The repository must enable **Allow GitHub Actions to create and approve pull +requests** under Actions workflow permissions. + +The workflow uses that capability only to create the version pull request. It +never approves or merges one, and no ruleset bypass actor or persistent release +credential is required. diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 0000000..bed9deb --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,555 @@ +# Observability + +[Documentation](index.md) · [API reference](api.md) + +Metrics are disabled unless a `DialCacheMetricsAdapter` is passed to the +constructor. `new DialCache()` does not import a metrics backend, register +collectors, or emit metrics. + +DialCache provides first-party adapters for Prometheus and Datadog. Both use +caller-created, caller-owned clients and preserve one backend-neutral set of +bounded labels. + +## Reading the signals + +Start with source load and caller latency, then explain changes with the cache +metrics. An earlier-layer hit stops traversal; a coalesced follower does not +repeat the leader's full read/miss trail. + +| Signal | Interpretation | +| --- | --- | +| Requests and misses by layer | Which layer actually serves or falls through | +| Miss reason | Absence, logical expiry, invalidation fencing, or an unclassified miss | +| Disabled reason | Intentional policy/ramp skips versus invalid configuration | +| Errors and fallback duration | Dependency failures and source cost, including recovered source failures | +| Shadow outcomes and value ages | Comparison verdicts, fill activity, drops, and detached failures | +| Recovery outcomes and value ages | How often an older snapshot serves during eligible source failures | +| Compression size, ratio, and duration | Prepared payload savings versus synchronous CPU cost | +| Future timestamp offset | Observed frames ahead of the reader clock; an incomplete clock-health signal | + +Durations and ages use seconds; sizes use bytes. Namespace, use case, and key +type should remain bounded application-defined labels. No metric includes cache +ids, arguments, payloads, or raw error text. + +## Miss reasons + +`miss()` receives `MissMetricLabels` with one required reason. Both bundled +backends emit the same bounded values: + +| `reason` | Meaning | +| --- | --- | +| `value_absent` | No retrievable value: never populated, physically expired, evicted, Redis nil, or tracked MGET wrong-type-as-nil. All local misses use this reason. | +| `expired` | A supported valid non-future Redis frame reached its logical fresh age, including retained stale candidates and frames beyond the recovery maximum. | +| `watermark_fenced` | A supported positive-timestamp tracked frame was rejected at or below a valid observed watermark, before deserialization. | +| `unclassified` | Other real misses, including unknown adapter results, malformed frames/metadata, invalid or future timestamps, and deserialization failures. | + +Read errors and timeouts are errors, not ordinary misses. The observed watermark +used for refill suppression is separate from the reason; a missing value can +carry a valid fence. + +## Prometheus + +Install `prom-client` separately: + +```bash +npm install prom-client@^15.1.3 +``` + +Create the registry your application owns, then pass an explicit adapter to +DialCache: + +```ts +import { Registry } from "prom-client"; +import { DialCache } from "dialcache"; +import { createPrometheusDialCacheMetrics } from "dialcache/prometheus"; + +const registry = new Registry(); + +const dialcache = new DialCache({ + namespace: "users-api", + metrics: createPrometheusDialCacheMetrics({ + registry, + prefix: "myapp_", + }), +}); + +app.get("/metrics", async (_req, res) => { + res.type(registry.contentType).send(await registry.metrics()); +}); +``` + +The adapter requires a caller-owned `Registry`. It never uses the global +default registry, and it does not clear or otherwise own the registry +lifecycle. `prefix` defaults to `""` and is concatenated literally with each +metric name; include any desired separator yourself. + +Multiple adapters with the same registry and prefix reuse existing collectors +when their type, help, labels, histogram buckets, and exemplar mode match. +Adapter construction fails before registering anything if a same-name +collector has an incompatible schema. Use a unique prefix or separate registry +to resolve a collision. DialCache's collectors do not enable exemplars, so an +exemplar-enabled collector with the same name is incompatible. + +### Histogram buckets + +Bucket boundaries are fixed; the adapter has no bucket customization option: + +| Metric family | Unit | Finite bucket boundaries | +| --- | --- | --- | +| All timers | Seconds | `0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10` | +| Serialized and stored sizes | Bytes | `100, 1000, 10000, 100000, 1000000, 10000000` | +| Compression ratio | Ratio | `0.05, 0.1, 0.2, 0.3, 0.5, 0.7, 0.9, 1` | +| Shadow and recovery value ages | Seconds | `1, 5, 15, 60, 300, 900, 3600, 10800, 43200, 86400, 259200, 604800` | +| Future timestamp offsets | Seconds | `0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 5, 15, 60, 300, 900, 3600, 10800, 43200` | + +### Prometheus metrics + +The names below exclude the optional caller-selected prefix: + +| Metric | Type | Labels | Description | +| --- | --- | --- | --- | +| `dialcache_request_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache-layer requests that reached an enabled layer | +| `dialcache_miss_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer`, `reason` | Cache misses, classified by one required bounded reason | +| `dialcache_disabled_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer`, `reason` | Cache skips (`context`, `policy_disabled`, `invalid_ttl`, `invalid_ramp`, `ramped_down`, `config_error`) | +| `dialcache_error_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer`, `error`, `in_fallback` | Cache/fallback errors and the bounded `tracked_ttl_clamped` configuration signal | +| `dialcache_invalidation_counter` | Counter | `cache_namespace`, `key_type`, `layer` | Invalidation calls for the layers touched | +| `dialcache_coalesced_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `scope` | Coalesced requests split by `request_local` or `process` scope | +| `dialcache_shadow_validation_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `outcome` | Sampled Redis shadow-job outcomes | +| `dialcache_shadow_value_age_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `outcome` | Age in seconds of the validated Redis value at shadow verdict time, recorded for `match` and `mismatch` | +| `dialcache_future_timestamp_offset_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Positive offset in seconds for a valid frame dated after the observing process clock | +| `dialcache_stale_recovery_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `outcome` | Classifier-authorized stale-recovery checks: `served`, `miss`, or `deserialization_error` | +| `dialcache_stale_recovery_value_age_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `outcome` | Actual return-time age in seconds of a retained value, recorded only for `served` | +| `dialcache_compression_counter` | Counter | `cache_namespace`, `use_case`, `key_type`, `layer`, `outcome` | Payload compression outcomes: writes record `compressed`, `below_threshold`, `not_smaller`, or `write_over_limit`; reads record `decompressed`, `fallback_raw`, or `read_over_limit` | +| `dialcache_get_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache get latency in seconds | +| `dialcache_fallback_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Elapsed time until the underlying function settles or timeout rejection is delivered | +| `dialcache_serialization_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Redis serializer dump/load latency | +| `dialcache_size_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Serialized Redis payload size in bytes, before compression | +| `dialcache_stored_size_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Prepared Redis payload size in bytes, after compression and escaping; before dispatch | +| `dialcache_compression_ratio_histogram` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Compressed-to-original payload size ratio for compressed writes | +| `dialcache_compression_timer` | Histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Payload compression and decompression latency in seconds | + +The disabled reasons are: + +- `context`; +- `policy_disabled`; +- `invalid_ttl`; +- `invalid_ramp`; +- `ramped_down`; and +- `config_error`. + +`policy_disabled` means that a process-local or remote layer has no effective +TTL after runtime overlays. This is an intentional policy result, including the +default when `defaultConfig` is omitted, rather than a configuration-loading +failure. + +Every metric includes `cache_namespace`, even disabled-context, +key-construction, coalescing, and invalidation paths that do not have a +constructed key. Its value is `DialCacheConfig.namespace`, which defaults to +`urn`. + +The `layer` label is: + +- `request_local`; +- `local`, meaning process-local; +- `remote`; +- `remote_shadow` for Redis reads, fills, serialization, compression, and + payload sizes performed by detached shadow jobs; or +- `noop` for disabled-context, key-construction, and config-provider failures + where no cache layer was reached. + +The bounded `scope` label on `dialcache_coalesced_counter` distinguishes +`request_local` from `process`. `scope="process"` coordinates calls only within +one `DialCache` instance; separate instances in the same process do not share +in-flight state. A use case with `coalesce: false` emits no coalesced counter; +each caller instead emits its own request, miss, duration, and error metrics. + +## Datadog + +Install `hot-shots` separately: + +```bash +npm install hot-shots@^17.0.0 +``` + +Create the DogStatsD client your application owns, then pass it to the Datadog +adapter: + +```ts +import StatsD from "hot-shots"; +import { DialCache } from "dialcache"; +import { createDatadogDialCacheMetrics } from "dialcache/datadog"; + +const dogStatsD = new StatsD({ + host: process.env.DD_AGENT_HOST ?? "127.0.0.1", + globalTags: { + service: "users-api", + env: process.env.DD_ENV ?? "development", + }, + errorHandler: (error) => + logger.warn("DogStatsD error", { error }), +}); + +const dialcache = new DialCache({ + namespace: "users-api", + metrics: createDatadogDialCacheMetrics({ + client: dogStatsD, + observationMetricType: "distribution", + namespace: "dialcache", + }), +}); + +function shutdown(): void { + // Close the client yourself once outstanding DialCache calls have settled. + // DialCache never flushes or closes it. + dogStatsD.close(); +} +``` + +`hot-shots` is the supported and tested client, but the adapter depends only on +the exported `DatadogDogStatsDClient` structural interface. Construction requires +all three methods, `increment`, `histogram`, and `distribution`, to be functions, +regardless of the selected observation mode. + +DialCache does not: + +- import or install `hot-shots`; +- create a client; +- flush buffers; +- close sockets; or +- otherwise own the client lifecycle. + +### Distribution or histogram + +`observationMetricType` is required. + +Choose `"distribution"` when latency and size percentiles must aggregate across +hosts. Enable the desired distribution percentiles and aggregations in +Datadog. + +Choose `"histogram"` when host-level histogram aggregation matches the existing +Datadog setup. The choice applies uniformly to every duration, size, ratio, age, and offset +observation emitted by the adapter. Both modes produce Datadog custom metrics. + +Metric volume depends on tag combinations and selected aggregations. Review +[Datadog's custom-metrics billing guidance](https://docs.datadoghq.com/account_management/billing/custom_metrics/) +before rollout. + +Do not send both observation types under the same metric namespace. When +changing types, use a new namespace during migration so one metric identity +never mixes histogram and distribution points. + +### Datadog namespaces + +`DatadogMetricsOptions.namespace` is the metric-name namespace and defaults to +`dialcache`. It is separate from `DialCacheConfig.namespace`, the logical cache +namespace emitted as the `cache_namespace` tag. + +The Datadog metric namespace must: + +- start with a letter; +- contain only letters, numbers, underscores, and dot-separated non-empty + segments; and +- produce final metric names no longer than 200 characters. + +The adapter rejects invalid namespaces and overlong final names instead of +relying on client-side normalization. + +A `hot-shots` `prefix` is applied after the adapter constructs the name. Include +that prefix when checking final length, and avoid accidentally combining it +with the adapter namespace. Client-level `globalTags` are appended by +`hot-shots`; the table below lists only tags added by DialCache. + +### Datadog metrics + +The adapter emits exact increments of `1` for counters and preserves seconds +and bytes without unit conversion: + +| Metric | Type | Tags | Description | +| --- | --- | --- | --- | +| `dialcache.request.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache-layer requests that reached an enabled layer | +| `dialcache.miss.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer`, `reason` | Cache misses, classified by one required bounded reason | +| `dialcache.disabled.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer`, `reason` | Cache skips by bounded reason | +| `dialcache.error.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer`, `error`, `in_fallback` | Cache/fallback errors and the bounded `tracked_ttl_clamped` configuration signal | +| `dialcache.invalidation.count` | Count | `cache_namespace`, `key_type`, `layer` | Invalidation calls for the layers touched | +| `dialcache.coalesced.count` | Count | `cache_namespace`, `use_case`, `key_type`, `scope` | Coalesced requests by sharing scope | +| `dialcache.shadow.count` | Count | `cache_namespace`, `use_case`, `key_type`, `outcome` | Sampled Redis shadow-job outcomes | +| `dialcache.shadow.value_age` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `outcome` | Age in seconds of the validated Redis value at shadow verdict time, recorded for `match` and `mismatch` | +| `dialcache.future_timestamp_offset` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Positive offset in seconds for a valid frame dated after the observing process clock | +| `dialcache.stale_recovery.count` | Count | `cache_namespace`, `use_case`, `key_type`, `outcome` | Classifier-authorized stale-recovery checks: `served`, `miss`, or `deserialization_error` | +| `dialcache.stale_recovery.value_age` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `outcome` | Actual return-time age in seconds of a retained value, recorded only for `served` | +| `dialcache.compression.count` | Count | `cache_namespace`, `use_case`, `key_type`, `layer`, `outcome` | Payload compression outcomes: writes record `compressed`, `below_threshold`, `not_smaller`, or `write_over_limit`; reads record `decompressed`, `fallback_raw`, or `read_over_limit` | +| `dialcache.get.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Cache get latency in seconds | +| `dialcache.fallback.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Elapsed time until the underlying function settles or timeout rejection is delivered | +| `dialcache.serialization.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Redis serializer dump/load latency in seconds | +| `dialcache.serialization.size` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Serialized Redis payload size in bytes, before compression | +| `dialcache.stored.size` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Prepared Redis payload size in bytes, after compression and escaping; before dispatch | +| `dialcache.compression.ratio` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer` | Compressed-to-original payload size ratio for compressed writes | +| `dialcache.compression.duration` | Distribution or histogram | `cache_namespace`, `use_case`, `key_type`, `layer`, `operation` | Payload compression and decompression latency in seconds | + +Synchronous client throws are isolated when DialCache invokes the adapter. +DialCache also consumes thenables returned by adapter hooks, but this adapter does +not forward every client return value: only `shadowValidation` and +`staleRecovery` return the counter call's result. A custom DogStatsD client must +handle its own asynchronous delivery failures, including rejected promises. +Direct adapter calls do not have DialCache's observer guard. Configure client error +handling and shutdown as part of application ownership. + +## Shadow outcomes + +`dialcache_shadow_validation_counter` reports one terminal outcome for each +admitted or explicitly dropped shadow job. Datadog exposes the same bounded +outcomes through `dialcache.shadow.count`: + +| `outcome` | Meaning | +| --- | --- | +| `match` | The cached and source values matched. | +| `mismatch` | They differed, and a confirmation read found the original Redis payload unchanged. | +| `superseded` | They differed, but the Redis payload changed or disappeared before confirmation. | +| `filled` | A clean shadow miss was populated successfully. | +| `fill_fenced` | A timestamp check skipped tracked fill dispatch against the watermark observed in the initial read. | +| `fill_error` | Preparing the payload (serialization or compression) or writing a clean-miss fill failed. | +| `redis_error` | The initial detached Redis read failed. | +| `source_error` | The source-of-truth read failed. | +| `deserialization_error` | The retained Redis payload could not be deserialized for comparison. | +| `comparison_error` | The comparator threw or did not return a synchronous boolean. | +| `confirmation_error` | The confirmation Redis read failed. | +| `timeout` | The shadow deadline expired. | +| `dropped` | Per-key deduplication or the instance flight cap rejected the job. | + +The outcome counter deliberately has no `layer` or cache-id label. Operational +Redis metrics produced inside the same job use `layer="remote_shadow"`, which +keeps detached work separate from caller-serving `layer="remote"` telemetry. +See [Shadow validation](shadow-validation.md) for the read, confirmation, fill, +and deadline semantics behind these outcomes. + +## Stale recovery outcomes + +`dialcache_stale_recovery_counter` and `dialcache.stale_recovery.count` record one +outcome for each classifier-authorized recovery check: + +| Outcome | Meaning | +| --- | --- | +| `served` | A retained candidate passed return-time age checks and supplied the result | +| `miss` | No candidate remained eligible | +| `deserialization_error` | Decoding the retained candidate failed | + +Only `served` emits the corresponding value-age observation. Recovery adds no +second Redis request, miss, or read-duration sequence. The source failure and +fallback duration remain visible even when a snapshot serves. Classifier denial +emits no recovery outcome. See [Stale-on-error](stale-on-error.md). + +## Value ages and clock offsets + +Shadow value age is reported only for `match` and confirmed `mismatch`, at verdict +time. Recovery age is reported only for `served`, at return time. Both use the +observing application's epoch clock minus the frame's writer timestamp. +Shadow age uses the original `C0` timestamp, even if confirmation finds identical +payload bytes with a newer timestamp. It clamps to zero after clock rollback and +skips nonfinite age observations. + +The future-offset histogram records a positive offset for valid decoded frames +ahead of the observer clock. Ordinary and initial-shadow reads then miss; +confirmation can retain the frame only for comparison. Invalid timestamps never +enter histogram sums. Repeated reads can observe the same future frame. + +For direct adapter callers, Prometheus additionally discards nonfinite or +nonpositive `observeFutureTimestampOffset` values. Datadog forwards those +observations without that extra guard; normal DialCache calls supply positive finite +offsets to both. + +Use external fleet clock monitoring as well: workload observations cannot detect +every skew direction or determine which node is wrong. Its dedicated histogram +buckets cover millisecond-scale through multi-hour faults. + +## Compression metrics + +Compression telemetry is bounded and uses `layer="remote"` for caller-serving +work or `layer="remote_shadow"` for detached shadow work. + +Write-side outcomes are: + +- `compressed`: zstd plus its envelope was smaller and selected for the + prepared Redis payload; +- `below_threshold`: the serialized payload did not reach the configured + threshold; this check runs before the size ceiling; +- `not_smaller`: compression ran, but the marked result was not smaller than + the raw stored form; and +- `write_over_limit`: the serialized value reached the threshold but exceeded + the 512 MiB decompression ceiling and was kept raw for the attempted write. + This is a capacity signal, not an error. + +Read-side outcomes are: + +- `decompressed`: a marked zstd payload was restored; +- `fallback_raw`: native zstd rejected a marked payload for a reason other than + the output limit, so it was passed unchanged to the serializer; and +- `read_over_limit`: decompression would exceed the 512 MiB ceiling, so the + stored bytes were passed unchanged to the serializer. Treat this as a + corruption or integrity signal. + +Raw reads do not emit a compression outcome. With `compression: false`, new +writes are still escaped when necessary but emit no compression outcome; reads +continue to report marked values because disabling writes does not disable +decoding. + +`dialcache_size_histogram` measures serializer output before compression and is +the distribution to use when selecting `thresholdBytes`. +`dialcache_stored_size_histogram` measures the prepared bytes after compression +or binary-envelope escaping. DialCache records it before final shadow-deadline/fence +gates and before calling the Redis client, so it is not proof that a write was +dispatched or succeeded. The ratio histogram is emitted when compression +selects the smaller representation, at the same pre-write stage. + +Compression duration is observed when zstd runs and produces either +`compressed` or `not_smaller`; decompression duration is observed for each +marked payload that produces a read-side outcome. + +A zstd exception while preparing a write records `error="compression"` and +the cache write fails open. Decompression rejects neither the cache call nor +the observer path directly: rejected marked bytes reach the configured +serializer. If `load` rejects, it records `serialization_load`. An ordinary fresh +read becomes a refreshable miss; shadow comparison reports +`deserialization_error` without repair, and retained recovery preserves the +original source rejection. See [Serialization](redis.md#serialization). + +zstd work is synchronous on the Node.js event loop. Use the duration, ratio, +and pre/post-size series together when changing the threshold or level; a good +space ratio does not make an event-loop stall acceptable. See +[Redis payload compression](redis.md#compression) for the envelope, limits, +and mixed-version rollout contract. + +## Confirmed mismatch warnings + +Shadow metrics remain bounded and contain no cache ids or values. A use case can +separately set `shadow.logMismatches: true` to emit one warning after a terminal +`mismatch` is confirmed. Logging is default-off, does not replace the outcome +metric, and does not activate shadow work without the `shadowValidation` hook. + +The warning contains stable metadata, the logical cache key capped at 2 KiB, +and independently generated native-JSON strings for the cached and source +comparator inputs capped at 8 KiB each. Those fields are value-bearing, and +truncation is not redaction. + +See +[Confirmed mismatch logging](shadow-validation.md#confirmed-mismatch-logging) +for confirmation semantics, exact fields, JSON behavior, operational limits, +and data-handling considerations. + +## Error categories + +The `error` label reports the operation that failed instead of copying the +thrown value's class or `Error.name`: + +| `error` | Meaning | +| --- | --- | +| `key_construction` | The cache-key selector or `DialCacheKey` construction failed | +| `config_resolution` | Runtime or layer configuration validation or resolution failed | +| `cache_read` | A process-local read or non-timeout remote read failed | +| `cache_read_timeout` | A remote read exceeded its effective DialCache deadline | +| `cache_write` | A process-local or remote cache write failed | +| `tracked_ttl_clamped` | A dispatched tracked write requested retention above the one-hour physical cap | +| `serialization_load` | Deserializing a Redis payload failed | +| `serialization_dump` | Serializing a value for Redis failed | +| `compression` | zstd compression failed while preparing a Redis write | +| `invalidation` | Writing an invalidation watermark failed | +| `fallback` | The source loader failed or exceeded its DialCache deadline | +| `unknown` | Reserved for a future failure site that cannot be classified otherwise | + +DialCache defines these values itself, so they are identical for every adapter. + +A valid `invalidateRemote()` call without a configured Redis client is still an +invalidation attempt: DialCache records `dialcache_invalidation_counter` (or +`dialcache.invalidation.count`), logs the failure, records +`error="invalidation"`, and rejects with the original focused `TypeError`. +Invalid `futureBufferMs` input is rejected before these observers run. + +Caller-serving remote-read timeouts use `layer="remote"` and +`in_fallback="false"`. They are +errors rather than misses, and the remote get-duration observation includes +the wait. Coalesced followers do not multiply the timeout error. Deadline +details remain out of labels and are available on the logged +`RedisReadTimeoutError`. + +Detached initial and confirmation reads attribute their operational metrics to +`remote_shadow`. Read failures, including read timeouts, can report `redis_error` +or `confirmation_error` without a matching error log. The overall shadow deadline +instead reports `timeout`; see [Shadow outcomes](#shadow-outcomes). + +Raw thrown values, error names, messages, cache ids, arguments, and Redis keys +are never included in labels. When DialCache logs a cache-plumbing failure, the +raw details remain available through the configured logger; not every metric +error or shadow outcome has a matching log entry. + +The explicitly opted-in confirmed-mismatch warning is a separate value-bearing +log and does not alter the metric schema. + +`in_fallback` remains the explicit distinction between cache plumbing and +application fallback failures. + +## Custom adapters + +Implement `DialCacheMetricsAdapter` and pass it through +`new DialCache({ metrics })` for another telemetry backend. + +| Hook | Required | Value | +| --- | --- | --- | +| `request(labels)` | yes | One active cache-layer lookup. | +| `miss(labels)` | yes | One cache miss with required bounded `reason` (`MissMetricLabels`). | +| `disabled(labels)` | yes | One skipped layer or no-layer invocation with a bounded `reason`. | +| `error(labels)` | yes | One bounded failure site with `inFallback`. | +| `invalidation(labels)` | yes | One explicit remote invalidation call. | +| `coalesced(labels)` | no | One follower that joined request-local or process-scoped work. | +| `shadowValidation(labels)` | no | One terminal sampled-shadow outcome. This hook must be implemented for shadow jobs to execute. | +| `observeShadowValueAge(labels, seconds)` | no | Age for shadow match/mismatch verdicts; does not gate admission. | +| `observeFutureTimestampOffset(labels, seconds)` | no | Positive decoded-frame clock offset; does not change read decisions. | +| `staleRecovery(labels)` | no | One authorized recovery outcome; omission does not disable recovery. | +| `observeStaleRecoveryValueAge(labels, seconds)` | no | Return-time age for served recovery only. | +| `compression(labels)` | no | One bounded compression or decompression outcome. | +| `observeGet(labels, seconds)` | yes | Cache-read duration in seconds. | +| `observeFallback(labels, seconds)` | yes | Fallback duration in seconds. | +| `observeSerialization(labels, seconds)` | yes | Serializer dump/load duration in seconds. | +| `observeSize(labels, bytes)` | yes | Serialized remote payload size in bytes, before compression. | +| `observeStoredSize(labels, bytes)` | no | Prepared remote payload size in bytes, after compression and escaping; emitted before client dispatch. | +| `observeCompressionRatio(labels, ratio)` | no | Compressed-to-original size ratio when compression selects the prepared representation. | +| `observeCompression(labels, seconds)` | no | Compression or decompression duration with `operation="compress"` or `operation="decompress"`. | + +The root package exports `DialCacheMetricsAdapter` and every associated label, +reason, error-kind, layer, scope, and shadow-outcome type, including +`ShadowValidationMetricLabels`, `ShadowValidationOutcome`, +`CompressionMetricLabels`, `CompressionOperationMetricLabels`, and +`CompressionOutcome`, `CacheMissReason`, `MissMetricLabels`, +`StaleRecoveryMetricLabels`, and `StaleRecoveryOutcome`. +`shadowValidation` remains optional so existing custom adapters keep +compiling, but DialCache does not admit shadow work when the configured +adapter omits it. The Prometheus and Datadog adapters implement the hook. + +The compression hooks are also optional for source compatibility with existing +custom adapters. They control observation only: omitting them does not disable +compression or decompression. The Prometheus and Datadog adapters implement +all four hooks. + +Metrics and logger methods are typed `void` and invoked as fire-and-forget +observers. DialCache also defensively consumes, but never awaits, a thenable +returned at runtime. + +Synchronous throws and rejections of those returned thenables are isolated so +telemetry cannot change cache correctness, fallback results, or shadow outcomes. +This guard applies when DialCache invokes the observer, not to direct calls to an +adapter or to asynchronous work whose promise the hook does not return. + +A custom adapter may buffer or transmit asynchronously, but it owns delivery, +flushing, resources, and shutdown after the call returns. Keep +application-owned namespace, use-case, and key-type labels stable and +low-cardinality, and preserve the seconds and bytes units shown above. + +Every backend-neutral label object exposes the logical namespace as camel-case +`cacheNamespace`. Map it to the backend's `cache_namespace` label or tag. This +field is present even when no key or cache layer was reached. + +Omit `metrics` to disable metrics entirely. Because shadow jobs require an +observable terminal outcome, omitting metrics also disables shadow execution +even when a key policy sets `shadow.ramp` or enables +`shadow.logMismatches`. + +See [Metric migrations](upgrading.md#metric-migrations) when upgrading collectors, +miss queries, or exhaustive outcome mappings. diff --git a/docs/redis.md b/docs/redis.md new file mode 100644 index 0000000..d835062 --- /dev/null +++ b/docs/redis.md @@ -0,0 +1,440 @@ +# Redis and Valkey + +[Documentation](index.md) · [API reference](api.md) + +The remote layer shares cached reads across application instances. DialCache +owns cache behavior; your application owns the connected Redis client, its +resource budgets, and shutdown. You can use either bundled adapter or implement +`DialCacheRedisClient` for another client. + +## Install a client + +```bash +# node-redis +npm install redis@~4.7.1 + +# or Valkey GLIDE +npm install @valkey/valkey-glide@^2.0.0 +``` + +Configuring a client makes the remote layer available. Each operation still +needs an effective remote TTL, an admitted serving ramp, and an enabled scope. +See [Configuration](configuration.md#runtime-config-and-ramp-controls). + +## node-redis + +Create and connect the client before wrapping it: + +```ts +import { createClient } from "redis"; +import { DialCache } from "dialcache"; +import { createNodeRedisDialCacheClient } from "dialcache/node-redis"; + +const redisClient = createClient({ + url: process.env.REDIS_URL ?? "redis://127.0.0.1:6379", + disableOfflineQueue: true, + commandsQueueMaxLength: 1_000, + socket: { connectTimeout: 2_000 }, +}); +redisClient.on("error", (error) => console.error("Redis client error", error)); +await redisClient.connect(); + +const dialcache = new DialCache({ + namespace: "users-api", + redis: { + client: createNodeRedisDialCacheClient(redisClient), + readTimeoutMs: 100, // Optional; the library default is 50 ms. + }, +}); +``` + +The helper accepts the promise-based node-redis client, including its Cluster +client. It requires binary command replies and does not support `legacyMode`. +It manages invalidation script dispatch internally; no caller-side script +registration is needed. Tracked Cluster reads route to the slot primary. + +These connection options are examples, not a complete operation budget. Bound +queueing, retries, reconnects, and command settlement for your application. + +## Valkey GLIDE + +Pass the direct standalone or Cluster client and the same module namespace that +created it: + +```ts +import * as valkeyGlide from "@valkey/valkey-glide"; +import { DialCache } from "dialcache"; +import { createValkeyGlideDialCacheClient } from "dialcache/valkey-glide"; + +const glideClient = await valkeyGlide.GlideClient.createClient({ + addresses: [{ host: "127.0.0.1", port: 6379 }], + requestTimeout: 2_000, + advancedConfiguration: { connectionTimeout: 2_000 }, +}); + +const dialcache = new DialCache({ + namespace: "users-api", + redis: { + client: createValkeyGlideDialCacheClient(glideClient, valkeyGlide), + }, +}); +``` + +The adapter uses `GlideClient` and `GlideClusterClient` identities, `Batch`, and +`Decoder.Bytes` from that namespace. It does not import its own GLIDE runtime. +It rejects ambiguous forwarding wrappers or clients from another module +instance because their topology cannot be established safely. + +In Cluster mode, tracked `MGET` uses an explicit primary route. In standalone +mode, a one-command non-atomic batch selects the primary even when the client +has a replica-read preference. `MGET` itself supplies the atomic snapshot; +there is no transaction and caller-owned `WATCH` state is not consumed. +`ClusterBatch` is not required. + +## Bundled Redis operations + +### Reads + +| Mode | Command | Meaning | +| --- | --- | --- | +| Untracked | `GET valueKey` | Decode one frame; ordinary client read routing applies | +| Tracked | `MGET valueKey watermarkKey` | Decode one authoritative value/watermark snapshot from the primary | + +Each semantic read is one top-level command and one round trip. The payload +travels to Node before frame validation, watermark fencing, age checks, and +deserialization. An invalidated large value therefore still consumes transfer +bandwidth until it expires or is replaced. + +The adapter returns either `DecodedRedisFrame { payload, createdAtMs }` or +`RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }`. DialCache then checks +logical age against the operation's effective TTL. Future-dated or invalid +frames miss before deserialization. With recovery enabled, the initial read +may retain expired bytes while the source runs; see [Stale-on-error](stale-on-error.md). + +Native wrong-type behavior is preserved. Untracked `GET` can reject with +`WRONGTYPE`. `MGET` represents a wrong-type member as `nil`: a wrong-type value +is absent, and a wrong-type watermark acts like a missing watermark. Explicit +invalidation repairs a wrong-type watermark. + +### Writes + +Every dispatched write uses the same complete-frame operation: + +```text +SET valueKey frame PX cacheTtlMs +``` + +The frame carries the writer application's epoch timestamp. There is no value +write script, placeholder, transaction, or watermark mutation. Same-key writes +are last-writer-wins; tracked **reads** enforce invalidation. + +Physical TTL is normally the remote TTL. With stale-on-error it is the maximum +recovery age instead. DialCache separately caps tracked values at one hour and emits +`tracked_ttl_clamped` for each dispatched write whose requested TTL exceeds the +cap. Untracked values retain their configured TTL, up to 365 days. + +A tracked miss can carry a valid observed watermark. DialCache skips a replacement +already known to be fenced, checking once before payload preparation and again +immediately before dispatch. An admitted write uses the final timestamp exactly. +Misses without that fence let the adapter sample `Date.now()` before dispatch. +No path adds a fence-check command. See [Conditional refills](invalidation.md#conditional-refills). + +### Invalidation retries and ambiguity + +Invalidation is the only Lua operation. Both adapters dispatch `EVALSHA` and +retry a rejected dispatch once using `EVAL` with the source and the same +invalidation timestamp. The script only advances the watermark and widens its +retention, so duplicate execution after an ambiguous response is harmless. +Invalid reply-domain values are errors and are not retried. + +If the retry also fails, GLIDE attaches the original error as `cause` when +possible. Node-redis surfaces the retry rejection unmodified because some +client errors are shared objects. A healed retry looks like success to DialCache +metrics; server command statistics expose unexpected `EVAL` activity. + +A rejected or timed-out dispatched mutation does not prove that Redis remained +unchanged. Native writes do not implement compare-and-set or deduplicate retries +performed by an application or client. + +### Redis compatibility and ACLs + +The integration suite covers Redis 6.2, Valkey 8, and Redis Cluster. The bundled +operations require `GET`, `MGET`, and `SET`, plus `EVALSHA` and `EVAL` for +invalidation. If commands called inside scripts are checked separately, allow +`GET`, `SET`, and `PTTL` for the invalidation script. + +DialCache does not issue `TIME`, `MULTI`, `EXEC`, `WATCH`, `UNLINK`, or +`SCRIPT LOAD`. Tracked invalidation also requires the +[clock and watermark durability contract](invalidation.md#application-clock-contract). + +## Remote-read deadlines and async liveness + +The read deadline is resolved per invocation: + +```text +runtime remoteReadTimeoutMs → defaultConfig.remoteReadTimeoutMs + → redis.readTimeoutMs → 50 ms +``` + +Values are positive safe integers through `2_147_483_647` milliseconds. A +remote read cannot be configured as unbounded. + +When the wait expires, DialCache aborts `RedisReadContext.signal`, logs a +`RedisReadTimeoutError`, records `cache_read_timeout`, and invokes the source. +Late read outcomes are consumed and ignored. A read error or timeout does not +trigger a Redis refill or stale recovery. An active untracked local layer may +store the successful source result; a tracked path suppresses that publication. + +The deadline covers the semantic read, not configuration, deserialization, +source work, writes, or invalidation. Coalesced followers share the leader's +remaining budget. The source deadline begins separately when fallback starts. +Recovery reuses the initial snapshot and creates no second read budget. + +Node-redis passes a cooperative signal to native reads where supported. GLIDE +uses its configured native request budget. DialCache still bounds its own wait; +neither mechanism promises server-side cancellation or bounds all underlying +client work. See [Coalescing and liveness](coalescing.md). + +## Lifecycle ownership + +Before shutdown, stop new work and await public cache-operation and invalidation +promises, including loaders that may later write Redis. A read that DialCache +stopped waiting for can still be active in the client. Use client-native +controls to drain or terminate that work before closing the connection. + +Close node-redis with `await redisClient.quit()` or close GLIDE with +`glideClient.close()` after draining application work. The adapters own no +additional resources. DialCache has no close or drain method. + +Detached shadow work has no drain handle and does not keep a process alive. +Already-started Redis, source, serializer, or telemetry work may outlive its +shadow deadline. Account for that work when closing its dependencies; shutdown +may lose a best-effort shadow outcome even when a fill was dispatched. + +## Serialization + +Redis serialization precedence is operation `serializer`, then instance +`redis.serializer`, then `JsonSerializer`. In-memory caches retain native +references and do not serialize them. + +### Default JSON behavior + +`JsonSerializer` uses native JSON semantics and supports top-level `undefined` +through a private marker. Redis hits containing `null`, `false`, `0`, `""`, or +`undefined` are still hits. + +JSON does not preserve every JavaScript value. Nested object `undefined` can +be omitted; undefined array elements and non-finite numbers can become `null`. +Dates lose their type, maps and sets lose their structure, and bigint or cycles +can fail serialization. Reference sharing and prototypes are not preserved. + +Direct `JsonSerializer.dump(value)` calls return `Promise`; +`load(string | Buffer)` returns `Promise`, decoding Buffer input as UTF-8. +Malformed JSON rejects with `SyntaxError`. After handling top-level `undefined`, +`dump` rejects with `Error` when `JSON.stringify` returns undefined, as it does +for ordinary top-level functions or symbols. Bigint and cycles normally reject +with native `TypeError`. The generic `T` is a caller assertion, not schema +validation. + +A fresh frame whose `load` fails becomes a refreshable miss: DialCache records +`serialization_load`, calls the source, and attempts replacement. The default +codec validates JSON syntax, not your application schema. For incompatible +value changes, use a validating serializer or change an identity dimension such +as `useCase`. Mixed incompatible readers can repeatedly replace one another's +values until a deployment converges. + +A non-null shadow payload that fails deserialization is observation-only and +is never repaired. A retained recovery candidate that fails deserialization +preserves the original source rejection. + +### Typed serializer requirement + +The public types require a `Serializer` when the result is not statically +JSON-compatible, even if the current policy uses only local memory. Runtime +policy can activate Redis later. + +```ts +import { CacheLayer, DialCache, DialCacheKeyConfig, type Serializer } from "dialcache"; + +const dialcache = new DialCache(); +const dateSerializer: Serializer = { + dump: (value) => value.toISOString(), + load: (value) => new Date(Buffer.isBuffer(value) ? value.toString("utf8") : value), +}; + +const getUpdatedAt = dialcache.cached( + async (userId: string) => new Date("2026-01-01T00:00:00Z"), + { + keyType: "user_id", + useCase: "GetUpdatedAt", + cacheKey: (userId) => userId, + serializer: dateSerializer, + defaultConfig: new DialCacheKeyConfig({ ttlSec: { [CacheLayer.LOCAL]: 60 } }), + }, +); +``` + +`dump` produces `string | Buffer`; `load` receives that payload and returns the +value. Both may be asynchronous. Give them finite application-owned budgets. +A global `Serializer` cannot satisfy a particular operation's typed +requirement. + +The guard rejects known incompatible shapes including `Date`, `Map`, `Set`, +bigint, functions, symbols, Buffers, typed arrays, method-bearing classes, +required nested undefined, `unknown`, and `any`. It is conservative and cannot +prove runtime data has no cycles, non-finite numbers, getters, or `toJSON` hooks. +The structural check stops at eight property/array-element steps, so deeply +nested or recursive JSON types can also require a serializer. When ordinary JSON +correctly round-trips those values, supply an explicit `new JsonSerializer()`. +Supplying a typed serializer is a trusted assertion, not an extra round-trip +validation performed by DialCache. + +## Compression + +Compression runs between the serializer and Redis adapter. It is on by default: + +```ts +const dialcache = new DialCache({ + redis: { + client: dialCacheRedisClient, + compression: { thresholdBytes: 4_096, level: 3 }, + }, +}); +``` + +`thresholdBytes` is a positive safe integer; `level` is an integer from 1 to 22. +Use `compression: false` to disable compression of new writes. Invalid options +throw at instance construction. This is instance policy, not a runtime overlay. + +Payloads meeting the threshold are compressed with zstd only when the stored +form is smaller. Reads always interpret the compression envelope, including +when new-write compression is disabled. Binary payloads beginning with an +envelope marker are escaped even in that disabled mode. + +Compression and decompression execute synchronously on the event loop. Higher +levels trade CPU and latency for size reduction. Use the size, ratio, and +duration [metrics](observability.md#compression-metrics) to evaluate that tradeoff. + +Decompressed output is capped at 512 MiB. Writes above the same ceiling remain +raw. With compression enabled, `below_threshold` takes precedence; +`write_over_limit` records an oversized payload that also reaches the threshold. +When native zstd rejects marked input, DialCache hands the original bytes to the +serializer (`fallback_raw`, or `read_over_limit` when the output limit caused +rejection). Native decoder acceptance is not corruption +validation: it can accept empty or truncated bodies as empty output and ignore +trailing bytes. A custom serializer must validate the application value it +receives, whether decompressed or raw. A compression exception fails the write +open. + +See [Upgrading](upgrading.md#compression-and-value-schemas) for legacy binary +collisions and readers-first deployment of the envelope. + +## Custom-client contract + +Implement the three methods of `DialCacheRedisClient` and pass the object in +`redis.client`: + +| Method | Return | Required semantics | +| --- | --- | --- | +| `read(request, context?)` | `RedisReadResult` or Promise | Decode the frame; atomically apply the primary watermark for tracked reads | +| `write(request)` | `void` or Promise | Write one complete frame with a finite TTL; honor an explicit `createdAtMs` exactly | +| `invalidate(request)` | `void` or Promise | Advance the watermark monotonically using the client timestamp and preserve required retention | + +`read` receives `valueKey` and, only for tracked reads, `watermarkKey`. +`RedisReadContext` supplies `timeoutMs` and an `AbortSignal` for cooperative +cancellation. Returned payload bytes transfer to DialCache and must remain stable +while retained for shadow or recovery; return a dedicated Buffer if the client +pools or reuses response storage. + +Use `decodeRedisReadResult` or `decodeTrackedRedisReadResult` from +`dialcache/redis-protocol`, or preserve their behavior exactly. Attach an +`observedWatermarkMs` only from the same valid tracked snapshot. Cause and fence +are independent: an absent value can carry a fence. DialCache validates the fence, +discards it for untracked keys, and maps unknown results/reasons to +`unclassified` misses. A `watermark_fenced` claim also becomes `unclassified` if +its observation is absent, invalid, or discarded for an untracked key. +Normalizing an unknown reason does not discard an otherwise valid tracked +observation. Use `isRedisReadMiss(result)` instead of a null comparison. + +`write` receives `valueKey`, `value`, `cacheTtlMs`, and optional `createdAtMs`. +If present, that timestamp is the final value DialCache admitted against an observed +fence: honor it exactly. Otherwise sample real client time before dispatch. +A constant timestamp is incompatible with logical age enforcement. + +`invalidate` receives `watermarkKey` and `futureBufferMs`. Supply a valid +`Date.now()` sample to `INVALIDATE_CACHE_SCRIPT` and reuse it across retries of +that logical operation. The public script takes `[futureBufferMs, +invalidatedAtMs]` as its arguments and returns integer `1`. + +Bound connection, queue, dispatch, retry, reconnect, and response lifetimes. +DialCache bounds read waits but does not own the client's resource lifecycle or add +write/invalidation deadlines. + +## Advanced wire protocol + +The protocol subpath exports: + +| Export | Contract | +| --- | --- | +| `encodeRedisFrame(payload, createdAtMs)` | Copy a `string \| Buffer` into a new version-1 Buffer; timestamp must be a nonnegative safe-integer number or it throws `RangeError` | +| `decodeRedisReadResult(raw)` | Decode one `Buffer \| null` reply into a frame or classified miss | +| `decodeTrackedRedisReadResult(raw, rawWatermark)` | Decode an atomic pair of `Buffer \| null` replies and preserve a valid observed fence on misses | +| `isRedisReadMiss(result)` | Test for a non-null object with `kind === "miss"`; does not validate its reason or watermark | +| `INVALIDATE_CACHE_SCRIPT` | Lua source; one watermark key and arguments `[futureBufferMs, invalidatedAtMs]`; returns numeric `1` | +| `validateRedisSetReply(reply)` | Accept exactly `"OK"` or a Buffer decoding to `"OK"`; return void, otherwise throw `DialCacheRedisProtocolError` | +| `validateRedisScriptInvalidationReply(reply)` | Accept and return numeric `1` only; otherwise throw `DialCacheRedisProtocolError` | +| `ceilSupportedCacheTtlMs(value)` | Accept a number whose ceiling is in `1..31_536_000_000` ms; return that ceiling, otherwise throw `RangeError` | + +`CacheMissReason`, `DecodedRedisFrame`, `RedisReadMiss`, and `RedisReadResult` +are also exported as types from this subpath. + +A stored value has a ten-byte header followed by payload: + +| Bytes | Meaning | +| --- | --- | +| `0` | Version `1` | +| `1..8` | Big-endian unsigned 64-bit application epoch timestamp; writers must stay within the JavaScript safe-integer domain | +| `9` | Encoding: `0` UTF-8 string, `1` binary | +| `10..` | Payload, possibly a compression envelope | + +### Read decoding and validation order + +Both decoders reject invalid raw reply types, including JavaScript strings, with +`DialCacheRedisPayloadError`. The tracked decoder validates both reply types +before classifying either value. Binary payloads are views into the input frame; +copy them if the backing Buffer may be mutated or reused. + +After reply validation, a null value is `value_absent`; a short frame or unknown +version is `unclassified`. Either tracked miss can preserve a valid paired +watermark. Watermark text must contain decimal digits only and represent a value +from zero through `Number.MAX_SAFE_INTEGER`. Zero and leading zeros are accepted; +signs, whitespace, fractions, and exponent notation are not. A missing watermark +uses a zero baseline and does not attach `observedWatermarkMs`. + +For a supported tracked frame, malformed present watermark text produces +`unclassified`. A zero frame timestamp also produces `unclassified`. A positive +timestamp at or below a valid watermark produces `watermark_fenced`. These +checks precede payload decoding, so even an unknown encoding can be hidden by +one of these misses. An otherwise eligible frame with an unsupported encoding +throws `DialCacheRedisPayloadEncodingError`. + +The untracked decoder accepts a zero timestamp. Both decoders convert the raw +uint64 to a JavaScript number without rejecting unsafe values, which can lose +precision. DialCache separately rejects unsafe timestamps and applies its +[age and clock rules](observability.md#value-ages-and-clock-offsets); the codecs +alone do not establish that a decoded frame is fresh or safe to serve. + +### Invalidation script and payload envelope + +The script requires digit-only decimal arguments in the nonnegative safe-integer +domain. The buffer must be at most `31_536_000_000` ms, and timestamp plus buffer +must remain safe. Invalid arguments return Redis errors before any mutation. +Its repair and retention rules are covered under +[Watermark lifetime](invalidation.md#watermark-lifetime). + +The binary payload envelope uses `0x00` to escape raw marker-prefixed bytes, +`0x01` for compressed string output, and `0x02` for compressed binary output. +Adapters treat the payload as opaque: DialCache interprets this envelope above them. +The physical value key appends `:dialcache-frame-v1` to the logical key. + +Read [Upgrading](upgrading.md) before migrating an older adapter or namespace. diff --git a/docs/shadow-validation.md b/docs/shadow-validation.md new file mode 100644 index 0000000..bfcb653 --- /dev/null +++ b/docs/shadow-validation.md @@ -0,0 +1,292 @@ +# Shadow validation + +[Documentation](index.md) · [Observability](observability.md#shadow-outcomes) + +Shadow validation checks cache coherence by comparing sampled Redis values +with the source, without serving a shadow result to the caller. + +It can also fill Redis misses while remote serving is ramped down, giving you +a way to warm a cache before enabling it. + +Shadow work is opt-in, sampled by key, and detached. Configure its own ramp and +a metrics adapter with `shadowValidation` support. The caller does not await +shadow reads, comparison, confirmation, or fills. + +## At a glance + +| Caller path | Additional shadow work | Caller receives | +| --- | --- | --- | +| Served Redis hit | Compare that frame with a detached source read | The ordinary Redis hit | +| Remote serving ramped down | Read Redis and compare with, or fill from, the caller's accepted source result | The source result | +| Earlier local hit, disabled scope, or missing/invalid remote policy | None | The normal caller result | +| Normally enabled remote miss | No duplicate shadow fill | The ordinary fallback/refill result | + +Tracked and untracked keys are eligible. Tracked reads enforce watermarks; +untracked reads and fills retain TTL-based last-writer-wins behavior. + +## Configure a shadow cohort + +Assuming `redisClient` is a semantic adapter, `metrics` supports shadow outcomes, +and `db` is your application data source: + +```ts +import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; + +const dialcache = new DialCache({ + namespace: "users-api", + redis: { client: redisClient }, + metrics, + shadowMaxInFlight: 4, +}); + +const getUser = dialcache.cached( + (userId: string) => db.fetchUser(userId), + { + keyType: "user_id", + useCase: "GetUser", + cacheKey: (userId) => userId, + trackForInvalidation: true, + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.REMOTE]: 300 }, + ramp: { [CacheLayer.REMOTE]: 0 }, + shadow: { ramp: 5 }, + }), + }, +); +``` + +Inside an enabled scope, callers use the source. Eligible keys in the independent +5% shadow cohort exercise Redis in the background. A semantic miss authorizes a +fill. Both bundled telemetry adapters supply the required outcome hook. + +Keep earlier layers off when the rollout needs to exercise Redis: a request-local +or process-local hit ends traversal before shadow admission. + +## Eligibility + +A job needs all of the following: + +- A live enabled scope and normal traversal reaching the Redis layer. +- A configured Redis client and a valid remote TTL/policy. +- A served Redis hit, or remote serving disabled specifically by `ramped_down`. +- A positive shadow ramp whose stable exact-key cohort selects this key. +- A configured `shadowValidation` metrics hook and available capacity. + +Missing policy, invalid policy, provider failure, or an omitted metrics hook +cannot start a shadow-only path. `logMismatches` does not enable one either. + +Serving and shadow cohorts are independent. Equal partial percentages do not +select the same keys. A partial shadow ramp therefore does not guarantee every +key admitted by a later serving ramp was validated or warmed. Even at 100%, +capacity, lifetime, and earlier-hit gates still apply. + +## Serving-hit and ramped-down paths + +On a served hit, DialCache retains the serialized frame that supplied the caller as +`C0`, returns the normal decoded result, then starts detached source work. +That loader runs with caching disabled for this `DialCache` instance, so nested +readers through the instance bypass caching. The comparator later receives an +independently deserialized cached value. + +On a ramped-down path, the caller starts and awaits its normal source loader +exactly once. Detached work reads `C0` and shares that caller-accepted source +result, `S`; it does not launch another loader. A source rejection or timeout +never becomes an accepted fill value. This foreground loader retains the caller's +context; it is not rerun inside the served-hit branch's disabled scope. + +## The `C0` / `S` / `C1` algorithm + +`C0` is the initial cached observation, `S` the source result, and `C1` an optional +confirmation read after a disagreement: + +```text +C0 miss ───────────────→ accepted S → eligible fill +C0 present → compare S + ├─ equal ────────→ match + └─ different → C1 + ├─ absent / changed → superseded + └─ same payload ───→ mismatch +``` + +### Clean-miss fill + +A semantic `C0` miss can be filled from `S`. For a tracked miss carrying a valid +`observedWatermarkMs`, DialCache checks the application timestamp before serialization +and again immediately before dispatch. A timestamp at or below that observation +skips the fill and emits `fill_fenced`. Preflight suppression also avoids +serialization, compression, and frame allocation. + +An admitted fill uses the final timestamp exactly and sends one complete-frame +`SET`. Without an observed fence, the adapter samples its normal dispatch-time +timestamp. The physical TTL follows ordinary write policy, including `M` when +stale recovery is active and the separate one-hour tracked cap. + +A semantic miss includes absent, unsupported, logically expired, future-dated, +and watermark-fenced frames. It does not include a present payload that fails +`load`: that is `deserialization_error`, with no repair. Miss reason and observed +fence are independent. A bundled `expired` result is classified after decoding +and does not carry a watermark fence; it follows normal refill behavior. + +`filled` means the client accepted the write before the deadline. `fill_error` +means payload preparation or writing failed. Neither proves what remains in +Redis afterward. `fill_fenced` means this job skipped dispatch against its +observed fence; another operation may still change Redis. + +### Comparison and confirmation + +For present `C0`, DialCache deserializes an independent cached snapshot after the +source result is available. Equal values report `match`. A disagreement triggers +one direct Redis `C1` read in the same tracked or untracked mode. + +If `C1` is a miss or its payload differs byte-for-byte, the result is +`superseded`. If the original payload remains, the result is `mismatch`. +Confirmation failure or read timeout produces `confirmation_error`. + +Confirmation bypasses logical age solely for supersession comparison. A +future-dated `C1` records its offset but can be retained for comparing bytes; it +cannot serve the caller. DialCache does not deserialize `C1`, compare it with `S`, or +chase another version. Strings compare exactly, Buffers by bytes, and mixed +string/Buffer payloads by UTF-8 bytes. + +Any non-null `C0` is observation-only. Shadow validation never repairs a mismatch +or overwrites an undecodable present value. + +## Comparison semantics + +The default comparator is Node's `util.isDeepStrictEqual`. Object property +insertion order does not matter; values, array order, prototypes, constructors, +and collection contents remain part of strict equality. + +For domain-specific equality, provide a typed operation option: + +```ts +const getVersionedUser = dialcache.cached(fetchVersionedUser, { + keyType: "user_id", + useCase: "GetVersionedUser", + cacheKey: (userId) => userId, + shadowComparator: (cached, source) => + cached.id === source.id && cached.version === source.version, + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.REMOTE]: 300 }, + shadow: { ramp: 5 }, + }), +}); +``` + +The comparator must synchronously return a boolean and be deterministic, +side-effect-free, non-mutating, and bounded. Throws and non-boolean results +produce `comparison_error`. Accidental thenables are consumed while retaining +the shadow slot until settlement, subject to the job deadline. + +Comparison uses the decoded cache value and raw source value intentionally: it +can reveal lossy serialization. Ignore differences only when they are acceptable +application semantics. + +## Data ownership and custom integrations + +A sampled served hit runs the serializer's `load` again in detached work. It +must be repeatable, non-mutating, and return independently usable values. +Returned Redis payload bytes must remain stable after the adapter read settles. + +DialCache retains the original `cached()` argument references or `getOrLoad()` +closure. It cannot generically clone source-selection state. Keep arguments, +captured state, and accepted source values immutable, or snapshot before the +invocation, so detached work still refers to the key that was selected. + +The caller's decoded hit object is not reused for comparison. No shadow copy, +hash, or deep comparison is added to the served-hit request path. + +## Capacity, deadlines, and detachment + +`shadowMaxInFlight` defaults to `1` per instance and must be a positive safe +integer. Same-key duplicates and jobs beyond capacity report `dropped`; there +is no queue or fleet-wide cap. Confirmation and fill stay in the original slot. + +Each job has one monotonic budget across `C0`, the source, serialization, +comparison, `C1`, and fill. A finite `fallbackTimeoutMs` is reused as that budget. +When fallback is unbounded (`null`), shadow still uses 60 seconds. Each Redis +read separately has the effective remote-read deadline. + +Served-hit timing starts with the detached callback. Ramped-down timing starts +immediately before the caller's source invocation, including its synchronous +prefix. On abandonment, DialCache releases retained `C0` and stops later phases. +Already-started shadow-owned work keeps capacity until it settles, even after +its DialCache deadline. A shared caller-owned loader can continue without +holding the shadow slot after timeout. + +Scheduling and shadow deadline timers are unreferenced. They do not keep an +otherwise idle process alive. Detachment uses the Node event loop, not a worker; +synchronous loader, serializer, comparator, or logger work still consumes it. +Underlying I/O is not generally cancellable. Give dependencies finite native +budgets, including commands that may settle after a DialCache timeout. + +## Consistency modes and race boundaries + +The initial read and later fill are not atomic. An allowed fill can overwrite a +concurrent writer; it is not write-if-still-missing. For tracked keys, the next +primary read applies the current watermark, including one advanced after `C0`. +Size the [future buffer](invalidation.md#choosing-futurebufferms) for the complete +source, serialization, dispatch, and clock-skew window. + +An untracked fill has no watermark fence. A detached older source value can be +written after a mutation and remain until expiry. Untracked reads also have no +shadow-specific primary guarantee. + +A confirmed mismatch means the original payload survived a second Redis read +after disagreement with the source. It is useful evidence, not an atomic +cross-system snapshot or a promise that the mismatch still exists. + +### Command amplification + +| Selected path | Extra work | +| --- | --- | +| Served Redis hit | One source read; one `C1` only after disagreement | +| Ramped-down Redis hit | One `C0`, reuse caller source, one `C1` only after disagreement | +| Ramped-down Redis miss | One `C0`, reuse caller source, at most one fill `SET` | + +A fenced fill adds no write; preflight fencing also avoids payload preparation. +No path adds a separate fence read. Coalescing suppresses duplicate caller paths; +shadow deduplication drops jobs but does not coalesce source calls when all +serving layers are disabled. + +## Confirmed mismatch logging + +`shadow.logMismatches: true` adds one warning only after confirmed `mismatch`. +It is default-off and independent of sampling. The logger receives the message +`"DialCache shadow validation mismatch"` and an object with `cacheNamespace`, +`useCase`, `keyType`, `outcome: "mismatch"`, and three bounded detail fields: + +| Field | Content | UTF-8 cap | +| --- | --- | --- | +| `cacheKey` | Logical URN, not the physical Redis key | 2 KiB | +| `cachedValueJson` | Native JSON of the decoded cached snapshot | 8 KiB | +| `sourceValueJson` | Native JSON of the raw source value | 8 KiB | + +Clipped fields end with `...[truncated]` inside the cap. JSON failure or undefined +output makes that side `null`; the other side is still attempted. Logging does +not compute a diff or reuse the Redis serializer. If detail construction fails, +DialCache still attempts the warning with the four metadata fields; all three detail +fields can be absent. + +Truncation is not redaction. Keys and values can include sensitive application +data. Native JSON may execute getters or `toJSON`, and the byte caps apply only +after stringification; they do not bound traversal time or intermediate +allocation. Enable diagnostics only for suitable data and your application's +logging policy. Logger failures are isolated from cache behavior. + +## Metrics and shutdown + +[Shadow outcomes](observability.md#shadow-outcomes) distinguish matches, +confirmed mismatches, superseded observations, fills, fences, errors, timeouts, +and drops. Only `match` and `mismatch` report observed value age at verdict time. + +Detached Redis and serializer work uses `layer="remote_shadow"`. The original +served read keeps `remote`; a ramped-down caller keeps its ordinary disabled +observation. Shadow reads time Redis settlement, while ordinary remote get +latency includes fresh deserialization. Optional age metrics do not gate jobs; +only the `shadowValidation` hook does. + +Turn down both serving and shadow ramps to stop new Redis work, or return +`DialCacheKeyConfig.disabled()`. Already-admitted work is not cancelled. There +is no public shadow drain handle; follow [client lifecycle](redis.md#lifecycle-ownership) +when shutting down and treat outcomes during teardown as best-effort. diff --git a/docs/stale-on-error.md b/docs/stale-on-error.md new file mode 100644 index 0000000..aa27b2d --- /dev/null +++ b/docs/stale-on-error.md @@ -0,0 +1,178 @@ +# Stale-on-error + +[Documentation](index.md) · [Redis and Valkey](redis.md) + +Stale-on-error lets selected source failures fall back to an older Redis value. +It is off by default. When enabled, DialCache retains a raw snapshot from the +initial Redis read, tries the source, and can return that snapshot if the error +policy allows it and the value is still within its maximum age. + +It performs **no second Redis read**. That keeps recovery available if Redis +becomes unavailable during the source call, but also means later invalidation, +deletion, refresh, or expiry cannot revoke the retained snapshot. + +## Configure the ages + +Use a remote TTL for ordinary freshness and a larger maximum age for recovery: + +```ts +import { CacheLayer, DialCache, DialCacheKeyConfig } from "dialcache"; + +const dialcache = new DialCache({ + redis: { client: dialCacheRedisClient }, +}); + +const getUser = dialcache.cached( + (userId: string) => db.fetchUser(userId), + { + keyType: "user_id", + useCase: "GetUserWithRecovery", + cacheKey: (userId) => userId, + fallbackTimeoutMs: 2_000, + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.REMOTE]: 60 }, + staleOnErrorMaxAgeSec: 300, + }), + }, +); +``` + +This assumes a configured semantic Redis client and application `db`. Inside +`enable()`, a frame younger than 60 seconds can serve normally. From 60 seconds +until strictly before 300 seconds, it can serve only after an authorized source +rejection. The built-in classifier accepts `FallbackTimeoutError` only. + +### Fresh age and maximum age + +| Symbol | Configuration | Meaning | +| --- | --- | --- | +| `F` | `ttlSec.remote` | Exclusive fresh age ceiling for ordinary Redis reads | +| `M` | `staleOnErrorMaxAgeSec` | Exclusive recovery age ceiling, measured from the same frame timestamp | + +`M` is total age, not extra time after `F`. Positive configuration must satisfy +`0 < F < M <= 31_536_000` seconds. Both ages must be safe-integer numbers. +Omission leaves recovery off, or inherits it in a sparse runtime overlay. +Explicit `0` disables inherited recovery. + +Invalid static defaults throw. Invalid runtime recovery policy records +`config_resolution`, disables only recovery, and preserves valid ordinary Redis +serving. A remote ramp of zero bypasses the caller-serving Redis path, including +recovery. + +## Follow one invocation + +The initial read uses one invocation snapshot of `F`, `M`, and the read deadline. +DialCache classifies the returned frame before normal deserialization: + +| Age when the initial read settles | Behavior | +| --- | --- | +| `0 <= age < F` | Deserialize and serve as an ordinary hit | +| `F <= age < M` | Record an `expired` miss, retain raw bytes, and call the source | +| `age >= M` | Record an `expired` miss and call the source with no candidate | +| Future timestamp, invalid frame, absent value, or watermark fence | Miss with no candidate | + +A read error or timeout never enters recovery. A fresh frame that failed ordinary +deserialization is not reconsidered as a stale candidate. + +If the source succeeds, normal refill rules apply; the retained candidate is not +deserialized. If the source rejects, DialCache calls the selected classifier. An +accepted rejection authorizes a recovery check, even when no candidate exists. + +With a candidate, DialCache checks `0 <= age < M`, deserializes/decompresses lazily, +and checks the age again before returning. Crossing `M` during asynchronous +`load` prevents serving. A missing, expired, or undecodable candidate preserves +the **exact original source rejection**. + +## Choose which errors permit recovery + +The synchronous `shouldAttemptStaleRecovery(error)` classifier has this +precedence: + +```text +operation option → instance option → error instanceof FallbackTimeoutError +``` + +An override replaces the lower policy. Include the timeout case yourself if an +application classifier should preserve it: + +```ts +import { FallbackTimeoutError } from "dialcache"; + +const cache = new DialCache({ + redis: { client: dialCacheRedisClient }, + shouldAttemptStaleRecovery: (error) => + error instanceof FallbackTimeoutError || isRetriableDatabaseError(error), +}); +``` + +`isRetriableDatabaseError` is your application's narrow classification of +transient infrastructure failures. Deny authoritative outcomes such as +permission or entitlement failures, revocation, deletion/not-found, validation, +and programmer errors. Use an operation override for data requiring a stricter +policy; `() => false` denies recovery for that operation. + +The built-in policy also accepts a `FallbackTimeoutError` propagated from a +nested/source operation. It is not limited to the current wrapper's own timer. + +A supplied policy must be a function. Runtime throws, thenables, and non-boolean +returns deny recovery, log the classifier failure, and preserve the original +source rejection. Rejecting thenables are consumed. Calls outside `enable()` +never invoke the classifier. `cached()` captures it at registration; +`getOrLoad()` captures it per invocation. + +## Snapshot and invalidation boundaries + +For a tracked key, the initial primary `MGET` applies the watermark that existed +with the value at read time. An invalidation completed before that read fences +the candidate. An invalidation completed afterward does **not** revoke bytes +already retained in the process. + +The same snapshot behavior applies to concurrent refresh, deletion, expiry, and +eviction for tracked and untracked keys. A retained frame can still recover +until its return-time age reaches `M`, even after the Redis key disappears. +Opting tracked data into recovery therefore relaxes its usual freshness behavior +on authorized source-error paths. Leave recovery off when that is unsuitable. + +A recovered value is not written to Redis, published process-locally, or used +to schedule shadow validation. If request-local caching is active, it is +memoized only in the current outer enabled scope. + +## Retention, clocks, and memory + +Writers request physical TTL `M` rather than `F`. Ordinary readers still enforce +logical `F`. Tracked values retain their separate one-hour physical cap: a +configured `M` above one hour remains the logical ceiling, but Redis may expire +the frame before a read can acquire it. Untracked retention is not capped at one +hour. Raising `M` does not resurrect or extend an existing Redis key. + +Ages measure time since frame creation on the writer's application clock, not +the underlying data's own last-update time. Clock skew affects the comparisons; +see the [application clock contract](invalidation.md#application-clock-contract). + +Earlier local layers retain their own lifetimes. A nearly expired Redis hit can +warm process-local storage with a full local TTL. For each invocation to make a +new remote frame-age check, disable both earlier layers and set `coalesce: false`. +Otherwise, a follower can reuse the leader's earlier age check and snapshot. + +Coalesced callers share one initial read, raw candidate, source attempt, and +recovery decision. With `coalesce: false`, each caller retains its own bytes and +runs independently. Across distinct in-flight keys, delayed source calls can +retain substantial raw payload memory until they settle. Use application +admission controls and finite source budgets. + +## Observability + +Each classifier-authorized check emits one optional `staleRecovery` outcome: +`served`, `miss`, or `deserialization_error`. Only `served` additionally reports +value age, measured at actual return time. Classifier denial emits no recovery +outcome. + +Recovery adds no ordinary Redis request, miss, or read-duration sequence; the +initial command is the single caller-serving read. Lazy deserialization and +compression observations still report their work. Source fallback duration and +error metrics still record the rejection even when recovery serves. + +The optional metrics hooks do not gate recovery. See +[Observability](observability.md#stale-recovery-outcomes) for backend names. +Before enabling longer retention in an existing fleet, follow the +[readers-first upgrade](upgrading.md#stale-retention-and-downgrades). diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..0a3882f --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,175 @@ +# Upgrading + +[Documentation](index.md) · [Maintainer guide](maintainers.md) + +Check the installed version and the behavior being enabled before sharing a +Redis namespace across releases. A compatible frame layout does not by itself +make two implementations safe to run together. + +This guide covers the transitions relevant to the implementation on `main`. +Use the [release notes](https://github.com/lan17/DialCache/releases) and matching +tagged source for the versions in your fleet. + +## Tracked protocol cutover + +The current protocol writes one complete client-stamped frame with native `SET`. +Older tracked writers used a placeholder and stamp script; those writers and +invalidators must not coexist with the new protocol in an active namespace. + +Before enabling the new protocol: + +1. Stop and drain every old writer and invalidator, including in-flight source + fallbacks, shadow work, client queues, and operations that can still write. +2. Purge every tracked value, including complete frames and placeholders, and + every watermark in the affected namespace. +3. Start the new fleet with its clock, buffer, and watermark-preservation + requirements in place. + +On a dedicated Redis deployment, an authorized full namespace purge is the +simplest option. Untracked complete frames may be retained. This is an external +operational procedure; DialCache does not implement a deployment gate or purge +API. + +Alternatively, leave affected traffic disabled until all old tracked values +and watermarks expire. That is safe only when their maximum remaining lifetimes +are bounded, no watermark is persistent, and the wait covers both old value +retention and future-buffer-derived watermark retention. + +Changing the namespace creates a cold boundary, but old and new namespaces also +have separate watermarks. Overlapping fleets need a coordinated invalidation +strategy; simply changing the string does not preserve mutable-data freshness. + +The current cap of one hour for tracked values and watermark floor of two hours +are part of this protocol relationship. Raising the cap or shrinking the floor +requires another coordinated transition because old markers cannot be extended +by deploying new constants alone. + +## Removed configuration fields + +Remove these legacy properties entirely; construction rejects their own-property +presence even when the value is `undefined`: + +| Older field | Replacement | +| --- | --- | +| `DialCacheConfig.urnPrefix`, `DialCacheKeyInit.urnPrefix` | `namespace` | +| `DialCacheConfig.rampSampler` | Built-in deterministic key-and-layer ramp assignment; no injected sampler | +| `DialCacheKeyConfig.shadowRamp` | `shadow.ramp` | +| `RedisConfig.keyPrefix` | The instance's `namespace` | +| `RedisConfig.createClient` | Create and connect the client in the application, then pass the semantic `client` | +| `RedisConfig.watermarkTtlSec` | Remove it; DialCache derives watermark retention | + +See [Runtime validation](configuration.md#validation-and-snapshots) for how an +obsolete field in a provider result differs from invalid static configuration. + +## Custom Redis adapters + +Migrate against the current [semantic interface](redis.md#custom-client-contract): + +| Older surface | Current contract | +| --- | --- | +| Tracked placeholder and stamp helpers | `encodeRedisFrame` plus one complete-frame `SET` | +| Write request with `watermarkKey`; boolean outcome | `RedisWriteRequest` has no watermark field; `write()` returns void | +| `dialcacheRedisScripts`, `DialCacheNodeRedisScripts` | Removed; node-redis manages invalidation dispatch internally | +| `DialCacheRedisPlaceholderLostError` | Removed with the placeholder write path | +| `ClusterBatch` required by GLIDE runtime | No longer required; direct Cluster MGET routing | +| `null` or `RedisWatermarkMiss` typed misses | `RedisReadMiss { kind: "miss", reason, observedWatermarkMs? }` | +| `decodeRedisFrame`, `decodeTrackedRedisFrame` | `decodeRedisReadResult`, `decodeTrackedRedisReadResult` | + +Use `isRedisReadMiss` from the root or protocol subpath to discriminate read +results. Runtime unknown results, including legacy null, become unclassified +misses and refill normally, but lose reason precision and observed-fence refill +suppression. + +If an adapter returns a trustworthy `observedWatermarkMs`, it must honor a +supplied `RedisWriteRequest.createdAtMs` exactly. Direct callers may omit that +field; the adapter then samples real client time immediately before dispatch. +All decoded frames need their real writer timestamp. Constants that older +untracked adapters treated as informational fail current logical-age checks. + +Invalidation is the only Lua script. It receives `[futureBufferMs, +invalidatedAtMs]`; reuse the second argument across retries of one logical +operation. See [Targeted invalidation](invalidation.md) for timing and retention. + +## Stale retention and downgrades + +Stale-on-error keeps the same frame keys and layout but can retain values +physically through `M` while ordinary reads enforce the shorter logical age +`F`. + +Deploy readers that enforce `F` everywhere **before** enabling writers with +longer `M` retention. A pre-feature reader that trusts physical expiry can +otherwise serve retained data normally between `F` and `M`. + +Once a key is written with physical `M`, do not reintroduce older readers until +all such keys expire or are explicitly removed. Turning recovery off on current +readers is safe because they still enforce `F`; it does not remove the older +readers' downgrade barrier. + +A larger maximum age does not extend an existing Redis key. Tracked values +retain their one-hour physical cap, and a snapshot already retained by a process +can remain eligible within `M` after Redis expiry or invalidation. + +## Compression and value schemas + +Current readers always decode the compression envelope, even when new-write +compression is disabled. For string/JSON values, a readers-first deployment with +`compression: false`, followed by enabling compression after convergence, avoids +old readers encountering compressed values. + +A reader without envelope support may fail `load` and refill a compressed value. +During an overlapping deployment, expect serialization failures and refill churn +unless the rollout prevents those reads. A permissive binary decoder can +misinterpret foreign bytes instead of rejecting them. + +Legacy binary output can collide with envelope markers: + +- A legacy `0x01`/`0x02` prefix whose remaining bytes are accepted by native zstd + can be decoded as compressed data. Acceptance does not guarantee a complete + valid stream; empty/truncated bodies or trailing bytes may also be accepted. +- A legacy payload beginning with `0x00` followed by `0x00`, `0x01`, or `0x02` + can lose its first byte to the escape rule. +- New writers escape colliding raw binary prefixes even with compression off. + +Version an identity dimension, such as the use case, when a custom binary +serializer can produce these collisions. A compression-off first phase alone +does not protect an old permissive reader from escaped binary values. + +For application schema changes, native JSON validates syntax only. Keep values +compatible, use a serializer that validates and rejects old shapes, or move to +a new identity. Mutually incompatible validating readers can continually replace +each other's values while they overlap. + +## Metric migrations + +Miss metrics carry a required bounded `reason`: `value_absent`, `expired`, +`watermark_fenced`, or `unclassified`. Old Prometheus collectors with four miss +labels cannot share the same in-process registry/prefix with the current +five-label collector. The future-offset histogram also uses dedicated clock-skew +buckets; an incompatible same-name collector fails adapter construction before +partial registration. Use a separate registry or prefix where needed. + +During a mixed-fleet rollout, aggregate away `reason` when comparing total misses +with total requests. For example: + +```text +sum by (cache_namespace, use_case, key_type, layer) ( + rate(dialcache_miss_counter[5m]) +) +/ +sum by (cache_namespace, use_case, key_type, layer) ( + rate(dialcache_request_counter[5m]) +) +``` + +Reason dashboards should group explicitly by `reason`. In Datadog, the extra +tag increases miss-series combinations by up to four per prior tuple; account +for overlapping old/new tag sets and the selected metric aggregations. + +Update exhaustive public-union mappings: `tracked_ttl_clamped` is a +`MetricErrorKind`; `fill_blocked` is removed and `fill_fenced` is a +`ShadowValidationOutcome`. Recovery has `served`, `miss`, and +`deserialization_error` outcomes. Custom `miss` handlers now receive +`MissMetricLabels`; broader handlers may ignore the additional reason, while +exact label mappings and direct calls need to include it. + +See [Observability](observability.md) for current names, units, and hooks. diff --git a/package.json b/package.json index 4c85fbc..d3eb7a8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dialcache", "version": "0.23.2", - "description": "Fine-grained TypeScript caching with explicit local and Redis controls.", + "description": "A TypeScript library that organizes caching into use cases, with runtime control and observability for each one.", "license": "MIT", "sideEffects": false, "type": "module", @@ -81,6 +81,9 @@ "benchmark:stale-on-error": "pnpm build && node scripts/benchmark-stale-on-error.mjs", "build": "tsup src/index.ts src/datadog.ts src/node-redis.ts src/prometheus.ts src/redis-protocol.ts src/valkey-glide.ts --format esm,cjs --dts --clean", "check": "pnpm typecheck && pnpm test && pnpm build && pnpm test:package", + "docs:dev": "vitepress dev docs", + "docs:build": "vitepress build docs", + "docs:preview": "vitepress preview docs", "typecheck": "tsc --noEmit", "test": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", @@ -90,8 +93,9 @@ "packageManager": "pnpm@10.33.0", "pnpm": { "overrides": { - "brace-expansion@<=5.0.7": "5.0.8", - "esbuild@<0.28.1": "^0.28.1" + "brace-expansion@<5.0.9": "5.0.9", + "esbuild@<0.28.1": "^0.28.1", + "vitepress@1.6.4>vite": "^6.4.3" } }, "engines": { @@ -110,6 +114,7 @@ "testcontainers": "^12.0.4", "tsup": "^8.5.1", "typescript": "^5.9.3", + "vitepress": "1.6.4", "vitest": "^4.0.14" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8228b78..2a909dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,8 +5,9 @@ settings: excludeLinksFromLockfile: false overrides: - brace-expansion@<=5.0.7: 5.0.8 + brace-expansion@<5.0.9: 5.0.9 esbuild@<0.28.1: ^0.28.1 + vitepress@1.6.4>vite: ^6.4.3 importers: @@ -43,12 +44,91 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + vitepress: + specifier: 1.6.4 + version: 1.6.4(@algolia/client-search@5.57.0)(@types/node@24.13.3)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(typescript@5.9.3)(yaml@2.9.0) vitest: specifier: ^4.0.14 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(yaml@2.9.0)) packages: + '@algolia/abtesting@1.23.0': + resolution: {integrity: sha512-j45MBISstltys9QyQ4xf6quRiN1g7vMuwQL9VM4dx8YuRZvCQ173b9royZAx6iAbRX3IB1VnG1z//NuwyQ8jpQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.57.0': + resolution: {integrity: sha512-JVFFujiZUCguk5tz3LZr4fTQxqpIrj4/Jw3SI7kMljSqtfLxYn/s/TWH0J2s4iNfsDpxPhgFGMotCpmDI4kZ8w==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.57.0': + resolution: {integrity: sha512-6KqECK4ED3JJQEoDrQWnGPQzElA828xAD4qK5ceawNNyP/LcSvzAoLHjFkoTPksZ/kxj6VUtCRH+IHZesLltng==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.57.0': + resolution: {integrity: sha512-uqpGF3oXYsoCbQq5d7BzNrNTfIfuvJyGP1CKvSW27T9boUg7KOwyxsAw1AX0a3jSW2HrYEJ/NN+Z4MiGivbpeQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.57.0': + resolution: {integrity: sha512-u5NboJVJXDEFplvNnqqX4CxkXPYysjJRj47hOSh9329H8kG5gFLKJBIiS5utMQ+GZm8xQl3Te7NInDk6elEADQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.57.0': + resolution: {integrity: sha512-uzc0b2LmHAK9/QID4xeo35OG84AkZl4YewkCqawqAOGLjT2eZpM/OZx45ESygMHG30Ws+ZTSdluPtMJcUnrbWQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.57.0': + resolution: {integrity: sha512-dIAhnM6ue/ssa5PjgNfu4g8A4yTojl9ZOUzZU3wIaIKRerL2R/3Emuf9n/D6ICXXP167KC6XCeC7nliSw7cuSw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.57.0': + resolution: {integrity: sha512-2TTPTTKSJmCptvhCm4Xf3bBYMqZni+Pgc2hVdqc4l9wsBpSJNVTVIKpnd10OubUgkGcmppVDj1XQqYaf6EnPSQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.57.0': + resolution: {integrity: sha512-W4JseHKt+pzOxlFV+T3MWEG0h4Z2Se5zjoXUD0ewlw8aOWMG/yjRdopUdLQsXULepB/My2tDuZjkwk2sMfsUrQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.57.0': + resolution: {integrity: sha512-BrxJVE0/eLinEPICCD7BKN/2xnt0nkjge70u8zzE2ISP3fuB3tjLgcwpanUycvlHBFLI4gK0l5ol54p6IYuR/Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.57.0': + resolution: {integrity: sha512-Gc29jkeiLKlVfHvyrIgyUHHE+aYTdXEeLfK42rjr5/1TTVsYwUJz0XkvoIBIqfMjcDg6gXeHb1jTUZ0H+SYIlQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.57.0': + resolution: {integrity: sha512-PIPnPN7MP3fp2VAi01BVXhCWmD366ZB2Hkq5TlYKtThd4KxUtMmaaNDpFgVCTXtSIqWVZLJntOHRvxg/sIPd8Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.57.0': + resolution: {integrity: sha512-AX3RlOudXMdTwtwUqdAf5hAVLvXfOZZH1FZh6ALDdrhVLT0TtAIe48N6nYcWcTnwoTxK/wDIQqZ19IMjK8zJAA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.57.0': + resolution: {integrity: sha512-cWZc1dKb7wy9/wPpwMtL1y89gK2G7y2A47Coa7zwf1ydtIeJm4+S+XxoQ2b/ZRiQnrC1YHavLjYUPDCdnT7Khg==} + engines: {node: '>= 14.0.0'} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -62,10 +142,19 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} @@ -73,6 +162,29 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -252,6 +364,12 @@ packages: engines: {node: '>=6'} hasBin: true + '@iconify-json/simple-icons@1.2.94': + resolution: {integrity: sha512-l8UWzVxKaqZd9ABsE/M/9p6NyGkQnmCnOoZyhQmjlXCtY5PuL2rcWxOFk2l9pk7ux3ERMPkTLE4jl6kQpTkwxA==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -585,6 +703,30 @@ packages: cpu: [x64] os: [win32] + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -606,6 +748,21 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.2.0': + resolution: {integrity: sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} @@ -621,6 +778,15 @@ packages: '@types/ssh2@1.15.5': resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + '@valkey/valkey-glide-darwin-arm64@2.0.0': resolution: {integrity: sha512-kiVne6nFqB/NetXarsmuuYyVB0fRf7fxfrLHW/cTh5u2eNCiti1BQqdIBPAW0q0PHsDIOvTB64dEjZnWQ8DjUQ==} cpu: [arm64] @@ -659,6 +825,13 @@ packages: resolution: {integrity: sha512-nJCeRCXgqb7fMEu2dmrdbqOAr/OVpkx2u3dfr+eJuv0zSb/vcanaIH1VZUJAEFMHfvP7WSUfsI6rxZVjJ+/xxQ==} engines: {node: '>=16'} + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + '@vitest/coverage-v8@4.1.10': resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} peerDependencies: @@ -697,6 +870,92 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vue/compiler-core@3.5.42': + resolution: {integrity: sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==} + + '@vue/compiler-dom@3.5.42': + resolution: {integrity: sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==} + + '@vue/compiler-sfc@3.5.42': + resolution: {integrity: sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==} + + '@vue/compiler-ssr@3.5.42': + resolution: {integrity: sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/reactivity@3.5.42': + resolution: {integrity: sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==} + + '@vue/runtime-core@3.5.42': + resolution: {integrity: sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==} + + '@vue/runtime-dom@3.5.42': + resolution: {integrity: sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==} + + '@vue/server-renderer@3.5.42': + resolution: {integrity: sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==} + + '@vue/shared@3.5.42': + resolution: {integrity: sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -706,6 +965,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + algoliasearch@5.57.0: + resolution: {integrity: sha512-HpND7MBGctOAkd1GoQoDZCGoCpqNTS5NG1LuhElFet3RdLJkwnyTYZXZhXwtpAQPrI36fqQ3eT6KQrdKDTKu3A==} + engines: {node: '>= 14.0.0'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -810,11 +1073,14 @@ packages: bintrees@1.0.2: resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - brace-expansion@5.0.8: - resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} buffer-crc32@1.0.0: @@ -845,10 +1111,19 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -871,6 +1146,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -889,6 +1167,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + copy-anything@4.1.0: + resolution: {integrity: sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q==} + engines: {node: '>=18'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -909,6 +1191,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -918,10 +1203,17 @@ packages: supports-color: optional: true + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + docker-compose@1.4.2: resolution: {integrity: sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==} engines: {node: '>= 6.0.0'} @@ -937,6 +1229,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -946,6 +1241,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + es-module-lexer@2.3.0: resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} @@ -958,6 +1257,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -994,6 +1296,9 @@ packages: fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -1030,6 +1335,15 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hot-shots@17.0.0: resolution: {integrity: sha512-d3URpxEO5b0HQfZsrU2qRJ+cUPr9WBrsgHCVnD1bRdFI7KIZokNVI8n1uthsl4WB2l50WWS9vTlwJSOF/6I12w==} engines: {node: '>=18.0.0'} @@ -1037,6 +1351,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1194,6 +1511,27 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + minimatch@5.1.9: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} @@ -1206,6 +1544,12 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -1246,6 +1590,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -1260,6 +1607,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1300,6 +1650,14 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + preact@10.29.8: + resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -1318,6 +1676,9 @@ packages: resolution: {integrity: sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==} engines: {node: '>=18'} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + protobufjs@7.6.5: resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} @@ -1346,6 +1707,15 @@ packages: redis@4.7.1: resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -1358,6 +1728,9 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rolldown@1.1.5: resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1377,6 +1750,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -1390,6 +1766,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1408,6 +1787,13 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + split-ca@1.0.1: resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} @@ -1441,6 +1827,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1454,10 +1843,17 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + tar-fs@2.1.5: resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} @@ -1516,6 +1912,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -1562,6 +1961,21 @@ packages: resolution: {integrity: sha512-YQ3WlbqjYMmNpdvDH64jAgLjxuAR9+649calDWhbshYaeQGO2bR4nI94ORJmwI3J9YhoKQnpyGOK+0zlWS5N5Q==} engines: {node: '>=22.19.0'} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unix-dgram@2.0.7: resolution: {integrity: sha512-pWaQorcdxEUBFIKjCqqIlQaOoNVmchyoaNAJ/1LwyyfK2XSxcBhgJNiSE8ZRhR0xkNGyk4xInt1G03QPoKXY5A==} engines: {node: '>=0.10.48'} @@ -1569,6 +1983,52 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vite@8.1.4: resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1612,6 +2072,18 @@ packages: yaml: optional: true + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + vitest@4.1.10: resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1653,6 +2125,14 @@ packages: jsdom: optional: true + vue@3.5.42: + resolution: {integrity: sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1698,8 +2178,123 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: + '@algolia/abtesting@1.23.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0) + '@algolia/client-search': 5.57.0 + algoliasearch: 5.57.0 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)': + dependencies: + '@algolia/client-search': 5.57.0 + algoliasearch: 5.57.0 + + '@algolia/client-abtesting@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/client-analytics@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/client-common@5.57.0': {} + + '@algolia/client-insights@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/client-personalization@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/client-query-suggestions@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/client-search@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/ingestion@1.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/monitoring@1.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/recommend@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + + '@algolia/requester-browser-xhr@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + + '@algolia/requester-fetch@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + + '@algolia/requester-node-http@5.57.0': + dependencies: + '@algolia/client-common': 5.57.0 + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -1708,15 +2303,49 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@balena/dockerignore@1.0.2': {} '@bcoe/v8-coverage@1.0.2': {} + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.57.0)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.57.0)(search-insights@2.17.3) + preact: 10.29.8 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - preact-render-to-string + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.57.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.57.0)(algoliasearch@5.57.0) + '@docsearch/css': 3.8.2 + algoliasearch: 5.57.0 + optionalDependencies: + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -1830,6 +2459,12 @@ snapshots: protobufjs: 7.6.5 yargs: 17.7.3 + '@iconify-json/simple-icons@1.2.94': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2047,6 +2682,46 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + '@standard-schema/spec@1.1.0': {} '@tybys/wasm-util@0.10.3': @@ -2074,6 +2749,23 @@ snapshots: '@types/estree@1.0.9': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.2.0': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + '@types/node@18.19.130': dependencies: undici-types: 5.26.5 @@ -2095,6 +2787,12 @@ snapshots: dependencies: '@types/node': 18.19.130 + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.21': {} + + '@ungap/structured-clone@1.4.0': {} + '@valkey/valkey-glide-darwin-arm64@2.0.0': optional: true @@ -2125,6 +2823,11 @@ snapshots: '@valkey/valkey-glide-linux-x64-gnu': 2.0.0 '@valkey/valkey-glide-linux-x64-musl': 2.0.0 + '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@24.13.3)(lightningcss@1.33.0)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))': + dependencies: + vite: 6.4.3(@types/node@24.13.3)(lightningcss@1.33.0)(yaml@2.9.0) + vue: 3.5.42(typescript@5.9.3) + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -2180,12 +2883,128 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vue/compiler-core@3.5.42': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.42 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.42': + dependencies: + '@vue/compiler-core': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/compiler-sfc@3.5.42': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.42 + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-ssr': 3.5.42 + '@vue/shared': 3.5.42 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.42': + dependencies: + '@vue/compiler-dom': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.42': + dependencies: + '@vue/shared': 3.5.42 + + '@vue/runtime-core@3.5.42': + dependencies: + '@vue/reactivity': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/runtime-dom@3.5.42': + dependencies: + '@vue/reactivity': 3.5.42 + '@vue/runtime-core': 3.5.42 + '@vue/shared': 3.5.42 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.42': + dependencies: + '@vue/compiler-ssr': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/shared@3.5.42': {} + + '@vueuse/core@12.8.2(typescript@5.9.3)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@5.9.3) + vue: 3.5.42(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@5.9.3)': + dependencies: + '@vueuse/core': 12.8.2(typescript@5.9.3) + '@vueuse/shared': 12.8.2(typescript@5.9.3) + vue: 3.5.42(typescript@5.9.3) + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@12.8.2(typescript@5.9.3)': + dependencies: + vue: 3.5.42(typescript@5.9.3) + transitivePeerDependencies: + - typescript + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 acorn@8.17.0: {} + algoliasearch@5.57.0: + dependencies: + '@algolia/abtesting': 1.23.0 + '@algolia/client-abtesting': 5.57.0 + '@algolia/client-analytics': 5.57.0 + '@algolia/client-common': 5.57.0 + '@algolia/client-insights': 5.57.0 + '@algolia/client-personalization': 5.57.0 + '@algolia/client-query-suggestions': 5.57.0 + '@algolia/client-search': 5.57.0 + '@algolia/ingestion': 1.57.0 + '@algolia/monitoring': 1.57.0 + '@algolia/recommend': 5.57.0 + '@algolia/requester-browser-xhr': 5.57.0 + '@algolia/requester-fetch': 5.57.0 + '@algolia/requester-node-http': 5.57.0 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -2284,13 +3103,15 @@ snapshots: bintrees@1.0.2: {} + birpc@2.9.0: {} + bl@4.1.0: dependencies: buffer: 5.7.1 inherits: 2.0.4 readable-stream: 3.6.2 - brace-expansion@5.0.8: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -2318,8 +3139,14 @@ snapshots: cac@6.7.14: {} + ccount@2.0.1: {} + chai@6.2.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -2340,6 +3167,8 @@ snapshots: color-name@1.1.4: {} + comma-separated-tokens@2.0.3: {} + commander@4.1.1: {} compress-commons@6.0.2: @@ -2356,6 +3185,8 @@ snapshots: convert-source-map@2.0.0: {} + copy-anything@4.1.0: {} + core-util-is@1.0.3: {} cpu-features@0.0.10: @@ -2377,12 +3208,20 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 + dequal@2.0.3: {} + detect-libc@2.1.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + docker-compose@1.4.2: dependencies: yaml: 2.9.0 @@ -2409,6 +3248,8 @@ snapshots: eastasianwidth@0.2.0: {} + emoji-regex-xs@1.0.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -2417,6 +3258,8 @@ snapshots: dependencies: once: 1.4.0 + entities@7.0.1: {} + es-module-lexer@2.3.0: {} esbuild@0.28.1: @@ -2450,6 +3293,8 @@ snapshots: escalade@3.2.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -2472,6 +3317,10 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + file-uri-to-path@1.0.0: optional: true @@ -2481,6 +3330,10 @@ snapshots: mlly: 1.8.2 rollup: 4.62.2 + focus-trap@7.8.0: + dependencies: + tabbable: 6.5.0 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -2510,12 +3363,34 @@ snapshots: has-flag@4.0.0: {} + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hookable@5.5.3: {} + hot-shots@17.0.0: optionalDependencies: unix-dgram: 2.0.7 html-escaper@2.0.2: {} + html-void-elements@3.0.0: {} + ieee754@1.2.1: {} inherits@2.0.4: {} @@ -2634,16 +3509,51 @@ snapshots: dependencies: semver: 7.8.5 + mark.js@8.11.1: {} + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + minimatch@5.1.9: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimatch@9.0.9: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minipass@7.1.3: {} + minisearch@7.2.0: {} + + mitt@3.0.1: {} + mkdirp-classic@0.5.3: {} mkdirp@3.0.1: {} @@ -2678,6 +3588,12 @@ snapshots: dependencies: wrappy: 1.0.2 + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + package-json-from-dist@1.0.1: {} path-key@3.1.1: {} @@ -2689,6 +3605,8 @@ snapshots: pathe@2.0.3: {} + perfect-debounce@1.0.0: {} + picocolors@1.1.1: {} picomatch@4.0.5: {} @@ -2716,6 +3634,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact@10.29.8: {} + process-nextick-args@2.0.1: {} process@0.11.10: {} @@ -2738,6 +3658,8 @@ snapshots: transitivePeerDependencies: - supports-color + property-information@7.2.0: {} + protobufjs@7.6.5: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -2796,12 +3718,24 @@ snapshots: '@redis/search': 1.2.0(@redis/client@1.6.1) '@redis/time-series': 1.1.0(@redis/client@1.6.1) + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + require-directory@2.1.1: {} resolve-from@5.0.0: {} retry@0.12.0: {} + rfdc@1.4.1: {} + rolldown@1.1.5: dependencies: '@oxc-project/types': 0.139.0 @@ -2860,6 +3794,8 @@ snapshots: safer-buffer@2.1.2: {} + search-insights@2.17.3: {} + semver@7.8.5: {} shebang-command@2.0.0: @@ -2868,6 +3804,17 @@ snapshots: shebang-regex@3.0.0: {} + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -2878,6 +3825,10 @@ snapshots: source-map@0.7.6: {} + space-separated-tokens@2.0.2: {} + + speakingurl@14.0.1: {} + split-ca@1.0.1: {} ssh-remote-port-forward@1.0.4: @@ -2926,6 +3877,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -2944,10 +3900,16 @@ snapshots: tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 + superjson@2.2.6: + dependencies: + copy-anything: 4.1.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 + tabbable@6.5.0: {} + tar-fs@2.1.5: dependencies: chownr: 1.1.4 @@ -3051,6 +4013,8 @@ snapshots: tree-kill@1.2.2: {} + trim-lines@3.0.1: {} + ts-interface-checker@0.1.13: {} tslib@2.8.1: @@ -3096,6 +4060,29 @@ snapshots: undici@8.10.1: {} + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + unix-dgram@2.0.7: dependencies: bindings: 1.5.0 @@ -3104,6 +4091,30 @@ snapshots: util-deprecate@1.0.2: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@6.4.3(@types/node@24.13.3)(lightningcss@1.33.0)(yaml@2.9.0): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.26 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + lightningcss: 1.33.0 + yaml: 2.9.0 + vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 @@ -3117,6 +4128,59 @@ snapshots: fsevents: 2.3.3 yaml: 2.9.0 + vitepress@1.6.4(@algolia/client-search@5.57.0)(@types/node@24.13.3)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(typescript@5.9.3)(yaml@2.9.0): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.57.0)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.94 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.2.0 + '@vitejs/plugin-vue': 5.2.4(vite@6.4.3(@types/node@24.13.3)(lightningcss@1.33.0)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) + '@vue/devtools-api': 7.7.10 + '@vue/shared': 3.5.42 + '@vueuse/core': 12.8.2(typescript@5.9.3) + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@5.9.3) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 6.4.3(@types/node@24.13.3)(lightningcss@1.33.0)(yaml@2.9.0) + vue: 3.5.42(typescript@5.9.3) + optionalDependencies: + postcss: 8.5.26 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jiti + - jwt-decode + - less + - lightningcss + - nprogress + - preact-render-to-string + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - tsx + - typescript + - universal-cookie + - yaml + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 @@ -3146,6 +4210,16 @@ snapshots: transitivePeerDependencies: - msw + vue@3.5.42(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-sfc': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/server-renderer': 3.5.42 + '@vue/shared': 3.5.42 + optionalDependencies: + typescript: 5.9.3 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3192,3 +4266,5 @@ snapshots: archiver-utils: 5.0.2 compress-commons: 6.0.2 readable-stream: 4.7.0 + + zwitch@2.0.4: {} diff --git a/release.config.mjs b/release.config.mjs index 350564a..48dd51f 100644 --- a/release.config.mjs +++ b/release.config.mjs @@ -10,7 +10,7 @@ export default { // A version PR records the selected version without selecting a new // release itself. Earlier commits still determine the release type. { type: "release", release: false }, - // Pre-1.0 policy — see README "Releasing", which owns this table. + // Pre-1.0 policy — see docs/maintainers.md "Releasing", which owns this table. // Restore "major" here when cutting 1.0.0. { breaking: true, release: "minor" }, { type: "feat", release: "minor" }, diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 026f77f..8d80a14 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -12,6 +12,20 @@ const fallbackTimeoutMarker = "dialcache-fallback-timeout-delivered"; const nodeInvalidationMarker = "dialcache-node-invalidation-retry-verified"; const observerIsolationMarker = "dialcache-observer-rejections-isolated"; const shadowPayloadReleaseMarker = "dialcache-shadow-payload-released"; +// Run the actual first TypeScript block from each onboarding page, without a +// separately maintained copy that could keep passing after the docs break. +const documentationExamples = [ + { + source: "README.md", + filename: "readme-example.mts", + stdout: "Loading from source: 123\nLoading from source: 456\nLoading from source: 123\n", + }, + { + source: "docs/getting-started.md", + filename: "getting-started-example.mts", + stdout: "1\n2\n", + }, +]; const packedInvalidationCheckSource = String.raw` function createPackedNodeRedisInvalidationAdapter(nodeRedis, dispatch, label) { const client = { @@ -995,8 +1009,20 @@ try { writeFile(join(workspace, "root-consumer.cts"), rootConsumer), writeFile( join(workspace, "tsconfig.root.json"), - typescriptConfig(["root-consumer.mts", "root-consumer.cts"]), + typescriptConfig([ + "root-consumer.mts", + "root-consumer.cts", + ...documentationExamples.map((example) => example.filename), + ]), ), + ...documentationExamples.map(async (example) => { + const markdown = await readFile(join(root, example.source), "utf8"); + const code = markdown.match(/^```ts\r?\n([\s\S]*?)^```\s*$/m)?.[1]; + if (code === undefined) { + throw new Error(`${example.source} has no runnable TypeScript example`); + } + await writeFile(join(workspace, example.filename), code); + }), ]); const { stdout: esmRootRuntimeOutput } = await exec( @@ -1750,6 +1776,17 @@ void (async () => { { cwd: workspace }, ); + for (const example of documentationExamples) { + const { stdout } = await exec( + process.execPath, + ["--experimental-strip-types", example.filename], + { cwd: workspace, timeout: 10_000 }, + ); + if (stdout !== example.stdout) { + throw new Error(`${example.source} produced unexpected output: ${JSON.stringify(stdout)}`); + } + } + await exec( "npm", [ diff --git a/src/internal/compression.ts b/src/internal/compression.ts index e935350..f87ccd1 100644 --- a/src/internal/compression.ts +++ b/src/internal/compression.ts @@ -17,7 +17,7 @@ import type { RedisCachePayload } from "../redis-client.js"; * Entries written by older releases have no envelope: a legacy binary payload * whose first bytes mimic the envelope (0x01/0x02 followed by a zstd-parsable * stream, or 0x00 followed by another envelope byte) is misread until it - * expires. The README documents this residual and the key-versioning + * expires. See docs/upgrading.md for this residual and the key-versioning * migration for serializers whose output can begin with these bytes. */ export const MARKER_ESCAPED_RAW = 0x00; @@ -174,7 +174,7 @@ export function compressPayload( * whose decompressed size would exceed the cap, is returned unchanged; the * caller's load then fails and the existing miss path repopulates the entry. * zstd acceptance of a non-DialCache payload is possible only for legacy - * entries written before escaping existed (see README residual). Never + * entries written before escaping existed (see docs/upgrading.md). Never * mutates the input and holds no state, keeping repeated loads of a retained * payload independent. */ diff --git a/src/redis-protocol.ts b/src/redis-protocol.ts index d63f2cc..b1a409a 100644 --- a/src/redis-protocol.ts +++ b/src/redis-protocol.ts @@ -9,9 +9,9 @@ * dispatch and its recovery. The * payload region past the header is opaque at this layer: entries written by * DialCache releases with payload compression may begin with a compression - * envelope byte (0x00 escape, 0x01/0x02 zstd; see the README Compression - * section), which DialCache core interprets above the adapter. Adapters must - * never decompress or otherwise rewrite payload bytes. + * envelope byte (0x00 escape, 0x01/0x02 zstd; see docs/redis.md, Compression), + * which DialCache core interprets above the adapter. Adapters must never + * decompress or otherwise rewrite payload bytes. */ export { ceilSupportedCacheTtlMs } from "./internal/duration.js"; export type { CacheMissReason } from "./metrics.js"; diff --git a/test/dialcache-coalescing.test.ts b/test/dialcache-coalescing.test.ts index 30b11fd..491752d 100644 --- a/test/dialcache-coalescing.test.ts +++ b/test/dialcache-coalescing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { CacheLayer, DialCache, DialCacheKeyConfig, type DialCacheMetricsAdapter } from "../src/index.js"; +import { CacheLayer, DialCache, DialCacheKeyConfig, JsonSerializer, type DialCacheMetricsAdapter } from "../src/index.js"; import { FakeRedis } from "./fake-redis.js"; interface Deferred { @@ -338,6 +338,66 @@ describe("DialCache request coalescing", () => { ]); }); + it.each([true, false])("uses coalescing policy for calls after tracked invalidation (coalesce=%s)", async (coalesce) => { + const redis = new FakeRedis(); + const loadStarted = deferred(); + const loadGate = deferred(); + const { metrics, coalesced } = spyMetrics(); + const json = new JsonSerializer<{ id: string; version: number }>(); + const dialcache = new DialCache({ redis: { client: redis, readTimeoutMs: 1_000 }, metrics }); + let version = 1; + const source = vi.fn(async (id: string) => ({ id, version })); + const getUser = dialcache.cached(source, { + keyType: "user_id", + useCase: "TrackedInvalidationFlight", + cacheKey: (id) => id, + trackForInvalidation: true, + defaultConfig: new DialCacheKeyConfig({ + ttlSec: { [CacheLayer.REMOTE]: 60 }, + // Exercise the default as well as the explicit opt-out. + ...(coalesce ? {} : { coalesce: false }), + }), + serializer: { + dump: (value) => json.dump(value), + load: async (payload) => { + const value = await json.load(payload); + loadStarted.resolve(); + await loadGate.promise; + return value; + }, + }, + }); + + // Warm Redis, then hold a validated old snapshot inside deserialization. + await dialcache.enable(() => getUser("1")); + source.mockClear(); + redis.mGetCalls = 0; + const beforeInvalidation = dialcache.enable(() => getUser("1")); + await loadStarted.promise; + + // A separate request starts only after the source change and invalidation. + version = 2; + await dialcache.invalidateRemote("user_id", "1", 5_000); + const afterInvalidation = dialcache.enable(() => getUser("1")); + const results = Promise.all([beforeInvalidation, afterInvalidation]); + try { + await tick(); + expect(redis.mGetCalls).toBe(coalesce ? 1 : 2); + expect(coalesced).toHaveBeenCalledTimes(coalesce ? 1 : 0); + } finally { + loadGate.resolve(); + } + + await expect(results).resolves.toEqual([ + { id: "1", version: 1 }, + { id: "1", version: coalesce ? 1 : 2 }, + ]); + expect(source).toHaveBeenCalledTimes(coalesce ? 0 : 1); + + // Once the earlier flight settles, both policies observe the watermark. + await expect(dialcache.enable(() => getUser("1"))).resolves.toEqual({ id: "1", version: 2 }); + }); + it("keeps different keys isolated while coalescing concurrent misses", async () => { const gate = deferred(); const dialcache = new DialCache(); diff --git a/tsconfig.json b/tsconfig.json index 5de5876..49c1d87 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,5 @@ "types": ["node", "vitest/globals"], "skipLibCheck": true }, - "include": ["src", "test"] + "include": ["src", "test", "docs/.vitepress/config.mts"] }