Skip to content

Preserve payload-error identity across CommonJS package subpaths #99

Description

@lan17

Priority

P2 — Medium — make the documented root error classes reliable for CommonJS adapter consumers and log classification.

v0.14.0 grooming verification (2026-08-01)

Verified on main@34bd022: only DialCacheRedisProtocolError has a cross-bundle brand/Symbol.hasInstance; both public payload-error classes remain unreliable across CommonJS subpath bundles. Keep P2. This remains a package-boundary identity fix with no cache or Redis-protocol behavior change.

Current baseline

At v0.11.0 / e06d833ba245706a499d66056fdad15dc1210b68, the root package exports DialCacheRedisPayloadError, DialCacheRedisPayloadEncodingError, and DialCacheRedisProtocolError.

The package builds root and integration entry points as both ESM and CommonJS. CommonJS adapter subpaths contain their own bundled copies of these classes. DialCacheRedisProtocolError already uses a global-symbol brand and custom Symbol.hasInstance specifically to preserve root instanceof checks across those bundles. The two payload errors do not.

Problem

A payload error thrown by dialcache/node-redis or dialcache/valkey-glide in CommonJS is not an instance of the corresponding class imported from dialcache. The same failure occurs for DialCacheRedisPayloadEncodingError.

A built-artifact probe against the pinned baseline confirmed:

node-redis malformed payload: root PayloadError instanceof = false
GLIDE malformed payload:     root PayloadError instanceof = false
GLIDE unknown encoding:      root PayloadEncodingError instanceof = false
GLIDE invalid script reply:  root ProtocolError instanceof = true

Core cache reads still fail open, but CommonJS callers and configured loggers cannot reliably use the documented root classes to distinguish corruption, unsupported encoding, and other read failures.

Pinned evidence

  • Only the protocol error has cross-bundle branding:
    const redisProtocolErrorBrand = Symbol.for("dialcache.DialCacheRedisProtocolError");
    export class DialCacheRedisPayloadError extends Error {
    constructor(message: string) {
    super(message);
    this.name = "DialCacheRedisPayloadError";
    }
    }
    export class DialCacheRedisPayloadEncodingError extends Error {
    constructor(message: string) {
    super(message);
    this.name = "DialCacheRedisPayloadEncodingError";
    }
    }
    export class DialCacheRedisProtocolError extends Error {
    static [Symbol.hasInstance](value: unknown): boolean {
    if (this !== DialCacheRedisProtocolError) {
    return Function.prototype[Symbol.hasInstance].call(this, value);
    }
    return typeof value === "object"
    && value !== null
    && Object.getOwnPropertyDescriptor(value, redisProtocolErrorBrand)?.value === true;
    }
    constructor(message: string) {
    super(message);
    this.name = "DialCacheRedisProtocolError";
    // CJS adapter subpaths are separate bundles; a global symbol preserves root-export instanceof checks.
    Object.defineProperty(this, redisProtocolErrorBrand, { value: true });
    }
  • Payload decoding throws the two unbranded classes:
    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");
  • The node-redis adapter exposes decoder failures from read():

    DialCache/src/node-redis.ts

    Lines 161 to 170 in e06d833

    export function createNodeRedisDialCacheClient(client: NodeRedisScriptClient): DialCacheRedisClient {
    return {
    async read({ valueKey, watermarkKey }, context) {
    const options: BufferReplyOptions = context === undefined
    ? bufferReplyOptions
    : commandOptions({ returnBuffers: true, signal: context.signal });
    const raw = watermarkKey === undefined
    ? await client.dialcacheRead(options, valueKey)
    : await client.dialcacheReadTracked(options, valueKey, watermarkKey);
    return raw === null ? null : decodeRedisPayload(raw);
  • The GLIDE adapter directly throws DialCacheRedisPayloadError for malformed replies:
    return {
    async read({ valueKey, watermarkKey }) {
    const raw = watermarkKey === undefined
    ? await invoke(scripts.read, [valueKey])
    : await invoke(scripts.readTracked, [valueKey, watermarkKey]);
    if (raw === null) {
    return null;
    }
    if (!Buffer.isBuffer(raw)) {
    throw new DialCacheRedisPayloadError("Invalid DialCache Redis payload reply");
    }
    return decodeRedisPayload(raw);
  • The multi-entry ESM/CommonJS build command:

    DialCache/package.json

    Lines 78 to 85 in e06d833

    "scripts": {
    "benchmark:request-local": "pnpm build && node scripts/benchmark-request-local.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",
    "typecheck": "tsc --noEmit",
    "test": "vitest run --coverage",
    "test:integration": "vitest run --config vitest.integration.config.ts",
    "test:package": "node scripts/test-package.mjs",
  • Packed runtime tests verify cross-entry protocol-error identity but omit both payload classes:
    try {
    nodeRedis.dialcacheRedisScripts.dialcacheWrite.transformReply(2);
    throw new Error("Expected an invalid node-redis script reply to fail");
    } catch (error) {
    if (!(error instanceof root.DialCacheRedisProtocolError)) {
    throw new Error("The node-redis protocol error does not match the root CommonJS export");
    }
    }
  • The README presents all three root classes as the adapter error taxonomy:

    DialCache/README.md

    Lines 394 to 396 in e06d833

    #### Serialization
    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 throw the root-exported `DialCacheRedisPayloadError`, `DialCacheRedisPayloadEncodingError`, and `DialCacheRedisProtocolError` classes to distinguish malformed payloads, unsupported encodings, and Lua reply-domain violations in logs. DialCache records bounded `cache_read`, `cache_write`, or `invalidation` metrics by failure site.

Simplest scope

  • Give DialCacheRedisPayloadError and DialCacheRedisPayloadEncodingError distinct private Symbol.for brands.
  • Apply the existing protocol-error Symbol.hasInstance pattern to both classes.
  • Keep the symbols private; add no public branding API.
  • Preserve current class names, messages, and the fact that payload and encoding errors are distinct classes.
  • Add packed runtime checks rather than relying only on source-level unit tests, because the defect exists at the emitted package boundary.
  • Do not change bundling strategy, Redis framing, Lua, adapter methods, or the semantic Redis interface.

Acceptance criteria

  • For both import and require, malformed payload errors thrown by the packed node-redis adapter satisfy instanceof the root DialCacheRedisPayloadError and retain their current name/message contracts.
  • For both import and require, malformed and unknown-encoding errors thrown by the packed GLIDE adapter match their corresponding root classes.
  • DialCacheRedisPayloadEncodingError does not accidentally match DialCacheRedisPayloadError unless a separate hierarchy change is explicitly approved.
  • Existing DialCacheRedisProtocolError behavior remains unchanged.
  • Directly constructed root error instances and subclass behavior remain correct.
  • Packed ESM/CommonJS runtime tests cover all three error classes across adapter subpaths.
  • No Redis key, payload frame, Lua protocol, dependency, or cache behavior changes.

Compatibility

This is a runtime-identity fix. It broadens successful instanceof checks to match the documented API and does not change which operations fail or how DialCache fails open.

Related issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions