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>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+116
-556
@@ -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,12 +414,9 @@ 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)| {
|
||||
// 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,
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user