Schema management

Schema Management

Schemas can make your life as Triplit developer significantly easier, by providing data validation at runtime, type-hinting, and access to some of Triplit's more advanced features, like relations.

As your app changes, your data model will also change, and the schema for your Triplit databases will need to change as well. This can be a major challenge of any distributed system including Triplit. It is inevitable that some clients may be using older schemas than the server or may go offline for significant periods of time. In this guide we'll go over the best practices for updating the Triplit schema of a live app with a very simple goal: prevent the server and clients from getting into incompatible states.

💡

This guide assumes some working knowledge of Triplit schemas, including the options available to them, client-server syncing, and the Triplit CLI (@triplit/cli) installed.

Getting setup

Let start with a simple schema, defined at ./triplit/schema.ts in your project directory.

./triplit/schema.ts
import { Schema as S } from '@triplit/db';
import { ClientSchema } from '@triplit/client';
 
export const schema = {
  todos: {
    schema: S.Schema({
      id: S.Id(),
      text: S.String(),
      completed: S.Boolean({ default: false }),
    }),
  },
} satisfies ClientSchema;

You can start the development server with the schema pre-loaded with the --initWithSchema or -i option.

triplit dev --initWithSchema

By default, the server will use in-memory storage, meaning that if you shut down the server, all of your data will be lost. This can be useful when you're early in development and frequently iterating on your schema. If you like this quick feedback loop but don't want to repeatedly re-insert test data by hand, you can use Triplit's seed commands. You can use seed command on its own:

triplit seed run my-seed

Or use the --seed flag with triplit dev:

triplit dev --initWithSchema --seed=my-seed

If you want a development environment that's more constrained and closer to production, consider using the SQLite (opens in a new tab) persistent storage option for the development server:

triplit dev --initWithSchema --storage=sqlite

Your database will be saved to triplit/.data. You can delete this folder to clear your database.

Updating your schema

Let's assume you've run some version of triplit dev --initWithSchema shown above and have a server up and running with a schema. You've also properly configured your .env such that Triplit CLI commands will be pointing at it. Let's also assume you've added some initial todos:

App.tsx
import { TriplitClient } from '@triplit/client';
import { schema } from '../triplit';
 
const client = new TriplitClient({
  schema,
  serverUrl: import.meta.env.VITE_TRIPLIT_SERVER_URL,
  token: import.meta.env.VITE_TRIPLIT_TOKEN,
});
 
client.insert('todos', { text: 'Get groceries' });
client.insert('todos', { text: 'Do laundry' });
client.insert('todos', { text: 'Work on project' });

Adding an attribute

Now let's edit our schema by adding a new tagId attribute to todos, in anticipation of letting users group their todos by tag.

./triplit/schema.ts
export const schema = {
  todos: {
    schema: S.Schema({
      id: S.Id(),
      text: S.String(),
      completed: S.Boolean({ default: false }),
      tagId: S.String(),
    }),
  },
} satisfies ClientSchema;

Pushing the schema

We're trying to mimic production patterns as much as possible, so we're not going to restart the server to apply this change (and in fact, that would cause problems, as we'll soon see). Instead let's use a new command:

triplit schema push

This will look at the schema defined at ./triplit/schema.ts and attempt to apply it to the server while it's still running. In our case, it fails, and we get an error like this:

✖ Failed to push schema to server
Found 1 backwards incompatible schema changes.
Schema update failed. Please resolve the following issues:

Collection: 'todos'
        'tagIds'
                Issue: added an attribute where optional is not set
                Fix:   make 'tagIds' optional or delete all entities in 'todos' to allow this edit

What's at issue here is that we tried to change the shape/schema of a todo to one that no longer matches those in the database. All attributes in Triplit are required by default, and by adding a new attribute without updating the existing todos, we would be violating the contract between the schema and the data.

Thankfully, the error gives us some instructions. We can either

  1. Make tagId optional e.g. tagIds: S.Optional(S.String()) and permit existing todos to have a tagId that's undefined.
  2. Delete all of the todos in the collection so that there isn't any conflicting data.

While 2. might be acceptable in development, 1. is the obvious choice in production. In production, we would first add the attribute as optional, backfill it for existing entities with calls to client.update, as well as upgrade any clients to start creating new todos with tagId defined. Only when you're confident that all clients have been updated to handle the new schema and all existing data has been updated to reflect the target schema, should you proceed with a backwards incompatible change.

Whenever you try to triplit schema push, the receiving database will run a diff between the current schema and the one attempting to be applied and surface issues like these. Here are all possible conflicts that may arise.

Fixing the issues

Let's make tagId optional:

./triplit/schema.ts
export const schema = {
  todos: {
    schema: S.Schema({
      id: S.Id(),
      text: S.String(),
      completed: S.Boolean({ default: false }),
      tagId: S.Optional(S.String()),
    }),
  },
} satisfies ClientSchema;

Now we can run triplit schema push again, and it should succeed. For completeness, let's also backfill the tagId for existing todos:

await client.transact(async (tx) => {
  const allTodos = await tx.fetch(client.query('todos').build());
  for (const [_id, todo] of allTodos) {
    await tx.update('todos', todo.id, (entity) => {
      entity.tagId = 'chores';
    });
  }
});

We've now successfully updated our schema in a backwards compatible way. If you're confident that all clients have been updated to handle the new schema and all existing data has been updated to reflect the target schema, you can then choose to make tagId required.

Handling backwards incompatible changes

Adding an attribute where optional is not set

Like in the example above, these changes will be backwards incompatible if you have existing entities in that collection. In production, only add optional attributes, and backfill that attribute for existing entities.

Removing a non-optional attribute

This is a backwards incompatible change, as it would leave existing entities in the collection with a missing attribute. In production, deprecate the attribute by making it optional, delete the attribute from all existing entities (set it to undefined), and then you be allowed to remove it from the schema.

Removing an optional attribute

While not technically a backwards incompatible change, it would lead to data loss. In production, delete the attribute from all existing entities first (set it to undefined) and then it will be possible to remove it from the schema.

Changing an attribute from optional to required

This is a backwards incompatible change, as existing entities with this attribute set to undefined will violate the schema. In production, update all existing entities to have a non-null value for the attribute, and then you will be able to make it required.

Changing the type of an attribute

Triplit will prevent you from changing the type of an attribute if there are existing entities in the collection. In production, create a new optional attribute with the desired type, backfill it for existing entities, and then remove the old attribute following the procedure described above ("Removing an optional attribute").

Changing the type of a set's items

This is similar to changing the type of an attribute, but for sets. In production, create a new optional set attribute with the desired type, backfill it for existing entities, and then remove the old set following the procedure described above ("Removing an optional attribute").

Changing an attribute from nullable to non-nullable

Triplit will prevent you from changing an attribute from nullable to non-nullable if there are existing entities in the collection for which the attribute is null. In production, update all of the existing entities to have a non-null value for the attribute and take care that no clients will continue writing null values to the attribute. Then you will be able to make it non-nullable.