Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
4a2dc71
docs: focus README on safe cache rollouts
lan17 Jul 23, 2026
183395b
docs: improve onboarding and reference coverage
lan17 Jul 26, 2026
0a833b2
docs: refine rollout safety guidance
lan17 Jul 26, 2026
6502c7c
docs: make rollout examples fail safe
lan17 Jul 26, 2026
e639a05
docs: clarify read-through cache positioning
lan17 Jul 26, 2026
b44c9de
docs: make README opening easier to scan
lan17 Jul 26, 2026
8bb7309
docs: refresh guides for v0.13.0
lan17 Jul 31, 2026
a50a0e8
docs: refresh guides for v0.14.1
lan17 Aug 2, 2026
0d30d6a
Merge origin/main into docs/readme-safety-adoption
lan17 Aug 12, 2026
e6ec849
docs: refresh guides for v0.19.0
lan17 Aug 12, 2026
671ca4f
docs: rebuild landing README and reference on current main
lan17 Sep 6, 2026
d8e268f
docs: host searchable reference with VitePress and GitHub Pages
lan17 Sep 6, 2026
bcec11b
docs: clarify runtime contracts and verify onboarding examples
lan17 Sep 6, 2026
a22c854
docs: distinguish operation budgets from the read path
lan17 Sep 6, 2026
9c3d694
docs: correct invalidation freshness and shared anchors
lan17 Sep 6, 2026
fa83520
docs: specify the legacy binary escape prefix
lan17 Sep 6, 2026
d31acd0
docs: surface serializer requirements during onboarding
lan17 Sep 6, 2026
9f7c9dc
docs: complete public behavior and helper contracts
lan17 Sep 7, 2026
f3d1648
docs: clarify serializer limits and edge-case telemetry
lan17 Sep 7, 2026
5273ab7
docs: qualify native JSON serialization failures
lan17 Sep 7, 2026
6c1e0bc
docs: make the README more direct and less promotional
lan17 Sep 7, 2026
fc2fb95
docs: address README review feedback
lan17 Sep 7, 2026
f60494a
docs: define terms and add scenarios to the README
lan17 Sep 7, 2026
6e3566a
docs: align page titles and fix reference nits
lan17 Sep 7, 2026
e0b1dfc
docs: open the README with a capability list and show both entry points
lan17 Sep 7, 2026
3632204
docs: trim the README to the example and its comments
lan17 Sep 7, 2026
ecd3a90
docs: give the README opening more energy
lan17 Sep 7, 2026
27c40dc
docs: rewrite the README opening paragraph
lan17 Sep 8, 2026
e935b1e
docs: write the README in the third person
lan17 Sep 8, 2026
dafd724
docs: reflow the stale-on-error paragraph
lan17 Sep 8, 2026
8a72292
docs: reframe the README opening around use cases
lan17 Sep 8, 2026
cc361ea
docs: drop the entry-point sentence from the README opening
lan17 Sep 8, 2026
f6aaf96
docs: shorten the multi-layer bullet to the layer chain
lan17 Sep 8, 2026
9070df4
docs: restore the README install section
lan17 Sep 8, 2026
24a3c53
docs: shape the README as a landing page
lan17 Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/docs.yaml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
node_modules/
dist/
coverage/
docs/.vitepress/cache/
*.tgz
.env
.env.*
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1,089 changes: 136 additions & 953 deletions README.md

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README no longer says why you'd want this.

Splitting the 1000-line monolith into docs/ plus a lean landing page is clearly right. But in trimming the promotional tone, the latest revision also dropped the evaluation content: the "Why DialCache?" framing and the sentence "It fits database lookups, service reads, and reusable computations whose results can be cached." What's left opens with a category label — "a TypeScript caching library for Node.js" — and goes straight to mechanics.

Meanwhile the new "Failures and metrics" section moves in the opposite direction, adding behavior specification to the README.

AGENTS.md:52-53 asks for the reverse on both counts:

Keep the README focused on evaluation and getting started. Document complete feature behavior in docs/ and link it from docs/index.md.

The bulleted pitch doesn't need to come back. One or two direct sentences on what problem this solves and where it fits would restore the evaluation half without reintroducing the sales register.


Generated by Claude Code

Large diffs are not rendered by default.

62 changes: 62 additions & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
@@ -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",
},
},
});
248 changes: 248 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -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<T>` | Enables caching while the callback and its awaited work run |
| `disable(fn)` | `Promise<T>` | Runs a nested region uncached; does not evict values |
| `withEnabled(fn)` | `Promise<T>` | Alias for `enable` |
| `withDisabled(fn)` | `Promise<T>` | 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<Fn>`: 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<Value>`. 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<Fn>` and `GetOrLoadOptions<Value>` 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<Value>` 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<void>` 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<T>` | `dump(value)` returns `string \| Buffer`; `load(payload)` returns `T`; either may return a Promise |
| `JsonSerializer<T>` | Default JSON codec, including top-level undefined support; both methods return Promises |

`CachedValue<Fn>` exposes a function's resolved result type. `ShadowComparator<T>`
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.
Loading
Loading