Ir al contenido

Store

Un objeto, acceso por rutas con puntos, suscripciones acotadas por ruta. Apps escritas en React, Vue y Svelte leen y escriben el mismo estado sin importar nada la una de la otra.

import { wu } from 'wu-framework';
// o los exports con nombre:
import { getState, setState, onStateChange } from 'wu-framework';

El store es un singleton de módulo: wu.store, el export por defecto del módulo del store y los atajos getState/setState apuntan todos a la misma instancia.

get(path?: string): any

Lee el valor en una ruta con puntos.

wu.store.get('user.profile.name'); // 'Ada'
wu.store.get('user'); // { profile: { name: 'Ada' }, id: 7 }
wu.store.get(); // el objeto de estado completo

Casos límite. Devuelve por referencia, no una copia: mutar lo que recibes muta el store, silenciosamente y sin notificar a nadie. Llamarlo sin ruta (o con '', null, 0) devuelve el objeto de estado completo, igual de mutable. Un intermedio inexistente da undefined en vez de lanzar. Cualquier segmento de ruta igual a __proto__, constructor o prototype devuelve undefined (protección contra contaminación de prototipos). Pasar un valor truthy que no sea cadena lanza un TypeError.

set(path: string, value: any): number

Escribe un valor y devuelve su número de secuencia: un contador monótono que empieza en 0 para la primera escritura.

const seq = wu.store.set('cart.items', [{ id: 1, qty: 2 }]);
wu.store.set('user.profile.name', 'Ada'); // crea user y user.profile

Casos límite. Varios, y son importantes:

  • Los valores se guardan por referencia. Sin clonar, sin congelar. Si mutas después el objeto que pasaste, el store cambia y nadie se entera. Pasa un objeto nuevo por escritura si quieres que el seguimiento de cambios sea honesto.
  • Sin comprobación de igualdad. set('a', 1) dos veces realiza dos escrituras completas, dos números de secuencia y dos rondas de notificación. Nunca es un no-op.
  • Los intermedios que faltan se crean, y los intermedios que no son objetos se sobrescriben: un set('a', 1) seguido de set('a.b', 2) destruye el 1.
  • Lanza [WuStore] Unsafe key in path: "<segment>" (path: "<path>") para __proto__, constructor o prototype en cualquier punto de la ruta.
  • Una ruta falsy reemplaza todo el estado. set('', v) funciona. Pero set(null, v) y set(undefined, v) también reemplazan el estado y luego lanzan un TypeError no capturado desde dentro del microtask de notificación. No hagas eso.

Las escrituras se aplican de forma síncrona: un get() justo después de un set() ve el valor nuevo. Los listeners se difieren a un microtask, uno por cada llamada a set(), sin agrupar.

wu.store.set('count', 1);
wu.store.get('count'); // 1 — inmediatamente
// los listeners de 'count' se disparan al final de este tick

Agrupar varias escrituras no agrupa sus notificaciones.

on(pattern: string, callback: Function): () => void

Se suscribe a una ruta o a un patrón con comodines. Devuelve una función para cancelar la suscripción.

Esta es la parte más propensa a errores de toda la API del store, así que vale la pena decirlo sin rodeos.

Patrón El callback recibe
Ruta exacta (sin *) Dos argumentos: (value, path)
Contiene * en cualquier sitio Un objeto: ({ path, value })
// Ruta exacta — dos argumentos posicionales
wu.store.on('user.profile.name', (value, path) => {
console.log(path, '=', value); // 'user.profile.name = Ada'
});
// Comodín — un único objeto
wu.store.on('user.*', ({ path, value }) => {
console.log(path, '=', value);
});

Equivocarse aquí falla en silencio: un listener con comodín escrito como (value, path) recibe el objeto {path, value} como value y undefined como path.

Una escritura notifica a su ruta exacta y a todas las rutas ancestras, empezando por la más profunda.

wu.store.on('user', (value, path) => console.log('listener de user', value));
wu.store.set('user.profile.name', 'Ada');
// se disparan: los listeners de 'user.profile.name', luego 'user.profile', luego 'user'

Los listeners ancestros reciben el objeto ancestro completo, leído de nuevo en el momento de la notificación, no el valor de la hoja que se escribió. El listener de user de arriba recibe { profile: { name: 'Ada' }, ... }, no 'Ada'.

El burbujeo solo aplica a los listeners de ruta exacta. Los comodines no burbujean: on('user.*') no se dispara ante una escritura en user.profile.name, porque el patrón se compara solo contra la ruta escrita y * no puede cruzar un punto. Burbujeo y comodines son mecanismos separados y sin solape.

Patrón Coincide con No coincide con
* Todo (caso especial)
user.* user.name, user.email user, user.profile.name
*.name user.name, org.name a.b.name
a.*.c a.x.c a.c, a.x.y.c

Un segmento * significa exactamente un segmento no vacío y sin puntos. El número de segmentos es fijo y la coincidencia está anclada por ambos extremos.

No existe **. user.** se escapa a un literal y solo coincide con una ruta literalmente llamada user.**. Los globs parciales tampoco funcionan: user.na* es un literal, no una coincidencia por prefijo. Fíjate en que esta gramática es distinta de la del bus de eventos, donde * se expande a .* sobre cualquier carácter.

Casos límite. on() nunca reproduce el estado actual: quien se suscribe no recibe nada hasta la siguiente escritura. Registrar dos veces la misma referencia de función colapsa (el almacenamiento es un Set), y una sola baja la elimina. Un listener que lanza se captura y se registra; nunca detiene a los demás. Darse de baja es idempotente.

