wakuwaku
Data Access

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:

  1. Queries are values. A query is a plain struct such as FindUser { id: 7 } that describes one operation, with its own Output and its own Error. It is not a method on a repository object.
  2. Handles are processors. A Db<S, C> handle implements kanau's Processor<Q> for every query Q it may run, so running a query is db.process(query).await. Cron jobs and event handlers use the same trait. Hooks, transactions and test doubles all build on it.
  3. Capability types must cover effects. Each query declares its Effect, the entities it reads and writes. Each handle carries a capability type C. Db<S, C> implements Processor<Q> only when C: Covers<Q::Effect>. If a write query is sent to a read-only handle, the program doesn't compile.
A query declares its effect; a Db handle must cover itQueries declare an EffectHandles carry a capabilityDatabasesFindUsertype Effect = Read<User>;ReadKind: only readsDeletePosttype Effect = Write<Post>;WriteKind: writesDb<PgSource, ReadOnly>impl CanRead<E> for every Emay read every entityDb<PgSource, ReadWrite>CanRead<E> + CanWrite<E>may read and write every entityReplicaread-onlyPrimaryread-writeC: Covers<Q::Effect>holds, so the call compilesdoes not compile:error: `ReadOnly: CanWrite<Post>` is not satisfiedThe types prove routing only. Keep the replica’s own read-only guard as well.
Every query names the entities it reads and writes. Db<S, C> implements Processor<Q> only when C covers Q::Effect.

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.

src/entities.rs
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:

TraitMeaning
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:

CapabilityReadsWritesTypical use
ReadOnlyevery entitynonea handle connected to a read replica
ReadWriteevery entityevery entitya handle connected to the primary

When a component may write only some entities, define your own capability by implementing the markers:

src/overview.rs
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:

EffectMeaningKind
Read<E>reads EReadKind
Write<E>writes EWriteKind
()touches nothing (SELECT 1, PING)ReadKind
(A, B, …)the union of its elements; up to 12 elements, and tuples nestWriteKind 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:

src/overview.rs
// 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 as Read<User> that runs an UPDATE compiles fine. Declare every entity the statement touches.
  • Where the connection points. Db::new takes 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.

Cargo.toml
[dependencies]
wakuwaku = { version = "1.0.0-rc3", features = ["sqlx-pg"] }
FeatureData sourceThird-party crateGuide
entitynone: entities, effects, queries, Db, hooksthis section
sqlx-pgentity::sqlx::backend::PgSourcewakuwaku::deps::sqlxsqlx, Transactions
sqlx-mysqlentity::sqlx::backend::MySqlSourcewakuwaku::deps::sqlxsqlx, Transactions
redisentity::redis::RedisSourcewakuwaku::deps::redisRedis
meilisearchentity::meilisearch::MeiliSourcewakuwaku::deps::meilisearch_sdkMeilisearch

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

On this page