From d60ee5eab2e226b4f39403a20f9de1309e7cedba Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 9 May 2026 20:20:48 +0000 Subject: [PATCH] feat(nakui-ui): persistencia con event log + replay al startup Cierra "sin persistencia entre runs" del commit anterior. Cada SeedEntity se appendea al nakui_core::event_log::EventLog con WAL semantics (log antes que store) y al re-abrir el binario el replay reconstruye el MemoryStore desde cero. Cerrar y volver a abrir ya no borra el data. Cambios: - MetaUi.event_log: Option>> nuevo. Compartido bajo Mutex para que commit_seed pueda mutar. - Apertura + replay al startup: path por env NAKUI_EVENT_LOG, default ./nakui-ui-state.jsonl. EventLog::open + replay_into reconstruyen el store. Toast: "log nuevo" o "log X cargado: N evento(s) replayed". - WAL en commit_seed: log.append(LogEntry::Seed { ..., schema_hash: None }) antes de store.seed. Si append falla, cancela operacion. - schema_hash: None es el path "legacy / pre-versioning" documentado para seeds que no pasan por Manifest+Executor. Correcto para alta via metainterfaz hasta que Action::Morphism wire el Manifest. - Degradacion gracil: si abrir log falla -> toast error + sigue in-memory. Tests: 1 nuevo E2E event_log_replay_restores_memory_store que escribe 2 seeds via EventLog::append, re-abre + replay_into store fresh, verifica records con values correctos. 7 tests verdes en nakui-ui. Activacion con persistencia: NAKUI_EVENT_LOG=~/.nakui/state.jsonl \\ NAKUI_MODULES_DIR=examples/nakui-modules \\ cargo run -p nakui-ui Pendientes: - Action::Morphism (cargar Manifest junto a Module). - Snapshot/compaction para logs grandes. - UI para editar/borrar records existentes (hoy solo alta). - Widget input simple sin selection/IME/clipboard. --- CHANGELOG.md | 53 ++++++++++ crates/apps/nakui-ui/src/main.rs | 161 ++++++++++++++++++++++++++++++- 2 files changed, 210 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 826c116..6dd33f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,59 @@ ratio/diff ver `git show `. ## 2026-05-09 +### feat(nakui-ui): persistencia con event log + replay al startup +Cierra "sin persistencia entre runs" del commit anterior. Cada +`SeedEntity` se appendea al `nakui_core::event_log::EventLog` con +WAL semantics (log antes que store) y al re-abrir el binario el +replay reconstruye el `MemoryStore` desde cero. Cerrar y volver a +abrir ya no borra el data. + +Cambios: +- **`MetaUi.event_log: Option>>`** nuevo. + Compartido bajo `Mutex` para que el commit_seed pueda mutar. +- **Apertura + replay al startup** (`MetaUi::new`): path por env + `NAKUI_EVENT_LOG`, default `./nakui-ui-state.jsonl`. + `EventLog::open` + `replay_into` reconstruyen el store. + Toast informativo: "log nuevo" o "log X cargado: N evento(s) + replayed". +- **WAL en `commit_seed`**: si `event_log.is_some()`, primero + `log.append(LogEntry::Seed { ..., schema_hash: None })`, después + `store.seed`. Si el append falla, cancela toda la operación + (el user reintenta sin haber dejado state inconsistente). +- **`schema_hash: None`**: documentado como path "legacy / + pre-versioning" para seeds que no pasan por un Manifest+Executor. + Es el path correcto para alta administrativa vía la metainterfaz + hasta que `Action::Morphism` wireé el Manifest loader. +- **Degradación grácil**: si abrir log falla (permisos, disco), + toast con error pero el runtime sigue en modo in-memory. + +Tests: 1 nuevo E2E `event_log_replay_restores_memory_store` que +escribe 2 seeds via `EventLog::append`, re-abre y `replay_into` un +store fresh, verifica que ambos records están con sus values +correctos. Reproduce el flujo del startup de `MetaUi::new` sin +necesitar GPUI. 7 tests verdes en nakui-ui. + +Activación con persistencia explícita: +```sh +NAKUI_EVENT_LOG=~/.nakui/state.jsonl \\ +NAKUI_MODULES_DIR=examples/nakui-modules \\ +cargo run -p nakui-ui +# Crear varios records vía el form, cerrar el binario, abrir de +# nuevo: los records están. +``` + +Limitaciones que **siguen** (próximos iters): +- **`Action::Morphism`** sigue como TODO: requiere cargar el + `Manifest` de nakui-core junto al `Module` UI para conocer los + inputs/params declarados y poder llamar `execute_and_log`. +- **No hay snapshot/compaction**: el log crece append-only para + siempre. Para repos grandes habría que integrar `Snapshot` de + nakui_core (existe, no se usa todavía). +- **No hay UI para borrar/editar** records existentes — sólo alta + vía form. Edit + delete en futuras iteraciones. +- **Widget input simple** (sin selection/IME/clipboard) — heredado + de la limitación documentada de `yahweh-widget-text-input`. + ### feat(nakui-ui): inputs reales con yahweh-widget-text-input + click handlers funcionales Cierra dos limitaciones documentadas en el commit anterior de la metainterfaz: los formularios ahora aceptan teclado real, y los diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs index 0fe4fc1..86e8a33 100644 --- a/crates/apps/nakui-ui/src/main.rs +++ b/crates/apps/nakui-ui/src/main.rs @@ -30,6 +30,7 @@ use gpui::{ div, prelude::*, px, App, Application, Bounds, ClickEvent, Context, Entity, IntoElement, Render, SharedString, Window, WindowBounds, WindowOptions, }; +use nakui_core::event_log::{replay_into, EventLog, LogEntry}; use nakui_core::store::{MemoryStore, Store}; use nakui_ui_schema::{ Action, FieldKind, FieldSpec, FormView, ListView, Module, View, @@ -68,6 +69,11 @@ struct MetaUi { modules: Vec, /// Store compartido. Mutado por el submit de los forms. store: Arc>, + /// Event log persistente compartido. Cada `seed_entity` se appende + /// acá antes de mutar el store (WAL). `None` si la apertura del + /// log falló — en ese caso el runtime degrada a in-memory only y + /// loggea un toast informativo. + event_log: Option>>, /// (módulo idx, vista key) actualmente activos. active: Option<(usize, String)>, /// Inputs vivos para el form actual: nombre del campo → TextInput. @@ -86,7 +92,7 @@ impl MetaUi { .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("nakui-modules")); - let (modules, load_error) = + let (modules, mut load_error) = match nakui_ui_schema::load_modules_from_dir(&modules_dir) { Ok(m) => (m, None), Err(e) => ( @@ -98,16 +104,75 @@ impl MetaUi { ), }; + // Persistencia: abrir/crear el event log y hacer replay al + // store. Path por env `NAKUI_EVENT_LOG`, default + // `./nakui-ui-state.jsonl`. Si abrir o replay falla, el + // runtime sigue en modo in-memory (sin persistencia) y el + // load_error se acumula al banner. + let log_path = std::env::var("NAKUI_EVENT_LOG") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("nakui-ui-state.jsonl")); + let mut store = MemoryStore::new(); + let mut initial_toast: Option = None; + let event_log = match EventLog::open(&log_path) { + Ok(log) => { + match replay_into(&log, &mut store) { + Ok(()) => { + let n = log.next_seq(); + if n > 0 { + initial_toast = Some(SharedString::from(format!( + "log {} cargado: {n} evento(s) replayed", + log_path.display() + ))); + } else { + initial_toast = Some(SharedString::from(format!( + "log nuevo en {}", + log_path.display() + ))); + } + Some(Arc::new(Mutex::new(log))) + } + Err(e) => { + let msg = format!( + "replay del log {} falló: {e} — running in-memory", + log_path.display() + ); + match &load_error { + Some(prev) => { + load_error = Some(SharedString::from(format!("{prev}; {msg}"))); + } + None => load_error = Some(SharedString::from(msg)), + } + None + } + } + } + Err(e) => { + let msg = format!( + "abrir log {}: {e} — running in-memory only", + log_path.display() + ); + match &load_error { + Some(prev) => { + load_error = Some(SharedString::from(format!("{prev}; {msg}"))); + } + None => load_error = Some(SharedString::from(msg)), + } + None + } + }; + let active = modules .first() .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone()))); Self { modules, - store: Arc::new(Mutex::new(MemoryStore::new())), + store: Arc::new(Mutex::new(store)), + event_log, active, form_inputs: BTreeMap::new(), - toast: None, + toast: initial_toast, load_error, } } @@ -206,8 +271,36 @@ impl MetaUi { obj.insert(f.name.clone(), value); } let id = Uuid::new_v4(); + let data = Value::Object(obj); + + // WAL: si hay event_log activo, escribir al log ANTES de mutar + // el store. Si el log falla, cancelamos toda la operación (el + // user reintenta). Si no hay log (degraded), seedea al store + // y aceptamos no-persistencia. + if let Some(log_arc) = self.event_log.as_ref() { + let mut log = log_arc + .lock() + .map_err(|_| "log mutex envenenado".to_string())?; + let seq = log.next_seq(); + // schema_hash = None: ver doc del campo en + // nakui_core::event_log::LogEntry — "legacy/pre-versioning + // entries". Es el path correcto cuando el ingreso no + // pasa por un Manifest+Executor (que sería el path de + // morphism). Las altas administrativas vía la + // metainterfaz quedan flagged como pre-versioning hasta + // que Action::Morphism wireé el Manifest. + log.append(LogEntry::Seed { + seq, + entity: entity.to_string(), + id, + data: data.clone(), + schema_hash: None, + }) + .map_err(|e| format!("append al log: {e}"))?; + } + if let Ok(mut store) = self.store.lock() { - store.seed(entity, id, Value::Object(obj)); + store.seed(entity, id, data); Ok(id) } else { Err("store mutex envenenado".into()) @@ -739,4 +832,64 @@ mod tests { assert_eq!(render_value(Some(&json!(false))), "✗"); assert_eq!(render_value(Some(&json!(42))), "42"); } + + /// E2E mínimo del WAL: armamos un log a mano con dos seeds, + /// abrimos con `EventLog::open` + `replay_into`, y verificamos + /// que el `MemoryStore` queda con esos records aplicados. + /// Esto reproduce el flujo del startup de `MetaUi::new` sin + /// necesitar GPUI. + #[test] + fn event_log_replay_restores_memory_store() { + use nakui_core::event_log::{replay_into, EventLog, LogEntry}; + use nakui_core::store::{MemoryStore, Store}; + + let tmp = tempfile::NamedTempFile::new().unwrap(); + let path = tmp.path().to_path_buf(); + // Cerramos el handle de tempfile pero conservamos el path + // para que EventLog pueda re-abrir. + drop(tmp); + + // Escribimos dos seeds via EventLog::append. + let id_a = Uuid::new_v4(); + let id_b = Uuid::new_v4(); + { + let mut log = EventLog::open(&path).unwrap(); + log.append(LogEntry::Seed { + seq: 0, + entity: "customer".into(), + id: id_a, + data: json!({"name": "Acme"}), + schema_hash: None, + }) + .unwrap(); + log.append(LogEntry::Seed { + seq: 1, + entity: "customer".into(), + id: id_b, + data: json!({"name": "Globex"}), + schema_hash: None, + }) + .unwrap(); + } + + // Re-abrir + replay (simula startup de MetaUi). + let log = EventLog::open(&path).unwrap(); + assert_eq!(log.next_seq(), 2, "next_seq debe ser 2 tras 2 entries"); + let mut store = MemoryStore::new(); + replay_into(&log, &mut store).unwrap(); + + // Verificar que ambos records están en el store. + assert_eq!( + store.load("customer", id_a), + Some(json!({"name": "Acme"})), + "Acme debería estar tras replay" + ); + assert_eq!( + store.load("customer", id_b), + Some(json!({"name": "Globex"})), + "Globex debería estar tras replay" + ); + + let _ = std::fs::remove_file(&path); + } }