Skip to content
You are reading v0.1, which is not the current release. Go to v0.4.

std::db

Talking to a database, as a capability rather than a library.

The ecosystem design note §“Applying the rule” settles what belongs here and it is not an engine. SQLite has no middle layer at all — no framing, no handshake, nothing that fails at a packet boundary — so putting it in std would be the mistake std::net::http avoided, all top layer plus a quarter of a million lines of C. It is a first-party package. Postgres is a package for the ordinary reason: its wire protocol is versioned by somebody else.

What a transaction does when its fiber is cancelled. That is the part that fails in production and never in testing, the part every package would answer differently, and the part that is Khora’s to answer rather than an engine’s — because cancellation semantics belong to the language. A transaction that returns without rolling back, holding a connection and its locks, is the truncated-request-at-a-packet-boundary of this subject.

So std owns three things: the Db capability’s shape, the row and value types two packages must agree on to exchange a result, and the contract below. Everything that speaks a protocol is somebody else’s.

transaction commits when its body returns and rolls back when it does not — including when the body raises, and including when the fiber running it is cancelled. A handler that cannot promise that is a broken handler, and the test beside this module is written against the promise rather than against any engine.

A rollback that itself fails is two facts, and they go to two places. What the caller is told is RolledBack with the body’s reason, because the engine’s complaint about a rollback is a worse thing to report than the reason the rollback was needed. What the handler is told is broken, because a connection whose ROLLBACK did not arrive may still be holding a transaction open and is not one to lend out again.

The second half is why broken is an operation rather than a return value: on the cancellation path there is no caller left to return anything to, and that is exactly the path where a connection goes back to a pool.

Database authority is carried by the capability row all the way through. Application code says with { db: Db }; transaction says the same thing. The concrete handler belongs at a composition boundary, not as a Db parameter threaded through domain functions.

All three ways out are covered, and the third took two pieces rather than one: the rollback is registered with a region before the body runs, and the body carries a raises row so that a cancellation has a channel to reach it on. transaction’s own comment has the argument. What the runtime owes in return is that a finalizer running because of a cancellation is not itself cancelled — khora-rt’s cancel::Shielded.

Types: Cell · Row · DbError

Effects: Db

Methods: Cell · Row · Cell

Trait implementations: Eq for Cell · Show for Cell · Show for DbError

Functions: transaction

pub type Cell =
| Null
| Text(String)
| Number(Int)
| Money(Decimal)
| Flag(Bool);

A value in a column, or bound to a parameter.

Decimal is here and Float is not, for money. A database NUMERIC read through a float is a number that has already lost, and The numbers design note is the long version. Floats can be added when something that is genuinely a measurement needs one.

pub type Row = {
columns: List<String>,
cells: List<Cell>,
};

One row: the columns the query named, and a cell under each.

The names are what let a row be read through a schema – Entry::schema() reads id, account, amount and memo by name – and a driver that has them has no reason to drop them. A driver that genuinely does not (a bare SELECT with no description) leaves them empty, and [Row::to_raw] then hands over a sequence rather than a record.

derive(Eq)
pub type DbError =
| Rejected(String)
| Disconnected(String)
| RolledBack(String);

What went wrong, as far as std is willing to say.

Deliberately coarse, on the same reasoning std::fs gives for not knowing C’s error numbers: a code that means “unique violation” is a different integer in every engine, and a std that pretended otherwise would be wrong for the second engine anybody wrote. A package that knows its engine can offer a richer type; this is what everything can agree on.

| Rejected(String)

The statement was not accepted.

| Disconnected(String)

The connection is gone.

| RolledBack(String)

The transaction was rolled back rather than committed.

pub effect Db {
query: (String, List<Cell>) -> Result<List<Row>, DbError>,
execute: (String, List<Cell>) -> Result<Int, DbError>,
begin: () -> Result<(), DbError>,
commit: () -> Result<(), DbError>,
rollback: () -> Result<(), DbError>,
broken: () -> (),
}

Somewhere to run statements.

A record of closures, so a Postgres package, a SQLite package, D1 behind a Worker and an in-memory double are the same type to everything above.

query: (String, List) -> Result<List, DbError>

Runs a query and gives back every row.

execute: (String, List) -> Result<Int, DbError>

Runs a statement and gives back how many rows it changed.

begin: () -> Result<(), DbError>

Begins a transaction. Handlers use this to implement transaction.

commit: () -> Result<(), DbError>

Commits the transaction in progress.

rollback: () -> Result<(), DbError>

Rolls the transaction in progress back.

broken: () ->()

Says this connection’s state is no longer known.

Called when a rollback fails, and only then. A ROLLBACK that does not arrive leaves a connection that may still be holding a transaction open and rows locked, and there is no second thing to try: another rollback would fail the same way, and asking the server what state it is in needs a working connection to ask over.

