Store Contract
Everything the engine persists goes through one interface, IStore. It is a set of small interfaces, so a store can be built and tested part by part.
| Interface | Holds | Key operations |
|---|---|---|
IRunStore | Runs | createRun (insert-if-absent), getRun, updateRun with expectStatus/expectOwner guards, listRuns, claimRun/renewClaim leases |
IStepStore | Append-only steps per run | appendStep, listSteps |
ICheckpointStore | The latest checkpoint per run | saveCheckpoint, latestCheckpoint |
IIdempotencyStore | Intake keys | claimKey(scope, key, runId, ttlMs): the first caller wins |
ILockStore | Keyed leases | acquire(key, owner, ttlMs), release(key, owner) |
IEffectStore | Recorded effect results | getEffect, putEffect |
IStateStore | Per-agent state | kvGet/kvSet, docList/docUpsert/docDelete |
IStore extends all seven.
Rules every store keeps
- One expiry rule. A claim, key or lock created at time
TwithttlMsis expired at exactlyT + ttlMsand can be taken over at that instant. - Time comes from the clock. Expiry decisions use an injected
IClock, never the database's clock, so tests can drive time and replicas agree on one rule. - No aliasing. Values are copied in and out. Mutating an object after passing it to the store, or one the store returned, never changes what the store holds.
- Atomic means atomic across callers.
createRun,claimKey,claimRun,renewClaimandacquiremust hold under concurrency, also across processes. - Ordering.
listRunsorders bycreatedAtascending, ties broken byidascending;totalignoreslimitandoffset. - Guards throw.
updateRunthrowsInvalidRunStateErrorwhenexpectStatusdoes not match, or whenexpectOwneris given and the claim is absent or held by another owner (expiry is not checked there: a lease is lost once taken over). It throwsRunNotFoundErrorfor an unknown run. - Checkpoints.
saveCheckpointkeeps the higheststepIndex; an equal index replaces. - State filters.
docListwherematches top-level fields by equality.
The interfaces
ts
/*
* Copyright (c) 2026.
* Author Peter Placzek (tada5hi)
* For the full copyright and license information,
* view the LICENSE file that was distributed with this source code.
*/
import type {
Checkpoint,
Run,
RunStatusLiteral,
Step,
} from '../run/types.ts';
/**
* Expiry rule shared by every TTL (run claims, idempotency keys, locks):
* a lease/key created at time T with ttlMs expires AT T + ttlMs, i.e. at exactly
* `expiresAt` it already counts as expired and can be taken over.
*
* Implementations must return copies: mutating a value passed in after the call,
* or a value returned from the store, must never change what the store holds.
*/
export interface IRunStore {
/**
* Atomic insert-if-absent: resolves false and keeps the stored run when a run
* with that id exists (two dispatches may create the run an idempotency key names).
*/
createRun(run: Run): Promise<boolean>;
getRun(id: string): Promise<Run | undefined>;
/**
* Throws InvalidRunStateError when `expectStatus` does not match the current
* status, or when `expectOwner` is given and the run's claim is absent or held
* by another owner (expiry is not checked: a lease is lost once taken over).
*/
updateRun(id: string, patch: Partial<Run>, opts?: { expectStatus?: RunStatusLiteral[]; expectOwner?: string }): Promise<Run>;
/** Ordered by createdAt ascending, ties broken by id ascending. `total` ignores limit/offset. */
listRuns(q: {
agent?: string;
status?: RunStatusLiteral[];
concurrencyKey?: string;
limit?: number;
offset?: number;
}): Promise<{ data: Run[]; total: number }>;
/** Atomic: only succeeds when unleased or the lease expired. */
claimRun(id: string, owner: string, ttlMs: number): Promise<boolean>;
/** Extends the lease; only succeeds for the current, unexpired owner. */
renewClaim(id: string, owner: string, ttlMs: number): Promise<boolean>;
}
export interface IStepStore {
appendStep(step: Step): Promise<void>;
listSteps(runId: string): Promise<Step[]>;
}
export interface ICheckpointStore {
/** Keeps the highest stepIndex; an equal stepIndex replaces (approval responses are appended that way). */
saveCheckpoint(cp: Checkpoint): Promise<void>;
latestCheckpoint(runId: string): Promise<Checkpoint | undefined>;
}
export interface IIdempotencyStore {
/** Atomic: the first caller per (scope, key) creates; others get the winner's runId. */
claimKey(scope: string, key: string, runId: string, ttlMs: number): Promise<{ created: boolean; runId: string }>;
}
export interface ILockStore {
acquire(key: string, owner: string, ttlMs: number): Promise<boolean>;
release(key: string, owner: string): Promise<void>;
}
export interface IEffectStore {
getEffect(key: string): Promise<{ result: unknown } | undefined>;
putEffect(key: string, result: unknown): Promise<void>;
}
export interface IStateStore {
kvGet(agent: string, key: string): Promise<unknown>;
kvSet(agent: string, key: string, value: unknown): Promise<void>;
/** `where` matches by top-level equality. */
docList(
agent: string,
coll: string,
q?: { where?: Record<string, unknown>; limit?: number },
): Promise<Array<{ id: string; doc: unknown }>>;
docUpsert(agent: string, coll: string, id: string, doc: unknown): Promise<void>;
docDelete(agent: string, coll: string, id: string): Promise<void>;
}
export interface IStore extends IRunStore, IStepStore, ICheckpointStore, IIdempotencyStore, ILockStore, IEffectStore, IStateStore {}Stores
| Store | Package | Status |
|---|---|---|
MemoryStore | @chamberu/core | Shipped. Development and tests |
| Postgres / TypeORM | @chamberu/store-typeorm | Planned |
Redis is never required. Any store that passes the contract suite from @chamberu/test-kit works with the engine.