feat(nakui): módulo crm — clientes, pipeline de ventas, interacciones

Módulo CRM declarativo (schema.ncl + nsmc.json + morfismos Rhai) con
tres entities (Cliente, Oportunidad, Interaccion) y tres morfismos:
abrir_oportunidad, mover_oportunidad (pipeline con validación de
transiciones) y registrar_interaccion.

crm_demo: demo realista de 18 eventos que —a diferencia de los otros
demos— conserva el event log e imprime el comando de nakui-explorer,
así el explorador muestra un CRM con cuerpo. tests/crm.rs: 8 tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-21 18:21:09 +00:00
parent bb21c28eb1
commit 78fbde12b4
38 changed files with 1229 additions and 334 deletions
@@ -0,0 +1,307 @@
//! Demo del módulo `crm`: un escenario realista — tres clientes, sus
//! oportunidades recorriendo el pipeline de ventas, e interacciones.
//!
//! A diferencia de los otros demos, **no borra el event log**: lo deja
//! en disco para que `nakui-explorer` lo muestre. Al terminar imprime
//! el comando exacto para abrir el explorador sobre este log.
//!
//! ```sh
//! cargo run -p nakui-core --bin crm_demo
//! # …luego, con la ruta que imprime:
//! NAKUI_EVENT_LOG=/tmp/nakui-crm.jsonl cargo run -p nakui-explorer
//! ```
use std::path::{Path, PathBuf};
use nakui_core::event_log::{execute_and_log, seed_and_log, EventLog, ExecuteError, LogEntry};
use nakui_core::executor::Executor;
use nakui_core::store::{MemoryStore, Store};
use serde_json::json;
use uuid::Uuid;
const TS: &str = "2026-05-21T12:00:00Z";
fn main() {
let module_dir = std::env::var("NAKUI_MODULE")
.map(PathBuf::from)
.unwrap_or_else(|_| {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("dir del módulo nakui sobre core/")
.join("modules/crm")
});
let exec = Executor::load_module(&module_dir).expect("cargar el módulo crm");
let log_path = std::env::var("NAKUI_EVENT_LOG")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir().join("nakui-crm.jsonl"));
let _ = std::fs::remove_file(&log_path); // empezar de cero
let mut log = EventLog::open(&log_path).expect("abrir el event log");
let mut store = MemoryStore::new();
// --- Seed: tres clientes -------------------------------------------
section("seed · 3 clientes");
let acme = Uuid::new_v4();
let beta = Uuid::new_v4();
let gamma = Uuid::new_v4();
seed_cliente(
&exec,
&mut store,
&mut log,
acme,
"Acme Corp",
"compras@acme.com",
);
seed_cliente(&exec, &mut store, &mut log, beta, "Beta SA", "ti@beta.com");
seed_cliente(
&exec,
&mut store,
&mut log,
gamma,
"Gamma Ltda",
"ceo@gamma.com",
);
// --- Acme: una oportunidad que se gana -----------------------------
section("Acme · «Licencia anual» $12 000 — recorre el pipeline");
let opp_acme = Uuid::new_v4();
abrir(
&exec,
&mut store,
&mut log,
acme,
opp_acme,
"Licencia anual",
12_000,
);
interaccion(
&exec,
&mut store,
&mut log,
acme,
"llamada",
"Primer contacto, interés alto",
);
for etapa in ["calificado", "propuesta", "negociacion", "ganada"] {
mover(&exec, &mut store, &mut log, opp_acme, etapa);
}
interaccion(
&exec,
&mut store,
&mut log,
acme,
"email",
"Contrato firmado recibido",
);
// --- Beta: una oportunidad que se pierde ---------------------------
section("Beta · «Piloto trimestral» $3 000 — se pierde");
let opp_beta = Uuid::new_v4();
abrir(
&exec,
&mut store,
&mut log,
beta,
opp_beta,
"Piloto trimestral",
3_000,
);
interaccion(
&exec,
&mut store,
&mut log,
beta,
"reunion",
"Demo en sus oficinas",
);
mover(&exec, &mut store, &mut log, opp_beta, "calificado");
mover(&exec, &mut store, &mut log, opp_beta, "propuesta");
mover(&exec, &mut store, &mut log, opp_beta, "perdida");
// --- Gamma: una oportunidad en curso -------------------------------
section("Gamma · «Expansión regional» $25 000 — en curso");
let opp_gamma = Uuid::new_v4();
abrir(
&exec,
&mut store,
&mut log,
gamma,
opp_gamma,
"Expansión regional",
25_000,
);
mover(&exec, &mut store, &mut log, opp_gamma, "calificado");
interaccion(
&exec,
&mut store,
&mut log,
gamma,
"llamada",
"Pidieron referencias",
);
// --- Operaciones inválidas: el kernel las rechaza, no se loguean ---
section("validaciones · estas operaciones se rechazan");
mover(&exec, &mut store, &mut log, opp_acme, "propuesta"); // ya cerrada
mover(&exec, &mut store, &mut log, opp_gamma, "prospecto"); // retroceso
abrir(
&exec,
&mut store,
&mut log,
gamma,
Uuid::new_v4(),
"Trato inválido",
-500,
);
interaccion(
&exec,
&mut store,
&mut log,
gamma,
"paloma",
"canal inexistente",
);
// --- Estado final --------------------------------------------------
section("estado final · oportunidades");
print_oportunidad(&store, "Acme ", opp_acme);
print_oportunidad(&store, "Beta ", opp_beta);
print_oportunidad(&store, "Gamma", opp_gamma);
let entries = log.entries().expect("leer el log");
let seeds = entries
.iter()
.filter(|e| matches!(e, LogEntry::Seed { .. }))
.count();
let morphs = entries.len() - seeds;
section(&format!(
"log · {} eventos ({seeds} seeds, {morphs} morfismos)",
entries.len()
));
println!(" archivo: {}", log_path.display());
println!();
println!("para ver el módulo CRM en el explorador:");
println!(
" NAKUI_EVENT_LOG={} cargo run -p nakui-explorer",
log_path.display()
);
}
fn seed_cliente(
exec: &Executor,
store: &mut MemoryStore,
log: &mut EventLog,
id: Uuid,
nombre: &str,
email: &str,
) {
seed_and_log(
exec,
store,
log,
"Cliente",
id,
json!({
"id": id.to_string(),
"nombre": nombre,
"email": email,
"empresa": nombre,
}),
)
.unwrap_or_else(|e| panic!("seed cliente {nombre}: {e}"));
println!(" ok · cliente {nombre}");
}
fn abrir(
exec: &Executor,
store: &mut MemoryStore,
log: &mut EventLog,
cliente: Uuid,
opp: Uuid,
titulo: &str,
monto: i64,
) {
report(
&format!("abrir_oportunidad «{titulo}»"),
execute_and_log(
exec,
store,
log,
"abrir_oportunidad",
&[("cliente", cliente)],
json!({
"oportunidad_id": opp.to_string(),
"titulo": titulo,
"monto": monto,
"currency": "USD",
"timestamp": TS,
}),
),
);
}
fn mover(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, opp: Uuid, destino: &str) {
report(
&format!("mover_oportunidad → {destino}"),
execute_and_log(
exec,
store,
log,
"mover_oportunidad",
&[("oportunidad", opp)],
json!({ "etapa": destino, "timestamp": TS }),
),
);
}
fn interaccion(
exec: &Executor,
store: &mut MemoryStore,
log: &mut EventLog,
cliente: Uuid,
canal: &str,
nota: &str,
) {
report(
&format!("registrar_interaccion ({canal})"),
execute_and_log(
exec,
store,
log,
"registrar_interaccion",
&[("cliente", cliente)],
json!({
"interaccion_id": Uuid::new_v4().to_string(),
"canal": canal,
"nota": nota,
"timestamp": TS,
}),
),
);
}
/// Reporta el resultado de un morfismo. Genérico sobre el tipo de op
/// para no exponer el tipo interno del executor.
fn report<T>(label: &str, result: Result<Vec<T>, ExecuteError>) {
match result {
Ok(ops) => println!(" ok · {label} ({} ops)", ops.len()),
Err(ExecuteError::PreLog(e)) => println!(" rechazado · {label}: {e}"),
Err(e) => println!(" ERROR · {label}: {e:?}"),
}
}
fn print_oportunidad(store: &MemoryStore, etiqueta: &str, id: Uuid) {
match store.load("Oportunidad", id) {
Some(v) => {
let titulo = v.get("titulo").and_then(|x| x.as_str()).unwrap_or("?");
let etapa = v.get("etapa").and_then(|x| x.as_str()).unwrap_or("?");
let monto = v.get("monto").and_then(|x| x.as_i64()).unwrap_or(0);
println!(" {etiqueta} · {titulo} — ${monto} — etapa: {etapa}");
}
None => println!(" {etiqueta} · (sin oportunidad)"),
}
}
fn section(title: &str) {
println!("\n{title}");
}
+13 -12
View File
@@ -1,5 +1,5 @@
use nakui_core::event_log::{
EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log,
execute_and_log, replay, seed_and_log, verify_log, EventLog, ExecuteError,
};
use nakui_core::executor::Executor;
use nakui_core::store::{MemoryStore, Store};
@@ -7,8 +7,7 @@ use serde_json::json;
use uuid::Uuid;
fn main() {
let module_dir =
std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/treasury".into());
let module_dir = std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/treasury".into());
let exec = Executor::load_module(&module_dir).expect("load module");
let log_path = std::env::temp_dir().join(format!("nakui_demo_{}.jsonl", Uuid::new_v4()));
@@ -162,10 +161,7 @@ fn main() {
seq, entity, id, ..
} => println!(" #{:02} seed {} {}", seq, entity, id),
nakui_core::event_log::LogEntry::Morphism {
seq,
morphism,
ops,
..
seq, morphism, ops, ..
} => println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()),
}
}
@@ -180,16 +176,17 @@ fn main() {
section("== determinism verification (ops) ==");
match verify_log(&log, &exec) {
Ok(()) => println!(
" ok: every logged morphism reproduced its ops on re-execution"
),
Ok(()) => println!(" ok: every logged morphism reproduced its ops on re-execution"),
Err(e) => println!(" nondeterminism detected: {}", e),
}
if std::env::var_os("NAKUI_DEMO_KEEP").is_none() {
let _ = std::fs::remove_file(&log_path);
} else {
println!("\n(NAKUI_DEMO_KEEP set — keeping log at {})", log_path.display());
println!(
"\n(NAKUI_DEMO_KEEP set — keeping log at {})",
log_path.display()
);
}
}
@@ -202,7 +199,11 @@ fn run_and_report(
params: serde_json::Value,
) {
match execute_and_log(exec, store, log, morphism, inputs, params) {
Ok(ops) => println!(" ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1),
Ok(ops) => println!(
" ok ({} ops, logged at #{})",
ops.len(),
log.next_seq() - 1
),
Err(ExecuteError::PreLog(e)) => println!(" rejected: {}", e),
Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e),
Err(ExecuteError::PostLogStore(e)) => println!(
@@ -1,5 +1,5 @@
use nakui_core::event_log::{
EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log,
execute_and_log, replay, seed_and_log, verify_log, EventLog, ExecuteError,
};
use nakui_core::executor::Executor;
use nakui_core::store::{MemoryStore, Store};
@@ -7,12 +7,10 @@ use serde_json::json;
use uuid::Uuid;
fn main() {
let module_dir = std::env::var("NAKUI_MODULE")
.unwrap_or_else(|_| "modules/inventory".into());
let module_dir = std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/inventory".into());
let exec = Executor::load_module(&module_dir).expect("load module");
let log_path =
std::env::temp_dir().join(format!("nakui_inv_{}.jsonl", Uuid::new_v4()));
let log_path = std::env::temp_dir().join(format!("nakui_inv_{}.jsonl", Uuid::new_v4()));
let mut log = EventLog::open(&log_path).expect("open log");
let mut store = MemoryStore::new();
@@ -23,34 +21,46 @@ fn main() {
let stock_c = Uuid::new_v4();
seed_and_log(
&exec,
&mut store, &mut log, "Stock", stock_a,
&mut store,
&mut log,
"Stock",
stock_a,
json!({
"id": stock_a.to_string(),
"sku_id": "kg-cafe-honduras-2026",
"ubicacion": "almacen-norte",
"cantidad": 500_i64,
}),
).expect("seed A");
)
.expect("seed A");
seed_and_log(
&exec,
&mut store, &mut log, "Stock", stock_b,
&mut store,
&mut log,
"Stock",
stock_b,
json!({
"id": stock_b.to_string(),
"sku_id": "kg-cafe-honduras-2026",
"ubicacion": "almacen-sur",
"cantidad": 100_i64,
}),
).expect("seed B");
)
.expect("seed B");
seed_and_log(
&exec,
&mut store, &mut log, "Stock", stock_c,
&mut store,
&mut log,
"Stock",
stock_c,
json!({
"id": stock_c.to_string(),
"sku_id": "lt-aceite-girasol",
"ubicacion": "almacen-sur",
"cantidad": 200_i64,
}),
).expect("seed C");
)
.expect("seed C");
section("== seed ==");
print_stock(&store, "A (cafe norte)", stock_a);
@@ -58,7 +68,11 @@ fn main() {
print_stock(&store, "C (aceite sur)", stock_c);
section("== recibir 250 kg cafe en A ==");
run_and_report(&exec, &mut store, &mut log, "recibir_stock",
run_and_report(
&exec,
&mut store,
&mut log,
"recibir_stock",
&[("stock", stock_a)],
json!({
"cantidad": 250_i64,
@@ -69,7 +83,11 @@ fn main() {
print_stock(&store, "A", stock_a);
section("== transferir 200 kg cafe A -> B (conserva por sku_id) ==");
run_and_report(&exec, &mut store, &mut log, "transferir_stock",
run_and_report(
&exec,
&mut store,
&mut log,
"transferir_stock",
&[("source", stock_a), ("dest", stock_b)],
json!({
"cantidad": 200_i64,
@@ -81,7 +99,11 @@ fn main() {
print_stock(&store, "B", stock_b);
section("== transferir 999_999 kg cafe A -> B (reject: stock <= 0) ==");
run_and_report(&exec, &mut store, &mut log, "transferir_stock",
run_and_report(
&exec,
&mut store,
&mut log,
"transferir_stock",
&[("source", stock_a), ("dest", stock_b)],
json!({
"cantidad": 999_999_i64,
@@ -91,7 +113,11 @@ fn main() {
);
section("== transferir 50 cafe(A) -> aceite(C) (reject: rhai SKU mismatch) ==");
run_and_report(&exec, &mut store, &mut log, "transferir_stock",
run_and_report(
&exec,
&mut store,
&mut log,
"transferir_stock",
&[("source", stock_a), ("dest", stock_c)],
json!({
"cantidad": 50_i64,
@@ -113,10 +139,12 @@ fn main() {
));
for e in &entries {
match e {
nakui_core::event_log::LogEntry::Seed { seq, entity, id, .. } =>
println!(" #{:02} seed {} {}", seq, entity, id),
nakui_core::event_log::LogEntry::Morphism { seq, morphism, ops, .. } =>
println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()),
nakui_core::event_log::LogEntry::Seed {
seq, entity, id, ..
} => println!(" #{:02} seed {} {}", seq, entity, id),
nakui_core::event_log::LogEntry::Morphism {
seq, morphism, ops, ..
} => println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()),
}
}
@@ -130,9 +158,7 @@ fn main() {
section("== determinism verification (ops) ==");
match verify_log(&log, &exec) {
Ok(()) => println!(
" ok: every logged morphism reproduced its ops on re-execution"
),
Ok(()) => println!(" ok: every logged morphism reproduced its ops on re-execution"),
Err(e) => println!(" nondeterminism detected: {}", e),
}
@@ -148,11 +174,16 @@ fn run_and_report(
params: serde_json::Value,
) {
match execute_and_log(exec, store, log, morphism, inputs, params) {
Ok(ops) => println!(" ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1),
Ok(ops) => println!(
" ok ({} ops, logged at #{})",
ops.len(),
log.next_seq() - 1
),
Err(ExecuteError::PreLog(e)) => println!(" rejected: {}", e),
Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e),
Err(ExecuteError::PostLogStore(e)) => println!(
" POST-LOG STORE FAILED (log canonical, store stale): {}", e
" POST-LOG STORE FAILED (log canonical, store stale): {}",
e
),
}
}
@@ -162,7 +193,10 @@ fn print_stock(store: &MemoryStore, label: &str, id: Uuid) {
let cantidad = v.get("cantidad").and_then(|v| v.as_i64()).unwrap_or(0);
let sku = v.get("sku_id").and_then(|v| v.as_str()).unwrap_or("?");
let loc = v.get("ubicacion").and_then(|v| v.as_str()).unwrap_or("?");
println!(" {}: cantidad={} sku={} ubic={}", label, cantidad, sku, loc);
println!(
" {}: cantidad={} sku={} ubic={}",
label, cantidad, sku, loc
);
}
fn section(title: &str) {
+19 -19
View File
@@ -13,10 +13,8 @@ use std::collections::BTreeMap;
use std::path::PathBuf;
use std::process::ExitCode;
use nakui_core::drift::{DriftDiff, check_against_socket};
use nakui_core::event_log::{
EventLog, LogEntry, Snapshot, replay_with_snapshot_into, verify_log,
};
use nakui_core::drift::{check_against_socket, DriftDiff};
use nakui_core::event_log::{replay_with_snapshot_into, verify_log, EventLog, LogEntry, Snapshot};
use nakui_core::executor::Executor;
use nakui_core::run::run_server;
use nakui_core::store::MemoryStore;
@@ -111,19 +109,16 @@ fn parse_flags(args: &[String], allowed: &[&str]) -> Result<BTreeMap<String, Str
if !allowed.contains(&name) {
return Err(CliError::BadArgs(format!("unknown flag `--{}`", name)));
}
let val = args.get(i + 1).ok_or_else(|| {
CliError::BadArgs(format!("flag `--{}` requires a value", name))
})?;
let val = args
.get(i + 1)
.ok_or_else(|| CliError::BadArgs(format!("flag `--{}` requires a value", name)))?;
out.insert(name.to_string(), val.clone());
i += 2;
}
Ok(out)
}
fn require<'a>(
flags: &'a BTreeMap<String, String>,
name: &str,
) -> Result<&'a String, CliError> {
fn require<'a>(flags: &'a BTreeMap<String, String>, name: &str) -> Result<&'a String, CliError> {
flags
.get(name)
.ok_or_else(|| CliError::BadArgs(format!("missing required flag `--{}`", name)))
@@ -141,7 +136,11 @@ fn cmd_inspect(args: &[String]) -> Result<(), CliError> {
if entries.is_empty() {
return Ok(());
}
println!("seq range: {}..={}", entries[0].seq(), entries.last().unwrap().seq());
println!(
"seq range: {}..={}",
entries[0].seq(),
entries.last().unwrap().seq()
);
println!();
for e in &entries {
match e {
@@ -195,18 +194,18 @@ fn cmd_replay(args: &[String]) -> Result<(), CliError> {
let entries = log
.entries()
.map_err(|e| CliError::Op(format!("read log: {}", e)))?;
let last_seq = entries.last().map(|e| e.seq().to_string()).unwrap_or_else(|| "<empty>".into());
let last_seq = entries
.last()
.map(|e| e.seq().to_string())
.unwrap_or_else(|| "<empty>".into());
println!("replayed log: {}", log.path().display());
if let Some(snap) = &snapshot {
println!("snapshot: seq {} (covers seq <= {})", snap.seq, snap.seq);
}
println!("last seq: {}", last_seq);
println!("entities:");
let mut by_entity: Vec<(&String, usize)> = store
.records()
.iter()
.map(|(k, v)| (k, v.len()))
.collect();
let mut by_entity: Vec<(&String, usize)> =
store.records().iter().map(|(k, v)| (k, v.len())).collect();
by_entity.sort_by(|a, b| a.0.cmp(b.0));
if by_entity.is_empty() {
println!(" (none)");
@@ -414,7 +413,8 @@ fn cmd_compact(args: &[String]) -> Result<(), CliError> {
let snap = Snapshot::load(&snap_path)
.map_err(|e| CliError::Op(format!("load snapshot: {}", e)))?
.ok_or_else(|| CliError::Op(format!("snapshot not found: {}", snap_path.display())))?;
let mut log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?;
let mut log =
EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?;
let before = log
.entries()
.map(|es| es.len())
+49 -22
View File
@@ -5,7 +5,7 @@
//! against all three.
use nakui_core::event_log::{
EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log,
execute_and_log, replay, seed_and_log, verify_log, EventLog, ExecuteError,
};
use nakui_core::executor::Executor;
use nakui_core::store::{MemoryStore, Store};
@@ -13,12 +13,10 @@ use serde_json::json;
use uuid::Uuid;
fn main() {
let module_dir = std::env::var("NAKUI_MODULE")
.unwrap_or_else(|_| "modules/sales".into());
let module_dir = std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/sales".into());
let exec = Executor::load_module(&module_dir).expect("load module");
let log_path =
std::env::temp_dir().join(format!("nakui_sales_{}.jsonl", Uuid::new_v4()));
let log_path = std::env::temp_dir().join(format!("nakui_sales_{}.jsonl", Uuid::new_v4()));
let mut log = EventLog::open(&log_path).expect("open log");
let mut store = MemoryStore::new();
@@ -26,24 +24,32 @@ fn main() {
let caja_id = Uuid::new_v4();
seed_and_log(
&exec,
&mut store, &mut log, "Stock", stock_id,
&mut store,
&mut log,
"Stock",
stock_id,
json!({
"id": stock_id.to_string(),
"sku_id": "kg-cafe-honduras-2026",
"ubicacion": "almacen-norte",
"cantidad": 500_i64,
}),
).expect("seed stock");
)
.expect("seed stock");
seed_and_log(
&exec,
&mut store, &mut log, "Caja", caja_id,
&mut store,
&mut log,
"Caja",
caja_id,
json!({
"id": caja_id.to_string(),
"name": "Caja Principal",
"saldo": 1_000_000_i64, // $10_000.00 in cents
"currency": "USD",
}),
).expect("seed caja");
)
.expect("seed caja");
section("== seed ==");
print_stock(&store, "stock", stock_id);
@@ -51,7 +57,11 @@ fn main() {
// 1. Sell 100 kg cafe at $50.00 / kg = $5000.00 total.
section("== vender 100 kg @ $50.00 c/u ==");
run_and_report(&exec, &mut store, &mut log, "vender",
run_and_report(
&exec,
&mut store,
&mut log,
"vender",
&[("stock", stock_id), ("caja", caja_id)],
json!({
"cantidad": 100_i64,
@@ -65,7 +75,11 @@ fn main() {
// 2. Try selling more than available stock — should fail Stock post-check.
section("== vender 9999 kg (reject: stock <= 0) ==");
run_and_report(&exec, &mut store, &mut log, "vender",
run_and_report(
&exec,
&mut store,
&mut log,
"vender",
&[("stock", stock_id), ("caja", caja_id)],
json!({
"cantidad": 9999_i64,
@@ -77,7 +91,11 @@ fn main() {
// 3. Negative price — caught by Rhai.
section("== vender con precio negativo (reject: rhai throw) ==");
run_and_report(&exec, &mut store, &mut log, "vender",
run_and_report(
&exec,
&mut store,
&mut log,
"vender",
&[("stock", stock_id), ("caja", caja_id)],
json!({
"cantidad": 10_i64,
@@ -89,7 +107,11 @@ fn main() {
// 4. Another good sale.
section("== vender 50 kg @ $60.00 c/u ==");
run_and_report(&exec, &mut store, &mut log, "vender",
run_and_report(
&exec,
&mut store,
&mut log,
"vender",
&[("stock", stock_id), ("caja", caja_id)],
json!({
"cantidad": 50_i64,
@@ -113,10 +135,12 @@ fn main() {
));
for e in &entries {
match e {
nakui_core::event_log::LogEntry::Seed { seq, entity, id, .. } =>
println!(" #{:02} seed {} {}", seq, entity, id),
nakui_core::event_log::LogEntry::Morphism { seq, morphism, ops, .. } =>
println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()),
nakui_core::event_log::LogEntry::Seed {
seq, entity, id, ..
} => println!(" #{:02} seed {} {}", seq, entity, id),
nakui_core::event_log::LogEntry::Morphism {
seq, morphism, ops, ..
} => println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()),
}
}
@@ -130,9 +154,7 @@ fn main() {
section("== determinism verification (ops) ==");
match verify_log(&log, &exec) {
Ok(()) => println!(
" ok: every logged morphism reproduced its ops on re-execution"
),
Ok(()) => println!(" ok: every logged morphism reproduced its ops on re-execution"),
Err(e) => println!(" nondeterminism detected: {}", e),
}
@@ -148,11 +170,16 @@ fn run_and_report(
params: serde_json::Value,
) {
match execute_and_log(exec, store, log, morphism, inputs, params) {
Ok(ops) => println!(" ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1),
Ok(ops) => println!(
" ok ({} ops, logged at #{})",
ops.len(),
log.next_seq() - 1
),
Err(ExecuteError::PreLog(e)) => println!(" rejected: {}", e),
Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e),
Err(ExecuteError::PostLogStore(e)) => println!(
" POST-LOG STORE FAILED (log canonical, store stale): {}", e
" POST-LOG STORE FAILED (log canonical, store stale): {}",
e
),
}
}
+1 -4
View File
@@ -150,10 +150,7 @@ pub fn simulate_on(state: &Value, entity: &str, id: Uuid, ops: &[FieldOp]) -> Op
} if e == entity && *i == id => {
s = Some(data.clone());
}
FieldOp::Delete {
entity: e,
id: i,
} if e == entity && *i == id => {
FieldOp::Delete { entity: e, id: i } if e == entity && *i == id => {
s = None;
}
_ => {}
+17 -30
View File
@@ -19,7 +19,7 @@ use std::path::Path;
use thiserror::Error;
use uuid::Uuid;
use crate::event_log::{EventLog, replay};
use crate::event_log::{replay, EventLog};
use crate::store::Store;
/// A single record-level difference between two snapshots. Variants are
@@ -333,13 +333,13 @@ fn parse_records(resp: &Value) -> Result<Vec<(String, Uuid, Value)>, DriftError>
field: "records[].entity".into(),
})?
.to_string();
let id_str = item
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| DriftError::MissingField {
op: "dump_records".into(),
field: "records[].id".into(),
})?;
let id_str =
item.get("id")
.and_then(Value::as_str)
.ok_or_else(|| DriftError::MissingField {
op: "dump_records".into(),
field: "records[].id".into(),
})?;
let id = Uuid::parse_str(id_str).map_err(|_| DriftError::MissingField {
op: "dump_records".into(),
field: format!("records[].id (not uuid: {})", id_str),
@@ -377,16 +377,8 @@ mod tests {
// The function compares records, not hashes — hash equality is
// the operator's fast-path, but the report's truth is the diffs.
let a = Uuid::new_v4();
let log = vec![(
"Caja".to_string(),
a,
json!({"saldo": 100}),
)];
let server = vec![(
"Caja".to_string(),
a,
json!({"saldo": 100}),
)];
let log = vec![("Caja".to_string(), a, json!({"saldo": 100}))];
let server = vec![("Caja".to_string(), a, json!({"saldo": 100}))];
let report = compare_states(log, h(1), server, h(2));
assert!(report.diffs.is_empty(), "records equal → no diffs");
}
@@ -395,11 +387,7 @@ mod tests {
fn detects_only_on_server() {
let a = Uuid::new_v4();
let b = Uuid::new_v4();
let log = vec![(
"Caja".to_string(),
a,
json!({"saldo": 100}),
)];
let log = vec![("Caja".to_string(), a, json!({"saldo": 100}))];
let server = vec![
("Caja".to_string(), a, json!({"saldo": 100})),
("Caja".to_string(), b, json!({"saldo": 999})),
@@ -457,17 +445,16 @@ mod tests {
let id_caja = Uuid::nil(); // sorts first byte-wise
let id_mov = Uuid::from_u128(u128::MAX);
let log = vec![
("Movimiento".to_string(), id_mov, json!({"x": 1})),
];
let server = vec![
("Caja".to_string(), id_caja, json!({"saldo": 0})),
];
let log = vec![("Movimiento".to_string(), id_mov, json!({"x": 1}))];
let server = vec![("Caja".to_string(), id_caja, json!({"saldo": 0}))];
let report = compare_states(log, h(0), server, h(1));
assert_eq!(report.diffs.len(), 2);
// Caja sorts before Movimiento.
match (&report.diffs[0], &report.diffs[1]) {
(DriftDiff::OnlyOnServer { entity: e1, .. }, DriftDiff::OnlyInLog { entity: e2, .. }) => {
(
DriftDiff::OnlyOnServer { entity: e1, .. },
DriftDiff::OnlyInLog { entity: e2, .. },
) => {
assert_eq!(e1, "Caja");
assert_eq!(e2, "Movimiento");
}
+5 -16
View File
@@ -363,10 +363,7 @@ impl Snapshot {
/// Verify the snapshot was produced under a bundle compatible with
/// `executor`. Snapshots without a hash (legacy / `from_memory_store`)
/// pass — the operator opted out of this check at capture time.
pub fn ensure_compatible_with(
&self,
executor: &Executor,
) -> Result<(), SnapshotMismatchError> {
pub fn ensure_compatible_with(&self, executor: &Executor) -> Result<(), SnapshotMismatchError> {
let Some(snap_hash) = self.schema_hash else {
return Ok(());
};
@@ -405,10 +402,8 @@ impl Snapshot {
return Ok(None);
}
let text = std::fs::read_to_string(path).map_err(LogError::Io)?;
let snap: Snapshot = serde_json::from_str(&text).map_err(|e| LogError::Parse {
line: 0,
source: e,
})?;
let snap: Snapshot =
serde_json::from_str(&text).map_err(|e| LogError::Parse { line: 0, source: e })?;
Ok(Some(snap))
}
}
@@ -485,10 +480,7 @@ pub fn execute_and_log<S: Store>(
let entry = LogEntry::Morphism {
seq,
morphism: morphism.to_string(),
inputs: inputs
.iter()
.map(|(r, id)| (r.to_string(), *id))
.collect(),
inputs: inputs.iter().map(|(r, id)| (r.to_string(), *id)).collect(),
params,
ops: ops.clone(),
schema_hash: executor.schema_hash(morphism),
@@ -534,10 +526,7 @@ pub fn execute_and_log_with_recovery<S: Store>(
let entry = LogEntry::Morphism {
seq,
morphism: morphism.to_string(),
inputs: inputs
.iter()
.map(|(r, id)| (r.to_string(), *id))
.collect(),
inputs: inputs.iter().map(|(r, id)| (r.to_string(), *id)).collect(),
params,
ops: ops.clone(),
schema_hash: executor.schema_hash(morphism),
+28 -34
View File
@@ -1,14 +1,14 @@
use serde_json::{Value, json};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use thiserror::Error;
use uuid::Uuid;
use crate::delta::{FieldOp, simulate_on};
use crate::delta::{simulate_on, FieldOp};
use crate::graph::{GraphError, ManifestGraph};
use crate::nickel_validator::{self, NickelError};
use crate::manifest::{ConserveRule, Manifest, ManifestError, MorphismSpec, ValidationError};
use crate::nickel_validator::{self, NickelError};
use crate::rhai_executor::{RhaiError, RhaiExecutor};
use crate::store::{Store, StoreError};
@@ -141,7 +141,8 @@ impl Executor {
// archivo apuntado se mueve). Sin esto, el bundle hash quedaba
// pegado y la versión del seed nunca detectaba ediciones de
// schema. Ver `verify_log_rejects_seed_after_schema_changes`.
let schema_bundle_bytes = read_schema_files_concat(&module_dir, &manifest.effective_schemas())?;
let schema_bundle_bytes =
read_schema_files_concat(&module_dir, &manifest.effective_schemas())?;
let schema_bundle_hash = compute_schema_bundle_hash(&schema_bundle_bytes);
let mut schema_hashes = HashMap::with_capacity(manifest.morphisms.len());
for spec in &manifest.morphisms {
@@ -291,20 +292,14 @@ impl Executor {
Some(_) => {
return Err(ExecError::CapabilityViolation {
morphism: morphism_name.to_string(),
token: format!(
"<entity-mismatch>.{}.{}",
path.entity, path.field
),
token: format!("<entity-mismatch>.{}.{}", path.entity, path.field),
declared: spec.writes.clone(),
});
}
None => {
return Err(ExecError::CapabilityViolation {
morphism: morphism_name.to_string(),
token: format!(
"<untracked id>.{}.{}",
path.entity, path.field
),
token: format!("<untracked id>.{}.{}", path.entity, path.field),
declared: spec.writes.clone(),
});
}
@@ -330,8 +325,7 @@ impl Executor {
// 7. Per-input KCL post-check; skip Deleted inputs.
for spec_in in &spec.inputs {
let id = inputs_map[&spec_in.role];
if let Some(new_state) =
simulate_on(&loaded[&spec_in.role], &spec_in.entity, id, &ops)
if let Some(new_state) = simulate_on(&loaded[&spec_in.role], &spec_in.entity, id, &ops)
{
self.validate_entity(&spec_in.entity, &new_state)
.map_err(|e| ExecError::SchemaPost {
@@ -598,23 +592,21 @@ fn check_conservation(
),
})?;
let old_state = &loaded[&binding.role];
let old_val =
old_state
.get(&rule.field)
.and_then(Value::as_i64)
.ok_or_else(|| ExecError::ConservationMalformed {
entity: rule.entity.clone(),
field: rule.field.clone(),
message: format!("old value at role `{}` is not i64", binding.role),
})?;
let new_val =
value
.as_i64()
.ok_or_else(|| ExecError::ConservationMalformed {
entity: rule.entity.clone(),
field: rule.field.clone(),
message: format!("Set value at role `{}` is not i64", binding.role),
})?;
let old_val = old_state
.get(&rule.field)
.and_then(Value::as_i64)
.ok_or_else(|| ExecError::ConservationMalformed {
entity: rule.entity.clone(),
field: rule.field.clone(),
message: format!("old value at role `{}` is not i64", binding.role),
})?;
let new_val = value
.as_i64()
.ok_or_else(|| ExecError::ConservationMalformed {
entity: rule.entity.clone(),
field: rule.field.clone(),
message: format!("Set value at role `{}` is not i64", binding.role),
})?;
let group_key = match &rule.group_by {
Some(g) => old_state
.get(g)
@@ -623,8 +615,7 @@ fn check_conservation(
.to_string(),
None => String::new(),
};
*delta_by_group.entry(group_key).or_insert(0) +=
(new_val as i128) - (old_val as i128);
*delta_by_group.entry(group_key).or_insert(0) += (new_val as i128) - (old_val as i128);
}
}
@@ -673,7 +664,10 @@ let y = 2;
// content, not cosmetic.
let src = r#"let s = "hello // not a comment \"world\"";"#;
let normalized = normalize_rhai_source(src);
assert_eq!(normalized, r#"let s = "hello // not a comment \"world\"";"#);
assert_eq!(
normalized,
r#"let s = "hello // not a comment \"world\"";"#
);
}
#[test]
+8 -2
View File
@@ -189,7 +189,10 @@ fn build_data_flow(
for r in &m.reads {
if let Some(token) = canonicalize_token(r, &role_to_entity) {
if seen_reads.insert(token.clone()) {
readers.entry(token.clone()).or_default().push(m.name.clone());
readers
.entry(token.clone())
.or_default()
.push(m.name.clone());
m_reads.entry(m.name.clone()).or_default().push(token);
}
}
@@ -198,7 +201,10 @@ fn build_data_flow(
for w in &m.writes {
if let Some(token) = canonicalize_token(w, &role_to_entity) {
if seen_writes.insert(token.clone()) {
writers.entry(token.clone()).or_default().push(m.name.clone());
writers
.entry(token.clone())
.or_default()
.push(m.name.clone());
m_writes.entry(m.name.clone()).or_default().push(token);
}
}
+4 -7
View File
@@ -150,11 +150,9 @@ impl Manifest {
resolved: resolved.display().to_string(),
});
}
let content = std::fs::read_to_string(&resolved).map_err(|e| {
ValidationError::Io {
path: s.clone(),
source: e,
}
let content = std::fs::read_to_string(&resolved).map_err(|e| ValidationError::Io {
path: s.clone(),
source: e,
})?;
for name in extract_schema_names(&content) {
entity_to_files.entry(name).or_default().push(s.clone());
@@ -168,8 +166,7 @@ impl Manifest {
});
}
}
let known_entities: HashSet<&str> =
entity_to_files.keys().map(String::as_str).collect();
let known_entities: HashSet<&str> = entity_to_files.keys().map(String::as_str).collect();
// 2. Manifest-level: morphism names must be unique.
let mut seen: HashSet<&str> = HashSet::new();
@@ -60,8 +60,8 @@ pub fn vet(schema_path: &Path, state: &Value, schema_name: &str) -> Result<(), N
(std.deserialize 'Json m%%\"{state_json}\"%%) | bundle.{schema_name}"
);
let mut ctx = nickel_lang::Context::new()
.with_source_name(format!("nakui-validate-{schema_name}"));
let mut ctx =
nickel_lang::Context::new().with_source_name(format!("nakui-validate-{schema_name}"));
match ctx.eval_deep_for_export(&source) {
Ok(_) => Ok(()),
@@ -130,7 +130,9 @@ mod tests {
let state = json!({"id": "abc"}); // falta cantidad
let err = vet(&schema, &state, "Stock").unwrap_err();
assert!(matches!(err, NickelError::ValidationFailed(_)));
let NickelError::ValidationFailed(msg) = err else { panic!() };
let NickelError::ValidationFailed(msg) = err else {
panic!()
};
assert!(
msg.to_lowercase().contains("cantidad") || msg.to_lowercase().contains("missing"),
"msg debe mencionar el field missing: {msg}"
@@ -2,7 +2,7 @@ use rhai::packages::{
ArithmeticPackage, BasicArrayPackage, BasicIteratorPackage, BasicMapPackage,
BasicStringPackage, CorePackage, LogicPackage, Package,
};
use rhai::{AST, Dynamic, Engine, Scope};
use rhai::{Dynamic, Engine, Scope, AST};
use serde_json::Value;
use std::cell::RefCell;
use std::collections::HashMap;
@@ -76,8 +76,8 @@ impl RhaiExecutor {
for item in arr {
let json: Value = rhai::serde::from_dynamic(&item)
.map_err(|e| RhaiError::BadOp(format!("dynamic -> json: {}", e)))?;
let op: FieldOp = serde_json::from_value(json)
.map_err(|e| RhaiError::BadOp(e.to_string()))?;
let op: FieldOp =
serde_json::from_value(json).map_err(|e| RhaiError::BadOp(e.to_string()))?;
ops.push(op);
}
Ok(ops)
+10 -16
View File
@@ -24,13 +24,13 @@ use std::os::unix::net::{UnixListener, UnixStream};
use std::path::Path;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
use thiserror::Error;
use uuid::Uuid;
use crate::event_log::{
EventLog, RecoverableExecuteError, ReplayError, Snapshot, SnapshotMismatchError,
execute_and_log_with_recovery, replay_with_snapshot_into, verify_log,
execute_and_log_with_recovery, replay_with_snapshot_into, verify_log, EventLog,
RecoverableExecuteError, ReplayError, Snapshot, SnapshotMismatchError,
};
use crate::executor::Executor;
use crate::store::Store;
@@ -206,7 +206,10 @@ fn handle_connection<S: Store>(
}
let (response, shutdown) = dispatch(&line, executor, store, log);
let bytes = serde_json::to_vec(&response).expect("response serializes");
if let Err(e) = writer.write_all(&bytes).and_then(|_| writer.write_all(b"\n")) {
if let Err(e) = writer
.write_all(&bytes)
.and_then(|_| writer.write_all(b"\n"))
{
eprintln!("nakui run: write: {}", e);
return false;
}
@@ -291,16 +294,10 @@ fn dispatch<S: Store>(
}
Request::Verify => match verify_log(log, executor) {
Ok(()) => {
let entries = log
.entries()
.map(|es| es.len())
.unwrap_or(0);
let entries = log.entries().map(|es| es.len()).unwrap_or(0);
(json!({"ok": true, "entries": entries}), false)
}
Err(e) => (
json!({"ok": false, "error": e.to_string()}),
false,
),
Err(e) => (json!({"ok": false, "error": e.to_string()}), false),
},
Request::HashState => {
let records: Vec<_> = match store.iter() {
@@ -324,9 +321,7 @@ fn dispatch<S: Store>(
Request::DumpRecords => match store.iter() {
Ok(it) => {
let records: Vec<Value> = it
.map(|(entity, id, value)| {
json!({"entity": entity, "id": id, "value": value})
})
.map(|(entity, id, value)| json!({"entity": entity, "id": id, "value": value}))
.collect();
(json!({"ok": true, "records": records}), false)
}
@@ -349,4 +344,3 @@ fn hex_encode(bytes: &[u8]) -> String {
}
out
}
+15 -25
View File
@@ -128,10 +128,7 @@ pub fn hash_value(hasher: &mut Sha256, v: &Value) {
// else (fractions, NaN, infinities) hashes as the raw
// f64 bit pattern — that's still deterministic, just
// not normalized.
if f.is_finite()
&& f.fract() == 0.0
&& f >= I128_MIN_AS_F64
&& f <= I128_MAX_AS_F64
if f.is_finite() && f.fract() == 0.0 && f >= I128_MIN_AS_F64 && f <= I128_MAX_AS_F64
{
hash_int(hasher, f as i128);
} else {
@@ -247,22 +244,12 @@ impl Store for MemoryStore {
}
}
FieldOp::Create { entity, id, .. } => {
if self
.records
.get(entity)
.and_then(|m| m.get(id))
.is_some()
{
if self.records.get(entity).and_then(|m| m.get(id)).is_some() {
return Err(StoreError::Conflict(entity.clone(), *id));
}
}
FieldOp::Delete { entity, id } => {
if self
.records
.get(entity)
.and_then(|m| m.get(id))
.is_none()
{
if self.records.get(entity).and_then(|m| m.get(id)).is_none() {
return Err(StoreError::NotFound(entity.clone(), *id));
}
}
@@ -343,7 +330,10 @@ impl Store for MemoryStore {
.map(move |(id, v)| (entity.clone(), *id, v.clone()))
})
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.as_bytes().cmp(b.1.as_bytes())));
out.sort_by(|a, b| {
a.0.cmp(&b.0)
.then_with(|| a.1.as_bytes().cmp(b.1.as_bytes()))
});
Ok(Box::new(out.into_iter()))
}
}
@@ -433,7 +423,11 @@ mod tests {
let after = store.load("Customer", id).unwrap();
let map = after.as_object().unwrap();
assert!(!map.contains_key("notes"), "notes debería estar borrado");
assert_eq!(map.get("name"), Some(&json!("Acme")), "otros fields intactos");
assert_eq!(
map.get("name"),
Some(&json!("Acme")),
"otros fields intactos"
);
}
#[test]
@@ -455,10 +449,7 @@ mod tests {
// No debería errar: clear de un field ausente es benigno.
store.apply(&[op]).unwrap();
let after = store.load("Customer", id).unwrap();
assert_eq!(
after.as_object().unwrap().get("name"),
Some(&json!("Acme"))
);
assert_eq!(after.as_object().unwrap().get("name"), Some(&json!("Acme")));
}
#[test]
@@ -680,9 +671,8 @@ mod tests {
// The empty hash is the SHA-256 of an empty input — fix the
// expected bytes so an accidental framing change in `hash_state`
// can't silently sail through.
let expected = hex_decode(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
);
let expected =
hex_decode("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
assert_eq!(s1.hash_state().unwrap().to_vec(), expected);
}
+9 -12
View File
@@ -18,10 +18,10 @@
//! a stable shape.
use serde_json::Value;
use surrealdb::Surreal;
use surrealdb::engine::local::{Db, Mem};
#[cfg(feature = "persistent")]
use surrealdb::engine::local::SurrealKv;
use surrealdb::engine::local::{Db, Mem};
use surrealdb::Surreal;
use thiserror::Error;
use tokio::runtime::Runtime;
use uuid::Uuid;
@@ -84,9 +84,7 @@ impl SurrealStore {
/// canonical use is `let store = SurrealStore::new_persistent(path)?`
/// at process startup, with the path stable across runs.
#[cfg(feature = "persistent")]
pub fn new_persistent(
path: impl AsRef<std::path::Path>,
) -> Result<Self, SurrealStoreError> {
pub fn new_persistent(path: impl AsRef<std::path::Path>) -> Result<Self, SurrealStoreError> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
@@ -175,10 +173,7 @@ impl Store for SurrealStore {
// sobre un field ausente no falla).
let exists = self.exists(&path.entity, path.id).await?;
if !exists {
return Err(StoreError::NotFound(
path.entity.clone(),
path.id,
));
return Err(StoreError::NotFound(path.entity.clone(), path.id));
}
// We don't model NotAnObject for SurrealStore: every
// record stored via this trait is map-shaped by
@@ -278,9 +273,11 @@ impl Store for SurrealStore {
out.push((table.clone(), id, Value::Object(map)));
}
}
out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.as_bytes().cmp(b.1.as_bytes())));
Ok(Box::new(out.into_iter())
as Box<dyn Iterator<Item = (String, Uuid, Value)>>)
out.sort_by(|a, b| {
a.0.cmp(&b.0)
.then_with(|| a.1.as_bytes().cmp(b.1.as_bytes()))
});
Ok(Box::new(out.into_iter()) as Box<dyn Iterator<Item = (String, Uuid, Value)>>)
})
}