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 the middle layer actually is
Section titled “What the middle layer actually is”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.
The contract
Section titled “The contract”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.
API at a glance
Section titled “API at a glance”Effects: Db
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.
DbError
Section titled “DbError”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
Section titled “Rejected”| Rejected(String)The statement was not accepted.
Disconnected
Section titled “Disconnected”| Disconnected(String)The connection is gone.
RolledBack
Section titled “RolledBack”| RolledBack(String)The transaction was rolled back rather than committed.
Effects
Section titled “Effects”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
Runs a query and gives back every row.
execute
Section titled “execute”execute: (String, List
Runs a statement and gives back how many rows it changed.
begin: () -> Result<(), DbError>
Begins a transaction. Handlers use this to implement transaction.
commit
Section titled “commit”commit: () -> Result<(), DbError>
Commits the transaction in progress.
rollback
Section titled “rollback”rollback: () -> Result<(), DbError>
Rolls the transaction in progress back.
broken
Section titled “broken”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.
Methods
Section titled “Methods”impl Cellpub 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.
number
Section titled “number”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.
is_null
Section titled “is_null”pub fn is_null(self) -> BoolWhether the cell is null, which is a question rather than a value.
impl Rowpub 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.
to_raw
Section titled “to_raw”pub fn to_raw(self) -> RawThe 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.
sequence
Section titled “sequence”pub fn sequence(rows: List<Row>) -> RawEvery row, in order, for list(Entry::schema()) to read at once: a
problem in the third row is reported as [2].amount.
impl Cellto_raw
Section titled “to_raw”pub fn to_raw(self) -> RawThe cell as a source hands it over.
Trait implementations
Section titled “Trait implementations”Eq for Cell
Section titled “Eq for Cell”impl Eq for Cellfn eq(self, other: Cell) -> BoolShow for Cell
Section titled “Show for Cell”impl Show for Cellfn show(self) -> StringShow for DbError
Section titled “Show for DbError”impl Show for DbErrorfn show(self) -> StringWritten 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.
Functions
Section titled “Functions”transaction
Section titled “transaction”pub fn transaction<A, 'ef, 'er>(body: () -> Result<A, DbError> with 'ef raises 'er) -> Result<A, DbError> with { 'ef | db: Db } raises 'erRuns 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.
How the third way out is covered
Section titled “How the third way out is covered”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