El crate es arkeion en crates.io — la versión actual es la v0.12.

Embebido — en el proceso tu app Arkeion (biblioteca) llamada fn db.arkeion Cliente / servidor — sobre TCP tu app arkeion-client arkeiond embebe Arkeion TCP db.arkeion
El mismo motor y el mismo fichero, alcanzados de dos formas. Todo lo que sigue describe la API embebida; la capa de red refleja un subconjunto de ella.

Tipos principales

#![forbid(unsafe_code)]

pub struct Database;            // shared, cloneable handle (internal Arc)
pub struct Connection;          // view over a branch (defaults to "main")
pub struct Transaction<'conn>;  // explicit multi-statement write
pub struct Rows;                // result iterator
pub struct Row;

#[derive(Clone, Debug, PartialEq)]
pub enum Value { Null, Bool(bool), Integer(i64), Real(f64), Text(String), Blob(Vec<u8>) }

#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version(pub u64);

#[derive(Clone, Copy)]
pub enum AsOf { Head, Version(u64), Timestamp(std::time::SystemTime) }

pub struct Options {
    pub create_if_missing: bool,   // true by default (zero-config)
    pub key: Option<Key>,          // Some(_) => AES-256-GCM at rest
    pub compress: bool,            // page compression on create (off by default)
    pub ecc_nsym: u8,              // Reed-Solomon parity per block on create (0 = off)
    pub cache_bytes: usize,        // page cache cap (default 64 MiB)
}
// builders: .create_if_missing(b) .with_key(k) .compress(b) .ecc(n) .cache_bytes(n)

pub struct Key([u8; 32]);          // raw key; Drop => zeroize. Deriving it from a
                                   // passphrase (a KDF) is the caller's responsibility.

#[non_exhaustive]
pub enum Error { Io(..), Corrupt{..}, ChainBroken{at: Version, ..}, WrongKey,
                 Sql{msg: String, pos: usize}, Conflict(MergeConflicts),
                 BranchNotFound(String), VersionNotFound(AsOf), Busy, .. }
pub type Result<T> = std::result::Result<T, Error>;

Dos cosas que conviene notar de entrada. Value es un conjunto cerrado de seis tipos SQL, así que no hay sorpresas de tipado dinámico. Y los errores son un enum tipado y #[non_exhaustive]: una cadena de hashes rota, una clave incorrecta o una corrupción en disco son valores que gestionas, nunca datos silenciosamente malos devueltos como si estuvieran bien.

Database — ciclo de vida, ramas, auditoría

El Database es un handle barato y clonable sobre el fichero. Clonarlo no es más que incrementar un Arc; compártelo entre hilos sin reparos.

impl Database {
    pub fn open(path: impl AsRef<Path>, opts: Options) -> Result<Database>;

    pub fn connect(&self) -> Result<Connection>;                  // "main" branch
    pub fn connect_branch(&self, name: &str) -> Result<Connection>;

    pub fn create_branch(&self, name: &str, from: AsOf) -> Result<()>;
    pub fn drop_branch(&self, name: &str) -> Result<()>;          // deletes the ref, not the pages
    pub fn branches(&self) -> Result<Vec<BranchInfo>>;            // {name, head: Version, created}

    pub fn diff(&self, from: &str, to: &str) -> Result<Diff>;     // O(changes), not O(data)
    pub fn merge(&self, from: &str, into: &str, policy: MergePolicy) -> Result<MergeReport>;

    pub fn verify(&self) -> Result<AuditReport>;                  // walks the entire hash chain
    pub fn verify_anchor(&self, anchor: &AuditAnchor) -> Result<AuditReport>; // detects truncation/rewriting

    pub fn history(&self) -> Result<Vec<Revision>>;               // "git log": timeline of versions
    pub fn diff_versions(&self, from: u64, to: u64) -> Result<Diff>;          // "git diff" between versions

    pub fn vacuum(&self, retention: Retention) -> Result<VacuumReport>;       // compacts + atomic rename
    pub fn vacuum_rekey(&self, retention: Retention, new_key: Option<Key>)    // compacts and rotates the key
        -> Result<VacuumReport>;
}

pub enum MergePolicy { FailOnConflict }                 // v1; future: Theirs, Ours, resolver
pub enum Retention   { KeepAll, KeepLast(u64), KeepSince(SystemTime) }

