This doc is from an earlier version of Evolu. While some concepts still apply, others may have changed — double-check with the latest source or tests.
This is still valid except that the outdated version can't accept new data anymore, as it uses the Evolu schema for validation.
Migrations
Traditional server-based databases are versioned and updated using migration scripts. However, this approach isn't practical for local-first databases that replicate across devices. Evolu apps must be capable of handling both existing data and data created in the future—even by newer app versions.
That means even an outdated version of an Evolu app needs to work seamlessly with data generated by a more recent version. Fortunately, Evolu handles this automatically with just two simple rules.
Evolu embraces a schemaless design, similar to best practices, but with a few key improvements. Since Evolu relay is completely generic—it has no knowledge of any specific Evolu app schemas—and the database is local, we can do more. Evolu can automatically filter rows with nullable columns and narrow their types, so developers don't have to manually handle nullable values in their code.
Append only schema
The first and most important rule is the append-only schema.
Once an app is released, the existing database schema must remain unchanged. That means:
- No renaming tables
- No renaming columns
- No changing column types
Why? Because there's always a chance that some data has already been created using the old structure. Changing it would break compatibility.
Instead, the solution is simple: just stop using outdated tables or columns in new mutations. They can remain in the schema for backward compatibility, and that's perfectly fine.
Nullability
While we can and should define non-nullable column types—enforced during mutations—all columns (except for id
) are treated as nullable in queries.
Why? Because Evolu apps must handle all data gracefully, regardless of when or where it was created.
Take this example: you replace an address
column with a new addressId
column (as a foreign key). What happens when an outdated app receives data using the new structure? It doesn't know what to do with addressId
—it has no logic for it yet.
The app should still store the data but simply ignore fields it doesn't understand until it's updated. That's exactly how Evolu works.
Evolu updates the underlying SQLite database on the fly. By treating all columns as nullable in queries, it ensures developers explicitly filter and work only with the data their app version can safely handle.
Take a look at this schema, mutation, and query.
Basic Evolu schema and create mutation
const TodoTable = {
id: TodoId,
// The title is not nullable.
title: NonEmptyString1000,
};
// Mutations enforce required columns.
evolu.insert("todo", { title });
// But in queries, all columns (except for `id`) are nullable
// until we explicitly filter and narrow them.
const allTodos = evolu.createQuery((db) =>
db
.selectFrom("todo")
.selectAll()
// Filter null value and ensure non-null type.
.where("title", "is not", null)
.$narrowType<{ title: kysely.NotNull }>()
.orderBy("createdAt"),
);
Now, what if we decide we no longer want to use the title
column?
We simply stop using it in new mutations and mark it as nullable. But since there's a chance the column has already been used in existing data, we must still support it in queries.
This means the app should continue to query the title
column but treat it as optional. Older versions of the app may still rely on it, and newer versions should gracefully ignore it unless needed.
In short: make it nullable, stop writing to it, but keep reading from it—for compatibility.
type TitleOrContent =
| { _tag: "title"; value: NonEmptyString1000 }
| { _tag: "content"; value: RichTextMax10k };
Such a DSL for ad-hoc migrations isn't available yet—but thanks to the flexibility of Kysely and SQLite, it's absolutely possible. We plan to build it soon.
One last question
Since RichTextMax10k
can be a JSON object, should we version it?
Yes. While it's possible to version via the column name (e.g., content2
, content3
), RichTextMax10k
might be reused in multiple places across the database schema. In that case, it's better to include the versioning inside the RichTextMax10k
type itself, making the structure more explicit and future-proof.