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 latestinterface 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.
saveRequestwrites only when the stored version is the expected one, and throwsConflictErrorotherwise (EC-15). - Exact tenant matching in every list (EC-62).
- The same views. Build inbox, request and blocked-step items with
taskViews,requestSummaryandblockedStepViewfrom@rungsjs/core/store. - Plain JSON. Never hand the service an object it could mutate later, and never keep one it passed in.
Prove it with the shared suites
Section titled “Prove it with the shared suites”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.