El crate es arkeion en crates.io — la versión actual es la v0.12.
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.
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)",
¶ms!["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)", ¶ms![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 + Syncy barato de clonar; unaConnectiones por hilo (Send, noSync).- Las lecturas nunca bloquean una escritura, ni son bloqueadas por ella.
- Escritor único. Las escrituras se serializan. El autocommit (
executeobulk_insertfuera de una transacción) hace cola: bajo contención espera su turno — que dura microsegundos, porque el commit libera al escritor antes de sufdatasyncy los syncs de commits concurrentes se agrupan mediante group commit — y solo devuelveBusysi el escritor sigue retenido pasado un umbral de seguridad. Una transacción explícita (begin()/BEGIN), en cambio, devuelveBusyde 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 delDatabase.- Cada lectura valida su tag de autenticación;
Corrupt,ChainBrokenyWrongKeyson 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 deConnectionque 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 mismoDatabase.
El protocolo está hecho a mano, sin serde, reutilizando la propia codificación de varint y de Value del motor.