Stores and repositories
The engine receives an IStore object with one implementation per persistence capability. Each role has its own small interface and can be built and tested independently.
| Interface | Holds | Key operations |
|---|---|---|
IRunRepository | Runs | insert (insert-if-absent), findOne, update with expectStatus/expectOwner guards, find, claim/renewClaim leases |
IStepRepository | Append-only steps per run | insert, find |
ICheckpointRepository | The latest checkpoint per run | save, findOne |
IIdempotencyRepository | Intake keys | claim(scope, key, runId, ttlMs): the first caller wins |
ILockRepository | Keyed leases | acquire(key, owner, ttlMs), release(key, owner) |
IEffectRepository | Recorded effect results | findOne, save |
IStateRepository | Per-agent state | findOne/set (undefined removes the key), find (key code-point order, limit/offset), findDocuments/upsertDocument/deleteDocument |
IArtifactRepository | Files of a run (attachments, files tools stored) | insert(artifact, data) (insert-if-absent), save(artifact, data) (replacing upsert), findOne (metadata), read (bytes), find(runId) |
IAgentRevisionRepository | One revision marker per folder agent: { agent, hash, changedAt }, no code | findOne, save (upsert), delete (a missing one is a no-op), find (agent code-point order) |
IConversationRepository (optional) | Conversation generations, turn ownership, recall, journal and explicit notes | findOne, findItems, findNotes, findOneNote; resolve, beginTurn, endTurn, prepareEmit, confirmEmit, applyNote, reset, delete, prune |
IStore has nine required fields: runs, steps, checkpoints, idempotency, locks, effects, state, artifacts and agentRevisions. Its optional conversations field implements IConversationRepository. Pass the aggregate as new Engine({ store, agents }); use store.runs.findOne(id) or store.state.findOne(agent, key) for direct access. IStore is a plain aggregate, not a facade with forwarding methods. Conversation storage alone does not enable memory: configure conversations: { authorize, limits? } on the engine and opt agents in.
Updating existing callers
This changes the storage API. IStore now aggregates individual repository interfaces rather than forwarding all operations through one facade. The MemoryStore and TypeormStore classes are removed; custom adapters assemble their capabilities into an IStore object.
| Previous API (0.3) | Store aggregate API (0.4) |
|---|---|
new MemoryStore(options) | createMemoryStore(options) |
new TypeormStore(options) | createDatabaseStore(options) |
TypeormStoreOptions | DatabaseStoreOptions |
IStore extends IRunStore, IStepStore, ... | IStore is a plain aggregate: runs, steps, checkpoints, idempotency, locks, effects, state, artifacts, agentRevisions, optional conversations |
IRunStore, IStepStore, ICheckpointStore, IIdempotencyStore, ILockStore, IEffectStore, IStateStore, IArtifactStore, IAgentRevisionStore | IRunRepository, IStepRepository, ICheckpointRepository, IIdempotencyRepository, ILockRepository, IEffectRepository, IStateRepository, IArtifactRepository, IAgentRevisionRepository |
store.createRun(run) | store.runs.insert(run) |
store.getRun(id) | store.runs.findOne(id) |
store.updateRun(id, patch, opts) | store.runs.update(id, patch, opts) |
store.listRuns(q) | store.runs.find(q) |
store.claimRun(id, owner, ttlMs) | store.runs.claim(id, owner, ttlMs) |
store.renewClaim(id, owner, ttlMs) | store.runs.renewClaim(id, owner, ttlMs) |
store.appendStep(step) | store.steps.insert(step) |
store.listSteps(runId) | store.steps.find(runId) |
store.saveCheckpoint(cp) | store.checkpoints.save(cp) |
store.latestCheckpoint(runId) | store.checkpoints.findOne(runId) |
store.claimKey(scope, key, runId, ttlMs) | store.idempotency.claim(scope, key, runId, ttlMs) |
store.acquire(key, owner, ttlMs), store.release(key, owner) | store.locks.acquire(...), store.locks.release(...) |
store.getEffect(key), store.putEffect(key, result) | store.effects.findOne(key), store.effects.save(key, result) |
store.kvGet(agent, key), store.kvSet(agent, key, value), store.kvList(agent, q) | store.state.findOne(agent, key), store.state.set(agent, key, value), store.state.find(agent, q) |
store.docList(...), store.docUpsert(...), store.docDelete(...) | store.state.findDocuments(...), store.state.upsertDocument(...), store.state.deleteDocument(...) |
store.putArtifact(artifact, data), store.getArtifact(id), store.readArtifact(id), store.listArtifacts(runId) | store.artifacts.insert(artifact, data), store.artifacts.findOne(id), store.artifacts.read(id), store.artifacts.find(runId) |
store.getAgentRevision(agent), store.setAgentRevision(revision), store.deleteAgentRevision(agent), store.listAgentRevisions() | store.agentRevisions.findOne(agent), store.agentRevisions.save(revision), store.agentRevisions.delete(agent), store.agentRevisions.find() |
store.purgeExpired(now?) resolving PurgeResult | store.idempotency.purgeExpired(now?) and store.locks.purgeExpired(now?), each resolving the number of rows deleted (database store) |
Conversation memory is new in 0.4: its storage is store.conversations (both built-in stores carry it) and its policy is createApp({ conversations: { authorize } }) or EngineOptions.conversations. There is nothing to migrate from 0.3.
Keep passing the aggregate through new Engine({ store, agents }), storeModule({ store }) and AppToken.STORE. Factory option types are MemoryStoreOptions and DatabaseStoreOptions; createDatabaseStore() returns DatabaseStore.
The package is now @chamvi/store-db (previously @chamvi/store-typeorm). Replace package imports and dependencies; database tables and migration history keep their names. The class split itself changes no database schema; 0.4's conversation and taint migration does, and it needs downtime: follow Upgrading to 0.4. Existing TypeORM roles must keep sharing the same DataSource so their relationships remain valid.
Query methods
findOne(...) returns one record or undefined. find(...) returns the repository's existing collection shape: run queries return { data, total }, while steps, artifacts, state and agent revisions return arrays. Conversation item/note queries return { data, total } through findItems and findNotes.
The matching OrFail method returns the same result when it exists. findOneOrFail(...) throws for undefined; stored state values such as null, false, 0 and '' are valid results. findOrFail(...) throws when the selected array or page is empty, including a run query with limit: 0 or an offset beyond the last result even when total is nonzero. Query validation is unchanged: conversation queries require limit >= 1, so their limit: 0 is a validation error. Qualified queries follow the same rule: findDocumentsOrFail, findItemsOrFail, findNotesOrFail and findOneNoteOrFail.
A missing single run or artifact uses RunNotFoundError or ArtifactNotFoundError; other missing repository results use RepositoryNotFoundError. Backend failures and validation errors pass through unchanged. Leases and mutations retain their domain operations, such as claim, renewClaim and beginTurn.
const run = await store.runs.findOneOrFail(runId);
const { data, total } = await store.runs.find({ agent: run.agent, limit: 20 });
const artifacts = await store.artifacts.find(runId);Rules every repository 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.
runs.insert,idempotency.claim,runs.claim,runs.renewClaim,locks.acquire,artifacts.insertandartifacts.savemust hold under concurrency, also across processes. - Ordering.
runs.findorders bycreatedAtascending, ties broken byidascending, or both descending withorder: 'desc'(newest first);totalignoreslimitandoffset.artifacts.findorders the same way.agentRevisions.findorders by agent name in code-point order. - Guards throw.
runs.updatethrowsInvalidRunStateErrorwhenexpectStatusdoes 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.
checkpoints.savekeeps the higheststepIndex; an equal index replaces. - State filters.
state.findDocumentswherematches top-level fields by equality. - Run references.
steps.insert,checkpoints.saveandconversations.beginTurnfor a run the store does not hold throwRunNotFoundErrorand write nothing.createMemoryStore()checks its own run repository; the database store maps the foreign key violation. - Artifacts.
artifacts.insertresolvesfalseand keeps the stored artifact when the id exists.artifacts.saveinserts an absent id, replaces the metadata (a missingfilenameremoves it) and bytes of an id held by the same run while keeping itscreatedAt, and throwsInvalidRunStateErrorwithout writing for an id held by another run. Bytes are copied in and out like every other value, andreadreturns a plainUint8Array(noBufferor other subclass).findOneandfindreturn metadata only.
The interfaces
/*
* 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 { AgentRevision } from '../agent/types.ts';
import type { Artifact } from '../artifact/types.ts';
import type { IConversationRepository } from '../conversation/types.ts';
import type {
Checkpoint,
Run,
RunStatusLiteral,
Step,
} from '../run/types.ts';
export type RunFindOptions = {
agent?: string;
status?: RunStatusLiteral[];
concurrencyKey?: string;
limit?: number;
offset?: number;
order?: 'asc' | 'desc';
};
export type StateFindOptions = { limit?: number; offset?: number };
export type StateDocumentFindOptions = { where?: Record<string, unknown>; limit?: number };
/**
* Query contract: findOne returns undefined when absent; find returns an empty
* result when nothing matches. Their OrFail counterparts throw on absence or an
* empty selected page, even if records exist outside that page. Falsy values
* (including null) count as present. They preserve each query's ordering and copies.
*
* 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 a repository, must never change what it holds.
*/
export interface IRunRepository {
/**
* 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).
*/
insert(run: Run): Promise<boolean>;
findOne(id: string): Promise<Run | undefined>;
findOneOrFail(id: string): Promise<Run>;
/**
* 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).
*/
update(id: string, patch: Partial<Run>, opts?: { expectStatus?: RunStatusLiteral[]; expectOwner?: string }): Promise<Run>;
/**
* Ordered by createdAt, ties broken by id, both ascending (`order` `asc`,
* the default) or both descending (`desc`). `total` ignores limit/offset.
* Without `limit`, every matching run (from `offset`) is returned: there is
* no default page size.
*/
find(q: RunFindOptions): Promise<{ data: Run[]; total: number }>;
findOrFail(q: RunFindOptions): Promise<{ data: Run[]; total: number }>;
/** Atomic: only succeeds when unleased or the lease expired. */
claim(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 IStepRepository {
/** The parent run must already exist. */
insert(step: Step): Promise<void>;
find(runId: string): Promise<Step[]>;
findOrFail(runId: string): Promise<Step[]>;
}
export interface ICheckpointRepository {
/**
* Keeps the highest stepIndex; an equal stepIndex replaces (approval responses are appended that way).
* The parent run must already exist.
*/
save(cp: Checkpoint): Promise<void>;
findOne(runId: string): Promise<Checkpoint | undefined>;
findOneOrFail(runId: string): Promise<Checkpoint>;
}
export interface IIdempotencyRepository {
/** Atomic: the first caller per (scope, key) creates; others get the winner's runId. */
claim(scope: string, key: string, runId: string, ttlMs: number): Promise<{ created: boolean; runId: string }>;
}
export interface ILockRepository {
acquire(key: string, owner: string, ttlMs: number): Promise<boolean>;
release(key: string, owner: string): Promise<void>;
}
export interface IEffectRepository {
findOne(key: string): Promise<{ result: unknown } | undefined>;
findOneOrFail(key: string): Promise<{ result: unknown }>;
save(key: string, result: unknown): Promise<void>;
}
export interface IStateRepository {
findOne(agent: string, key: string): Promise<unknown>;
findOneOrFail(agent: string, key: string): Promise<unknown>;
/** `undefined` removes the key. */
set(agent: string, key: string, value: unknown): Promise<void>;
/**
* One agent's entries ordered by key in code-point order (`COLLATE "C"`),
* values copied out; every entry (from `offset`) without `limit`.
*/
find(agent: string, q?: StateFindOptions): Promise<Array<{ key: string; value: unknown }>>;
findOrFail(agent: string, q?: StateFindOptions): Promise<Array<{ key: string; value: unknown }>>;
/** `where` matches by top-level equality. */
findDocuments(
agent: string,
coll: string,
q?: StateDocumentFindOptions,
): Promise<Array<{ id: string; doc: unknown }>>;
findDocumentsOrFail(
agent: string,
coll: string,
q?: StateDocumentFindOptions,
): Promise<Array<{ id: string; doc: unknown }>>;
upsertDocument(agent: string, coll: string, id: string, doc: unknown): Promise<void>;
deleteDocument(agent: string, coll: string, id: string): Promise<void>;
}
/** Files of runs (attachments and tool outputs): metadata and bytes, both copied in and out. */
export interface IArtifactRepository {
/**
* Atomic insert-if-absent: resolves false and keeps the stored artifact
* (metadata and bytes) when an artifact with that id exists.
*/
insert(artifact: Artifact, data: Uint8Array): Promise<boolean>;
/**
* Atomic upsert by id. An absent id inserts the artifact as given; an id held by
* the same run replaces `mediaType`, `filename` (removed when absent), `size` and
* the bytes, keeps the stored `createdAt` and resolves the stored metadata; an id
* held by another run throws `InvalidRunStateError` and writes nothing.
*/
save(artifact: Artifact, data: Uint8Array): Promise<Artifact>;
/** Metadata only. */
findOne(id: string): Promise<Artifact | undefined>;
findOneOrFail(id: string): Promise<Artifact>;
/** The bytes, as a plain Uint8Array (no Buffer or other subclass). */
read(id: string): Promise<Uint8Array | undefined>;
/** Metadata of a run's artifacts, ordered by createdAt ascending, ties broken by id ascending. */
find(runId: string): Promise<Artifact[]>;
findOrFail(runId: string): Promise<Artifact[]>;
}
/** One revision marker per folder agent, keyed by agent name, copied in and out. */
export interface IAgentRevisionRepository {
findOne(agent: string): Promise<AgentRevision | undefined>;
findOneOrFail(agent: string): Promise<AgentRevision>;
/** Upsert: replaces the hash and `changedAt` of an existing marker. */
save(revision: AgentRevision): Promise<void>;
/** Deleting a missing marker is a no-op. */
delete(agent: string): Promise<void>;
/** Every marker, ordered by agent in code-point order (`COLLATE "C"`). */
find(): Promise<AgentRevision[]>;
findOrFail(): Promise<AgentRevision[]>;
}
/** A store of repositories that may use independent storage implementations. */
export interface IStore {
runs: IRunRepository;
steps: IStepRepository;
checkpoints: ICheckpointRepository;
idempotency: IIdempotencyRepository;
locks: ILockRepository;
effects: IEffectRepository;
state: IStateRepository;
artifacts: IArtifactRepository;
agentRevisions: IAgentRevisionRepository;
conversations?: IConversationRepository;
}Store implementations
| Store | Package | Status |
|---|---|---|
createMemoryStore() | @chamvi/core | Shipped. Development and tests |
| Database (store-db) | @chamvi/store-db | Shipped. PostgreSQL, production |
Redis is never required. Any store that passes the contract suite from @chamvi/test-kit works with the engine.