Data Modelling and Schema Design in Jazz

To understand how best to model data for your Jazz application, it's helpful to first think about how your data is related.

Jazz as a Collaborative Graph

In a traditional database, you might model different data types as 'tables' or 'collections'. With Jazz, you model data types as schemas, and data as an explicitly linked graph.

For example, consider the following SQL data model for a simple blog:

-- Users table
CREATE TABLE authors (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) NOT NULL
);

-- Posts table
CREATE TABLE posts (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    content TEXT NOT NULL,
);

This data model for a Jazz app is defined in a schema which might look like this:

const Author = co.map({
  name: z.string()
});

const Post = co.map({
  title: z.string(),
  content: co.richText(),
});
What are z. and co.?

Jazz uses Zod schemas to define primitive data types. z.string() indicates that the stored value should be a string.

To define collaborative data types, Jazz provides utilities under the co namespace. For example, here, we use co.richText(), to indicate a collaborative value.

Permissions are part of the data model

In traditional databases, permissions are often left to the application layer, or are a more advanced feature of the database that requires additional configuration.

With Jazz, permissions are an integral part of the data model — you need to consider permissions when structuring your data, not just when you're accessing it. Each CoValue has an ownership group, and permissions are defined based on the ownership hierarchy. This is very powerful, as it allows you to easily build cascading permissions hierarchies, but it does mean that you need to ensure each piece of data which needs different permissions to be applied lives in a different container (e.g. a separate CoMap or CoList). It is not possible to change the group which owns a CoValue. In order to change the permissions applying to a particular CoValue, an admin (or manager) can make changes to the group membership.

You should consider the default permissions you would like a CoValue to have when you are designing your data model. You can specify these in your schema.

Permissions Levels

The following main permissions levels exist (with each level including all the permissions from the previous level):

  • none: can't read the content of CoValue
  • reader: can read the content of the CoValue
  • writer: can update the content of the CoValue (overwrite values, add/remove items from lists, etc.)
  • admin: can grant or revoke permissions for others, as well as writing.

These permissions can be granted to individual users or to groups.

Admins

By default, only the user creating a CoValue is the admin. In contrast to traditional databases, Jazz does not have a concept of an 'application admin', or a 'root' or 'superuser'.

Unless explicitly defined otherwise in your data model, only the creator of the CoValue will be added as an admin. Once a CoValue is created, its permissions can only be changed by an admin (or a manager).

If creating a nested CoValue inline, then by default, permissions are inherited from the containing CoValue.

Choosing your building blocks

Jazz helps you build your app by providing concrete building blocks. Basic types which do not need collaborative editing, such as simple strings, numbers, and other scalar types are defined using Zod.

Most apps will have more complex needs, however, and for this, Jazz provides you with CoValues, which are composite data structures which hold references to either these scalar types or other CoValues. Each CoValue is suited to a particular use case.

TypeScript TypeCorresponding CoValueUsage
objectCoMapKey-value stores with pre-defined keys (struct-like)
Record<string, T>CoRecordKey-value stores with arbitrary string keys (dict-like)
T[]CoListLists
T[] (append-only)CoFeedSession-based append-only lists
stringCoPlainText/CoRichTextCollaborative text
Blob | FileFileStreamFiles
Blob | File (image)ImageDefinitionImages
number[] | Float32ArrayCoVectorEmbeddings
T | U (discriminated)DiscriminatedUnionLists of different types of items

In some cases, there are both scalar and collaborative options for the same data type. For example, you can use either z.string() or co.richText() for a string field. The key difference is that z.string() does not support collaborative editing. If you would like to update the string field, you can only do so by replacing the entire field with a new value. co.richText() supports collaborative editing, allows multiple users to edit the same string simultaneously.

In our blog example, we used co.richText() for the content, to allow multiple users to edit the same post simultaneously, but we preferred z.string() for the title, as the title doesn't need to be edited collaboratively.

This same principle applies with other data types too. For example, you can use z.object() or co.map() for an object field, and z.tuple() or co.list() for an array field.

As a general rule of thumb: if you expect the whole object to be replaced when you update it, you should use a scalar type. If you expect to make small, surgical edits, or collaborate with others, a CoValue may be a better choice.

Can't I just use CoValues for everything?

