Files
brahman/docs/changelog/nakui.md
T
sergio e187ab4cd3 feat(nakui-ui): CRM como ERP — UiModule con listas y formularios
examples/nakui-modules/crm/module.json: el módulo crm se ve ahora como
un ERP en nakui-ui (sidebar + listas + formularios), no sólo como el
timeline del event log. 7 vistas — lista+form de Clientes, Oportunidades
e Interacciones — con los formularios de morfismo Abrir/Mover/Registrar
que disparan los morfismos reales del kernel (nakui_module_dir engancha
el módulo crm). 2 tests verifican parseo, validación y carga por el
camino brahman_cards.

Correr: NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:35:35 +00:00

1315 lines
63 KiB
Markdown

# Changelog — nakui
ERP categórico.
### feat(nakui-ui): CRM como ERP — listas y formularios
UiModule `examples/nakui-modules/crm/module.json`: hace que el módulo
`crm` se vea como un ERP de verdad en `nakui-ui` (sidebar + listas +
formularios), no como el timeline del event log de `nakui-explorer`.
- 3 entities, 7 vistas: lista + formulario de Clientes, Oportunidades e
Interacciones, más los formularios de morfismo «Abrir», «Mover» y
«Registrar».
- `nakui_module_dir` engancha el módulo-kernel `crm`: el form de cliente
siembra (`seed_entity`); «Abrir/Mover/Registrar» disparan los morfismos
reales con la validación del kernel (etapas, transiciones, canal).
- 2 tests en `nakui-ui` verifican que el UiModule parsea, valida y carga
por el camino real (`brahman_cards::load_cards_from_dir`).
Correr: `NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui`.
### feat(nakui): módulo `crm` — clientes, pipeline de ventas, interacciones
Módulo CRM funcional, declarativo como inventory/sales/treasury
(`crates/modules/nakui/modules/crm/`): `schema.ncl` + `nsmc.json` +
morfismos Rhai. Tres entities — `Cliente`, `Oportunidad`, `Interaccion`
— y tres morfismos:
- `abrir_oportunidad(cliente)` — crea una `Oportunidad` en etapa
`prospecto`.
- `mover_oportunidad(oportunidad)` — avanza el pipeline
(prospecto→calificado→propuesta→negociacion→ganada/perdida). Valida:
etapa destino conocida, no mover una oportunidad cerrada, no retroceder.
- `registrar_interaccion(cliente)` — crea una `Interaccion`
(llamada/email/reunión).
- `core/src/bin/crm_demo.rs` — demo realista (3 clientes, 18 eventos).
A diferencia de los otros demos **no borra el event log**: lo deja en
disco e imprime el comando para abrirlo con `nakui-explorer` — así el
explorador, que sólo mostraba un log vacío, muestra un CRM con cuerpo.
- `core/tests/crm.rs` — 8 tests de integración (pipeline completo +
rechazos: retroceso, oportunidad cerrada, etapa/canal/monto inválidos).
Nota: el engine Rhai de `nakui-core` es `Engine::new_raw()` con paquetes
selectos — `Array::index_of` no resuelve; los morfismos usan sólo
`if`/`==`/`throw`.
Iter 17. Regresión surfaceada por el workspace test
`verify_log_rejects_seed_after_schema_kcl_changes` (rebautizado a
`verify_log_rejects_seed_after_schema_changes`).
**Bug**: `compute_schema_bundle_hash` operaba sobre los bytes del
bundle compilado, que `build_schema_bundle` arma como
`(import "/abs/path/schema.ncl") & ...`. Los bytes del bundle nunca
cambian cuando se edita el archivo apuntado — sólo cambian si se
agregan/quitan schemas o se mueve el módulo de path. Resultado: el
hash quedaba pegado y los seeds firmados bajo schema vN se aceptaban
silenciosamente como válidos bajo schema vN+1, aunque las invariantes
hubieran cambiado.
**Fix**: nueva fn `read_schema_files_concat(module_dir, schemas)`
que lee los bytes de cada schema declarado y los concatena con
framing `\0NCL:<name>\0` (separador no ambiguo + nombre relativo, no
absoluto, para que el hash sea estable entre máquinas). Esos bytes
alimentan `compute_schema_bundle_hash` y `compute_morphism_schema_hash`
en lugar de los bytes del bundle. El bundle compilado sigue siendo
imports-style (necesario para que Nickel resuelva paths relativos);
sólo la fuente del hash cambió.
**Impacto en logs existentes**: como cualquier cambio al insumo del
hash, los seeds y morphisms versados bajo el bundle hash anterior
fallarán `SchemaMismatch` al verificarse contra un binario nuevo. No
hay migración — esto es exactamente el comportamiento que el hash
busca: re-seed.
Tests: 10/10 en `schema_versioning` (era 9/10 con 1 FAILED).
### refactor(nakui-core): KCL → Nickel — `kcl_wrapper` reemplazado por evaluación in-process
Cierra el ciclo: el motor de validación de entities deja de
shellear el binario externo `kcl` y pasa a evaluar **Nickel
contracts** in-process via la dep `nickel-lang` (la misma que ya
usa `brahman-cards` para sus templates). Los 3 schemas de los
módulos sales/inventory/treasury migran de `.k` a `.ncl`.
Además se borran los 2 archivos `.k` doc-only del repo
(`ente-card/schema/card.k`, `ente-brain/schema/rule.k` — ambos
estaban marcados "REFERENCE ONLY. NOT LOADED").
Cambios en **nakui-core**:
- **Nueva dep**: `nickel-lang = "2.0.0"` (interfaz estable).
- **Borrado** `kcl_wrapper.rs` (43 líneas) — shellear el binario
desaparece.
- **Nuevo** `nickel_validator.rs`:
- `pub fn vet(schema_path, state, schema_name) -> Result<(), NickelError>`
evalúa `let bundle = (import "<schema>") in
(std.deserialize 'Json m%%"<json>"%%) | bundle.<entity>`.
- El state JSON va dentro de un raw string Nickel
(`m%%"..."%%`) y se deserialize via `std.deserialize 'Json`.
No embebemos el state como record literal Nickel directo
porque la sintaxis JSON usa `:` (Nickel records usan `=`).
- 5 tests propios cubriendo happy path + 4 fallure modes
(field missing, predicate fails, cross-field invariant
fails, optional field present/absent).
- **`executor.rs`**:
- `kcl_wrapper::vet``nickel_validator::vet`.
- `KclError``NickelError`.
- `ExecError::KclPre/KclPost/KclPostCreate``SchemaPre/Post/PostCreate`
(más neutro, ya no menciona KCL).
- `kcl_check` (privado) → `validate_entity`.
- `build_schema_bundle` ahora emite un archivo Nickel con
`(import "X") & (import "Y") & ...` en lugar de concatenar
bytes (cada `.ncl` es una expresión record completa, no
juntable como texto plano).
- **`manifest.rs`**:
- `effective_schemas` default `"schema.k"``"schema.ncl"`.
- `extract_schema_names` reescrito: ahora detecta keys
CapitalCase con 2 spaces de indent (convención de los
`schema.ncl`), no más patrón `schema X:` de KCL.
- Tests del extractor actualizados (1 test reemplazado por 2:
`_handles_nickel_record_top_level` + `_skips_let_bindings_and_lowercase`).
Cambios en **schemas de módulos**:
- **`sales/schema.ncl`**: contracts Nickel para `Venta`. Usa
`std.contract.Sequence [record_contract, from_predicate]`
para combinar shape + invariante cross-field
(`total == cantidad * precio_unitario`). El patrón directo
`record | from_predicate` rebota con "missing definition" porque
el predicate evalúa el contract antes de que el value lo
populate; documentado en el comment.
- **`inventory/schema.ncl`**: `Stock`, `MovimientoStock`,
`TransferenciaStock` (esta última con cross-field
`source != dest` via Sequence).
- **`treasury/schema.ncl`**: `Caja`, `Movimiento`,
`Transferencia` (con cross-field via Sequence).
- Helpers locales en cada archivo: `positive_int`,
`non_negative_int`, `currency_iso`, etc. via
`std.contract.from_predicate`.
- Los 3 `schema.k` viejos **borrados**.
- `sales/nsmc.json` actualizado: paths `schema.k`
`schema.ncl`.
Cambios en **tests**:
- `sales.rs`, `inventory.rs`: `KclPost``SchemaPost`.
- `kernel_guards.rs`: `KclPostCreate``SchemaPostCreate`,
path del schema directo `treasury/schema.k`
`treasury/schema.ncl`.
- `graph.rs`, `manifest_validation.rs`: tests que escriben
`schema.k` inline cambian a `schema.ncl` con sintaxis Nickel.
- `schema_versioning.rs`: refs `schema.k``schema.ncl`.
Cambios documentales:
- **Borrado** `crates/core/ente-card/schema/card.k` (1 archivo,
REFERENCE ONLY documentado en su header).
- **Borrado** `crates/core/ente-brain/schema/rule.k` (REFERENCE
ONLY documentado en su header).
Tests:
- **nakui-core**: 84 tests verdes (41 unit + 43 integration en
graph/event_log/manifest_validation/schema_versioning/
inventory/sales/kernel_guards). Suite full pasa.
- **nakui-ui**, **brahman-cards**, **yahweh-***: sin cambios,
todos verdes.
- Total cubriendo el área: 174 tests.
Beneficios:
- **Sin dep externa**: el binario `kcl` ya no es requisito de
runtime ni de tests. Build limpio en CI sin instalar KCL.
- **Errores en línea**: Nickel reporta contract violations con
caret pointing al field exacto del schema y el value que
falló. KCL daba mensajes textuales menos navegables.
- **Mismo motor que el brazo de cards**: una sola dependencia
Nickel para todo el repo (validación + templates de cards).
- **Sin tempfile JSON intermedio**: el state se evalúa
directamente en memoria; no hay `std::fs::write` por cada
validate.
Limitaciones / decisiones:
- El comentario "REFERENCE ONLY" de los `.k` borrados ya estaba
marcado en sus headers; eran sólo notas de diseño para humanos.
La autoridad real (Rust validate methods) sigue intacta.
- La sintaxis Nickel `record_contract | from_predicate` no
funciona — hay que envolver en `std.contract.Sequence [record,
from_predicate]`. Documentado en cada schema y en el doc del
validator.
**Pendientes restantes**: ninguno del refactor original. Los
yahweh + KCL + card.k cierran. Próximos pendientes salen de
nuevo trabajo (no del plan que arrastrábamos).
### feat(nakui-ui): migrar consumer al brazo unificado `brahman_cards::load_cards_from_dir`
Primera consumer migration del brazo. `nakui-ui` ya no llama a
`nakui_ui_schema::load_modules_from_dir` directamente — pasa por
`brahman_cards::load_cards_from_dir` y extrae el variant `UiModule`
del `CardBody` de cada Card. Beneficios concretos:
- **Soporta `.ncl` además de `.json`**: el usuario puede dropear un
`card.ncl` (con templates Nickel + merge) en cualquier subdir y
el runtime lo levanta automáticamente. El layout legacy
`examples/nakui-modules/<id>/module.json` sigue funcionando vía
los filenames default `[card.ncl, card.json, module.ncl, module.json]`.
- **Cards de otros body kinds (Ente/Monad) se skipean limpio**:
si el dir contiene Cards no-UiModule, se reportan en un toast
informativo en lugar de fallar la carga.
Cambios en `brahman-cards`:
- **Nuevo `load_cards_from_dir(dir)`** + variante con readers/filenames
custom. Walkea subdirs (orden lexicográfico), busca el primero de
`DEFAULT_CARD_FILENAMES`, dispatcha al reader. Subdirs sin ningún
filename matching se skipean silenciosamente (permite assets/fixtures
sueltos al lado de los cards). Errores per-file se propagan loud
(sin ocultar corrupción).
- **`pub const DEFAULT_CARD_FILENAMES`**: lista canónica probada en
orden. `card.ncl` tiene prioridad sobre `card.json` y sobre los
legacy `module.*`.
- **4 tests nuevos del helper**: walk + skip de subdirs sin
card, prioridad ncl > json, propagación loud de errores per-file,
custom filenames.
Cambios en `nakui-ui`:
- **Nueva dep** `brahman-cards` en `Cargo.toml`.
- **Nuevo helper `load_ui_modules(dir) -> (Vec<Module>, Vec<String>)`**
que envuelve `brahman_cards::load_cards_from_dir`, filtra a
UiModule body, valida cada Module con su `validate()`, ordena
por id, y detecta duplicados. El callsite en `MetaUi::new` pasa
a usarlo y al ver Cards skipped emite un toast informativo.
- **3 tests nuevos**:
- `load_ui_modules_via_brahman_cards_returns_ui_modules_and_skips_others`
— verifica que un dir con UiModule + Ente carga el primero y
reporta el segundo en `skipped`.
- `load_ui_modules_via_brahman_cards_rejects_invalid_module`
`Module::validate()` se sigue aplicando (menu apuntando a view
inexistente rebota).
- `load_ui_modules_detects_duplicate_id` — dos UiModule con
mismo id rebotan con mensaje claro.
Tests totales:
- `brahman-cards`: 22 → 26 (+4 helper directorio).
- `nakui-ui`: 45 → 48 (+3 e2e migración).
- Workspace build verde.
Lo que NO cambió:
- `nakui_ui_schema::load_modules_from_dir` se mantiene intacto (sus
propios tests lo siguen usando, y otros consumers futuros podrían
preferir su error-typing más específico). La migración es opt-in:
`nakui-ui` usa el brazo, ui-schema sigue siendo una API válida.
- Layout actual de `examples/nakui-modules/<id>/module.json` no
requiere cambio. Un usuario puede convertir cualquier módulo a
`card.ncl` sin tocar el dir layout.
**Pendientes para próximos commits** (orden):
1. **Yahweh refactor**: lift del MetaUi runtime a
`crates/modules/ui_engine/` para reuso. El brazo + canónico ya
estables, ahora puede extraerse el meta-form widget genérico.
2. **KCL → Nickel**: kcl_wrapper reemplazado por Nickel contracts;
los 3 schemas .k de nakui modules pasan a .ncl.
3. **card.k eliminado** (es REFERENCE ONLY documentado).
### feat(nakui-ui): validación cross-field del EntityRef (existence en store)
Cierra otro pendiente. Hasta ahora `parse_field_value(EntityRef, raw)`
sólo validaba **forma** (UUID parseable + trim de whitespace) — un
UUID válido pero inexistente en el store pasaba silenciosamente al
log/store, dejando dangling references. Ahora validamos también
**existencia** contra la entity declarada en `FieldSpec.ref_entity`.
Cambios:
- **Nuevo helper `validate_entity_refs<S: Store>(store, refs)`**:
- `refs: &[(label, target_entity, uuid)]`.
- Loop fail-fast: primer record ausente → error con label
legible + UUID corto + target entity en el msg
`"campo 'Stock': record abc12345 de 'Stock' no existe en el store"`.
- Pure (toma `&S: Store`), totalmente testable sin GPUI.
- **Wireup en `commit_seed`**:
- Durante el parse loop, cuando un field es EntityRef + tiene
`ref_entity` declarado + value parseado a UUID, lo encolamos
en `entity_refs: Vec<(String, String, Uuid)>`.
- Después del parse loop (antes del seed/edit branch), si
`entity_refs` no está vacío, una sola toma del store lock
para validar todos via el helper.
- Falla early: ningún log entry, ningún apply.
- **Cobertura**:
- SEED path: alta nueva con EntityRef → validamos antes de
`Seed { data }`.
- EDIT path: edit con EntityRef → validamos antes de calcular
delta. Una optional empty (que iría a clear) no cuenta como
EntityRef (raw vacío skipea el push).
- Morphism inputs: NO se duplica acá. `Executor::compute` ya
valida cada input via `store.load(...).ok_or(EntityMissing)`
antes de correr el script Rhai. Documentado en el doc del
helper.
5 tests nuevos:
- `validate_entity_refs_passes_when_all_records_exist` — happy path.
- `validate_entity_refs_fails_on_first_missing` — fail-fast con
msg que incluye entity + UUID corto.
- `validate_entity_refs_uses_label_not_entity_in_msg` — el label
legible (ej: "Stock origen") aparece en el error, no la entity
desnuda.
- `validate_entity_refs_empty_list_is_ok` — lista vacía es Ok.
- `validate_entity_refs_distinguishes_target_from_other_entities`
un UUID que existe bajo Customer pero NO Stock falla la
validación contra Stock.
45 tests verdes en nakui-ui (+5). Workspace build verde.
Comportamiento esperado:
- **Selector clickable es happy path**: el dropdown sólo lista
records existentes, así que clickearlo nunca debería disparar
el error. Sólo dispara con paste manual de UUID que no existe
o records borrados después de la selección (timing race).
- **Optional empty no se valida**: si el field es EntityRef
optional y el form lo deja vacío, lo manejamos como "no value"
(skipea el push a `entity_refs`); la lógica de Clear se
encarga del resto.
- **Lock contention**: una sola toma del store lock por
`commit_seed`, no una por field. La validación es O(refs) reads.
Pendientes restantes:
- **Validación KCL del record post-edit** antes de emitir Set/Clear
(hoy `ui.edit_record` no pasa por `Executor::compute`).
- **EntityRef cross-module** (referenciar records de OTRO módulo
por nombre, no sólo por entity local).
### feat(nakui-core,nakui-ui): FieldOp::Clear — borrar values vía form vacío
Cierra el último pendiente de UX del round. El edit no podía
"borrar" un value vaciando el input — empty optional fields
hacían `continue` en `commit_seed`, así que el current value
quedaba intacto. Para honrar el intent del usuario ("este field
ya no aplica") necesitábamos un FieldOp explícito que remueva
la key del map.
Cambios en **nakui-core** (la variante es semántica del kernel,
no específica de la UI):
- **`delta::FieldOp::Clear { path }`** — nueva variante.
Distinta de `Set { value: Null }`: Clear borra la clave; Set
Null deja la clave con valor literal `null`. Importa para
downstream que diferencia "ausente" vs "presente como null"
(ej: serde con `skip_serializing_if = "Option::is_none"`).
- **`capability_token`** — Clear devuelve `entity.field`,
mismo shape que Set. Una capability `writes: ["Customer.notes"]`
autoriza tanto Set como Clear sobre ese field.
- **`simulate_on`** — Clear remueve la key del Object si el
state es Some(Object). Skip silente si el state es None
(deleted) o no-objeto.
- **`MemoryStore::apply_dry_run`** — Set y Clear comparten
pre-condición (record padre existe + es objeto). Pattern
combinado con `|`.
- **`MemoryStore::apply`** — Clear hace `map.remove(field)`.
Field ausente = no-op silencioso (post-state idéntico).
- **`SurrealStore::apply_dry_run`** — Set/Clear combinados.
- **`SurrealStore::apply`** — Clear emite
`UPDATE type::thing UNSET <field>`. El field name viene de
un FieldSpec validado upstream; SurrealQL no soporta binding
de identifiers, así que va inline (con la advertencia
documentada en el comment).
- **`Executor` capability check** — Set/Clear comparten match
(mismo token shape, misma resolución a binding role).
- **Conservation rules** (en `check_conservation`) NO consideran
Clear — sólo Set. Documentado: morphism authors que querían
clear de un field con conservation tienen que ser cuidadosos;
KCL post-checks pueden capturar violations.
Cambios en **nakui-ui**:
- **`commit_seed` loop** acumula `to_clear: Vec<String>` con
los nombres de fields optional empty (en lugar de hacer
`continue` silencioso).
- **EDIT branch**:
- Computa `set_delta` (igual que antes) + `clear_fields` via
nuevo helper `compute_clear_fields(current, to_clear)`.
- Helper filtra a sólo los fields que actualmente tienen
valor non-null — Clear de un field ausente o ya null no
se emite (sería no-op semántico). Preserva el orden del
input para estabilidad del log entry.
- Construye `ops` combinando Set + Clear.
- NoChange ahora requiere AMBOS vacíos (set_delta y
clear_fields).
- `params` del log entry incluye `cleared: ["field1", ...]`
sólo si non-empty (preserva la shape `fields:` para
edits sin clears).
- `CommitOutcome::Updated.changed = sets + clears` para
que el toast `"actualizado X (N campo(s))"` siga siendo
preciso.
Tests nuevos:
- **delta.rs**: `simulate_clear_removes_field`,
`simulate_clear_then_set_same_field_keeps_set`,
`clear_capability_token_matches_set_shape`.
- **store.rs**: `apply_clear_removes_field_key`,
`apply_clear_on_absent_field_is_noop`,
`dry_run_rejects_clear_on_missing_record`,
`dry_run_rejects_clear_on_non_object`.
- **nakui-ui main.rs**: `clear_fields_skips_absent_and_null`,
`clear_fields_preserves_input_order`,
`clear_fields_empty_when_current_is_null`.
34 tests verdes en nakui-core (+7), 40 en nakui-ui (+3).
Workspace build verde. E2E del morphism real
`morphism_pipeline_executes_real_sales_vender` intacto — `vender`
no usa Clear.
Implicaciones:
- **El log puede crecer con entries `ui.edit_record` que sólo
tienen `cleared: [...]`** sin `fields`. Esperado y esperable.
- **Replay**: las entries con Clear se aplican en orden via
`store.apply(&ops)`. La semantic es deterministic.
- **Si un módulo tiene KCL invariants sobre la presencia de un
field**, el usuario podría romper el record vaciando ese
field via UI. Hoy esto NO se chequea — `ui.edit_record` es
un morphism manual que no pasa por `Executor::compute`. Si
esto es un problema, el camino futuro es validar contra el
KCL del entity al submit (otro pendiente).
Pendientes restantes:
- **Validación cross-field** (ej: UUID del EntityRef existe en
la entity referida).
- **Validación KCL del record post-edit** antes de emitir Set/Clear.
### feat(nakui-ui): snapshot/compaction durante runtime cada N writes
Cierra el último pending del round de persistencia. Antes el compact
sólo corría al startup — para una sesión larga con muchas escrituras,
el log crecía sin tope hasta el próximo restart, y el siguiente boot
pagaba el costo lineal del replay.
Cambios:
- **Nuevos fields en `MetaUi`**:
- `snap_path: PathBuf` — cacheado del init para que el tick no
tenga que recomputarlo.
- `snapshot_threshold: usize` — leído del env en `new()` y
cacheado. `0` desactiva runtime compact (mismo env y
semantic que el threshold de startup).
- `writes_since_compact: u64` — contador que incrementa por cada
write efectivo y se resetea cuando el threshold dispara
`maybe_compact_log`.
- **Nuevo método `tick_runtime_compact()`**:
- Early return si `threshold == 0`.
- Increment + check vs threshold.
- Si cruza: lock log + store, llama `maybe_compact_log`.
- **Si compactó OK**: counter = 0, devuelve msg.
- **Si `maybe_compact_log` returned None** (counter dijo "go"
pero entries < 2): counter = 0 (no re-entrar cada write).
- **Si error**: counter NO se resetea (próximo write reintenta),
devuelve el error.
- **Nuevo helper `append_compact_msg(base, opt)`**: concatena el
msg del compact al toast del op original con `";"` separator.
- **Wireup en 3 callsites de write efectivo**:
- `apply_action::SeedEntity`: tick si outcome != NoChange.
- `apply_action::Morphism`: tick siempre que Ok.
- Click handler `[Confirmar]` del delete modal: tick si commit_delete Ok.
- **NoChange no cuenta**: un edit que no cambia nada no escribe al
log, así que tampoco debería avanzar el counter — preserva la
semantic "1 write = 1 log entry = 1 tick".
2 tests nuevos:
- `append_compact_msg_handles_both_branches` — base solo vs base
+ compact, formato del separator.
- `runtime_compact_cycle_resets_counter_after_threshold` — E2E
estilo simulación: 7 writes con threshold=3 → 2 compacts (en
write 3 y 6), counter residual = 1, log final con 2 entries
(1 anchor + 1 write residual). Reproduce el algoritmo del tick
sin GPUI cx; si la lógica del método cambia, se rompe como signal.
37 tests verdes (+2). Workspace build verde.
Trade-offs:
- **Counter en memoria, no persistido**: si la app crashea entre
compacts, al próximo boot el counter parte de 0. El startup
compact (basado en entry_count del log file) compensa esto:
si quedó mucho post-último-compact, se compacta al boot.
- **Lock orden**: tick toma log lock primero, store lock después.
Misma orden que `commit_seed` y `commit_morphism`, no debería
haber deadlock.
- **Costo del tick**: 1 increment + 1 compare por write. Cuando
cruza threshold, 1 read del log (entries) + 1 snapshot write +
1 compact. Para threshold=50 es ~1 fsync cada 50 writes —
amortiza bien.
Pendientes restantes:
- **`FieldOp::Clear`** — para soportar borrar un value vía form vacío.
- **Validación cross-field** (UUID del EntityRef existe en la
entity referida).
### feat(nakui-ui): atajo Esc para cancelar el modal de delete
Cierra otro pendiente de UX. El banner de confirmación de delete
ya tenía botones [Cancelar] / [Confirmar], pero la acción más
natural para cancelar un dialog es Esc — y no la teníamos wireada.
Cambios:
- **`capture_key_down` en el root div** del `MetaUi::render`. Capture
phase (no bubble) para interceptar el Esc *antes* que cualquier
TextInput descendiente lo consuma. Sin pending el handler es
no-op y el evento sigue su flujo normal.
- **Match `event.keystroke.key == "escape"`** + `pending_delete.take()`
→ toast `"delete cancelado (Entity) [esc]"` (sufijo `[esc]` para
diferenciar visualmente del botón). Si no hay pending, return
temprano sin tocar nada.
- **Hint visual en el banner**: subtítulo en amber tenue debajo del
título: `"Esc para cancelar · click [Confirmar] para borrar"`.
Que el usuario descubra el atajo sin RTFM.
35 tests verdes — el handler de Esc es 8 líneas no-testeables sin
GPUI cx (la lógica de pending_delete + toast vive dentro del
listener); el wireup compila por type-check.
Pendientes restantes:
- **`FieldOp::Clear`** — para soportar borrar un value vía form vacío.
- **Snapshot durante runtime** (cada N writes, no sólo al startup).
- **Validación cross-field** (UUID del EntityRef existe en la
entity referida).
### feat(nakui-ui): EntityRef validation en parse_field_value (UUID al submit)
Cierra otro pendiente: `parse_field_value(FieldKind::EntityRef, raw)`
devolvía `Ok(json!(raw))` blindly — el value entraba al log/store
incluso si era basura. La validación de UUID sólo ocurría cuando el
field se usaba como **input** del morphism (línea ~540 de
`commit_morphism`); como **seed field** o como **param**, garbage
pasaba silenciosamente.
Cambios:
- **`parse_field_value(EntityRef, raw)`** ahora hace
`Uuid::parse_str(raw.trim())` y devuelve error claro si falla:
`"'<raw>' no es UUID válido (usá el selector de records)"`. En
caso de éxito, devuelve el UUID **trimmed** como string —
protege contra paste manual con whitespace.
- **Doble cobertura**: este path cubre seed fields (commit_seed via
obj.insert) y morphism params (resolve_param_value lo invoca por
cada FieldSpec con kind=EntityRef). El path de morphism inputs
ya validaba antes con `Uuid::parse_str` directo — sigue intacto,
no hay double-validation.
- **Selector clickable es happy path**: el dropdown setea valores
bien-formados, así que el usuario nunca debería ver el error en
uso normal. Sólo dispara con paste manual o si el usuario escribe
garbage en el input — defensivo.
5 tests nuevos (reemplazan al obsoleto `parse_field_entity_ref_returns_string`):
- `parse_field_entity_ref_accepts_valid_uuid` — happy path.
- `parse_field_entity_ref_trims_whitespace``" uuid\n"``"uuid"`.
- `parse_field_entity_ref_rejects_non_uuid``"abc-123"` → error
con el value y la palabra "UUID" en el mensaje.
- `parse_field_entity_ref_rejects_empty_string``""` → rebota.
- `resolve_param_strict_entity_ref_propagates_error` — sanity de
que el wireup en resolve_param_value hereda el strict checking,
con label del FieldSpec en el mensaje.
35 tests verdes (+4 net). El E2E del morphism real
`morphism_pipeline_executes_real_sales_vender` sigue verde — sus
inputs van por el path dedicado, no por parse_field_value.
Pendientes restantes:
- **Atajo Esc para Cancelar** del modal de delete.
- **`FieldOp::Clear`** — para soportar borrar un value vía form.
- **Snapshot durante runtime** (cada N writes, no sólo al startup).
- **Validación cross-field** (ej: el UUID del EntityRef existe en
la entity referida) — hoy sólo validamos forma; un UUID válido
pero inexistente sí pasa.
### feat(nakui-ui): snapshot/compaction automático del event log al startup
Cierra el último gran pendiente del round: el replay full cada
startup escala lineal en el log. Con 60+ entries el costo de boot
se nota; con 10k entries es prohibitivo. Wireamos el snapshot
machinery que ya estaba en `nakui-core` (`Snapshot`,
`replay_with_snapshot_into`, `EventLog::compact_through`) al
runtime de la UI.
Cambios:
- **Path del snapshot**: sibling del log, extensión `.snap.json`.
`nakui-ui-state.jsonl``nakui-ui-state.snap.json`.
- **Nuevo helper `snapshot_path_for(log_path)`** — derivación
pura, testeable.
- **Nuevo helper `maybe_compact_log(log, snap_path, store, threshold)`**:
- Si `entry_count >= threshold` y `>= 2`, captura
`Snapshot::from_memory_store(store, next_seq - 1)`, lo escribe
atómicamente, y compacta el log dejando la última entry como
anchor.
- Anchor invariant: `EventLog::open` deriva `next_seq` del primer
entry del archivo. Si compactáramos *todo* el log file, al
reabrir el cursor volvería a 0 y el próximo append crashearía
con `NonMonotonic`. Por eso compactamos sólo hasta
`next_seq - 2` — la entry del `snap.seq` queda como anchor del
cursor; `replay_with_snapshot_into` la skipea porque snap ya
cubre hasta ese seq inclusive.
- Threshold via env `NAKUI_SNAPSHOT_THRESHOLD`, default 50.
`0` desactiva por completo.
- Devuelve `Result<Option<msg>, String>`: `Ok(Some)` si compactó,
`Ok(None)` si no había payoff, `Err` si snap o compact fallaron.
- **`MetaUi::new` reescrito**:
- Carga snapshot al inicio (Some/None según exista).
- `replay_with_snapshot_into(&log, snapshot.as_ref(), &mut store)`
en lugar de `replay_into`.
- Después del replay corre `maybe_compact_log` con el threshold.
- Toast inicial menciona snapshot loaded si aplica
("snapshot @ seq K") y la compactación si ocurrió.
- Errores de snapshot load **no son fatales**: cae a full replay
con un msg en el banner.
- Errores de auto-compact **no son fatales**: el log + snap
quedan como estaban, msg al banner.
5 tests nuevos:
- `snapshot_path_for_replaces_extension``.jsonl``.snap.json`,
edge case sin extensión.
- `maybe_compact_log_below_threshold_noops` — 5 entries vs threshold
50: no toca nada, no escribe snap.
- `maybe_compact_log_threshold_zero_noops` — threshold 0 = disabled.
- `maybe_compact_log_then_reopen_preserves_records` — E2E:
- Escribe 60 seeds (log + store en sync).
- Compacta (60 >= 50): snap escrito, log queda con 1 anchor entry,
msg reporta "59 entries dropped (1 anchor kept)".
- Reopen: `next_seq=60` se preserva via anchor, `entries.len()=1`.
- Replay con snap loadado en store fresco: los 60 records están.
- Segunda corrida del compact con threshold=1: no-op (idempotente).
31 tests verdes (+5). Workspace build verde tras la nueva firma.
Trade-offs y notas:
- **Fail-soft**: cualquier error de snap/compact no rompe el boot;
la UI sigue funcionando con full replay y el toast lo reporta.
Sólo `EventLog::open` failing es no-recoverable (pierde
persistencia).
- **Crash-safety**: WAL order preservado — escribimos snap (atómico
via tempfile + fsync + rename) ANTES de compactar el log
(atómico igual). Si crasheamos entre los dos, próximo boot ve
snap@K + log con todas las entries 0..N — replay skippea las que
snap cubre, outcome idéntico.
- **Sólo en startup**: no hay snapshot durante runtime. Para sesiones
largas con muchas escrituras, el log puede crecer arbitrariamente
hasta el próximo restart. Pendiente futuro: snapshot N writes
desde el último compact.
- **Anchor entry sobrevive sin uso útil**: el costo es 1 línea JSON
por compact. No es preocupación a menos que el threshold sea
muy chico (cada compact deja 1 línea de basura).
Pendientes restantes:
- **EntityRef validation post-submit** — validar UUID parseable al
submit en lugar de al execute del morphism.
- **Atajo Esc para Cancelar** del modal de delete.
- **`FieldOp::Clear`** — para soportar borrar un value vía form vacío.
- **Snapshot durante runtime** (cada N writes, no sólo al startup).
### feat(nakui-ui): edit delta-only — sólo campos modificados al log/store
Antes de este cambio, editar un record emitía un `FieldOp::Set` por
**cada field del form**, incluso los no tocados. Eso bloata el log
(replay tenía que aplicar N ops cuando 1 alcanzaba) y oscurece el
intent en una auditoría posterior. Con delta-only, el edit emite
sólo los Sets cuyo value nuevo difiere del actual; un edit que no
cambia nada deja el log intacto.
Cambios:
- **Nuevo helper `compute_field_delta(current, proposed)`** — toma
el record actual del store (un `Value`, posible `Null` si el
record no existe) y el `Map` propuesto desde el form, y devuelve
sólo las entries que difieren. Comparación: `PartialEq` estructural
de `serde_json::Value` (un `Null` en current = todos los proposed
son nuevos).
- **Nuevo enum `CommitOutcome`**:
- `Created(Uuid)` — alta nueva.
- `Updated { id, changed }` — edit con N campos modificados.
- `NoChange(Uuid)` — edit sin diferencias (el toast lo refleja
como "X sin cambios — no log entry").
- **`commit_seed` en path EDIT**:
- Carga current via `store.load(entity, id)` con fallback a
`Value::Null`.
- Calcula delta. Si vacío → return early sin tocar log ni store.
- Si no vacío → emite `Morphism { ui.edit_record, ops: [Set...] }`
con `params.fields` reflejando el delta (no todo el form),
haciendo la auditoría grep-able por field cambiado.
- **Toast del callsite**:
- `creado X uuid` (Created)
- `actualizado X uuid (N campo(s))` (Updated)
- `X uuid sin cambios — no log entry` (NoChange)
- **`editing` se limpia incluso en NoChange** — el modo edit cierra,
el form vuelve al state limpio.
5 tests nuevos del helper:
- delta vacío cuando todo coincide.
- delta sólo con el field cambiado.
- delta full cuando current = Null (record no existe).
- distingue int 100 de string "100".
- ignora fields del current que no están en proposed.
27 tests verdes (+5). El path SEED no cambió; el E2E del morphism
real sigue verde.
Limitación conocida (consistente con pre-delta): el form no puede
**borrar** un value vaciando el input — empty optional fields hacen
`continue` antes de llegar al delta. Para clearear un value hay que
declarar el field como required, o esperar a un `FieldOp::Clear`
futuro (no necesario hoy: ningún demo lo requiere).
Pendientes restantes:
- **Snapshot/compaction** del log (replay full cada startup escala
mal con repos grandes).
- **EntityRef validation post-submit** — validar UUID parseable al
submit en lugar de al execute del morphism.
- **Atajo Esc para Cancelar** del modal de delete.
- **`FieldOp::Clear`** — para soportar borrar un value vía form.
### feat(nakui-ui): confirmación de delete vía banner modal antes de borrar
Cierra el primer pending del último round: borrar un record pedía un
solo click en `✕` y se ejecutaba inmediatamente (irreversible —
queda en el log como `Morphism { name: "ui.delete_record" }`). Ahora
hay un paso intermedio: el click marca el record como pendiente y el
banner amber al tope de la ventana ofrece [Cancelar] o [Confirmar].
Cambios:
- **Nuevo state `MetaUi.pending_delete: Option<(String, Uuid)>`**.
Set en el click del `✕`; limpiado por:
- [Cancelar] → toast "delete cancelado (Entity)".
- [Confirmar] → llama `commit_delete` (igual que antes) y emite
el toast usual.
- Navegación a otra view (`select_view`) — el record marcado
podría no estar visible en la nueva pantalla.
- **Click handler de `✕` ya no llama `commit_delete`**: sólo setea
`pending_delete` y limpia toast. La acción destructiva ahora vive
exclusivamente en el botón [Confirmar] del banner.
- **Nuevo método `render_confirm_delete_banner`**: devuelve
`Option<Div>` (None si no hay pending). Banner amber con el texto
`¿Borrar {Entity} {short_uuid}?` + dos botones. Renderea como
sibling del row sidebar+main en `flex_col` raíz — no es overlay
flotante (GPUI no expone z-index trivialmente), pero la posición
fija al tope + color amber lo hacen imposible de ignorar.
- **Limpieza pre-commit**: `pending_delete = None` se ejecuta antes
de `commit_delete`, así un fallo del commit no deja el banner
colgado además del toast de error.
22 tests verdes — la lógica del store/log no cambió, sólo el state
machine de UI. La confirmación es puramente UX/state, no testable
sin GPUI cx, pero la compilación garantiza wireup correcto de las
closures.
Pendientes restantes:
- **Snapshot/compaction** del log para repos grandes (replay full
cada startup escala mal).
- **Edit delta-only** — sólo campos modificados, no todos.
- **EntityRef validation post-submit** — validar UUID parseable
al submit en lugar de al execute del morphism.
- **Atajo de teclado Esc para Cancelar** — requiere event
dispatcher de GPUI, fuera de scope inmediato.
### feat(nakui-ui): validación estricta de params del morphism vía FieldKind del FieldSpec
Cierra el último trade-off documentado: `infer_param_value` adivinaba
el tipo de cada param por la shape del string (i64 → f64 → bool →
string). Ahora cuando hay `FieldSpec` declarado, usamos
`parse_field_value(spec.kind, raw)` — un Boolean field con value
"abc" rebota con mensaje claro en la UI antes de llegar al morphism
Rhai (donde el error sería opaco como "Function not found: * ((), ())").
Cambios:
- **Nuevo helper `resolve_param_value(field_name, raw, spec)`**:
- Si hay `FieldSpec`: validación de `required` (rebota empty con
"param 'X' es obligatorio y está vacío") + parseo estricto via
`parse_field_value(spec.kind, raw)`. Errores incluyen el `label`
del spec para que el toast sea interpretable.
- Si NO hay spec (param declarado en `Action::Morphism.params`
que no existe en `form.fields` — módulo mal-formado): fallback
a `infer_param_value` como red de seguridad.
- Empty + opcional → `Value::Null`.
- **`commit_morphism` simplificado**: el loop de params ahora es
3 líneas (lookup spec + llamada a `resolve_param_value` +
inserción al map). La lógica vive en el helper standalone,
testable sin GPUI.
Tests: 6 nuevos en `tests` mod, todos contra `resolve_param_value`:
- `resolve_param_strict_number_parses_i64` — happy path.
- `resolve_param_strict_boolean_rejects_non_boolean` — un Boolean
con "abc" rebota con mensaje que incluye el label.
- `resolve_param_strict_number_rejects_garbage` — Number con "abc"
rebota.
- `resolve_param_required_empty_rejected` — required vacío rebota
con "obligatorio".
- `resolve_param_optional_empty_returns_null` — optional vacío
→ null.
- `resolve_param_no_spec_falls_back_to_infer` — el fallback
preserva el comportamiento anterior para back-compat.
22 tests verdes en nakui-ui (+6). E2E del morphism real
(`morphism_pipeline_executes_real_sales_vender`) sigue verde — la
validación estricta no rompe el path correcto, sólo agrega rebotes
tempranos a values mal-tipados.
Beneficio operativo:
- Mensaje de error en la UI ahora identifica el field problemático
por su label legible ("param 'Cantidad': 'abc' no es número")
en lugar del error opaco del morphism Rhai.
- Errores se ven antes de tocar el log o el store — ningún cambio
parcial.
- El módulo Nakui ya no tiene que defender contra inputs garbage
desde la UI: la metainterfaz se vuelve la primera línea de
validación tipada.
Pendientes futuros (orden de prioridad):
- **Confirmación de delete** — modal antes de borrar.
- **Snapshot/compaction** del log para repos grandes.
- **Edit delta-only** — sólo campos modificados, no todos.
- **EntityRef validation post-submit**: hoy `parse_field_value`
para EntityRef devuelve string raw; el commit_morphism luego
valida como Uuid sólo cuando es input del morphism. Para
EntityRef como param, podríamos validar UUID al submit.
### feat(nakui-ui): FieldKind::EntityRef — selector clickable de records existentes
Cierra el principal trade-off documentado del commit anterior:
"Inputs UUID a mano (no dropdown)". Los formularios pueden declarar
un campo `entity_ref` que apunta a una entity y el runtime renderea
una lista clickable de records existentes; click selecciona, el
UUID queda guardado para el submit.
Schema `nakui-ui-schema`:
- **Nueva variante `FieldKind::EntityRef`** (serializa como
`"entity_ref"` en JSON).
- **`FieldSpec.ref_entity: Option<String>`** nuevo. Indica qué
entity ofrecer en el selector. `validate()` chequea que cualquier
field con `kind=entity_ref` tenga `ref_entity` set.
- Nuevo error tipado `SchemaError::EntityRefMissingTarget`.
Runtime `nakui-ui`:
- **`render_entity_ref_selector(field_name, target_entity, ...)`** —
helper que arma la lista debajo del input. Cada item:
- Etiqueta humana via `human_label_for_record` (heurística:
`name``label``title``sku``sku_id` → fallback al
UUID corto).
- Click handler vía `cx.listener` que llama
`input.set_text(uuid_completo)` — el TextInput interno queda
como source-of-truth, así que `commit_seed` y `commit_morphism`
leen el UUID seleccionado sin saber que vino de un selector.
- Highlight en accent color cuando el item es el actualmente
seleccionado (compara contra el contenido del TextInput).
- **`parse_field_value(EntityRef, raw)`** devuelve string del raw
(la validación como Uuid ocurre downstream en `commit_morphism`).
- Mensaje "(sin {entity}: creá uno antes para referenciar)" cuando
la lista está vacía — el user sabe qué hacer en lugar de quedarse
trabado.
Demo actualizado: `examples/nakui-modules/sales_engine/module.json`:
- `vender_form.fields.stock_id_input` y `caja_id_input` cambian de
`kind: "text"` a `kind: "entity_ref"` con `ref_entity: "Stock"`
y `"Caja"` respectivamente.
- Ahora el flujo "Vender" es: (1) click en una Stock listada bajo
el input, (2) click en una Caja, (3) escribir venta_id/cantidad/
precio_unitario/timestamp, (4) submit. Sin copiar UUIDs.
Tests:
- 2 nuevos en schema: `validate_catches_entity_ref_without_target`
y `entity_ref_with_target_validates_clean`. 8 totales.
- 4 nuevos en runtime: `parse_field_entity_ref_returns_string`,
`human_label_for_record_prefers_name_over_id`,
`human_label_falls_back_through_label_title_sku`,
`human_label_falls_back_to_id_when_no_known_keys`. 16 totales.
- Integration de los 7 demos sigue verde — el demo `sales_engine`
ahora valida con EntityRef + ref_entity correctamente set.
29 tests totales nakui-ui + schema, 100% verde. El demo
`sales_engine` carga limpio con la nueva forma del schema.
Pendientes futuros:
- **Confirmación de delete** — modal antes de borrar.
- **Snapshot/compaction** del log para repos grandes.
- **Edit delta-only** (sólo campos modificados).
- **Validación de tipos en params del morphism**: `FieldKind`
declarado en el FieldSpec se podría usar para forzar parseo
estricto en `commit_morphism` en lugar de la heurística
`infer_param_value`.
### feat(nakui-ui): Action::Morphism wired al pipeline real (compute → log → apply)
Cierra el último gran TODO de la metainterfaz Nakui: las acciones
`Action::Morphism` ya no son un toast informativo; despachan al
`Executor` cargado del manifest nakui-core (`nsmc.json` + schemas
KCL + scripts Rhai), pasando por el pipeline completo de Nakui:
compute (con dry-run + KCL post-checks) → log append → store apply.
Schema `nakui-ui-schema` extendido:
- **`Module.nakui_module_dir: Option<String>`** nuevo. Path
(relativo al directorio del `module.json` o absoluto) a un módulo
nakui-core. Sin esto, las Action::Morphism del módulo quedan
no-op con toast informativo. Las Action::SeedEntity siguen
funcionando sin manifest (alta administrativa).
- **`Action::Morphism`** ganó dos campos opcionales:
- `inputs: BTreeMap<String, String>` — mapeo `role → field_name`.
Por cada input declarado en el `MorphismSpec.inputs`, indica
qué field del form contiene el UUID del record. El runtime
parsea como `Uuid` y lo pasa al `execute_and_log`.
- `params: Vec<String>` — lista de fields cuyos values van al
`params` JSON. Si vacío, todos los fields no-input van a params.
Runtime `nakui-ui`:
- **`MetaUi.executors: BTreeMap<String, Arc<Executor>>`** nuevo.
Carga `Executor::load_module(nakui_module_dir)` en `MetaUi::new`
por cada módulo UI que declare la entry. Errores de carga van al
banner; el módulo sigue cargado para SeedEntity, sólo Morphism
queda no-op.
- **`commit_morphism(mod_idx, name, inputs_map, params_fields)`** nuevo.
Resuelve inputs (parsea cada field como Uuid), arma params (Value
object con tipos inferidos via `infer_param_value` — int/float/
bool/string), llama `execute_and_log_with_recovery`. Toast con
cantidad de ops aplicadas o el error tipado.
- **`infer_param_value`** nuevo helper: heurística simple para
pasar values del form al morphism con tipo inferido (i64 → f64 →
bool → string).
Tests: 2 nuevos:
- `infer_param_value_int_then_float_then_bool_then_string`
cobertura de la heurística.
- **E2E `morphism_pipeline_executes_real_sales_vender`** —
carga el módulo real `crates/modules/nakui/modules/sales`,
arma store + log, ejecuta el morphism `vender` con inputs
Stock+Caja y params (cantidad=5, precio_unitario=200,
venta_id, timestamp). Asserta:
- el morphism produce ops (no vacío).
- stock.cantidad bajó 100 → 95.
- caja.saldo subió 1_000_000 → 1_001_000.
12 tests verdes en nakui-ui (+1 vs commit anterior). Schema
extension no rompió nada (6 unit + 5 integration siguen verdes).
Demo nuevo: **`examples/nakui-modules/sales_engine/module.json`**
- Apunta a `crates/modules/nakui/modules/sales` vía `nakui_module_dir`.
- 6 vistas: list + form para cada Stock, Caja, Venta + form
"Vender" con `Action::Morphism { name: "vender", inputs: {stock,
caja}, params: [venta_id, cantidad, precio_unitario, timestamp] }`.
- El user crea Stocks + Cajas con seed_entity, copia los UUIDs
cortos a los inputs de "Vender", y ejecuta el morphism real:
stock baja, caja sube, Venta se persiste, todo loggeado.
- Validaciones KCL fallan limpio (toast con error) si el morphism
rebota — p. ej. cantidad > stock disponible.
Activación full:
```sh
NAKUI_EVENT_LOG=~/.nakui/state.jsonl \
NAKUI_MODULES_DIR=examples/nakui-modules \
cargo run -p nakui-ui
# Sidebar gana "Ventas (con morphism)" — los 6 menús aparecen y
# el form "Vender" dispara el pipeline nakui-core completo.
```
Trade-offs documentados:
- **Inputs UUID a mano**: el form pide que el user copie el UUID de
un Stock/Caja existente. Para UX seria habría que agregar
`FieldKind::EntityRef { entity }` que renderiza un dropdown — no
hecho por scope, queda como nice-to-have.
- **Inferencia de tipo en params**: `infer_param_value` adivina
por shape del string. Para casos sutiles (ej. "true" como string
literal vs bool), el módulo nakui-core puede explicitar tipos
via `kind` en el FieldSpec — el form lo respeta para validación
pre-submit; la inferencia final sigue siendo heurística.
### feat(nakui-ui): edit + delete de records (ciclo CRUD completo)
Cierra "no hay UI para editar/borrar records existentes" del commit
anterior. Cada fila de la lista gana dos botones (✎ edit, ✕ delete);
el form view se reusa para alta y para edit; el delete es inline.
Las mutaciones pasan por `LogEntry::Morphism` con sus ops, así el
replay restaura el estado correcto.
Cambios:
- **`MetaUi.editing: Option<(String, Uuid)>`** nuevo. Set al click
en ✎; cleared al cambiar de view o tras submit exitoso.
- **`open_edit(mod_idx, entity, id, cx)`**: setea `editing`, busca la
primera Form view del módulo cuya `entity` matchee, navega ahí. Si
el módulo no tiene Form para esa entity → toast con error
("no hay form view para entity X").
- **`select_view`** extendido: cuando carga un Form, si `editing`
matchea esa entity y el record existe en el store, pre-llena cada
input con el valor del record (vía nuevo helper
`value_to_input_text` — inverso de `parse_field_value`).
- **`commit_seed`** ramifica:
- **Edit path** (cuando `editing.is_some()` y entity matchea):
emite `LogEntry::Morphism { name: "ui.edit_record", ops:
[Set { path, value } for each field], params: { entity, id,
fields } }`. Aplica al store via `apply(&ops)`.
- **Seed path** (alta nueva): comportamiento previo.
- **`commit_delete(entity, id)`**: emite `LogEntry::Morphism {
name: "ui.delete_record", ops: [Delete { entity, id }] }` + apply.
- **Render del form**: título cambia a "Editar customer abc12345"
cuando `editing` matchea; submit label cambia a "Guardar cambios
en customer".
- **Render de la lista**: dos columnas nuevas — "id" y "acciones".
Cada fila tiene ✎ (accent color, click → open_edit) y ✕ (rojo,
click → commit_delete). Hover states.
Ramificación visible en el event log:
```
{"kind":"seed","seq":0,"entity":"customer","id":"abc...","data":{"name":"Acme"}}
{"kind":"morphism","seq":1,"morphism":"ui.edit_record","ops":[
{"op":"set","path":{"entity":"customer","id":"abc...","field":"name"},
"value":"Acme S.A."}
]}
{"kind":"morphism","seq":2,"morphism":"ui.delete_record","ops":[
{"op":"delete","entity":"customer","id":"abc..."}
]}
```
Coherente con el modelo de Nakui — todo cambio post-seed pasa por
ops dentro de Morphism. `nakui-explorer` muestra estos morphisms
con sus ops claros en su timeline.
Trade-offs documentados:
- **`schema_hash: None`** sigue para los morphism de la UI (legacy/
pre-versioning path) hasta que `Action::Morphism` cargue Manifest
schemas.
- **Delete sin confirmación**: 1 click, sin modal. Para MVP es OK
(los records son recuperables vía replay parcial), pero un futuro
iter agregaría confirmación.
- **Edit sobreescribe TODOS los campos del form**, no sólo los
cambiados — emite N ops Set, una por field. Adecuado para forms
chicos; para forms con muchos campos optimizar a delta-only.
Tests: 3 nuevos (10 totales en nakui-ui):
- `value_to_input_text_inverse_of_parse` y
`value_to_input_then_parse_round_trip` — la propiedad fundamental
del pre-llenado: text → parse devuelve el Value original.
- `event_log_replay_handles_full_crud_cycle` — E2E del log: escribe
Seed + Morphism(Set ops) + Morphism(Delete op), replay desde cero,
verifica que el store termina vacío (delete fue el último). Verifica
además que un replay parcial (sin el delete) deja los valores
editados.
Activación:
```sh
NAKUI_EVENT_LOG=~/.nakui/state.jsonl \
NAKUI_MODULES_DIR=examples/nakui-modules \
cargo run -p nakui-ui
# Crear un customer, click ✎ en su fila, modificar campos,
# "Guardar cambios". Click ✕ en otra fila para borrar.
# Cerrar y reabrir: el state persiste con todos los cambios.
```
### feat(nakui-ui): persistencia con event log + replay al startup
Cierra "sin persistencia entre runs" del commit anterior. Cada
`SeedEntity` se appendea al `nakui_core::event_log::EventLog` con
WAL semantics (log antes que store) y al re-abrir el binario el
replay reconstruye el `MemoryStore` desde cero. Cerrar y volver a
abrir ya no borra el data.
Cambios:
- **`MetaUi.event_log: Option<Arc<Mutex<EventLog>>>`** nuevo.
Compartido bajo `Mutex` para que el commit_seed pueda mutar.
- **Apertura + replay al startup** (`MetaUi::new`): path por env
`NAKUI_EVENT_LOG`, default `./nakui-ui-state.jsonl`.
`EventLog::open` + `replay_into` reconstruyen el store.
Toast informativo: "log nuevo" o "log X cargado: N evento(s)
replayed".
- **WAL en `commit_seed`**: si `event_log.is_some()`, primero
`log.append(LogEntry::Seed { ..., schema_hash: None })`, después
`store.seed`. Si el append falla, cancela toda la operación
(el user reintenta sin haber dejado state inconsistente).
- **`schema_hash: None`**: documentado como path "legacy /
pre-versioning" para seeds que no pasan por un Manifest+Executor.
Es el path correcto para alta administrativa vía la metainterfaz
hasta que `Action::Morphism` wireé el Manifest loader.
- **Degradación grácil**: si abrir log falla (permisos, disco),
toast con error pero el runtime sigue en modo in-memory.
Tests: 1 nuevo E2E `event_log_replay_restores_memory_store` que
escribe 2 seeds via `EventLog::append`, re-abre y `replay_into` un
store fresh, verifica que ambos records están con sus values
correctos. Reproduce el flujo del startup de `MetaUi::new` sin
necesitar GPUI. 7 tests verdes en nakui-ui.
Activación con persistencia explícita:
```sh
NAKUI_EVENT_LOG=~/.nakui/state.jsonl \\
NAKUI_MODULES_DIR=examples/nakui-modules \\
cargo run -p nakui-ui
# Crear varios records vía el form, cerrar el binario, abrir de
# nuevo: los records están.
```
Limitaciones que **siguen** (próximos iters):
- **`Action::Morphism`** sigue como TODO: requiere cargar el
`Manifest` de nakui-core junto al `Module` UI para conocer los
inputs/params declarados y poder llamar `execute_and_log`.
- **No hay snapshot/compaction**: el log crece append-only para
siempre. Para repos grandes habría que integrar `Snapshot` de
nakui_core (existe, no se usa todavía).
- **No hay UI para borrar/editar** records existentes — sólo alta
vía form. Edit + delete en futuras iteraciones.
- **Widget input simple** (sin selection/IME/clipboard) — heredado
de la limitación documentada de `yahweh-widget-text-input`.
### feat(nakui-ui): inputs reales con yahweh-widget-text-input + click handlers funcionales
Cierra dos limitaciones documentadas en el commit anterior de la
metainterfaz: los formularios ahora aceptan teclado real, y los
clicks en menús + botones mutan estado correctamente.
Cambios:
- **Inputs vivos**: cada `FieldSpec` del Form view materializa un
`Entity<TextInput>` (de `yahweh-widget-text-input`) al entrar a la
vista. Los entities se reemplazan al cambiar de view (drop limpio).
El widget soporta: escribir caracteres, Backspace, Enter (Confirmed
event — no usado todavía; el submit va por botón), Escape
(Cancelled). El cursor se renderea como `|` al final.
- **Click handlers wired vía `cx.listener`**: menús del sidebar
invocan `select_view`; botones de acción (header de list, submit
de form) invocan `apply_action`. Los handlers tienen acceso real
al `Context<MetaUi>` y mutan el modelo + emiten `cx.notify()`.
- **Submit lee texto de los inputs**: `commit_seed` reemplaza el
buffer ad-hoc anterior por `input.read(cx).text()` por cada
field. El value parseado va al `MemoryStore` con su tipo correcto
(text/number/boolean/date).
- **Reset de inputs tras submit**: si la acción no tiene `next_view`,
los inputs se vacían (`set_text("")`) para alta consecutiva sin
re-tipear.
- **Hover states**: items del sidebar y botones cambian de bg al
pasar el mouse, feedback visual consistente con el resto del
ecosistema yahweh.
- **Theme global**: `Theme::install_default(cx)` al inicio (lo
requiere el text_input para sus colores).
Wire en Cargo:
- Deps nuevas: `yahweh-widget-text-input`, `yahweh-theme` (paths
relativos al monorepo).
Limitaciones que **siguen abiertas** (próximos iters):
- **`Action::Morphism`** sigue como TODO: requiere cargar el
`Manifest` de nakui-core junto al `Module` UI para conocer los
inputs/params declarados.
- **Sin persistencia entre runs**: `MemoryStore` en RAM. Wire con
`EventLog` o `SurrealStore` queda para cuando exista el daemon
Nakui.
- **Inputs simples**: el widget no soporta cursor positioning,
selection, copy/paste, IME, multilínea. Para edits serios habrá
que portar `gpui::examples::input` o adoptar `gpui-input` cuando
exista upstream.
- **Enter no envía**: el `TextInputEvent::Confirmed` que emite el
widget no está suscrito todavía; el submit va por click. Trivial
de wirear si lo necesitamos.
Tests: los 6 unit del runtime siguen verdes (parse_field_value para
los 5 kinds, lookup_field nested, render_value). El comportamiento
visual requiere correr el binario con `cargo run -p nakui-ui` y
probar a mano — GPUI no provee harness de UI testing en CI hoy.
Activación full:
```sh
NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
# Click en un menú → carga vista. Click en "Nuevo" → form.
# Tipear en cada campo → ver el `|` al final. Click "Crear customer"
# → record aparece en la lista.
```
### feat(nakui): metainterfaz declarativa + 6 módulos ERP estándar
Salto cualitativo: Nakui pasa de "library + demos + read-only viewer
del event log" a **plataforma ERP con UI dirigida por datos**. Cada
módulo de negocio se declara como un `module.json` (sin código Rust
nuevo) y el runtime GPUI lo carga dinámicamente: sidebar de menús,
listas con columnas configurables, formularios de alta.
Tres entregables:
**1) Crate nuevo `nakui-ui-schema`** (datos puros, ~250 LOC + 200
LOC tests):
- `Module { id, label, entities, menu, views }`.
- `View::List { entity, columns, actions, search_in }` o
`View::Form { entity, fields, on_submit }`.
- `FieldSpec { name, label, kind, default, required, help }` con
`FieldKind = Text|Multiline|Number|Boolean|Date`.
- `Action::OpenView | SeedEntity | Morphism` — el runtime las
dispara desde botones / submits.
- `Module::from_path` parsea un JSON; `Module::validate` chequea que
cada `MenuItem.view` exista en `views`.
- `load_modules_from_dir(dir)` busca `dir/<modulo>/module.json`,
parsea, valida, detecta IDs duplicados, devuelve ordenado.
- 6 tests unit + 4 integration (los 6 demos cargan limpio, todos
tienen list+form, kinds reconocidos, validate pasa).
**2) Crate nuevo `nakui-ui`** (binario GPUI, ~700 LOC + 100 LOC tests):
- Carga módulos desde `NAKUI_MODULES_DIR` (default `./nakui-modules`).
- Sidebar con módulos + sus menús; click en menu cambia la vista activa.
- **List view**: tabla de instancias del entity con columnas
weighted (header de columnas + filas + id corto).
- **Form view**: campos labeled + botón submit que dispara la action
declarada (`SeedEntity` mete el record al `MemoryStore`
in-process; `Morphism` queda como TODO hasta integrar el manifest
loader nakui-core).
- `MemoryStore` compartido entre todas las vistas (Arc<Mutex>); el
cambio en un módulo se refleja en otro inmediato.
- Toast + error banner para feedback.
- 6 tests unit (parse_field_value para los 5 kinds, lookup_field
nested, render_value).
**3) 6 módulos demo** en `examples/nakui-modules/` que cubren un
ERP estándar:
- **customers**: nombre, email, teléfono, activo, límite de
crédito, notas.
- **products**: SKU, nombre, categoría, precio, stock, activo.
- **suppliers**: razón social, ID fiscal, contacto, email,
teléfono, términos de pago.
- **inventory_movements**: fecha, tipo (in/out/adjustment), SKU
producto, cantidad, costo unitario, motivo, doc. referencia.
- **sales_orders**: número, cliente, emisión, vencimiento,
estado, subtotal, impuestos, total, notas.
- **invoices**: número, cliente, emisión, vencimiento, subtotal,
impuestos, total, pagado, estado, moneda, orden referenciada.
Cada módulo tiene su `list` (catálogo) + `form` (alta), con search
field y columns weighted. Los 6 cubren un setup de ERP de ventas
chico funcional para demo.
Filosofía documentada:
- **UI como datos**: agregar un módulo = escribir un JSON, no
recompilar el binario.
- **Persistencia universal**: el runtime conecta cada vista al
`nakui_core::store::Store`; cambiar de MemoryStore a SurrealStore
no toca los module.json.
- **Schema primero, semántica después**: `nakui-ui-schema` sólo
define la forma; validación de referencias rotas (entity inexistente,
morphism faltante) vive en el runtime.
Activación:
```sh
NAKUI_MODULES_DIR=examples/nakui-modules cargo run -p nakui-ui
```
Limitaciones conocidas (próximas iteraciones):
- **Inputs sin teclado**: GPUI no incluye text input; los forms
muestran los `default` del schema y el submit usa esos. Próximo
iter: integración con `yahweh-widget-text-input`.
- **Click handlers no wired**: GPUI necesita pasar `Entity<MetaUi>`
a los handlers para mutar estado; refactor con `cx.listener` +
weak refs queda para el próximo iter. Hoy la navegación es
visual; el código de mutación sí funciona via API programática
(los tests lo cubren).
- **Acción `Morphism`**: pendiente de cargar el `Manifest` de
nakui-core junto con el `Module` UI para wirear `execute_and_log`.
- **Sin persistencia entre runs**: el `MemoryStore` se pierde al
cerrar. Wire con `EventLog` o `SurrealStore` queda para cuando
el daemon Nakui exista.
Tests: 16 totales nuevos (10 schema + 6 runtime). 100% verde.
Lo que esto desbloquea: cualquiera puede escribir un `module.json`
para su dominio (pacientes médicos, alumnos de escuela,
reservaciones de hotel) y aparece en la UI sin tocar Rust ni
recompilar. La forma de extender Nakui dejó de ser "agregar código
al ERP" y pasó a ser "escribir el contrato del módulo".
### feat(nakui-explorer): nuevo binario GPUI — Nakui visible en la interfaz
Cierra "nakui no tiene UI propia" del audit. Nuevo binario standalone
`nakui-explorer` (paralelo a `nouser-explorer`) que renderea el
event log de un repo Nakui: timeline scrollable de seeds + morphisms
con sus parámetros, breakdown por entity type, polling cada 2s para
detectar nuevos eventos appended sin restart del explorer.
Diseño:
- Lee directamente el archivo `.jsonl` del `nakui_core::event_log::EventLog`.
Path por env `NAKUI_EVENT_LOG`, default `nakui.jsonl` en pwd.
- Sin discovery vía broker brahman porque nakui hoy es CLI/library/
demos, no daemon. Cuando se daemonice, sustituir el lector de
archivo por un sidecar consumer (mismo patrón que nouser-explorer
actualmente usa).
UI:
- **Header**: path del log, count total + breakdown seeds/morphisms,
tiempo del último reload en ms.
- **Breakdown line**: top 5 buckets por frecuencia (entities + nombres
de morphisms con prefijo ``).
- **Timeline**: tarjetas color-coded por kind (azul=seed,
verde=morphism). Cada tarjeta muestra `#seq`, kind, entity/morphism
name, id corto (8 hex), preview del data/params (80 chars), schema
hash corto (8 hex) o `(legacy)` si pre-versioning. Mostradas
más-recientes-primero, hasta 200 visibles (suficiente para
navegación; sin scroll virtualizado por ahora).
- **Error banner**: si la lectura falla (archivo inexistente o
corrupto), banner rojo con el motivo. El explorer NO crashea —
sigue intentando cada 2s.
Wire en workspace:
- Nuevo `crates/apps/nakui-explorer/` agregado a `[workspace] members`.
- Deps mínimas: `nakui-core` (para EventLog + LogEntry), `gpui`,
`serde_json`, `uuid` (con feature serde para parsear los IDs).
- Sin deps de brahman por ahora (Nakui standalone).
Tests: 7 unitarios en `tests` mod del bin:
- `load_log_returns_all_entries_in_order` — cargar un .jsonl
generado a mano, asserta que devuelve 5 entries con seqs 0..4
contiguous.
- `breakdown_counts_seeds_morphisms_and_buckets` — verifica el
conteo (3 seeds + 2 morphisms) y los buckets esperados.
- `load_missing_file_yields_empty_not_error` — archivo inexistente
devuelve `[]` sin error (delegado al contrato de `EventLog::open`).
- `preview_value_truncates_long_strings` y `_keeps_short_strings_intact`.
- `short_uuid_takes_first_8_chars` y `short_hash_takes_first_4_bytes_hex`.
Activación:
```sh
NAKUI_EVENT_LOG=/tmp/nakui_inv_xxx.jsonl cargo run -p nakui-explorer
```
Estado del CHANGELOG global tras este commit: cero pendientes
fundamentados activos. Lo único que queda es `minga-vfs` (FUSE,
explícitamente diferido por el usuario) y mejoras nice-to-have
(cobertura adicional per-lenguaje, daemon-ización de nakui para
sidecar discovery).
### chore(nakui): alinear `nakui-core` con `[workspace.package]` y deps compartidas
Cleanup de drift de convenciones: `nakui-core` era el único crate del
monorepo que mantenía `version = "0.1.0"` / `edition = "2021"` /
`thiserror = "1"` hardcoded, mientras el resto heredaba del workspace
y usaba `thiserror = "2"`. Eso significaba que un bump global de versión
o de edition se olvidaba sistemáticamente de nakui.
Cambios:
- `[package]`: `version`, `edition`, `rust-version`, `license`, `authors`,
`publish` → todos `*.workspace = true`. Agregado `description` (cumple
convención del resto de crates).
- Deps compartidas migradas a `{ workspace = true }`: serde, serde_json,
thiserror (v1→v2), tokio, ulid, sha2.
- `uuid` migrado a `{ workspace = true, features = ["serde"] }` — la
feature `serde` no está en el workspace dep porque nakui es el único
user; queda local opt-in en lugar de inflar el dep común.
- Deps específicas de nakui (sin compartición posible): rhai, petgraph,
surrealdb permanecen inline con versión local.
Verificación: `cargo build -p nakui-core` verde tras el bump de
`thiserror` v1→v2 — el `#[derive(Error)]` de los 14+ enums de error
en nakui no requirió ajustes (la API de derive es backwards-compatible
para los patrones simples). `cargo test -p nakui-core --lib`: 27/27
verdes, sin regresión.