Interface Evolu<T>

The Evolu interface provides a type-safe SQL query building and state management defined by a database schema. It leverages Kysely for creating SQL queries in TypeScript, enabling operations such as data querying, loading, subscription to data changes, and mutations (create, update, createOrUpdate). It also includes functionalities for error handling, syncing state management, and owner data manipulation. Specifically, Evolu allows:

  • Subscribing to and getting errors via subscribeError and getError.
  • Creating type-safe SQL queries with createQuery, leveraging Kysely's capabilities.
  • Loading queries and subscribing to query result changes using loadQuery, loadQueries, subscribeQuery, and getQuery.
  • Subscribing to and getting the owner's information and sync state changes.
  • Performing mutations on the database with create, update, and createOrUpdate methods, which include automatic management of common columns like createdAt, updatedAt, and isDeleted.
  • Managing owner data with resetOwner and restoreOwner.
  • Ensuring the database schema's integrity with ensureSchema.
interface Evolu<T> {
    create: Mutate<T, "create">;
    createOrUpdate: Mutate<T, "createOrUpdate">;
    createQuery: (<R>(queryCallback: ((db: Pick<Kysely<{
        [Table in string | number | symbol]: NullableExceptIdCreatedAtUpdatedAt<{
            [Column in string | number | symbol]: T[Table][Column]
        }>
    } & {
        evolu_message: {
            column: string;
            row: string & Brand<"Id">;
            table: keyof T;
            timestamp: TimestampString;
            value: Value;
        };
    }>,
        | "with"
        | "selectFrom"
        | "fn"
        | "withRecursive">) => SelectQueryBuilder<any, any, R>), options?: SqliteQueryOptions) => Query<R>);
    ensureSchema: ((schema: DbSchema) => void);
    exportDatabase: (() => Promise<Uint8Array>);
    getError: (() => null | EvoluError);
    getOwner: (() => null | Owner);
    getQuery: (<R>(query: Query<R>) => QueryResult<R>);
    getSyncState: (() => SyncState);
    loadQueries: (<R, Q>(queries: [...Q[]]) => [...QueryResultsPromisesFromQueries<Q>[]]);
    loadQuery: (<R>(query: Query<R>) => Promise<QueryResult<R>>);
    reloadApp: (() => void);
    resetOwner: ((options?: {
        reload: boolean;
    }) => Promise<void>);
    restoreOwner: ((mnemonic: Mnemonic, options?: {
        reload: boolean;
    }) => Promise<void>);
    socket: Promise<undefined | WebSocket>;
    subscribeError: ((listener: Listener) => Unsubscribe);
    subscribeOwner: ((listener: Listener) => Unsubscribe);
    subscribeQuery: ((query: Query) => ((listener: Listener) => Unsubscribe));
    subscribeSyncState: ((listener: Listener) => Unsubscribe);
    update: Mutate<T, "update">;
}

Type Parameters

Properties

create: Mutate<T, "create">

Create a row in the database and returns a new ID. The first argument is the table name, and the second is an object.

The third optional argument, the onComplete callback, is generally unnecessary because creating a row cannot fail. Still, UI libraries can use it to ensure the DOM is updated if we want to manipulate it, for example, to focus an element.

Evolu does not use SQL for mutations to ensure data can be safely and predictably merged without conflicts.

Explicit mutations also allow Evolu to automatically add and update a few useful columns common to all tables. Those columns are: createdAt, updatedAt, and isDeleted.

import * as S from "@effect/schema/Schema";

// Evolu uses the Schema to enforce domain model.
const title = S.decodeSync(Evolu.NonEmptyString1000)("A title");

const { id } = evolu.create("todo", { title }, () => {
// onComplete callback
});
createOrUpdate: Mutate<T, "createOrUpdate">

Create or update a row in the database and return the existing ID. The first argument is the table name, and the second is an object.

This function is useful when we already have an id and want to create a new row or update an existing one.

The third optional argument, the onComplete callback, is generally unnecessary because updating a row cannot fail. Still, UI libraries can use it to ensure the DOM is updated if we want to manipulate it, for example, to focus an element.

Evolu does not use SQL for mutations to ensure data can be safely and predictably merged without conflicts.

Explicit mutations also allow Evolu to automatically add and update a few useful columns common to all tables. Those columns are: createdAt, updatedAt, and isDeleted.

import * as S from "@effect/schema/Schema";
import { Id } from "@evolu/react";

// Id can be stable.
// 2024-02-0800000000000
const id = S.decodeSync(Id)(date.toString().padEnd(21, "0")) as TodoId;

evolu.createOrUpdate("todo", { id, title });
createQuery: (<R>(queryCallback: ((db: Pick<Kysely<{
    [Table in string | number | symbol]: NullableExceptIdCreatedAtUpdatedAt<{
        [Column in string | number | symbol]: T[Table][Column]
    }>
} & {
    evolu_message: {
        column: string;
        row: string & Brand<"Id">;
        table: keyof T;
        timestamp: TimestampString;
        value: Value;
    };
}>,
    | "with"
    | "selectFrom"
    | "fn"
    | "withRecursive">) => SelectQueryBuilder<any, any, R>), options?: SqliteQueryOptions) => Query<R>)

Create type-safe SQL Query.

Evolu uses Kysely - the type-safe SQL query builder for TypeScript. See https://kysely.dev.

For mutations, use create and update.

const allTodos = evolu.createQuery((db) =>
db.selectFrom("todo").selectAll(),
);

