Durable stateful entities for TypeScript.
Define entities with state, commands, and queries. Cue handles persistence, concurrency, and schema evolution—so you don't have to.
import { define, create } from "@twoabove/cue";
const Counter = define("Counter")
.initialState(() => ({ count: 0 }))
.commands({
increment: (s, by = 1) => {
s.count += by;
},
})
.queries({
value: (s) => s.count,
})
.build();
const app = create({ definition: Counter });
const counter = app.get("my-counter");
await counter.send.increment(5);
console.log(await counter.read.value()); // 5- Durable. State survives restarts. Plug in Postgres, SQLite, or Redis—or run in-memory for tests.
- Safe. One operation at a time, always. No race conditions, no corrupted state.
- Evolvable. Schema changes are type-checked and automatic. Add a field, rename a property—old entities migrate on load.
- Streamable. Long-running operations yield progress in real-time.
- Time-travel. Query historical state at any point with full type safety.
npm install @twoabove/cue immerNote:
immeris a peer dependency and must be installed alongside cue.
import { create, define, memoryStore } from "@twoabove/cue";
// 1. Define your entity
const Character = define("Character")
.initialState(() => ({
level: 1,
hp: 100,
quests: new Set<string>(),
}))
.commands({
takeDamage: (state, amount: number) => {
state.hp -= amount;
if (state.hp <= 0) {
state.hp = 0;
return "You have been defeated!";
}
return `Ouch! HP is now ${state.hp}.`;
},
levelUp: async (state) => {
await new Promise((res) => setTimeout(res, 50));
state.level++;
state.hp += 10;
return `Ding! Reached level ${state.level}!`;
},
// Streaming command using async generator
startQuest: async function* (state, quest: string) {
if (state.quests.has(quest)) {
yield { status: "already_on_quest" };
return "Quest already started.";
}
state.quests.add(quest);
yield { status: "started", quest };
await new Promise((res) => setTimeout(res, 100));
yield { status: "completed", quest };
state.quests.delete(quest);
return "Quest complete!";
},
})
.queries({
getStats: (state) => ({
level: state.level,
hp: state.hp,
}),
})
.build();
// 2. Create an entity manager
const manager = create({ definition: Character });
// 3. Get a reference to a specific entity by its unique ID
const playerOne = manager.get("player-one");
// 4. Interact with the entity
const damageResult = await playerOne.send.takeDamage(10);
console.log(damageResult); // "Ouch! HP is now 90."
const levelUpMessage = await playerOne.send.levelUp();
console.log(levelUpMessage); // "Ding! Reached level 2!"
// Read-only queries
const stats = await playerOne.read.getStats();
console.log(stats); // { level: 2, hp: 110 }
// Stream progress from long-running commands
console.log("Starting a new quest...");
for await (const update of playerOne.stream.startQuest("The Lost Amulet")) {
console.log(`Quest update: ${update.status}`);
}
// > Quest update: started
// > Quest update: completed
// Snapshot for debugging
const snapshot = await playerOne.snapshot();
console.log(snapshot.state.quests); // Set(0) {}
// Shut down when done
await manager.stop();Use the fluent builder to define your entity's shape and behavior:
.initialState(() => ({...})): Sets the default state for new entities..commands({...}): Methods that can modify state (sync, async, or generators for streaming)..queries({...}): Read-only methods for safe state access..evolve((prevState) => ({...})): Migration function for schema changes..persistence({...}): Configure snapshotting behavior..build(): Finalize the definition.
Get a handle to interact with a specific entity:
const ref = manager.get("entity-id");
await ref.send.someCommand(); // Execute a command (may modify state)
await ref.read.someQuery(); // Execute a query (read-only)
ref.stream.streamingCommand(); // Get AsyncIterable for streaming commands
await ref.snapshot(); // Get current state and version
await ref.stateAt(version); // Get historical state at a specific event version
await ref.stop(); // Manually stop this entityProvide a store to persist state changes:
import { create, memoryStore } from "@twoabove/cue";
const manager = create({
definition: Character,
store: new memoryStore(), // or your custom PersistenceAdapter
});Implement the PersistenceAdapter interface to plug in any database:
interface PersistenceAdapter {
getEvents(entityId: string, fromVersion: bigint): Promise<EventRecord[]>;
commitEvent(entityId: string, version: bigint, data: string): Promise<void>;
getLatestSnapshot(entityId: string): Promise<SnapshotRecord | null>;
commitSnapshot(entityId: string, version: bigint, data: string): Promise<void>;
clearEntity?(entityId: string): Promise<void>;
}Enable snapshotting to avoid replaying long event histories:
const Entity = define("Entity")
// ...
.persistence({ snapshotEvery: 100 }) // Snapshot every 100 versions
.build();Migrate entity state without downtime:
// V1
const Character = define("Character").initialState(() => ({
name: "Player",
hitPoints: 100,
}));
// V2 with evolved schema
const Character = define("Character")
.initialState(() => ({ name: "Player", hitPoints: 100 }))
.evolve((v1) => ({
name: v1.name,
health: { current: v1.hitPoints, max: 100 },
mana: 50, // new field
}))
.commands({
takeDamage: (state, amount: number) => {
state.health.current -= amount;
},
})
.build();When an old entity loads, Cue automatically runs upcasters to migrate its state.
Query historical state at any event version with full type safety:
import { create, define, type HistoryOf, type VersionState } from "@twoabove/cue";
const Character = define("Character")
.initialState(() => ({ hp: 100 }))
.evolve((v1) => ({ health: { current: v1.hp, max: 100 } }))
.evolve((v2) => ({ ...v2, mana: 50 }))
.commands({
damage: (s, amount: number) => {
s.health.current -= amount;
},
})
.build();
const app = create({ definition: Character, store: new memoryStore() });
const hero = app.get("hero-1");
// Make some changes
await hero.send.damage(10);
await hero.send.damage(5);
// Query historical state
const atV1 = await hero.stateAt(1n);
console.log(atV1.schemaVersion); // 3
console.log(atV1.state.health.current); // 90The return type of stateAt() is a discriminated union of all schema versions:
// Extract the full history union
type CharacterHistory = HistoryOf<typeof Character>;
// | { schemaVersion: 1; state: { hp: number } }
// | { schemaVersion: 2; state: { health: { current: number; max: number } } }
// | { schemaVersion: 3; state: { health: { current: number; max: number }; mana: number } }
// Extract a specific version's state type
type CharacterV1 = VersionState<typeof Character, 1>; // { hp: number }
type CharacterV2 = VersionState<typeof Character, 2>; // { health: { current: number; max: number } }
// TypeScript narrows correctly in switch statements
function renderHistorical(h: CharacterHistory) {
switch (h.schemaVersion) {
case 1:
return `HP: ${h.state.hp}`;
case 2:
return `Health: ${h.state.health.current}/${h.state.health.max}`;
case 3:
return `Health: ${h.state.health.current}, Mana: ${h.state.mana}`;
}
}This enables building debug UIs, audit logs, and replay systems with compile-time guarantees.
Handle errors declaratively:
import { create, supervisor } from "@twoabove/cue";
const mySupervisor = supervisor({
stop: (_state, err) => err.name === "CatastrophicError",
reset: (_state, err) => err.name === "CorruptionError",
resume: (_state, err) => err.name === "ValidationError",
default: "resume",
});
const manager = create({
definition: MyEntity,
supervisor: mySupervisor,
});Strategies:
- resume: Error bubbles up, entity stays healthy
- reset: Clear persisted history, reinitialize state
- stop: Entity enters failed state, rejects all future messages
Automatically evict idle entities to save memory:
const manager = create({
definition: MyEntity,
store: myStore,
passivation: {
idleAfter: 5 * 60 * 1000, // Evict after 5 minutes
sweepInterval: 60 * 1000, // Check every minute
},
});Entities rehydrate transparently when accessed again.
Hook into entity lifecycle events:
const manager = create({
definition: MyEntity,
metrics: {
onHydrate: (id) => console.log(`${id} hydrated`),
onEvict: (id) => console.log(`${id} evicted`),
onError: (id, error) => console.error(`${id} failed:`, error),
onSnapshot: (id, version) => console.log(`${id} snapshot at v${version}`),
onAfterCommit: (id, version, patch) => {
/* ... */
},
onBeforeSnapshot: (id, version) => {
/* ... */
},
onHydrateFallback: (id, reason) => {
/* ... */
},
},
});Cue uses SuperJSON under the hood, so Date, Map, Set, BigInt, and RegExp values survive serialization automatically.
Under the hood, Cue uses event sourcing. Every state change is recorded as a patch. Entities rebuild from their history on load, with periodic snapshots for efficiency.
But you don't need to think about events. Write mutations directly—Cue captures them automatically via Immer.
Creates a new entity definition builder.
const Entity = define("Entity")
.initialState(() => ({ ... }))
.commands({ ... })
.queries({ ... })
.evolve((prev) => ({ ... }))
.persistence({ snapshotEvery: 100 })
.build();Creates an entity manager.
const manager = create({
definition: Entity, // Required: entity definition
store: new memoryStore(), // Optional: persistence adapter
supervisor: mySupervisor, // Optional: error handling
passivation: { ... }, // Optional: idle eviction
metrics: { ... }, // Optional: lifecycle hooks
});const ref = manager.get("id");
ref.send.command(...args); // Execute command, returns Promise
ref.read.query(...args); // Execute query, returns Promise
ref.stream.command(...args); // Returns AsyncIterable for streaming commands
ref.snapshot(); // Returns Promise<{ state, version }>
ref.stateAt(eventVersion); // Returns Promise<{ schemaVersion, state }>
ref.stop(); // Stop and release this entityimport { HistoryOf, VersionState, StateOf } from "@twoabove/cue";
type History = HistoryOf<typeof Entity>; // Discriminated union of all versions
type V2State = VersionState<typeof Entity, 2>; // State type at schema version 2
type Current = StateOf<typeof Entity>; // Current state typeMIT License