pub struct Diff { pub tables: Vec<TableDiff> }          // additions/deletions/modifications by rowid + schema diffs
pub struct AuditReport { pub head: u64, pub commits: u64, pub chain_ok: bool, pub chain_hash: [u8; 32] }
pub struct AuditAnchor { pub version: u64, pub chain_hash: [u8; 32] }   // AuditReport::anchor() creates it
pub struct Revision { pub version: u64, pub timestamp: SystemTime, pub parent: u64 }
pub struct VacuumReport {
    pub kept_from: u64, pub head: u64, pub reclaimed_versions: u64,
    pub pages_before: u64, pub pages_after: u64,
}

branches, diff y merge son la superficie de control de versiones: una rama es un puntero con nombre a una versión, diff es O(cambios) porque recorre las dos raíces de commit en lugar de los datos, y merge reproduce una rama sobre otra bajo una política. verify() recorre la cadena de hashes entera y te dice que está intacta; verify_anchor() la compara con un anchor que guardaste antes, que es la forma de detectar que el historial se ha truncado o reescrito a tus espaldas. vacuum(Retention) es la única operación que olvida: descarta las versiones fuera de la ventana de retención y reescribe el conjunto vivo en un fichero nuevo con un rename atómico.

Connection — SQL, transacciones, viajes en el tiempo

impl Connection {
    pub fn execute(&self, sql: &str, params: &[Value]) -> Result<usize>;   // rows affected
    pub fn query(&self, sql: &str, params: &[Value]) -> Result<Rows>;
    pub fn prepare(&self, sql: &str) -> Result<Statement>;                 // parses once

    /// Bulk load: all rows in ONE transaction (one commit, one fdatasync),
    /// without a per-row SQL executor; index entries are inserted in bulk
    /// (UNIQUE verified). Autocommit only: either the whole batch or nothing.
    pub fn bulk_insert<I, R>(&self, table: &str, rows: I) -> Result<usize>
    where I: IntoIterator<Item = R>, R: AsRef<[Value]>;

    pub fn begin(&self) -> Result<Transaction<'_>>;     // acquires the single writer

    pub fn snapshot(&self, at: AsOf) -> Result<Connection>;  // pinned READ-ONLY connection
    pub fn version(&self) -> Version;                   // current head of the branch
    pub fn branch(&self) -> &str;
}

impl Transaction<'_> {
    pub fn execute(&self, sql: &str, params: &[Value]) -> Result<usize>;
    pub fn query(&self, sql: &str, params: &[Value]) -> Result<Rows>;      // reads its own writes
    pub fn commit(self) -> Result<Version>;
    pub fn rollback(self) -> Result<()>;                // Drop without commit => implicit rollback
}

execute/query fuera de una transacción son autocommit: una transacción por sentencia. Dentro de begin(), las sentencias se acumulan y commit() devuelve la nueva Version que han producido.

Un SELECT sencillo — una proyección de columnas o * sin WHERE/JOIN/agregados/ORDER BY — se sirve en streaming: Rows es dueño de su snapshot y descodifica cada fila conforme itera, tocando solo las columnas proyectadas y sin materializar nunca el resultado completo. Cualquier otra consulta pasa por el ejecutor completo; el resultado es indistinguible salvo en coste.

Snapshots y viajes en el tiempo

snapshot(at) te da una Connection de solo lectura fijada a una versión pasada. Como el historial es append-only, ese snapshot es estable por muchos commits que lleguen después: los lectores fijan una versión, el escritor único añade otras nuevas y ambos nunca compiten.

historial de commits (solo añadir) v1 v2 v3 v4 v5 cabeza el escritor añade snapshot(Version(2)) lector @ cabeza
Un lector fijado conserva su versión aunque el escritor siga añadiendo. Viajar en el tiempo no es una copia: es un segundo puntero al mismo historial inmutable.

API del motor (sin SQL)

Acceso tipado a filas que se salta el parser, el planificador y el ejecutor de SQL y va directo al catálogo y al b-tree, preservando todas las garantías (versionado, índices, cifrado y cadena de auditoría). Connection::table toma un snapshot consistente al crearse; es una lectura estable que no ve las transacciones abiertas. Escribir a nivel de motor se hace con bulk_insert.

impl Connection {
    pub fn table(&self, name: &str) -> Result<TableReader>;  // snapshot on creation
}

impl TableReader {
    pub fn get(&self, rowid: i64) -> Result<Option<Vec<Value>>>;      // point lookup by PK
    pub fn scan(&self) -> Result<impl Iterator<Item = Result<(i64, Vec<Value>)>>>;
    pub fn scan_columns(&self, cols: &[usize]) -> Result<ProjectedScan>; // projected, no alloc/row
    pub fn count(&self) -> Result<u64>;
    pub fn column_index(&self, name: &str) -> Option<usize>;
    pub fn version(&self) -> u64;
}