const todoById = (id: TodoId) =>
evolu.createQuery((db) =>
db.selectFrom("todo").selectAll().where("id", "=", id),
);
ensureSchema: ((schema: DbSchema) => void)

Ensure tables and columns defined in EvoluSchema exist in the database.

This function is for hot/live reloading.

exportDatabase: (() => Promise<Uint8Array>)

Export SQLite database as Uint8Array.

getError: (() => null | EvoluError)
getOwner: (() => null | Owner)

Get Owner.

const unsubscribe = evolu.subscribeOwner(() => {
const owner = evolu.getOwner();
});
getQuery: (<R>(query: Query<R>) => QueryResult<R>)
const unsubscribe = evolu.subscribeQuery(allTodos)(() => {
const { rows } = evolu.getQuery(allTodos);
});
getSyncState: (() => SyncState)

Get SyncState.

const unsubscribe = evolu.subscribeSyncState(() => {
const syncState = evolu.getSyncState();
});
loadQueries: (<R, Q>(queries: [...Q[]]) => [...QueryResultsPromisesFromQueries<Q>[]])

Load an array of Query queries and return an array of QueryResult promises. It's like queries.map(loadQuery) but with proper types for returned promises.

evolu.loadQueries([allTodos, todoById(1)]);
loadQuery: (<R>(query: Query<R>) => Promise<QueryResult<R>>)

Load Query and return a promise with QueryResult.

A returned promise always resolves successfully because there is no reason why loading should fail. All data are local, and the query is typed. A serious unexpected Evolu error shall be handled with subscribeError.

Loading is batched, and returned promises are cached, so there is no need for an additional cache. Evolu's internal cache is invalidated on mutation.

The returned promise is enriched with special status and value properties for the upcoming React use Hook, but other UI libraries can also leverage them. Speaking of React, there are two essential React Suspense-related patterns that every developer should be aware of—passing promises to children and caching over mutations.

With promises passed to children, we can load a query as soon as possible, but we don't have to use the returned promise immediately. That's useful for prefetching, which is generally not necessary for local-first apps but can be if a query takes a long time to load.

Caching over mutation is a pattern that every developer should know. As we said, Evolu caches promise until a mutation happens. A query loaded after that will return a new pending promise. That's okay for general usage but not for UI with React Suspense because a mutation would suspend rerendered queries on a page, and that's not a good UX.

We call this pattern "caching over mutation" because it has no globally accepted name yet. React RFC for React Cache does not exist yet.

For better UX, a query must be subscribed for updates. This way, instead of Suspense flashes, the user sees new data immediately because Evolu replaces cached promises with fresh, already resolved new ones.

If you are curious why Evolu does not do that for all queries by default, the answer is simple: performance. Tracking changes is costly and meaningful only for visible (hence subscribed) queries anyway. To subscribe to a query, use subscribeQuery.

const allTodos = evolu.createQuery((db) =>
db.selectFrom("todo").selectAll(),
);
evolu.loadQuery(allTodos).then(({ rows }) => {
console.log(rows);
});
reloadApp: (() => void)

Reload the app in a platform-specific way. For browsers, this will reload all tabs using Evolu. For native apps, it will restart the app.

resetOwner: ((options?: {
    reload: boolean;
}) => Promise<void>)

Delete Owner and all their data from the current device. After the deletion, Evolu will purge the application state. For browsers, this will reload all tabs using Evolu. For native apps, it will restart the app.

Reloading can be turned off via options if you want to provide a different UX.

restoreOwner: ((mnemonic: Mnemonic, options?: {
    reload: boolean;
}) => Promise<void>)

Restore Owner with all their synced data. It uses resetOwner, so be careful.

socket: Promise<undefined | WebSocket>
subscribeError: ((listener: Listener) => Unsubscribe)

Subscribe to EvoluError changes.

const unsubscribe = evolu.subscribeError(() => {
const error = evolu.getError();
console.log(error);
});
subscribeOwner: ((listener: Listener) => Unsubscribe)

Subscribe to Owner changes.

const unsubscribe = evolu.subscribeOwner(() => {
const owner = evolu.getOwner();
});
subscribeQuery: ((query: Query) => ((listener: Listener) => Unsubscribe))

Subscribe to Query QueryResult changes.

const unsubscribe = evolu.subscribeQuery(allTodos)(() => {
const { rows } = evolu.getQuery(allTodos);
});
subscribeSyncState: ((listener: Listener) => Unsubscribe)

Subscribe to SyncState changes.

const unsubscribe = evolu.subscribeSyncState(() => {
const syncState = evolu.getSyncState();
});
update: Mutate<T, "update">

Update a row in the database and return the existing ID. The first argument is the table name, and the second is an object.

The third optional argument, the onComplete callback, is generally unnecessary because updating a row cannot fail. Still, UI libraries can use it to ensure the DOM is updated if we want to manipulate it, for example, to focus an element.

Evolu does not use SQL for mutations to ensure data can be safely and predictably merged without conflicts.

Explicit mutations also allow Evolu to automatically add and update a few useful columns common to all tables. Those columns are: createdAt, updatedAt, and isDeleted.

import * as S from "@effect/schema/Schema";

// Evolu uses the Schema to enforce domain model.
const title = S.decodeSync(Evolu.NonEmptyString1000)("A title");
evolu.update("todo", { id, title });

// To delete a row, set `isDeleted` to true.
evolu.update("todo", { id, isDeleted: true });