Architecture
View SourceThis document describes the internal architecture of Kura and how the modules fit together.
Module Overview
┌─────────────────────────────────────────────────┐
│ User Code │
│ (YourRepo, YourSchema modules) │
└──────────┬──────────────────────┬───────────────┘
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ kura_repo │ │ kura_schema │
│ (behaviour) │ │ (behaviour) │
└──────┬──────┘ └──────┬──────┘
│ │
┌──────▼──────────────────────▼───────────────┐
│ kura_repo_worker │
│ (query execution, type conversion, txns) │
└──────┬──────────────┬───────────────────────┘
│ │
┌──────▼──────┐ ┌─────▼──────────┐
│ kura_query │ │ kura_changeset │
│ (builder) │ │ (validation) │
└──────┬──────┘ └────────────────┘
│
┌──────▼──────────────┐
│ kura_query_compiler │
│ (facade, cache) │
└──────┬──────────────┘
│
┌──────▼──────────────────────────────────┐
│ kura_pool / kura_driver / kura_dialect │
│ / kura_capabilities (behaviours) │
└──────┬──────────────────────┬───────────┘
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ kura_pool_pgo │ │ kura_pool_sqlite │
│ kura_driver_pgo │ │ kura_driver_sqlite │
│ kura_dialect_pg │ │ kura_dialect_sqlite │
│ (via pgo) │ │ (via esqlite) │
└──────┬──────┘ └──────┬──────┘
Postgres SQLiteCore Modules
kura_schema
Behaviour that defines the shape of a database table. Schemas declare
their table name, fields (via #kura_field{} records), primary key,
and optionally associations and embeds. Field metadata is cached in
persistent_term for fast lookups at runtime.
kura_changeset
Tracks changes between a schema's current data and incoming params.
Provides casting, validations (validate_required, validate_format,
validate_length, etc.), and constraint declarations that map PostgreSQL
constraint errors back to user-facing field errors.
kura_changeset_assoc
Handles nested association and embed casting (cast_assoc/2,3,
cast_embed/2,3, put_assoc/3). Builds child changesets from nested
params and manages the assoc_changes list on the parent changeset.
kura_query
Composable, functional query builder. Queries are built by chaining
calls like from/1, where/2, order_by/2, limit/2, and
preload/2. The query record accumulates conditions without executing
anything - execution happens in kura_repo_worker.
kura_query_compiler and kura_dialect
#kura_query{} is the portable AST. kura_dialect is the behaviour
that turns it into SQL bytes for a specific backend.
kura_dialect_pgis the default ANSI-ish SQL emitter ($Nplaceholders,RETURNING,ON CONFLICT (...) DO UPDATE, etc.). Lives in kura core.kura_dialect_sqlite(inkura_sqlite) delegates AST emission tokura_dialect_pgand post-processes$N→?N. Type/default emission is overridden viacolumn_type/1andformat_default/1.kura_query_compileris the public-facing facade. It owns the query cache and resolves the configured dialect viaapplication:get_env(kura, dialect). The dialect is set automatically when you configure{backend, kura_backend_postgres|kura_backend_sqlite}.
kura_repo and kura_repo_worker
kura_repo is a thin behaviour - user repo modules implement otp_app/0
(plus optional init/1 to mutate config at runtime). The repo module itself
holds no CRUD callbacks; operations are invoked as
kura_repo_worker:insert(MyRepo, CS) etc., or wrapped with thin
delegations like insert(CS) -> kura_repo_worker:insert(?MODULE, CS).
in the user's repo module.
Database configuration is read from application:get_env(kura, ...) first
(backend, plus connection keys like database, pool_size, and any
backend-specific keys such as host/port/user/password for Postgres).
The legacy application:get_env(OtpApp, RepoModule) form is still accepted
as a fallback. The worker handles:
- Query execution via the configured
kura_driver - Type conversion between Erlang terms and database values (dump/load)
- Constraint-violation mapping back to changeset errors
- Transaction wrapping for changesets with
assoc_changes - Query telemetry (optional logging of queries, durations, results)
kura_preloader
Executes preload queries after the primary query completes. Uses
WHERE ... IN (...) queries rather than JOINs, supporting nested
preloads and all association types including many-to-many via join tables.
kura_multi
Transaction pipelines inspired by Ecto.Multi. Lets you compose named
steps (insert, update, delete, run) where each step can access
the accumulated results of previous steps. The entire pipeline executes
inside a single database transaction.
kura_types
Type system that defines how Erlang values map to database column types.
Each type implements cast (user input → internal), dump (internal →
backend), and load (backend → internal). Supported types include id,
integer, float, string, text, boolean, date, utc_datetime,
uuid, jsonb, {enum, [atom()]}, {array, T}, and {embed, Type, Module}.
The backend dialect's column_type/1 callback determines the SQL column
type emitted in DDL.
kura_migration and kura_migrator
kura_migration is a behaviour for writing migrations with up/0 and
down/0 callbacks (plus optional safe/0 to acknowledge intentionally
unsafe operations during rolling deployments). kura_migrator discovers
migration modules, maintains a schema_migrations table, and applies
pending migrations in order. All migrations run inside a single
transaction. On Postgres, the transaction is guarded by an advisory
lock so concurrent nodes do not race; on SQLite the single-writer
transaction handles serialisation. The advisory-lock SQL is gated on
the configured pool declaring the advisory_locks capability.
Other Modules
kura_db- central facade. All query execution, transactions, pool lookup, sandbox routing, and telemetry events go through here. Resolves the configuredkura_poolandkura_driverper repo and routes calls accordingly.kura_type- behaviour for user-defined custom field types (cast/1,dump/1,load/1,pg_type/0).kura_query_cache- ETS-backed cache for compiled SQL strings, keyed by query shape.kura_paginator- offset and cursor pagination on top ofkura_query.kura_stream- server-side cursor streaming for very large result sets.kura_tenant- process-dictionary-scoped schema prefix for multi-tenant deployments.kura_sandbox- test sandbox that runs each test in its own transaction and rolls back on completion.kura_audit/kura_audit_log- automatic change tracking with actor context, written to a separate audit table.kura_app/kura_sup- OTP application and root supervisor. The application callback resolves the configured backend aggregator (setsdialect,pool_module,driver_modulefromkura_backend_postgres/kura_backend_sqlite) and starts the configured pool.pg_typesdefaults are seeded only when the active dialect iskura_dialect_pg.kura_capabilities- capability flags for backends. A backend module implements the optionalcapabilities/0callback to declare features it supports (returning,jsonb,listen_notify,select_for_update_skip_locked, ...). Consumers callkura_capabilities:require/2from supervisor init so the application refuses to start on a backend that lacks a required feature instead of failing at runtime.
Data Flow
A typical insert operation flows through the system like this:
- User calls
kura_repo_worker:insert(MyRepo, Changeset)(or a thin wrapper on the repo module). - If the changeset is invalid, returns
{error, CS}immediately. - If
assoc_changesis non-empty, or the schema declares lifecycle hooks that need a transaction, the operation is wrapped inkura_db:transaction_ok/2. kura_types:dump/2converts each changed field to its backend representation.kura_query_compiler:insert/4generates a parameterizedINSERT ... RETURNING ...statement (via the configured dialect).- The configured
kura_driverexecutes the query through thekura_poolcheckout. kura_db:load_row/2loads the returned row back into Erlang terms.- Any association changes are persisted with the parent's primary key set as their foreign key.
- Database constraint errors are mapped back to changeset errors via the constraints declared on the changeset.