refactor(yahweh): Fase 2b — MetaBackend trait + NakuiBackend + MetaUi consume el backend
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) <noreply@anthropic.com>
2462aca4442efbd2bdd4c65b85bc62c44cf1d626 · by Sergio · 2026-05-10T01:48:49Z (5 months ago) · parent 610448449 · browse the tree at this commit
Semantic delta · 61
crates/apps/nakui-ui/src/backend.rs
crates/apps/nakui-ui/src/main.rs
crates/modules/ui_engine/libs/meta-runtime/src/backend.rs
crates/modules/ui_engine/libs/meta-runtime/src/lib.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 <sha>`.
## 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<Mutex<MemoryStore>>`,
+ `Option<Arc<Mutex<EventLog>>>`, `BTreeMap<id, Arc<Executor>>`,
+ `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<String>` 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<B: MetaBackend>`
+ genérico, `nakui-ui` queda como ~50 líneas de shell con
+ `MetaApp::<NakuiBackend>::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<Option<String>, 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<String>,
+ /// 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<String>,
+}
+
+/// 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<Mutex<MemoryStore>>,
+ /// Log persistente. `None` si abrir falló — el backend degrada
+ /// a in-memory only (writes no se persisten; reads siguen).
+ event_log: Option<Arc<Mutex<EventLog>>>,
+ /// 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<String, Arc<Executor>>,
+ /// 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<String, Arc<Executor>>,
+ ) -> (Self, OpenStatus) {
+ let snap_path = snapshot_path_for(&log_path);
+ let mut store = MemoryStore::new();
+ let mut init_toast: Option<String> = None;
+ let mut load_error: Option<String> = None;
+
+ // Cargar snapshot (si existe).
+ let snapshot: Option<Snapshot> = 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<String> {
+ 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<Value> {
+ self.store.lock().ok()?.load(entity, id)
+ }
+
+ fn seed(
+ &mut self,
+ entity: &str,
+ data: serde_json::Map<String, Value>,
+ ) -> Result<WriteOutcome, String> {
+ 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<String, Value>,
+ clear: Vec<String>,
+ ) -> Result<WriteOutcome, String> {
+ 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<FieldOp> = 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<WriteOutcome, String> {
+ 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<String, Uuid>,
+ params: Value,
+ ) -> Result<WriteOutcome, String> {
+ 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<String, Value> {
+ 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<Module>,
- /// Store compartido. Mutado por el submit de los forms.
- store: Arc<Mutex<MemoryStore>>,
- /// 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<Arc<Mutex<EventLog>>>,
- /// 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<String, Arc<Executor>>,
+ /// 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<String, Entity<TextInput>>,
/// 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<SharedString>,
/// 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<SharedString> = None;
-
- // Cargar snapshot (si existe y no falla). Un snapshot
- // corrupto no es fatal: caemos a full replay del log.
- let snapshot: Option<Snapshot> = 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/<id>/), 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<String, Arc<Executor>> = 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<Value> = 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<String> {
- 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<WriteOutcome, String> {
+ 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<Self>) {
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<String, String>,
params_fields: &[String],
cx: &mut Context<Self>,
- ) -> Result<usize, String> {
- let _ = cx;
+ ) -> Result<WriteOutcome, String> {
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<String, Uuid> = 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<String> = 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<Vec<FieldSpec>> = 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<Vec<FieldSpec>> =
+ 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<Self>,
- ) -> Result<CommitOutcome, String> {
+ ) -> Result<WriteOutcome, String> {
let module = &self.modules[mod_idx];
let spec_fields: Vec<FieldSpec> = 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<String> = 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<Value>` 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<FieldOp> = 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<String>) -> 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<Option<String>, 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<threshold también incluye el caso post-compact
- // donde sobrevive 1 anchor: idempotente, no rebote infinito.
- 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
- )))
-}
-
/// Walker dentro de un `Value` por path con `.` como separador.
/// Local porque sólo lo usa la lista renderer y no tiene tests
/// dedicados afuera. Si crece su uso se puede mover a meta-runtime.
@@ -1199,14 +749,14 @@ impl MetaUi {
// toast tape el banner.
this.pending_delete = None;
match this.commit_delete(&entity_for_confirm, id_owned) {
- Ok(()) => {
+ 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<Uuid>,
+ /// 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<String>,
+}
+
+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<MetaApp<B>>`
+/// 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<Value>;
+
+ /// 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<String, Value>,
+ ) -> Result<WriteOutcome, String>;
+
+ /// 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<String, Value>,
+ clear: Vec<String>,
+ ) -> Result<WriteOutcome, String>;
+
+ /// Borra un record. `changed = 1` si existía, error si no.
+ fn delete(&mut self, entity: &str, id: Uuid) -> Result<WriteOutcome, String>;
+
+ /// 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<String, Uuid>,
+ params: Value,
+ ) -> Result<WriteOutcome, String>;
+}
+
+#[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<Value> {
+ self.records.get(&(entity.to_string(), id)).cloned()
+ }
+
+ fn seed(
+ &mut self,
+ entity: &str,
+ data: serde_json::Map<String, Value>,
+ ) -> Result<WriteOutcome, String> {
+ 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<String, Value>,
+ clear: Vec<String>,
+ ) -> Result<WriteOutcome, String> {
+ 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<WriteOutcome, String> {
+ 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<String, Uuid>,
+ _params: Value,
+ ) -> Result<WriteOutcome, String> {
+ Err(format!("MemBackend no soporta morphism '{name}'"))
+ }
+ }
+
+ fn map_of(items: &[(&str, Value)]) -> serde_json::Map<String, Value> {
+ 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::<Vec<_>>(),
+ a_again.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
+ );
+ }
+
+ #[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<dyn MetaBackend>` si el use case requiere borrado de
+ /// tipo (vs. el path normal con `MetaApp<B: MetaBackend>`).
+ #[test]
+ fn trait_is_object_safe() {
+ let mut b: Box<dyn MetaBackend> = 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};