Skip to content

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.

InterfaceHoldsKey operations
IRunRepositoryRunsinsert (insert-if-absent), findOne, update with expectStatus/expectOwner guards, find, claim/renewClaim leases
IStepRepositoryAppend-only steps per runinsert, find
ICheckpointRepositoryThe latest checkpoint per runsave, findOne
IIdempotencyRepositoryIntake keysclaim(scope, key, runId, ttlMs): the first caller wins
ILockRepositoryKeyed leasesacquire(key, owner, ttlMs), release(key, owner)
IEffectRepositoryRecorded effect resultsfindOne, save
IStateRepositoryPer-agent statefindOne/set (undefined removes the key), find (key code-point order, limit/offset), findDocuments/upsertDocument/deleteDocument
IArtifactRepositoryFiles of a run (attachments, files tools stored)insert(artifact, data) (insert-if-absent), save(artifact, data) (replacing upsert), findOne (metadata), read (bytes), find(runId)
IAgentRevisionRepositoryOne revision marker per folder agent: { agent, hash, changedAt }, no codefindOne, 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 notesfindOne, 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)
TypeormStoreOptionsDatabaseStoreOptions
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, IAgentRevisionStoreIRunRepository, 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 PurgeResultstore.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.

ts
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 ​

  1. One expiry rule. A claim, key or lock created at time T with ttlMs is expired at exactly T + ttlMs and can be taken over at that instant.
  2. 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.
  3. 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.
  4. Atomic means atomic across callers. runs.insert, idempotency.claim, runs.claim, runs.renewClaim, locks.acquire, artifacts.insert and artifacts.save must hold under concurrency, also across processes.
  5. Ordering. runs.find orders by createdAt ascending, ties broken by id ascending, or both descending with order: 'desc' (newest first); total ignores limit and offset. artifacts.find orders the same way. agentRevisions.find orders by agent name in code-point order.
  6. Guards throw. runs.update throws InvalidRunStateError when expectStatus does not match, or when expectOwner is given and the claim is absent or held by another owner (expiry is not checked there: a lease is lost once taken over). It throws RunNotFoundError for an unknown run.
  7. Checkpoints. checkpoints.save keeps the highest stepIndex; an equal index replaces.
  8. State filters. state.findDocuments where matches top-level fields by equality.
  9. Run references. steps.insert, checkpoints.save and conversations.beginTurn for a run the store does not hold throw RunNotFoundError and write nothing. createMemoryStore() checks its own run repository; the database store maps the foreign key violation.
  10. Artifacts. artifacts.insert resolves false and keeps the stored artifact when the id exists. artifacts.save inserts an absent id, replaces the metadata (a missing filename removes it) and bytes of an id held by the same run while keeping its createdAt, and throws InvalidRunStateError without writing for an id held by another run. Bytes are copied in and out like every other value, and read returns a plain Uint8Array (no Buffer or other subclass). findOne and find return metadata only.

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 { 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 ​

StorePackageStatus
createMemoryStore()@chamvi/coreShipped. Development and tests
Database (store-db)@chamvi/store-dbShipped. PostgreSQL, production

Redis is never required. Any store that passes the contract suite from @chamvi/test-kit works with the engine.