ALWAYS readllms.txtfor curated documentation pages and examples.
Zero Schema
# Zero Schema
Zero applications have both a *database schema* (the normal backend schema all web apps have) and a *Zero schema*.
The Zero schema is conventionally located in `schema.ts` in your app's source code. The Zero schema serves two purposes:
1. Provide typesafety for ZQL queries
2. Define first-class relationships between tables
The Zero schema is usually generated from your backend schema, but can be defined by hand for more control.
## Generating from Database
If you use Drizzle or Prisma ORM, you can generate `schema.ts` with [`drizzle-zero`](https://www.npmjs.com/package/drizzle-zero) or [`prisma-zero`](https://www.npmjs.com/package/prisma-zero):
> 🧑💻 **Not seeing your generator?**: We'd love more! See the source for [drizzle-zero](https://github.com/rocicorp/drizzle-zero)and [prisma-zero](https://github.com/rocicorp/prisma-zero)as a guide, or reach out on [Discord](https://discord.rocicorp.dev/) with questions.
## Writing by Hand
You can also write Zero schemas by hand for full control.
### Table Schemas
Use the `table` function to define each table in your Zero schema:
```tsx
import {table, string, boolean} from '@rocicorp/zero'
const user = table('user')
.columns({
id: string(),
name: string(),
partner: boolean()
})
.primaryKey('id')
```
Column types are defined with the `boolean()`, `number()`, `string()`, `json()`, and `enumeration()` helpers. See [Column Types](https://zero.rocicorp.dev/docs/postgres-support#column-types) for how database types are mapped to these types.
#### Name Mapping
Use `from()` to map a TypeScript table or column name to a different database name:
```ts
const userPref = table('userPref')
// Map TS "userPref" to DB name "user_pref"
.from('user_pref')
.columns({
id: string(),
// Map TS "orgID" to DB name "org_id"
orgID: string().from('org_id')
})
```
#### Multiple Schemas
You can also use `from()` to access other Postgres schemas:
```ts
// Sync the "event" table from the "analytics" schema.
const event = table('event').from('analytics.event')
```
#### Optional Columns
Columns can be marked *optional*. This corresponds to the SQL concept `nullable`.
```tsx
const user = table('user')
.columns({
id: string(),
name: string(),
nickName: string().optional()
})
.primaryKey('id')
```
An optional column can store a value of the specified type or `null` to mean *no value*.
> **Null and undefined**: Note that `null` and `undefined` mean different things when working with Zero rows.
>
> * When reading, if a column is `optional`, Zero can return `null` for that field. `undefined` is not used at all when Reading from Zero.
> * When writing, you can specify `null` for an optional field to explicitly write `null` to the datastore, unsetting any previous value.
> * For `create` and `upsert` you can set optional fields to `undefined` (or leave the field off completely) to take the default value as specified by backend schema for that column. For `update` you can set any non-PK field to `undefined` to leave the previous value unmodified.
#### Enumerations
Use the `enumeration` helper to define a column that can only take on a specific set of values. This is most often used alongside an [`enum` Postgres column type](https://zero.rocicorp.dev/docs/postgres-support#column-types).
```tsx
import {table, string, enumeration} from '@rocicorp/zero'
const user = table('user')
.columns({
id: string(),
name: string(),
mood: enumeration<'happy' | 'sad' | 'taco'>()
})
.primaryKey('id')
```
#### Custom JSON Types
Use the `json` helper to define a column that stores a JSON-compatible value:
```tsx
import {table, string, json} from '@rocicorp/zero'
const user = table('user')
.columns({
id: string(),
name: string(),
settings: json<{theme: 'light' | 'dark'}>()
})
.primaryKey('id')
```
#### Compound Primary Keys
Pass multiple columns to `primaryKey` to define a compound primary key:
```ts
const user = table('user')
.columns({
orgID: string(),
userID: string(),
name: string()
})
.primaryKey('orgID', 'userID')
```
### Relationships
Use the `relationships` function to define relationships between tables. Use the `one` and `many` helpers to define singular and plural relationships, respectively:
```ts
const messageRelationships = relationships(
message,
({one, many}) => ({
sender: one({
sourceField: ['senderID'],
destField: ['id'],
destSchema: user
}),
replies: many({
sourceField: ['id'],
destSchema: message,
destField: ['parentMessageID']
})
})
)
```
This creates "sender" and "replies" relationships that can later be queried with the [`related` ZQL clause](https://zero.rocicorp.dev/docs/reading-data#relationships):
```ts
const messagesWithSenderAndReplies = z.query.messages
.related('sender')
.related('replies')
```
This will return an object for each message row. Each message will have a `sender` field that is a single `User` object or `null`, and a `replies` field that is an array of `Message` objects.
#### Many-to-Many Relationships
You can create many-to-many relationships by chaining the relationship definitions. Assuming `issue` and `label` tables, along with an `issueLabel` junction table, you can define a `labels` relationship like this:
```ts
const issueRelationships = relationships(
issue,
({many}) => ({
labels: many(
{
sourceField: ['id'],
destSchema: issueLabel,
destField: ['issueID']
},
{
sourceField: ['labelID'],
destSchema: label,
destField: ['id']
}
)
})
)
```
> **Only two levels of chaining are supported**: See [https://bugs.rocicorp.dev/issue/3454](https://bugs.rocicorp.dev/issue/3454).
#### Compound Keys Relationships
Relationships can traverse compound keys. Imagine a `user` table with a compound primary key of `orgID` and `userID`, and a `message` table with a related `senderOrgID` and `senderUserID`. This can be represented in your schema with:
```ts
const messageRelationships = relationships(
message,
({one}) => ({
sender: one({
sourceField: ['senderOrgID', 'senderUserID'],
destSchema: user,
destField: ['orgID', 'userID']
})
})
)
```
#### Circular Relationships
Circular relationships are fully supported:
```tsx
const commentRelationships = relationships(
comment,
({one}) => ({
parent: one({
sourceField: ['parentID'],
destSchema: comment,
destField: ['id']
})
})
)
```
### Database Schemas
Use `createSchema` to define the entire Zero schema:
```tsx
import {createSchema} from '@rocicorp/zero'
export const schema = createSchema({
tables: [user, medium, message],
relationships: [
userRelationships,
mediumRelationships,
messageRelationships
]
})
```
### Default Type Parameter
Use `DefaultTypes` to register the your `Schema` type with Zero:
```ts
declare module '@rocicorp/zero' {
interface DefaultTypes {
schema: Schema
}
}
```
This prevents having to pass `Schema` manually to every Zero API.
## Migrations
Zero uses TypeScript-style structural typing to detect schema changes and implement smooth migrations.
### How it Works
When the Zero client connects to `zero-cache` it sends a copy of the schema it was constructed with. `zero-cache` compares this schema to the one it has, and rejects the connection with a special error code if the schema is incompatible.
By default, the Zero client handles this error code by calling `location.reload()`. The intent is to request a newer version of the app that has been updated to handle the new server schema.
> **Update Order**: It's important to update the database schema first, then the app. Otherwise a reload loop will occur.
>
> If a reload loop does occur, Zero uses exponential backoff to avoid overloading the server.
If you want to change or delay this reload, you can do so by providing the `onUpdateNeeded` constructor parameter:
```ts
new Zero({
onUpdateNeeded: updateReason => {
if (reason.type === 'SchemaVersionNotSupported') {
// Do something custom here, like show a banner.
// When you're ready, call `location.reload()`.
}
}
})
```
If the schema changes in a compatible way while a client is running, `zero-cache` syncs the schema change to the client so that it's ready when the app reloads.
If the schema changes in an incompatible way while a client is running, `zero-cache` will close the client connection with the same error code as above.
### Schema Change Process
Like other database-backed applications, Zero schema migrations generally follow an "expand/migrate/contract" pattern:
1. Implement and run an “expand” migration on the backend that is backwards compatible with existing schemas. Add new rows, tables, as well as any defaults and triggers needed for backwards compatibility.
2. Update and deploy the client app to use the new schema.
3. Optionally, after some grace period, implement and run a “contract” migration on the backend, deleting any obsolete rows/tables.
Steps 1 and 2 can generally be done as part of a single deploy in your CI pipeline, but step 4 should be weeks later, when most open clients have refreshed the application.
> Certain schema changes require special handling in Postgres. See [Schema Changes](https://zero.rocicorp.dev/docs/postgres-support#schema-changes) for details.
Or, for AI assistants
ALWAYS readllms.txtfor curated documentation pages and examples.
Zero applications have both a database schema (the normal backend schema all web apps have) and a Zero schema.
The Zero schema is conventionally located in schema.ts in your app's source code. The Zero schema serves two purposes:
Provide typesafety for ZQL queries
Define first-class relationships between tables
The Zero schema is usually generated from your backend schema, but can be defined by hand for more control.
Use the table function to define each table in your Zero schema:
import {table, string, boolean} from '@rocicorp/zero'const user = table('user') .columns({ id: string(), name: string(), partner: boolean() }) .primaryKey('id')
Column types are defined with the boolean(), number(), string(), json(), and enumeration() helpers. See Column Types for how database types are mapped to these types.
Use from() to map a TypeScript table or column name to a different database name:
const userPref = table('userPref') // Map TS "userPref" to DB name "user_pref" .from('user_pref') .columns({ id: string(), // Map TS "orgID" to DB name "org_id" orgID: string().from('org_id') })
Use the enumeration helper to define a column that can only take on a specific set of values. This is most often used alongside an enum Postgres column type.
Use the relationships function to define relationships between tables. Use the one and many helpers to define singular and plural relationships, respectively:
This will return an object for each message row. Each message will have a sender field that is a single User object or null, and a replies field that is an array of Message objects.
You can create many-to-many relationships by chaining the relationship definitions. Assuming issue and label tables, along with an issueLabel junction table, you can define a labels relationship like this:
Relationships can traverse compound keys. Imagine a user table with a compound primary key of orgID and userID, and a message table with a related senderOrgID and senderUserID. This can be represented in your schema with:
When the Zero client connects to zero-cache it sends a copy of the schema it was constructed with. zero-cache compares this schema to the one it has, and rejects the connection with a special error code if the schema is incompatible.
By default, the Zero client handles this error code by calling location.reload(). The intent is to request a newer version of the app that has been updated to handle the new server schema.
If you want to change or delay this reload, you can do so by providing the onUpdateNeeded constructor parameter:
new Zero({ onUpdateNeeded: updateReason => { if (reason.type === 'SchemaVersionNotSupported') { // Do something custom here, like show a banner. // When you're ready, call `location.reload()`. } }})
If the schema changes in a compatible way while a client is running, zero-cache syncs the schema change to the client so that it's ready when the app reloads.
If the schema changes in an incompatible way while a client is running, zero-cache will close the client connection with the same error code as above.
Like other database-backed applications, Zero schema migrations generally follow an "expand/migrate/contract" pattern:
Implement and run an “expand” migration on the backend that is backwards compatible with existing schemas. Add new rows, tables, as well as any defaults and triggers needed for backwards compatibility.
Update and deploy the client app to use the new schema.
Optionally, after some grace period, implement and run a “contract” migration on the backend, deleting any obsolete rows/tables.
Steps 1 and 2 can generally be done as part of a single deploy in your CI pipeline, but step 4 should be weeks later, when most open clients have refreshed the application.