So the handler is told, because the handler is the connection and is the only thing that can act. A pool stops lending it out. A single connection may close it. A test counts it.

Answers nothing and cannot fail, because it is called from a region finalizer on the way out — including on the way out of a cancellation, where there is no caller left to tell. A signal that could fail here would need a handler for its own failure, and that handler would be this one.

impl Cell
pub fn text(self) -> Option<String>

The text in this cell, or None if it holds something else.

No coercion. A Number read as text is a schema misunderstanding, and a library that rendered it would hide the day somebody changed a column’s type.

pub fn number(self) -> Option<Int>

The number in this cell, or None.

pub fn money(self) -> Option<Decimal>

The decimal in this cell, or None.

pub fn flag(self) -> Option<Bool>

The boolean in this cell, or None.

pub fn is_null(self) -> Bool

Whether the cell is null, which is a question rather than a value.

impl Row
pub fn cell(self, index: Int) -> Option<Cell>

The cell at index, or None past the end.

pub fn named(self, name: String) -> Option<Cell>

The cell under name, or None when no column has it.

pub fn to_raw(self) -> Raw

The row as a source hands it over, keyed by column, for a schema to read.

A Number and a Money become the token their digits spell, which both round-trip exactly, so Raw keeps one numeric representation. Text is text the column said was text, and is not read as a number.

pub fn sequence(rows: List<Row>) -> Raw

Every row, in order, for list(Entry::schema()) to read at once: a problem in the third row is reported as [2].amount.

impl Cell
pub fn to_raw(self) -> Raw

The cell as a source hands it over.

impl Eq for Cell
fn eq(self, other: Cell) -> Bool
impl Show for Cell
fn show(self) -> String
impl Show for DbError
fn show(self) -> String

Written out rather than derived, unlike the Eq above.

A derived Show gives DbError::Rejected(...), which is right for the error types whose cases a reader has to tell apart. This one goes into a log line a person reads, and “rejected: duplicate key” is the sentence they want rather than a constructor.

pub fn transaction<A, 'ef, 'er>(body: () -> Result<A, DbError> with 'ef raises 'er) -> Result<A, DbError> with { 'ef | db: Db } raises 'er

Runs body in a transaction, committing on success and rolling back otherwise.

The database is a capability, not an ordinary argument. The caller must have db: Db in its capability row; the same binding is used for begin, the body, commit, and rollback.

This is the function the whole module exists for. A caller that wrote begin, body and commit by hand has written the correct thing only for the path where nothing goes wrong, and the paths where something does are the ones that leave a connection holding locks until somebody restarts the service.

The body’s failure is a DbError, not a type variable. A body doing database work produces one already, and a caller with an error of its own renders it into Rejected before getting here — which keeps this function concrete, and keeps the rendering where the type is still known rather than behind a Show the transaction would have to carry.

A body can return, it can fail, and its fiber can be cancelled — and the third one does not pass through any match written here. A cancellation travels the same tagged return an error does, straight out of this function, so a version that only inspected body()’s answer was correct for two paths out of three. The one it missed is the one that happens in production: a request times out, the nursery stops its children, and a connection goes back to the pool inside an open transaction holding its locks.

So the rollback is registered before the body runs rather than decided after it, with scoped and acquire: a region’s finalizers run on every path out, because every path out already releases the binding holding the region. Committing marks the transaction settled, and a settled transaction has nothing left to undo — so the finalizer costs a boolean on the ordinary path and is the whole contract on the extraordinary one.

The region is this function’s own, for the reason postgres::pool gives at greater length: a transaction that borrowed its caller’s scope would end when the caller did, which is not what a transaction is.

Why the body carries a row it does not seem to need

Section titled “Why the body carries a row it does not seem to need”

raises 'er is the other half, and without it the paragraph above is theatre. The effect-runtime design note §6: a cancellation point is a ! in a function that can raise, and it travels out on the tagged return an error uses. A body typed () -> Result<A, DbError> with no row has no ! anywhere inside it, so nothing in the transaction can observe a cancellation, and the frames above have no channel to receive one on either. The first version of this function was in that state: a region, a finalizer, and no path that could ever reach it.

With the row, a body that does fallible work has cancellation points, and one that fires unwinds through here — releasing the region, running the rollback. A body that does no fallible work instantiates 'r empty, needs no ! at the call, and has nothing to interrupt: §6’s promise is that an interruption is delayed to the next mark that can carry it, never lost, and a transaction with no marks in it runs to its commit.

The row is the caller’s, deliberately. A transaction over a body that can fail its own way keeps that failure’s type — folding it into DbError would be the loss Result<A, DbError> already declines to take.

Khora 0.1.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource