Kura - Ecto-inspired database layer for Erlang

Hi all,

I’d like to share Kura, a database layer for Erlang that brings Ecto-style abstractions to pure Erlang, targeting PostgreSQL via pgo.

Why I built it

Working with PostgreSQL in Erlang has always meant either raw SQL, thin wrappers, or reaching for Elixir. I wanted the ergonomics that Ecto provides — schemas, changesets, composable
queries, migrations — but in idiomatic Erlang using OTP behaviours and records.

What it does

  • Schemas — behaviour-based definitions with type metadata
  • Changesets — cast external params, validate, track changes and errors
  • Query builder — composable, functional query construction with parameterized SQL (no string interpolation)
  • Associations — belongs_to, has_one, has_many, many_to_many with preloading
  • Embedded schemas — embeds_one/embeds_many stored as JSONB
  • Multi — atomic transaction pipelines with named steps
  • Migrations — DDL operations with automatic module-based discovery
  • Enums — atom-backed enum types stored as VARCHAR
  • Upserts — on_conflict support (nothing, replace_all, replace specific fields)
  • Telemetry — query logging with timing

Quick taste

%% Define a schema
-module(user).
-behaviour(kura_schema).
-include_lib(“kura/include/kura.hrl”).
-export([table/0, fields/0, primary_key/0]).

table() → <<“users”>>.
primary_key() → id.
fields() → 
[#kura_field{name = id, type = id, primary_key = true},
#kura_field{name = name, type = string, nullable = false},
#kura_field{name = email, type = string, nullable = false},
#kura_field{name = age, type = integer}].

%% Cast, validate, insert
CS = kura_changeset:cast(user, #{}, Params, [name, email, age]),
CS1 = kura_changeset:validate_required(CS, [name, email]),
CS2 = kura_changeset:validate_format(CS1, email, <<“@”>>),
{ok, User} = my_repo:insert(CS2).

%% Composable queries
Q = kura_query:from(user),
Q1 = kura_query:where(Q, {age, ‘>’, 18}),
Q2 = kura_query:order_by(Q1, [{name, asc}]),
Q3 = kura_query:limit(Q2, 10),
{ok, Users} = my_repo:all(Q3).

Example project

pet_store is a sample REST API built with Nova and Kura, demonstrating schemas, changesets, queries, migrations, and associations in a working application.

Rebar3 plugin

rebar3_kura auto-generates migration files from schema changes — add a field to your schema, run rebar3 compile, and the migration is created for you.

Links

Requires OTP 27+ and PostgreSQL 14+. Currently at v1.0.1.

I’d love to hear feedback, questions, or feature ideas. Happy to discuss the design choices.

15 Likes

Kura v1.9.0 — Lifecycle Hooks, Audit Trail, Multitenancy, and more

It’s been a busy few weeks since the initial release. Kura has grown from a basic Ecto-style database layer into something with real production features. Here’s what’s new in v1.9.0.

Lifecycle Hooks

Schema modules can now define before_insert, after_insert, before_update, after_update, before_delete, and after_delete callbacks. These run inside the same transaction as the operation, so returning an error rolls everything back.

-module(my_user).
-behaviour(kura_schema).
-include_lib("kura/include/kura.hrl").

-export([table/0, fields/0, after_insert/1]).

table() -> ~"users".
fields() -> [
    #kura_field{name = id, type = id, primary_key = true},
    #kura_field{name = name, type = string},
    #kura_field{name = email, type = string}
].

after_insert(Record) ->
    logger:info("User created: ~p", [maps:get(id, Record)]),
    {ok, Record}.

Audit Trail

Built on top of lifecycle hooks, kura_audit tracks inserts, updates, and deletes in an audit_log table. It captures who made the change (actor context), what changed (automatic diff computation for updates), and stores everything as JSON.

%% Set the actor for the current request
kura_audit:set_actor(~"user-123", #{ip => ~"10.0.0.1"}).

%% In your schema hooks:
before_update(CS) ->
    kura_audit:stash(CS),  %% capture old data for diff
    {ok, CS}.

after_insert(Record) ->
    kura_audit:log(my_repo, ?MODULE, insert, Record),
    {ok, Record}.

Migration setup is one line: kura_audit:migration_up() returns the DDL operations.

Multitenancy

Two strategies out of the box:

Schema prefix — each tenant gets a PostgreSQL schema:

kura_tenant:set_tenant({prefix, ~"tenant_abc"}).
%% All queries now target the tenant_abc schema

Attribute-based — shared table with automatic tenant_id filtering:

kura_tenant:set_tenant({attribute, {org_id, 42}}).
%% Queries get WHERE org_id = 42, inserts get org_id set automatically

Pagination

Both offset-based and cursor-based (keyset) pagination via kura_paginator:

%% Offset
{ok, Page} = kura_paginator:paginate(my_repo, Q, #{page => 2, page_size => 20}).
%% #{entries => [...], total_entries => 150, total_pages => 8, ...}

%% Cursor (efficient for large datasets)
{ok, Page} = kura_paginator:cursor_paginate(my_repo, Q, #{limit => 20, after => LastCursor}).
%% #{entries => [...], has_next => true, start_cursor => ..., end_cursor => ...}

Streaming

Process large result sets in batches without loading everything into memory, using PostgreSQL server-side cursors:

kura_stream:stream(my_repo, Q, fun(Batch) ->
    [process(Row) || Row <- Batch],
    ok
end, #{batch_size => 500}).

Optimistic Locking

Detect concurrent update conflicts with a version field:

CS = kura_changeset:cast(my_schema, Record, Params, [name, email]),
CS1 = kura_changeset:optimistic_lock(CS, lock_version),
case my_repo:update(CS1) of
    {ok, Updated} -> Updated;
    {error, stale} -> %% someone else updated first
end.

Other improvements

  • eqWAlizer clean — removed 19 of 28 type suppressions with proper typed helpers. The remaining 9 are genuine eqWAlizer limitations (pgo returns any(), union clause narrowing limits).
  • ELP lint clean — zero warnings across the entire codebase, migrated all binary strings to OTP 28 sigil syntax (~"").
  • 21 guides in the documentation covering every feature.
  • Requires OTP 28+ (up from 27).

Links

Feedback welcome as always.

4 Likes

Quick update. Kura is at v1.23.0 now, up from the v1.9.0 I posted about in March.

Most of what landed since is filling in corners. Aggregate functions so you don’t have to drop to raw SQL for basic reporting. has_many :through associations. Conditional preloads with where/order_by/limit, which I’d been missing badly. Soft delete. A query compilation cache that gets the SQL builder off the hot path. Custom types via a kura_type behaviour, so domain types plug in without forking. Multi-column ON CONFLICT targets for upserts.

Setup got less ceremonial. Kura now starts its own pgo pool from app env and auto-generates UUID primary keys. Migrations are topo-sorted and can ensure_database for you. ssl_options and socket_options forward through to pgo. There’s an optional generate_id/0 callback on kura_schema for non-UUID ids.

New in the last couple of weeks: kura_schema_verify catches runtime drift between your schema modules and the actual database. The migrator emits telemetry events for migrate and rollback lifecycle, alongside the per-query events that have been there since 1.7.

The query telemetry metadata also gained tenant and error_reason fields. That feeds into the other thing worth mentioning: opentelemetry_kura is on Hex. One call to opentelemetry_kura:setup() in your app start and every Kura query produces an OTel span with db.system, db.statement, db.operation, table name, row count, repo. No instrumentation in your own code, it hooks the telemetry events Kura already emits.

1 Like