Packages
howdy_database
1.0.0
Package-owned migrations and portable transactions over Gloo for Howdy apps
Current section
Files
Jump to
Current section
Files
howdy_database
README.md
README.md
# howdy_database
Package-owned migrations and portable transactions over
[Gloo](https://hexdocs.pm/gloo) for Howdy apps, on PostgreSQL and SQLite.
[`howdy_auth`](https://github.com/howdy-gleam/howdy/blob/v1.0.0/auth/README.md) is built on it, and applications can use it
for their own tables, with or without auth.
```sh
gleam add howdy_database@1 gloo@1
```
The application opens, configures and closes the Gloo Repo; everything else in
this package only receives it. Operations return `howdy/service` results, so a
failure maps straight to an HTTP response.
## Opening the database
For SQLite, open the Repo with Gloo and call `database.sqlite_defaults(db)`
once. It turns on foreign keys, waits up to five seconds for another
connection's write lock, and switches the file to write-ahead logging.
For PostgreSQL, `howdy/database/postgres` opens a Repo with production
defaults:
```gleam
import howdy/database/postgres
let assert Ok(db) =
postgres.from_env()
|> result.try(postgres.start)
```
- `from_env` reads `DATABASE_URL`, and `from_url` takes
`postgres://user:password@host:port/database?sslmode=...` directly. The URL
is never repeated in an error.
- Without `sslmode`, TLS is off for loopback addresses and dotless host names
such as a Compose service called `db`, and verified against the system's
CAs for everything else. `sslmode=require` encrypts without verifying, and
`disable` turns TLS off. `allow` and `prefer` are refused.
- Every connection starts with `timezone` UTC, a 30 second
`statement_timeout`, a 5 second `lock_timeout` and a 60 second
`idle_in_transaction_session_timeout`. Change them with the functions of
the same name (zero turns one off), or set any parameter with `parameter`.
Migrations lift the statement and lock timeouts for their own transaction.
- `start` waits up to `startup_timeout` (10 seconds) for the server to answer
and refuses a server older than PostgreSQL 14, so a wrong host, password or
database fails at startup instead of on the first request.
Behind a pooler such as PgBouncer that rejects unknown startup parameters,
set the timeouts on the role with `ALTER ROLE ... SET` and pass zero here.
The driver reports the Erlang node name as `application_name`.
## Migrations
A package is a name and a list of ordinary `gloo/migration.Migration` values.
It owns every table, index and trigger whose name begins with `<name>_`.
```gleam
import gloo/migration as gloo_migration
import howdy/migration
pub fn schema() -> migration.Package {
migration.Package("notes", [
gloo_migration.new(
1,
"create_notes",
"CREATE TABLE notes_notes (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE)"
<> migration.per_database(
postgres: "; ALTER TABLE notes_notes ADD COLUMN created TIMESTAMPTZ",
sqlite: "; ALTER TABLE notes_notes ADD COLUMN created BIGINT",
),
),
])
}
```
Run every package in one transaction from a deployment command, before starting
the new version, and check at startup:
```gleam
// src/migrate.gleam
let assert Ok(_) =
migration.run(db, [auth.schema(), authorization.schema(), notes.schema()])
// at startup
let assert Ok(_) = migration.check(db, notes.schema())
```
- `run` applies what is pending, in list order, and rolls the whole batch back
on failure. Concurrent migrators serialize. The ledger records a checksum per
migration, so edited or missing history is refused: append new migrations and
never edit a published one. There are no down migrations.
- `check` applies nothing. It refuses a schema that is older or newer than the
installed package, and one whose owned objects were changed outside its
migrations. A plain, non-unique index named outside the namespace is
allowed, as is a trigger named `howdy_admin_…`, which the development admin
adds to hear about changes.
- `rebaseline` accepts the current shape of the owned schema after you have
confirmed by hand that it has not drifted, for example after a PostgreSQL
major upgrade. Never call it at startup.
- `per_database` appends the statements that differ by database; only the
matching variant runs, and the checksum covers both.
- `around_runs` brackets every later `run` on the node, for a module that keeps
state derived from the database in memory. Auth uses it to invalidate its
authorization cache.
Migration SQL is trusted code. It is split on semicolons, as in Gloo's runner,
so it cannot contain semicolons inside literals or procedural bodies, nor
transaction control. Do not apply the same migrations through `gloo/runner`.
## Transactions and queries
```gleam
import gloo/sql
import howdy/database
import howdy/service
pub fn rename(db, id: String, slug: String) -> service.Result(Nil) {
use conn <- database.write_transaction(db, touching: "notes_notes")
use _ <- result.try(database.one(
conn,
"SELECT id FROM notes_notes n WHERE id = $1"
<> database.for_update(conn, "n"),
[sql.string(id)],
decode.field(0, decode.string, decode.success),
or: service.NotFound("note"),
))
database.execute_or(
conn,
"UPDATE notes_notes SET slug = $1 WHERE id = $2",
[sql.string(slug), sql.string(id)],
on_constraint: fn(_) { service.Conflict("slug already taken") },
)
}
```
- `transaction` commits on `Ok` and rolls back on `Error`, returning your typed
error unchanged. `write_transaction` is for work that reads before it writes:
it takes SQLite's write lock up front so independent connections cannot race
the upgrade. `connect` runs without a transaction.
- `query`, `one`, `execute` and `exec` run SQL. Driver errors may contain
personal data, so they are reported as an opaque `service.Internal`.
`execute_or` turns a violated constraint into an error of your choosing.
- `returning` runs an INSERT, UPDATE or DELETE built with `gloo/query` and
returns the row it touched, so an update or delete and its result are one
statement and a missing row is your `NotFound`. Gloo's own
`returning_columns` only reaches INSERT. `howdy create` contexts use it.
- `for_update`, `read_time` and `write_time` produce the SQL fragments that
differ between the two databases: row locks, and instants kept as
`TIMESTAMPTZ` on PostgreSQL and unix seconds on SQLite.
- `backend` reports which database a Repo is; `locked` is the fair, reentrant
mutex beneath all of this.
- `around_transactions` brackets the outermost `transaction` in each process,
including `postgres.transaction`, for a module whose in-memory state must
change after the commit rather than after a nested savepoint. Auth uses it
so a grant changed inside your own transaction invalidates its cache when
your transaction ends.
Gloo's SQLite adapter binds `$n` placeholders by position: never reuse one
placeholder in a query, pass the value again.
**Call `query.where` after `query.update` or `query.delete`.** In Gloo 1.x,
`query.update` and `query.delete` start a fresh condition list, so a `where`
placed before them is silently dropped and the statement touches every row:
```gleam
// UPDATE "notes_notes" SET slug = $1 WHERE id = $2
query.from(note.table())
|> query.update([#("slug", sql.string(slug))])
|> query.where(query.Eq("id", sql.string(id)))
// UPDATE "notes_notes" SET slug = $1, which renames every note
query.from(note.table())
|> query.where(query.Eq("id", sql.string(id)))
|> query.update([#("slug", sql.string(slug))])
```
**Sharing a SQLite Repo.** Gloo 1.x gives a SQLite Repo one connection.
Everything that goes through this module serializes on that Repo, first come
first served, so auth and application code can share it. Calls made directly
through `gloo/repo` are not serialized; give those their own Repo on the same
file. PostgreSQL's pool reserves a connection per transaction, so none of this
applies there.
## Testing
```sh
gleam test
HOWDY_DATABASE_TEST_POSTGRES_URL=postgres://postgres@localhost/postgres gleam test
```
The PostgreSQL tests run only when the variable is set; its user must be able
to create databases.