Schema

An Evolu app schema is a plain object that defines the app's tables and columns. Every column is defined by a Standard Schema-compatible validator. Evolu uses the schema to create local SQLite tables and infer mutation and query types.

AppSchema.ts

import {
  createQueryBuilder,
  id,
  NonEmptyTrimmedString100,
  nullOr,
  SqliteBoolean,
} from "@evolu/common";

const AppSchema = {
  todo: {
    id: id("Todo"),
    title: NonEmptyTrimmedString100,
    isCompleted: nullOr(SqliteBoolean),
  },
};

const _createQueryBuilder = createQueryBuilder(AppSchema);

Every table must have an id column whose output type extends Id. Other column outputs must be compatible with SqliteValue.

Evolu automatically adds the system columns createdAt, updatedAt, isDeleted, and ownerId to every table. Pass the same schema to createEvolu and createQueryBuilder so mutations, queries, and the local SQLite structure use the same definition.

Evolu mutations accept already validated values so they do not run column validators again. Validate untrusted input with your chosen Standard Schema-compatible library before passing its output to Evolu's insert, update, or upsert.

Versioning

A local-first app cannot assume that all its instances use the latest app schema: users update the app at different times.

When an app receives changes for tables or columns that its schema does not support yet, Evolu stores them in its system quarantine table. Quarantined data does not appear in app queries, but it still syncs to other devices. Each time the app opens its local database, Evolu checks the quarantine again. After the app is updated with a schema that supports those changes, Evolu applies them and removes them from quarantine. The same table also holds changes whose timestamps exceed the clock-drift limit; those are applied when the app opens its local database once the device's system time comes within the limit of their timestamps. The reason column tells the two apart, and apps can query the table with createQuery.

Evolu uses GraphQL's proven pattern of versionless schema evolution: app schemas are append-only. Instead of running migration scripts on every local database, a released schema can only be extended with new tables and columns:

  • Do not rename or remove tables
  • Do not rename or remove columns
  • Do not change column types

Relaxing a column's constraints—for example, allowing longer text or a wider numeric range—can be compatible, but do it only with explicit tests. The safest rule is never to change a released column type.

Preserving existing declarations is not enough. Newer app code must also handle data created by previous versions. For example, if a string address column is replaced by an addressId column referencing a new address table, the app should use addressId when present and fall back to address otherwise.

Nullability

New columns do not have to be nullable. Schema nullability controls mutations made by the current app version: non-nullable columns other than id are required by insert and upsert, while nullable columns are optional. In the first example, title is required and isCompleted is optional.

Regardless of their mutation nullability, Evolu treats every app-defined column except id as nullable in queries. A query must explicitly select the data shape the app can handle:

import {
  assertType,
  createQueryBuilder,
  id,
  type KyselyNotNull,
  NonEmptyTrimmedString100,
  nullOr,
  SqliteBoolean,
  sqliteTrue,
} from "@evolu/common";

const TodoId = id("Todo");
type TodoId = typeof TodoId.Output;

const AppSchema = {
  todo: {
    id: TodoId,
    title: NonEmptyTrimmedString100,
    isCompleted: nullOr(SqliteBoolean),
  },
};

const createAppQuery = createQueryBuilder(AppSchema);

const todosQuery = createAppQuery((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)
    // Filter nulls with where + $narrowType.
    .where("title", "is not", null)
    .$narrowType<{ title: KyselyNotNull }>()
    // createdAt, updatedAt, isDeleted, and ownerId are added automatically.
    .orderBy("createdAt"),
);

// Extract the row type from the query for type-safe component props.
type TodosRow = typeof todosQuery.Row;

assertType<
  {
    id: TodoId;
    title: NonEmptyTrimmedString100;
    isCompleted: SqliteBoolean | null;
  },
  TodosRow
>();

Evolu does not rely on changes being delivered in timestamp order. An update can arrive before the corresponding insertion, leaving the row incomplete until the older change arrives. Evolu still resolves the final value of each column independently by timestamp. By explicitly filtering for the shape the current app expects, only usable rows are selected.

Default values

Prefer applying defaults in the view instead of storing them in the database. Keep a column nullable and use ?? when reading or displaying its value if the default only determines how the app presents missing data.

Absence can carry meaning. For a notification preference, null can mean the user has not made a decision, while an explicit false value means notifications were disabled. Both may look the same in the UI, but keeping them distinct lets the user clear their preference by setting it back to null.

Use withDefault, or another validator's defaulting API, in a database schema only when storing the default is necessary. Replacing absence and storing the result can add unnecessary data to database rows and erase the distinction between a default and a user decision. Store a value when it represents a user's decision or another fact your application needs to retain.

Declaring a default does not make Evolu fill omitted mutation values: mutations accept already validated values. Defaulting happens when you decode data with the validator before passing the result to Evolu.

Standard Schema

Evolu app schemas are not tied to Type. You can define columns with Zod, Valibot, ArkType, or any other library implementing Standard Schema V1.

The output type of every validator must still satisfy Evolu's SQLite constraints. In particular, numeric outputs must be typed as FiniteNumber rather than number. Zod 4 validates that numbers are finite at runtime but still infers number. Every table also requires an id whose output extends Id. These adapters provide both:

import * as z from "zod";
import { assertType, type Brand, type FiniteNumber, Id } from "@evolu/common";

const zodId = <Table extends string>(_table: Table) =>
  z.custom<Id & Brand<Table>>(Id.is);

const TodoId = zodId("Todo");
assertType<Id & Brand<"Todo">, z.output<typeof TodoId>>();

const ZodFiniteNumber = z
  .number()
  .transform((value): FiniteNumber => value as FiniteNumber);

assertType<FiniteNumber, z.output<typeof ZodFiniteNumber>>();