From 2462aca4442efbd2bdd4c65b85bc62c44cf1d626 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 10 May 2026 01:48:49 +0000 Subject: [PATCH] =?UTF-8?q?refactor(yahweh):=20Fase=202b=20=E2=80=94=20Met?= =?UTF-8?q?aBackend=20trait=20+=20NakuiBackend=20+=20MetaUi=20consume=20el?= =?UTF-8?q?=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 steps en un commit: A) yahweh-meta-runtime/backend.rs: trait MetaBackend con 6 métodos (list_records, load_record, seed, update, delete, morphism) + WriteOutcome { id, changed, post_status }. 9 tests con MemBackend. B) nakui-ui/backend.rs: NakuiBackend struct con store/log/executors/ compaction. NakuiBackend::open() compone log+snapshot+replay+tick; impl MetaBackend mapea cada método al pipeline nakui-core. snapshot_path_for / maybe_compact_log se mueven acá. 7 tests del impl. C) MetaUi consume el backend: - 6 fields colapsan en `backend: NakuiBackend`. - MetaUi::new pasa de ~150 líneas a ~10 (delega a NakuiBackend::open). - commit_seed / commit_morphism / commit_delete delegan al trait; CommitOutcome enum eliminado, reemplazado por WriteOutcome. - tick_runtime_compact eliminado (interno al backend; el msg sale por WriteOutcome.post_status). - validate_entity_refs callsite usa cierre sobre backend.load_record. - Imports nakui_core::delta y event_log salen de main.rs (sólo quedan en tests E2E). Tests: 33→42 yahweh-meta-runtime (+9 trait), 14→21 nakui-ui (+7 backend impl). 97 totales en el área. Cada crate compila individualmente. Pendiente Fase 2c: extraer widget render (form/list/modal/EntityRef) al crate yahweh — ahora trivial porque el render solo consume &self.modules + self.backend (via trait). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 99 +++ crates/apps/nakui-ui/src/backend.rs | 597 +++++++++++++++ crates/apps/nakui-ui/src/main.rs | 682 ++++-------------- .../libs/meta-runtime/src/backend.rs | 368 ++++++++++ .../ui_engine/libs/meta-runtime/src/lib.rs | 2 + 5 files changed, 1187 insertions(+), 561 deletions(-) create mode 100644 crates/apps/nakui-ui/src/backend.rs create mode 100644 crates/modules/ui_engine/libs/meta-runtime/src/backend.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cea0a5..694724d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,105 @@ ratio/diff ver `git show `. ## 2026-05-10 +### refactor(yahweh): Fase 2b — `MetaBackend` trait + `NakuiBackend` + MetaUi consume el backend +Materialización del trait que diseñamos en charla. Tres pasos +combinados en un solo commit: + +**Step A** — trait + WriteOutcome en `yahweh-meta-runtime`: +- Nuevo módulo `backend.rs` con: + - `pub trait MetaBackend: 'static` con 6 métodos: + `list_records`, `load_record`, `seed`, `update`, `delete`, + `morphism`. Convención de ids como `Uuid` canónico (los + backends que internamente usan otros tipos mapean), `set+clear` + pre-computados por el caller (no double-roundtrip al store), + threshold `'static` sin Send/Sync (suficiente para handlers + GPUI single-threaded). + - `pub struct WriteOutcome { id, changed, post_status }` con + constructor `no_change(id)`. La UI usa `changed = 0` para + "sin cambios", `post_status` para concatenar mensajes + auto-emitidos por el backend (compact, etc.). +- 9 tests con un `MemBackend` mínimo (HashMap por + `(entity, uuid)`): seed/load round-trip, list/filter/order, + update set/clear/no-op, delete/missing, object-safety check. + +**Step B** — `NakuiBackend` en `nakui-ui/src/backend.rs`: +- Estructura que ownea `Arc>`, + `Option>>`, `BTreeMap>`, + `snap_path`, `snapshot_threshold`, `writes_since_compact`. +- `NakuiBackend::open(log_path, threshold, executors) -> (Self, OpenStatus)`: + abre log, carga snapshot, replay, auto-compact si threshold + cruzado; devuelve `OpenStatus { init_toast, load_error }` para + que el caller agregue al banner. +- `tick_compact()` privado que cada write public method invoca + tras éxito; devuelve `Option` que se mete en + `WriteOutcome.post_status`. +- `impl MetaBackend for NakuiBackend`: + - `seed`: WAL order (log first, store after), `tick_compact`, + devuelve `WriteOutcome { id: Some(uuid), changed: 1, post_status }`. + - `update`: si `set+clear` vacíos devuelve `WriteOutcome::no_change`; + si no construye `FieldOp::Set`+`FieldOp::Clear`, log Morphism + `ui.edit_record` con `params.fields/cleared`, store.apply, tick. + - `delete`: `FieldOp::Delete`, log Morphism `ui.delete_record`, + store.apply, tick. + - `morphism`: locks log + store, `execute_and_log_with_recovery`, + tick. `WriteOutcome { id: None, changed: ops.len(), post_status }`. +- Funciones `snapshot_path_for` y `maybe_compact_log` movidas acá + desde main.rs (ahora son detalle del backend). +- 7 tests del impl: round-trip via trait, set+clear, no-op edit + no escribe, delete/load, list_records, morphism sin executor da + error claro, threshold dispara snapshot. + +**Step C** — `MetaUi` consume el backend: +- Reemplaza fields `store` / `event_log` / `executors` / + `snap_path` / `snapshot_threshold` / `writes_since_compact` + por un único `backend: NakuiBackend`. +- `MetaUi::new` colapsa el wiring de persistencia en + `NakuiBackend::open(...)` — pasó de ~150 líneas a ~10 líneas. +- `commit_seed` ya no construye `LogEntry`/`FieldOp` directos: + - SEED → `self.backend.seed(entity, obj)`. + - EDIT → `self.backend.load_record + compute_field_delta + + compute_clear_fields → self.backend.update(set, clear)`. + - Devuelve `WriteOutcome` (reemplaza el viejo enum `CommitOutcome`). +- `commit_morphism` parsea inputs/params del form y delega a + `self.backend.morphism(...)`. +- `commit_delete` es one-liner: `self.backend.delete(entity, id)`. +- `tick_runtime_compact` eliminado (ahora interno al backend; el + msg viaja en `WriteOutcome.post_status`). +- `list_rows` queda como proxy `self.backend.list_records(entity)`. +- `validate_entity_refs` callsite usa cierre sobre + `backend.load_record` (en vez de `&Store`). +- Nuevo helper `format_seed_toast(entity, was_editing, &outcome)` + reemplaza el match sobre `CommitOutcome`. +- Imports limpiados: no más `nakui_core::delta::FieldOp`/`FieldPath`, + no más `nakui_core::event_log::*` en main.rs (sólo en tests E2E). + No más `Arc/Mutex` (vive en backend). + +Distribución de tests post-refactor: +- `yahweh-meta-runtime`: 33 → **42** (+9 trait tests con MemBackend). +- `nakui-ui`: 14 → **21** (+7 tests del NakuiBackend impl). +- `yahweh-meta-schema`: 8 (sin cambio). +- `brahman-cards`: 26 (sin cambio). +- Total: **97**. + +Build: cada crate compila individualmente. + +Nota sobre Fase 2b/c estado: +- ✅ Backend trait + impl + MetaUi usa backend. +- ⏭ Falta extraer los **widgets render** (form/list/modal/EntityRef + selector) de nakui-ui a un crate yahweh nuevo + (sugerencia: `yahweh-widget-meta-form`). Esa extracción ahora es + trivial: el render code ya consume sólo `&self.modules` + + `self.backend` (vía trait). Lo dejo para próximo commit. + +**Pendientes**: +1. **Fase 2c**: extraer widget render al crate yahweh + (`yahweh-widget-meta-form` o similar) — `MetaApp` + genérico, `nakui-ui` queda como ~50 líneas de shell con + `MetaApp::::new(...)`. +2. **KCL → Nickel**: kcl_wrapper reemplazado por evaluación de + Nickel contracts. +3. **`card.k` eliminado** (REFERENCE ONLY). + ### refactor(yahweh): Fase 2 — extraer helpers puros a `yahweh-meta-runtime` Sigue de la Fase 1 (lift del schema a yahweh). Ahora extraemos los **helpers puros** que cualquier widget renderer o backend ejecutor diff --git a/crates/apps/nakui-ui/src/backend.rs b/crates/apps/nakui-ui/src/backend.rs new file mode 100644 index 0000000..a7c747f --- /dev/null +++ b/crates/apps/nakui-ui/src/backend.rs @@ -0,0 +1,597 @@ +//! Implementación de [`MetaBackend`] para Nakui — compone +//! `nakui_core::store::MemoryStore`, `event_log::EventLog`, los +//! `Executor`s por módulo, y la lógica de auto-compaction. +//! +//! Es lo único que sabe de Nakui en el binario nuevo. El widget de +//! UI no toca ninguno de estos tipos directamente. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use serde_json::{json, Value}; +use uuid::Uuid; + +use nakui_core::delta::{FieldOp, FieldPath}; +use nakui_core::event_log::{ + execute_and_log_with_recovery, replay_with_snapshot_into, EventLog, LogEntry, Snapshot, +}; +use nakui_core::executor::Executor; +use nakui_core::store::{MemoryStore, Store}; +use yahweh_meta_runtime::{MetaBackend, WriteOutcome}; + +/// Path del snapshot sibling del log: +/// `nakui-ui-state.jsonl` ↔ `nakui-ui-state.snap.json`. +pub fn snapshot_path_for(log_path: &Path) -> PathBuf { + log_path.with_extension("snap.json") +} + +/// Si el log file tiene >= `threshold` entries, captura un snapshot +/// del store actual y compacta el log dejando 1 entry como anchor del +/// cursor. Idempotente abajo del threshold o con < 2 entries. +/// +/// Ver el doc original (commit del runtime compact) para detalles +/// sobre el anchor invariant. Re-locado acá porque es detalle del +/// backend, no del widget. +pub fn maybe_compact_log( + log: &mut EventLog, + snap_path: &Path, + store: &MemoryStore, + threshold: usize, +) -> Result, String> { + if threshold == 0 { + return Ok(None); + } + let entry_count = log + .entries() + .map_err(|e| format!("read entries: {e}"))? + .len(); + if entry_count < threshold || entry_count < 2 { + return Ok(None); + } + let snap_seq = log.next_seq() - 1; + let through = log.next_seq() - 2; + let snap = Snapshot::from_memory_store(store, snap_seq); + snap.write(snap_path) + .map_err(|e| format!("write snapshot {}: {e}", snap_path.display()))?; + log.compact_through(through) + .map_err(|e| format!("compact_through({through}): {e}"))?; + Ok(Some(format!( + "auto-compact: snapshot @ seq {snap_seq}, {} entries dropped (1 anchor kept)", + entry_count - 1 + ))) +} + +/// Estado inicial del backend tras abrir el log + cargar snapshot +/// + replay. Devuelto desde [`NakuiBackend::open`] para que el caller +/// (typicamente `main.rs`) acumule mensajes informativos al banner. +pub struct OpenStatus { + /// Mensaje "log X cargado: next_seq=N (snapshot @ seq K)" o similar. + pub init_toast: Option, + /// Errores no-fatales acumulados (snapshot corrupto, replay falló, + /// log inaccesible). El backend igualmente queda usable + /// (eventualmente in-memory only si log_arc es None). + pub load_error: Option, +} + +/// Backend Nakui: WAL persistente + MemoryStore + executors por +/// módulo + auto-compaction. +/// +/// Implementa [`MetaBackend`] proyectando cada operación al +/// pipeline de nakui-core (compute → log → apply para morphisms; +/// log → apply para seed/edit/delete). +pub struct NakuiBackend { + /// Store compartido (Arc para que el render pueda hacer reads + /// sin bloquear writes; el lock interno serializa). + store: Arc>, + /// Log persistente. `None` si abrir falló — el backend degrada + /// a in-memory only (writes no se persisten; reads siguen). + event_log: Option>>, + /// Executors indexados por `module.id`. Los módulos sin + /// `nakui_module_dir` no aparecen acá; sus llamadas a + /// `morphism()` rebotan con error claro. + executors: BTreeMap>, + /// Path del snapshot (cacheado del init). + snap_path: PathBuf, + /// Threshold de auto-compaction. `0` = desactivado. + snapshot_threshold: usize, + /// Contador de writes desde el último compact. Se resetea al + /// disparar compact. + writes_since_compact: u64, +} + +impl NakuiBackend { + /// Abre/crea el log en `log_path`, intenta cargar el snapshot + /// sibling, hace replay al store. Si el log no abre, degrada a + /// in-memory only. Ningún error es fatal — los mensajes se + /// devuelven en `OpenStatus` para que el caller los acumule. + /// + /// `executors` se pasan ya cargados (la lógica de qué módulos + /// declaran `nakui_module_dir` es responsabilidad del caller). + pub fn open( + log_path: PathBuf, + snapshot_threshold: usize, + executors: BTreeMap>, + ) -> (Self, OpenStatus) { + let snap_path = snapshot_path_for(&log_path); + let mut store = MemoryStore::new(); + let mut init_toast: Option = None; + let mut load_error: Option = None; + + // Cargar snapshot (si existe). + let snapshot: Option = match Snapshot::load(&snap_path) { + Ok(s) => s, + Err(e) => { + load_error = Some(format!("snapshot {}: {e} — full replay", snap_path.display())); + None + } + }; + + let event_log = match EventLog::open(&log_path) { + Ok(mut log) => { + match replay_with_snapshot_into(&log, snapshot.as_ref(), &mut store) { + Ok(()) => { + let n = log.next_seq(); + let from_snap = snapshot + .as_ref() + .map(|s| format!(" (snapshot @ seq {})", s.seq)) + .unwrap_or_default(); + if n > 0 { + init_toast = Some(format!( + "log {} cargado: next_seq={n}{from_snap}", + log_path.display() + )); + } else { + init_toast = + Some(format!("log nuevo en {}", log_path.display())); + } + + // Auto-compact si pasamos el threshold. + match maybe_compact_log(&mut log, &snap_path, &store, snapshot_threshold) + { + Ok(Some(msg)) => { + let prev = init_toast.unwrap_or_default(); + init_toast = Some(format!("{prev}; {msg}")); + } + Ok(None) => {} + Err(e) => { + let msg = format!("auto-compact: {e}"); + load_error = Some(match load_error { + Some(p) => format!("{p}; {msg}"), + None => msg, + }); + } + } + Some(Arc::new(Mutex::new(log))) + } + Err(e) => { + let msg = format!( + "replay del log {} falló: {e} — running in-memory", + log_path.display() + ); + load_error = Some(match load_error { + Some(p) => format!("{p}; {msg}"), + None => msg, + }); + None + } + } + } + Err(e) => { + let msg = format!( + "abrir log {}: {e} — running in-memory only", + log_path.display() + ); + load_error = Some(match load_error { + Some(p) => format!("{p}; {msg}"), + None => msg, + }); + None + } + }; + + let backend = NakuiBackend { + store: Arc::new(Mutex::new(store)), + event_log, + executors, + snap_path, + snapshot_threshold, + writes_since_compact: 0, + }; + ( + backend, + OpenStatus { + init_toast, + load_error, + }, + ) + } + + /// Increment + check del threshold; si cruza, captura snapshot + /// + compacta. Devuelve el mensaje de status para concatenar al + /// `WriteOutcome.post_status`. + fn tick_compact(&mut self) -> Option { + if self.snapshot_threshold == 0 { + return None; + } + self.writes_since_compact += 1; + if self.writes_since_compact < self.snapshot_threshold as u64 { + return None; + } + let log_arc = self.event_log.as_ref()?.clone(); + let mut log = match log_arc.lock() { + Ok(l) => l, + Err(_) => return Some("auto-compact skip: log mutex envenenado".into()), + }; + let store = match self.store.lock() { + Ok(s) => s, + Err(_) => return Some("auto-compact skip: store mutex envenenado".into()), + }; + match maybe_compact_log(&mut log, &self.snap_path, &store, self.snapshot_threshold) { + Ok(Some(msg)) => { + self.writes_since_compact = 0; + Some(msg) + } + Ok(None) => { + self.writes_since_compact = 0; + None + } + Err(e) => Some(format!("auto-compact: {e}")), + } + } + + /// Helper: append una entry al log si está disponible. Errors si + /// el lock falla o el append falla. + fn append_log(&self, entry: LogEntry) -> Result<(), String> { + let Some(log_arc) = self.event_log.as_ref() else { + return Ok(()); // in-memory mode, no log. + }; + let mut log = log_arc + .lock() + .map_err(|_| "log mutex envenenado".to_string())?; + log.append(entry).map_err(|e| format!("append al log: {e}")) + } +} + +impl MetaBackend for NakuiBackend { + fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)> { + let store = match self.store.lock() { + Ok(g) => g, + Err(_) => return Vec::new(), + }; + let it = match store.iter() { + Ok(i) => i, + Err(_) => return Vec::new(), + }; + let mut out: Vec<(Uuid, Value)> = it + .filter(|(e, _, _)| e == entity) + .map(|(_, id, v)| (id, v)) + .collect(); + out.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes())); + out + } + + fn load_record(&self, entity: &str, id: Uuid) -> Option { + self.store.lock().ok()?.load(entity, id) + } + + fn seed( + &mut self, + entity: &str, + data: serde_json::Map, + ) -> Result { + let id = Uuid::new_v4(); + let value = Value::Object(data); + // WAL: log primero, store después. + if self.event_log.is_some() { + let seq = { + let log_arc = self + .event_log + .as_ref() + .expect("checked above") + .clone(); + let log = log_arc + .lock() + .map_err(|_| "log mutex envenenado".to_string())?; + log.next_seq() + }; + self.append_log(LogEntry::Seed { + seq, + entity: entity.to_string(), + id, + data: value.clone(), + schema_hash: None, + })?; + } + let mut store = self + .store + .lock() + .map_err(|_| "store mutex envenenado".to_string())?; + store.seed(entity, id, value); + drop(store); + let post_status = self.tick_compact(); + Ok(WriteOutcome { + id: Some(id), + changed: 1, + post_status, + }) + } + + fn update( + &mut self, + entity: &str, + id: Uuid, + set: serde_json::Map, + clear: Vec, + ) -> Result { + if set.is_empty() && clear.is_empty() { + return Ok(WriteOutcome::no_change(id)); + } + // Construir ops: Set primero, después Clear (la sem es + // independiente del orden, pero estable mejor para diff). + let mut ops: Vec = set + .iter() + .map(|(field, value)| FieldOp::Set { + path: FieldPath { + entity: entity.to_string(), + id, + field: field.clone(), + }, + value: value.clone(), + }) + .collect(); + for field in &clear { + ops.push(FieldOp::Clear { + path: FieldPath { + entity: entity.to_string(), + id, + field: field.clone(), + }, + }); + } + let changed = set.len() + clear.len(); + + // Log: Morphism { ui.edit_record, ops, params: {entity, id, fields, cleared} }. + if self.event_log.is_some() { + let seq = { + let log_arc = self.event_log.as_ref().expect("checked").clone(); + let log = log_arc + .lock() + .map_err(|_| "log mutex envenenado".to_string())?; + log.next_seq() + }; + let mut params = serde_json::Map::new(); + params.insert("entity".into(), json!(entity)); + params.insert("id".into(), json!(id.to_string())); + if !set.is_empty() { + params.insert("fields".into(), Value::Object(set.clone())); + } + if !clear.is_empty() { + params.insert( + "cleared".into(), + Value::Array(clear.iter().map(|s| json!(s)).collect()), + ); + } + self.append_log(LogEntry::Morphism { + seq, + morphism: "ui.edit_record".into(), + inputs: Default::default(), + params: Value::Object(params), + ops: ops.clone(), + schema_hash: None, + })?; + } + let mut store = self + .store + .lock() + .map_err(|_| "store mutex envenenado".to_string())?; + store.apply(&ops).map_err(|e| format!("apply edit ops: {e}"))?; + drop(store); + let post_status = self.tick_compact(); + Ok(WriteOutcome { + id: Some(id), + changed, + post_status, + }) + } + + fn delete(&mut self, entity: &str, id: Uuid) -> Result { + let ops = vec![FieldOp::Delete { + entity: entity.to_string(), + id, + }]; + if self.event_log.is_some() { + let seq = { + let log_arc = self.event_log.as_ref().expect("checked").clone(); + let log = log_arc + .lock() + .map_err(|_| "log mutex envenenado".to_string())?; + log.next_seq() + }; + self.append_log(LogEntry::Morphism { + seq, + morphism: "ui.delete_record".into(), + inputs: Default::default(), + params: json!({ "entity": entity, "id": id.to_string() }), + ops: ops.clone(), + schema_hash: None, + })?; + } + let mut store = self + .store + .lock() + .map_err(|_| "store mutex envenenado".to_string())?; + store.apply(&ops).map_err(|e| format!("apply Delete: {e}"))?; + drop(store); + let post_status = self.tick_compact(); + Ok(WriteOutcome { + id: Some(id), + changed: 1, + post_status, + }) + } + + fn morphism( + &mut self, + module_id: &str, + name: &str, + inputs: BTreeMap, + params: Value, + ) -> Result { + let executor = self + .executors + .get(module_id) + .ok_or_else(|| { + format!( + "módulo '{module_id}' no tiene executor nakui (falta nakui_module_dir o falló la carga)" + ) + })? + .clone(); + let log_arc = self + .event_log + .as_ref() + .ok_or_else(|| "morphism requiere event log activo".to_string())? + .clone(); + + let inputs_owned: Vec<(String, Uuid)> = inputs.into_iter().collect(); + let inputs_ref: Vec<(&str, Uuid)> = inputs_owned + .iter() + .map(|(r, id)| (r.as_str(), *id)) + .collect(); + + let mut log = log_arc + .lock() + .map_err(|_| "log mutex envenenado".to_string())?; + let mut store = self + .store + .lock() + .map_err(|_| "store mutex envenenado".to_string())?; + + let ops = execute_and_log_with_recovery( + &executor, + &mut *store, + &mut *log, + name, + &inputs_ref, + params, + ) + .map_err(|e| format!("{e}"))?; + drop(store); + drop(log); + let post_status = self.tick_compact(); + Ok(WriteOutcome { + id: None, + changed: ops.len(), + post_status, + }) + } +} + +#[cfg(test)] +mod tests { + //! Tests del impl `NakuiBackend` contra el contrato del trait. + //! Exercises seed/load/list/update/delete sin GPUI ni morphism. + //! El path de morphism está cubierto por + //! `morphism_pipeline_executes_real_sales_vender` en main.rs. + + use super::*; + use serde_json::json; + + fn open_in_tempdir() -> (NakuiBackend, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let log_path = dir.path().join("log.jsonl"); + let (backend, _status) = NakuiBackend::open(log_path, 0, BTreeMap::new()); + (backend, dir) + } + + fn map_of(items: &[(&str, Value)]) -> serde_json::Map { + items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect() + } + + #[test] + fn seed_then_load_round_trip_via_trait() { + let (mut b, _dir) = open_in_tempdir(); + let out = b + .seed("Customer", map_of(&[("name", json!("Acme"))])) + .unwrap(); + let id = out.id.unwrap(); + assert_eq!(out.changed, 1); + let rec = b.load_record("Customer", id).unwrap(); + assert_eq!(rec.get("name"), Some(&json!("Acme"))); + } + + #[test] + fn update_set_then_clear_via_trait() { + let (mut b, _dir) = open_in_tempdir(); + let id = b + .seed("X", map_of(&[("a", json!(1)), ("b", json!(2))])) + .unwrap() + .id + .unwrap(); + + let out = b + .update("X", id, map_of(&[("a", json!(10))]), vec!["b".into()]) + .unwrap(); + assert_eq!(out.changed, 2, "1 set + 1 clear = 2 cambios"); + + let rec = b.load_record("X", id).unwrap(); + assert_eq!(rec.get("a"), Some(&json!(10))); + assert!(rec.get("b").is_none()); + } + + #[test] + fn update_no_op_returns_no_change() { + let (mut b, _dir) = open_in_tempdir(); + let id = b.seed("X", map_of(&[("a", json!(1))])).unwrap().id.unwrap(); + let out = b + .update("X", id, serde_json::Map::new(), vec![]) + .unwrap(); + assert_eq!(out, WriteOutcome::no_change(id)); + } + + #[test] + fn delete_via_trait_then_load_returns_none() { + let (mut b, _dir) = open_in_tempdir(); + let id = b.seed("X", map_of(&[("a", json!(1))])).unwrap().id.unwrap(); + b.delete("X", id).unwrap(); + assert!(b.load_record("X", id).is_none()); + } + + #[test] + fn list_records_returns_seeded_in_id_order() { + let (mut b, _dir) = open_in_tempdir(); + let _ = b.seed("X", map_of(&[("k", json!(1))])).unwrap(); + let _ = b.seed("X", map_of(&[("k", json!(2))])).unwrap(); + let _ = b.seed("Y", map_of(&[("k", json!(3))])).unwrap(); + assert_eq!(b.list_records("X").len(), 2); + assert_eq!(b.list_records("Y").len(), 1); + assert!(b.list_records("Z").is_empty()); + } + + #[test] + fn morphism_without_executor_errors_clearly() { + let (mut b, _dir) = open_in_tempdir(); + let err = b + .morphism("missing", "vender", BTreeMap::new(), json!({})) + .unwrap_err(); + assert!(err.contains("missing"), "msg debe mencionar el módulo: {err}"); + assert!(err.contains("nakui_module_dir") || err.contains("executor")); + } + + #[test] + fn tick_compact_writes_snapshot_after_threshold() { + // threshold=3: tras 3 writes debería haber compactado. + let dir = tempfile::tempdir().unwrap(); + let log_path = dir.path().join("log.jsonl"); + let snap_path = snapshot_path_for(&log_path); + let (mut b, _) = NakuiBackend::open(log_path, 3, BTreeMap::new()); + + for _ in 0..3 { + let _ = b.seed("X", map_of(&[("k", json!(1))])).unwrap(); + } + // El último seed debería traer un post_status del compact. + // (En la 3ra llamada el contador llega a 3 y dispara.) + // Verificamos que el snapshot file exists. + assert!(snap_path.exists(), "snap debería haberse escrito"); + } +} diff --git a/crates/apps/nakui-ui/src/main.rs b/crates/apps/nakui-ui/src/main.rs index a62d504..12c6c15 100644 --- a/crates/apps/nakui-ui/src/main.rs +++ b/crates/apps/nakui-ui/src/main.rs @@ -22,33 +22,34 @@ //! # default sin env: ./nakui-modules en pwd. //! ``` +mod backend; + use std::collections::BTreeMap; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use gpui::{ div, prelude::*, px, App, Application, Bounds, ClickEvent, Context, Entity, IntoElement, KeyDownEvent, Render, SharedString, Window, WindowBounds, WindowOptions, }; -use nakui_core::delta::{FieldOp, FieldPath}; -use nakui_core::event_log::{ - execute_and_log_with_recovery, replay_with_snapshot_into, EventLog, LogEntry, Snapshot, -}; + use brahman_cards::CardBody; use nakui_core::executor::Executor; -use nakui_core::store::{MemoryStore, Store}; use yahweh_meta_runtime::{ compute_clear_fields, compute_field_delta, human_label_for_record, parse_field_value, render_value, resolve_param_value, short_uuid, validate_entity_refs, value_to_input_text, + MetaBackend, WriteOutcome, }; use yahweh_meta_schema::{ Action, FieldKind, FieldSpec, FormView, ListView, Module, View, }; -use serde_json::{json, Value}; +use serde_json::Value; use uuid::Uuid; use yahweh_theme::Theme; use yahweh_widget_text_input::TextInput; +use crate::backend::NakuiBackend; + fn main() { Application::new().run(|cx: &mut App| { // El text input pide Theme::global; instalarlo antes de @@ -72,22 +73,16 @@ fn main() { }); } -/// Estado del runtime. +/// Estado del runtime de UI. Toda la persistencia/ejecución está +/// detrás del trait `MetaBackend`; este struct sólo conoce GPUI +/// state y el schema de los módulos. struct MetaUi { /// Módulos cargados, ordenados por id. 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>>, - /// Executors nakui cargados, indexados por `module.id`. Sólo - /// existen los módulos que declaran `nakui_module_dir`. Las - /// acciones `Morphism` requieren que el módulo activo tenga - /// uno; sin él, despachan un toast informativo. - executors: BTreeMap>, + /// Backend que ejecuta seed/update/delete/morphism. Para Nakui + /// esto wirea al stack de event_log + MemoryStore + Executors. + /// Otra app podría implementar `MetaBackend` distinto. + backend: NakuiBackend, /// (módulo idx, vista key) actualmente activos. active: Option<(usize, String)>, /// Inputs vivos para el form actual: nombre del campo → TextInput. @@ -95,9 +90,8 @@ struct MetaUi { form_inputs: BTreeMap>, /// Si está set, el próximo render del Form pre-llena los inputs /// con los valores del record indicado, y `commit_seed` emite - /// un `LogEntry::Morphism { name: "ui.edit_record", ops: [Set...] }` - /// en lugar de un Seed nuevo. Limpia al cambiar de view o tras - /// submit exitoso. + /// un `update` (no un seed nuevo). Limpia al cambiar de view o + /// tras submit exitoso. editing: Option<(String, Uuid)>, /// Si está set, el banner modal de confirmación de delete está /// activo: `(entity, id)` del record que el usuario marcó para @@ -105,18 +99,6 @@ struct MetaUi { /// (ejecuta `commit_delete` y limpia) o [Cancelar] (sólo limpia). /// Navegación a otra view también cancela. pending_delete: Option<(String, Uuid)>, - /// Path del snapshot sibling del log; cacheado en `new()` para - /// que `tick_runtime_compact` no tenga que recomputarlo. - snap_path: PathBuf, - /// Threshold de auto-compaction (número de writes antes de - /// snapshot+compact). Cacheado del env `NAKUI_SNAPSHOT_THRESHOLD` - /// al startup; 0 = runtime compact desactivado. - snapshot_threshold: usize, - /// Contador de writes desde el último compact (o desde el boot - /// si nunca compactamos). Incrementa por cada commit_seed - /// efectivo / commit_morphism / commit_delete; cuando alcanza - /// `snapshot_threshold` dispara el auto-compact y se resetea. - writes_since_compact: u64, /// Mensaje toast al pie (success de submit, error de carga, etc.). toast: Option, /// Si la carga de módulos falló al inicio. @@ -161,137 +143,11 @@ impl MetaUi { ), }; - // Persistencia: abrir/crear el event log + opcionalmente un - // snapshot sibling para acortar el replay. Path del log por - // env `NAKUI_EVENT_LOG`, default `./nakui-ui-state.jsonl`. El - // snapshot vive como sibling con extensión `.snap.json`. - // - // Si abrir o replay falla, el runtime sigue en modo in-memory - // (sin persistencia) y el load_error se acumula al banner. - // - // Threshold de auto-compaction via env - // `NAKUI_SNAPSHOT_THRESHOLD` (default 50): después del replay, - // si el log file tiene >= N entries, capturamos un snapshot - // del store actual y compactamos el log. La próxima boot ya - // arranca de snapshot + log corto. - let log_path = std::env::var("NAKUI_EVENT_LOG") - .map(PathBuf::from) - .unwrap_or_else(|_| PathBuf::from("nakui-ui-state.jsonl")); - let snap_path = snapshot_path_for(&log_path); - let snapshot_threshold: usize = std::env::var("NAKUI_SNAPSHOT_THRESHOLD") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(50); - let mut store = MemoryStore::new(); - let mut initial_toast: Option = None; - - // Cargar snapshot (si existe y no falla). Un snapshot - // corrupto no es fatal: caemos a full replay del log. - let snapshot: Option = match Snapshot::load(&snap_path) { - Ok(s) => s, - Err(e) => { - let msg = format!( - "snapshot {}: {e} — full replay", - snap_path.display() - ); - match &load_error { - Some(prev) => { - load_error = Some(SharedString::from(format!("{prev}; {msg}"))); - } - None => load_error = Some(SharedString::from(msg)), - } - None - } - }; - - let event_log = match EventLog::open(&log_path) { - Ok(mut log) => { - match replay_with_snapshot_into(&log, snapshot.as_ref(), &mut store) { - Ok(()) => { - let n = log.next_seq(); - let from_snap = snapshot - .as_ref() - .map(|s| format!(" (snapshot @ seq {})", s.seq)) - .unwrap_or_default(); - if n > 0 { - initial_toast = Some(SharedString::from(format!( - "log {} cargado: next_seq={n}{from_snap}", - log_path.display() - ))); - } else { - initial_toast = Some(SharedString::from(format!( - "log nuevo en {}", - log_path.display() - ))); - } - - // Auto-compact si pasamos el threshold. No - // fatal — un fallo deja log+snap como están. - match maybe_compact_log( - &mut log, - &snap_path, - &store, - snapshot_threshold, - ) { - Ok(Some(msg)) => { - let prev = initial_toast - .map(|t| t.to_string()) - .unwrap_or_default(); - initial_toast = Some(SharedString::from(format!( - "{prev}; {msg}" - ))); - } - Ok(None) => {} - Err(e) => { - let msg = format!("auto-compact: {e}"); - match &load_error { - Some(prev) => { - load_error = Some(SharedString::from(format!( - "{prev}; {msg}" - ))); - } - None => load_error = Some(SharedString::from(msg)), - } - } - } - - 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 - } - }; - // Cargar Executors para los módulos que declararon // `nakui_module_dir`. Resolvemos paths relativos al // directorio del modules (NAKUI_MODULES_DIR//), no al // pwd. Cualquier error de carga deja la entry afuera y - // anota al banner — Action::Morphism queda no-op para ese + // anota al banner — el morphism queda inejecutable para ese // módulo pero el resto sigue funcionando. let mut executors: BTreeMap> = BTreeMap::new(); for m in &modules { @@ -324,22 +180,38 @@ impl MetaUi { } } + // Persistencia: el backend abre el log + snapshot + replay. + // Path del log por env `NAKUI_EVENT_LOG` (default + // `./nakui-ui-state.jsonl`). Threshold de auto-compaction + // via env `NAKUI_SNAPSHOT_THRESHOLD` (default 50). + let log_path = std::env::var("NAKUI_EVENT_LOG") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("nakui-ui-state.jsonl")); + let snapshot_threshold: usize = std::env::var("NAKUI_SNAPSHOT_THRESHOLD") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(50); + let (backend, status) = + NakuiBackend::open(log_path, snapshot_threshold, executors); + let initial_toast = status.init_toast.map(SharedString::from); + if let Some(msg) = status.load_error { + load_error = Some(match load_error { + Some(prev) => SharedString::from(format!("{prev}; {msg}")), + None => SharedString::from(msg), + }); + } + let active = modules .first() .and_then(|m| m.menu.first().map(|item| (0usize, item.view.clone()))); Self { modules, - store: Arc::new(Mutex::new(store)), - event_log, - executors, + backend, active, form_inputs: BTreeMap::new(), editing: None, pending_delete: None, - snap_path, - snapshot_threshold, - writes_since_compact: 0, toast: initial_toast, load_error, } @@ -362,8 +234,7 @@ impl MetaUi { // Snapshot del record si estamos editando esta entity. let editing_record: Option = self.editing.as_ref().and_then(|(e, id)| { if e == &form.entity { - let store = self.store.lock().ok()?; - store.load(e, *id) + self.backend.load_record(e, *id) } else { None } @@ -421,92 +292,18 @@ impl MetaUi { /// Borra un record. Emite Morphism con un FieldOp::Delete + lo /// aplica al store via `apply` (no via remove directo, mantiene /// el modelo de "todo cambio post-seed pasa por ops"). + /// Borra un record vía `MetaBackend::delete`. Devuelve el outcome + /// del backend (incluye eventual `post_status` del compact tick). fn commit_delete( &mut self, entity: &str, id: Uuid, - ) -> Result<(), String> { - let ops = vec![FieldOp::Delete { - entity: entity.to_string(), - id, - }]; - 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(); - log.append(LogEntry::Morphism { - seq, - morphism: "ui.delete_record".into(), - inputs: Default::default(), - params: json!({ "entity": entity, "id": id.to_string() }), - ops: ops.clone(), - schema_hash: None, - }) - .map_err(|e| format!("append al log: {e}"))?; - } - let mut store = self - .store - .lock() - .map_err(|_| "store mutex envenenado".to_string())?; - store - .apply(&ops) - .map_err(|e| format!("apply Delete: {e}"))?; - Ok(()) - } - - /// Incrementa el contador de writes y, si cruzó el threshold, - /// ejecuta `maybe_compact_log` (snapshot + compact). Devuelve un - /// mensaje de status si compactó (para concatenar al toast del - /// op original) o si la compactación falló; `None` si todavía no - /// alcanzó el threshold. - /// - /// Llamar SIEMPRE después de cada write efectivo (commit_seed - /// Created/Updated, commit_morphism Ok, commit_delete Ok). - /// `NoChange` NO debería llamar — no hay write nuevo que contar. - /// - /// Threshold == 0 desactiva el runtime compact (early return). - fn tick_runtime_compact(&mut self) -> Option { - if self.snapshot_threshold == 0 { - return None; - } - self.writes_since_compact += 1; - if self.writes_since_compact < self.snapshot_threshold as u64 { - return None; - } - let log_arc = self.event_log.as_ref()?.clone(); - let mut log = match log_arc.lock() { - Ok(l) => l, - Err(_) => return Some("auto-compact skip: log mutex envenenado".into()), - }; - let store = match self.store.lock() { - Ok(s) => s, - Err(_) => return Some("auto-compact skip: store mutex envenenado".into()), - }; - match maybe_compact_log(&mut log, &self.snap_path, &store, self.snapshot_threshold) { - Ok(Some(msg)) => { - self.writes_since_compact = 0; - Some(msg) - } - Ok(None) => { - // Counter cruzó pero entry_count no — log tiene < 2 - // entries en disco (ej: snapshot ya cubrió todo y sólo - // quedan los nuevos). Reseteamos para no re-entrar - // en cada write subsiguiente. - self.writes_since_compact = 0; - None - } - Err(e) => { - // Compactación falló — NO reseteamos el counter, así - // el próximo write reintenta. El usuario ve el error - // en el toast. - Some(format!("auto-compact: {e}")) - } - } + ) -> Result { + self.backend.delete(entity, id) } /// Aplica una acción (click en menú, botón de form, action de - /// list). Mutaciones contra el store ocurren acá. + /// list). Mutaciones contra el backend ocurren acá. fn apply_action(&mut self, action: Action, cx: &mut Context) { let mod_idx = match self.active.as_ref() { Some((i, _)) => *i, @@ -519,34 +316,11 @@ impl MetaUi { self.select_view(mod_idx, view, cx); } Action::SeedEntity { entity, next_view } => { + let was_editing = self.editing.is_some(); match self.commit_seed(mod_idx, &entity, cx) { Ok(outcome) => { - let id = outcome.id(); - let toast_msg = match &outcome { - CommitOutcome::Created(_) => { - format!("creado {entity} {}", short_uuid(&id)) - } - CommitOutcome::Updated { changed, .. } => { - format!( - "actualizado {entity} {} ({changed} campo(s))", - short_uuid(&id) - ) - } - CommitOutcome::NoChange(_) => { - format!( - "{entity} {} sin cambios — no log entry", - short_uuid(&id) - ) - } - }; - // NoChange no escribió al log → no avanza el - // counter de runtime compact. Created/Updated - // sí lo hacen. - let was_write = !matches!(outcome, CommitOutcome::NoChange(_)); - self.toast = Some(append_compact_msg( - toast_msg, - was_write.then(|| self.tick_runtime_compact()).flatten(), - )); + let toast_msg = format_seed_toast(&entity, was_editing, &outcome); + self.toast = Some(append_compact_msg(toast_msg, outcome.post_status)); // Limpia editing tras un commit exitoso — // el record ya está sincronizado (incluso // un NoChange cierra el modo edit). @@ -572,10 +346,12 @@ impl MetaUi { next_view, } => { match self.commit_morphism(mod_idx, &name, &inputs, ¶ms, cx) { - Ok(op_count) => { - let base = format!("morphism '{name}' OK ({op_count} op(s) aplicadas)"); - self.toast = - Some(append_compact_msg(base, self.tick_runtime_compact())); + Ok(outcome) => { + let base = format!( + "morphism '{name}' OK ({} op(s) aplicadas)", + outcome.changed + ); + self.toast = Some(append_compact_msg(base, outcome.post_status)); if let Some(v) = next_view { self.select_view(mod_idx, v, cx); } @@ -591,10 +367,8 @@ impl MetaUi { } } - /// Despacha un morphism al pipeline real de nakui-core: lee - /// inputs (UUIDs) + params (Value object) del form, llama - /// `execute_and_log_with_recovery`. Devuelve la cantidad de ops - /// que el morphism produjo (para feedback). + /// Despacha un morphism via el backend. Resuelve inputs (UUIDs) + /// + params (Value object) leyendo los TextInput del form. fn commit_morphism( &mut self, mod_idx: usize, @@ -602,31 +376,16 @@ impl MetaUi { inputs_map: &BTreeMap, params_fields: &[String], cx: &mut Context, - ) -> Result { - let _ = cx; + ) -> Result { let module = self .modules .get(mod_idx) .ok_or_else(|| "módulo inválido".to_string())?; - let executor = self - .executors - .get(&module.id) - .ok_or_else(|| { - format!( - "módulo '{}' no tiene executor nakui (falta nakui_module_dir o falló la carga)", - module.id - ) - })? - .clone(); - let log_arc = self - .event_log - .as_ref() - .ok_or_else(|| "morphism requiere event log activo".to_string())? - .clone(); + let module_id = module.id.clone(); // Resolver inputs: por cada (role, field_name), parsear el // value del input como Uuid. - let mut input_pairs: Vec<(String, Uuid)> = Vec::with_capacity(inputs_map.len()); + let mut inputs: BTreeMap = BTreeMap::new(); for (role, field_name) in inputs_map { let raw = self .form_inputs @@ -638,12 +397,11 @@ impl MetaUi { "input '{role}' (field '{field_name}'): '{raw}' no es UUID válido" ) })?; - input_pairs.push((role.clone(), id)); + inputs.insert(role.clone(), id); } // Resolver params: si la lista está vacía, todos los fields - // del form que no estén en `inputs_map` van a params. Si - // hay lista, sólo esos. + // del form que no estén en `inputs_map` van a params. let input_field_set: std::collections::BTreeSet<&String> = inputs_map.values().collect(); let mut params_obj = serde_json::Map::new(); let field_iter: Vec = if params_fields.is_empty() { @@ -656,17 +414,14 @@ impl MetaUi { params_fields.to_vec() }; - // Buscamos los FieldSpec del Form view activo para conocer - // el `kind` declarado de cada param. Usamos `parse_field_value` - // estricto en lugar de la heurística `infer_param_value` — - // así un "abc" en un campo Boolean rebota en la UI con un - // mensaje claro ANTES de llegar al morphism Rhai. - let active_form_fields: Option> = self.active.as_ref().and_then(|(_, vk)| { - module.views.get(vk).and_then(|v| match v { - View::Form(f) => Some(f.fields.clone()), - _ => None, - }) - }); + // FieldSpec del Form view activo para parseo estricto por kind. + let active_form_fields: Option> = + self.active.as_ref().and_then(|(_, vk)| { + module.views.get(vk).and_then(|v| match v { + View::Form(f) => Some(f.fields.clone()), + _ => None, + }) + }); for field_name in field_iter { let raw = self @@ -681,40 +436,20 @@ impl MetaUi { params_obj.insert(field_name, value); } - let inputs_ref: Vec<(&str, Uuid)> = input_pairs - .iter() - .map(|(r, id)| (r.as_str(), *id)) - .collect(); - - let mut log = log_arc - .lock() - .map_err(|_| "log mutex envenenado".to_string())?; - let mut store = self - .store - .lock() - .map_err(|_| "store mutex envenenado".to_string())?; - - let ops = execute_and_log_with_recovery( - &executor, - &mut *store, - &mut *log, - morphism, - &inputs_ref, - Value::Object(params_obj), - ) - .map_err(|e| format!("{e}"))?; - Ok(ops.len()) + self.backend + .morphism(&module_id, morphism, inputs, Value::Object(params_obj)) } - /// Construye un Value desde los TextInput vivos y lo seedea al store. - /// Resultado de `commit_seed`. Distingue alta nueva vs edit - /// efectivo vs no-op para que el toast sea preciso. + /// Construye un payload desde los TextInput vivos y delega al + /// backend (`seed` para alta nueva, `update` con set+clear para + /// edit). Devuelve el `WriteOutcome` del backend (incluye + /// `changed` y `post_status` para el toast). fn commit_seed( &mut self, mod_idx: usize, entity: &str, cx: &mut Context, - ) -> Result { + ) -> Result { let module = &self.modules[mod_idx]; let spec_fields: Vec = match self.active.as_ref() { Some((_, view_key)) => match module.views.get(view_key) { @@ -724,14 +459,11 @@ impl MetaUi { None => return Err("ninguna vista activa".into()), }; let mut obj = serde_json::Map::new(); - // Fields que el form deja vacíos y son optional: candidatos a - // `FieldOp::Clear` en el path de EDIT (sólo se emiten si el - // current store value tiene algo en ese key). En el SEED path - // simplemente no se incluyen, igual que antes. + // Fields que el form deja vacíos y son optional: candidatos + // a Clear en el path de EDIT. let mut to_clear: Vec = Vec::new(); - // EntityRef references a chequear existencia DESPUÉS del parse - // loop (necesitan lock del store; lo tomamos una sola vez). - // Tuple: (label legible, target entity, parsed UUID). + // EntityRef refs a validar tras parse loop (en una toma del + // backend en lugar de N). let mut entity_refs: Vec<(String, String, Uuid)> = Vec::new(); for f in &spec_fields { let raw = self @@ -748,10 +480,6 @@ impl MetaUi { } let value = parse_field_value(f.kind, &raw) .map_err(|e| format!("campo '{}': {e}", f.label))?; - // Si el field es EntityRef y declara ref_entity, encolamos - // el (label, target, uuid) para validar existence en lote. - // El UUID ya está bien-formado (parse_field_value lo - // validó); ahora chequeamos que el record exista. if f.kind == FieldKind::EntityRef { if let (Some(target), Some(uuid_str)) = (&f.ref_entity, value.as_str()) { let id = Uuid::parse_str(uuid_str) @@ -761,167 +489,52 @@ impl MetaUi { } obj.insert(f.name.clone(), value); } - // Validar EntityRefs contra el store actual. Una sola toma del - // lock para todas las refs. + // Validar EntityRefs contra el backend actual. Cierre wrappea + // backend.load_record para mantener la firma de + // validate_entity_refs (que es store-agnóstica). if !entity_refs.is_empty() { - let store = self - .store - .lock() - .map_err(|_| "store mutex envenenado".to_string())?; - // El helper de yahweh-meta-runtime es store-agnóstico — - // toma un cierre `Fn(&str, Uuid) -> Option` que - // wrappea el store concreto. - validate_entity_refs(|e, id| store.load(e, id), &entity_refs)?; + let backend = &self.backend; + validate_entity_refs(|e, id| backend.load_record(e, id), &entity_refs)?; } - // Ramificación: si `editing` está set para esta entity, es un - // edit de un record existente — emitimos Morphism con un - // FieldOp::Set por cada campo del form (sobreescribe). Si no, - // es alta nueva — emitimos Seed con UUID fresco. + + // Ramificación: edit (hay editing para esta entity) vs alta. let editing_match = self.editing.as_ref().filter(|(e, _)| e == entity).cloned(); if let Some((_, id)) = editing_match { - // EDIT path: delta-only. Cargar el record actual del store - // y emitir `FieldOp::Set` por cada campo cuyo valor nuevo - // difiere del actual + `FieldOp::Clear` por cada optional - // empty cuyo current tenía un valor non-null. Si nada - // cambió, ningún log entry y ningún apply. - let current: Value = { - let store = self - .store - .lock() - .map_err(|_| "store mutex envenenado".to_string())?; - store.load(entity, id).unwrap_or(Value::Null) - }; + // EDIT: cargar current, computar delta, llamar + // backend.update con set+clear pre-computados. + let current = self.backend.load_record(entity, id).unwrap_or(Value::Null); let set_delta = compute_field_delta(¤t, &obj); let clear_fields = compute_clear_fields(¤t, &to_clear); - - if set_delta.is_empty() && clear_fields.is_empty() { - // No-op edit: no entry al log, no apply. - return Ok(CommitOutcome::NoChange(id)); - } - - let mut ops: Vec = set_delta - .iter() - .map(|(field, value)| FieldOp::Set { - path: FieldPath { - entity: entity.to_string(), - id, - field: field.clone(), - }, - value: value.clone(), - }) - .collect(); - for field in &clear_fields { - ops.push(FieldOp::Clear { - path: FieldPath { - entity: entity.to_string(), - id, - field: field.clone(), - }, - }); - } - - 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(); - let mut params = serde_json::Map::new(); - params.insert("entity".into(), json!(entity)); - params.insert("id".into(), json!(id.to_string())); - if !set_delta.is_empty() { - params.insert("fields".into(), Value::Object(set_delta.clone())); - } - if !clear_fields.is_empty() { - params.insert( - "cleared".into(), - Value::Array( - clear_fields.iter().map(|s| json!(s)).collect(), - ), - ); - } - log.append(LogEntry::Morphism { - seq, - morphism: "ui.edit_record".into(), - inputs: Default::default(), - params: Value::Object(params), - ops: ops.clone(), - schema_hash: None, - }) - .map_err(|e| format!("append al log: {e}"))?; - } - let mut store = self - .store - .lock() - .map_err(|_| "store mutex envenenado".to_string())?; - store.apply(&ops).map_err(|e| format!("apply edit ops: {e}"))?; - Ok(CommitOutcome::Updated { - id, - changed: set_delta.len() + clear_fields.len(), - }) + self.backend.update(entity, id, set_delta, clear_fields) } else { - // SEED path: alta nueva. - let id = Uuid::new_v4(); - let data = Value::Object(obj); - 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(); - log.append(LogEntry::Seed { - seq, - entity: entity.to_string(), - id, - data: data.clone(), - schema_hash: None, - }) - .map_err(|e| format!("append al log: {e}"))?; - } - let mut store = self - .store - .lock() - .map_err(|_| "store mutex envenenado".to_string())?; - store.seed(entity, id, data); - Ok(CommitOutcome::Created(id)) + // SEED: alta nueva — el backend genera el Uuid. + self.backend.seed(entity, obj) } } - /// Snapshot ordenado de records de una entity. + /// Snapshot ordenado de records de una entity (proxy al backend). fn list_rows(&self, entity: &str) -> Vec<(Uuid, Value)> { - let store = match self.store.lock() { - Ok(g) => g, - Err(_) => return Vec::new(), - }; - let it = match store.iter() { - Ok(i) => i, - Err(_) => return Vec::new(), - }; - it.filter(|(e, _, _)| e == entity) - .map(|(_, id, v)| (id, v)) - .collect() + self.backend.list_records(entity) } } -/// Resultado de `commit_seed`. Distingue alta nueva, edit efectivo -/// con N campos modificados, y no-op (delta vacío en el path de edit). -#[derive(Debug, Clone, PartialEq, Eq)] -enum CommitOutcome { - Created(Uuid), - Updated { id: Uuid, changed: usize }, - NoChange(Uuid), -} - -impl CommitOutcome { - fn id(&self) -> Uuid { - match self { - Self::Created(id) | Self::Updated { id, .. } | Self::NoChange(id) => *id, - } +/// Formatea el toast para la rama Action::SeedEntity según el +/// `WriteOutcome` del backend. `was_editing` distingue "creado" +/// vs "actualizado" — el WriteOutcome solo no alcanza porque +/// `seed` y `update` ambos devuelven `id = Some(...)`. +fn format_seed_toast(entity: &str, was_editing: bool, outcome: &WriteOutcome) -> String { + let id_short = outcome + .id + .map(|id| short_uuid(&id)) + .unwrap_or_default(); + match (was_editing, outcome.changed) { + (false, _) => format!("creado {entity} {id_short}"), + (true, 0) => format!("{entity} {id_short} sin cambios — no log entry"), + (true, n) => format!("actualizado {entity} {id_short} ({n} campo(s))"), } } -/// Concatena un mensaje de compact opcional al toast del op original. -/// Devuelve el toast resultante listo para ir a `SharedString`. -/// Sin `compact_msg` devuelve `base` tal cual. /// Carga UiModules desde un directorio via el brazo unificado /// `brahman_cards::load_cards_from_dir`. Aplica las reglas /// específicas de la UI: @@ -973,69 +586,6 @@ fn append_compact_msg(base: String, compact_msg: Option) -> SharedString } } -/// Devuelve el path del snapshot sibling para un log dado: -/// `nakui-ui-state.jsonl` → `nakui-ui-state.snap.json`. Mantiene el -/// snapshot junto al log para que un usuario pueda mover la pareja -/// sin desincronizarlos. -fn snapshot_path_for(log_path: &std::path::Path) -> PathBuf { - log_path.with_extension("snap.json") -} - -/// Si el log file tiene >= `threshold` entries, captura un snapshot -/// del store actual y compacta el log. Idempotente abajo del threshold. -/// -/// Cursor invariant: `EventLog::open` re-deriva `next_seq` del primer -/// entry del archivo. Si compactáramos *todo*, al reabrir el cursor -/// volvería a 0 — el próximo append crashearía con NonMonotonic. Por -/// eso siempre dejamos la última entry como anchor: compactamos sólo -/// hasta `next_seq - 2`. La survivor entry del snap.seq queda en -/// disco pero `replay_with_snapshot_into` la skipea (snap ya cubre -/// hasta snap.seq inclusive), así el costo es 1 línea de log y el -/// resultado del replay es idéntico. -/// -/// Orden de operaciones (importa por crash safety): -/// 1. Capturar snapshot en memoria. -/// 2. `Snapshot::write` (atómico via tempfile + fsync + rename). -/// 3. `EventLog::compact_through` (atómico igual). -/// Si (3) falla tras (2) éxito, el próximo boot ve snap@K + log con -/// entries 0..N — `replay_with_snapshot_into` skipea las cubiertas -/// por snap, outcome idéntico. -/// -/// Devuelve `Ok(Some(msg))` si compactó, `Ok(None)` si no había -/// nada que hacer, `Err(s)` si snapshot/compact falló. -fn maybe_compact_log( - log: &mut EventLog, - snap_path: &std::path::Path, - store: &MemoryStore, - threshold: usize, -) -> Result, String> { - if threshold == 0 { - return Ok(None); - } - let entry_count = log - .entries() - .map_err(|e| format!("read entries: {e}"))? - .len(); - if entry_count < threshold || entry_count < 2 { - // < 2 entries: la regla "dejar 1 como anchor" no permite - // dropear nada útil (sólo el anchor sobreviviría). - // entry_count { + Ok(outcome) => { let base = format!( "borrado {entity_for_confirm} {}", short_uuid(&id_owned) ); this.toast = Some(append_compact_msg( base, - this.tick_runtime_compact(), + outcome.post_status, )); } Err(e) => { @@ -1757,6 +1307,16 @@ impl MetaUi { mod tests { use super::*; + // Helpers de persistencia movidos al backend en Fase 2b. + use crate::backend::{maybe_compact_log, snapshot_path_for}; + // Tests E2E de nakui-core que vivieron históricamente en este + // crate y siguen acá (no son duplicados con yahweh-meta-runtime). + use nakui_core::event_log::{ + replay_with_snapshot_into, EventLog, LogEntry, Snapshot, + }; + use nakui_core::store::{MemoryStore, Store}; + use serde_json::json; + // NOTA: `parse_field_value` / `parse_field_*` viven y se testean // en `yahweh-meta-runtime`. Tests duplicados aquí se borraron en // la Fase 2 del refactor yahweh. diff --git a/crates/modules/ui_engine/libs/meta-runtime/src/backend.rs b/crates/modules/ui_engine/libs/meta-runtime/src/backend.rs new file mode 100644 index 0000000..8c733d5 --- /dev/null +++ b/crates/modules/ui_engine/libs/meta-runtime/src/backend.rs @@ -0,0 +1,368 @@ +//! `MetaBackend` trait — la frontera entre el widget metainterfaz +//! (yahweh) y la implementación concreta de persistencia/ejecución +//! (nakui-core, Surreal, mocks para tests). +//! +//! El widget consume este trait; el binario lo implementa con su +//! stack particular. Esto es lo que hace que el widget sea reusable. +//! +//! Convenciones documentadas en el doc del trait abajo. + +use std::collections::BTreeMap; + +use serde_json::Value; +use uuid::Uuid; + +/// Resultado uniforme de una operación de escritura del backend. +/// +/// La UI lo usa para componer el toast: `id` para mostrar el +/// short_uuid, `changed` para diferenciar "actualizado X (3 campos)" +/// vs "sin cambios", `post_status` para concatenar mensajes +/// emitidos por hooks internos del backend (ej. "auto-compact: +/// snapshot @ seq 49") sin que la UI tenga que conocer el detalle. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct WriteOutcome { + /// Id del record afectado. `Some` para seed/update/delete; + /// `None` para morphism cuando afecta múltiples records. + pub id: Option, + /// Cantidad de cambios efectivos. `0` = no-op (edit que no + /// modificó ningún campo, etc.). + pub changed: usize, + /// Mensaje de status opcional para concatenar al toast del op + /// original con el separator estándar. + pub post_status: Option, +} + +impl WriteOutcome { + /// Constructor para no-op writes (edits sin cambios). + pub fn no_change(id: Uuid) -> Self { + Self { + id: Some(id), + changed: 0, + post_status: None, + } + } +} + +/// Backend que un widget de metainterfaz usa para leer y mutar +/// records. Decoupla el widget (yahweh) de la implementación +/// concreta (nakui-core, Surreal, mock para tests). +/// +/// # Convención sobre ids +/// +/// `Uuid` canónico. Backends que internamente usan otros tipos +/// deben mapear via Uuid (hash determinista, wrapper, lo que sirva). +/// Esto evita generic associated types que complicarían el dispatch +/// en `cx.listener` de GPUI. +/// +/// # Convención sobre validación +/// +/// El backend ES la fuente de verdad sobre invariantes (KCL/Nickel +/// post-checks, conservación, etc.). El widget pre-valida shape +/// (yahweh-meta-runtime: `parse_field_value`, `validate_entity_refs`) +/// pero el backend puede rebotar con `Err(...)` si su validación +/// adicional falla — el widget muestra el error al usuario. +/// +/// # Convención sobre threading +/// +/// `'static` (no `Send + Sync`): el widget vive en `Entity>` +/// que requiere `'static`, pero los handlers son single-threaded en +/// el main UI thread de GPUI. Si en el futuro un backend necesita +/// `cx.spawn`, agregamos los marker traits. +/// +/// # Convención sobre delta computation +/// +/// El widget pre-computa `set` y `clear` con +/// [`crate::delta::compute_field_delta`] + +/// [`crate::delta::compute_clear_fields`] *antes* de llamar a +/// [`MetaBackend::update`]. El backend no recomputa: si recibe ambos +/// vacíos devuelve `changed = 0` sin escribir nada. Esto evita +/// double-roundtrip al store por el mismo dato. +pub trait MetaBackend: 'static { + /// Snapshot ordenado de records de una entity. + /// Orden estable (lexicográfico por id) para UI determinista. + /// Vacío si no hay records. + fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)>; + + /// Lee un record por id. `None` si no existe. + fn load_record(&self, entity: &str, id: Uuid) -> Option; + + /// Crea un record nuevo. El backend asigna el `Uuid` + /// (devuelve en `WriteOutcome.id`). `changed = 1` siempre. + fn seed( + &mut self, + entity: &str, + data: serde_json::Map, + ) -> Result; + + /// Edita un record existente. Aplica `set` (overrides) y + /// `clear` (key removal). `changed = set.len() + clear.len()`. + /// Si ambos están vacíos (no-op edit), devuelve + /// `WriteOutcome::no_change(id)` sin error y sin escribir al log. + fn update( + &mut self, + entity: &str, + id: Uuid, + set: serde_json::Map, + clear: Vec, + ) -> Result; + + /// Borra un record. `changed = 1` si existía, error si no. + fn delete(&mut self, entity: &str, id: Uuid) -> Result; + + /// Ejecuta un morphism declarado por un módulo. El backend + /// resuelve la implementación, valida, computa ops, las aplica. + /// `changed = N ops aplicadas`. + /// + /// `module_id` ubica al módulo (el trait no asume estructura del + /// manifest — el backend lo resuelve internamente). + fn morphism( + &mut self, + module_id: &str, + name: &str, + inputs: BTreeMap, + params: Value, + ) -> Result; +} + +#[cfg(test)] +mod tests { + //! Tests del trait via un `MemBackend` mínimo (HashMap por + //! `(entity, uuid)`). Verifica el contrato del trait sin atar + //! a un backend concreto. + + use super::*; + use serde_json::json; + use std::collections::HashMap; + + /// Mock backend in-memory. NO soporta morphisms (devuelve error + /// inmediato) — un mock para tests del trait, no para uso real. + #[derive(Default)] + struct MemBackend { + records: HashMap<(String, Uuid), Value>, + } + + impl MetaBackend for MemBackend { + fn list_records(&self, entity: &str) -> Vec<(Uuid, Value)> { + let mut out: Vec<(Uuid, Value)> = self + .records + .iter() + .filter(|((e, _), _)| e == entity) + .map(|((_, id), v)| (*id, v.clone())) + .collect(); + out.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes())); + out + } + + fn load_record(&self, entity: &str, id: Uuid) -> Option { + self.records.get(&(entity.to_string(), id)).cloned() + } + + fn seed( + &mut self, + entity: &str, + data: serde_json::Map, + ) -> Result { + let id = Uuid::new_v4(); + self.records + .insert((entity.to_string(), id), Value::Object(data)); + Ok(WriteOutcome { + id: Some(id), + changed: 1, + post_status: None, + }) + } + + fn update( + &mut self, + entity: &str, + id: Uuid, + set: serde_json::Map, + clear: Vec, + ) -> Result { + if set.is_empty() && clear.is_empty() { + return Ok(WriteOutcome::no_change(id)); + } + let rec = self + .records + .get_mut(&(entity.to_string(), id)) + .ok_or_else(|| format!("not found: {entity}/{id}"))?; + let map = rec + .as_object_mut() + .ok_or_else(|| format!("not an object: {entity}/{id}"))?; + let changed = set.len() + clear.len(); + for (k, v) in set { + map.insert(k, v); + } + for k in clear { + map.remove(&k); + } + Ok(WriteOutcome { + id: Some(id), + changed, + post_status: None, + }) + } + + fn delete(&mut self, entity: &str, id: Uuid) -> Result { + self.records + .remove(&(entity.to_string(), id)) + .ok_or_else(|| format!("not found: {entity}/{id}"))?; + Ok(WriteOutcome { + id: Some(id), + changed: 1, + post_status: None, + }) + } + + fn morphism( + &mut self, + _module_id: &str, + name: &str, + _inputs: BTreeMap, + _params: Value, + ) -> Result { + Err(format!("MemBackend no soporta morphism '{name}'")) + } + } + + fn map_of(items: &[(&str, Value)]) -> serde_json::Map { + items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect() + } + + #[test] + fn seed_then_load_round_trip() { + let mut b = MemBackend::default(); + let out = b + .seed("Customer", map_of(&[("name", json!("Acme"))])) + .unwrap(); + let id = out.id.expect("seed devuelve id"); + assert_eq!(out.changed, 1); + assert!(out.post_status.is_none()); + + let rec = b.load_record("Customer", id).unwrap(); + assert_eq!(rec.get("name"), Some(&json!("Acme"))); + } + + #[test] + fn list_records_filters_by_entity_and_orders_stably() { + let mut b = MemBackend::default(); + let _ = b.seed("A", map_of(&[("k", json!(1))])).unwrap(); + let _ = b.seed("B", map_of(&[("k", json!(2))])).unwrap(); + let _ = b.seed("A", map_of(&[("k", json!(3))])).unwrap(); + + let a = b.list_records("A"); + assert_eq!(a.len(), 2); + let b_only = b.list_records("B"); + assert_eq!(b_only.len(), 1); + let none = b.list_records("Missing"); + assert!(none.is_empty()); + + // Orden estable: re-llamadas devuelven mismo orden. + let a_again = b.list_records("A"); + assert_eq!( + a.iter().map(|(id, _)| *id).collect::>(), + a_again.iter().map(|(id, _)| *id).collect::>(), + ); + } + + #[test] + fn update_with_set_changes_field() { + let mut b = MemBackend::default(); + let id = b + .seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))])) + .unwrap() + .id + .unwrap(); + + let out = b + .update( + "Customer", + id, + map_of(&[("name", json!("Acme S.A."))]), + vec![], + ) + .unwrap(); + assert_eq!(out.changed, 1); + assert_eq!(out.id, Some(id)); + + let rec = b.load_record("Customer", id).unwrap(); + assert_eq!(rec.get("name"), Some(&json!("Acme S.A."))); + assert_eq!(rec.get("notes"), Some(&json!("x")), "notes intacto"); + } + + #[test] + fn update_with_clear_removes_key() { + let mut b = MemBackend::default(); + let id = b + .seed("Customer", map_of(&[("name", json!("Acme")), ("notes", json!("x"))])) + .unwrap() + .id + .unwrap(); + + let out = b + .update("Customer", id, serde_json::Map::new(), vec!["notes".into()]) + .unwrap(); + assert_eq!(out.changed, 1); + + let rec = b.load_record("Customer", id).unwrap(); + assert_eq!(rec.get("name"), Some(&json!("Acme"))); + assert!(rec.get("notes").is_none(), "notes debería estar borrado"); + } + + #[test] + fn update_with_empty_set_and_clear_returns_no_change() { + let mut b = MemBackend::default(); + let id = b + .seed("Customer", map_of(&[("name", json!("Acme"))])) + .unwrap() + .id + .unwrap(); + + let out = b + .update("Customer", id, serde_json::Map::new(), vec![]) + .unwrap(); + assert_eq!(out, WriteOutcome::no_change(id)); + } + + #[test] + fn update_on_missing_record_errors() { + let mut b = MemBackend::default(); + let id = Uuid::new_v4(); + let err = b + .update("Customer", id, map_of(&[("x", json!(1))]), vec![]) + .unwrap_err(); + assert!(err.contains("not found")); + } + + #[test] + fn delete_removes_and_then_load_returns_none() { + let mut b = MemBackend::default(); + let id = b + .seed("Customer", map_of(&[("name", json!("Acme"))])) + .unwrap() + .id + .unwrap(); + let out = b.delete("Customer", id).unwrap(); + assert_eq!(out.changed, 1); + assert_eq!(out.id, Some(id)); + assert!(b.load_record("Customer", id).is_none()); + } + + #[test] + fn delete_on_missing_record_errors() { + let mut b = MemBackend::default(); + let id = Uuid::new_v4(); + assert!(b.delete("Customer", id).is_err()); + } + + /// Sanity: el trait acepta llamadas via `&mut dyn MetaBackend` + /// (object-safety). Esto permite que el widget tenga + /// `Box` si el use case requiere borrado de + /// tipo (vs. el path normal con `MetaApp`). + #[test] + fn trait_is_object_safe() { + let mut b: Box = Box::new(MemBackend::default()); + let _ = b.seed("X", map_of(&[("k", json!(1))])).unwrap(); + assert_eq!(b.list_records("X").len(), 1); + } +} diff --git a/crates/modules/ui_engine/libs/meta-runtime/src/lib.rs b/crates/modules/ui_engine/libs/meta-runtime/src/lib.rs index 788f854..b470f5b 100644 --- a/crates/modules/ui_engine/libs/meta-runtime/src/lib.rs +++ b/crates/modules/ui_engine/libs/meta-runtime/src/lib.rs @@ -22,11 +22,13 @@ #![forbid(unsafe_code)] +pub mod backend; pub mod delta; pub mod format; pub mod parse; pub mod refs; +pub use backend::{MetaBackend, WriteOutcome}; pub use delta::{compute_clear_fields, compute_field_delta}; pub use format::{human_label_for_record, render_value, short_uuid, value_to_input_text}; pub use parse::{infer_param_value, parse_field_value, resolve_param_value};