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.try — result.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 RecordNotFound — create_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.guard — bool.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 ↩︎