You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: apps/web/src/app/(docs)/docs/migrations/page.mdx
+33-69Lines changed: 33 additions & 69 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -3,101 +3,65 @@ export const metadata = {
3
3
description: "How to handle migrations in Evolu apps.",
4
4
};
5
5
6
-
<Warn>
7
-
This doc is from an earlier version of Evolu. While some concepts still apply,
8
-
others may have changed — double-check with the latest source or tests.
9
-
</Warn>
10
-
11
-
<Warn>
12
-
This is still valid except that the outdated version can't accept new data
13
-
anymore, as it uses the Evolu schema for validation.
14
-
</Warn>
15
-
16
6
# Migrations
17
7
18
-
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.
19
-
20
-
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.
8
+
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.
21
9
22
-
<Note>
23
-
Evolu embraces a schemaless design, similar to [best
24
-
practices](https://graphql.org/learn/best-practices/#versioning), but with a
25
-
few key improvements. Since Evolu relay is completely generic—it has no
26
-
knowledge of any specific Evolu app schemas—and the database is local, we can
27
-
do more. Evolu can automatically filter rows with nullable columns and narrow
28
-
their types, so developers don't have to manually handle nullable values in
29
-
their code.
30
-
</Note>
10
+
For these reasons, Evolu embraces a version-less append-only schema—the same pattern as [GraphQL schema design](https://graphql.org/learn/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.
31
11
32
12
## Append only schema
33
13
34
-
The first and most important rule is the append-only schema.
35
-
36
-
Once an app is released, the existing database schema must remain unchanged. That means:
14
+
Once an Evolu app is released, the existing database schema must remain unchanged. That means:
37
15
38
16
-**No renaming tables**
39
17
-**No renaming columns**
40
18
-**No changing column types**
41
19
42
-
Why? Because there's always a chance that some data has already been created using the old structure. Changing it would break compatibility.
43
-
44
-
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.
20
+
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.
45
21
46
-
## Nullability
47
-
48
-
While we _can_ and _should_ define non-nullable column types—enforced during mutations—all columns (except for `id`) are treated as nullable in queries.
49
-
50
-
Why? Because Evolu apps must handle all data gracefully, regardless of when or where it was created.
22
+
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.
51
23
52
-
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.
24
+
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](https://graphql.org/learn/schema-design/) pattern—nullability.
53
25
54
-
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.
26
+
## Nullability
55
27
56
-
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.
28
+
To understand how Evolu handles nullability, let's look at a simple schema:
57
29
58
-
Take a look at this schema, mutation, and query.
30
+
```ts {{ title: 'schema.ts' }}
31
+
const TodoId =id("Todo");
59
32
60
-
```ts {{ title: 'Basic Evolu schema and create mutation' }}
61
-
const TodoTable = {
62
-
id: TodoId,
63
-
// The title is not nullable.
64
-
title: NonEmptyString1000,
33
+
const Schema = {
34
+
todo: {
35
+
id: TodoId,
36
+
title: NonEmptyString100,
37
+
isCompleted: nullOr(SqliteBoolean),
38
+
},
65
39
};
40
+
```
66
41
67
-
// Mutations enforce required columns.
68
-
evolu.insert("todo", { title });
42
+
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).
69
43
70
-
// But in queries, all columns (except for `id`) are nullable
71
-
// until we explicitly filter and narrow them.
72
-
const allTodos =evolu.createQuery((db) =>
44
+
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.
45
+
46
+
```ts {{ title: 'query.ts' }}
47
+
// Evolu uses Kysely for type-safe SQL (https://kysely.dev/).
48
+
const todosQuery =evolu.createQuery((db) =>
73
49
db
50
+
// Type-safe SQL: try autocomplete for table and column names.
74
51
.selectFrom("todo")
75
-
.selectAll()
76
-
// Filter null value and ensure non-null type.
52
+
.select(["id", "title", "isCompleted"])
53
+
// Soft delete: filter out deleted rows.
54
+
.where("isDeleted", "is not", sqliteTrue)
55
+
// Like with GraphQL, all columns except id are nullable in queries
56
+
// (even if defined without nullOr in the schema) to allow schema
57
+
// evolution without migrations. Filter nulls with where + $narrowType.
77
58
.where("title", "is not", null)
78
59
.$narrowType<{ title:kysely.NotNull }>()
60
+
// Columns createdAt, updatedAt, isDeleted are auto-added to all tables.
79
61
.orderBy("createdAt"),
80
62
);
81
63
```
82
64
83
-
Now, what if we decide we no longer want to use the `title` column?
84
-
85
-
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**.
86
-
87
-
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.
88
-
89
-
In short: make it nullable, stop writing to it, but keep reading from it—for compatibility.
90
-
91
-
```ts
92
-
typeTitleOrContent=
93
-
| { _tag:"title"; value:NonEmptyString1000 }
94
-
| { _tag:"content"; value:RichTextMax10k };
95
-
```
96
-
97
-
Such a DSL for ad-hoc migrations isn't available yet—but thanks to the flexibility of [Kysely](https://kysely.dev/) and SQLite, it's absolutely possible. We plan to build it soon.
98
-
99
-
### One last question
100
-
101
-
Since `RichTextMax10k` can be a JSON object, should we version it?
65
+
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.
102
66
103
-
**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.
67
+
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.
0 commit comments