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 titleHistory = evolu.createQuery((db) =>
  db
    .selectFrom("evolu_history")
    .select(["value", "timestamp"])
    .where("table", "==", "todo")
    .where("id", "==", idToBinaryId(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(titleHistory).then(({ rows }) => {
    const rowsWithTimestamp = rows.map((row) => ({
      ...row,
      timestamp: binaryTimestampToTimestamp(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, you should not rename or change existing table or column types. Only new tables and columns should be added to avoid breaking compatibility with existing data.

Was this page helpful?