Short answer is yes, you can. But you should be aware of the trade-offs. CoValues track their full edit history, and although they are very performant, they cannot achieve the same raw speed as their scalar counterparts for single-writer, full-value replacement updates.

In most real-world applications, the benefits of collaborative editing outweigh the (slight) performance costs.

Linking them together

In almost every case, you'll want to link your CoValues together somehow. This is because CoValues are only addressable by their unique ID. Discovering CoValues without knowing their ID is only possible by traversing references. In a normal Jazz app, we attach a 'root' to a user's account which serves as the entry point into the graph. In case there is a need to create a 'global root', then a CoValue ID can be hard-coded, or added as an environment variable.

One directional relationships

Let's extend our example above to attach an Author to each Post.

const Post = co.map({
  title: z.string(),
  content: co.richText(),
  // A single author
  author: Author
});

Here, Post.author is a one-way reference. Jazz stores the ID of the referenced Author, and when loading a Post you can choose whether to resolve that reference (and if so, how deeply).

The above models a one-to-one relationship: both the Post and the Author have a single reference you can fill.

If you would like to model a one-to-many relationship, use a CoList:

const Post = co.map({
  title: z.string(),
  content: co.richText(),
  // Multiple authors collaborating on a single post
  authors: co.list(Author)
});
Mental model

This is similar to a foreign key in a relational database: the reference lives on one side, and Jazz doesn't infer reverse links for you. You explicitly control how references are followed using resolve queries.

Modelling inverse relationships

Jazz relationships are one-way by default: a reference is stored and you can follow the breadcrumbs to resolve the references. If you need to be able to traverse the relationship from both sides, you solve this by adding the reference to both sides of the relationship.

No inferred relationships

You are in full control of the relationships in your app, Jazz will not create inferred inverse relationships for you. It is particularly important to bear this in mind because it is not possible to query CoValues based on references from other CoValues.

Recursive references

As soon as you start building schemas with inverse (or recursive) relationships, you'll need to defer schema evaluation to avoid TypeScript errors. If we want Author to reference Post and Post to reference Author, whichever order we put them in will cause an error.

You can address this by using getters to refer to schemas which are not yet defined:

const Author = co.map({
  name: z.string(),
  // This allows us to defer the evaluation
  get posts() {
    return co.list(Post);
  }
});

const Post = co.map({
  title: z.string(),
  content: co.richText(),
  author: Author
});

To model a many-to-many relationship, use a CoList at both ends of the relationship — be aware that Jazz does not maintain consistency for you, this should be managed in your application code.

const Author = co.map({
  name: z.string(),
  // A single author has multiple posts
  get posts() {
    return co.list(Post)
  }
});

const Post = co.map({
  title: z.string(),
  content: co.richText(),
  // Multiple authors collaborating on a single post
  authors: co.list(Author)
});
Can I add a unique constraint?

A CoList can contain the same item multiple times. There's no built in way to enforce that items in the list are unique, but you can adjust your data model to use a CoRecord keyed on the referenced CoValue ID to create a set-like collection

const Author = co.map({
  name: z.string(),
  posts: co.record(z.string(), Post)
});

// Assuming 'newPost' is a Post we want to link to from the Author
author.posts.$jazz.set(newPost.$jazz.id, newPost);

Note that CoRecords are always keyed on strings. Jazz does not enforce referential integrity here, so validating that these are valid Post IDs is an application-level responsibility.

Changing Your Data Model

Over time, your data model may change. In a traditional app with a single source of truth, you could simply update the data directly in the database. With Jazz, each individual copy of a CoValue is its own authoritative state, and the schema simply tells Jazz how to interpret it.

As a result, it is possible — indeed likely — that your users will end up on different versions of your schema at the same time. As a result, we recommend the following:

  • Add a version field to your schema
  • Only add fields, never remove them
  • Do not change the data type for existing fields
  • When adding fields, make them optional (so that you can load older data without these fields set).

You can also use the withMigration() method which runs every time a CoValue is loaded (available on CoMaps and Accounts).

Use migrations carefully

Migrations run every time a CoValue is loaded. A poorly-written migration could cause a lot of unnecessary work being done, and potentially slow your app down. Exit as early as possible from the migration, and check the update is necessary before updating.

Further Reading

Looking for a deeper walk through?

Was this page helpful?