Database¶
Read rows into typed values, write several rows that all land or none, and
answer sensibly when the database is slow or busy. Statements are
rowtype’s: each is a value holding
its SQL and the types of its parameters and rows, run through a pure-OCaml
Postgres driver with nothing to link.
Install the package, which brings rowtype and rowtype-postgres with it:
Add the libraries to your bin/dune. The preprocessor is the one from
your first app, for [@@deriving wiretype]:
(executable
(name main)
(libraries spindle spindle-postgres rowtype rowtype-postgres eio_main)
(preprocess (pps ppx_wiretype)))
To try the examples, start a throwaway Postgres in Docker. It listens on
port 5432, with the user and password postgres, and is deleted when you
stop it:
Connecting and querying¶
A statement is a value: its SQL, the type of its parameters and the type of
each row. Pool.exec runs it on a connection borrowed from the pool, and
Spindle_postgres.refusal turns a database failure into an answer (see
Database errors):
let databases =
S.list ~params:S.unit ~row:S.text
"select datname from pg_database order by datname"
(* A borrow that waits past the pool's limit is [`Pool `Busy], which [refusal]
answers 503: an overloaded server says so rather than answering late. *)
let list_databases pool =
Spindle_postgres.Pool.exec pool databases ()
|> Result.map_error Spindle_postgres.refusal
The routes read the pool as a dependency, db, made once with
Dep.of_resource. It is a resource: the application’s, not the request’s,
so the routes are a plain value that exists before any database does:
(* The pool, as the routes read it: supplied where the server starts. *)
let db : Spindle_postgres.Pool.t Spindle.Dep.t =
Spindle.Dep.of_resource "database"
let routes =
[
Spindle.get Spindle.Path.root
(Spindle.Returns.json (Wiretype.list Wiretype.string))
(let+ pool = db in
list_databases pool);
]
Make the pool where the program starts, from a connection string, and
supply it to the server with ~resources:
let database = "postgres://postgres:postgres@localhost:5432/postgres"
(* The pool makes every connection at once, so a database that refuses is a
server that does not start. *)
let () =
Spindle.Log.setup ();
Eio_main.run @@ fun env ->
Spindle_postgres.Pool.run env database @@ fun pool ->
Spindle.serve env ~resources:[ Spindle.Dep.resource db pool ] routes
A route that reads a resource the server was not given stops the program before it listens, naming the route and the resource. A test supplies its own pool, or none for a route that reads none (Testing), and the command line describes the routes with no database at all.
Run it from the repository’s root, then ask for /:
Pool.run opens every connection before the server listens. If the
database is not there, the program logs why and exits with status 1:
{"timestamp":"2026-10-16T09:24:46.621Z","level":"error","logger.name":"spindle-postgres","message":"the pool could not be opened: Eio.Io Net Connection_failure Refused Unix_error (Connection refused, \"connect\", \"\")"}
The whole app
(* A pool of Postgres connections, and a route that runs a statement on it:
/ answers the names of the databases on the server.
docker run --rm -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:18-alpine
dune exec examples/sql_query.exe *)
open Spindle.Syntax
module S = Rowtype
let database = "postgres://postgres:postgres@localhost:5432/postgres"
let databases =
S.list ~params:S.unit ~row:S.text
"select datname from pg_database order by datname"
(* A borrow that waits past the pool's limit is [`Pool `Busy], which [refusal]
answers 503: an overloaded server says so rather than answering late. *)
let list_databases pool =
Spindle_postgres.Pool.exec pool databases ()
|> Result.map_error Spindle_postgres.refusal
(* The pool, as the routes read it: supplied where the server starts. *)
let db : Spindle_postgres.Pool.t Spindle.Dep.t =
Spindle.Dep.of_resource "database"
let routes =
[
Spindle.get Spindle.Path.root
(Spindle.Returns.json (Wiretype.list Wiretype.string))
(let+ pool = db in
list_databases pool);
]
(* The pool makes every connection at once, so a database that refuses is a
server that does not start. *)
let () =
Spindle.Log.setup ();
Eio_main.run @@ fun env ->
Spindle_postgres.Pool.run env database @@ fun pool ->
Spindle.serve env ~resources:[ Spindle.Dep.resource db pool ] routes
Transactions¶
Claiming a name is an insert and a read in one transaction. The answer is
201 the first time and 200 every time after:
(* One transaction, so the name is claimed and read in one go: [Ok] commits
both statements and a failure rolls both back. *)
let claim pool name ~now =
Spindle_postgres.Pool.transaction pool (fun db ->
let* made = Pg.run db insert_name (name, now) in
let* claimed_at = Pg.run db when_claimed name in
Ok ((if made = 1 then `Created else `OK), { name; claimed_at }))
|> Result.map_error Spindle_postgres.refusal
The handler returns the status together with the value,
(`Created, claim) or (`OK, claim). A route whose status is chosen
at run time says so with Returns.json_response, listing each status it may
answer and when (see Request bodies, responses and
errors):
Spindle.Returns.json_response claim_json
~statuses:
[
(`Created, "The name was free, and is now claimed.");
(`OK, "The name was claimed already.");
]
Pool.transaction borrows a connection and runs your function in one
transaction: Ok commits, Error rolls back. let* here is
Result.bind, defined in the program (let ( let* ) = Result.bind).
The whole app
(* A write in one transaction, and an answer that says what became of it:
PUT /names/{name} claims a name, 201 the first time and 200 every time
after, with when it was first claimed.
docker run --rm -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:18-alpine
dune exec examples/sql_transaction.exe
curl -i -X PUT localhost:8080/names/ada 201
curl -i -X PUT localhost:8080/names/ada 200 *)
open Spindle.Syntax
module S = Rowtype
module Pg = Rowtype_postgres
let ( let* ) = Result.bind
let database = "postgres://postgres:postgres@localhost:5432/postgres"
type claim = { name : string; claimed_at : Ptime.t } [@@deriving wiretype]
let create_names =
S.exec ~params:S.unit
"create table if not exists names (name text primary key, claimed_at \
timestamptz not null)"
(* The database says whether this request made the row: an insert that meets
one already there changes nothing, and counts none. *)
let insert_name =
S.exec_count ~params:(S.t2 S.text S.instant)
"insert into names (name, claimed_at) values ($1, $2) on conflict (name) \
do nothing"
let when_claimed =
S.find ~params:S.text ~row:S.instant
"select claimed_at from names where name = $1"
(* One transaction, so the name is claimed and read in one go: [Ok] commits
both statements and a failure rolls both back. *)
let claim pool name ~now =
Spindle_postgres.Pool.transaction pool (fun db ->
let* made = Pg.run db insert_name (name, now) in
let* claimed_at = Pg.run db when_claimed name in
Ok ((if made = 1 then `Created else `OK), { name; claimed_at }))
|> Result.map_error Spindle_postgres.refusal
let name = Spindle.Path.str "name"
let db : Spindle_postgres.Pool.t Spindle.Dep.t =
Spindle.Dep.of_resource "database"
(* Every branch returns the status it reached, and the route lists each with
when it happens, which is what the API's document says of it. *)
let routes =
[
Spindle.put ~summary:"Claim a name"
Spindle.Path.(s "names" / name)
(Spindle.Returns.json_response claim_json
~statuses:
[
(`Created, "The name was free, and is now claimed.");
(`OK, "The name was claimed already.");
])
(let+ pool = db and+ name = Spindle.param name and+ now = Spindle.now in
claim pool name ~now);
]
let () =
Spindle.Log.setup ();
Eio_main.run @@ fun env ->
Spindle_postgres.Pool.run env database @@ fun pool ->
match Spindle_postgres.Pool.exec pool create_names () with
| Ok () ->
Spindle.serve env ~resources:[ Spindle.Dep.resource db pool ] routes
| Error e ->
Logs.err (fun m ->
m "could not create the table: %s"
(Spindle_postgres.error_to_string e));
exit 1
Pool settings¶
Spindle_postgres.Pool is rowtype’s pool with server-friendly bounds:
Pool.exec pool statement params– one statement, as rowtype’srunruns it, for a read that is the whole of the work. (Pool.runis the program’s pool, above.)Pool.transaction pool (fun db -> ...)– one transaction; takes~isolation,~retriesand~keep. A transaction is never run again unless you ask: at~isolation:Serializable, pass~retries:3to run it again when Postgres reports a conflict with a concurrent one (`Not_serializable). The work runs again in full, so keep anything that is not a statement out of it.Pool.use pool (fun db -> ...)– a borrowed connection for several statements outside a transaction.Pool.check– the pool as a readiness check (Health checks);Pool.statsandPool.measurefor metrics.Pool.create ~sw ~net ~mono_clock target– the same pool answering aresult, for a program that can go on without its database.targetis aRowtype_postgres.Conninfo.t, read from a connection string byConninfo.of_string;Pool.runtakes the string and reads it once.
Every connection is bounded, so one bad query cannot hold a connection and its locks for ever:
| Option | Default | |
|---|---|---|
~statement_timeout |
10 s | a statement running longer is ended by Postgres |
~idle_in_transaction_timeout |
30 s | so is a transaction left open and idle |
~size |
8 | connections, all opened at start-up |
~wait |
5 s | a borrow waiting longer is `Pool `Busy, a 503 |
~parameters |
none | Postgres settings for each connection, such as [ ("application_name", "shop") ]; yours win over the framework’s |
~size is a count. The three durations are Mtime.Span.ts, written
Mtime.Span.(2 * s). Connecting waits
10 s for each address unless the connection string says connect_timeout
(Conninfo.with_connect_timeout). Postgres is given
the two bounds in whole milliseconds, rounded up, so a span under a
millisecond is still a bound; a span of zero lifts one, as Postgres’s 0
does.
~statement_cache:0 is for a pooler in front of Postgres that cannot carry
named statements.
Sessions in the database are
Spindle_postgres.Session.store pool ~table, over a table you create in a
migration from Session.schema ~table. Migrations are
rowtype-migrate’s, run before the
server starts.
Database errors¶
A failure is a variant, and Spindle_postgres.refusal turns any you do not
handle into a status, with the detail in the log:
`Pool `Busy(no connection within the wait),`Unsent(the database could not be reached, so nothing was done),`Pool `Closed(the pool is closed, answered at once) and`Not_serializable(a concurrent transaction won, past its retries):503 busywithRetry-After: 1, since asking again may succeed. The two`Poolstates and`Unsentare logged as warnings, not errors: a server under load, shutting down or cut off from its database is not a broken one.- Everything else –
`Conflict,`Lost,`Not_committed,`Db, a closed connection, … –500 internal.
Where a failure is logged rather than answered,
Spindle_postgres.error_to_string describes it in words, without any
parameter’s value. Here the program cannot create its table, so it logs
why and exits:
match Spindle_postgres.Pool.exec pool create_names () with
| Ok () ->
Spindle.serve env ~resources:[ Spindle.Dep.resource db pool ] routes
| Error e ->
Logs.err (fun m ->
m "could not create the table: %s"
(Spindle_postgres.error_to_string e));
exit 1
Handle the ones you expect first. A unique-constraint violation is a
`Conflict with the constraint’s name, whether a statement found it or
a constraint declared DEFERRABLE refused the transaction’s COMMIT:
let sign_up pool ~name ~email =
Spindle_postgres.Pool.transaction pool (fun db ->
let* id = Pg.run db add_user (name, email) in
let* () = Pg.run db log_event (id, "signed_up") in
Ok id)
|> function
| Ok id -> Ok id
| Error (`Conflict (Some "users_email_key")) ->
Error (Spindle.Refusal.make email_taken "That address is taken.")
| Error e -> Error (Spindle_postgres.refusal e)
email_taken is a refusal code of your own. Make it once, and list it in
the route’s ~refuses so the route’s documentation shows it:
let email_taken =
Spindle.Refusal.Code.make "email_taken" ~status:`Conflict
~doc:"An account already uses this email address."
The transaction’s failures join your work’s error type as polymorphic variants; if your own errors are an ordinary variant, wrap the work’s errors once:
let transaction pool ~failed work =
match
Spindle_postgres.Pool.transaction pool (fun db ->
Result.map_error (fun e -> `Work e) (work db))
with
| Ok v -> Ok v
| Error (`Work e) -> Error e
| Error ((`Pool _ | #Rowtype_postgres.Transaction.failure) as f) ->
Error (failed f)
Why a transaction is not a dependency¶
A transaction holds its rows locked while it is open. So let the dependencies run first – the body read, the cookies parsed – and open the transaction inside the handler, around the database work alone:
let add_item =
Spindle.post
Spindle.Path.(s "orders" / order_id / s "items")
(Spindle.Returns.json ~status:`Created item_json)
(let+ pool = db
and+ id = Spindle.param order_id
and+ item = Spindle.json item_json in
match
Spindle_postgres.Pool.transaction pool (fun db ->
let* () = Pg.run db reserve (id, item.sku) in
let* () = Pg.run db insert_item (id, item.sku, item.quantity) in
Ok item)
with
| Ok item ->
notify id item; (* only once the work is committed *)
Ok item
| Error e -> Error (Spindle_postgres.refusal e))
- Read inside, not before. A dependency hands over a credential (a cookie, a token), never a resolved user: a row read before the transaction and written inside it is a race.
- What is not the database comes after. A publish, a stream or a call to another server goes after the transaction returns, so it runs only once the work is kept.
Next: OpenAPI.