Actualizaciones optimistas
Las actualizaciones optimistas permiten interfaces muy receptivas y rápidas al evitar los tiempos de espera de la red. Una actualización es optimista porque asume que la red tendrá éxito.
Hacerlo amplifica y crea nuevas condiciones de carrera; por suerte, Reactive Data Client las maneja automáticamente por ti.
Resources
resource() se puede configurar estableciendo optimistic: true.
import { Entity, resource } from '@data-client/rest'; export class Todo extends Entity { id = 0; userId = 0; title = ''; completed = false; static key = 'Todo'; } export const TodoResource = resource({ urlPrefix: 'https://jsonplaceholder.typicode.com', path: '/todos/:id', searchParams: {} as { userId?: string | number } | undefined, schema: Todo, optimistic: true, });
Esto hace que todas las mutaciones sean optimistas usando algunas implementaciones por defecto razonables que cubren la mayoría de los casos.
update/getList.push/getList.unshift
function optimisticUpdate(
snap: SnapshotInterface,
params: any,
body: any,
) {
return {
...params,
...ensureBodyPojo(body),
};
}
function ensureBodyPojo(body: any) {
return body instanceof FormData
? Object.fromEntries((body as any).entries())
: body;
}
En las creaciones (push/unshift) esto normalmente da como resultado que la respuesta no tenga id con el que calcular una pk.
Data Client creará una pk aleatoria para que esto funcione.
Hasta que el objeto se crea realmente, hacer mutaciones sobre ese objeto generalmente no funciona.
Por lo tanto, en estos casos puede ser prudente deshabilitar nuevas mutaciones hasta que el
POST real se complete. Una forma de determinarlo es simplemente comprobar la existencia de
un id real en la entidad.
partialUpdate
function optimisticPartial(schema: Queryable) {
return function (snap: SnapshotInterface, params: any, body: any) {
const data = snap.get(schema, params);
if (!data) throw snap.abort;
return {
...params,
...data,
// even tho we don't always have two arguments, the extra one will simply be undefined which spreads fine
...ensurePojo(body),
};
};
}
Las actualizaciones parciales no envían el cuerpo completo, por lo que podemos usar la entidad del store para calcular la respuesta esperada. Los Snapshots nos dan acceso seguro al valor existente del store, a prueba de cualquier condición de carrera.
delete
function optimisticDelete(snap: SnapshotInterface, params: any) {
return params;
}
Si no quieres que todos los endpoints sean optimistas, o si tienes diseños de API poco habituales, puedes definir getOptimisticResponse() usando Resource.extend()
Transformaciones optimistas
A veces las acciones del usuario deben producir transformaciones de datos que dependen del estado anterior de los datos. Los ejemplos más simples son alternar un booleano o incrementar un contador; pero el mismo principio se aplica a transformaciones más complicadas. Para que sea más evidente, aquí usamos un contador simple.
import { RestEndpoint } from '@data-client/rest'; import { CountEntity, getCount } from './count'; export const increment = new RestEndpoint({ path: '/api/count/increment', method: 'POST', body: undefined, name: 'increment', schema: CountEntity, getOptimisticResponse(snap) { const data = snap.get(CountEntity, {}); if (!data) throw snap.abort; return { count: data.count + 1, }; }, });
Reactive Data Client maneja automáticamente todas las condiciones de carrera debidas a los tiempos de red. Reactive Data Client registra los tiempos de los fetches, empareja las respuestas con su respectiva actualización optimista y la revierte cuando se resuelve o se rechaza/falla.
Puedes ver lo problemático que esto resulta para otras librerías incluso sin actualizaciones optimistas; pero las actualizaciones optimistas lo empeoran aún más.
Ejemplo de condición de carrera
Este es un ejemplo de la condición de carrera. Aquí solicitamos un incremento dos veces; pero la primera respuesta llega al cliente después de la segunda.
Con otras librerías y sin actualizaciones optimistas, esto daría como resultado mostrar 0, luego 2 y luego 1.
Si la otra librería sí tiene actualizaciones optimistas, mostraría 0, 1, 2, 2 y luego 1.
En ambos casos terminamos mostrando un estado incorrecto y, por el camino, vemos actualizaciones de estado extrañas y entrecortadas.
Compensar las variaciones de tiempo del servidor
Hay tres tiempos que pueden variar en una mutación asíncrona.
- Tiempo de la petición
- Tiempo del servidor
- Tiempo de la respuesta
Reactive Data Client es capaz de manejar automáticamente los tiempos de red, es decir, los tiempos de la petición y de la respuesta. Normalmente esto es suficiente, ya que los servidores tienden a procesar primero las peticiones que reciben antes. Sin embargo, si el orden de persistencia en el servidor varía respecto al orden de las peticiones, esto podría causar otra condición de carrera.
Esto se puede resolver manteniendo un orden total. Como los
servidores y los clientes pueden tener relojes distintos, necesitamos registrar el tiempo desde una perspectiva consistente.
Dado que realizamos actualizaciones optimistas, esto significa que debemos usar el reloj del cliente. Es decir, enviaremos el tiempo
de la petición al servidor en un header updatedAt mediante getRequestInit(). El servidor debe entonces garantizar el procesamiento según ese orden y
luego almacenar este updatedAt en la entidad para devolverlo en cualquier petición.
Sobrescribiendo shouldReorder, podemos reordenar las respuestas que llegan desordenadas según la marca de tiempo del servidor.
Usamos snap.fetchedAt en nuestro getOptimisticResponse. Esto representa el momento en que se dispara el fetch, que será el mismo instante en que se calcula el header updatedAt.
import { RestEndpoint } from '@data-client/rest'; import { CountEntity } from './count'; export const increment = new RestEndpoint({ path: '/api/count/increment', method: 'POST', body: undefined, name: 'increment', schema: CountEntity, getRequestInit() { // this is a substitute for super.getRequestInit() // since we aren't in a class context return RestEndpoint.prototype.getRequestInit.call(this, { updatedAt: Date.now(), }); }, getOptimisticResponse(snap) { const data = snap.get(CountEntity, {}); if (!data) throw snap.abort; return { count: data.count + 1, updatedAt: snap.fetchedAt, }; }, });