Skip to content

Repository files navigation

Kura

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_many with preloading
  • Composite keys - N-column primary keys (key/0) and composite foreign keys (#kura_ref{})
  • Embedded Schemas - embeds_one, embeds_many stored 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, Query} so neither dialects nor tenants share entries.

Because bound parameters are part of the cached result, a parameterised query interns one entry per distinct value set. 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 result 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

About

Database layer for Erlang

Resources

Stars

16 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages