Skip to content
You are reading v0.3, 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 belongs in a package rather than here. Postgres belongs in one for the ordinary reason: its wire protocol is versioned by somebody else.

No driver ships in std, and this interface is the whole of what is here. Db is a record of closures, so writing a handler over an existing client is a day’s work and a test double is a few lines. Said plainly because the shape of this module invites the opposite assumption: an interface this settled usually means implementations exist. For the drivers maintained alongside the compiler, see the Packages section of the documentation.

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.

A numeric column arrives as Text, not as Money, and the PostgreSQL driver is not alone in that. numeric is arbitrary-precision, so a driver that produced a Money would have to decide what to do with digits that will not parse — become a wrong number, or lose the value — and neither is a decision a driver may take on a caller’s behalf. The server’s own digits are kept instead, and turning them into a Decimal is the caller’s step: Decimal::of_string, whose Option::None arm means this is not a number I can represent and must not be answered with a zero.

What it costs is that Cell::money answers None for every money column a PostgreSQL server returns. Read those through Cell::text.

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 when a BEGIN or a COMMIT comes back Disconnected. 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.

The other two are the same shape from a different direction. A Disconnected from begin or commit says the exchange never completed, so nobody knows whether the statement reached the server; a Rejected says the engine answered and refused, which ends the transaction and leaves the connection perfectly usable, so that one does not call this.

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.

None for a PostgreSQL numeric, which arrives as Cell::Text — see the note on Cell for why, and read it with Cell::text and Decimal::of_string. A money column that reads as None here is the normal case, not a null.

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.

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 – a region and a finalizer with 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 'er 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.

Placeholder syntax belongs to the driver, not to Db. This example uses PostgreSQL’s $1, $2, which is what the postgres package takes; another engine’s handler would take that engine’s spelling. Db passes the string through untouched.

import std::db::{Cell, Db, DbError, transaction};
fn transfer(from: Int, to: Int, amount: Int) -> Result<(), DbError>
with { db: Db }
{
transaction(fn () =>
match db.execute("update accounts set balance = balance - $1 where id = $2",
[Cell::Number(amount), Cell::Number(from)]) {
Result::Err(problem) => Result::Err(problem),
Result::Ok(_) => db.execute("update accounts set balance = balance + $1 where id = $2",
[Cell::Number(amount), Cell::Number(to)]),
})
}

There is no connection parameter. with { db: Db } is the function’s authority to run statements, and the caller supplies it at a boundary – which is what keeps a transaction from turning the capability back into plumbing threaded through every signature.

A is unconstrained, so Result<Result<(), Refusal>, DbError> is a legal body type and transaction commits it. A body has three outcomes more often than two: it worked, the database broke, or the business said no – an overdraft, a closed account, a mismatched currency. A refusal is not a DbError, so the obvious typing puts it in the Ok channel, where this function cannot see it. The rollback that the body’s author expected does not happen, and whatever the body wrote before the refusal is committed.

The failure is quiet and it is not the database’s: an idempotency key inserted at the top of the body outlives a refusal, so retrying the same request after the refusal’s cause is gone answers “already done” forever.

Roll back on a refusal by raising it, not by returning it. transaction carries a raises 'er row for exactly this: a raise leaves the body without an Ok, so the transaction rolls back, and the row carries the refusal out to a caller that can answer it.

fn transfer() -> Result<(), DbError> with { db: Db } raises Refusal {
// ... a refusal is `raise Refusal(why)`, not `Result::Err(..)`
}
// At the boundary, where the refusal becomes the caller's answer:
let outcome = attempt(fn () => transaction(fn () => transfer()!)!);

The body’s Err channel is DbError and nothing else, so a refusal cannot travel that way — it does not typecheck. The rule is that anything which must undo the transaction leaves the body as a raise or as Result::Err(DbError); Ok means commit, whatever is inside it.

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