Actualización asincrónica de elementos relacionados con AWS DataStore en una aplicación web Next.js.
RESUMEN
Al trabajar con AWS DataStore tienes que lidiar con operaciones async/await.
Actualizar una lista de elementos cuando el orden de ejecución de las operaciones
asincrónicas no es obligatorio es viable hacerlo con Promise.all() y map.
Actualizar elementos relacionados, donde el resultado de la promesa del elemento anterior es necesario como entrada para el siguiente elemento, se puede lograr con la declaración for await...of. Prueba la demostración funcionando.
Introducción
Como puedes ver en la imagen anterior, tenemos una interfaz de usuario que consiste en una tabla que muestra varios SaleItems. Allí puedes agregar, eliminar, actualizar y listar SaleItems.
Queremos hacer algo similar a MS Excel, donde tenemos celdas que dependen entre sí (recursivamente). Por ejemplo, si actualizamos B3, entonces B4 y B5 también deben actualizarse según el valor de la celda anterior; B4 depende del valor de B5 y B5 depende del valor de B4.
Aquí hay una sección del schema.graphql, que incluye los tipos relevantes (entidades):
type SaleItem
@model
@auth(rules: [{ allow: public }])
@key(name: "bySalesPlanner", fields: ["salesplannerID"]) {
id: ID!
quantity: Float
priceBasedOnRoi: Boolean
price: Float
accSold: Float
accProfit: Float
sharesRemain: Float
salesplannerID: ID
}
type SalesPlanner @model @auth(rules: [{ allow: public }]) {
id: ID!
SaleItems: [SaleItem] @connection(keyName: "bySalesPlanner", fields: ["id"])
}
En este modelo decidí incluir campos calculados como accSold, accProfit y sharesRemain como parte de la entidad SaleItem.
La razón principal de esa decisión fue que en el estado actual de la computación versus el almacenamiento, el almacenamiento es menos costoso que la computación. DynamoDB fue construido con esa restricción en mente, optimizado para lectura versus ahorro de espacio de almacenamiento. Para aprovechar plenamente eso, deberíamos usar un modelo de tabla única, por ahora usamos un modelo relacional con una base de datos DynamoDB debajo.
En mi caso de uso, tener los valores calculados almacenados en la base de datos facilita hacer operaciones CRUD porque no tenemos que calcularlos recursivamente, evitando la complejidad de las operaciones recursivas y mejorando la capacidad de respuesta de la UX para conjuntos de datos grandes.
Un enfoque de diseño de tabla única se recomienda después de que tengas certeza de que los patrones de acceso no van a cambiar demasiado. Estoy en las primeras iteraciones de la aplicación, así que mantendré el enfoque relacional. También asumiré que sabes cómo modelarlo.
En este caso tenemos una relación uno a muchos entre SalesPlanner y SaleItem. Ese tipo de modelado relacional es más fácil usando Amplify Studio, donde visualmente puedes agregar Modelos (entidades), relaciones y reglas de autenticación a tu modelo. Permite compartir tu modelo y administrar contenido como un CMS. Incluso puedes usarlo sin una cuenta de AWS.
La API de AWS DataStore es muy directa, consulta la documentación para más información.
Listar SaleItems
La lista de elementos se mantiene en la variable de estado salesItems. La variable de estado item se usa para poder discernir entre los caminos de crear y eliminar/actualizar en el onSubmit del formulario.
const [salesItems, setSalesItems] = useState([]);
const [item, setItem] = useState(null);
En este efecto accedemos a los SaleItems relacionados con el salesplannerID actual. Sirve para tener acceso en tiempo real a esos elementos, la suscripción está esperando cambios en el tipo SaleItem para disparar una actualización de la lista.
useEffect(() => {
const getItems = async () => {
const models = await DataStore.query(SaleItem, (si) =>
si.salesplannerID("eq", salesplannerID)
);
setSalesItems(models);
};
const subscription = DataStore.observe(SaleItem).subscribe((msg) => {
getItems();
});
getItems();
return () => subscription.unsubscribe();
}, [salesplannerID]);
Los tipos se crean cuando usas el comando CLI amplify codegen models. Entonces
import { SaleItem } from "../src/models";
Agregar SaleItem
Aquí el punto principal es considerar el caso cuando hay un elemento anterior en la lista porque se usa para calcular accSold,
accProfit y sharesRemain. El nuevo elemento se añade al final de la lista como en una estructura de datos FIFO, así que solo el elemento anterior es relevante.
salesItems es una variable de estado. El parámetro data se pasa desde el formulario creado con api react-hook-form.
const prevItem =
salesItems.length > 0 ? salesItems[salesItems.length - 1] : null;
await DataStore.save(
new SaleItem({
"quantity": data.quantity,
"priceBasedOnRoi": true,
"price": data.price,
"accSold": AccSold(prevItem, data.price, data.quantity),
"accProfit": AccProfit(
prevItem,
data.price,
data.quantity,
totalInvestment
),
"sharesRemain": SharesRemain(prevItem, shares, data.quantity),
"salesplannerID": salesplannerID,
})
);
Eliminar SaleItem.
La lista (tabla) tiene acceso a cada elemento que va a ser eliminado. Podemos pasarlo al evento onClick respectivo.
onClick={() => handleDeleteItem(item, index)}
La función handleDeleteItem elimina el elemento pero si no es el último, actualiza el resto según lo eliminas.
Las operaciones en los modelos de DataStore son asincrónicas.
const handleDeleteItem = async (item, index) => {
const { id } = item;
const modelToDelete = await DataStore.query(SaleItem, id);
DataStore.delete(modelToDelete);
if (index !== salesItems.length - 1) {
await updateSaleItemsOnDelete(item, index);
}
};
La función updateSaleItemsOnDelete es más compleja.
Aquí obtenemos del estado salesItems solo la porción de elementos que tenemos que actualizar.
const saleItemsToUpdate = salesItems.slice(index + 1);
Así que necesitamos hacer operaciones asincrónicas en una lista pero cuando eliminas un elemento, se comporta como una constante porque vamos a sumar o restar
la misma cantidad a los campos calculados, nos deshacemos de la restricción de elementos relacionados para las operaciones asincrónicas. Esto significa que no hay
necesidad de esperar a que la operación actual termine para hacer la siguiente.
Además es útil porque no tenemos garantía de que DataStore devuelva los valores en el orden especificado en las iteraciones del map. Algunos tardan más que otros, incluso cuando todos se acceden en el almacén local en el navegador.
DataStore intenta acceder a los datos localmente primero, si no los encuentra se conecta a la base de datos. Los datos actualizados se sincronizan en segundo plano. La explicación de sincronización de DataStore y estrategias de detección/resolución de conflictos están fuera del alcance de este artículo.
En este escenario Promise.all(), sirve para tu propósito. Ejecuta en paralelo (casi) el iterable de promesas pasadas como entrada y devuelve una Promesa cuando todas las promesas han resuelto o rechaza inmediatamente cuando alguna de las promesas de entrada rechaza.
No vamos a usar la promesa devuelta en este caso, solo necesitamos garantizar que todas fallen si alguna de las operaciones asincrónicas falla.
const updateSaleItemsOnDelete = async (toDelete, index) => {
const saleItemsToUpdate = salesItems.slice(index + 1);
await Promise.all(
saleItemsToUpdate.map(async (toUpdate) => {
let profit = toUpdate.accProfit - toDelete.price * toDelete.quantity;
let sold = toUpdate.accSold - toDelete.price * toDelete.quantity;
let remain = toUpdate.sharesRemain + toDelete.quantity;
await DataStore.save(
SaleItem.copyOf(toUpdate, (updated) => {
// Update the values on {updated} variable to update DataStore entry
updated.accSold = parseFloat(sold.toFixed(4));
updated.accProfit = parseFloat(profit.toFixed(4));
updated.sharesRemain = parseFloat(remain.toFixed(4));
})
);
})
);
};
DataStore tiene una notación especial para actualizar elementos porque los modelos en DataStore son inmutables. Usa la función copyOf para actualizar el campo del elemento en lugar de la mutación directa de la instancia.
SaleItem.copyOf(toUpdate, (updated) => {
// Update the values on {updated} variable to update DataStore entry
updated.accSold = parseFloat(sold.toFixed(4));
updated.accProfit = parseFloat(profit.toFixed(4));
updated.sharesRemain = parseFloat(remain.toFixed(4));
});
Actualizar SaleItems
Con la operación de actualización (cuando presionas el botón de actualizar) tenemos una situación diferente. El siguiente elemento depende del elemento anterior, en consecuencia el orden debe respetarse.
Si intentamos usar Promise.all con map podríamos encontrarnos en la situación de que el elemento prev no está actualizado a tiempo antes de la siguiente
iteración.
Para garantizar que tenemos acceso al prev actualizado, usamos for await of, itera sobre objetos iterables asincrónico,
esperando a completar las operaciones pendientes anteriores antes de continuar con la siguiente iteración.
const updateSaleItemsOnUpdate = async (data) => {
const index = salesItems.findIndex((s) => s.id === item?.id);
const prevItem = salesItems.length > 0 ? salesItems[index - 1] : null;
const saleItemsToUpdate = salesItems.slice(index + 1);
// update selected item
let prev = await DataStore.save(
SaleItem.copyOf(item, (u) => {
u.price = data.price;
u.quantity = data.quantity;
u.accSold = AccSold(prevItem, data.price, data.quantity);
u.accProfit = AccProfit(
prevItem,
data.price,
data.quantity,
totalInvestment
);
u.sharesRemain = SharesRemain(prevItem, shares, data.quantity);
})
);
// update the remaining items base on the selected item update
for await (const toUpdate of saleItemsToUpdate) {
prev = await DataStore.save(
SaleItem.copyOf(toUpdate, (item) => {
// Update the values on {item} variable to update DataStore entry
item.accSold = AccSold(prev, toUpdate.price, toUpdate.quantity);
item.accProfit = AccProfit(
prev,
toUpdate.price,
toUpdate.quantity,
totalInvestment
);
item.sharesRemain = SharesRemain(prev, shares, toUpdate.quantity);
})
);
}
};