Persistence, audit, and messaging
The datastore
Persistence lives in riverbase_core::datastore:
| Type | Role |
|---|---|
DataStore | Resource-keyed reads, queries, writes, hard remove, and cache invalidation |
ResourceRegistry | Allow-list that maps public resource strings to ResourceName |
PgDataStore | The Postgres implementation, dispatching each resource to a registered entity |
DataStoreInit | Builds the store for a domain from its DomainStoreSpec |
DataQuery carries the source, client filter, server policy filter, ordering, projection, joins,
and pagination. PgDataStore routes each resource to an ErasedEntity, which is usually generated
by pg_domain_entity!. Custom executors can serve a query resource directly.
Postgres is the only backend. The in-memory store was removed, so tests run against a real database.
DataStore serves both reads and writes and is broad enough to bypass typed invariants if used
carelessly. The direction is to keep it for read models and framework metadata, and to add typed
repositories for aggregate state.
Shared row metadata
Framework-backed tables carry domain fields, declared once with diesel_table_with_domain_fields!:
| Column | Meaning |
|---|---|
_id | Row UUID |
_created, _creator | Creation time and actor |
_updated, _updater | Last update time and actor |
_etag | Optimistic concurrency token |
_tenant | Tenant UUID, stamped at creation |
_deleted | Soft-delete marker |
AggregateCore fills these from AggregateContext on every write. _tenant is a UUID from the
caller's authorization context (usually the organization). It is not the auth realm string.
Audit channels
riverbase_core::logstore defines one append-only store per channel, bundled as DomainLogStore:
| Channel | Written by |
|---|---|
| context and command | The command engine, before dispatch |
| event | A successful #[domain_action] (or enqueue_action_event) |
| message | AggregateCore::emit_message |
| activity | AggregateCore::emit_activity |
| query | The query engine (off by default) |
| response and idempotency | The command engine, after dispatch |
Each channel can be switched off in [riverbase.audit_log]. A disabled channel gets a no-op store.
On Postgres the tables live in the riverbase_audit schema. A command row is logged as Created
and updated to a terminal success or error status when the unit of work completes.
Messages and the outbox
MessageBus is the publication port. Implementations cover NATS (the production default), Redis, and
Postgres LISTEN/NOTIFY. All three also implement StreamBus, the subscribe side that fans realtime
updates out across replicas.
Messages emitted during a command become outbox rows in the same transaction as the state
change. After commit, the outbox publisher, started by DomainRuntime::from_config, delivers them:
- at least once: a crash after publish and before marking can redeliver;
- with retries: failed deliveries are retried;
- so consumers must be idempotent.
Durable workflows
Multi-step work that spans transactions or domains uses a process manager: rows in
ProcessManagerStore with optimistic concurrency and leased claiming (claim_due), driven by
spawn_process_manager_worker. Register handlers on DomainRuntime::process_registry.
Job scheduling by itself (riverbase_task) does not make a workflow durable.
Deletion
DataStore::remove and invalidate are low-level primitives. They do not decide whether a root may
be deleted, what happens to its children, or what retention applies. Every aggregate root declares
its deletion semantics: soft, hard, or prohibited.
Consistency guarantees
| Situation | Guarantee |
|---|---|
| State and audit in one Postgres database | State, status, response, idempotency, audit effects, and outbox rows commit atomically |
| State and audit in different databases | Weaker; the deployment must state and reconcile it |
| Handler opens its own connection mid-command | Outside the transaction: non-conforming |
| Outbox delivery | At least once, after commit |
Data errors surface as structured RiverbaseError values with DAT-* codes. See Errors.