Timeline
Redux DevTools rebobina un store. La timeline de Wu rebobina la página: escrituras del store, eventos del bus y ciclo de vida de las apps, en todos los frameworks que haya. Arrastra el control deslizante y una app React se desmonta, una app Vue se remonta y el store compartido vuelve de golpe a lo que contenía 40 escrituras atrás.
Aclaremos la frontera. Lo que se rebobina es lo que fluye por el sustrato: el
store, el bus de eventos, mount/unmount/update. Los efectos secundarios internos
de una app no: un setTimeout dentro de una micro-app, un useState que nunca
subió al store, una petición de red que ya está en vuelo. El viaje en el tiempo
aquí trata de las costuras entre apps, no de sus interiores.
wu.timeline es un chunk perezoso. La fachada de wu.timeline existe desde
el principio y dispara la carga en el primer uso.
import { wu } from 'wu-framework';
wu.timeline.record(); // arma el grabador (asíncronamente — ver más abajo)const s = wu.timeline.status(); // responde de inmediato, sin forzar una cargaLa fachada se divide con claridad:
| Tipo | Métodos | Devuelve |
|---|---|---|
| Síncronos, encadenables | record(), stop(), clear() |
La propia fachada |
| Asíncronos | seek(), live(), stepBack(), stepForward(), export(), import(), ingest() |
Promise |
| Síncronos, baratos | entries(), status(), loaded, instance |
Valores planos |
wu.timeline.record().stop().record(); // el encadenado funcionaawait wu.timeline.seek(120); // a este sí le haces awaitLa advertencia honesta sobre record()
Sección titulada «La advertencia honesta sobre record()»Como el grabador es un chunk perezoso, record() se arma de forma
asíncrona. Las escrituras del store que ocurran antes de que el chunk se
resuelva no se registran en el diario.
wu.timeline.record();store.set('user.name', 'Ada'); // puede estar o no en el diarioSi necesitas capturar la siguiente escritura sí o sí, precalienta el chunk primero:
await wu.timelineReady(); // resuelve el WuTimeline realwu.timeline.record(); // ahora se arma de forma síncronastore.set('user.name', 'Ada'); // registro garantizadoEn el camino de la interfaz esto no es un problema: abrir el inspector carga el
chunk, así que el botón de grabar se arma de inmediato. La fachada también
rastrea la intención, de modo que un stop() emitido antes de que el chunk
llegue cancela un record() pendiente, en lugar de producir una grabación
sorpresa más tarde.
record()
Sección titulada «record()»record(opts?: { snapshotEvery?: number; maxEntries?: number }): TimelineEmpieza a grabar. Reinicia el diario, toma una instantánea de referencia del store y de todas las apps montadas, y engancha taps al store y al bus de eventos.
| Opción | Por defecto | Significado |
|---|---|---|
snapshotEvery |
64 |
Escrituras del store entre instantáneas completas de estado. Menos = búsquedas más rápidas, más memoria. |
maxEntries |
5000 |
Tope del diario. Las entradas más antiguas se pliegan sobre la línea base al superarlo. |
wu.timeline.record({ snapshotEvery: 32, maxEntries: 2000 });Casos límite. Llamar a record() mientras ya está grabando es un no-op
idempotente — y las opciones nuevas se ignoran, porque la guarda se ejecuta
antes de la fusión. Haz stop() y luego record() para cambiarlas. Las opciones
persisten entre sesiones de grabación una vez asignadas.
stop() y clear()
Sección titulada «stop() y clear()»stop(): Timelineclear(): Timelinestop() desengancha los taps y deja de registrar. El diario se conserva:
buscar, exportar y desplazarse siguen funcionando sobre él.
clear() vacía el diario y vuelve a tomar una línea base del estado actual,
pero no detiene la grabación. La grabación continúa sobre el diario nuevo.
wu.timeline.stop(); // congelar lo capturadowu.timeline.clear(); // empezar de cero, sin dejar de grabarCasos límite. stop() sobre una timeline que no está grabando es un no-op
silencioso: sin evento, sin log. Ninguno de los dos métodos lanza.
seek(pos: number, opts?: { store?: boolean; apps?: boolean; props?: boolean }): Promise<Status>Mueve la página a la posición pos del diario. pos es un índice — el número
de entradas del diario aplicadas, de 0 a length. No es una marca de
tiempo.
| Opción | Por defecto | Qué restaura |
|---|---|---|
store |
true |
Rematerializa el estado del store en esa posición. |
apps |
true |
Fuerza el desmontaje de las apps que no estaban montadas entonces y remonta las que sí. |
props |
true |
Vuelve a empujar las props vivas que cada app tenía en esa posición. |
await wu.timeline.seek(0); // volver a la línea baseawait wu.timeline.seek(120); // 120 entradas dentroawait wu.timeline.seek(9e9); // recortado al final — igual que live()Casos límite. seek() nunca se rechaza. Las búsquedas se serializan a
través de una cadena interna, y cualquier fallo se registra y se traga,
resolviendo con el estado actual. Los fallos por app durante la búsqueda se
capturan y se avisan individualmente: un diario importado que referencia apps que
esta página nunca registró avisa y continúa.
La entrada se recorta y se convierte: NaN, null, undefined y las cadenas no
numéricas pasan todas a 0; los negativos se recortan a 0; pasarse de largo se
recorta a length; los decimales se truncan hacia abajo. Las propias escrituras
del store hechas por la búsqueda se excluyen del diario, así que no hay bucle de
realimentación.
live(), stepBack(), stepForward()
Sección titulada «live(), stepBack(), stepForward()»live(): Promise<Status>stepBack(): Promise<Status>stepForward(): Promise<Status>Búsquedas de conveniencia. live() salta al final del diario, y los dos pasos
mueven una entrada.
await wu.timeline.stepBack();await wu.timeline.stepBack();await wu.timeline.live(); // de vuelta al presenteCasos límite. Los tres calculan su destino de forma perezosa, en el momento
en que la búsqueda realmente se ejecuta — de lo contrario, dos stepBack()
seguidos resolverían al mismo índice, y un live() compitiendo con una búsqueda
en cola podría dejarte varado en el pasado. stepBack() en la posición 0 y
stepForward() al final se recortan sin daño (siguen reaplicando el estado y
emitiendo timeline:seek).
La política de no bifurcar
Sección titulada «La política de no bifurcar»Esto es lo más importante que hay que entender de la timeline, y el lugar donde vive su limitación honesta.
Mientras estés posicionado en el pasado, las escrituras nuevas no se
registran. Se ejecutan con normalidad contra el store real y la página real
—la timeline no las bloquea— pero no se añade nada al diario, no se trunca nada
y nada se bifurca. Cuando vuelves a live(), la búsqueda rematerializa el estado
del final del diario y esas escrituras se descartan.
Recibes un aviso por sesión de grabación:
[WuTimeline] Writes while positioned in the past are not journaled —they will be discarded when you return to live().Es la misma política que usa Redux DevTools. La alternativa —diarios con ramas— implica reconciliar historiales divergentes entre apps desplegadas de forma independiente, que es un problema distinto y mucho más grande.
status()
Sección titulada «status()»status(): { loaded: boolean; recording: boolean; live: boolean; position: number; length: number; site: string; lamport: number; snapshots: number;}| Campo | Significado |
|---|---|
loaded |
¿Está resuelto el chunk real del grabador? false desde el stub de la fachada. |
recording |
¿Están instalados los taps? |
live |
¿Es position === length? |
position |
Índice actual del diario. |
length |
Total de entradas. |
site |
Id de esta réplica: un UUID, o un fallback de tiempo más aleatorio. |
lamport |
Contador del reloj lógico. |
snapshots |
Número de instantáneas, no el array. |
const s = wu.timeline.status();if (s.recording && !s.live) { console.log(`Viendo la entrada ${s.position} de ${s.length}`);}Casos límite. Es seguro consultarlo en bucle: nunca fuerza la carga del
chunk. Antes de que el chunk se resuelva devuelve un stub congelado con
loaded: false, length: 0, site: null.
entries()
Sección titulada «entries()»entries(): Array<Entry>Una copia superficial del diario. Cada entrada lleva l (contador de Lamport),
site y t (Date.now()), además de:
Escrituras del store — { kind: 'store', seq, path, value }. Un path vacío
significa un reemplazo del estado completo.
Eventos del bus — { kind: 'event', name, appName }, con container añadido
para app:mounted y props para app:updated.
const writes = wu.timeline.entries().filter(e => e.kind === 'store');console.log(writes.map(e => e.path));Casos límite. El array es una copia, pero los objetos de entrada son
referencias compartidas: seguro para ordenar o filtrar, no seguro para mutar.
Antes de que el chunk cargue devuelve [].
Dos detalles que conviene conocer. El diario registra todos los eventos del
bus salvo los timeline:*, pero solo app:mounted, app:unmounted y
app:updated se interpretan durante la reproducción — el resto se registran pero
son inertes. Y las entradas de app:updated guardan las props acumuladas
absolutas, no el delta: un delta no puede expresar la eliminación de una
clave que solo apareció después.
export() e import()
Sección titulada «export() e import()»export(): Promise<Journal>import(data: Journal): Promise<Status>Serializa una sesión y reprodúcela en otro sitio — el flujo de “adjunta la grabación al informe de error”.
const journal = await wu.timeline.export();await fetch('/bug-reports', { method: 'POST', body: JSON.stringify(journal) });
// En otra máquina:await wu.timeline.import(journal);await wu.timeline.seek(0);await wu.timeline.stepForward(); // recorrer lo que hizo el usuarioLa forma del diario:
{ format: 'wu-timeline/1', wu: '2.7.2', site: '…', exportedAt: 1730000000000, baselineApps: [{ name, container, props }], entries: [ /* … */ ], snapshots: [{ at, state }],}Casos límite. import() es el único método que lanza:
[WuTimeline] Unsupported journal format: <format> cuando data.format no es
'wu-timeline/1'. Detiene primero la grabación, te deja al final del diario
importado (position === length), conserva tu propio id de site en lugar del
del exportador, y no impone maxEntries. Los valores se clonan con
structuredClone donde esté disponible, cayendo a JSON y, finalmente, a la
referencia cruda — así que un diario con valores no clonables guarda referencias
vivas en vez de reventar.
ingest()
Sección titulada «ingest()»ingest(entries: Entry[]): Promise<Status>Fusiona un diario remoto con este, deduplicado y con orden total por
(lamport, site). La primitiva que hay debajo de la sincronización multijugador.
socket.on('journal', (remote) => wu.timeline.ingest(remote));Casos límite. ingest() reinicia todas las instantáneas y ancla la
reproducción en {}. La convergencia necesita una base compartida: dos réplicas
que divergen y vuelven a fusionarse deben acabar en el mismo estado, y una
instantánea tomada del estado local de una réplica rompería eso. La consecuencia
práctica es que los diarios que pretendas sincronizar deben grabarse desde una
base compartida, o empezar con una entrada de reemplazo completo — de lo
contrario, ingerirlos produce un estado al que le falta todo lo que contenía la
línea base. ingest() nunca lanza y no emite ningún evento.
Eventos
Sección titulada «Eventos»| Evento | Payload |
|---|---|
timeline:record |
{ site } |
timeline:stop |
{ length } |
timeline:seek |
{ position, length, live } |
timeline:import |
{ length, site } |
Todos se emiten con history: false, para que arrastrar el control deslizante no
expulse todos los eventos reales de apps del historial acotado del bus, y el
grabador ignora sus propios eventos timeline:* para que nunca se
autorregistren.
Limitaciones honestas
Sección titulada «Limitaciones honestas»- Solo el sustrato. El estado interno de las apps, los timers y las peticiones en vuelo no rebobinan.
- Sin bifurcación. Las escrituras hechas en el pasado se descartan silenciosamente al volver a live.
record()se arma de forma asíncrona salvo que hagas antesawait wu.timelineReady().- La reproducción remonta apps. Buscar a través de una frontera de montaje fuerza desmontajes y remontajes, lo cual no es gratis y reinicia todo lo que la app no subió al store.
ingest()descarta las instantáneas, lo que hace que los diarios largos sean más lentos de recorrer tras una fusión.- El reloj de Lamport ordena; las marcas de tiempo no. El campo
tse registra para mostrarlo, pero nunca se usa para ordenar.
