Time Travel
Evolu does not delete data—it only marks it as deleted. This is a fundamental design choice because local-first is a distributed system. There is no central authority (if there is, it’s not truly local-first).
Imagine this scenario: you delete a piece of data on a disconnected device, while another device updates that same data. Should the update be discarded? To enforce true deletion across all devices—even future ones—would require complex logic to reject the data forever, without exposing the original data (for security reasons). This is possible (and planned for Evolu), but it's not trivial.
By retaining all data, Evolu enables time travel. All mutations—including deletes and overrides—are stored in the evolu_history table.
Here’s how to query the history of a specific column:
const titleHistoryQuery = evolu.createQuery((db) =>
db
.selectFrom("evolu_history")
.select(["value", "timestamp"])
.where("table", "==", "todo")
.where("id", "==", idToIdBytes(id))
.where("column", "==", "title")
// `value` isn't typed; this is how we can narrow its type.
.$narrowType<{ value: (typeof Schema)["todo"]["title"]["Type"] }>()
.orderBy("timestamp", "desc"),
);
const handleHistoryClick = () => {
void evolu.loadQuery(titleHistoryQuery).then((rows) => {
const rowsWithTimestamp = rows.map((row) => ({
value: row.value,
timestamp: timestampToDateIso(timestampBytesToTimestamp(row.timestamp)),
}));
alert(JSON.stringify(rowsWithTimestamp, null, 2));
});
};
This API isn’t fully type-safe, but it’s not a concern. Evolu Schemas are append-only. Once an app is released, do not rename or change the type of an existing table or column — only add new tables or columns to evolve your schema; changing existing columns or types breaks compatibility with historical data.