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:
@@ -56,3 +56,7 @@ path = "src/bin/inventory_demo.rs"
|
|||||||
[[bin]]
|
[[bin]]
|
||||||
name = "sales_demo"
|
name = "sales_demo"
|
||||||
path = "src/bin/sales_demo.rs"
|
path = "src/bin/sales_demo.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "crm_demo"
|
||||||
|
path = "src/bin/crm_demo.rs"
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use nakui_core::event_log::{
|
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::executor::Executor;
|
||||||
use nakui_core::store::{MemoryStore, Store};
|
use nakui_core::store::{MemoryStore, Store};
|
||||||
@@ -7,8 +7,7 @@ use serde_json::json;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let module_dir =
|
let module_dir = std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/treasury".into());
|
||||||
std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/treasury".into());
|
|
||||||
let exec = Executor::load_module(&module_dir).expect("load module");
|
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()));
|
let log_path = std::env::temp_dir().join(format!("nakui_demo_{}.jsonl", Uuid::new_v4()));
|
||||||
@@ -162,10 +161,7 @@ fn main() {
|
|||||||
seq, entity, id, ..
|
seq, entity, id, ..
|
||||||
} => println!(" #{:02} seed {} {}", seq, entity, id),
|
} => println!(" #{:02} seed {} {}", seq, entity, id),
|
||||||
nakui_core::event_log::LogEntry::Morphism {
|
nakui_core::event_log::LogEntry::Morphism {
|
||||||
seq,
|
seq, morphism, ops, ..
|
||||||
morphism,
|
|
||||||
ops,
|
|
||||||
..
|
|
||||||
} => println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()),
|
} => println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,16 +176,17 @@ fn main() {
|
|||||||
|
|
||||||
section("== determinism verification (ops) ==");
|
section("== determinism verification (ops) ==");
|
||||||
match verify_log(&log, &exec) {
|
match verify_log(&log, &exec) {
|
||||||
Ok(()) => println!(
|
Ok(()) => println!(" ok: every logged morphism reproduced its ops on re-execution"),
|
||||||
" ok: every logged morphism reproduced its ops on re-execution"
|
|
||||||
),
|
|
||||||
Err(e) => println!(" nondeterminism detected: {}", e),
|
Err(e) => println!(" nondeterminism detected: {}", e),
|
||||||
}
|
}
|
||||||
|
|
||||||
if std::env::var_os("NAKUI_DEMO_KEEP").is_none() {
|
if std::env::var_os("NAKUI_DEMO_KEEP").is_none() {
|
||||||
let _ = std::fs::remove_file(&log_path);
|
let _ = std::fs::remove_file(&log_path);
|
||||||
} else {
|
} 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,
|
params: serde_json::Value,
|
||||||
) {
|
) {
|
||||||
match execute_and_log(exec, store, log, morphism, inputs, params) {
|
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::PreLog(e)) => println!(" rejected: {}", e),
|
||||||
Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e),
|
Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e),
|
||||||
Err(ExecuteError::PostLogStore(e)) => println!(
|
Err(ExecuteError::PostLogStore(e)) => println!(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use nakui_core::event_log::{
|
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::executor::Executor;
|
||||||
use nakui_core::store::{MemoryStore, Store};
|
use nakui_core::store::{MemoryStore, Store};
|
||||||
@@ -7,12 +7,10 @@ use serde_json::json;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let module_dir = std::env::var("NAKUI_MODULE")
|
let module_dir = std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/inventory".into());
|
||||||
.unwrap_or_else(|_| "modules/inventory".into());
|
|
||||||
let exec = Executor::load_module(&module_dir).expect("load module");
|
let exec = Executor::load_module(&module_dir).expect("load module");
|
||||||
|
|
||||||
let log_path =
|
let log_path = std::env::temp_dir().join(format!("nakui_inv_{}.jsonl", Uuid::new_v4()));
|
||||||
std::env::temp_dir().join(format!("nakui_inv_{}.jsonl", Uuid::new_v4()));
|
|
||||||
let mut log = EventLog::open(&log_path).expect("open log");
|
let mut log = EventLog::open(&log_path).expect("open log");
|
||||||
let mut store = MemoryStore::new();
|
let mut store = MemoryStore::new();
|
||||||
|
|
||||||
@@ -23,34 +21,46 @@ fn main() {
|
|||||||
let stock_c = Uuid::new_v4();
|
let stock_c = Uuid::new_v4();
|
||||||
seed_and_log(
|
seed_and_log(
|
||||||
&exec,
|
&exec,
|
||||||
&mut store, &mut log, "Stock", stock_a,
|
&mut store,
|
||||||
|
&mut log,
|
||||||
|
"Stock",
|
||||||
|
stock_a,
|
||||||
json!({
|
json!({
|
||||||
"id": stock_a.to_string(),
|
"id": stock_a.to_string(),
|
||||||
"sku_id": "kg-cafe-honduras-2026",
|
"sku_id": "kg-cafe-honduras-2026",
|
||||||
"ubicacion": "almacen-norte",
|
"ubicacion": "almacen-norte",
|
||||||
"cantidad": 500_i64,
|
"cantidad": 500_i64,
|
||||||
}),
|
}),
|
||||||
).expect("seed A");
|
)
|
||||||
|
.expect("seed A");
|
||||||
seed_and_log(
|
seed_and_log(
|
||||||
&exec,
|
&exec,
|
||||||
&mut store, &mut log, "Stock", stock_b,
|
&mut store,
|
||||||
|
&mut log,
|
||||||
|
"Stock",
|
||||||
|
stock_b,
|
||||||
json!({
|
json!({
|
||||||
"id": stock_b.to_string(),
|
"id": stock_b.to_string(),
|
||||||
"sku_id": "kg-cafe-honduras-2026",
|
"sku_id": "kg-cafe-honduras-2026",
|
||||||
"ubicacion": "almacen-sur",
|
"ubicacion": "almacen-sur",
|
||||||
"cantidad": 100_i64,
|
"cantidad": 100_i64,
|
||||||
}),
|
}),
|
||||||
).expect("seed B");
|
)
|
||||||
|
.expect("seed B");
|
||||||
seed_and_log(
|
seed_and_log(
|
||||||
&exec,
|
&exec,
|
||||||
&mut store, &mut log, "Stock", stock_c,
|
&mut store,
|
||||||
|
&mut log,
|
||||||
|
"Stock",
|
||||||
|
stock_c,
|
||||||
json!({
|
json!({
|
||||||
"id": stock_c.to_string(),
|
"id": stock_c.to_string(),
|
||||||
"sku_id": "lt-aceite-girasol",
|
"sku_id": "lt-aceite-girasol",
|
||||||
"ubicacion": "almacen-sur",
|
"ubicacion": "almacen-sur",
|
||||||
"cantidad": 200_i64,
|
"cantidad": 200_i64,
|
||||||
}),
|
}),
|
||||||
).expect("seed C");
|
)
|
||||||
|
.expect("seed C");
|
||||||
|
|
||||||
section("== seed ==");
|
section("== seed ==");
|
||||||
print_stock(&store, "A (cafe norte)", stock_a);
|
print_stock(&store, "A (cafe norte)", stock_a);
|
||||||
@@ -58,7 +68,11 @@ fn main() {
|
|||||||
print_stock(&store, "C (aceite sur)", stock_c);
|
print_stock(&store, "C (aceite sur)", stock_c);
|
||||||
|
|
||||||
section("== recibir 250 kg cafe en A ==");
|
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)],
|
&[("stock", stock_a)],
|
||||||
json!({
|
json!({
|
||||||
"cantidad": 250_i64,
|
"cantidad": 250_i64,
|
||||||
@@ -69,7 +83,11 @@ fn main() {
|
|||||||
print_stock(&store, "A", stock_a);
|
print_stock(&store, "A", stock_a);
|
||||||
|
|
||||||
section("== transferir 200 kg cafe A -> B (conserva por sku_id) ==");
|
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)],
|
&[("source", stock_a), ("dest", stock_b)],
|
||||||
json!({
|
json!({
|
||||||
"cantidad": 200_i64,
|
"cantidad": 200_i64,
|
||||||
@@ -81,7 +99,11 @@ fn main() {
|
|||||||
print_stock(&store, "B", stock_b);
|
print_stock(&store, "B", stock_b);
|
||||||
|
|
||||||
section("== transferir 999_999 kg cafe A -> B (reject: stock <= 0) ==");
|
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)],
|
&[("source", stock_a), ("dest", stock_b)],
|
||||||
json!({
|
json!({
|
||||||
"cantidad": 999_999_i64,
|
"cantidad": 999_999_i64,
|
||||||
@@ -91,7 +113,11 @@ fn main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
section("== transferir 50 cafe(A) -> aceite(C) (reject: rhai SKU mismatch) ==");
|
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)],
|
&[("source", stock_a), ("dest", stock_c)],
|
||||||
json!({
|
json!({
|
||||||
"cantidad": 50_i64,
|
"cantidad": 50_i64,
|
||||||
@@ -113,10 +139,12 @@ fn main() {
|
|||||||
));
|
));
|
||||||
for e in &entries {
|
for e in &entries {
|
||||||
match e {
|
match e {
|
||||||
nakui_core::event_log::LogEntry::Seed { seq, entity, id, .. } =>
|
nakui_core::event_log::LogEntry::Seed {
|
||||||
println!(" #{:02} seed {} {}", seq, entity, id),
|
seq, entity, id, ..
|
||||||
nakui_core::event_log::LogEntry::Morphism { seq, morphism, ops, .. } =>
|
} => println!(" #{:02} seed {} {}", seq, entity, id),
|
||||||
println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()),
|
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) ==");
|
section("== determinism verification (ops) ==");
|
||||||
match verify_log(&log, &exec) {
|
match verify_log(&log, &exec) {
|
||||||
Ok(()) => println!(
|
Ok(()) => println!(" ok: every logged morphism reproduced its ops on re-execution"),
|
||||||
" ok: every logged morphism reproduced its ops on re-execution"
|
|
||||||
),
|
|
||||||
Err(e) => println!(" nondeterminism detected: {}", e),
|
Err(e) => println!(" nondeterminism detected: {}", e),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,11 +174,16 @@ fn run_and_report(
|
|||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
) {
|
) {
|
||||||
match execute_and_log(exec, store, log, morphism, inputs, params) {
|
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::PreLog(e)) => println!(" rejected: {}", e),
|
||||||
Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e),
|
Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e),
|
||||||
Err(ExecuteError::PostLogStore(e)) => println!(
|
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 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 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("?");
|
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) {
|
fn section(title: &str) {
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ use std::collections::BTreeMap;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use nakui_core::drift::{DriftDiff, check_against_socket};
|
use nakui_core::drift::{check_against_socket, DriftDiff};
|
||||||
use nakui_core::event_log::{
|
use nakui_core::event_log::{replay_with_snapshot_into, verify_log, EventLog, LogEntry, Snapshot};
|
||||||
EventLog, LogEntry, Snapshot, replay_with_snapshot_into, verify_log,
|
|
||||||
};
|
|
||||||
use nakui_core::executor::Executor;
|
use nakui_core::executor::Executor;
|
||||||
use nakui_core::run::run_server;
|
use nakui_core::run::run_server;
|
||||||
use nakui_core::store::MemoryStore;
|
use nakui_core::store::MemoryStore;
|
||||||
@@ -111,19 +109,16 @@ fn parse_flags(args: &[String], allowed: &[&str]) -> Result<BTreeMap<String, Str
|
|||||||
if !allowed.contains(&name) {
|
if !allowed.contains(&name) {
|
||||||
return Err(CliError::BadArgs(format!("unknown flag `--{}`", name)));
|
return Err(CliError::BadArgs(format!("unknown flag `--{}`", name)));
|
||||||
}
|
}
|
||||||
let val = args.get(i + 1).ok_or_else(|| {
|
let val = args
|
||||||
CliError::BadArgs(format!("flag `--{}` requires a value", name))
|
.get(i + 1)
|
||||||
})?;
|
.ok_or_else(|| CliError::BadArgs(format!("flag `--{}` requires a value", name)))?;
|
||||||
out.insert(name.to_string(), val.clone());
|
out.insert(name.to_string(), val.clone());
|
||||||
i += 2;
|
i += 2;
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require<'a>(
|
fn require<'a>(flags: &'a BTreeMap<String, String>, name: &str) -> Result<&'a String, CliError> {
|
||||||
flags: &'a BTreeMap<String, String>,
|
|
||||||
name: &str,
|
|
||||||
) -> Result<&'a String, CliError> {
|
|
||||||
flags
|
flags
|
||||||
.get(name)
|
.get(name)
|
||||||
.ok_or_else(|| CliError::BadArgs(format!("missing required flag `--{}`", 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() {
|
if entries.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
println!("seq range: {}..={}", entries[0].seq(), entries.last().unwrap().seq());
|
println!(
|
||||||
|
"seq range: {}..={}",
|
||||||
|
entries[0].seq(),
|
||||||
|
entries.last().unwrap().seq()
|
||||||
|
);
|
||||||
println!();
|
println!();
|
||||||
for e in &entries {
|
for e in &entries {
|
||||||
match e {
|
match e {
|
||||||
@@ -195,18 +194,18 @@ fn cmd_replay(args: &[String]) -> Result<(), CliError> {
|
|||||||
let entries = log
|
let entries = log
|
||||||
.entries()
|
.entries()
|
||||||
.map_err(|e| CliError::Op(format!("read log: {}", e)))?;
|
.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());
|
println!("replayed log: {}", log.path().display());
|
||||||
if let Some(snap) = &snapshot {
|
if let Some(snap) = &snapshot {
|
||||||
println!("snapshot: seq {} (covers seq <= {})", snap.seq, snap.seq);
|
println!("snapshot: seq {} (covers seq <= {})", snap.seq, snap.seq);
|
||||||
}
|
}
|
||||||
println!("last seq: {}", last_seq);
|
println!("last seq: {}", last_seq);
|
||||||
println!("entities:");
|
println!("entities:");
|
||||||
let mut by_entity: Vec<(&String, usize)> = store
|
let mut by_entity: Vec<(&String, usize)> =
|
||||||
.records()
|
store.records().iter().map(|(k, v)| (k, v.len())).collect();
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k, v.len()))
|
|
||||||
.collect();
|
|
||||||
by_entity.sort_by(|a, b| a.0.cmp(b.0));
|
by_entity.sort_by(|a, b| a.0.cmp(b.0));
|
||||||
if by_entity.is_empty() {
|
if by_entity.is_empty() {
|
||||||
println!(" (none)");
|
println!(" (none)");
|
||||||
@@ -414,7 +413,8 @@ fn cmd_compact(args: &[String]) -> Result<(), CliError> {
|
|||||||
let snap = Snapshot::load(&snap_path)
|
let snap = Snapshot::load(&snap_path)
|
||||||
.map_err(|e| CliError::Op(format!("load snapshot: {}", e)))?
|
.map_err(|e| CliError::Op(format!("load snapshot: {}", e)))?
|
||||||
.ok_or_else(|| CliError::Op(format!("snapshot not found: {}", snap_path.display())))?;
|
.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
|
let before = log
|
||||||
.entries()
|
.entries()
|
||||||
.map(|es| es.len())
|
.map(|es| es.len())
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! against all three.
|
//! against all three.
|
||||||
|
|
||||||
use nakui_core::event_log::{
|
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::executor::Executor;
|
||||||
use nakui_core::store::{MemoryStore, Store};
|
use nakui_core::store::{MemoryStore, Store};
|
||||||
@@ -13,12 +13,10 @@ use serde_json::json;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let module_dir = std::env::var("NAKUI_MODULE")
|
let module_dir = std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/sales".into());
|
||||||
.unwrap_or_else(|_| "modules/sales".into());
|
|
||||||
let exec = Executor::load_module(&module_dir).expect("load module");
|
let exec = Executor::load_module(&module_dir).expect("load module");
|
||||||
|
|
||||||
let log_path =
|
let log_path = std::env::temp_dir().join(format!("nakui_sales_{}.jsonl", Uuid::new_v4()));
|
||||||
std::env::temp_dir().join(format!("nakui_sales_{}.jsonl", Uuid::new_v4()));
|
|
||||||
let mut log = EventLog::open(&log_path).expect("open log");
|
let mut log = EventLog::open(&log_path).expect("open log");
|
||||||
let mut store = MemoryStore::new();
|
let mut store = MemoryStore::new();
|
||||||
|
|
||||||
@@ -26,24 +24,32 @@ fn main() {
|
|||||||
let caja_id = Uuid::new_v4();
|
let caja_id = Uuid::new_v4();
|
||||||
seed_and_log(
|
seed_and_log(
|
||||||
&exec,
|
&exec,
|
||||||
&mut store, &mut log, "Stock", stock_id,
|
&mut store,
|
||||||
|
&mut log,
|
||||||
|
"Stock",
|
||||||
|
stock_id,
|
||||||
json!({
|
json!({
|
||||||
"id": stock_id.to_string(),
|
"id": stock_id.to_string(),
|
||||||
"sku_id": "kg-cafe-honduras-2026",
|
"sku_id": "kg-cafe-honduras-2026",
|
||||||
"ubicacion": "almacen-norte",
|
"ubicacion": "almacen-norte",
|
||||||
"cantidad": 500_i64,
|
"cantidad": 500_i64,
|
||||||
}),
|
}),
|
||||||
).expect("seed stock");
|
)
|
||||||
|
.expect("seed stock");
|
||||||
seed_and_log(
|
seed_and_log(
|
||||||
&exec,
|
&exec,
|
||||||
&mut store, &mut log, "Caja", caja_id,
|
&mut store,
|
||||||
|
&mut log,
|
||||||
|
"Caja",
|
||||||
|
caja_id,
|
||||||
json!({
|
json!({
|
||||||
"id": caja_id.to_string(),
|
"id": caja_id.to_string(),
|
||||||
"name": "Caja Principal",
|
"name": "Caja Principal",
|
||||||
"saldo": 1_000_000_i64, // $10_000.00 in cents
|
"saldo": 1_000_000_i64, // $10_000.00 in cents
|
||||||
"currency": "USD",
|
"currency": "USD",
|
||||||
}),
|
}),
|
||||||
).expect("seed caja");
|
)
|
||||||
|
.expect("seed caja");
|
||||||
|
|
||||||
section("== seed ==");
|
section("== seed ==");
|
||||||
print_stock(&store, "stock", stock_id);
|
print_stock(&store, "stock", stock_id);
|
||||||
@@ -51,7 +57,11 @@ fn main() {
|
|||||||
|
|
||||||
// 1. Sell 100 kg cafe at $50.00 / kg = $5000.00 total.
|
// 1. Sell 100 kg cafe at $50.00 / kg = $5000.00 total.
|
||||||
section("== vender 100 kg @ $50.00 c/u ==");
|
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)],
|
&[("stock", stock_id), ("caja", caja_id)],
|
||||||
json!({
|
json!({
|
||||||
"cantidad": 100_i64,
|
"cantidad": 100_i64,
|
||||||
@@ -65,7 +75,11 @@ fn main() {
|
|||||||
|
|
||||||
// 2. Try selling more than available stock — should fail Stock post-check.
|
// 2. Try selling more than available stock — should fail Stock post-check.
|
||||||
section("== vender 9999 kg (reject: stock <= 0) ==");
|
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)],
|
&[("stock", stock_id), ("caja", caja_id)],
|
||||||
json!({
|
json!({
|
||||||
"cantidad": 9999_i64,
|
"cantidad": 9999_i64,
|
||||||
@@ -77,7 +91,11 @@ fn main() {
|
|||||||
|
|
||||||
// 3. Negative price — caught by Rhai.
|
// 3. Negative price — caught by Rhai.
|
||||||
section("== vender con precio negativo (reject: rhai throw) ==");
|
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)],
|
&[("stock", stock_id), ("caja", caja_id)],
|
||||||
json!({
|
json!({
|
||||||
"cantidad": 10_i64,
|
"cantidad": 10_i64,
|
||||||
@@ -89,7 +107,11 @@ fn main() {
|
|||||||
|
|
||||||
// 4. Another good sale.
|
// 4. Another good sale.
|
||||||
section("== vender 50 kg @ $60.00 c/u ==");
|
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)],
|
&[("stock", stock_id), ("caja", caja_id)],
|
||||||
json!({
|
json!({
|
||||||
"cantidad": 50_i64,
|
"cantidad": 50_i64,
|
||||||
@@ -113,10 +135,12 @@ fn main() {
|
|||||||
));
|
));
|
||||||
for e in &entries {
|
for e in &entries {
|
||||||
match e {
|
match e {
|
||||||
nakui_core::event_log::LogEntry::Seed { seq, entity, id, .. } =>
|
nakui_core::event_log::LogEntry::Seed {
|
||||||
println!(" #{:02} seed {} {}", seq, entity, id),
|
seq, entity, id, ..
|
||||||
nakui_core::event_log::LogEntry::Morphism { seq, morphism, ops, .. } =>
|
} => println!(" #{:02} seed {} {}", seq, entity, id),
|
||||||
println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()),
|
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) ==");
|
section("== determinism verification (ops) ==");
|
||||||
match verify_log(&log, &exec) {
|
match verify_log(&log, &exec) {
|
||||||
Ok(()) => println!(
|
Ok(()) => println!(" ok: every logged morphism reproduced its ops on re-execution"),
|
||||||
" ok: every logged morphism reproduced its ops on re-execution"
|
|
||||||
),
|
|
||||||
Err(e) => println!(" nondeterminism detected: {}", e),
|
Err(e) => println!(" nondeterminism detected: {}", e),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,11 +170,16 @@ fn run_and_report(
|
|||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
) {
|
) {
|
||||||
match execute_and_log(exec, store, log, morphism, inputs, params) {
|
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::PreLog(e)) => println!(" rejected: {}", e),
|
||||||
Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e),
|
Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e),
|
||||||
Err(ExecuteError::PostLogStore(e)) => println!(
|
Err(ExecuteError::PostLogStore(e)) => println!(
|
||||||
" POST-LOG STORE FAILED (log canonical, store stale): {}", e
|
" POST-LOG STORE FAILED (log canonical, store stale): {}",
|
||||||
|
e
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,10 +150,7 @@ pub fn simulate_on(state: &Value, entity: &str, id: Uuid, ops: &[FieldOp]) -> Op
|
|||||||
} if e == entity && *i == id => {
|
} if e == entity && *i == id => {
|
||||||
s = Some(data.clone());
|
s = Some(data.clone());
|
||||||
}
|
}
|
||||||
FieldOp::Delete {
|
FieldOp::Delete { entity: e, id: i } if e == entity && *i == id => {
|
||||||
entity: e,
|
|
||||||
id: i,
|
|
||||||
} if e == entity && *i == id => {
|
|
||||||
s = None;
|
s = None;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use std::path::Path;
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::event_log::{EventLog, replay};
|
use crate::event_log::{replay, EventLog};
|
||||||
use crate::store::Store;
|
use crate::store::Store;
|
||||||
|
|
||||||
/// A single record-level difference between two snapshots. Variants are
|
/// 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(),
|
field: "records[].entity".into(),
|
||||||
})?
|
})?
|
||||||
.to_string();
|
.to_string();
|
||||||
let id_str = item
|
let id_str =
|
||||||
.get("id")
|
item.get("id")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.ok_or_else(|| DriftError::MissingField {
|
.ok_or_else(|| DriftError::MissingField {
|
||||||
op: "dump_records".into(),
|
op: "dump_records".into(),
|
||||||
field: "records[].id".into(),
|
field: "records[].id".into(),
|
||||||
})?;
|
})?;
|
||||||
let id = Uuid::parse_str(id_str).map_err(|_| DriftError::MissingField {
|
let id = Uuid::parse_str(id_str).map_err(|_| DriftError::MissingField {
|
||||||
op: "dump_records".into(),
|
op: "dump_records".into(),
|
||||||
field: format!("records[].id (not uuid: {})", id_str),
|
field: format!("records[].id (not uuid: {})", id_str),
|
||||||
@@ -377,16 +377,8 @@ mod tests {
|
|||||||
// The function compares records, not hashes — hash equality is
|
// The function compares records, not hashes — hash equality is
|
||||||
// the operator's fast-path, but the report's truth is the diffs.
|
// the operator's fast-path, but the report's truth is the diffs.
|
||||||
let a = Uuid::new_v4();
|
let a = Uuid::new_v4();
|
||||||
let log = vec![(
|
let log = vec![("Caja".to_string(), a, json!({"saldo": 100}))];
|
||||||
"Caja".to_string(),
|
let server = vec![("Caja".to_string(), a, json!({"saldo": 100}))];
|
||||||
a,
|
|
||||||
json!({"saldo": 100}),
|
|
||||||
)];
|
|
||||||
let server = vec![(
|
|
||||||
"Caja".to_string(),
|
|
||||||
a,
|
|
||||||
json!({"saldo": 100}),
|
|
||||||
)];
|
|
||||||
let report = compare_states(log, h(1), server, h(2));
|
let report = compare_states(log, h(1), server, h(2));
|
||||||
assert!(report.diffs.is_empty(), "records equal → no diffs");
|
assert!(report.diffs.is_empty(), "records equal → no diffs");
|
||||||
}
|
}
|
||||||
@@ -395,11 +387,7 @@ mod tests {
|
|||||||
fn detects_only_on_server() {
|
fn detects_only_on_server() {
|
||||||
let a = Uuid::new_v4();
|
let a = Uuid::new_v4();
|
||||||
let b = Uuid::new_v4();
|
let b = Uuid::new_v4();
|
||||||
let log = vec![(
|
let log = vec![("Caja".to_string(), a, json!({"saldo": 100}))];
|
||||||
"Caja".to_string(),
|
|
||||||
a,
|
|
||||||
json!({"saldo": 100}),
|
|
||||||
)];
|
|
||||||
let server = vec![
|
let server = vec![
|
||||||
("Caja".to_string(), a, json!({"saldo": 100})),
|
("Caja".to_string(), a, json!({"saldo": 100})),
|
||||||
("Caja".to_string(), b, json!({"saldo": 999})),
|
("Caja".to_string(), b, json!({"saldo": 999})),
|
||||||
@@ -457,17 +445,16 @@ mod tests {
|
|||||||
let id_caja = Uuid::nil(); // sorts first byte-wise
|
let id_caja = Uuid::nil(); // sorts first byte-wise
|
||||||
let id_mov = Uuid::from_u128(u128::MAX);
|
let id_mov = Uuid::from_u128(u128::MAX);
|
||||||
|
|
||||||
let log = vec![
|
let log = vec![("Movimiento".to_string(), id_mov, json!({"x": 1}))];
|
||||||
("Movimiento".to_string(), id_mov, json!({"x": 1})),
|
let server = vec![("Caja".to_string(), id_caja, json!({"saldo": 0}))];
|
||||||
];
|
|
||||||
let server = vec![
|
|
||||||
("Caja".to_string(), id_caja, json!({"saldo": 0})),
|
|
||||||
];
|
|
||||||
let report = compare_states(log, h(0), server, h(1));
|
let report = compare_states(log, h(0), server, h(1));
|
||||||
assert_eq!(report.diffs.len(), 2);
|
assert_eq!(report.diffs.len(), 2);
|
||||||
// Caja sorts before Movimiento.
|
// Caja sorts before Movimiento.
|
||||||
match (&report.diffs[0], &report.diffs[1]) {
|
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!(e1, "Caja");
|
||||||
assert_eq!(e2, "Movimiento");
|
assert_eq!(e2, "Movimiento");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -363,10 +363,7 @@ impl Snapshot {
|
|||||||
/// Verify the snapshot was produced under a bundle compatible with
|
/// Verify the snapshot was produced under a bundle compatible with
|
||||||
/// `executor`. Snapshots without a hash (legacy / `from_memory_store`)
|
/// `executor`. Snapshots without a hash (legacy / `from_memory_store`)
|
||||||
/// pass — the operator opted out of this check at capture time.
|
/// pass — the operator opted out of this check at capture time.
|
||||||
pub fn ensure_compatible_with(
|
pub fn ensure_compatible_with(&self, executor: &Executor) -> Result<(), SnapshotMismatchError> {
|
||||||
&self,
|
|
||||||
executor: &Executor,
|
|
||||||
) -> Result<(), SnapshotMismatchError> {
|
|
||||||
let Some(snap_hash) = self.schema_hash else {
|
let Some(snap_hash) = self.schema_hash else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -405,10 +402,8 @@ impl Snapshot {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let text = std::fs::read_to_string(path).map_err(LogError::Io)?;
|
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 {
|
let snap: Snapshot =
|
||||||
line: 0,
|
serde_json::from_str(&text).map_err(|e| LogError::Parse { line: 0, source: e })?;
|
||||||
source: e,
|
|
||||||
})?;
|
|
||||||
Ok(Some(snap))
|
Ok(Some(snap))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -485,10 +480,7 @@ pub fn execute_and_log<S: Store>(
|
|||||||
let entry = LogEntry::Morphism {
|
let entry = LogEntry::Morphism {
|
||||||
seq,
|
seq,
|
||||||
morphism: morphism.to_string(),
|
morphism: morphism.to_string(),
|
||||||
inputs: inputs
|
inputs: inputs.iter().map(|(r, id)| (r.to_string(), *id)).collect(),
|
||||||
.iter()
|
|
||||||
.map(|(r, id)| (r.to_string(), *id))
|
|
||||||
.collect(),
|
|
||||||
params,
|
params,
|
||||||
ops: ops.clone(),
|
ops: ops.clone(),
|
||||||
schema_hash: executor.schema_hash(morphism),
|
schema_hash: executor.schema_hash(morphism),
|
||||||
@@ -534,10 +526,7 @@ pub fn execute_and_log_with_recovery<S: Store>(
|
|||||||
let entry = LogEntry::Morphism {
|
let entry = LogEntry::Morphism {
|
||||||
seq,
|
seq,
|
||||||
morphism: morphism.to_string(),
|
morphism: morphism.to_string(),
|
||||||
inputs: inputs
|
inputs: inputs.iter().map(|(r, id)| (r.to_string(), *id)).collect(),
|
||||||
.iter()
|
|
||||||
.map(|(r, id)| (r.to_string(), *id))
|
|
||||||
.collect(),
|
|
||||||
params,
|
params,
|
||||||
ops: ops.clone(),
|
ops: ops.clone(),
|
||||||
schema_hash: executor.schema_hash(morphism),
|
schema_hash: executor.schema_hash(morphism),
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::delta::{FieldOp, simulate_on};
|
use crate::delta::{simulate_on, FieldOp};
|
||||||
use crate::graph::{GraphError, ManifestGraph};
|
use crate::graph::{GraphError, ManifestGraph};
|
||||||
use crate::nickel_validator::{self, NickelError};
|
|
||||||
use crate::manifest::{ConserveRule, Manifest, ManifestError, MorphismSpec, ValidationError};
|
use crate::manifest::{ConserveRule, Manifest, ManifestError, MorphismSpec, ValidationError};
|
||||||
|
use crate::nickel_validator::{self, NickelError};
|
||||||
use crate::rhai_executor::{RhaiError, RhaiExecutor};
|
use crate::rhai_executor::{RhaiError, RhaiExecutor};
|
||||||
use crate::store::{Store, StoreError};
|
use crate::store::{Store, StoreError};
|
||||||
|
|
||||||
@@ -141,7 +141,8 @@ impl Executor {
|
|||||||
// archivo apuntado se mueve). Sin esto, el bundle hash quedaba
|
// archivo apuntado se mueve). Sin esto, el bundle hash quedaba
|
||||||
// pegado y la versión del seed nunca detectaba ediciones de
|
// pegado y la versión del seed nunca detectaba ediciones de
|
||||||
// schema. Ver `verify_log_rejects_seed_after_schema_changes`.
|
// 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 schema_bundle_hash = compute_schema_bundle_hash(&schema_bundle_bytes);
|
||||||
let mut schema_hashes = HashMap::with_capacity(manifest.morphisms.len());
|
let mut schema_hashes = HashMap::with_capacity(manifest.morphisms.len());
|
||||||
for spec in &manifest.morphisms {
|
for spec in &manifest.morphisms {
|
||||||
@@ -291,20 +292,14 @@ impl Executor {
|
|||||||
Some(_) => {
|
Some(_) => {
|
||||||
return Err(ExecError::CapabilityViolation {
|
return Err(ExecError::CapabilityViolation {
|
||||||
morphism: morphism_name.to_string(),
|
morphism: morphism_name.to_string(),
|
||||||
token: format!(
|
token: format!("<entity-mismatch>.{}.{}", path.entity, path.field),
|
||||||
"<entity-mismatch>.{}.{}",
|
|
||||||
path.entity, path.field
|
|
||||||
),
|
|
||||||
declared: spec.writes.clone(),
|
declared: spec.writes.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
return Err(ExecError::CapabilityViolation {
|
return Err(ExecError::CapabilityViolation {
|
||||||
morphism: morphism_name.to_string(),
|
morphism: morphism_name.to_string(),
|
||||||
token: format!(
|
token: format!("<untracked id>.{}.{}", path.entity, path.field),
|
||||||
"<untracked id>.{}.{}",
|
|
||||||
path.entity, path.field
|
|
||||||
),
|
|
||||||
declared: spec.writes.clone(),
|
declared: spec.writes.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -330,8 +325,7 @@ impl Executor {
|
|||||||
// 7. Per-input KCL post-check; skip Deleted inputs.
|
// 7. Per-input KCL post-check; skip Deleted inputs.
|
||||||
for spec_in in &spec.inputs {
|
for spec_in in &spec.inputs {
|
||||||
let id = inputs_map[&spec_in.role];
|
let id = inputs_map[&spec_in.role];
|
||||||
if let Some(new_state) =
|
if let Some(new_state) = simulate_on(&loaded[&spec_in.role], &spec_in.entity, id, &ops)
|
||||||
simulate_on(&loaded[&spec_in.role], &spec_in.entity, id, &ops)
|
|
||||||
{
|
{
|
||||||
self.validate_entity(&spec_in.entity, &new_state)
|
self.validate_entity(&spec_in.entity, &new_state)
|
||||||
.map_err(|e| ExecError::SchemaPost {
|
.map_err(|e| ExecError::SchemaPost {
|
||||||
@@ -598,23 +592,21 @@ fn check_conservation(
|
|||||||
),
|
),
|
||||||
})?;
|
})?;
|
||||||
let old_state = &loaded[&binding.role];
|
let old_state = &loaded[&binding.role];
|
||||||
let old_val =
|
let old_val = old_state
|
||||||
old_state
|
.get(&rule.field)
|
||||||
.get(&rule.field)
|
.and_then(Value::as_i64)
|
||||||
.and_then(Value::as_i64)
|
.ok_or_else(|| ExecError::ConservationMalformed {
|
||||||
.ok_or_else(|| ExecError::ConservationMalformed {
|
entity: rule.entity.clone(),
|
||||||
entity: rule.entity.clone(),
|
field: rule.field.clone(),
|
||||||
field: rule.field.clone(),
|
message: format!("old value at role `{}` is not i64", binding.role),
|
||||||
message: format!("old value at role `{}` is not i64", binding.role),
|
})?;
|
||||||
})?;
|
let new_val = value
|
||||||
let new_val =
|
.as_i64()
|
||||||
value
|
.ok_or_else(|| ExecError::ConservationMalformed {
|
||||||
.as_i64()
|
entity: rule.entity.clone(),
|
||||||
.ok_or_else(|| ExecError::ConservationMalformed {
|
field: rule.field.clone(),
|
||||||
entity: rule.entity.clone(),
|
message: format!("Set value at role `{}` is not i64", binding.role),
|
||||||
field: rule.field.clone(),
|
})?;
|
||||||
message: format!("Set value at role `{}` is not i64", binding.role),
|
|
||||||
})?;
|
|
||||||
let group_key = match &rule.group_by {
|
let group_key = match &rule.group_by {
|
||||||
Some(g) => old_state
|
Some(g) => old_state
|
||||||
.get(g)
|
.get(g)
|
||||||
@@ -623,8 +615,7 @@ fn check_conservation(
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
};
|
};
|
||||||
*delta_by_group.entry(group_key).or_insert(0) +=
|
*delta_by_group.entry(group_key).or_insert(0) += (new_val as i128) - (old_val as i128);
|
||||||
(new_val as i128) - (old_val as i128);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,7 +664,10 @@ let y = 2;
|
|||||||
// content, not cosmetic.
|
// content, not cosmetic.
|
||||||
let src = r#"let s = "hello // not a comment \"world\"";"#;
|
let src = r#"let s = "hello // not a comment \"world\"";"#;
|
||||||
let normalized = normalize_rhai_source(src);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -189,7 +189,10 @@ fn build_data_flow(
|
|||||||
for r in &m.reads {
|
for r in &m.reads {
|
||||||
if let Some(token) = canonicalize_token(r, &role_to_entity) {
|
if let Some(token) = canonicalize_token(r, &role_to_entity) {
|
||||||
if seen_reads.insert(token.clone()) {
|
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);
|
m_reads.entry(m.name.clone()).or_default().push(token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,7 +201,10 @@ fn build_data_flow(
|
|||||||
for w in &m.writes {
|
for w in &m.writes {
|
||||||
if let Some(token) = canonicalize_token(w, &role_to_entity) {
|
if let Some(token) = canonicalize_token(w, &role_to_entity) {
|
||||||
if seen_writes.insert(token.clone()) {
|
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);
|
m_writes.entry(m.name.clone()).or_default().push(token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,11 +150,9 @@ impl Manifest {
|
|||||||
resolved: resolved.display().to_string(),
|
resolved: resolved.display().to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let content = std::fs::read_to_string(&resolved).map_err(|e| {
|
let content = std::fs::read_to_string(&resolved).map_err(|e| ValidationError::Io {
|
||||||
ValidationError::Io {
|
path: s.clone(),
|
||||||
path: s.clone(),
|
source: e,
|
||||||
source: e,
|
|
||||||
}
|
|
||||||
})?;
|
})?;
|
||||||
for name in extract_schema_names(&content) {
|
for name in extract_schema_names(&content) {
|
||||||
entity_to_files.entry(name).or_default().push(s.clone());
|
entity_to_files.entry(name).or_default().push(s.clone());
|
||||||
@@ -168,8 +166,7 @@ impl Manifest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let known_entities: HashSet<&str> =
|
let known_entities: HashSet<&str> = entity_to_files.keys().map(String::as_str).collect();
|
||||||
entity_to_files.keys().map(String::as_str).collect();
|
|
||||||
|
|
||||||
// 2. Manifest-level: morphism names must be unique.
|
// 2. Manifest-level: morphism names must be unique.
|
||||||
let mut seen: HashSet<&str> = HashSet::new();
|
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}"
|
(std.deserialize 'Json m%%\"{state_json}\"%%) | bundle.{schema_name}"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut ctx = nickel_lang::Context::new()
|
let mut ctx =
|
||||||
.with_source_name(format!("nakui-validate-{schema_name}"));
|
nickel_lang::Context::new().with_source_name(format!("nakui-validate-{schema_name}"));
|
||||||
|
|
||||||
match ctx.eval_deep_for_export(&source) {
|
match ctx.eval_deep_for_export(&source) {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
@@ -130,7 +130,9 @@ mod tests {
|
|||||||
let state = json!({"id": "abc"}); // falta cantidad
|
let state = json!({"id": "abc"}); // falta cantidad
|
||||||
let err = vet(&schema, &state, "Stock").unwrap_err();
|
let err = vet(&schema, &state, "Stock").unwrap_err();
|
||||||
assert!(matches!(err, NickelError::ValidationFailed(_)));
|
assert!(matches!(err, NickelError::ValidationFailed(_)));
|
||||||
let NickelError::ValidationFailed(msg) = err else { panic!() };
|
let NickelError::ValidationFailed(msg) = err else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
assert!(
|
assert!(
|
||||||
msg.to_lowercase().contains("cantidad") || msg.to_lowercase().contains("missing"),
|
msg.to_lowercase().contains("cantidad") || msg.to_lowercase().contains("missing"),
|
||||||
"msg debe mencionar el field missing: {msg}"
|
"msg debe mencionar el field missing: {msg}"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use rhai::packages::{
|
|||||||
ArithmeticPackage, BasicArrayPackage, BasicIteratorPackage, BasicMapPackage,
|
ArithmeticPackage, BasicArrayPackage, BasicIteratorPackage, BasicMapPackage,
|
||||||
BasicStringPackage, CorePackage, LogicPackage, Package,
|
BasicStringPackage, CorePackage, LogicPackage, Package,
|
||||||
};
|
};
|
||||||
use rhai::{AST, Dynamic, Engine, Scope};
|
use rhai::{Dynamic, Engine, Scope, AST};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -76,8 +76,8 @@ impl RhaiExecutor {
|
|||||||
for item in arr {
|
for item in arr {
|
||||||
let json: Value = rhai::serde::from_dynamic(&item)
|
let json: Value = rhai::serde::from_dynamic(&item)
|
||||||
.map_err(|e| RhaiError::BadOp(format!("dynamic -> json: {}", e)))?;
|
.map_err(|e| RhaiError::BadOp(format!("dynamic -> json: {}", e)))?;
|
||||||
let op: FieldOp = serde_json::from_value(json)
|
let op: FieldOp =
|
||||||
.map_err(|e| RhaiError::BadOp(e.to_string()))?;
|
serde_json::from_value(json).map_err(|e| RhaiError::BadOp(e.to_string()))?;
|
||||||
ops.push(op);
|
ops.push(op);
|
||||||
}
|
}
|
||||||
Ok(ops)
|
Ok(ops)
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ use std::os::unix::net::{UnixListener, UnixStream};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::event_log::{
|
use crate::event_log::{
|
||||||
EventLog, RecoverableExecuteError, ReplayError, Snapshot, SnapshotMismatchError,
|
execute_and_log_with_recovery, replay_with_snapshot_into, verify_log, EventLog,
|
||||||
execute_and_log_with_recovery, replay_with_snapshot_into, verify_log,
|
RecoverableExecuteError, ReplayError, Snapshot, SnapshotMismatchError,
|
||||||
};
|
};
|
||||||
use crate::executor::Executor;
|
use crate::executor::Executor;
|
||||||
use crate::store::Store;
|
use crate::store::Store;
|
||||||
@@ -206,7 +206,10 @@ fn handle_connection<S: Store>(
|
|||||||
}
|
}
|
||||||
let (response, shutdown) = dispatch(&line, executor, store, log);
|
let (response, shutdown) = dispatch(&line, executor, store, log);
|
||||||
let bytes = serde_json::to_vec(&response).expect("response serializes");
|
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);
|
eprintln!("nakui run: write: {}", e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -291,16 +294,10 @@ fn dispatch<S: Store>(
|
|||||||
}
|
}
|
||||||
Request::Verify => match verify_log(log, executor) {
|
Request::Verify => match verify_log(log, executor) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let entries = log
|
let entries = log.entries().map(|es| es.len()).unwrap_or(0);
|
||||||
.entries()
|
|
||||||
.map(|es| es.len())
|
|
||||||
.unwrap_or(0);
|
|
||||||
(json!({"ok": true, "entries": entries}), false)
|
(json!({"ok": true, "entries": entries}), false)
|
||||||
}
|
}
|
||||||
Err(e) => (
|
Err(e) => (json!({"ok": false, "error": e.to_string()}), false),
|
||||||
json!({"ok": false, "error": e.to_string()}),
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
Request::HashState => {
|
Request::HashState => {
|
||||||
let records: Vec<_> = match store.iter() {
|
let records: Vec<_> = match store.iter() {
|
||||||
@@ -324,9 +321,7 @@ fn dispatch<S: Store>(
|
|||||||
Request::DumpRecords => match store.iter() {
|
Request::DumpRecords => match store.iter() {
|
||||||
Ok(it) => {
|
Ok(it) => {
|
||||||
let records: Vec<Value> = it
|
let records: Vec<Value> = it
|
||||||
.map(|(entity, id, value)| {
|
.map(|(entity, id, value)| json!({"entity": entity, "id": id, "value": value}))
|
||||||
json!({"entity": entity, "id": id, "value": value})
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
(json!({"ok": true, "records": records}), false)
|
(json!({"ok": true, "records": records}), false)
|
||||||
}
|
}
|
||||||
@@ -349,4 +344,3 @@ fn hex_encode(bytes: &[u8]) -> String {
|
|||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,10 +128,7 @@ pub fn hash_value(hasher: &mut Sha256, v: &Value) {
|
|||||||
// else (fractions, NaN, infinities) hashes as the raw
|
// else (fractions, NaN, infinities) hashes as the raw
|
||||||
// f64 bit pattern — that's still deterministic, just
|
// f64 bit pattern — that's still deterministic, just
|
||||||
// not normalized.
|
// not normalized.
|
||||||
if f.is_finite()
|
if f.is_finite() && f.fract() == 0.0 && f >= I128_MIN_AS_F64 && f <= I128_MAX_AS_F64
|
||||||
&& f.fract() == 0.0
|
|
||||||
&& f >= I128_MIN_AS_F64
|
|
||||||
&& f <= I128_MAX_AS_F64
|
|
||||||
{
|
{
|
||||||
hash_int(hasher, f as i128);
|
hash_int(hasher, f as i128);
|
||||||
} else {
|
} else {
|
||||||
@@ -247,22 +244,12 @@ impl Store for MemoryStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
FieldOp::Create { entity, id, .. } => {
|
FieldOp::Create { entity, id, .. } => {
|
||||||
if self
|
if self.records.get(entity).and_then(|m| m.get(id)).is_some() {
|
||||||
.records
|
|
||||||
.get(entity)
|
|
||||||
.and_then(|m| m.get(id))
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
return Err(StoreError::Conflict(entity.clone(), *id));
|
return Err(StoreError::Conflict(entity.clone(), *id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FieldOp::Delete { entity, id } => {
|
FieldOp::Delete { entity, id } => {
|
||||||
if self
|
if self.records.get(entity).and_then(|m| m.get(id)).is_none() {
|
||||||
.records
|
|
||||||
.get(entity)
|
|
||||||
.and_then(|m| m.get(id))
|
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
return Err(StoreError::NotFound(entity.clone(), *id));
|
return Err(StoreError::NotFound(entity.clone(), *id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,7 +330,10 @@ impl Store for MemoryStore {
|
|||||||
.map(move |(id, v)| (entity.clone(), *id, v.clone()))
|
.map(move |(id, v)| (entity.clone(), *id, v.clone()))
|
||||||
})
|
})
|
||||||
.collect();
|
.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()))
|
Ok(Box::new(out.into_iter()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,7 +423,11 @@ mod tests {
|
|||||||
let after = store.load("Customer", id).unwrap();
|
let after = store.load("Customer", id).unwrap();
|
||||||
let map = after.as_object().unwrap();
|
let map = after.as_object().unwrap();
|
||||||
assert!(!map.contains_key("notes"), "notes debería estar borrado");
|
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]
|
#[test]
|
||||||
@@ -455,10 +449,7 @@ mod tests {
|
|||||||
// No debería errar: clear de un field ausente es benigno.
|
// No debería errar: clear de un field ausente es benigno.
|
||||||
store.apply(&[op]).unwrap();
|
store.apply(&[op]).unwrap();
|
||||||
let after = store.load("Customer", id).unwrap();
|
let after = store.load("Customer", id).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(after.as_object().unwrap().get("name"), Some(&json!("Acme")));
|
||||||
after.as_object().unwrap().get("name"),
|
|
||||||
Some(&json!("Acme"))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -680,9 +671,8 @@ mod tests {
|
|||||||
// The empty hash is the SHA-256 of an empty input — fix the
|
// The empty hash is the SHA-256 of an empty input — fix the
|
||||||
// expected bytes so an accidental framing change in `hash_state`
|
// expected bytes so an accidental framing change in `hash_state`
|
||||||
// can't silently sail through.
|
// can't silently sail through.
|
||||||
let expected = hex_decode(
|
let expected =
|
||||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
hex_decode("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
||||||
);
|
|
||||||
assert_eq!(s1.hash_state().unwrap().to_vec(), expected);
|
assert_eq!(s1.hash_state().unwrap().to_vec(), expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,10 @@
|
|||||||
//! a stable shape.
|
//! a stable shape.
|
||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use surrealdb::Surreal;
|
|
||||||
use surrealdb::engine::local::{Db, Mem};
|
|
||||||
#[cfg(feature = "persistent")]
|
#[cfg(feature = "persistent")]
|
||||||
use surrealdb::engine::local::SurrealKv;
|
use surrealdb::engine::local::SurrealKv;
|
||||||
|
use surrealdb::engine::local::{Db, Mem};
|
||||||
|
use surrealdb::Surreal;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -84,9 +84,7 @@ impl SurrealStore {
|
|||||||
/// canonical use is `let store = SurrealStore::new_persistent(path)?`
|
/// canonical use is `let store = SurrealStore::new_persistent(path)?`
|
||||||
/// at process startup, with the path stable across runs.
|
/// at process startup, with the path stable across runs.
|
||||||
#[cfg(feature = "persistent")]
|
#[cfg(feature = "persistent")]
|
||||||
pub fn new_persistent(
|
pub fn new_persistent(path: impl AsRef<std::path::Path>) -> Result<Self, SurrealStoreError> {
|
||||||
path: impl AsRef<std::path::Path>,
|
|
||||||
) -> Result<Self, SurrealStoreError> {
|
|
||||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
.build()?;
|
.build()?;
|
||||||
@@ -175,10 +173,7 @@ impl Store for SurrealStore {
|
|||||||
// sobre un field ausente no falla).
|
// sobre un field ausente no falla).
|
||||||
let exists = self.exists(&path.entity, path.id).await?;
|
let exists = self.exists(&path.entity, path.id).await?;
|
||||||
if !exists {
|
if !exists {
|
||||||
return Err(StoreError::NotFound(
|
return Err(StoreError::NotFound(path.entity.clone(), path.id));
|
||||||
path.entity.clone(),
|
|
||||||
path.id,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
// We don't model NotAnObject for SurrealStore: every
|
// We don't model NotAnObject for SurrealStore: every
|
||||||
// record stored via this trait is map-shaped by
|
// 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.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())));
|
out.sort_by(|a, b| {
|
||||||
Ok(Box::new(out.into_iter())
|
a.0.cmp(&b.0)
|
||||||
as Box<dyn Iterator<Item = (String, Uuid, Value)>>)
|
.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)>>)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
//! Tests de integración del módulo `crm`. Mismo kernel que
|
||||||
|
//! inventory/sales/treasury, apuntado a `modules/crm`: clientes,
|
||||||
|
//! oportunidades que recorren un pipeline de ventas, e interacciones.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use nakui_core::executor::{ExecError, Executor};
|
||||||
|
use nakui_core::store::{MemoryStore, Store};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn crm_module() -> PathBuf {
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.parent()
|
||||||
|
.expect("dir del módulo nakui sobre core/")
|
||||||
|
.join("modules/crm")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_cliente(store: &mut MemoryStore, id: Uuid, nombre: &str) {
|
||||||
|
store.seed(
|
||||||
|
"Cliente",
|
||||||
|
id,
|
||||||
|
json!({
|
||||||
|
"id": id.to_string(),
|
||||||
|
"nombre": nombre,
|
||||||
|
"email": "contacto@example.com",
|
||||||
|
"empresa": nombre,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abre una oportunidad y devuelve su id. Camino feliz (panica si falla).
|
||||||
|
fn abrir_opp(exec: &Executor, store: &mut MemoryStore, cliente: Uuid) -> Uuid {
|
||||||
|
let opp = Uuid::new_v4();
|
||||||
|
exec.run(
|
||||||
|
store,
|
||||||
|
"abrir_oportunidad",
|
||||||
|
&[("cliente", cliente)],
|
||||||
|
json!({
|
||||||
|
"oportunidad_id": opp.to_string(),
|
||||||
|
"titulo": "Licencia anual",
|
||||||
|
"monto": 12_000_i64,
|
||||||
|
"currency": "USD",
|
||||||
|
"timestamp": "2026-05-21T10:00:00Z",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("abrir_oportunidad debe pasar");
|
||||||
|
opp
|
||||||
|
}
|
||||||
|
|
||||||
|
fn etapa(store: &MemoryStore, opp: Uuid) -> String {
|
||||||
|
store
|
||||||
|
.load("Oportunidad", opp)
|
||||||
|
.and_then(|v| v.get("etapa").and_then(Value::as_str).map(String::from))
|
||||||
|
.expect("oportunidad con etapa")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corre `mover_oportunidad`; devuelve el conteo de ops en éxito.
|
||||||
|
// `ExecError` es un enum grande — el resto del crate convive con este
|
||||||
|
// lint; lo suprimimos local en vez de boxear sólo este helper.
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
fn mover(
|
||||||
|
exec: &Executor,
|
||||||
|
store: &mut MemoryStore,
|
||||||
|
opp: Uuid,
|
||||||
|
destino: &str,
|
||||||
|
) -> Result<usize, ExecError> {
|
||||||
|
exec.run(
|
||||||
|
store,
|
||||||
|
"mover_oportunidad",
|
||||||
|
&[("oportunidad", opp)],
|
||||||
|
json!({ "etapa": destino, "timestamp": "2026-05-21T11:00:00Z" }),
|
||||||
|
)
|
||||||
|
.map(|ops| ops.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn abrir_crea_oportunidad_en_prospecto() {
|
||||||
|
let exec = Executor::load_module(crm_module()).expect("load module");
|
||||||
|
let mut store = MemoryStore::new();
|
||||||
|
let cliente = Uuid::new_v4();
|
||||||
|
seed_cliente(&mut store, cliente, "Acme Corp");
|
||||||
|
|
||||||
|
let opp = abrir_opp(&exec, &mut store, cliente);
|
||||||
|
|
||||||
|
assert_eq!(etapa(&store, opp), "prospecto", "nace en prospecto");
|
||||||
|
let o = store.load("Oportunidad", opp).expect("oportunidad existe");
|
||||||
|
let cid = cliente.to_string();
|
||||||
|
assert_eq!(
|
||||||
|
o.get("cliente_id").and_then(Value::as_str),
|
||||||
|
Some(cid.as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(o.get("monto").and_then(Value::as_i64), Some(12_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pipeline_avanza_hasta_ganada() {
|
||||||
|
let exec = Executor::load_module(crm_module()).expect("load module");
|
||||||
|
let mut store = MemoryStore::new();
|
||||||
|
let cliente = Uuid::new_v4();
|
||||||
|
seed_cliente(&mut store, cliente, "Acme Corp");
|
||||||
|
let opp = abrir_opp(&exec, &mut store, cliente);
|
||||||
|
|
||||||
|
for destino in ["calificado", "propuesta", "negociacion", "ganada"] {
|
||||||
|
mover(&exec, &mut store, opp, destino)
|
||||||
|
.unwrap_or_else(|e| panic!("mover a {destino} debe pasar: {e:?}"));
|
||||||
|
assert_eq!(etapa(&store, opp), destino);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_se_retrocede_en_el_pipeline() {
|
||||||
|
let exec = Executor::load_module(crm_module()).expect("load module");
|
||||||
|
let mut store = MemoryStore::new();
|
||||||
|
let cliente = Uuid::new_v4();
|
||||||
|
seed_cliente(&mut store, cliente, "Acme Corp");
|
||||||
|
let opp = abrir_opp(&exec, &mut store, cliente);
|
||||||
|
|
||||||
|
mover(&exec, &mut store, opp, "propuesta").expect("avanzar debe pasar");
|
||||||
|
|
||||||
|
// prospecto está antes de propuesta → retroceso, rechazado por el script.
|
||||||
|
let result = mover(&exec, &mut store, opp, "prospecto");
|
||||||
|
match result {
|
||||||
|
Err(ExecError::Rhai(_)) => {}
|
||||||
|
other => panic!("esperaba Rhai (throw por retroceso), obtuve {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(etapa(&store, opp), "propuesta", "la etapa no cambió");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oportunidad_cerrada_no_se_mueve() {
|
||||||
|
let exec = Executor::load_module(crm_module()).expect("load module");
|
||||||
|
let mut store = MemoryStore::new();
|
||||||
|
let cliente = Uuid::new_v4();
|
||||||
|
seed_cliente(&mut store, cliente, "Acme Corp");
|
||||||
|
let opp = abrir_opp(&exec, &mut store, cliente);
|
||||||
|
|
||||||
|
// Cerrar es legal desde cualquier etapa abierta.
|
||||||
|
mover(&exec, &mut store, opp, "ganada").expect("cerrar debe pasar");
|
||||||
|
|
||||||
|
// Una oportunidad ganada ya no se mueve.
|
||||||
|
let result = mover(&exec, &mut store, opp, "negociacion");
|
||||||
|
match result {
|
||||||
|
Err(ExecError::Rhai(_)) => {}
|
||||||
|
other => panic!("esperaba Rhai (throw por cerrada), obtuve {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(etapa(&store, opp), "ganada");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn etapa_destino_desconocida_es_rechazada() {
|
||||||
|
let exec = Executor::load_module(crm_module()).expect("load module");
|
||||||
|
let mut store = MemoryStore::new();
|
||||||
|
let cliente = Uuid::new_v4();
|
||||||
|
seed_cliente(&mut store, cliente, "Acme Corp");
|
||||||
|
let opp = abrir_opp(&exec, &mut store, cliente);
|
||||||
|
|
||||||
|
let result = mover(&exec, &mut store, opp, "facturada");
|
||||||
|
assert!(matches!(result, Err(ExecError::Rhai(_))));
|
||||||
|
assert_eq!(etapa(&store, opp), "prospecto");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monto_negativo_es_rechazado() {
|
||||||
|
let exec = Executor::load_module(crm_module()).expect("load module");
|
||||||
|
let mut store = MemoryStore::new();
|
||||||
|
let cliente = Uuid::new_v4();
|
||||||
|
seed_cliente(&mut store, cliente, "Acme Corp");
|
||||||
|
|
||||||
|
let opp = Uuid::new_v4();
|
||||||
|
let result = exec.run(
|
||||||
|
&mut store,
|
||||||
|
"abrir_oportunidad",
|
||||||
|
&[("cliente", cliente)],
|
||||||
|
json!({
|
||||||
|
"oportunidad_id": opp.to_string(),
|
||||||
|
"titulo": "Trato inválido",
|
||||||
|
"monto": -500_i64,
|
||||||
|
"currency": "USD",
|
||||||
|
"timestamp": "2026-05-21T10:00:00Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert!(matches!(result, Err(ExecError::Rhai(_))));
|
||||||
|
assert!(store.load("Oportunidad", opp).is_none(), "no se creó nada");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registrar_interaccion_crea_registro() {
|
||||||
|
let exec = Executor::load_module(crm_module()).expect("load module");
|
||||||
|
let mut store = MemoryStore::new();
|
||||||
|
let cliente = Uuid::new_v4();
|
||||||
|
seed_cliente(&mut store, cliente, "Acme Corp");
|
||||||
|
|
||||||
|
let int_id = Uuid::new_v4();
|
||||||
|
exec.run(
|
||||||
|
&mut store,
|
||||||
|
"registrar_interaccion",
|
||||||
|
&[("cliente", cliente)],
|
||||||
|
json!({
|
||||||
|
"interaccion_id": int_id.to_string(),
|
||||||
|
"canal": "llamada",
|
||||||
|
"nota": "Primer contacto, interés alto",
|
||||||
|
"timestamp": "2026-05-21T09:00:00Z",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("registrar_interaccion debe pasar");
|
||||||
|
|
||||||
|
let i = store
|
||||||
|
.load("Interaccion", int_id)
|
||||||
|
.expect("interacción existe");
|
||||||
|
assert_eq!(i.get("canal").and_then(Value::as_str), Some("llamada"));
|
||||||
|
let cid = cliente.to_string();
|
||||||
|
assert_eq!(
|
||||||
|
i.get("cliente_id").and_then(Value::as_str),
|
||||||
|
Some(cid.as_str())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canal_invalido_es_rechazado() {
|
||||||
|
let exec = Executor::load_module(crm_module()).expect("load module");
|
||||||
|
let mut store = MemoryStore::new();
|
||||||
|
let cliente = Uuid::new_v4();
|
||||||
|
seed_cliente(&mut store, cliente, "Acme Corp");
|
||||||
|
|
||||||
|
let int_id = Uuid::new_v4();
|
||||||
|
let result = exec.run(
|
||||||
|
&mut store,
|
||||||
|
"registrar_interaccion",
|
||||||
|
&[("cliente", cliente)],
|
||||||
|
json!({
|
||||||
|
"interaccion_id": int_id.to_string(),
|
||||||
|
"canal": "paloma-mensajera",
|
||||||
|
"nota": "canal inexistente",
|
||||||
|
"timestamp": "2026-05-21T09:00:00Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert!(matches!(result, Err(ExecError::Rhai(_))));
|
||||||
|
assert!(store.load("Interaccion", int_id).is_none());
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ use std::os::unix::net::UnixStream;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
use nakui_core::drift::{DriftDiff, check_against_socket};
|
use nakui_core::drift::{check_against_socket, DriftDiff};
|
||||||
use nakui_core::event_log::{EventLog, execute_and_log, seed_and_log};
|
use nakui_core::event_log::{execute_and_log, seed_and_log, EventLog};
|
||||||
use nakui_core::executor::Executor;
|
use nakui_core::executor::Executor;
|
||||||
use nakui_core::run::run_server;
|
use nakui_core::run::run_server;
|
||||||
use nakui_core::store::MemoryStore;
|
use nakui_core::store::MemoryStore;
|
||||||
|
|||||||
@@ -5,13 +5,12 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use nakui_core::delta::FieldOp;
|
use nakui_core::delta::FieldOp;
|
||||||
use nakui_core::event_log::{
|
use nakui_core::event_log::{
|
||||||
EventLog, ExecuteError, LogEntry, RecoverableExecuteError, Snapshot, execute_and_log,
|
execute_and_log, execute_and_log_with_recovery, reconcile, replay, replay_with_snapshot_into,
|
||||||
execute_and_log_with_recovery, reconcile, replay, replay_with_snapshot_into, seed_and_log,
|
seed_and_log, verify_log, EventLog, ExecuteError, LogEntry, RecoverableExecuteError, Snapshot,
|
||||||
verify_log,
|
|
||||||
};
|
};
|
||||||
use nakui_core::executor::Executor;
|
use nakui_core::executor::Executor;
|
||||||
use nakui_core::store::{MemoryStore, Store, StoreError};
|
use nakui_core::store::{MemoryStore, Store, StoreError};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn workspace_root() -> PathBuf {
|
fn workspace_root() -> PathBuf {
|
||||||
@@ -247,11 +246,21 @@ fn post_log_store_failure_leaves_log_canonical() {
|
|||||||
|
|
||||||
// Live store is stale: apply was rejected, so saldos are unchanged.
|
// Live store is stale: apply was rejected, so saldos are unchanged.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store.load("Caja", a).unwrap().get("saldo").unwrap().as_i64(),
|
store
|
||||||
|
.load("Caja", a)
|
||||||
|
.unwrap()
|
||||||
|
.get("saldo")
|
||||||
|
.unwrap()
|
||||||
|
.as_i64(),
|
||||||
Some(200_000)
|
Some(200_000)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store.load("Caja", b).unwrap().get("saldo").unwrap().as_i64(),
|
store
|
||||||
|
.load("Caja", b)
|
||||||
|
.unwrap()
|
||||||
|
.get("saldo")
|
||||||
|
.unwrap()
|
||||||
|
.as_i64(),
|
||||||
Some(50_000)
|
Some(50_000)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -464,7 +473,10 @@ fn snapshot_then_compact_then_replay_equals_pre_compaction() {
|
|||||||
let loaded_snap = Snapshot::load(&snap_path).unwrap().unwrap();
|
let loaded_snap = Snapshot::load(&snap_path).unwrap().unwrap();
|
||||||
let mut replayed = MemoryStore::new();
|
let mut replayed = MemoryStore::new();
|
||||||
replay_with_snapshot_into(&log, Some(&loaded_snap), &mut replayed).expect("replay");
|
replay_with_snapshot_into(&log, Some(&loaded_snap), &mut replayed).expect("replay");
|
||||||
assert_eq!(live, replayed, "snapshot + post-compact log must equal live");
|
assert_eq!(
|
||||||
|
live, replayed,
|
||||||
|
"snapshot + post-compact log must equal live"
|
||||||
|
);
|
||||||
|
|
||||||
let _ = std::fs::remove_file(&log_path);
|
let _ = std::fs::remove_file(&log_path);
|
||||||
let _ = std::fs::remove_file(&snap_path);
|
let _ = std::fs::remove_file(&snap_path);
|
||||||
@@ -520,8 +532,14 @@ fn reconcile_rebuilds_drifted_store_from_log() {
|
|||||||
assert_ne!(live, canonical, "drift was set up to differ from log");
|
assert_ne!(live, canonical, "drift was set up to differ from log");
|
||||||
|
|
||||||
reconcile(&mut live, &log).expect("reconcile");
|
reconcile(&mut live, &log).expect("reconcile");
|
||||||
assert_eq!(live, canonical, "reconcile must restore log-canonical state");
|
assert_eq!(
|
||||||
assert!(live.load("Caja", ghost).is_none(), "poison record must be wiped");
|
live, canonical,
|
||||||
|
"reconcile must restore log-canonical state"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
live.load("Caja", ghost).is_none(),
|
||||||
|
"poison record must be wiped"
|
||||||
|
);
|
||||||
|
|
||||||
let _ = std::fs::remove_file(&log_path);
|
let _ = std::fs::remove_file(&log_path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use nakui_core::executor::Executor;
|
use nakui_core::executor::Executor;
|
||||||
use nakui_core::graph::{DirtyTracker, GraphError, ManifestGraph};
|
use nakui_core::graph::{DirtyTracker, GraphError, ManifestGraph};
|
||||||
use nakui_core::manifest::{
|
use nakui_core::manifest::{ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec};
|
||||||
ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn workspace_root() -> PathBuf {
|
fn workspace_root() -> PathBuf {
|
||||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
@@ -114,14 +112,28 @@ fn treasury_data_flow_indexes_match_manifest() {
|
|||||||
let g = &exec.graph;
|
let g = &exec.graph;
|
||||||
|
|
||||||
// Both register_cash_move and transfer_between_cajas write Caja.saldo.
|
// Both register_cash_move and transfer_between_cajas write Caja.saldo.
|
||||||
let mut writers: Vec<&str> = g.writers_of("Caja.saldo").iter().map(|s| s.as_str()).collect();
|
let mut writers: Vec<&str> = g
|
||||||
|
.writers_of("Caja.saldo")
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.collect();
|
||||||
writers.sort();
|
writers.sort();
|
||||||
assert_eq!(writers, vec!["register_cash_move", "transfer_between_cajas"]);
|
assert_eq!(
|
||||||
|
writers,
|
||||||
|
vec!["register_cash_move", "transfer_between_cajas"]
|
||||||
|
);
|
||||||
|
|
||||||
// Both read Caja.saldo too.
|
// Both read Caja.saldo too.
|
||||||
let mut readers: Vec<&str> = g.readers_of("Caja.saldo").iter().map(|s| s.as_str()).collect();
|
let mut readers: Vec<&str> = g
|
||||||
|
.readers_of("Caja.saldo")
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.collect();
|
||||||
readers.sort();
|
readers.sort();
|
||||||
assert_eq!(readers, vec!["register_cash_move", "transfer_between_cajas"]);
|
assert_eq!(
|
||||||
|
readers,
|
||||||
|
vec!["register_cash_move", "transfer_between_cajas"]
|
||||||
|
);
|
||||||
|
|
||||||
// Movimiento is written only by register_cash_move.
|
// Movimiento is written only by register_cash_move.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -246,8 +258,11 @@ fn executor_load_module_rejects_cyclic_manifest() {
|
|||||||
Err(e) => e,
|
Err(e) => e,
|
||||||
};
|
};
|
||||||
let msg = err.to_string();
|
let msg = err.to_string();
|
||||||
assert!(msg.contains("graph") || msg.contains("cycle"),
|
assert!(
|
||||||
"expected graph diagnostic, got `{}`", msg);
|
msg.contains("graph") || msg.contains("cycle"),
|
||||||
|
"expected graph diagnostic, got `{}`",
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&tmp);
|
let _ = std::fs::remove_dir_all(&tmp);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use nakui_core::executor::{ExecError, Executor};
|
use nakui_core::executor::{ExecError, Executor};
|
||||||
use nakui_core::store::{MemoryStore, Store};
|
use nakui_core::store::{MemoryStore, Store};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn workspace_root() -> PathBuf {
|
fn workspace_root() -> PathBuf {
|
||||||
@@ -104,7 +104,10 @@ fn transfer_across_different_skus_is_rejected_by_conservation() {
|
|||||||
|
|
||||||
match result {
|
match result {
|
||||||
Err(ExecError::Rhai(_)) => {}
|
Err(ExecError::Rhai(_)) => {}
|
||||||
other => panic!("expected Rhai (script throw on sku mismatch), got {:?}", other),
|
other => panic!(
|
||||||
|
"expected Rhai (script throw on sku mismatch), got {:?}",
|
||||||
|
other
|
||||||
|
),
|
||||||
}
|
}
|
||||||
assert_eq!(cantidad(&store, a), 500);
|
assert_eq!(cantidad(&store, a), 500);
|
||||||
assert_eq!(cantidad(&store, c), 200);
|
assert_eq!(cantidad(&store, c), 200);
|
||||||
|
|||||||
@@ -13,12 +13,10 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use nakui_core::executor::{ExecError, Executor};
|
use nakui_core::executor::{ExecError, Executor};
|
||||||
use nakui_core::graph::ManifestGraph;
|
use nakui_core::graph::ManifestGraph;
|
||||||
use nakui_core::manifest::{
|
use nakui_core::manifest::{ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec};
|
||||||
ConserveRule, Invariants, Manifest, MorphismInput, MorphismSpec,
|
|
||||||
};
|
|
||||||
use nakui_core::rhai_executor::RhaiExecutor;
|
use nakui_core::rhai_executor::RhaiExecutor;
|
||||||
use nakui_core::store::{MemoryStore, Store};
|
use nakui_core::store::{MemoryStore, Store};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn workspace_root() -> PathBuf {
|
fn workspace_root() -> PathBuf {
|
||||||
@@ -207,7 +205,12 @@ fn capability_rejects_entity_mismatch_on_tracked_id() {
|
|||||||
let caja_id = Uuid::new_v4();
|
let caja_id = Uuid::new_v4();
|
||||||
seed_caja(&mut store, caja_id, "tracked", 100_000, "USD");
|
seed_caja(&mut store, caja_id, "tracked", 100_000, "USD");
|
||||||
|
|
||||||
let result = exec.run(&mut store, "evil_entity_mismatch", &[("caja", caja_id)], json!({}));
|
let result = exec.run(
|
||||||
|
&mut store,
|
||||||
|
"evil_entity_mismatch",
|
||||||
|
&[("caja", caja_id)],
|
||||||
|
json!({}),
|
||||||
|
);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Err(ExecError::CapabilityViolation { token, .. }) => {
|
Err(ExecError::CapabilityViolation { token, .. }) => {
|
||||||
|
|||||||
@@ -173,7 +173,9 @@ fn rejects_missing_script() {
|
|||||||
let mut m = baseline_manifest();
|
let mut m = baseline_manifest();
|
||||||
m.morphisms[0].script = "morphisms/ghost.rhai".into();
|
m.morphisms[0].script = "morphisms/ghost.rhai".into();
|
||||||
match m.validate(&treasury_dir()) {
|
match m.validate(&treasury_dir()) {
|
||||||
Err(ValidationError::ScriptMissing { morphism, script, .. }) => {
|
Err(ValidationError::ScriptMissing {
|
||||||
|
morphism, script, ..
|
||||||
|
}) => {
|
||||||
assert_eq!(morphism, "test_op");
|
assert_eq!(morphism, "test_op");
|
||||||
assert_eq!(script, "morphisms/ghost.rhai");
|
assert_eq!(script, "morphisms/ghost.rhai");
|
||||||
}
|
}
|
||||||
@@ -200,16 +202,8 @@ fn rejects_duplicate_schema_across_files() {
|
|||||||
let tmp = std::env::temp_dir().join(format!("nakui_dup_{}", Uuid::new_v4()));
|
let tmp = std::env::temp_dir().join(format!("nakui_dup_{}", Uuid::new_v4()));
|
||||||
fs::create_dir_all(&tmp).unwrap();
|
fs::create_dir_all(&tmp).unwrap();
|
||||||
fs::create_dir_all(tmp.join("morphisms")).unwrap();
|
fs::create_dir_all(tmp.join("morphisms")).unwrap();
|
||||||
fs::write(
|
fs::write(tmp.join("a.ncl"), "{\n Caja = { saldo | Number },\n}\n").unwrap();
|
||||||
tmp.join("a.ncl"),
|
fs::write(tmp.join("b.ncl"), "{\n Caja = { monto | Number },\n}\n").unwrap();
|
||||||
"{\n Caja = { saldo | Number },\n}\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
fs::write(
|
|
||||||
tmp.join("b.ncl"),
|
|
||||||
"{\n Caja = { monto | Number },\n}\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
fs::write(tmp.join("morphisms/op.rhai"), "[]").unwrap();
|
fs::write(tmp.join("morphisms/op.rhai"), "[]").unwrap();
|
||||||
|
|
||||||
let m = Manifest {
|
let m = Manifest {
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use nakui_core::event_log::{EventLog, execute_and_log, seed_and_log};
|
use nakui_core::event_log::{execute_and_log, seed_and_log, EventLog};
|
||||||
use nakui_core::executor::Executor;
|
use nakui_core::executor::Executor;
|
||||||
use nakui_core::run::run_server;
|
use nakui_core::run::run_server;
|
||||||
use nakui_core::store::MemoryStore;
|
use nakui_core::store::MemoryStore;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn workspace_root() -> PathBuf {
|
fn workspace_root() -> PathBuf {
|
||||||
@@ -118,9 +118,7 @@ fn run_server_full_protocol_round_trip() {
|
|||||||
if resp["protocol"] != json!(1) {
|
if resp["protocol"] != json!(1) {
|
||||||
return Err(format!("protocol mismatch: {}", resp));
|
return Err(format!("protocol mismatch: {}", resp));
|
||||||
}
|
}
|
||||||
let morphisms = resp["morphisms"]
|
let morphisms = resp["morphisms"].as_array().ok_or("morphisms not array")?;
|
||||||
.as_array()
|
|
||||||
.ok_or("morphisms not array")?;
|
|
||||||
if !morphisms.iter().any(|m| m["name"] == "register_cash_move") {
|
if !morphisms.iter().any(|m| m["name"] == "register_cash_move") {
|
||||||
return Err("register_cash_move missing from describe".into());
|
return Err("register_cash_move missing from describe".into());
|
||||||
}
|
}
|
||||||
@@ -183,9 +181,13 @@ fn run_server_full_protocol_round_trip() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bad JSON — connection survives, server keeps serving.
|
// Bad JSON — connection survives, server keeps serving.
|
||||||
conn.writer.write_all(b"not json\n").map_err(|e| e.to_string())?;
|
conn.writer
|
||||||
|
.write_all(b"not json\n")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
conn.reader.read_line(&mut line).map_err(|e| e.to_string())?;
|
conn.reader
|
||||||
|
.read_line(&mut line)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
let parsed: Value = serde_json::from_str(line.trim()).map_err(|e| e.to_string())?;
|
let parsed: Value = serde_json::from_str(line.trim()).map_err(|e| e.to_string())?;
|
||||||
if parsed["ok"] != json!(false) {
|
if parsed["ok"] != json!(false) {
|
||||||
return Err(format!("bad request didn't get error: {}", parsed));
|
return Err(format!("bad request didn't get error: {}", parsed));
|
||||||
@@ -207,7 +209,10 @@ fn run_server_full_protocol_round_trip() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
run_server(executor, log, store, None, &socket_path).expect("server clean exit");
|
run_server(executor, log, store, None, &socket_path).expect("server clean exit");
|
||||||
client.join().expect("client thread joined").expect("client assertions");
|
client
|
||||||
|
.join()
|
||||||
|
.expect("client thread joined")
|
||||||
|
.expect("client assertions");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!socket_path.exists(),
|
!socket_path.exists(),
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use nakui_core::event_log::{EventLog, seed_and_log};
|
use nakui_core::event_log::{seed_and_log, EventLog};
|
||||||
use nakui_core::executor::Executor;
|
use nakui_core::executor::Executor;
|
||||||
use nakui_core::run::run_server;
|
use nakui_core::run::run_server;
|
||||||
use nakui_core::store::Store;
|
use nakui_core::store::Store;
|
||||||
use nakui_core::surreal_store::SurrealStore;
|
use nakui_core::surreal_store::SurrealStore;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn workspace_root() -> PathBuf {
|
fn workspace_root() -> PathBuf {
|
||||||
@@ -232,8 +232,7 @@ fn run_server_skips_replay_when_persistent_store_is_in_sync() {
|
|||||||
// Out-of-band mutation: open the persistent store directly and
|
// Out-of-band mutation: open the persistent store directly and
|
||||||
// change the saldo. Marker stays at the same seq.
|
// change the saldo. Marker stays at the same seq.
|
||||||
{
|
{
|
||||||
let mut store =
|
let mut store = SurrealStore::new_persistent(&store_path).expect("reopen for poison");
|
||||||
SurrealStore::new_persistent(&store_path).expect("reopen for poison");
|
|
||||||
store.seed(
|
store.seed(
|
||||||
"Caja",
|
"Caja",
|
||||||
caja,
|
caja,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use nakui_core::executor::{ExecError, Executor};
|
use nakui_core::executor::{ExecError, Executor};
|
||||||
use nakui_core::store::{MemoryStore, Store};
|
use nakui_core::store::{MemoryStore, Store};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn workspace_root() -> PathBuf {
|
fn workspace_root() -> PathBuf {
|
||||||
@@ -95,10 +95,7 @@ fn sale_decreases_stock_and_increases_caja() {
|
|||||||
.expect("venta must be persisted");
|
.expect("venta must be persisted");
|
||||||
assert_eq!(venta.get("total").and_then(Value::as_i64), Some(500_000));
|
assert_eq!(venta.get("total").and_then(Value::as_i64), Some(500_000));
|
||||||
assert_eq!(venta.get("cantidad").and_then(Value::as_i64), Some(100));
|
assert_eq!(venta.get("cantidad").and_then(Value::as_i64), Some(100));
|
||||||
assert_eq!(
|
assert_eq!(venta.get("currency").and_then(Value::as_str), Some("USD"));
|
||||||
venta.get("currency").and_then(Value::as_str),
|
|
||||||
Some("USD")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -10,11 +10,11 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use nakui_core::event_log::{
|
use nakui_core::event_log::{
|
||||||
EventLog, LogEntry, VerifyError, execute_and_log, replay, seed_and_log, verify_log,
|
execute_and_log, replay, seed_and_log, verify_log, EventLog, LogEntry, VerifyError,
|
||||||
};
|
};
|
||||||
use nakui_core::executor::Executor;
|
use nakui_core::executor::Executor;
|
||||||
use nakui_core::store::MemoryStore;
|
use nakui_core::store::MemoryStore;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn workspace_root() -> PathBuf {
|
fn workspace_root() -> PathBuf {
|
||||||
@@ -66,12 +66,7 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deposit_5k(
|
fn deposit_5k(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, caja: Uuid) {
|
||||||
exec: &Executor,
|
|
||||||
store: &mut MemoryStore,
|
|
||||||
log: &mut EventLog,
|
|
||||||
caja: Uuid,
|
|
||||||
) {
|
|
||||||
execute_and_log(
|
execute_and_log(
|
||||||
exec,
|
exec,
|
||||||
store,
|
store,
|
||||||
@@ -122,7 +117,10 @@ fn executor_exposes_per_morphism_schema_hash() {
|
|||||||
// Re-loading the same module yields the same hashes — the contract
|
// Re-loading the same module yields the same hashes — the contract
|
||||||
// depends only on the bytes on disk, not load-time state.
|
// depends only on the bytes on disk, not load-time state.
|
||||||
let exec2 = Executor::load_module(treasury_module()).expect("reload");
|
let exec2 = Executor::load_module(treasury_module()).expect("reload");
|
||||||
assert_eq!(exec.schema_hash("register_cash_move"), exec2.schema_hash("register_cash_move"));
|
assert_eq!(
|
||||||
|
exec.schema_hash("register_cash_move"),
|
||||||
|
exec2.schema_hash("register_cash_move")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -202,7 +200,10 @@ fn verify_log_rejects_log_after_morphism_script_changes() {
|
|||||||
// Reload — the hash for register_cash_move must change.
|
// Reload — the hash for register_cash_move must change.
|
||||||
let exec2 = Executor::load_module(&temp.path).expect("reload v2");
|
let exec2 = Executor::load_module(&temp.path).expect("reload v2");
|
||||||
let new_hash = exec2.schema_hash("register_cash_move").unwrap();
|
let new_hash = exec2.schema_hash("register_cash_move").unwrap();
|
||||||
assert_ne!(original_hash, new_hash, "real source edit must move the hash");
|
assert_ne!(
|
||||||
|
original_hash, new_hash,
|
||||||
|
"real source edit must move the hash"
|
||||||
|
);
|
||||||
|
|
||||||
// verify_log must surface SchemaMismatch, not OpsMismatch — the
|
// verify_log must surface SchemaMismatch, not OpsMismatch — the
|
||||||
// schema check runs first because "rules changed" is more
|
// schema check runs first because "rules changed" is more
|
||||||
@@ -361,7 +362,10 @@ fn verify_log_rejects_seed_after_schema_changes() {
|
|||||||
|
|
||||||
let exec2 = Executor::load_module(&temp.path).expect("reload v2");
|
let exec2 = Executor::load_module(&temp.path).expect("reload v2");
|
||||||
let new_hash = exec2.schema_bundle_hash;
|
let new_hash = exec2.schema_bundle_hash;
|
||||||
assert_ne!(original_hash, new_hash, "schema.ncl byte change must move the bundle hash");
|
assert_ne!(
|
||||||
|
original_hash, new_hash,
|
||||||
|
"schema.ncl byte change must move the bundle hash"
|
||||||
|
);
|
||||||
|
|
||||||
let log = EventLog::open(&log_path).unwrap();
|
let log = EventLog::open(&log_path).unwrap();
|
||||||
match verify_log(&log, &exec2) {
|
match verify_log(&log, &exec2) {
|
||||||
@@ -433,7 +437,11 @@ fn morphism_script_change_does_not_flag_unrelated_seeds() {
|
|||||||
// Modify a Rhai script. Bundle stays the same.
|
// Modify a Rhai script. Bundle stays the same.
|
||||||
let script_path = temp.path.join("morphisms/register_cash_move.rhai");
|
let script_path = temp.path.join("morphisms/register_cash_move.rhai");
|
||||||
let original = std::fs::read_to_string(&script_path).expect("read");
|
let original = std::fs::read_to_string(&script_path).expect("read");
|
||||||
std::fs::write(&script_path, format!("{}\n// rhai-only mutation\n", original)).unwrap();
|
std::fs::write(
|
||||||
|
&script_path,
|
||||||
|
format!("{}\n// rhai-only mutation\n", original),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let exec2 = Executor::load_module(&temp.path).expect("reload");
|
let exec2 = Executor::load_module(&temp.path).expect("reload");
|
||||||
let log = EventLog::open(&log_path).unwrap();
|
let log = EventLog::open(&log_path).unwrap();
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ use std::thread;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use nakui_core::event_log::{
|
use nakui_core::event_log::{
|
||||||
EventLog, Snapshot, SnapshotMismatchError, execute_and_log, replay, seed_and_log,
|
execute_and_log, replay, seed_and_log, EventLog, Snapshot, SnapshotMismatchError,
|
||||||
};
|
};
|
||||||
use nakui_core::executor::Executor;
|
use nakui_core::executor::Executor;
|
||||||
use nakui_core::run::run_server;
|
use nakui_core::run::run_server;
|
||||||
use nakui_core::store::{MemoryStore, Store};
|
use nakui_core::store::{MemoryStore, Store};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn workspace_root() -> PathBuf {
|
fn workspace_root() -> PathBuf {
|
||||||
@@ -166,7 +166,10 @@ fn snapshot_then_compact_then_run_server_resumes_correctly() {
|
|||||||
let socket_for_client = socket_path.clone();
|
let socket_for_client = socket_path.clone();
|
||||||
let client = thread::spawn(move || -> Result<(), String> {
|
let client = thread::spawn(move || -> Result<(), String> {
|
||||||
let mut conn = connect_with_retry(&socket_for_client);
|
let mut conn = connect_with_retry(&socket_for_client);
|
||||||
let resp = exchange(&mut conn, json!({"op": "load", "entity": "Caja", "id": caja.to_string()}));
|
let resp = exchange(
|
||||||
|
&mut conn,
|
||||||
|
json!({"op": "load", "entity": "Caja", "id": caja.to_string()}),
|
||||||
|
);
|
||||||
if resp["value"]["saldo"].as_i64() != Some(112_500) {
|
if resp["value"]["saldo"].as_i64() != Some(112_500) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"expected saldo 112_500 (100k seed + 5k + 7.5k from snapshot), got {}",
|
"expected saldo 112_500 (100k seed + 5k + 7.5k from snapshot), got {}",
|
||||||
@@ -191,9 +194,15 @@ fn snapshot_then_compact_then_run_server_resumes_correctly() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if resp["ok"] != json!(true) {
|
if resp["ok"] != json!(true) {
|
||||||
return Err(format!("execute on snapshot-booted server failed: {}", resp));
|
return Err(format!(
|
||||||
|
"execute on snapshot-booted server failed: {}",
|
||||||
|
resp
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let resp = exchange(&mut conn, json!({"op": "load", "entity": "Caja", "id": caja.to_string()}));
|
let resp = exchange(
|
||||||
|
&mut conn,
|
||||||
|
json!({"op": "load", "entity": "Caja", "id": caja.to_string()}),
|
||||||
|
);
|
||||||
if resp["value"]["saldo"].as_i64() != Some(113_500) {
|
if resp["value"]["saldo"].as_i64() != Some(113_500) {
|
||||||
return Err(format!("post-execute saldo wrong: {}", resp));
|
return Err(format!("post-execute saldo wrong: {}", resp));
|
||||||
}
|
}
|
||||||
@@ -234,10 +243,7 @@ fn run_server_refuses_snapshot_with_wrong_schema_hash() {
|
|||||||
let store = MemoryStore::new();
|
let store = MemoryStore::new();
|
||||||
let result = run_server(exec, log, store, Some(bad_snap), &socket_path);
|
let result = run_server(exec, log, store, Some(bad_snap), &socket_path);
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(result, Err(nakui_core::run::RunError::SnapshotMismatch(_))),
|
||||||
result,
|
|
||||||
Err(nakui_core::run::RunError::SnapshotMismatch(_))
|
|
||||||
),
|
|
||||||
"expected SnapshotMismatch, got {:?}",
|
"expected SnapshotMismatch, got {:?}",
|
||||||
result
|
result
|
||||||
);
|
);
|
||||||
@@ -352,7 +358,8 @@ fn snapshot_write_recovers_from_stale_tempfile() {
|
|||||||
|
|
||||||
let exec = Executor::load_module(treasury_module()).expect("load");
|
let exec = Executor::load_module(treasury_module()).expect("load");
|
||||||
let snap = Snapshot::capture(&MemoryStore::new(), 0, &exec);
|
let snap = Snapshot::capture(&MemoryStore::new(), 0, &exec);
|
||||||
snap.write(&snap_path).expect("write despite stale tempfile");
|
snap.write(&snap_path)
|
||||||
|
.expect("write despite stale tempfile");
|
||||||
|
|
||||||
// Tempfile should be renamed (not orphaned), so it's gone.
|
// Tempfile should be renamed (not orphaned), so it's gone.
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use nakui_core::event_log::{EventLog, execute_and_log, replay, seed_and_log};
|
use nakui_core::event_log::{execute_and_log, replay, seed_and_log, EventLog};
|
||||||
use nakui_core::executor::Executor;
|
use nakui_core::executor::Executor;
|
||||||
use nakui_core::store::{MemoryStore, Store};
|
use nakui_core::store::{MemoryStore, Store};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -27,13 +27,7 @@ fn fresh_log_path() -> PathBuf {
|
|||||||
std::env::temp_dir().join(format!("nakui_hash_{}.jsonl", Uuid::new_v4()))
|
std::env::temp_dir().join(format!("nakui_hash_{}.jsonl", Uuid::new_v4()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seed_two_cajas(
|
fn seed_two_cajas(exec: &Executor, store: &mut MemoryStore, log: &mut EventLog, a: Uuid, b: Uuid) {
|
||||||
exec: &Executor,
|
|
||||||
store: &mut MemoryStore,
|
|
||||||
log: &mut EventLog,
|
|
||||||
a: Uuid,
|
|
||||||
b: Uuid,
|
|
||||||
) {
|
|
||||||
seed_and_log(
|
seed_and_log(
|
||||||
exec,
|
exec,
|
||||||
store,
|
store,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use nakui_core::store::Store;
|
use nakui_core::store::Store;
|
||||||
use nakui_core::surreal_store::SurrealStore;
|
use nakui_core::surreal_store::SurrealStore;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn fresh_db_path() -> PathBuf {
|
fn fresh_db_path() -> PathBuf {
|
||||||
@@ -39,9 +39,7 @@ fn data_survives_close_and_reopen() {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let store = SurrealStore::new_persistent(&path).expect("reopen persistent");
|
let store = SurrealStore::new_persistent(&path).expect("reopen persistent");
|
||||||
let loaded = store
|
let loaded = store.load("Caja", id).expect("record must survive reopen");
|
||||||
.load("Caja", id)
|
|
||||||
.expect("record must survive reopen");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loaded.get("saldo").and_then(Value::as_i64),
|
loaded.get("saldo").and_then(Value::as_i64),
|
||||||
Some(12_345),
|
Some(12_345),
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use nakui_core::delta::{FieldOp, FieldPath};
|
use nakui_core::delta::{FieldOp, FieldPath};
|
||||||
use nakui_core::event_log::{
|
use nakui_core::event_log::{
|
||||||
EventLog, execute_and_log, reconcile, replay_into, seed_and_log, verify_log,
|
execute_and_log, reconcile, replay_into, seed_and_log, verify_log, EventLog,
|
||||||
};
|
};
|
||||||
use nakui_core::executor::Executor;
|
use nakui_core::executor::Executor;
|
||||||
use nakui_core::store::{MemoryStore, Store, StoreError};
|
use nakui_core::store::{MemoryStore, Store, StoreError};
|
||||||
use nakui_core::surreal_store::SurrealStore;
|
use nakui_core::surreal_store::SurrealStore;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{json, Value};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn workspace_root() -> PathBuf {
|
fn workspace_root() -> PathBuf {
|
||||||
@@ -240,8 +240,24 @@ fn verify_log_against_surreal_passes() {
|
|||||||
|
|
||||||
let a = Uuid::new_v4();
|
let a = Uuid::new_v4();
|
||||||
let b = Uuid::new_v4();
|
let b = Uuid::new_v4();
|
||||||
seed_and_log(&exec, &mut live, &mut log, "Caja", a, caja_data(a, 200_000, "USD")).unwrap();
|
seed_and_log(
|
||||||
seed_and_log(&exec, &mut live, &mut log, "Caja", b, caja_data(b, 50_000, "USD")).unwrap();
|
&exec,
|
||||||
|
&mut live,
|
||||||
|
&mut log,
|
||||||
|
"Caja",
|
||||||
|
a,
|
||||||
|
caja_data(a, 200_000, "USD"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
seed_and_log(
|
||||||
|
&exec,
|
||||||
|
&mut live,
|
||||||
|
&mut log,
|
||||||
|
"Caja",
|
||||||
|
b,
|
||||||
|
caja_data(b, 50_000, "USD"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
execute_and_log(
|
execute_and_log(
|
||||||
&exec,
|
&exec,
|
||||||
&mut live,
|
&mut live,
|
||||||
@@ -432,8 +448,24 @@ fn iter_and_hash_state_round_trip_against_surreal() {
|
|||||||
let mut live = SurrealStore::new_in_memory().expect("live");
|
let mut live = SurrealStore::new_in_memory().expect("live");
|
||||||
let a = Uuid::new_v4();
|
let a = Uuid::new_v4();
|
||||||
let b = Uuid::new_v4();
|
let b = Uuid::new_v4();
|
||||||
seed_and_log(&exec, &mut live, &mut log, "Caja", a, caja_data(a, 200_000, "USD")).unwrap();
|
seed_and_log(
|
||||||
seed_and_log(&exec, &mut live, &mut log, "Caja", b, caja_data(b, 50_000, "USD")).unwrap();
|
&exec,
|
||||||
|
&mut live,
|
||||||
|
&mut log,
|
||||||
|
"Caja",
|
||||||
|
a,
|
||||||
|
caja_data(a, 200_000, "USD"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
seed_and_log(
|
||||||
|
&exec,
|
||||||
|
&mut live,
|
||||||
|
&mut log,
|
||||||
|
"Caja",
|
||||||
|
b,
|
||||||
|
caja_data(b, 50_000, "USD"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
execute_and_log(
|
execute_and_log(
|
||||||
&exec,
|
&exec,
|
||||||
&mut live,
|
&mut live,
|
||||||
@@ -453,12 +485,17 @@ fn iter_and_hash_state_round_trip_against_surreal() {
|
|||||||
// iter must enumerate every record.
|
// iter must enumerate every record.
|
||||||
let recs: Vec<_> = live.iter().expect("iter").collect();
|
let recs: Vec<_> = live.iter().expect("iter").collect();
|
||||||
let by_entity: std::collections::HashMap<&str, usize> =
|
let by_entity: std::collections::HashMap<&str, usize> =
|
||||||
recs.iter().fold(std::collections::HashMap::new(), |mut m, (e, _, _)| {
|
recs.iter()
|
||||||
*m.entry(e.as_str()).or_insert(0) += 1;
|
.fold(std::collections::HashMap::new(), |mut m, (e, _, _)| {
|
||||||
m
|
*m.entry(e.as_str()).or_insert(0) += 1;
|
||||||
});
|
m
|
||||||
|
});
|
||||||
assert_eq!(by_entity.get("Caja").copied(), Some(2), "two Cajas");
|
assert_eq!(by_entity.get("Caja").copied(), Some(2), "two Cajas");
|
||||||
assert_eq!(by_entity.get("Movimiento").copied(), Some(1), "one Movimiento");
|
assert_eq!(
|
||||||
|
by_entity.get("Movimiento").copied(),
|
||||||
|
Some(1),
|
||||||
|
"one Movimiento"
|
||||||
|
);
|
||||||
|
|
||||||
// canonical order: entities sorted, ids byte-sorted within entity.
|
// canonical order: entities sorted, ids byte-sorted within entity.
|
||||||
let entities: Vec<&str> = recs.iter().map(|(e, _, _)| e.as_str()).collect();
|
let entities: Vec<&str> = recs.iter().map(|(e, _, _)| e.as_str()).collect();
|
||||||
@@ -496,7 +533,15 @@ fn reconcile_rebuilds_drifted_surreal_store_from_log() {
|
|||||||
let mut store = SurrealStore::new_in_memory().expect("surreal");
|
let mut store = SurrealStore::new_in_memory().expect("surreal");
|
||||||
|
|
||||||
let a = Uuid::new_v4();
|
let a = Uuid::new_v4();
|
||||||
seed_and_log(&exec, &mut store, &mut log, "Caja", a, caja_data(a, 100_000, "USD")).unwrap();
|
seed_and_log(
|
||||||
|
&exec,
|
||||||
|
&mut store,
|
||||||
|
&mut log,
|
||||||
|
"Caja",
|
||||||
|
a,
|
||||||
|
caja_data(a, 100_000, "USD"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
execute_and_log(
|
execute_and_log(
|
||||||
&exec,
|
&exec,
|
||||||
&mut store,
|
&mut store,
|
||||||
@@ -518,7 +563,9 @@ fn reconcile_rebuilds_drifted_surreal_store_from_log() {
|
|||||||
store.seed("Caja", ghost, caja_data(ghost, 0, "USD"));
|
store.seed("Caja", ghost, caja_data(ghost, 0, "USD"));
|
||||||
store.seed("Caja", a, caja_data(a, 999_999, "USD"));
|
store.seed("Caja", a, caja_data(a, 999_999, "USD"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store.load("Caja", a).and_then(|v| v.get("saldo").and_then(Value::as_i64)),
|
store
|
||||||
|
.load("Caja", a)
|
||||||
|
.and_then(|v| v.get("saldo").and_then(Value::as_i64)),
|
||||||
Some(999_999),
|
Some(999_999),
|
||||||
"drift was applied"
|
"drift was applied"
|
||||||
);
|
);
|
||||||
@@ -528,7 +575,9 @@ fn reconcile_rebuilds_drifted_surreal_store_from_log() {
|
|||||||
// After reconcile: ghost gone, saldo = 100_000 (seed) + 5_000 (deposit).
|
// After reconcile: ghost gone, saldo = 100_000 (seed) + 5_000 (deposit).
|
||||||
assert!(store.load("Caja", ghost).is_none(), "poison record wiped");
|
assert!(store.load("Caja", ghost).is_none(), "poison record wiped");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store.load("Caja", a).and_then(|v| v.get("saldo").and_then(Value::as_i64)),
|
store
|
||||||
|
.load("Caja", a)
|
||||||
|
.and_then(|v| v.get("saldo").and_then(Value::as_i64)),
|
||||||
Some(105_000),
|
Some(105_000),
|
||||||
"reconcile must restore log-canonical saldo"
|
"reconcile must restore log-canonical saldo"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// abrir_oportunidad
|
||||||
|
// Abre una oportunidad de venta para un cliente existente. La etapa
|
||||||
|
// inicial la fija el morfismo ("prospecto"), no el caller — una
|
||||||
|
// oportunidad siempre nace al principio del pipeline.
|
||||||
|
//
|
||||||
|
// ids.cliente: UUID del Cliente (la oportunidad guarda la FK).
|
||||||
|
// params: { oportunidad_id, titulo, monto:i64, currency:str, timestamp:str }
|
||||||
|
|
||||||
|
let opp_id = input.params.oportunidad_id;
|
||||||
|
if type_of(opp_id) == "()" {
|
||||||
|
throw "params.oportunidad_id es obligatorio (idempotencia)"
|
||||||
|
}
|
||||||
|
|
||||||
|
let titulo = input.params.titulo;
|
||||||
|
if type_of(titulo) == "()" {
|
||||||
|
throw "params.titulo es obligatorio"
|
||||||
|
}
|
||||||
|
|
||||||
|
let monto = input.params.monto;
|
||||||
|
if monto <= 0 {
|
||||||
|
throw "monto debe ser positivo"
|
||||||
|
}
|
||||||
|
|
||||||
|
[
|
||||||
|
#{
|
||||||
|
op: "create",
|
||||||
|
entity: "Oportunidad",
|
||||||
|
id: opp_id,
|
||||||
|
data: #{
|
||||||
|
id: opp_id,
|
||||||
|
cliente_id: input.ids.cliente,
|
||||||
|
titulo: titulo,
|
||||||
|
monto: monto,
|
||||||
|
currency: input.params.currency,
|
||||||
|
etapa: "prospecto",
|
||||||
|
timestamp: input.params.timestamp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// mover_oportunidad
|
||||||
|
// Mueve una oportunidad por el pipeline de ventas. La etapa destino la
|
||||||
|
// fija el caller; el morfismo valida que la transición sea legal:
|
||||||
|
// - la etapa destino debe ser conocida,
|
||||||
|
// - una oportunidad cerrada (ganada/perdida) ya no se mueve,
|
||||||
|
// - dentro del pipeline abierto sólo se avanza, no se retrocede;
|
||||||
|
// cerrar (ganada/perdida) es legal desde cualquier etapa abierta.
|
||||||
|
//
|
||||||
|
// states.oportunidad: el registro Oportunidad.
|
||||||
|
// params: { etapa:str, timestamp:str }
|
||||||
|
|
||||||
|
let etapa_actual = input.states.oportunidad.etapa;
|
||||||
|
let etapa_destino = input.params.etapa;
|
||||||
|
|
||||||
|
// Rango de cada etapa en el pipeline (-1 = etapa desconocida).
|
||||||
|
let r_actual = -1;
|
||||||
|
if etapa_actual == "prospecto" { r_actual = 0; }
|
||||||
|
if etapa_actual == "calificado" { r_actual = 1; }
|
||||||
|
if etapa_actual == "propuesta" { r_actual = 2; }
|
||||||
|
if etapa_actual == "negociacion" { r_actual = 3; }
|
||||||
|
if etapa_actual == "ganada" { r_actual = 4; }
|
||||||
|
if etapa_actual == "perdida" { r_actual = 5; }
|
||||||
|
|
||||||
|
let r_destino = -1;
|
||||||
|
if etapa_destino == "prospecto" { r_destino = 0; }
|
||||||
|
if etapa_destino == "calificado" { r_destino = 1; }
|
||||||
|
if etapa_destino == "propuesta" { r_destino = 2; }
|
||||||
|
if etapa_destino == "negociacion" { r_destino = 3; }
|
||||||
|
if etapa_destino == "ganada" { r_destino = 4; }
|
||||||
|
if etapa_destino == "perdida" { r_destino = 5; }
|
||||||
|
|
||||||
|
if r_destino < 0 {
|
||||||
|
throw "etapa destino desconocida: " + etapa_destino
|
||||||
|
}
|
||||||
|
if etapa_actual == "ganada" || etapa_actual == "perdida" {
|
||||||
|
throw "la oportunidad ya está cerrada (etapa " + etapa_actual + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cerrar es legal desde cualquier etapa abierta; dentro del pipeline
|
||||||
|
// abierto sólo se avanza.
|
||||||
|
let cerrando = etapa_destino == "ganada" || etapa_destino == "perdida";
|
||||||
|
if !cerrando {
|
||||||
|
if r_destino <= r_actual {
|
||||||
|
throw "transición inválida " + etapa_actual + " -> " + etapa_destino
|
||||||
|
+ " (no se retrocede)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[
|
||||||
|
#{
|
||||||
|
op: "set",
|
||||||
|
path: #{ entity: "Oportunidad", id: input.ids.oportunidad, field: "etapa" },
|
||||||
|
value: etapa_destino,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// registrar_interaccion
|
||||||
|
// Registra una interacción (llamada, email o reunión) con un cliente.
|
||||||
|
// Crea un registro Interaccion — el historial de contacto del CRM.
|
||||||
|
//
|
||||||
|
// ids.cliente: UUID del Cliente (la interacción guarda la FK).
|
||||||
|
// params: { interaccion_id, canal:str, nota:str, timestamp:str }
|
||||||
|
|
||||||
|
let int_id = input.params.interaccion_id;
|
||||||
|
if type_of(int_id) == "()" {
|
||||||
|
throw "params.interaccion_id es obligatorio (idempotencia)"
|
||||||
|
}
|
||||||
|
|
||||||
|
let canal = input.params.canal;
|
||||||
|
if canal != "llamada" && canal != "email" && canal != "reunion" {
|
||||||
|
throw "canal desconocido: " + canal + " (esperado: llamada | email | reunion)"
|
||||||
|
}
|
||||||
|
|
||||||
|
[
|
||||||
|
#{
|
||||||
|
op: "create",
|
||||||
|
entity: "Interaccion",
|
||||||
|
id: int_id,
|
||||||
|
data: #{
|
||||||
|
id: int_id,
|
||||||
|
cliente_id: input.ids.cliente,
|
||||||
|
canal: canal,
|
||||||
|
nota: input.params.nota,
|
||||||
|
timestamp: input.params.timestamp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"module": "crm",
|
||||||
|
"morphisms": [
|
||||||
|
{
|
||||||
|
"name": "abrir_oportunidad",
|
||||||
|
"inputs": [
|
||||||
|
{ "role": "cliente", "entity": "Cliente" }
|
||||||
|
],
|
||||||
|
"reads": [],
|
||||||
|
"writes": ["Oportunidad"],
|
||||||
|
"depends_on": [],
|
||||||
|
"script": "morphisms/abrir_oportunidad.rhai"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mover_oportunidad",
|
||||||
|
"inputs": [
|
||||||
|
{ "role": "oportunidad", "entity": "Oportunidad" }
|
||||||
|
],
|
||||||
|
"reads": ["oportunidad.etapa"],
|
||||||
|
"writes": ["oportunidad.etapa"],
|
||||||
|
"depends_on": [],
|
||||||
|
"script": "morphisms/mover_oportunidad.rhai"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "registrar_interaccion",
|
||||||
|
"inputs": [
|
||||||
|
{ "role": "cliente", "entity": "Cliente" }
|
||||||
|
],
|
||||||
|
"reads": [],
|
||||||
|
"writes": ["Interaccion"],
|
||||||
|
"depends_on": [],
|
||||||
|
"script": "morphisms/registrar_interaccion.rhai"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Schema Nickel para el módulo `crm` (Cliente + Oportunidad + Interaccion).
|
||||||
|
#
|
||||||
|
# Un CRM: clientes, oportunidades de venta que recorren un pipeline e
|
||||||
|
# interacciones registradas. El kernel de Nakui valida cada entity
|
||||||
|
# contra el contract de su record — antes y después de cada morfismo.
|
||||||
|
|
||||||
|
let non_empty_string = std.contract.from_predicate (fun s =>
|
||||||
|
std.is_string s && std.string.length s > 0
|
||||||
|
) in
|
||||||
|
|
||||||
|
let positive_int = std.contract.from_predicate (fun n =>
|
||||||
|
std.is_number n && n > 0
|
||||||
|
) in
|
||||||
|
|
||||||
|
let currency_iso = std.contract.from_predicate (fun s =>
|
||||||
|
std.is_string s && std.string.length s == 3
|
||||||
|
) in
|
||||||
|
|
||||||
|
# Etapas del pipeline de ventas. `ganada` y `perdida` son terminales.
|
||||||
|
let etapa_pipeline = std.contract.from_predicate (fun s =>
|
||||||
|
std.is_string s
|
||||||
|
&& std.array.elem s
|
||||||
|
["prospecto", "calificado", "propuesta", "negociacion", "ganada", "perdida"]
|
||||||
|
) in
|
||||||
|
|
||||||
|
let canal_interaccion = std.contract.from_predicate (fun s =>
|
||||||
|
std.is_string s && std.array.elem s ["llamada", "email", "reunion"]
|
||||||
|
) in
|
||||||
|
|
||||||
|
{
|
||||||
|
Cliente = {
|
||||||
|
id | String,
|
||||||
|
nombre | non_empty_string,
|
||||||
|
email | non_empty_string,
|
||||||
|
empresa | String,
|
||||||
|
},
|
||||||
|
|
||||||
|
Oportunidad = {
|
||||||
|
id | String,
|
||||||
|
cliente_id | String,
|
||||||
|
titulo | non_empty_string,
|
||||||
|
monto | positive_int,
|
||||||
|
currency | currency_iso,
|
||||||
|
etapa | etapa_pipeline,
|
||||||
|
timestamp | String,
|
||||||
|
},
|
||||||
|
|
||||||
|
Interaccion = {
|
||||||
|
id | String,
|
||||||
|
cliente_id | String,
|
||||||
|
canal | canal_interaccion,
|
||||||
|
nota | String,
|
||||||
|
timestamp | String,
|
||||||
|
},
|
||||||
|
}
|
||||||
+25
-1
@@ -2,7 +2,31 @@
|
|||||||
|
|
||||||
ERP categórico.
|
ERP categórico.
|
||||||
|
|
||||||
### fix(nakui-core): schema_bundle_hash debe reflejar el contenido real del schema
|
### feat(nakui): módulo `crm` — clientes, pipeline de ventas, interacciones
|
||||||
|
|
||||||
|
Módulo CRM funcional, declarativo como inventory/sales/treasury
|
||||||
|
(`crates/modules/nakui/modules/crm/`): `schema.ncl` + `nsmc.json` +
|
||||||
|
morfismos Rhai. Tres entities — `Cliente`, `Oportunidad`, `Interaccion`
|
||||||
|
— y tres morfismos:
|
||||||
|
|
||||||
|
- `abrir_oportunidad(cliente)` — crea una `Oportunidad` en etapa
|
||||||
|
`prospecto`.
|
||||||
|
- `mover_oportunidad(oportunidad)` — avanza el pipeline
|
||||||
|
(prospecto→calificado→propuesta→negociacion→ganada/perdida). Valida:
|
||||||
|
etapa destino conocida, no mover una oportunidad cerrada, no retroceder.
|
||||||
|
- `registrar_interaccion(cliente)` — crea una `Interaccion`
|
||||||
|
(llamada/email/reunión).
|
||||||
|
|
||||||
|
- `core/src/bin/crm_demo.rs` — demo realista (3 clientes, 18 eventos).
|
||||||
|
A diferencia de los otros demos **no borra el event log**: lo deja en
|
||||||
|
disco e imprime el comando para abrirlo con `nakui-explorer` — así el
|
||||||
|
explorador, que sólo mostraba un log vacío, muestra un CRM con cuerpo.
|
||||||
|
- `core/tests/crm.rs` — 8 tests de integración (pipeline completo +
|
||||||
|
rechazos: retroceso, oportunidad cerrada, etapa/canal/monto inválidos).
|
||||||
|
|
||||||
|
Nota: el engine Rhai de `nakui-core` es `Engine::new_raw()` con paquetes
|
||||||
|
selectos — `Array::index_of` no resuelve; los morfismos usan sólo
|
||||||
|
`if`/`==`/`throw`.
|
||||||
Iter 17. Regresión surfaceada por el workspace test
|
Iter 17. Regresión surfaceada por el workspace test
|
||||||
`verify_log_rejects_seed_after_schema_kcl_changes` (rebautizado a
|
`verify_log_rejects_seed_after_schema_kcl_changes` (rebautizado a
|
||||||
`verify_log_rejects_seed_after_schema_changes`).
|
`verify_log_rejects_seed_after_schema_changes`).
|
||||||
|
|||||||
Reference in New Issue
Block a user