impl ProjectedScan { // borrowing iterator: next() borrows &[Value] until the next call
    pub fn next(&mut self) -> Result<Option<&[Value]>>;
}

Para qué sirve: rendimiento y control cuando no hace falta SQL — libros contables embebidos, almacenes de eventos, mantenimiento de índices a medida. Medido sobre el mismo motor y los mismos datos, una búsqueda puntual por PK es unas 3,7× más rápida que SELECT … WHERE id = ?, porque se ahorra el parseo, la planificación y la validación de cada llamada. Un escaneo completo solo gana en torno a 1,1×, porque la ruta de escaneo de SQL ya usa la misma vía de streaming proyectado: ahí el coste es la descodificación del registro por fila, no la capa SQL. En resumen, la ventaja de la API del motor está en el acceso aleatorio y puntual, no en el escaneo.

Filas y parámetros

impl Rows { /* Iterator<Item = Result<Row>> */ }

impl Row {
    pub fn get<T: FromValue>(&self, col: impl ColIndex) -> Result<T>;  // by index or name
}

// FromValue for: i64, f64, String, Vec<u8>, bool, Option<T>, Value
// Into<Value> for the same, via a convenience macro:
let n = conn.execute(
    "INSERT INTO clients (name, created) VALUES (?1, ?2)",
    &params!["Acme GmbH", 1718000000_i64],
)?;

Ejemplo completo

use arkeion::{Database, Options, AsOf, MergePolicy, params};

let db = Database::open("tenant-42.arkeion", Options::default().with_key(key))?;
let conn = db.connect()?;

conn.execute("CREATE TABLE invoices (id INTEGER PRIMARY KEY, total REAL, status TEXT)", &[])?;

let tx = conn.begin()?;
tx.execute("INSERT INTO invoices (total, status) VALUES (?1, ?2)", &params![120.0, "draft"])?;
let v1 = tx.commit()?;

// — time-travel —
conn.execute("UPDATE invoices SET status = 'issued' WHERE id = 1", &[])?;
let before = conn.snapshot(AsOf::Version(v1.0))?;
let status: String = before.query("SELECT status FROM invoices WHERE id = 1", &[])?
                           .next().unwrap()?.get(0)?;          // "draft"

// — branching for a migration —
db.create_branch("vat-migration", AsOf::Head)?;
let mig = db.connect_branch("vat-migration")?;
mig.execute("UPDATE invoices SET total = total * 1.21", &[])?;
let diff = db.diff("main", "vat-migration")?;                  // review before merging
db.merge("vat-migration", "main", MergePolicy::FailOnConflict)?;

// — audit —
assert!(db.verify()?.chain_ok);

Garantías de la API

  • Database: Send + Sync y barato de clonar; una Connection es por hilo (Send, no Sync).
  • Las lecturas nunca bloquean una escritura, ni son bloqueadas por ella.
  • Escritor único. Las escrituras se serializan. El autocommit (execute o bulk_insert fuera de una transacción) hace cola: bajo contención espera su turno — que dura microsegundos, porque el commit libera al escritor antes de su fdatasync y los syncs de commits concurrentes se agrupan mediante group commit — y solo devuelve Busy si el escritor sigue retenido pasado un umbral de seguridad. Una transacción explícita (begin() / BEGIN), en cambio, devuelve Busy de inmediato si hay otra escritura en curso, ya que puede retener al escritor un tiempo indefinido y no debe dejar colgados a otros llamantes.
  • snapshot() y las conexiones a ramas comparten la misma caché de páginas del Database.
  • Cada lectura valida su tag de autenticación; Corrupt, ChainBroken y WrongKey son errores tipados, nunca datos silenciosamente malos.

Acceso por red

La API anterior es embebida y en proceso. Para el acceso por red, la capa cliente / servidor la expone sobre un protocolo nativo — una rama por sesión, con AS OF y verify como operaciones de primera clase — en crates separados:

  • arkeion-client (Client, MIT/Apache, repositorio aparte): connect, use_branch, execute, query / query_as_of, verify. Refleja el subconjunto de Connection que viaja por la red.
  • arkeiond (el servidor, propietario): el daemon, con un hilo por conexión, que sirve cada sesión sobre una rama del mismo Database.

El protocolo está hecho a mano, sin serde, reutilizando la propia codificación de varint y de Value del motor.