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:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user