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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ Fine-grained TypeScript caching with explicit enabled contexts, stable key const

```bash
pnpm add dialcache
# For Redis-backed caching:
# Choose a Redis client when using the remote layer:
pnpm add redis@~4.7.1
# or
pnpm add @valkey/valkey-glide@^2.4.2
```

DialCache requires Node.js 20 or Node.js 22 and newer.
Expand Down Expand Up @@ -61,6 +63,30 @@ const dialcache = new DialCache({

The `redis.client` and `redis.createClient` options accept the semantic `DialCacheRedisClient` interface. Node-redis users should register the supplied scripts and wrap their client with `createNodeRedisDialCacheClient` as shown above.

Valkey GLIDE users pass an already-created standalone or cluster client to the GLIDE adapter:

```ts
import { GlideClient } from "@valkey/valkey-glide";
import { DialCache } from "dialcache";
import { createValkeyGlideDialCacheClient } from "dialcache/valkey-glide";

const glideClient = await GlideClient.createClient({
addresses: [{ host: "127.0.0.1", port: 6379 }],
});
const redisClient = createValkeyGlideDialCacheClient(glideClient);
const dialcache = new DialCache({
redis: { client: redisClient, keyPrefix: "dialcache:" },
});

function shutdown(): void {
// Release adapter-owned scripts before closing GLIDE.
redisClient.dispose();
glideClient.close();
}
```

DialCache does not create, connect, or close the underlying Redis client. After outstanding cache operations finish, the GLIDE adapter's `dispose()` method releases its five native `Script` handles; it is idempotent and does not close the wrapped GLIDE connection.

When caching is enabled, reads flow through:

```text
Expand All @@ -81,7 +107,7 @@ 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.

Node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`. Its cluster client routes scripts by their first key and performs that fallback on the selected shard. Tracked reads are deliberately routed to primaries so a lagging Redis replica cannot hide an invalidation watermark. The adapter also fans `flushAll()` across every cluster master instead of silently clearing one shard.
Node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`. Its cluster client routes scripts by their first key and performs that fallback on the selected shard. The GLIDE adapter uses GLIDE's native `Script` lifecycle and byte decoder; GLIDE routes scripts from their declared keys and the adapter broadcasts `flushAll()` to all cluster primaries. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark.

You can also provide a lazy factory that returns a script-enabled client:

Expand All @@ -100,7 +126,7 @@ const dialcache = new DialCache({
});
```

The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. It exchanges serialized values as `string | Buffer` and does not expose node-redis commands or wire encodings. Other clients can implement that semantic interface without changing DialCache; distinct untracked/tracked read and write Lua sources, the invalidation source, and wire constants are available from `dialcache/redis-protocol`, so a Valkey GLIDE adapter can use its own script lifecycle, cluster routing, and byte decoder without depending on node-redis. Custom adapters can use the root-exported `DialCacheRedisPayloadError` and `DialCacheRedisPayloadEncodingError` classes to preserve the standard metrics labels.
The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. It exchanges serialized values as `string | Buffer` and does not expose client commands or wire encodings. Distinct untracked/tracked read and write Lua sources, the invalidation source, and wire constants are available from `dialcache/redis-protocol`. Custom adapters can use the root-exported `DialCacheRedisPayloadError` and `DialCacheRedisPayloadEncodingError` classes to preserve the standard metrics labels.

Redis values use a compact binary frame:

Expand Down Expand Up @@ -294,6 +320,7 @@ Included:
- Lua-backed Redis reads and writes with Redis-generated timestamps
- Versioned binary Redis frames for UTF-8 and Buffer serializer output
- Native node-redis script registration with automatic `NOSCRIPT` recovery
- Native Valkey GLIDE adapter with explicit script disposal and automatic script-cache recovery
- Standalone Redis, Valkey, and Redis Cluster support
- JSON and custom serializer support for Redis values
- Duplicate and reserved use-case validation
Expand Down
17 changes: 16 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@
"default": "./dist/node-redis.cjs"
}
},
"./valkey-glide": {
"import": {
"types": "./dist/valkey-glide.d.ts",
"default": "./dist/valkey-glide.js"
},
"require": {
"types": "./dist/valkey-glide.d.cts",
"default": "./dist/valkey-glide.cjs"
}
},
"./redis-protocol": {
"import": {
"types": "./dist/redis-protocol.d.ts",
Expand All @@ -46,7 +56,7 @@
"LICENSE"
],
"scripts": {
"build": "tsup src/index.ts src/node-redis.ts src/redis-protocol.ts --format esm,cjs --dts --clean",
"build": "tsup src/index.ts src/node-redis.ts src/redis-protocol.ts src/valkey-glide.ts --format esm,cjs --dts --clean",
"check": "pnpm typecheck && pnpm test && pnpm build && pnpm test:package",
"typecheck": "tsc --noEmit",
"test": "vitest run --coverage",
Expand All @@ -64,6 +74,7 @@
"prom-client": "^15.1.3"
},
"devDependencies": {
"@valkey/valkey-glide": "^2.4.2",
"@vitest/coverage-v8": "^4.0.14",
"redis": "~4.7.1",
"testcontainers": "^12.0.4",
Expand All @@ -72,9 +83,13 @@
"vitest": "^4.0.14"
},
"peerDependencies": {
"@valkey/valkey-glide": "^2.4.2",
"redis": "~4.7.1"
},
"peerDependenciesMeta": {
"@valkey/valkey-glide": {
"optional": true
},
"redis": {
"optional": true
}
Expand Down
71 changes: 71 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 50 additions & 2 deletions scripts/test-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const workspace = await mkdtemp(join(tmpdir(), "dialcache-package-"));
const consumer = `import { DialCache, DialCacheKeyConfig, type DialCacheRedisClient } from "dialcache";
import { createNodeRedisDialCacheClient } from "dialcache/node-redis";
import { READ_CACHE_SCRIPT } from "dialcache/redis-protocol";
import { createValkeyGlideDialCacheClient, type ValkeyGlideDialCacheClient } from "dialcache/valkey-glide";

const cache = new DialCache();
const load = cache.cached(async (id: string) => id, {
Expand All @@ -22,6 +23,7 @@ const load = cache.cached(async (id: string) => id, {

void load;
void createNodeRedisDialCacheClient;
void createValkeyGlideDialCacheClient;
void READ_CACHE_SCRIPT;

const customRedisClient: DialCacheRedisClient = {
Expand All @@ -31,6 +33,8 @@ const customRedisClient: DialCacheRedisClient = {
flushAll: async () => undefined,
};
void customRedisClient;
const glideRedisClient: ValkeyGlideDialCacheClient | undefined = undefined;
void glideRedisClient;
`;

try {
Expand All @@ -54,6 +58,47 @@ try {
{ cwd: workspace },
);

let glideWasInstalled = false;
try {
await exec(process.execPath, ["--eval", "require.resolve('@valkey/valkey-glide')"], { cwd: workspace });
glideWasInstalled = true;
} catch {
// Optional peers remain absent until the consumer selects the corresponding adapter.
}
if (glideWasInstalled) {
throw new Error("The optional Valkey GLIDE peer was installed automatically");
}

await exec(
process.execPath,
[
"--input-type=module",
"--eval",
"await import('dialcache'); await import('dialcache/redis-protocol'); await import('dialcache/node-redis')",
],
{ cwd: workspace },
);
await exec(
process.execPath,
["--eval", "require('dialcache'); require('dialcache/redis-protocol'); require('dialcache/node-redis')"],
{ cwd: workspace },
);

await exec(
"npm",
[
"install",
"--ignore-scripts",
"--no-package-lock",
"--no-save",
join(workspace, tarball),
"redis@~4.7.1",
"typescript@5.9.3",
"@valkey/valkey-glide@^2.4.2",
],
{ cwd: workspace },
);

await Promise.all([
writeFile(join(workspace, "consumer.mts"), consumer),
writeFile(join(workspace, "consumer.cts"), consumer),
Expand All @@ -80,13 +125,16 @@ try {
[
"--input-type=module",
"--eval",
"await import('dialcache'); await import('dialcache/redis-protocol'); await import('dialcache/node-redis')",
"await import('dialcache'); await import('dialcache/redis-protocol'); await import('dialcache/node-redis'); await import('dialcache/valkey-glide')",
],
{ cwd: workspace },
);
await exec(
process.execPath,
["--eval", "require('dialcache'); require('dialcache/redis-protocol'); require('dialcache/node-redis')"],
[
"--eval",
"require('dialcache'); require('dialcache/redis-protocol'); require('dialcache/node-redis'); require('dialcache/valkey-glide')",
],
{ cwd: workspace },
);
await exec(
Expand Down
26 changes: 26 additions & 0 deletions src/internal/redis-payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {
DialCacheRedisPayloadEncodingError,
DialCacheRedisPayloadError,
type RedisCachePayload,
} from "../redis-client.js";
import { REDIS_ENCODING_BINARY, REDIS_ENCODING_UTF8 } from "./redis-scripts.js";

export function redisPayloadEncoding(value: RedisCachePayload): number {
return Buffer.isBuffer(value) ? REDIS_ENCODING_BINARY : REDIS_ENCODING_UTF8;
}

export function decodeRedisPayload(raw: Buffer): RedisCachePayload {
if (raw.length === 0) {
throw new DialCacheRedisPayloadError("Invalid DialCache Redis payload");
}

const encoding = raw[0];
const payload = raw.subarray(1);
if (encoding === REDIS_ENCODING_UTF8) {
return payload.toString("utf8");
}
if (encoding === REDIS_ENCODING_BINARY) {
return payload;
}
throw new DialCacheRedisPayloadEncodingError("Invalid DialCache Redis payload encoding");
}
26 changes: 4 additions & 22 deletions src/node-redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@ import {
INVALIDATE_CACHE_SCRIPT,
READ_CACHE_SCRIPT,
READ_TRACKED_CACHE_SCRIPT,
REDIS_ENCODING_BINARY,
REDIS_ENCODING_UTF8,
WRITE_CACHE_SCRIPT,
WRITE_TRACKED_CACHE_SCRIPT,
} from "./internal/redis-scripts.js";
import { DialCacheRedisPayloadEncodingError, DialCacheRedisPayloadError } from "./redis-client.js";
import type { DialCacheRedisClient, RedisCachePayload } from "./redis-client.js";
import { decodeRedisPayload, redisPayloadEncoding } from "./internal/redis-payload.js";
import type { DialCacheRedisClient } from "./redis-client.js";

type BufferReplyOptions = ReturnType<typeof commandOptions<{ readonly returnBuffers: true }>>;
// Redis bulk strings are binary data; decoding them as UTF-8 would corrupt arbitrary serializer output.
Expand Down Expand Up @@ -158,11 +156,11 @@ export function createNodeRedisDialCacheClient(client: NodeRedisScriptClient): D
const raw = watermarkKey === undefined
? await client.dialcacheRead(bufferReplyOptions, valueKey)
: await client.dialcacheReadTracked(bufferReplyOptions, valueKey, watermarkKey);
return raw === null ? null : decodePayload(raw);
return raw === null ? null : decodeRedisPayload(raw);
},
async write(request) {
const { valueKey, watermarkKey, cacheTtlMs, value } = request;
const encodingByte = Buffer.isBuffer(value) ? REDIS_ENCODING_BINARY : REDIS_ENCODING_UTF8;
const encodingByte = redisPayloadEncoding(value);
const result = watermarkKey === undefined
? await client.dialcacheWrite(valueKey, cacheTtlMs, encodingByte, value)
: await client.dialcacheWriteTracked(
Expand Down Expand Up @@ -200,22 +198,6 @@ export function createNodeRedisDialCacheClient(client: NodeRedisScriptClient): D
};
}

function decodePayload(raw: Buffer): RedisCachePayload {
if (raw.length === 0) {
throw new DialCacheRedisPayloadError("Invalid DialCache Redis payload");
}

const encoding = raw[0];
const payload = raw.subarray(1);
if (encoding === REDIS_ENCODING_UTF8) {
return payload.toString("utf8");
}
if (encoding === REDIS_ENCODING_BINARY) {
return payload;
}
throw new DialCacheRedisPayloadEncodingError("Invalid DialCache Redis payload encoding");
}

function clusterCommands(client: NodeRedisScriptClient): NodeRedisClusterCommands | null {
if (!("masters" in client) || !("nodeClient" in client) || !Array.isArray(client.masters)) {
return null;
Expand Down
Loading
Loading