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:
geton an unset stream returnsundefinedsetthengetround-trips an entrysetoverwrites a prior entry on the same streaminvalidateremoves one stream and leaves othersinvalidate/clearare no-ops on absent stateclearempties every stream- Entries are isolated per stream
disposeis 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
- Contract:
libs/act/src/types/ports.ts - Reference implementation:
InMemoryCache - TCK source:
libs/act-tck/src/cache-tck.ts - Architecture: cache-and-snapshots.md