Skip to main content
Version: Current

Writing a custom Cache adapter

Cache is the snapshot-cache port — it sits in front of state loading so hot streams skip rehydrating from the event log on every action. The framework ships a process-local InMemoryCache (LRU). Anything else — Redis, Memcached, Valkey, a distributed cache fronted by your own service — is a custom adapter.

The contract

The interface in libs/act/src/types/ports.ts is small:

interface Cache extends Disposable {
get<TState>(stream: string): Promise<CacheEntry<TState> | undefined>;
set<TState>(stream: string, entry: CacheEntry<TState>): Promise<void>;
invalidate(stream: string): Promise<void>;
clear(): Promise<void>;
}

Four methods plus dispose. The async signature is forward-compatible with external caches like Redis.

The TCK is the spec

// libs/act-redis/test/cache-tck.spec.ts
import { runCacheTck } from "@rotorsoft/act-tck";
import { RedisCache } from "../src/index.js";

runCacheTck({
name: "RedisCache",
factory: () => new RedisCache({ url: process.env.REDIS_URL! }),
});

The TCK exercises:

  • get on an unset stream returns undefined
  • set then get round-trips an entry
  • set overwrites a prior entry on the same stream
  • invalidate removes one stream and leaves others
  • invalidate / clear are no-ops on absent state
  • clear empties every stream
  • Entries are isolated per stream
  • dispose is idempotent

Adapter-specific behavior — LRU ordering, TTL, size limits, network reconnect — stays in your adapter's own test suite. The TCK only asserts what every Cache must honor.

Scaffolding @rotorsoft/act-redis (sketch)

libs/act-redis/
├── package.json
├── tsconfig.json
├── tsconfig.build.json
├── tsup.config.ts
├── src/
│ ├── index.ts
│ └── redis-cache.ts # implements Cache
├── test/
│ ├── cache-tck.spec.ts # runCacheTck({ factory: () => new RedisCache(…) })
│ └── ttl.spec.ts # adapter-specific TTL semantics
└── README.md

The README's testing section:

## Testing

```ts no-check
import { runCacheTck } from "@rotorsoft/act-tck";
import { RedisCache } from "@rotorsoft/act-redis";

runCacheTck({
name: "RedisCache",
factory: () => new RedisCache({ url: process.env.REDIS_URL! }),
});
```

Differential testing against the reference cache

runCacheTck proves your cache honors the contract in isolation. runCacheDifferentialTck proves it honors the contract identically to a reference cache. It drives a family of randomized, seeded workloads (set / invalidate / clear over a small key set kept within capacity, so eviction — your adapter's policy, not a contract guarantee — never enters the comparison) against every cache you pass and asserts their observable get() snapshot is identical after every operation:

import { runCacheDifferentialTck } from "@rotorsoft/act-tck";
import { InMemoryCache } from "@rotorsoft/act";
import { RedisCache } from "../src/index.js";

runCacheDifferentialTck({
name: "InMemory vs Redis",
// First entry is the reference; every other cache must match it.
caches: [
{ name: "InMemoryCache", factory: () => new InMemoryCache({ maxSize: 1000 }) },
{ name: "RedisCache", factory: () => new RedisCache({ /* … */ }) },
],
});

A cache that mishandles overwrite ordering, leaks an invalidated key, or clears partially diverges from the reference on the exact operation that broke it — with the seed in the describe block for replay.

When the Cache port changes

If the framework extends the Cache interface (a TTL primitive, a multi-get for batched rehydration, etc.), the corresponding cases land in libs/act-tck/src/cache-tck.ts. New optional methods are gated behind a Capabilities flag so existing adapters keep passing until they opt in.

Cross-references