chore: absorbe nakui (ERP matemático) en modules/nakui

- crates/modules/nakui/core/: el crate nakui-core (4 bins, tests).
  Deps directas (serde, rhai, surrealdb, petgraph, sha2, uuid, tokio,
  thiserror v1) — no convertidas a workspace = true en esta pasada.
- crates/modules/nakui/modules/{inventory,sales,treasury}/: datos
  declarativos del dominio (nsmc.json, schema.k, morphisms/) que el
  crate consume — no son crates.

cargo check -p nakui-core: 0 errores.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-08 05:49:58 +00:00
parent 53dbdf0f1d
commit 4d50bfc587
49 changed files with 11953 additions and 40 deletions
+224
View File
@@ -0,0 +1,224 @@
use nakui_core::event_log::{
EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log,
};
use nakui_core::executor::Executor;
use nakui_core::store::{MemoryStore, Store};
use serde_json::json;
use uuid::Uuid;
fn main() {
let module_dir =
std::env::var("NAKUI_MODULE").unwrap_or_else(|_| "modules/treasury".into());
let exec = Executor::load_module(&module_dir).expect("load module");
let log_path = std::env::temp_dir().join(format!("nakui_demo_{}.jsonl", Uuid::new_v4()));
let mut log = EventLog::open(&log_path).expect("open log");
let mut store = MemoryStore::new();
let caja_a = Uuid::new_v4();
let caja_b = Uuid::new_v4();
let caja_c = Uuid::new_v4();
seed_and_log(
&exec,
&mut store,
&mut log,
"Caja",
caja_a,
json!({
"id": caja_a.to_string(),
"name": "Caja Principal",
"saldo": 200_000_i64,
"currency": "USD",
}),
)
.expect("seed A");
seed_and_log(
&exec,
&mut store,
&mut log,
"Caja",
caja_b,
json!({
"id": caja_b.to_string(),
"name": "Caja Chica",
"saldo": 50_000_i64,
"currency": "USD",
}),
)
.expect("seed B");
seed_and_log(
&exec,
&mut store,
&mut log,
"Caja",
caja_c,
json!({
"id": caja_c.to_string(),
"name": "Caja EUR",
"saldo": 30_000_i64,
"currency": "EUR",
}),
)
.expect("seed C");
section("== seed ==");
print_caja(&store, "A", caja_a);
print_caja(&store, "B", caja_b);
print_caja(&store, "C", caja_c);
section("== A: deposit 50_000 USD ==");
run_and_report(
&exec,
&mut store,
&mut log,
"register_cash_move",
&[("caja", caja_a)],
json!({
"monto": 50_000_i64,
"tipo": "in",
"timestamp": "2026-05-04T12:00:00Z",
"memo": "deposito A",
"movimiento_id": Uuid::new_v4().to_string(),
}),
);
print_caja(&store, "A", caja_a);
section("== transfer A -> B 100_000 USD ==");
run_and_report(
&exec,
&mut store,
&mut log,
"transfer_between_cajas",
&[("source", caja_a), ("dest", caja_b)],
json!({
"monto": 100_000_i64,
"timestamp": "2026-05-04T12:30:00Z",
"memo": "transferencia operativa",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
print_caja(&store, "A", caja_a);
print_caja(&store, "B", caja_b);
section("== transfer A -> B 999_999_999 USD (reject: post-check on source) ==");
run_and_report(
&exec,
&mut store,
&mut log,
"transfer_between_cajas",
&[("source", caja_a), ("dest", caja_b)],
json!({
"monto": 999_999_999_i64,
"timestamp": "2026-05-04T13:00:00Z",
"memo": "overdraw",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
section("== transfer A(USD) -> C(EUR) (reject: rhai throws) ==");
run_and_report(
&exec,
&mut store,
&mut log,
"transfer_between_cajas",
&[("source", caja_a), ("dest", caja_c)],
json!({
"monto": 10_000_i64,
"timestamp": "2026-05-04T14:00:00Z",
"memo": "USD -> EUR",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
section("== self-transfer A -> A (reject: DuplicateInputId) ==");
run_and_report(
&exec,
&mut store,
&mut log,
"transfer_between_cajas",
&[("source", caja_a), ("dest", caja_a)],
json!({
"monto": 1_000_i64,
"timestamp": "2026-05-04T15:00:00Z",
"memo": "self",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
section("== final live state ==");
print_caja(&store, "A", caja_a);
print_caja(&store, "B", caja_b);
print_caja(&store, "C", caja_c);
let entries = log.entries().expect("read log");
section(&format!(
"== log: {} entries at {} ==",
entries.len(),
log.path().display()
));
for e in &entries {
match e {
nakui_core::event_log::LogEntry::Seed {
seq, entity, id, ..
} => println!(" #{:02} seed {} {}", seq, entity, id),
nakui_core::event_log::LogEntry::Morphism {
seq,
morphism,
ops,
..
} => println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()),
}
}
section("== replay verification (state) ==");
let replayed = replay(&log).expect("replay");
if store == replayed {
println!(" ok: replayed store byte-equal to live store");
} else {
println!(" MISMATCH: replay diverges from live");
}
section("== determinism verification (ops) ==");
match verify_log(&log, &exec) {
Ok(()) => println!(
" ok: every logged morphism reproduced its ops on re-execution"
),
Err(e) => println!(" nondeterminism detected: {}", e),
}
if std::env::var_os("NAKUI_DEMO_KEEP").is_none() {
let _ = std::fs::remove_file(&log_path);
} else {
println!("\n(NAKUI_DEMO_KEEP set — keeping log at {})", log_path.display());
}
}
fn run_and_report(
exec: &Executor,
store: &mut MemoryStore,
log: &mut EventLog,
morphism: &str,
inputs: &[(&str, Uuid)],
params: serde_json::Value,
) {
match execute_and_log(exec, store, log, morphism, inputs, params) {
Ok(ops) => println!(" ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1),
Err(ExecuteError::PreLog(e)) => println!(" rejected: {}", e),
Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e),
Err(ExecuteError::PostLogStore(e)) => println!(
" POST-LOG STORE FAILED (log is canonical, store stale): {}",
e
),
}
}
fn print_caja(store: &MemoryStore, label: &str, id: Uuid) {
let v = store.load("Caja", id).expect("caja exists");
let saldo = v.get("saldo").and_then(|v| v.as_i64()).unwrap_or(0);
let currency = v.get("currency").and_then(|v| v.as_str()).unwrap_or("?");
println!(" {} {}: saldo={} {}", label, id, saldo, currency);
}
fn section(title: &str) {
println!("\n{}", title);
}
@@ -0,0 +1,170 @@
use nakui_core::event_log::{
EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log,
};
use nakui_core::executor::Executor;
use nakui_core::store::{MemoryStore, Store};
use serde_json::json;
use uuid::Uuid;
fn main() {
let module_dir = std::env::var("NAKUI_MODULE")
.unwrap_or_else(|_| "modules/inventory".into());
let exec = Executor::load_module(&module_dir).expect("load module");
let log_path =
std::env::temp_dir().join(format!("nakui_inv_{}.jsonl", Uuid::new_v4()));
let mut log = EventLog::open(&log_path).expect("open log");
let mut store = MemoryStore::new();
// Two stocks of SKU "kg-cafe-honduras-2026" at warehouses A and B,
// plus a third stock of SKU "lt-aceite-girasol" at warehouse C.
let stock_a = Uuid::new_v4();
let stock_b = Uuid::new_v4();
let stock_c = Uuid::new_v4();
seed_and_log(
&exec,
&mut store, &mut log, "Stock", stock_a,
json!({
"id": stock_a.to_string(),
"sku_id": "kg-cafe-honduras-2026",
"ubicacion": "almacen-norte",
"cantidad": 500_i64,
}),
).expect("seed A");
seed_and_log(
&exec,
&mut store, &mut log, "Stock", stock_b,
json!({
"id": stock_b.to_string(),
"sku_id": "kg-cafe-honduras-2026",
"ubicacion": "almacen-sur",
"cantidad": 100_i64,
}),
).expect("seed B");
seed_and_log(
&exec,
&mut store, &mut log, "Stock", stock_c,
json!({
"id": stock_c.to_string(),
"sku_id": "lt-aceite-girasol",
"ubicacion": "almacen-sur",
"cantidad": 200_i64,
}),
).expect("seed C");
section("== seed ==");
print_stock(&store, "A (cafe norte)", stock_a);
print_stock(&store, "B (cafe sur)", stock_b);
print_stock(&store, "C (aceite sur)", stock_c);
section("== recibir 250 kg cafe en A ==");
run_and_report(&exec, &mut store, &mut log, "recibir_stock",
&[("stock", stock_a)],
json!({
"cantidad": 250_i64,
"timestamp": "2026-05-04T08:00:00Z",
"movimiento_id": Uuid::new_v4().to_string(),
}),
);
print_stock(&store, "A", stock_a);
section("== transferir 200 kg cafe A -> B (conserva por sku_id) ==");
run_and_report(&exec, &mut store, &mut log, "transferir_stock",
&[("source", stock_a), ("dest", stock_b)],
json!({
"cantidad": 200_i64,
"timestamp": "2026-05-04T09:00:00Z",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
print_stock(&store, "A", stock_a);
print_stock(&store, "B", stock_b);
section("== transferir 999_999 kg cafe A -> B (reject: stock <= 0) ==");
run_and_report(&exec, &mut store, &mut log, "transferir_stock",
&[("source", stock_a), ("dest", stock_b)],
json!({
"cantidad": 999_999_i64,
"timestamp": "2026-05-04T10:00:00Z",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
section("== transferir 50 cafe(A) -> aceite(C) (reject: rhai SKU mismatch) ==");
run_and_report(&exec, &mut store, &mut log, "transferir_stock",
&[("source", stock_a), ("dest", stock_c)],
json!({
"cantidad": 50_i64,
"timestamp": "2026-05-04T11:00:00Z",
"transfer_id": Uuid::new_v4().to_string(),
}),
);
section("== final live state ==");
print_stock(&store, "A", stock_a);
print_stock(&store, "B", stock_b);
print_stock(&store, "C", stock_c);
let entries = log.entries().expect("read log");
section(&format!(
"== log: {} entries at {} ==",
entries.len(),
log.path().display()
));
for e in &entries {
match e {
nakui_core::event_log::LogEntry::Seed { seq, entity, id, .. } =>
println!(" #{:02} seed {} {}", seq, entity, id),
nakui_core::event_log::LogEntry::Morphism { seq, morphism, ops, .. } =>
println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()),
}
}
section("== replay verification (state) ==");
let replayed = replay(&log).expect("replay");
if store == replayed {
println!(" ok: replayed store byte-equal to live store");
} else {
println!(" MISMATCH");
}
section("== determinism verification (ops) ==");
match verify_log(&log, &exec) {
Ok(()) => println!(
" ok: every logged morphism reproduced its ops on re-execution"
),
Err(e) => println!(" nondeterminism detected: {}", e),
}
let _ = std::fs::remove_file(&log_path);
}
fn run_and_report(
exec: &Executor,
store: &mut MemoryStore,
log: &mut EventLog,
morphism: &str,
inputs: &[(&str, Uuid)],
params: serde_json::Value,
) {
match execute_and_log(exec, store, log, morphism, inputs, params) {
Ok(ops) => println!(" ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1),
Err(ExecuteError::PreLog(e)) => println!(" rejected: {}", e),
Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e),
Err(ExecuteError::PostLogStore(e)) => println!(
" POST-LOG STORE FAILED (log canonical, store stale): {}", e
),
}
}
fn print_stock(store: &MemoryStore, label: &str, id: Uuid) {
let v = store.load("Stock", id).expect("stock exists");
let cantidad = v.get("cantidad").and_then(|v| v.as_i64()).unwrap_or(0);
let sku = v.get("sku_id").and_then(|v| v.as_str()).unwrap_or("?");
let loc = v.get("ubicacion").and_then(|v| v.as_str()).unwrap_or("?");
println!(" {}: cantidad={} sku={} ubic={}", label, cantidad, sku, loc);
}
fn section(title: &str) {
println!("\n{}", title);
}
+455
View File
@@ -0,0 +1,455 @@
//! `nakui` — operator CLI for inspecting, replaying, and verifying an
//! event log produced by the kernel. The three subcommands map to the
//! three things you need when something goes sideways in production:
//!
//! - `inspect` — what's in the log? (audit trail)
//! - `replay` — what state does the log produce? (recovery dry-run)
//! - `verify-log` — does every morphism still reproduce its ops?
//! (determinism contract — the regression alarm)
//!
//! Exit codes: 0 on success, 1 on operational error, 2 on bad arguments.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::process::ExitCode;
use nakui_core::drift::{DriftDiff, check_against_socket};
use nakui_core::event_log::{
EventLog, LogEntry, Snapshot, replay_with_snapshot_into, verify_log,
};
use nakui_core::executor::Executor;
use nakui_core::run::run_server;
use nakui_core::store::MemoryStore;
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
let prog = args.first().cloned().unwrap_or_else(|| "nakui".into());
let sub = match args.get(1).map(String::as_str) {
Some(s) => s,
None => {
print_usage(&prog);
return ExitCode::from(2);
}
};
let rest = &args[2..];
let result = match sub {
"inspect" => cmd_inspect(rest),
"replay" => cmd_replay(rest),
"verify-log" => cmd_verify_log(rest),
"run" => cmd_run(rest),
"drift" => cmd_drift(rest),
"snapshot" => cmd_snapshot(rest),
"compact" => cmd_compact(rest),
"-h" | "--help" | "help" => {
print_usage(&prog);
return ExitCode::SUCCESS;
}
other => {
eprintln!("nakui: unknown subcommand `{}`", other);
print_usage(&prog);
return ExitCode::from(2);
}
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(CliError::BadArgs(msg)) => {
eprintln!("nakui: {}", msg);
print_usage(&prog);
ExitCode::from(2)
}
Err(CliError::Op(msg)) => {
eprintln!("nakui: {}", msg);
ExitCode::from(1)
}
// Drift uses its own exit code so callers can distinguish "the
// tool failed" (1) from "the tool worked and detected drift" (3).
Err(CliError::DriftDetected) => ExitCode::from(3),
}
}
enum CliError {
BadArgs(String),
Op(String),
DriftDetected,
}
fn print_usage(prog: &str) {
eprintln!(
"usage:
{p} inspect --log <path>
{p} replay --log <path> [--snapshot <path>]
{p} verify-log --log <path> --module <dir>
{p} run --log <path> --module <dir> --socket <path>
[--snapshot <path>] [--store-path <dir>]
{p} drift --log <path> --against <socket>
{p} snapshot --log <path> --module <dir> --out <path>
{p} compact --log <path> --snapshot <path>
--store-path activates persistent SurrealStore (kv-surrealkv);
requires the binary to be built with `--features persistent`.",
p = prog
);
}
/// Minimal flag parser: `--name value` pairs, no `=` form, no clustering.
/// Returns a map of name -> value. Unknown flags are an error so typos
/// surface immediately instead of silently being ignored.
fn parse_flags(args: &[String], allowed: &[&str]) -> Result<BTreeMap<String, String>, CliError> {
let mut out = BTreeMap::new();
let mut i = 0;
while i < args.len() {
let flag = &args[i];
if !flag.starts_with("--") {
return Err(CliError::BadArgs(format!(
"expected --flag, got `{}`",
flag
)));
}
let name = &flag[2..];
if !allowed.contains(&name) {
return Err(CliError::BadArgs(format!("unknown flag `--{}`", name)));
}
let val = args.get(i + 1).ok_or_else(|| {
CliError::BadArgs(format!("flag `--{}` requires a value", name))
})?;
out.insert(name.to_string(), val.clone());
i += 2;
}
Ok(out)
}
fn require<'a>(
flags: &'a BTreeMap<String, String>,
name: &str,
) -> Result<&'a String, CliError> {
flags
.get(name)
.ok_or_else(|| CliError::BadArgs(format!("missing required flag `--{}`", name)))
}
fn cmd_inspect(args: &[String]) -> Result<(), CliError> {
let flags = parse_flags(args, &["log"])?;
let log_path = PathBuf::from(require(&flags, "log")?);
let log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?;
let entries = log
.entries()
.map_err(|e| CliError::Op(format!("read log: {}", e)))?;
println!("log: {}", log.path().display());
println!("entries: {}", entries.len());
if entries.is_empty() {
return Ok(());
}
println!("seq range: {}..={}", entries[0].seq(), entries.last().unwrap().seq());
println!();
for e in &entries {
match e {
LogEntry::Seed {
seq, entity, id, ..
} => println!(" #{:04} seed {} {}", seq, entity, id),
LogEntry::Morphism {
seq,
morphism,
ops,
inputs,
..
} => {
let inputs_s = inputs
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join(", ");
println!(
" #{:04} morph {} ({} ops) [{}]",
seq,
morphism,
ops.len(),
inputs_s
);
}
}
}
Ok(())
}
fn cmd_replay(args: &[String]) -> Result<(), CliError> {
let flags = parse_flags(args, &["log", "snapshot"])?;
let log_path = PathBuf::from(require(&flags, "log")?);
let log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?;
let snapshot = if let Some(p) = flags.get("snapshot") {
let path = PathBuf::from(p);
Snapshot::load(&path)
.map_err(|e| CliError::Op(format!("load snapshot: {}", e)))?
.ok_or_else(|| CliError::Op(format!("snapshot not found: {}", path.display())))?
.into()
} else {
None::<Snapshot>
};
let mut store = MemoryStore::new();
replay_with_snapshot_into(&log, snapshot.as_ref(), &mut store)
.map_err(|e| CliError::Op(format!("replay: {}", e)))?;
let entries = log
.entries()
.map_err(|e| CliError::Op(format!("read log: {}", e)))?;
let last_seq = entries.last().map(|e| e.seq().to_string()).unwrap_or_else(|| "<empty>".into());
println!("replayed log: {}", log.path().display());
if let Some(snap) = &snapshot {
println!("snapshot: seq {} (covers seq <= {})", snap.seq, snap.seq);
}
println!("last seq: {}", last_seq);
println!("entities:");
let mut by_entity: Vec<(&String, usize)> = store
.records()
.iter()
.map(|(k, v)| (k, v.len()))
.collect();
by_entity.sort_by(|a, b| a.0.cmp(b.0));
if by_entity.is_empty() {
println!(" (none)");
} else {
for (entity, count) in by_entity {
println!(" {:<20} {}", entity, count);
}
}
Ok(())
}
fn cmd_drift(args: &[String]) -> Result<(), CliError> {
let flags = parse_flags(args, &["log", "against"])?;
let log_path = PathBuf::from(require(&flags, "log")?);
let socket_path = PathBuf::from(require(&flags, "against")?);
let report = check_against_socket(&log_path, &socket_path)
.map_err(|e| CliError::Op(format!("drift check: {}", e)))?;
let log_hex = hex_encode(&report.log_hash);
let server_hex = hex_encode(&report.server_hash);
if report.in_sync() {
println!(
"ok: in sync (hash {}, {} records)",
short_hash(&log_hex),
report.log_records
);
return Ok(());
}
println!("DRIFT detected");
println!(
" log replay: hash {} ({} records)",
log_hex, report.log_records
);
println!(
" server state: hash {} ({} records)",
server_hex, report.server_records
);
println!();
println!("diffs:");
for d in &report.diffs {
match d {
DriftDiff::OnlyOnServer { entity, id, .. } => {
println!(" + {} {} (only on server)", entity, id);
}
DriftDiff::OnlyInLog { entity, id, .. } => {
println!(" - {} {} (only in log replay)", entity, id);
}
DriftDiff::Tampered {
entity,
id,
log_value,
server_value,
} => {
println!(
" ~ {} {} (tampered)\n log: {}\n server: {}",
entity, id, log_value, server_value
);
}
}
}
Err(CliError::DriftDetected)
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
fn short_hash(hex: &str) -> String {
if hex.len() <= 12 {
hex.to_string()
} else {
format!("{}{}", &hex[..6], &hex[hex.len() - 4..])
}
}
fn cmd_run(args: &[String]) -> Result<(), CliError> {
let flags = parse_flags(args, &["log", "module", "socket", "snapshot", "store-path"])?;
let log_path = PathBuf::from(require(&flags, "log")?);
let module_dir = PathBuf::from(require(&flags, "module")?);
let socket_path = PathBuf::from(require(&flags, "socket")?);
let snapshot_path = flags.get("snapshot").map(PathBuf::from);
let store_path = flags.get("store-path").map(PathBuf::from);
eprintln!(
"nakui run: module={} log={} socket={} snapshot={} store={}",
module_dir.display(),
log_path.display(),
socket_path.display(),
snapshot_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<none>".into()),
store_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<memory>".into()),
);
let executor = Executor::load_module(&module_dir)
.map_err(|e| CliError::Op(format!("load module {}: {}", module_dir.display(), e)))?;
let log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?;
let snapshot = match &snapshot_path {
Some(p) => Some(
Snapshot::load(p)
.map_err(|e| CliError::Op(format!("load snapshot: {}", e)))?
.ok_or_else(|| {
CliError::Op(format!("snapshot file does not exist: {}", p.display()))
})?,
),
None => None,
};
if let Some(p) = store_path {
run_persistent(executor, log, snapshot, &socket_path, &p)
} else {
let store = MemoryStore::new();
run_server(executor, log, store, snapshot, &socket_path)
.map_err(|e| CliError::Op(format!("run: {}", e)))
}
}
#[cfg(feature = "persistent")]
fn run_persistent(
executor: Executor,
log: EventLog,
snapshot: Option<Snapshot>,
socket_path: &std::path::Path,
store_path: &std::path::Path,
) -> Result<(), CliError> {
use nakui_core::surreal_store::SurrealStore;
let store = SurrealStore::new_persistent(store_path).map_err(|e| {
CliError::Op(format!(
"open persistent store at {}: {}",
store_path.display(),
e
))
})?;
run_server(executor, log, store, snapshot, socket_path)
.map_err(|e| CliError::Op(format!("run: {}", e)))
}
#[cfg(not(feature = "persistent"))]
fn run_persistent(
_executor: Executor,
_log: EventLog,
_snapshot: Option<Snapshot>,
_socket_path: &std::path::Path,
_store_path: &std::path::Path,
) -> Result<(), CliError> {
Err(CliError::Op(
"--store-path requires building with `--features persistent`".into(),
))
}
fn cmd_snapshot(args: &[String]) -> Result<(), CliError> {
let flags = parse_flags(args, &["log", "module", "out"])?;
let log_path = PathBuf::from(require(&flags, "log")?);
let module_dir = PathBuf::from(require(&flags, "module")?);
let out_path = PathBuf::from(require(&flags, "out")?);
let exec = Executor::load_module(&module_dir)
.map_err(|e| CliError::Op(format!("load module {}: {}", module_dir.display(), e)))?;
let log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?;
let mut store = MemoryStore::new();
replay_with_snapshot_into(&log, None, &mut store)
.map_err(|e| CliError::Op(format!("replay: {}", e)))?;
let last_seq = log
.entries()
.map_err(|e| CliError::Op(format!("read log: {}", e)))?
.last()
.map(|e| e.seq())
.ok_or_else(|| CliError::Op("log is empty; nothing to snapshot".into()))?;
let snap = Snapshot::capture(&store, last_seq, &exec);
snap.write(&out_path)
.map_err(|e| CliError::Op(format!("write snapshot: {}", e)))?;
let entity_count: usize = store.records().values().map(|m| m.len()).sum();
println!(
"snapshot written to {} (seq {}, {} records, schema {})",
out_path.display(),
last_seq,
entity_count,
short_hash(&hex_encode(&exec.module_schema_hash())),
);
Ok(())
}
fn cmd_compact(args: &[String]) -> Result<(), CliError> {
let flags = parse_flags(args, &["log", "snapshot"])?;
let log_path = PathBuf::from(require(&flags, "log")?);
let snap_path = PathBuf::from(require(&flags, "snapshot")?);
let snap = Snapshot::load(&snap_path)
.map_err(|e| CliError::Op(format!("load snapshot: {}", e)))?
.ok_or_else(|| CliError::Op(format!("snapshot not found: {}", snap_path.display())))?;
let mut log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?;
let before = log
.entries()
.map(|es| es.len())
.map_err(|e| CliError::Op(format!("read log: {}", e)))?;
log.compact_through(snap.seq)
.map_err(|e| CliError::Op(format!("compact: {}", e)))?;
let after = log
.entries()
.map(|es| es.len())
.map_err(|e| CliError::Op(format!("read log: {}", e)))?;
println!(
"compacted {} through seq {} ({}{} entries; {} dropped)",
log_path.display(),
snap.seq,
before,
after,
before.saturating_sub(after),
);
Ok(())
}
fn cmd_verify_log(args: &[String]) -> Result<(), CliError> {
let flags = parse_flags(args, &["log", "module"])?;
let log_path = PathBuf::from(require(&flags, "log")?);
let module_dir = PathBuf::from(require(&flags, "module")?);
let exec = Executor::load_module(&module_dir)
.map_err(|e| CliError::Op(format!("load module {}: {}", module_dir.display(), e)))?;
let log = EventLog::open(&log_path).map_err(|e| CliError::Op(format!("open log: {}", e)))?;
match verify_log(&log, &exec) {
Ok(()) => {
let n = log
.entries()
.map(|es| es.len())
.map_err(|e| CliError::Op(format!("read log: {}", e)))?;
println!("ok: {} entries; every morphism reproduced its ops", n);
Ok(())
}
Err(e) => Err(CliError::Op(format!("verify failed: {}", e))),
}
}
@@ -0,0 +1,176 @@
//! Cross-module demo: a `vender` morphism that touches a Stock entity
//! (defined in inventory's schema) and a Caja entity (defined in
//! treasury's schema). The sales module's `nsmc.json` lists three schema
//! files; the executor concatenates them at load time so KCL validates
//! against all three.
use nakui_core::event_log::{
EventLog, ExecuteError, execute_and_log, replay, seed_and_log, verify_log,
};
use nakui_core::executor::Executor;
use nakui_core::store::{MemoryStore, Store};
use serde_json::json;
use uuid::Uuid;
fn main() {
let module_dir = std::env::var("NAKUI_MODULE")
.unwrap_or_else(|_| "modules/sales".into());
let exec = Executor::load_module(&module_dir).expect("load module");
let log_path =
std::env::temp_dir().join(format!("nakui_sales_{}.jsonl", Uuid::new_v4()));
let mut log = EventLog::open(&log_path).expect("open log");
let mut store = MemoryStore::new();
let stock_id = Uuid::new_v4();
let caja_id = Uuid::new_v4();
seed_and_log(
&exec,
&mut store, &mut log, "Stock", stock_id,
json!({
"id": stock_id.to_string(),
"sku_id": "kg-cafe-honduras-2026",
"ubicacion": "almacen-norte",
"cantidad": 500_i64,
}),
).expect("seed stock");
seed_and_log(
&exec,
&mut store, &mut log, "Caja", caja_id,
json!({
"id": caja_id.to_string(),
"name": "Caja Principal",
"saldo": 1_000_000_i64, // $10_000.00 in cents
"currency": "USD",
}),
).expect("seed caja");
section("== seed ==");
print_stock(&store, "stock", stock_id);
print_caja(&store, "caja", caja_id);
// 1. Sell 100 kg cafe at $50.00 / kg = $5000.00 total.
section("== vender 100 kg @ $50.00 c/u ==");
run_and_report(&exec, &mut store, &mut log, "vender",
&[("stock", stock_id), ("caja", caja_id)],
json!({
"cantidad": 100_i64,
"precio_unitario": 5_000_i64, // $50.00 in cents
"timestamp": "2026-05-04T10:00:00Z",
"venta_id": Uuid::new_v4().to_string(),
}),
);
print_stock(&store, "stock", stock_id);
print_caja(&store, "caja", caja_id);
// 2. Try selling more than available stock — should fail Stock post-check.
section("== vender 9999 kg (reject: stock <= 0) ==");
run_and_report(&exec, &mut store, &mut log, "vender",
&[("stock", stock_id), ("caja", caja_id)],
json!({
"cantidad": 9999_i64,
"precio_unitario": 1_000_i64,
"timestamp": "2026-05-04T11:00:00Z",
"venta_id": Uuid::new_v4().to_string(),
}),
);
// 3. Negative price — caught by Rhai.
section("== vender con precio negativo (reject: rhai throw) ==");
run_and_report(&exec, &mut store, &mut log, "vender",
&[("stock", stock_id), ("caja", caja_id)],
json!({
"cantidad": 10_i64,
"precio_unitario": -100_i64,
"timestamp": "2026-05-04T11:30:00Z",
"venta_id": Uuid::new_v4().to_string(),
}),
);
// 4. Another good sale.
section("== vender 50 kg @ $60.00 c/u ==");
run_and_report(&exec, &mut store, &mut log, "vender",
&[("stock", stock_id), ("caja", caja_id)],
json!({
"cantidad": 50_i64,
"precio_unitario": 6_000_i64,
"timestamp": "2026-05-04T12:00:00Z",
"venta_id": Uuid::new_v4().to_string(),
}),
);
print_stock(&store, "stock", stock_id);
print_caja(&store, "caja", caja_id);
section("== final live state ==");
print_stock(&store, "stock", stock_id);
print_caja(&store, "caja", caja_id);
let entries = log.entries().expect("read log");
section(&format!(
"== log: {} entries at {} ==",
entries.len(),
log.path().display()
));
for e in &entries {
match e {
nakui_core::event_log::LogEntry::Seed { seq, entity, id, .. } =>
println!(" #{:02} seed {} {}", seq, entity, id),
nakui_core::event_log::LogEntry::Morphism { seq, morphism, ops, .. } =>
println!(" #{:02} morph {} ({} ops)", seq, morphism, ops.len()),
}
}
section("== replay verification (state) ==");
let replayed = replay(&log).expect("replay");
if store == replayed {
println!(" ok: replayed store byte-equal to live store");
} else {
println!(" MISMATCH");
}
section("== determinism verification (ops) ==");
match verify_log(&log, &exec) {
Ok(()) => println!(
" ok: every logged morphism reproduced its ops on re-execution"
),
Err(e) => println!(" nondeterminism detected: {}", e),
}
let _ = std::fs::remove_file(&log_path);
}
fn run_and_report(
exec: &Executor,
store: &mut MemoryStore,
log: &mut EventLog,
morphism: &str,
inputs: &[(&str, Uuid)],
params: serde_json::Value,
) {
match execute_and_log(exec, store, log, morphism, inputs, params) {
Ok(ops) => println!(" ok ({} ops, logged at #{})", ops.len(), log.next_seq() - 1),
Err(ExecuteError::PreLog(e)) => println!(" rejected: {}", e),
Err(ExecuteError::LogAppend(e)) => println!(" LOG APPEND FAILED: {}", e),
Err(ExecuteError::PostLogStore(e)) => println!(
" POST-LOG STORE FAILED (log canonical, store stale): {}", e
),
}
}
fn print_stock(store: &MemoryStore, label: &str, id: Uuid) {
let v = store.load("Stock", id).expect("stock exists");
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("?");
println!(" {} cantidad={} sku={}", label, cantidad, sku);
}
fn print_caja(store: &MemoryStore, label: &str, id: Uuid) {
let v = store.load("Caja", id).expect("caja exists");
let saldo = v.get("saldo").and_then(|v| v.as_i64()).unwrap_or(0);
let cur = v.get("currency").and_then(|v| v.as_str()).unwrap_or("?");
println!(" {} saldo={} {} (en centavos)", label, saldo, cur);
}
fn section(title: &str) {
println!("\n{}", title);
}