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

pub type Backend {
  Postgres
  Sqlite
}

Constructors

  • Postgres
  • Sqlite

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 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.

✨ Search Document