feat(nakui-ui): snapshot/compaction automático del event log al startup

Wirea el snapshot machinery existente en nakui-core (Snapshot,
replay_with_snapshot_into, EventLog::compact_through) al runtime de
la UI. Reduce el costo de boot de O(events) a O(events_post_snapshot).

- Path: sibling del log, extensión `.snap.json`.
- Threshold via `NAKUI_SNAPSHOT_THRESHOLD` env (default 50; 0 = off).
- Helper `maybe_compact_log` captura snapshot + compacta dejando la
  última entry como anchor del cursor (sin anchor, EventLog::open
  vería log vacío y perdería next_seq).
- WAL order: write snap antes de compactar; un crash entre los dos
  da el mismo outcome al próximo boot (replay skipea entries
  cubiertas por snap).
- Fail-soft: snap load/compact errors no son fatales, sólo banner.

5 tests nuevos: derivación del path, dos no-ops bajo threshold, E2E
60 entries → snapshot+compact → reopen+replay → 60 records intactos.

31 tests verdes en nakui-ui. Workspace build verde.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-09 21:37:04 +00:00
parent 70f8c66548
commit 7e2be96a57
2 changed files with 371 additions and 9 deletions
+82
View File
@@ -6,6 +6,88 @@ ratio/diff ver `git show <sha>`.
## 2026-05-09
### 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