SagaSaga
Kraken

Repositories

Kraken executes every read, write, preload, and transaction through one Repo effect. A production handler captures its PostgreSQL connection, so ordinary application functions do not accept or retrieve a pool:

pub fun find_user : Int -> Result (Maybe User) Db.DbError needs {Db.Repo}
find_user id = Db.one (user_query id)

Create and install the handler at the application boundary:

let connection = SagaPgo.connect config
let repo = Db.postgres_repo connection
app () with repo

Keep this installation outside leaf query and service functions. That keeps the repository replaceable in tests and makes each function's database dependency visible in its effect row.

Query logging

Use the repository builder when the application wants structured PostgreSQL execution events:

fun make_repo : SagaPgo.Connection -> Handler Db.Repo needs {AppLog}
make_repo connection =
  Db.repo_config connection
  |> Db.log_queries Db.Queries (fun event -> debug! (show event))
  |> Db.build_repo

The sink is effect-transparent: AppLog above belongs to the application, not Kraken. It can instead write JSON, emit an OpenTelemetry span, append to a test collector, or be a pure callback. Install the resulting repository exactly like Db.postgres_repo.

Db.Queries emits one QueryLog per physical SQL statement. Relation preloads therefore produce their own Preload events rather than being hidden inside the parent all or one call. Db.Errors emits only statements rejected by PostgreSQL.

Each event contains:

  • kind: the originating repository operation, or Preload
  • sql: SQL with PostgreSQL placeholders
  • param_count: the number of bound parameters
  • duration_us: elapsed execution time
  • result: Ok count or the original QueryError

Bound parameter values are deliberately not logged: web application parameters frequently contain credentials, tokens, and personal data. show event provides a useful single-line default, while structured sinks can consume the fields directly.

Multiple databases

For one database, use Db.Repo directly. For two or more semantic repositories, mint nominal copies of the interface:

pub neweffect AuthRepo = Db.Repo
pub neweffect DataRepo = Db.Repo

The effects intentionally do not satisfy one another or Db.Repo. Functions call the operation from the repository they require:

fun find_user : Int -> Result (Maybe User) Db.DbError needs {AuthRepo}
find_user id = AuthRepo.one! (user_query id)

fun recent_activity : Int -> Result (List Activity) Db.DbError needs {DataRepo}
recent_activity id = DataRepo.all! (activity_query id)

fun dashboard : Int -> Result Page AppError needs {AuthRepo, DataRepo}

Nominal adapter

An application defines the exact boundary between each nominal effect and a concrete Handler Db.Repo. The separate transaction-body handler reinstalls the nominal repository inside a transaction and rejects a nested transaction before it can disturb the outer one:

import Kraken.Db
import SagaPgo as Pgo

fun auth_transaction_body : Handler Db.Repo -> Handler AuthRepo
auth_transaction_body backend = handler for AuthRepo {
  run query = resume (Db.Repo.run! query with backend)
  all query = resume (Db.Repo.all! query with backend)
  one query = resume (Db.Repo.one! query with backend)
  exactly_one query = resume (Db.Repo.exactly_one! query with backend)
  exec statement = resume (Db.Repo.exec! statement with backend)
  transaction _body =
    resume Err (Pgo.TransactionFailed Pgo.NestedTransaction)
}

fun auth_repo : Handler Db.Repo -> Handler AuthRepo
auth_repo backend = handler for AuthRepo {
  run query = resume (Db.Repo.run! query with backend)
  all query = resume (Db.Repo.all! query with backend)
  one query = resume (Db.Repo.one! query with backend)
  exactly_one query = resume (Db.Repo.exactly_one! query with backend)
  exec statement = resume (Db.Repo.exec! statement with backend)
  transaction body = {
    let body_repo = auth_transaction_body backend
    resume (Db.Repo.transaction! (fun () -> body () with body_repo) with backend)
  }
}

Build one independently captured backend per database:

let auth = auth_repo (Db.postgres_repo auth_connection)
let data = data_repo (Db.postgres_repo data_connection)
dashboard user_id with {auth, data}

If two connections target the same PostgreSQL database, give their saga_pgo configs distinct pool_name values before calling connect.

A route may require both effects, but a transaction belongs to exactly one repository. Kraken does not imply a distributed transaction across two databases.

Transactions

The base repository wrapper is connectionless:

Db.transaction (fun () -> {
  case Db.exec (insert_user user) {
    Err err -> Err err
    Ok _ -> Db.exec (insert_profile profile)
  }
})

Ok value commits. Returning Err error rolls back and produces Err (RolledBack error). Inside the body, rollback! error exits immediately, skips the continuation, and produces the same result. A nested transaction fails with TransactionFailed NestedTransaction; savepoints are not yet supported.

Nominal repositories use their copied operation directly:

AuthRepo.transaction! (fun () -> {
  # AuthRepo.one!, AuthRepo.exec!, ...
})

Test backends

Db.empty_repo is deterministic and does not inspect SQL:

  • all returns Ok []
  • one returns Ok Nothing
  • exactly_one returns Err (ExpectedOneRow 0)
  • exec returns Ok 0
  • transactions execute their body with normal returned-error and rollback! behavior

Db.rejecting_repo message lets transaction callbacks run but panics at the first read or write. Use it to prove validation or authorization exits before touching the database.

For integration tests, use Db.postgres_repo test_connection through the same base or nominal adapter used in production. Kraken intentionally does not pretend to be an in-memory SQL engine. A savepoint-based sandbox is future work.