Skip to content

Task Repository

With the database connection wired through the server, we'll now introduce the domain model and a thin layer that maps raw SQL rows to typed Gleam values — plus a small shell helper that makes testing each function in gleam shell much more convenient.

Four files change[1] [2]:

sh
doable/
├── shared/
   └── src/
       ├── shared.gleam          # placeholder removed
       └── task.gleam            # Task and TaskInput domain types
└── server/
    └── src/
        ├── console.gleam         # debug helper for gleam shell
        ├── error.gleam           # DatabaseError type
        └── task/
            └── repository.gleam  # CRUD operations over the tasks table

Shared Task Types

shared/src/task.gleam

shared.gleam was a placeholder from project setup. Replace it with task.gleam, which defines the domain types shared across the entire app:

gleam
// shared/src/task.gleam

pub type Task {
  Task(id: Int, name: String, description: String, completed: Bool)
}

pub fn to_task_input(task: Task) -> TaskInput {
  TaskInput(
    name: task.name,
    description: task.description,
    completed: task.completed,
  )
}

pub type TaskInput {
  TaskInput(name: String, description: String, completed: Bool)
}

pub fn to_task(input: TaskInput, id: Int) -> Task {
  Task(
    id: id,
    name: input.name,
    description: input.description,
    completed: input.completed,
  )
}
  • Task — the full record as stored and returned; includes an id.
  • TaskInput — the create/update payload; no id because it's either generated by the database (on insert) or provided separately (on update).
  • to_task / to_task_input — convenience conversions between the two.

The shared package is imported by both server and (eventually) client, so placing domain types here ensures they're defined once and used everywhere.

Error Types

error.gleam

gleam
// server/src/error.gleam

import pog

pub type DatabaseError {
  UnexpectedNoRows
  RecordNotFound
  QueryError(pog.QueryError)
}

Three variants cover everything that can go wrong at the database repository:

  • UnexpectedNoRows — a query that must return a row didn't. INSERT ... RETURNING returning nothing signals a bug rather than a user-visible condition.
  • RecordNotFound — a lookup by ID found no matching record; maps to a 404 response.
  • QueryError(pog.QueryError) — the driver reported a database-level error (connection failure, constraint violation, etc.).

Keeping errors as a plain Gleam type means route handlers can pattern-match on them directly when building HTTP responses.

Database Repository

task/repository.gleam sits between the generated SQL functions and the rest of the application. Each function follows the same shape: call an SQL function, map the error, unwrap the returned rows, and build a domain value:

gleam
// server/src/task/repository.gleam

import error.{type DatabaseError, QueryError, RecordNotFound, UnexpectedNoRows}
import gleam/bool
import gleam/list
import gleam/result
import pog
import task.{type Task, type TaskInput, Task}
import task/sql

pub fn all_tasks(db_conn: pog.Connection) -> Result(List(Task), DatabaseError) {
  let query_result =
    db_conn
    |> sql.all_tasks
    |> result.map_error(QueryError)
  use pog.Returned(_, rows) <- result.map(query_result)
  use row <- list.map(rows)

  Task(
    id: row.id,
    name: row.name,
    description: row.description,
    completed: row.completed,
  )
}

pub fn create_task(
  db_conn: pog.Connection,
  input: TaskInput,
) -> Result(Task, DatabaseError) {
  let query_result =
    sql.create_task(db_conn, input.name, input.description, input.completed)
    |> result.map_error(QueryError)
  use pog.Returned(_, rows) <- result.try(query_result)
  let row =
    rows
    |> list.first
    |> result.replace_error(UnexpectedNoRows)
  use row <- result.map(row)

  Task(
    id: row.id,
    name: row.name,
    description: row.description,
    completed: row.completed,
  )
}

pub fn get_task(db_conn: pog.Connection, id: Int) -> Result(Task, DatabaseError) {
  let query_result =
    sql.get_task(db_conn, id)
    |> result.map_error(QueryError)
  use pog.Returned(_, rows) <- result.try(query_result)
  let row =
    rows
    |> list.first
    |> result.replace_error(RecordNotFound)
  use row <- result.map(row)

  Task(
    id: row.id,
    name: row.name,
    description: row.description,
    completed: row.completed,
  )
}

