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.

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.

Was this page helpful?