Skip to content

Store contract

Rungs ships a memory store and a PostgreSQL store. Another database needs an adapter that implements RungsStore: one method that runs a function in a transaction, and the reads and writes the service needs inside it. Everything passed in and out is plain JSON. The interface and its helpers come from @rungsjs/core/store, the entry point for adapter authors.

interface RungsStore {
/** Runs `fn` atomically: all writes commit together, or none do when `fn` throws. */
transaction<T>(fn: (tx: StoreTx) => Promise<T>): Promise<T>;
}
/** A flow or idempotency key belongs to one tenant, or is global when `tenantId` is omitted. */
interface FlowRef { key: string; tenantId?: string; version?: number } // no version: the latest
interface IdempotencyRef { key: string; tenantId?: string }
interface EventKey { requestId: string; seq: number }
interface OutboxEntry {
event: DomainEvent;
attempts: number; // failed deliveries so far
nextAttemptAt: string; // ISO 8601 UTC
lastError?: string;
}
interface IdempotentRecord {
fingerprint: string; // canonical form of the command, to refuse a reused key
result: RequestState; // what the first call returned
at: string;
}
interface StoreTx {
// Flows. Versions are numbered per (tenantId, key). getFlow matches the scope exactly;
// the service looks up the tenant's flow first and then the global one.
getFlow(ref: FlowRef): Promise<RegisteredFlow | null>;
saveFlow(flow: RegisteredFlow): Promise<void>; // flow.tenantId sets the scope
// Requests. saveRequest is a compare-and-set on `version`: expectedVersion null means
// "must not exist yet". A mismatch throws ConflictError (EC-15).
getRequest(id: string, opts?: { forUpdate?: boolean }): Promise<RequestState | null>;
saveRequest(state: RequestState, expectedVersion: number | null): Promise<void>;
appendAudit(events: AuditEvent[]): Promise<void>;
findDue(now: string, limit: number, options?: { claim?: boolean }): Promise<string[]>; // nextDueAt <= now
// Outbox (see "Event delivery").
enqueueEvents(events: DomainEvent[], now: string): Promise<void>; // attempts 0, due now
pendingEvents(q: { now: string; limit: number; requestId?: string; claim?: boolean }): Promise<OutboxEntry[]>;
markDispatched(keys: EventKey[], now?: string): Promise<void>;
markFailed(
key: EventKey,
failure: { attempts: number; nextAttemptAt: string; error: string },
): Promise<void>;
// Idempotency (EC-16), written in the same transaction as the command.
getIdempotent(ref: IdempotencyRef): Promise<IdempotentRecord | null>;
putIdempotent(ref: IdempotencyRef, record: IdempotentRecord): Promise<void>;
// Queries.
getAudit(requestId: string): Promise<AuditEvent[]>; // ordered by seq
findInbox(query: InboxQuery): Promise<TaskView[]>; // see below
findRequests(query: RequestQuery): Promise<RequestSummary[]>; // see below
findBlocked(query: BlockedQuery): Promise<BlockedStepView[]>; // see below
// Delegation rules. With a tenantId: that tenant's rules and rules without one; without: only
// rules without one. The engine filters by time, scope, tenant and cancelledAt.
findDelegations(fromUserIds: string[], tenantId?: string): Promise<DelegationRule[]>;
saveDelegation(rule: DelegationRule): Promise<void>; // insert or replace by id
getDelegation(id: string): Promise<DelegationRule | null>;
listDelegations(query: { userId: string; tenantId?: string }): Promise<DelegationRule[]>; // from or to
}

Rules every adapter keeps:

  • Atomic transactions. All writes of a transaction commit together, or none do when the function throws.
  • Compare-and-set. saveRequest writes only when the stored version is the expected one, and throws ConflictError otherwise (EC-15).
  • Exact tenant matching in every list (EC-62).
  • The same views. Build inbox, request and blocked-step items with taskViews, requestSummary and blockedStepView from @rungsjs/core/store.
  • Plain JSON. Never hand the service an object it could mutate later, and never keep one it passed in.

The repository’s private @rungsjs/store-contract package holds the tests both shipped stores pass: storeContract(name, makeStore) for the store itself and serviceSuite(name, makeStore) for createRungs on top of it. Run both from your adapter’s tests; each test gets a fresh store from makeStore.