batch(updates: Record<string, any>): number[]

Aplica varias escrituras y devuelve sus números de secuencia en orden de inserción.

wu.store.batch({
'user.name': 'Ada',
'user.email': 'ada@example.com',
'ui.theme': 'dark',
});

Casos límite. Pese al nombre, esto no es atómico ni agrupado: es un bucle sobre set(). N escrituras, N microtasks, N rondas de notificación. Si una ruta contiene una clave insegura, el bucle lanza a mitad de camino, dejando aplicadas las escrituras anteriores y perdiendo el array devuelto. Trátalo como una comodidad, no como una transacción.

clear(): void

Reinicia el estado a {}, descarta todos los listeners y reinicia el cursor de escritura.

wu.store.clear();

Casos límite. No reinicia las métricas, no elimina los taps y no detiene un sync() activo: las escrituras posteriores al clear se seguirán difundiendo a los peers. wu.destroy() lo llama como parte del desmontaje.

getMetrics(): {
reads: number;
writes: number;
notifications: number;
bufferUtilization: number;
sequenceNumber: number;
bufferSize: number;
listenerCount: number;
}
console.log(wu.store.getMetrics());

Casos límite. listenerCount cuenta rutas y patrones distintos, no callbacks: diez listeners en una misma ruta cuentan como uno. notifications cuenta rondas de despacho, no callbacks invocados. Las métricas sobreviven a clear().

getRecentEvents(count?: number): Array<{ path: string, value: any, timestamp: number }>

Las últimas escrituras, las más recientes primero, desde un búfer circular de 256 posiciones.

console.table(wu.store.getRecentEvents(20));

Casos límite. El búfer es un anillo de tamaño fijo: cualquier cosa más antigua que 256 escrituras ya se sobrescribió. value es la referencia viva guardada en la posición, así que las entradas pueden estar desactualizadas. timestamp viene de performance.now() — relativo a la carga de la página, no un epoch.

sync(opts?: {
transport?: 'broadcast' | WebSocket | string | { send, onMessage, close };
room?: string;
}): SyncHandle

Estado colaborativo en tiempo real. Cada escritura posterior a la llamada se sella con un reloj de Lamport y un id de sitio, se difunde a los peers y se fusiona con la política “gana el último que escribe” por ruta.

// Sincronizar entre pestañas del mismo origen
const handle = wu.store.sync();
// O entre clientes
const handle = wu.store.sync({ transport: 'wss://sync.example.com', room: 'doc-42' });
await handle.ready();
handle.status(); // { connected, site, lamport, peers, sent, received, ... }
handle.stop();

El handle se devuelve de forma síncrona mientras el chunk del CRDT se carga en segundo plano. ready() resuelve a la instancia, o a null si la conexión falló — nunca se rechaza.

Casos límite y limitaciones reales:

  • El estado escrito antes de sync() nunca se propaga. Solo se rastrean y se envían a los que llegan tarde las rutas escritas después de la llamada.
  • Los reemplazos de estado completo nunca se sincronizan: un set('', value) se queda en local.
  • Un segundo sync() mientras hay uno activo devuelve el handle existente e ignora las opciones nuevas. Deténlo primero para reconfigurarlo.
  • Las rutas que se solapan no convergen de forma fiable. Escrituras concurrentes a user y a user.name desde dos peers pueden producir resultados distintos en cada réplica. Escribe en las hojas.
  • Sobre WebSocket el formato de cable es JSON, así que Date se convierte en una cadena ISO, Map/Set se convierten en {}, y NaN/Infinity se convierten en null. BroadcastChannel usa clonado estructurado y los preserva.
  • Los valores bigint, function y symbol de primer nivel se descartan con un aviso.
  • Un fallo de conexión no lanza: se registra, y el handle simplemente nunca conecta.
tap(fn: (sequence: number, path: string, value: any) => void): () => void

Una manguera síncrona sobre todas las escrituras, disparada dentro de set() antes del microtask de notificación. Esto es lo que usa el grabador de la timeline.

const untap = wu.store.tap((seq, path, value) => {
console.log(seq, path, value);
});

Casos límite. Un argumento que no sea función se ignora silenciosamente y se devuelve una baja que es un no-op. Los taps no los dispara hydrate(), deliberadamente: eso crearía un bucle de realimentación en el registro durante el viaje en el tiempo. Los errores se capturan y se registran por cada tap.

Vale la pena decirlo explícitamente, porque la gente los busca:

  • No hay delete(). Pon la ruta a null o undefined en su lugar. La clave sigue presente en el objeto.
  • No hay has(). Usa get(path) !== undefined, asumiendo que no puede distinguir “ausente” de “asignado a undefined”.
  • No hay off(): conserva la función que devolvió on().
  • No hay getAll(), reset(), subscribe(), snapshot(), select() ni computed().
  • Semántica de referencias por todas partes. get() entrega referencias vivas, set() las guarda. Nada se clona ni se congela. La disciplina corre de tu cuenta.
  • Existen dos gramáticas de comodines incompatibles en el framework: esta (por segmentos) y la del bus de eventos (por caracteres). Se parecen y se comportan distinto.
  • La forma del callback cambia con el patrón. No hay ningún aviso en tiempo de ejecución cuando te equivocas.
  • Sin transacciones. batch() es un bucle; un fallo a mitad deja el estado parcial.
  • El store es global y no está particionado. Cualquier app puede escribir en cualquier ruta. No hay imposición de namespaces ni control de acceso — convenciones como prefijar con el nombre de tu app son responsabilidad tuya.