howdy/database
Portable transactions and queries over a Gloo Repo (PostgreSQL and SQLite). Gloo is the storage seam. The application owns the Repo lifecycle; every Howdy module and the application itself can share it.
Types
Values
pub fn around_transactions(
name: String,
hook: fn(fn() -> dynamic.Dynamic) -> dynamic.Dynamic,
) -> Nil
Bracket the outermost transaction in each process on this node, for a
module that keeps state derived from the database in memory, such as a
cache that must be invalidated after the commit rather than after a
savepoint. hook must call the function it is given exactly once and
return its result; it runs outside the transaction. Registering name
again replaces its hook. Transactions opened directly through
gloo/repo or pog are not bracketed.
pub fn backend(repo: repo.Repo) -> Result(Backend, service.Error)
The one place an adapter name is interpreted. Anything else is refused rather than treated as one of the supported databases.
pub fn connect(
repo: repo.Repo,
run: fn(repo.Repo) -> Result(a, service.Error),
) -> Result(a, service.Error)
Gloo’s SQLite Repo has one connection. Serialize operations so a
concurrent request cannot join another request’s transaction. Everything
that goes through this module shares the lock, so modules and application
code can safely share one SQLite Repo. Code that calls gloo/repo
directly on a shared SQLite Repo is not serialized.
pub fn error(error: error.GlooError) -> service.Error
pub fn exec(
repo: repo.Repo,
statements: String,
) -> Result(Nil, service.Error)
DDL batches follow Gloo runner’s statement convention: semicolon-separated statements, with no embedded semicolons in literals or procedural bodies.
pub fn execute(
repo: repo.Repo,
sql: String,
args: List(value.GlooValue),
) -> Result(Nil, service.Error)
pub fn execute_or(
repo: repo.Repo,
sql: String,
args: List(value.GlooValue),
on_constraint violated: fn(String) -> service.Error,
) -> Result(Nil, service.Error)
execute, with a violated constraint turned into a domain error such as
service.Conflict. PostgreSQL passes the constraint’s name. SQLite has
none to give and passes its description, such as
UNIQUE constraint failed: app_notes.slug; do not show either to users.
In a PostgreSQL transaction the violation has already aborted the
transaction, so return the error rather than carrying on.
pub fn for_update(repo: repo.Repo, alias: String) -> String
Row-lock suffix for a SELECT inside a transaction. SQLite transactions
already hold the database write lock. alias is trusted query text.
pub fn health_check(
repo: repo.Repo,
) -> fn() -> Result(Nil, String)
A check for howdy/health that the database answers a SELECT 1:
health.new()
|> health.check("database", database.health_check(db))
On SQLite it waits its turn behind the queries in progress, like any other, so a write that holds the database for longer than the health check’s timeout fails the check too.
pub fn locked(key: key, run: fn() -> a) -> a
Run with the fair, reentrant lock for key held. Waiters are served in
arrival order and the lock is released if its holder dies.
pub fn one(
repo: repo.Repo,
sql: String,
args: List(value.GlooValue),
decoder: decode.Decoder(a),
or missing: service.Error,
) -> Result(a, service.Error)
The first row, or missing when there is none.
pub fn promote(
conn: repo.Repo,
table: String,
) -> Result(Nil, service.Error)
Acquire SQLite’s write lock inside an open transaction using a statement
that changes nothing. table is trusted, module-owned text.
pub fn query(
repo: repo.Repo,
sql: String,
args: List(value.GlooValue),
decoder: decode.Decoder(a),
) -> Result(List(a), service.Error)
pub fn read_time(repo: repo.Repo, column: String) -> String
SQL reading an instant column as unix seconds. PostgreSQL keeps instants
as TIMESTAMPTZ; SQLite has no such type and keeps the seconds themselves.
Fractional seconds, such as from CURRENT_TIMESTAMP, are truncated like
the system clock’s seconds: a bare ::bigint cast would round up, reading
an instant as up to half a second in the future. column is trusted query
text.
pub fn returning(
repo: repo.Repo,
mutation: query.Query(a),
columns: List(String),
or missing: service.Error,
) -> Result(a, service.Error)
Run an INSERT, UPDATE or DELETE built with gloo/query and return the row
it wrote or removed, decoded with the query’s decoder. Gloo’s own
returning_columns only reaches INSERT; do not combine the two. One
statement does the work and reports it, so there is no window between
them: a statement that matched no row returns missing. columns must
be in the order the decoder reads them, and are quoted here.
query.from(todo_item.table())
|> query.update(todo_item.values(input))
|> query.where(query.Eq("id", sql.int(id)))
|> database.returning(db, _, todo_item.columns, or: service.NotFound("todo not found"))
A violated constraint is a Conflict that names nothing. If the conditions
match several rows they are all written and the first is returned.
Call query.where after query.update or query.delete, as above: both
start a fresh condition list in Gloo 1.x, so an earlier where is dropped
and the statement writes or removes every row.
pub fn sqlite_defaults(
repo: repo.Repo,
) -> Result(Nil, service.Error)
The settings a SQLite Repo needs for the rest of this module to behave:
foreign keys enforced, which SQLite leaves off, and a five second wait for
another connection’s write lock rather than an immediate failure. It also
switches the file to write-ahead logging, which persists in the file, so
readers such as a backup or another Repo never block the writer. Call it
once after opening the Repo. PostgreSQL needs none of this and is left
alone; open it with howdy/database/postgres for its defaults.
pub fn traced(repo: repo.Repo) -> repo.Repo
The Repo, reporting each query it runs as an OpenTelemetry span, named
after the operation and table, such as SELECT app_notes, with the SQL
text but never the parameter values. Queries only get a span inside a
span that is being recorded, such as a request’s, so background polling
does not fill your traces. Without howdy_telemetry started this costs
next to nothing. howdy/database/postgres traces the Repos it opens;
call this on a SQLite Repo after opening it.
pub fn transaction(
repo: repo.Repo,
run: fn(repo.Repo) -> Result(a, service.Error),
) -> Result(a, service.Error)
Commit when run returns Ok; roll back and return its error otherwise.
The transaction is a transaction span, with its queries inside.
pub fn write_time(repo: repo.Repo, placeholder: String) -> String
SQL writing the unix seconds bound to placeholder, such as $2, to an
instant column.
pub fn write_transaction(
repo: repo.Repo,
touching table: String,
run run: fn(repo.Repo) -> Result(a, service.Error),
) -> Result(a, service.Error)
A transaction that reads before it writes. SQLite’s deferred BEGIN takes the write lock only at the first write, and independent connections must not race that read-to-write upgrade, so take it up front.