Kura
Database layer for Erlang - Ecto-equivalent abstractions in pure Erlang. Pluggable backends: kura_postgres (PostgreSQL via pgo), kura_sqlite (SQLite via esqlite).
Features
- Schema - behaviour-based schema definitions with type metadata
- Changeset - cast external params, validate, track changes and errors
- Query Builder - composable, functional query construction
- SQL Compiler - parameterized SQL generation (no string interpolation)
- Repo - CRUD operations with automatic type conversion and PG error mapping
- Associations -
belongs_to,has_one,has_many,many_to_manywith preloading - Composite keys - N-column primary keys (
key/0) and composite foreign keys (#kura_ref{}) - Embedded Schemas -
embeds_one,embeds_manystored as JSONB - Multi - atomic transaction pipelines
- Migrations - DDL operations with automatic module-based discovery
- Enums - atom-backed enum types stored as
VARCHAR - Telemetry - query logging with timing
- Lifecycle Hooks - before/after callbacks for insert, update, delete
- Audit Trail - automatic change tracking with actor context
- Pagination - offset-based and cursor-based pagination
- Streaming - server-side cursor streaming for large result sets
- Multitenancy - schema prefix and attribute-based tenant isolation
- Optimistic Locking - concurrent update conflict detection
- Encryption at rest -
{encrypted, Type}fields, AES-256-GCM, key rotation
Quick Start
Define a Schema
-module(user).
-behaviour(kura_schema).
-include_lib("kura/include/kura.hrl").
-export([table/0, fields/0]).
table() -> ~"users".
fields() ->
[
#kura_field{name = id, type = id, primary_key = true, nullable = false},
#kura_field{name = name, type = string, nullable = false},
#kura_field{name = email, type = string, nullable = false},
#kura_field{name = age, type = integer},
#kura_field{name = inserted_at, type = utc_datetime},
#kura_field{name = updated_at, type = utc_datetime}
].
Define a Repo
-module(my_repo).
-behaviour(kura_repo).
-export([otp_app/0, start/0, all/1, get/2, insert/1, update/1, delete/1]).
otp_app() -> my_app.
start() -> kura_repo_worker:start(?MODULE).
all(Q) -> kura_repo_worker:all(?MODULE, Q).
get(Schema, Id) -> kura_repo_worker:get(?MODULE, Schema, Id).
insert(CS) -> kura_repo_worker:insert(?MODULE, CS).
update(CS) -> kura_repo_worker:update(?MODULE, CS).
delete(CS) -> kura_repo_worker:delete(?MODULE, CS).
Configure the database connection in sys.config:
[{my_app, [
{my_repo, #{
database => ~"myapp",
hostname => ~"localhost",
port => 5432,
username => ~"postgres",
password => <<>>,
pool_size => 10
}}
]}].
Changesets
%% Cast and validate external params
CS = kura_changeset:cast(user, #{}, #{~"name" => ~"Alice", ~"email" => ~"alice@example.com"}, [name, email, age]),
CS1 = kura_changeset:validate_required(CS, [name, email]),
CS2 = kura_changeset:validate_format(CS1, email, ~"@"),
CS3 = kura_changeset:validate_length(CS2, name, [{min, 1}, {max, 100}]),
%% Insert
{ok, User} = my_repo:insert(CS3).
Query Builder
Q = kura_query:from(user),
Q1 = kura_query:where(Q, {age, '>', 18}),
Q2 = kura_query:where(Q1, {'or', [{role, ~"admin"}, {role, ~"moderator"}]}),
Q3 = kura_query:select(Q2, [name, email]),
Q4 = kura_query:order_by(Q3, [{name, asc}]),
Q5 = kura_query:limit(Q4, 10),
{ok, Users} = my_repo:all(Q5).
Supported conditions: =, !=, <, >, <=, >=, like, ilike, matches, in, not_in, is_nil, is_not_nil, between, {'and', [...]}, {'or', [...]}, {'not', ...}, {fragment, SQL, Params}.
Full-text search
{Field, matches, Query} is a full-text search: on PostgreSQL it compiles
to to_tsvector(Field) @@ plainto_tsquery($n), with the query text bound
as a parameter (plainto_tsquery treats it as plain words, so no query
syntax is needed or trusted). Requires a backend declaring the
full_text_search capability.
Q = kura_query:where(kura_query:from(article), {body, matches, ~"erlang database"}),
{ok, Hits} = my_repo:all(Q).
Matching uses the server's default_text_search_config for stemming and
stop-words. For production, add a GIN expression index so the search
doesn't recompute a tsvector per row:
CREATE INDEX articles_body_fts ON articles USING gin (to_tsvector('english', body));
Window functions
Use over/2 inside select_expr/2 for OVER (PARTITION BY ... ORDER BY ...)
expressions. The window function is an aggregate ({count, '*'}, {sum, Field},
{avg, Field}, {min, Field}, {max, Field}) or a ranking function
(row_number, rank, dense_rank).
Q = kura_query:select_expr(kura_query:from(sales), [
{category, category},
{row_num, kura_query:over(row_number, #{partition_by => [category], order_by => [{amount, desc}]})},
{running_total, kura_query:over({sum, amount}, #{partition_by => [category], order_by => [{day, asc}]})}
]),
{ok, Rows} = my_repo:all(Q).
Requires a backend declaring the window_functions capability — PostgreSQL today (SQLite window rendering is planned).
Migrations
-module(m20240115120000_create_users).
-behaviour(kura_migration).
-include_lib("kura/include/kura.hrl").
-export([up/0, down/0]).
up() ->
[{create_table, ~"users", [
#kura_column{name = id, type = id, primary_key = true, nullable = false},
#kura_column{name = name, type = string, nullable = false},
#kura_column{name = email, type = string, nullable = false},
#kura_column{name = age, type = integer},
#kura_column{name = inserted_at, type = utc_datetime},
#kura_column{name = updated_at, type = utc_datetime}
]},
{create_index, ~"users", [email], #{unique => true}}].
down() ->
[{drop_index, ~"users_email_index"},
{drop_table, ~"users"}].
Run migrations:
kura_migrator:migrate(my_repo).
kura_migrator:rollback(my_repo).
kura_migrator:status(my_repo).
Type Mapping
| Kura | PostgreSQL | SQLite | Erlang |
|---|---|---|---|
id |
BIGSERIAL |
INTEGER PRIMARY KEY |
integer() |
integer |
INTEGER |
INTEGER |
integer() |
float |
DOUBLE PRECISION |
REAL |
float() |
string |
VARCHAR(255) |
TEXT |
binary() |
text |
TEXT |
TEXT |
binary() |
boolean |
BOOLEAN |
INTEGER (0/1) |
boolean() |
date |
DATE |
TEXT (ISO 8601) |
{Y, M, D} |
utc_datetime |
TIMESTAMPTZ |
TEXT (ISO 8601) |
calendar:datetime() |
uuid |
UUID |
TEXT |
binary() |
jsonb |
JSONB |
TEXT |
map() |
{array, T} |
T[] |
unsupported | list() |
{encrypted, T} |
BYTEA |
BLOB |
T (encrypted at rest) |
{vector, N} |
VECTOR(N) |
unsupported | [float()] |
SQLite values round-trip transparently via kura_types:cast/2 (booleans 0/1 → true/false, ISO 8601 → datetime tuples, JSON text → maps).
{vector, N} (and dimensionless vector) map to pgvector
columns for embeddings; requires CREATE EXTENSION vector and a backend
declaring the vector capability. A value is a list of numbers, dimension-checked
on cast. Distance-query operators (<-> / <=>) are not yet a first-class query
form - use a {fragment, SQL, Params} for nearest-neighbour ordering meanwhile.
Elements are stored as float4, so a value read back from a column is the
single-precision approximation of what was written, not the exact double.
Encryption at rest
Wrap a field type in {encrypted, Type} to store it AES-256-GCM-encrypted
in a BYTEA column. Values are encrypted on write and decrypted on read;
casting and validation still run on the plaintext.
fields() ->
[
#kura_field{name = id, type = id, primary_key = true},
#kura_field{name = email, type = string},
#kura_field{name = ssn, type = {encrypted, string}, nullable = false}
].
Supported inner types: string, text, binary, uuid, jsonb,
integer, bigint, smallint, boolean. Configure keys under the
kura app env - each a base64-encoded 32-byte key, with active
selecting the one used for new writes:
[{kura, [
{encryption, #{
active => 1,
keys => [{1, <<"base64-encoded-32-byte-key">>}]
}}
]}].
Rotation: add a new key and move active to it. Each ciphertext
carries its key id, so existing rows keep decrypting under their original
key - no backfill. A custom keyring (KMS/Vault) can replace the default by
implementing the kura_keyring behaviour and setting {kura, [{keyring, my_keyring}]}.
Constraints: encrypted columns are not queryable and cannot carry a
unique constraint (each write has a random nonce, so equal plaintexts
produce different ciphertexts). Primary and foreign keys cannot be
encrypted. NULL stays NULL (unencrypted), so presence still leaks.
Schema introspection (rebar3 kura gen_schemas) cannot detect that a
BYTEA column was encrypted - annotate it by hand. The audit trail
redacts encrypted fields to [encrypted] (it keeps the "changed" signal
but never logs the value). Ciphertext is not yet bound to its row/column,
so a database-level actor could copy a ciphertext between rows of the same
column; per-column binding is planned.
Configuration
Configure repos under the kura app env. Each repo is a map keyed by
its module name; pick a backend package and Kura starts the configured
pool at app boot, populating dialect, pool_module, and driver_module
from the aggregator automatically.
%% sys.config — single Postgres repo
[{kura, [
{repos, #{
my_repo => #{
backend => kura_backend_postgres,
host => "localhost",
port => 5432,
database => "my_app_dev",
user => "postgres",
password => "postgres",
pool_size => 10
}
}}
]}].
%% sys.config — single SQLite repo
[{kura, [
{repos, #{
my_repo => #{
backend => kura_backend_sqlite,
database => <<"my_app.db">>, %% or <<":memory:">>
pool_size => 4
}
}}
]}].
%% sys.config — Postgres primary + SQLite analytics
[{kura, [
{repos, #{
my_repo => #{
backend => kura_backend_postgres,
host => "localhost",
database => "main",
user => "postgres",
pool_size => 10
},
analytics_repo => #{
backend => kura_backend_sqlite,
database => <<":memory:">>
}
}}
]}].
Each repo module declares itself in code:
-module(my_repo).
-behaviour(kura_repo).
-export([otp_app/0]).
otp_app() -> my_app.
Queries through my_repo emit Postgres SQL; queries through
analytics_repo emit SQLite SQL. The query cache is keyed on
{Repo, Tenant, Shape} so neither dialects nor tenants share entries.
The shape is the query with its bound values taken out, so
get(User, Id) holds one entry however many ids it is called with. An
in list is one shape per list length. Two settings bound the table,
both counted in words:
| Setting | Default | Effect |
|---|---|---|
query_cache_max_memory |
8000000 (~64 MB) |
cache is dropped wholesale once the table exceeds this |
query_cache_max_entry_size |
4096 |
a single entry larger than this is never cached |
Lower query_cache_max_entry_size if callers build in filters from
user-supplied lists and you want them kept out of the cache entirely.
kura_query_cache:flush/0 drops every entry; call it if you swap a
repo's dialect at runtime, since the dialect is not part of the key.
UUID primary keys are auto-generated on insert when no value is
provided. The default is UUIDv4 (random) - secure by default, since
a v4 key discloses nothing about the row. Set uuid_version => v7 in a
repo's config to use time-ordered UUIDv7 for better index locality; note
that a v7 key embeds its creation timestamp, so avoid it for keys exposed
in URLs or public APIs. A schema's own generate_id/0 callback still
takes precedence over both.
Read replicas
A replica is just another repo pointed at a read replica. Mark it
read_only => true so writes are refused, and list it under the
primary's replicas so the primary can hand it out:
{repos, #{
my_repo => #{backend => kura_backend_postgres, host => "primary", replicas => [my_repo_ro]},
my_repo_ro => #{backend => kura_backend_postgres, host => "replica", read_only => true}
}}
read_only => true makes every write (insert/update/delete and the
bulk and soft-delete variants) return {error, read_only}. Routing is
explicit - pick a replica per read and own read-after-write yourself:
Replica = kura_repo:replica(my_repo), %% a replica repo, or my_repo if none
{ok, Rows} = Replica:all(Q).
There is no automatic write/read split - reads go to whichever repo you
call, so a read that must see a just-written row should use the primary.
The guard covers the changeset write API; raw SQL via query/3 is not
guarded, so a raw write on a replica is rejected by PostgreSQL itself.
Legacy v1.x config forms (still supported)
The flat single-repo form:
[{kura, [
{repo, my_repo},
{backend, kura_backend_postgres},
{host, "localhost"},
{port, 5432},
{database, "my_app_dev"},
{user, "postgres"},
{password, "postgres"},
{pool_size, 10}
]}].
The per-app form:
[{my_app, [
{my_repo, #{
backend => kura_backend_postgres,
database => ~"my_app_dev",
hostname => ~"localhost",
port => 5432,
username => ~"postgres",
password => ~"postgres",
pool_size => 10
}}
]}].
The per-app form requires the consuming app to call my_repo:start()
manually. The {repos, #{...}} form (above) is preferred for new
projects - single-repo today, no rewrite when you add a second backend.
Migrations are discovered automatically from compiled modules implementing the kura_migration behaviour.
Optional telemetry/logging config:
[{kura, [
{log, true} %% true | {M, F} | false (default)
]}].
Plugins
- rebar3_kura - Rebar3 plugin that auto-generates migration files from schema changes. Add a field to your schema, run
rebar3 compile, and the migration is created for you. - opentelemetry_kura - OpenTelemetry instrumentation. Subscribes to Kura's telemetry events and creates spans for every database query.
Examples
- pet_store - A sample REST API built with Kura and Nova demonstrating schemas, changesets, queries, migrations, and associations in practice.
Requirements
- Erlang/OTP 28+
- One backend: PostgreSQL 14+ (via kura_postgres) or SQLite 3.35+ (via kura_sqlite)