Capability Check
Capability-typed data access, where every query declares what it reads and writes and the compiler checks that the handle running it may do so.
wakuwaku's data layer lives in wakuwaku::entity (the wakuwaku-entity crate). It runs on PostgreSQL and MySQL through sqlx, on Redis, and on Meilisearch. The same three ideas apply to all of them:
- Queries are values. A query is a plain struct such as
FindUser { id: 7 }that describes one operation, with its ownOutputand its ownError. It is not a method on a repository object. - Handles are processors. A
Db<S, C>handle implements kanau'sProcessor<Q>for every queryQit may run, so running a query isdb.process(query).await. Cron jobs and event handlers use the same trait. Hooks, transactions and test doubles all build on it. - Capability types must cover effects. Each query declares its
Effect, the entities it reads and writes. Each handle carries a capability typeC.Db<S, C>implementsProcessor<Q>only whenC: Covers<Q::Effect>. If a write query is sent to a read-only handle, the program doesn't compile.
In Spring you would usually split reads and writes with @Transactional(readOnly = true) and a routing DataSource that picks the replica at runtime. If the routing is wrong, you find out from an error raised by the replica. wakuwaku puts the routing decision in the handle's type, so a handler wired to the replica handle can't issue a write query at all.
Entities
An Entity is a name at the type level for something a query can read or write: a table, a Redis key space, a search index.
use wakuwaku::entity::Entity;
/// The `users` table.
pub struct User;
impl Entity for User {}
/// The `posts` table.
pub struct Post;
impl Entity for Post {}Entity types are never constructed, and they aren't tied to a data source. The same User can live in PostgreSQL and be cached in Redis. Choose whatever granularity you want the compiler to check. One entity per table is a good default.
Capabilities
A capability is a type that is never constructed. It describes what a handle may do through two marker traits:
| Trait | Meaning |
|---|---|
CanRead<E> | may run queries that read E |
CanWrite<E> | may run queries that write E; it requires CanRead<E>, because writing implies reading |
Two capabilities come with the crate, and both cover every entity:
| Capability | Reads | Writes | Typical use |
|---|---|---|---|
ReadOnly | every entity | none | a handle connected to a read replica |
ReadWrite | every entity | every entity | a handle connected to the primary |
When a component may write only some entities, define your own capability by implementing the markers:
use wakuwaku::entity::{CanRead, CanWrite, Covers, Entity, Read, ReadOnly, ReadWrite, Write};
pub struct Order;
impl Entity for Order {}
pub struct AuditLog;
impl Entity for AuditLog {}
/// Reads everything, writes only the audit log.
pub enum Auditor {}
impl<E: Entity> CanRead<E> for Auditor {}
impl CanWrite<AuditLog> for Auditor {}A Db<_, Auditor> handle can run any read query and any query that writes only AuditLog. A query that writes Order fails to compile on it.
Effects
A query's Effect is a type-level set of entity accesses:
| Effect | Meaning | Kind |
|---|---|---|
Read<E> | reads E | ReadKind |
Write<E> | writes E | WriteKind |
() | touches nothing (SELECT 1, PING) | ReadKind |
(A, B, …) | the union of its elements; up to 12 elements, and tuples nest | WriteKind if any element writes |
Every effect has a Kind: WriteKind if it writes anything, ReadKind otherwise. The kind decides whether write hooks run for a query and whether a transaction starts as read-only.
C: Covers<X> holds when the capability C has the CanRead / CanWrite marker for every access in X. It is implemented automatically from the markers; don't implement it by hand. You can use it to state facts about your capabilities as compile-time assertions:
// Compile-time checks of which capability covers which effect.
fn covers<C: Covers<X>, X>() {}
pub fn checks() {
covers::<ReadOnly, (Read<Order>, Read<AuditLog>)>();
covers::<ReadWrite, (Read<Order>, Write<Order>)>();
covers::<Auditor, (Read<Order>, Write<AuditLog>)>();
covers::<ReadOnly, ()>();
// Nested tuples are unions too.
covers::<Auditor, ((Read<Order>,), (Read<AuditLog>, Write<AuditLog>))>();
}Adding covers::<ReadOnly, Write<Order>>() to that list would fail to compile.
What the types prove, and what they don't
The capability check proves routing: code wired to a Db<_, ReadOnly> never issues a query that is declared as a write. Some things are outside what the compiler can see:
- The declaration itself. The SQL inside a query is not checked against its
Effect. A query declared asRead<User>that runs anUPDATEcompiles fine. Declare every entity the statement touches. - Where the connection points.
Db::newtakes the capability from the type you write. It can't know whether the pool behind it is connected to a primary or a replica. Only build a capability that writes from a source connected to a writable database, and do the wiring in one place.
Keep the database's own guard on replicas
Because a query declared as a read might still write, keep the database's own protection in place. A PostgreSQL hot standby rejects writes. On a primary, a role used for read-only access can get the same protection:
ALTER ROLE app_reader SET default_transaction_read_only = on;The check can't be bypassed through the handle. Db keeps its data source private, and the sqlx source keeps its pool private, so the only way to run something is a query whose effect the capability covers. Run migrations and other setup on the pool before you wrap it.
Features
Turn on the data sources you need as wakuwaku features. Each one enables the entity module.
[dependencies]
wakuwaku = { version = "1.0.0-rc3", features = ["sqlx-pg"] }| Feature | Data source | Third-party crate | Guide |
|---|---|---|---|
entity | none: entities, effects, queries, Db, hooks | this section | |
sqlx-pg | entity::sqlx::backend::PgSource | wakuwaku::deps::sqlx | sqlx, Transactions |
sqlx-mysql | entity::sqlx::backend::MySqlSource | wakuwaku::deps::sqlx | sqlx, Transactions |
redis | entity::redis::RedisSource | wakuwaku::deps::redis | Redis |
meilisearch | entity::meilisearch::MeiliSource | wakuwaku::deps::meilisearch_sdk | Meilisearch |
entity alone is useful for a crate that only defines entities and queries, or for implementing your own DataSource. Import the third-party crates through wakuwaku::deps so their versions always match the ones wakuwaku was built against.
In this section
Queries and handles
Define queries, build Db handles, route reads to a replica, and inject handles into components.
Write hooks
Audit, invalidate caches, and sync search indexes around every write.
sqlx
PostgreSQL and MySQL data sources.
Transactions
Several queries atomically, written as a state machine whose steps run queries.
Redis
Caches, locks, rate limiters, and live structures.
Meilisearch
Search indexes as entities, kept in sync with their source.
Error handling
Classify handler failures as Transient, Retryable or Unrecoverable, and understand the retry topic, key quarantine and what is dropped.
Queries and Handles
Define entities and queries, run them through capability-typed Db handles, route reads to a replica, and inject the handles into your components.