Migrations
Traditional centralized databases are versioned and updated using migration scripts. However, this approach isn't practical for local-first databases. Migration scripts can be slow when processing large amounts of local data, and if a migration fails, there's no centralized database to fix—each failed instance would need to be repaired individually.
For these reasons, Evolu embraces a version-less append-only schema—the same pattern as GraphQL schema design. Facebook adopted this pattern to maintain a single endpoint for countless clients, including those that had not been updated for years. Evolu applies this principle to local-first databases, ensuring compatibility across all versions.
Append only schema
Once an Evolu app is released, the existing database schema must remain unchanged. That means:
- No renaming tables
- No renaming columns
- No changing column types
This is important because there's always a chance that some data has already been created using the previous schema. Changing it would break compatibility.
Instead of migrations, simply add new tables and columns to the existing schema. Newer app code must keep already existing tables and columns in the schema (don't delete them) and continue using them when receiving data from previous app versions. For example, if you replace a string address column with an addressId column (because we have a new address table), the app should use addressId when present, and fall back to address when it's not.
This append-only approach raises an important question: if new tables and columns can be added over time and obsolete ones can stop being used at all, how should Evolu apps handle that? The answer is another GraphQL schema design pattern—nullability.
Nullability
To understand how Evolu handles nullability, let's look at a simple schema:
schema.ts
const TodoId = id("Todo");
const Schema = {
todo: {
id: TodoId,
title: NonEmptyString100,
isCompleted: nullOr(SqliteBoolean),
},
};
In this schema, id and title columns are not nullable, while isCompleted is nullable (it may have been added later, or it's just not required when a new todo is inserted).
However, even though title is not nullable—which is enforced in mutations—Evolu treats it as nullable when writing queries. This forces developers to explicitly define the shape they want.
query.ts
// Evolu uses Kysely for type-safe SQL (https://kysely.dev/).
const todosQuery = evolu.createQuery((db) =>
db
// Type-safe SQL: try autocomplete for table and column names.
.selectFrom("todo")
.select(["id", "title", "isCompleted"])
// Soft delete: filter out deleted rows.
.where("isDeleted", "is not", sqliteTrue)
// Like with GraphQL, all columns except id are nullable in queries
// (even if defined without nullOr in the schema) to allow schema
// evolution without migrations. Filter nulls with where + $narrowType.
.where("title", "is not", null)
.$narrowType<{ title: kysely.NotNull }>()
// Columns createdAt, updatedAt, isDeleted are auto-added to all tables.
.orderBy("createdAt"),
);
This approach ensures data integrity even when CRDT messages arrive out of order. For example, a message deleting a todo might arrive before the message creating it. By explicitly filtering for the shape the current app expects, only valid rows—or rows the current version of the app can handle—are selected from the database.
Just like schemas, queries must be append-only as well. If we stop querying obsolete columns or tables, older data using those columns will suddenly disappear from the results.