pub fn update_task(
  db_conn: pog.Connection,
  task: Task,
) -> Result(Task, DatabaseError) {
  let query_result =
    sql.update_task(
      db_conn,
      task.id,
      task.name,
      task.description,
      task.completed,
    )
    |> result.map_error(QueryError)
  use pog.Returned(_, rows) <- result.try(query_result)
  let row =
    rows
    |> list.first
    |> result.replace_error(RecordNotFound)
  use row <- result.map(row)

  Task(
    id: row.id,
    name: row.name,
    description: row.description,
    completed: row.completed,
  )
}

pub fn delete_task(
  db_conn: pog.Connection,
  id: Int,
) -> Result(Nil, DatabaseError) {
  let query_result =
    sql.delete_task(db_conn, id)
    |> result.map_error(QueryError)
  use pog.Returned(count, _) <- result.try(query_result)
  use <- bool.guard(count == 0, Error(RecordNotFound))

  Ok(Nil)
}

A few things worth noting:

  • result.map vs result.tryresult.try is used when the next step can also fail (e.g. extracting the first row with list.first); result.map is used for the final pure transformation. Both work with use for a consistent pipeline style.
  • UnexpectedNoRows vs RecordNotFoundcreate_task uses UnexpectedNoRows because RETURNING guarantees a row on success; an empty result is a bug. get_task and update_task use RecordNotFound because an empty result simply means the record doesn't exist.
  • update_task takes a full Task — the caller is responsible for merging any partial update with the existing record before calling this.
  • pog.Returned(count, _) — destructures the Returned type directly in the pattern. count is the number of affected rows; _ discards the empty row list. DELETE has no RETURNING, so this is the only way to detect that nothing was deleted.
  • bool.guardbool.guard(condition, fallback) evaluates fallback if condition is true and continues otherwise. It reads more naturally than an if/else for early-return guards.

Shell Helper

The previous chapter required six Erlang expressions just to get a pog.Connection in gleam shell. console.gleam wraps that setup into a single Gleam function.

gleam
// server/src/console.gleam

import config
import context.{Context}
import database
import gleam/dynamic.{type Dynamic}
import gleam/erlang/atom
import pog

@external(erlang, "shell", "strings")
fn shell_strings(enabled: Bool) -> Dynamic

@external(erlang, "application", "ensure_all_started")
fn ensure_all_started(app: atom.Atom) -> Dynamic

pub fn init() -> pog.Connection {
  let _ = shell_strings(True)
  let _ = ensure_all_started(atom.create("pgo"))
  let config = config.load()
  let db_pool_name = database.start(config)
  let context = Context(config:, db_pool_name:)
  context.db_conn(context)
}

@external(erlang, ...) binds a Gleam function to an existing Erlang function. The two FFI calls here are the same setup steps from before — enabling string display and starting the pog driver — wrapped so they happen automatically on console:init().

Verifying Database Repository

With the database running, open a shell from the server/ directory:

sh
docker compose up -d
cd server
gleam shell

A single call to console:init() sets everything up and returns a connection:

erlang
1> DbConn = console:init().

% Then exercise each database function directly:

2> 'task@repository':all_tasks(DbConn).
% {ok,[]}

3> 'task@repository':create_task(DbConn, {task_input, "Buy milk", "2% fat", false}).
% {ok,{task,1,<<"Buy milk">>,<<"2% fat">>,false}}

4> 'task@repository':get_task(DbConn, 1).
% {ok,{task,1,<<"Buy milk">>,<<"2% fat">>,false}}

5> 'task@repository':update_task(DbConn, {task, 1, "Buy milk", "Whole milk", false}).
% {ok,{task,1,<<"Buy milk">>,<<"Whole milk">>,false}}

6> 'task@repository':delete_task(DbConn, 1).
% {ok,nil}

7> 'task@repository':get_task(DbConn, 1).
% {error,record_not_found}

A few things worth noting:

  • console:init() — compiled from console.gleam, callable with Erlang module syntax just like any other Gleam module.
  • Gleam constructors in Erlang{task_input, "Buy milk", "2% fat", false} is how Gleam's TaskInput("Buy milk", "2% fat", false) looks in Erlang. Custom types become tuples with a lowercase atom tag matching the constructor name.
  • <<"...">> — Gleam's String type compiles to an Erlang binary. shell:strings(true) prints bare binaries as quoted strings, but when a binary is nested inside a tuple the shell still uses the <<"...">> binary literal notation.

What's Next

The repository exposes a clean API over the database, returning Task values and typed errors. Next, we'll wire the five handler stubs to it — decoding JSON bodies, translating DatabaseError into HTTP status codes, and finally returning real task data over the wire.


  1. See commit 9110637 on GitHub ↩︎

  2. See commit 1979a89 on GitHub ↩︎