From 09e5e2a0a8ffb2497164d07d3e4dd1ab4758ebba Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Mon, 23 Mar 2026 11:51:45 -0700 Subject: [PATCH 1/3] docs: added provider-specific docs --- assets/search-index.json | 1076 ++++++++++------- contents/docs/connecting-to-postgres.mdx | 135 ++- contents/docs/deployment.mdx | 20 +- .../connecting-to-postgres/neon-enable.png | Bin 0 -> 110873 bytes 4 files changed, 734 insertions(+), 497 deletions(-) create mode 100644 public/images/connecting-to-postgres/neon-enable.png diff --git a/assets/search-index.json b/assets/search-index.json index 1bfcb006..b1292745 100644 --- a/assets/search-index.json +++ b/assets/search-index.json @@ -4,7 +4,7 @@ "title": "Authentication", "searchTitle": "Authentication", "url": "/docs/auth", - "content": "Setting up auth in Zero apps has a few steps: Setting the userID on the client Sending credentials to the mutate and queries endpoints Setting the Context type to implement permissions Logging out if desired Setting userID Because multiple users can share the same browser, Zero requires that you provide a userID parameter on construction: import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' const opts: ZeroOptions = { // ... userID: 'user-123' } return ( )import {ZeroProvider} from '@rocicorp/zero/solid' import type {ZeroOptions} from '@rocicorp/zero' const opts: ZeroOptions = { // ... userID: 'user-123' } return ( )import {Zero} from '@rocicorp/zero' import type {ZeroOptions} from '@rocicorp/zero' const opts: ZeroOptions = { // ... userID: 'user-123' } const zero = new Zero(opts) If the user is not logged in, just pass empty string or some other constant value: const opts: ZeroOptions = { // ... userID: 'anon' } Zero segregates the client-side storage for each user. This allows users to quickly switch between multiple users and accounts without resyncing. All users that have access to a browser profile have access to the same IndexedDB instances. There is nothing that Zero can do about this – users can just open the folder where the data is stored and look inside it. If you have more than one set of Zero data per-user (i.e., for different apps in the same domain), you can additionally use the storageKey parameter: const opts: ZeroOptions = { // ... userID: 'user-123', storageKey: 'my-app' } If specified, storageKey is concatenated along with userID and other internal Zero information to form a unique IndexedDB database name. Sending Credentials You can send credentials using either cookies or tokens. Cookies The most common way to authenticate Zero is with cookies. To enable it, set the ZERO_QUERY_FORWARD_COOKIES and ZERO_MUTATE_FORWARD_COOKIES options to true: export ZERO_QUERY_FORWARD_COOKIES=\"true\" export ZERO_MUTATE_FORWARD_COOKIES=\"true\" # run zero-cache, e.g. `npx zero-cache-dev` Zero-cache will then forward all cookies sent to cacheURL to your mutate and queries endpoints: const opts: ZeroOptions = { schema, // Cookies sent to zero.example.com will be forwarded to // api.example.com/mutate and api.example.com/queries. cacheURL: 'https://zero.example.com', mutateURL: 'https://api.example.com/mutate', queryURL: 'https://api.example.com/queries' } Cookies will show up in the normal HTTP Cookie header and you can authenticate these endpoints just like you would any API request. Deployment In order for cookie auth to work, the browser must send your frontend's cookies to zero-cache, so that zero-cache can forward them to your API. During development, this works automatically as long as your frontend and zero-cache are both running on localhost with different ports. Browsers send cookies based on domain name, not port number, so cookies set by localhost:3000 are also sent to localhost:4848. For production you'll need to do two things: Run zero-cache on a subdomain of your main site (e.g., zero.example.com if your main site is example.com). Consult your hosting provider's docs, or your favorite LLM for how to configure this. Set cookies from your main site with the Domain attribute set to your root domain (e.g., .example.com). If you use a third-party auth provider, consult their docs on how to do this. For example, for Better Auth, this is done with the crossSubDomainCookies feature. Do not set SameSite=None on cookies used for authentication with Zero. Because Zero uses WebSockets, setting SameSite=None can expose your application to Cross-Site WebSocket Hijacking (CSWSH) attacks. Use SameSite=Lax (the browser default) or SameSite=Strict instead. Tokens Zero also supports token-based authentication. If you have an opaque auth token, such as a JWT or a token from your auth provider, you can pass it to Zero's auth parameter: const opts: ZeroOptions = { // ... auth: token } Zero will forward this token to your mutate and queries endpoints in an Authorization: Bearer header, which you can use to authenticate the request as normal: export async function handleMutate(request: Request) { const session = await authenticate( request.headers.get('Authorization') ) // handle mutate request ... } Auth Failure and Refresh To mark a request as unauthorized, return a 401 or 403 status code from your queries or mutate endpoint. export async function handleMutate(request: Request) { const session = await authenticate( request.headers.get('Authorization') ) if (!session) { // can be 401 or 403 return json({error: 'Unauthorized'}, {status: 401}) } // handle mutate request ... } This will cause Zero to disconnect from zero-cache and the connection status will change to needs-auth. You can then re-authenticate the user and call zero.connection.connect() to reconnect to zero-cache: function NeedsAuthDialog() { const connectionState = useConnectionState() const refreshCookie = async () => { await login() // no token needed since we use cookie auth zero.connection.connect() } if (connectionState.name === 'needs-auth') { return (

Authentication Required

) } return null } Or, if you aren't using cookie auth: function NeedsAuthDialog() { const connectionState = useConnectionState() const refreshAuthToken = async () => { const token = await fetchNewToken() // pass a new token to reconnect to zero-cache zero.connection.connect({auth: token}) } if (connectionState.name === 'needs-auth') { return (

Authentication Required

) } return null } Context When a user is authenticated, you will want to know who they are in your queries and mutators to enforce permissions. To do this, define a Context type that includes the user's ID and any other relevant information, then register that type with Zero: export type ZeroContext = { userID: string role: 'admin' | 'user' } declare module '@rocicorp/zero' { interface DefaultTypes { context: ZeroContext } } Then pass an instance of this context when instantiating Zero: const opts: ZeroOptions = { // ... context: { userID: 'user-123', role: 'admin' } } On the server-side, you will also pass an instance of this context when invoking your queries and mutators: const query = mustGetQuery(queries, name) query.fn({args, ctx}) // or const mutator = mustGetMutator(mutators, name) mutator.fn({tx, args, ctx}) You can then access the context within your queries and mutators to implement permissions. Permission Patterns Zero does not have (or need) a first-class permission system like RLS. Instead, you implement permissions by authenticating the user in your queries and mutators endpoints, and creating a Context object that contains the user's ID and other information. This context is passed to your queries and mutators and used to control what data the user can access. Here are a collection of common permissions patterns and how to implement them in Zero. Read Permissions Only Owned Rows // Use the context's `userID` to filter the rows to only the // ones owned by the user. const myPosts = defineQuery(({ctx: {userID}}) => { return zql.post.where('authorID', userID) }) Owned or Shared Rows // Use the context's `userID` to filter the rows to only the // ones owned by the user or shared with the user. const allowedPosts = defineQuery(({ctx: {userID}}) => { return zql.post.where(({cmp, exists, or}) => or( cmp('authorID', userID), exists('sharedWith', q => q.where('userID', userID)) ) ) }) Owned Rows or All if Admin const allowedPosts = defineQuery( ({ctx: {userID, role}}) => { if (role === 'admin') { return zql.post } return zql.post.where('authorID', userID) } ) Write Permissions Enforce Ownership // All created items are owned by the user who created them. const createPost = defineMutator( z.object({ id: z.string(), title: z.string(), content: z.string() }), (tx, {ctx: {userID}, args: {id, title, content}}) => { return zql.post.insert({ id, title, content, authorID: userID }) } ) Edit Owned Rows const updatePost = defineMutator( z.object({ id: z.string(), content: z.string().optional() }), (tx, {ctx: {userID}, args: {id, content}}) => { const prev = await tx.run( zql.post.where('id', id).one() ) if (!prev) { return } if (prev.authorID !== userID) { throw new Error('Access denied') } return zql.post.update({ id, content }) } ) Edit Owned or Shared Rows const updatePost = defineMutator( z.object({ id: z.string(), content: z.string().optional() }), (tx, {ctx: {userID}, args: {id, content}}) => { const prev = await tx.run( zql.post .where('id', id) .related('sharedWith', q => q.where('userID', userID) ) .one() ) if (!prev) { return } if ( prev.authorID !== userID && prev.sharedWith.length === 0 ) { throw new Error('Access denied') } return zql.post.update({ id, content }) } ) Edit Owned or All if Admin const updatePost = defineMutator( z.object({ id: z.string(), content: z.string().optional() }), (tx, {ctx: {role, userID}, args: {id, content}}) => { const prev = await tx.run( zql.post.where('id', id).one() ) if (!prev) { return } if (role !== 'admin' && prev.authorID !== userID) { throw new Error('Access denied') } return zql.post.update({ id, content }) } ) Logging Out When a user logs out, you should consider what should happen to the synced data. If you do nothing, the synced data will be left on the device. The next login will be a little faster because Zero doesn't have to resync that data from scratch. But also, the data will be left on the device indefinitely which could be undesirable for privacy and security. If you instead want to clear data on logout, Zero provides the dropAllDatabases function: import {dropAllDatabases} from '@rocicorp/zero' // Returns an object with: // - The names of the successfully dropped databases // - Any errors encountered while dropping const {dropped, errors} = await dropAllDatabases() // or, if you are using a custom kvStore const {dropped, errors} = await dropAllDatabases({ kvStore: customKvStore })", + "content": "Setting up auth in Zero apps has a few steps: Setting the userID on the client Sending credentials to the mutate and queries endpoints Setting the Context type to implement permissions Logging out if desired Setting userID Because multiple users can share the same browser, Zero requires that you provide a userID parameter on construction: import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' const opts: ZeroOptions = { // ... userID: 'user-123' } return ( )import {ZeroProvider} from '@rocicorp/zero/solid' import type {ZeroOptions} from '@rocicorp/zero' const opts: ZeroOptions = { // ... userID: 'user-123' } return ( )import {Zero} from '@rocicorp/zero' import type {ZeroOptions} from '@rocicorp/zero' const opts: ZeroOptions = { // ... userID: 'user-123' } const zero = new Zero(opts) If the user is not logged in, just pass empty string or some other constant value: const opts: ZeroOptions = { // ... userID: 'anon' } Zero segregates the client-side storage for each user. This allows users to quickly switch between multiple users and accounts without resyncing. All users that have access to a browser profile have access to the same IndexedDB instances. There is nothing that Zero can do about this – users can just open the folder where the data is stored and look inside it. If you have more than one set of Zero data per-user (i.e., for different apps in the same domain), you can additionally use the storageKey parameter: const opts: ZeroOptions = { // ... userID: 'user-123', storageKey: 'my-app' } If specified, storageKey is concatenated along with userID and other internal Zero information to form a unique IndexedDB database name. Sending Credentials You can send credentials using either cookies or tokens. Cookies The most common way to authenticate Zero is with cookies. To enable it, set the ZERO_QUERY_FORWARD_COOKIES and ZERO_MUTATE_FORWARD_COOKIES options to true: export ZERO_QUERY_FORWARD_COOKIES=\"true\" export ZERO_MUTATE_FORWARD_COOKIES=\"true\" # run zero-cache, e.g. `npx zero-cache-dev` Zero-cache will then forward all cookies sent to cacheURL to your mutate and queries endpoints: const opts: ZeroOptions = { schema, // Cookies sent to zero.example.com will be forwarded to // api.example.com/mutate and api.example.com/queries. cacheURL: 'https://zero.example.com', mutateURL: 'https://api.example.com/mutate', queryURL: 'https://api.example.com/queries' } Cookies will show up in the normal HTTP Cookie header and you can authenticate these endpoints just like you would any API request. Deployment In order for cookie auth to work, the browser must send your frontend's cookies to zero-cache, so that zero-cache can forward them to your API. During development, this works automatically as long as your frontend and zero-cache are both running on localhost with different ports. Browsers send cookies based on domain name, not port number, so cookies set by localhost:3000 are also sent to localhost:4848. For production you'll need to do two things: Run zero-cache on a subdomain of your main site (e.g., zero.example.com if your main site is example.com). Consult your hosting provider's docs, or your favorite LLM for how to configure this. Set cookies from your main site with the Domain attribute set to your root domain (e.g., .example.com). If you use a third-party auth provider, consult their docs on how to do this. For example, for Better Auth, this is done with the crossSubDomainCookies feature. Do not set SameSite=None on cookies used for authentication with Zero. Because Zero uses WebSockets, setting SameSite=None can expose your application to Cross-Site WebSocket Hijacking (CSWSH) attacks. Use SameSite=Lax (the browser default) or SameSite=Strict instead. Tokens Zero also supports token-based authentication. If you have an opaque auth token, such as a JWT or a token from your auth provider, you can pass it to Zero's auth parameter: const opts: ZeroOptions = { // ... auth: token } Zero will forward this token to your mutate and queries endpoints in an Authorization: Bearer header, which you can use to authenticate the request as normal: export async function handleMutate(request: Request) { const session = await authenticate( request.headers.get('Authorization') ) // handle mutate request ... } Auth Failure and Refresh To mark a request as unauthorized, return a 401 or 403 status code from your queries or mutate endpoint. export async function handleMutate(request: Request) { const session = await authenticate( request.headers.get('Authorization') ) if (!session) { // can be 401 or 403 return json({error: 'Unauthorized'}, {status: 401}) } // handle mutate request ... } This will cause Zero to disconnect from zero-cache and the connection status will change to needs-auth. You can then re-authenticate the user and call zero.connection.connect() to reconnect to zero-cache: function NeedsAuthDialog() { const connectionState = useConnectionState() const refreshCookie = async () => { await login() // no token needed since we use cookie auth zero.connection.connect() } if (connectionState.name === 'needs-auth') { return (

Authentication Required

) } return null } Or, if you aren't using cookie auth: function NeedsAuthDialog() { const connectionState = useConnectionState() const refreshAuthToken = async () => { const token = await fetchNewToken() // pass a new token to reconnect to zero-cache zero.connection.connect({auth: token}) } if (connectionState.name === 'needs-auth') { return (

Authentication Required

) } return null } Context When a user is authenticated, you will want to know who they are in your queries and mutators to enforce permissions. To do this, define a Context type that includes the user's ID and any other relevant information, then register that type with Zero: export type ZeroContext = { userID: string role: 'admin' | 'user' } declare module '@rocicorp/zero' { interface DefaultTypes { context: ZeroContext } } Then pass an instance of this context when instantiating Zero: const opts: ZeroOptions = { // ... context: { userID: 'user-123', role: 'admin' } } On the server-side, you will also pass an instance of this context when invoking your queries and mutators: const query = mustGetQuery(queries, name) query.fn({args, ctx}) // or const mutator = mustGetMutator(mutators, name) mutator.fn({tx, args, ctx}) You can then access the context within your queries and mutators to implement permissions. Permission Patterns Zero does not have (or need) a first-class permission system like RLS. Instead, you implement permissions by authenticating the user in your queries and mutators endpoints, and creating a Context object that contains the user's ID and other information. This context is passed to your queries and mutators and used to control what data the user can access. Here are a collection of common permissions patterns and how to implement them in Zero. Read Permissions Only Owned Rows // Use the context's `userID` to filter the rows to only the // ones owned by the user. const myPosts = defineQuery(({ctx: {userID}}) => { return zql.post.where('authorID', userID) }) Owned or Shared Rows // Use the context's `userID` to filter the rows to only the // ones owned by the user or shared with the user. const allowedPosts = defineQuery(({ctx: {userID}}) => { return zql.post.where(({cmp, exists, or}) => or( cmp('authorID', userID), exists('sharedWith', q => q.where('userID', userID)) ) ) }) Owned Rows or All if Admin const allowedPosts = defineQuery( ({ctx: {userID, role}}) => { if (role === 'admin') { return zql.post } return zql.post.where('authorID', userID) } ) Write Permissions Enforce Ownership // All created items are owned by the user who created them. const createPost = defineMutator( z.object({ id: z.string(), title: z.string(), content: z.string() }), (tx, {ctx: {userID}, args: {id, title, content}}) => { return zql.post.insert({ id, title, content, authorID: userID }) } ) Edit Owned Rows const updatePost = defineMutator( z.object({ id: z.string(), content: z.string().optional() }), (tx, {ctx: {userID}, args: {id, content}}) => { const prev = await tx.run( zql.post.where('id', id).one() ) if (!prev) { return } if (prev.authorID !== userID) { throw new Error('Access denied') } return zql.post.update({ id, content }) } ) Edit Owned or Shared Rows const updatePost = defineMutator( z.object({ id: z.string(), content: z.string().optional() }), (tx, {ctx: {userID}, args: {id, content}}) => { const prev = await tx.run( zql.post .where('id', id) .related('sharedWith', q => q.where('userID', userID) ) .one() ) if (!prev) { return } if ( prev.authorID !== userID && prev.sharedWith.length === 0 ) { throw new Error('Access denied') } return zql.post.update({ id, content }) } ) Edit Owned or All if Admin const updatePost = defineMutator( z.object({ id: z.string(), content: z.string().optional() }), (tx, {ctx: {role, userID}, args: {id, content}}) => { const prev = await tx.run( zql.post.where('id', id).one() ) if (!prev) { return } if (role !== 'admin' && prev.authorID !== userID) { throw new Error('Access denied') } return zql.post.update({ id, content }) } ) Logging Out When a user logs out, you should consider what should happen to the synced data. If you do nothing, the synced data will be left on the device. The next login will be a little faster because Zero doesn't have to resync that data from scratch. But also, the data will be left on the device indefinitely which could be undesirable for privacy and security. If you instead want to clear data on logout, use zero.delete(): await zero.delete() This immediately closes the Zero instance and deletes all data from the browser's IndexedDB database.", "headings": [ { "text": "Setting userID", @@ -82,7 +82,7 @@ "kind": "page" }, { - "id": "63-auth#setting-userid", + "id": "64-auth#setting-userid", "title": "Authentication", "searchTitle": "Setting userID", "sectionTitle": "Setting userID", @@ -92,7 +92,7 @@ "kind": "section" }, { - "id": "64-auth#sending-credentials", + "id": "65-auth#sending-credentials", "title": "Authentication", "searchTitle": "Sending Credentials", "sectionTitle": "Sending Credentials", @@ -102,7 +102,7 @@ "kind": "section" }, { - "id": "65-auth#cookies", + "id": "66-auth#cookies", "title": "Authentication", "searchTitle": "Cookies", "sectionTitle": "Cookies", @@ -112,7 +112,7 @@ "kind": "section" }, { - "id": "66-auth#deployment", + "id": "67-auth#deployment", "title": "Authentication", "searchTitle": "Deployment", "sectionTitle": "Deployment", @@ -122,7 +122,7 @@ "kind": "section" }, { - "id": "67-auth#tokens", + "id": "68-auth#tokens", "title": "Authentication", "searchTitle": "Tokens", "sectionTitle": "Tokens", @@ -132,7 +132,7 @@ "kind": "section" }, { - "id": "68-auth#auth-failure-and-refresh", + "id": "69-auth#auth-failure-and-refresh", "title": "Authentication", "searchTitle": "Auth Failure and Refresh", "sectionTitle": "Auth Failure and Refresh", @@ -142,7 +142,7 @@ "kind": "section" }, { - "id": "69-auth#context", + "id": "70-auth#context", "title": "Authentication", "searchTitle": "Context", "sectionTitle": "Context", @@ -152,7 +152,7 @@ "kind": "section" }, { - "id": "70-auth#permission-patterns", + "id": "71-auth#permission-patterns", "title": "Authentication", "searchTitle": "Permission Patterns", "sectionTitle": "Permission Patterns", @@ -162,7 +162,7 @@ "kind": "section" }, { - "id": "71-auth#read-permissions", + "id": "72-auth#read-permissions", "title": "Authentication", "searchTitle": "Read Permissions", "sectionTitle": "Read Permissions", @@ -172,7 +172,7 @@ "kind": "section" }, { - "id": "72-auth#only-owned-rows", + "id": "73-auth#only-owned-rows", "title": "Authentication", "searchTitle": "Only Owned Rows", "sectionTitle": "Only Owned Rows", @@ -182,7 +182,7 @@ "kind": "section" }, { - "id": "73-auth#owned-or-shared-rows", + "id": "74-auth#owned-or-shared-rows", "title": "Authentication", "searchTitle": "Owned or Shared Rows", "sectionTitle": "Owned or Shared Rows", @@ -192,7 +192,7 @@ "kind": "section" }, { - "id": "74-auth#owned-rows-or-all-if-admin", + "id": "75-auth#owned-rows-or-all-if-admin", "title": "Authentication", "searchTitle": "Owned Rows or All if Admin", "sectionTitle": "Owned Rows or All if Admin", @@ -202,7 +202,7 @@ "kind": "section" }, { - "id": "75-auth#write-permissions", + "id": "76-auth#write-permissions", "title": "Authentication", "searchTitle": "Write Permissions", "sectionTitle": "Write Permissions", @@ -212,7 +212,7 @@ "kind": "section" }, { - "id": "76-auth#enforce-ownership", + "id": "77-auth#enforce-ownership", "title": "Authentication", "searchTitle": "Enforce Ownership", "sectionTitle": "Enforce Ownership", @@ -222,7 +222,7 @@ "kind": "section" }, { - "id": "77-auth#edit-owned-rows", + "id": "78-auth#edit-owned-rows", "title": "Authentication", "searchTitle": "Edit Owned Rows", "sectionTitle": "Edit Owned Rows", @@ -232,7 +232,7 @@ "kind": "section" }, { - "id": "78-auth#edit-owned-or-shared-rows", + "id": "79-auth#edit-owned-or-shared-rows", "title": "Authentication", "searchTitle": "Edit Owned or Shared Rows", "sectionTitle": "Edit Owned or Shared Rows", @@ -242,7 +242,7 @@ "kind": "section" }, { - "id": "79-auth#edit-owned-or-all-if-admin", + "id": "80-auth#edit-owned-or-all-if-admin", "title": "Authentication", "searchTitle": "Edit Owned or All if Admin", "sectionTitle": "Edit Owned or All if Admin", @@ -252,13 +252,13 @@ "kind": "section" }, { - "id": "80-auth#logging-out", + "id": "81-auth#logging-out", "title": "Authentication", "searchTitle": "Logging Out", "sectionTitle": "Logging Out", "sectionId": "logging-out", "url": "/docs/auth", - "content": "When a user logs out, you should consider what should happen to the synced data. If you do nothing, the synced data will be left on the device. The next login will be a little faster because Zero doesn't have to resync that data from scratch. But also, the data will be left on the device indefinitely which could be undesirable for privacy and security. If you instead want to clear data on logout, Zero provides the dropAllDatabases function: import {dropAllDatabases} from '@rocicorp/zero' // Returns an object with: // - The names of the successfully dropped databases // - Any errors encountered while dropping const {dropped, errors} = await dropAllDatabases() // or, if you are using a custom kvStore const {dropped, errors} = await dropAllDatabases({ kvStore: customKvStore })", + "content": "When a user logs out, you should consider what should happen to the synced data. If you do nothing, the synced data will be left on the device. The next login will be a little faster because Zero doesn't have to resync that data from scratch. But also, the data will be left on the device indefinitely which could be undesirable for privacy and security. If you instead want to clear data on logout, use zero.delete(): await zero.delete() This immediately closes the Zero instance and deletes all data from the browser's IndexedDB database.", "kind": "section" }, { @@ -280,7 +280,7 @@ "kind": "page" }, { - "id": "81-community#ui-frameworks", + "id": "82-community#ui-frameworks", "title": "From the Community", "searchTitle": "UI Frameworks", "sectionTitle": "UI Frameworks", @@ -290,7 +290,7 @@ "kind": "section" }, { - "id": "82-community#miscellaneous", + "id": "83-community#miscellaneous", "title": "From the Community", "searchTitle": "Miscellaneous", "sectionTitle": "Miscellaneous", @@ -304,8 +304,12 @@ "title": "Connecting to Postgres", "searchTitle": "Connecting to Postgres", "url": "/docs/connecting-to-postgres", - "content": "In the future, Zero will work with many different backend databases. Today only Postgres is supported. Specifically, Zero requires Postgres v15.0 or higher, and support for logical replication. Here are some common Postgres options and what we know about their support level: Event Triggers Zero uses Postgres “Event Triggers” when possible to implement high-quality, efficient schema migration. Some hosted Postgres providers don’t provide access to Event Triggers. Zero still works out of the box with these providers, but for correctness, any schema change triggers a full reset of all server-side and client-side state. For small databases (< 10GB) this can be OK, but for bigger databases we recommend choosing a provider that grants access to Event Triggers. Configuration WAL Level The Postgres wal_level config parameter has to be set to logical. You can check what level your pg has with this command: psql -c 'SHOW wal_level' If it doesn’t output logical then you need to change the wal level. To do this, run: psql -c \"ALTER SYSTEM SET wal_level = 'logical';\" Then restart Postgres. On most pg systems you can do this like so: data_dir=$(psql -t -A -c 'SHOW data_directory') pg_ctl -D \"$data_dir\" restart After your server restarts, show the wal_level again to ensure it has changed: psql -c 'SHOW wal_level' Bounding WAL Size For development databases, you can set a max_slot_wal_keep_size value in Postgres. This will help limit the amount of WAL kept around. This is a configuration parameter that bounds the amount of WAL kept around for replication slots, and invalidates the slots that are too far behind. zero-cache will automatically detect if the replication slot has been invalidated and re-sync replicas from scratch. This configuration can cause problems like slot has been invalidated because it exceeded the maximum reserved size and is not recommended for production databases. Provider-Specific Notes Google Cloud SQL Zero works with Google Cloud SQL out of the box. In many configurations, when you connect with a user that has sufficient privileges, zero-cache will create its default publication automatically. If your Cloud SQL user does not have permission to create publications, you can still use Zero by creating a publication manually and then specifying that publication name in App Publications when running zero-cache. On Google Cloud SQL for PostgreSQL, enable logical decoding by turning on the instance flag cloudsql.logical_decoding. You do not set wal_level directly on Cloud SQL. See Google's documentation for details: Configure logical replication. Fly.io Fly does not support TLS on their internal networks. If you run both zero-cache and Postgres on Fly, you need to stop zero-cache from trying to use TLS to talk to Postgres. You can do this by adding the sslmode=disable query parameter to your connection strings from zero-cache. Supabase Postgres Version Supabase requires at least 15.8.1.083 for event trigger support. If you have a lower 15.x, Zero will still work but schema updates will be slower. See Supabase's docs for upgrading your Postgres version. Connection Type In order to connect to Supabase you must use the \"Direct Connection\" style connection string, not the pooler: This is because Zero sets up a logical replication slot, which is only supported with a direct connection. IPv4 You may also need to assign an IPv4 address to your Supabase instance: This will be required if you cannot use IPv6 from wherever zero-cache is running. Most cloud providers support IPv6, but some do not. For example, if you are running zero-cache in AWS, it is possible to use IPv6 but difficult. Hetzner offers cheap hosted VPS that supports IPv6. IPv4 addresses are only supported on the Pro plan and are an extra $4/month. PlanetScale for Postgres You need to connect using the \"default\" role that PlanetScale provides, because PlanetScale's \"User-Defiend Roles\" cannot create replication slots. Be sure to use a direct connection for \"Upstream DB\" and a pg bouncer connnection string for \"CVR DB\" and \"Change DB\". Otherwise you will likely exhaust connection limits to PlanetScale. Neon Neon fully supports Zero, but you should be aware of how Neon's pricing model and Zero interact. Because Zero keeps an open connection to Postgres to replicate changes, as long as zero-cache is running, Postgres will be running and you will be charged by Neon. For production databases that have enough usage to always be running anyway, this is fine. But for smaller applications that would otherwise not always be running, this can create a surprisingly high bill. You may want to choose a provider that charge a flat monthly rate instead. Also some users choose Neon because they hope to use branching for previews. This can work, but if not done with care, Zero can end up keeping each Neon preview branch running too 😳. For the recommended approach to preview URLs, see Preview Deployments.", + "content": "In the future, Zero will work with many different backend databases. Today only Postgres is supported. Specifically, Zero requires Postgres v15.0 or higher, and support for logical replication. Here are some common Postgres options and what we know about their support level: Common Problems too many connections / remaining connection slots are reserved: use pooled URLs for ZERO_CVR_DB and ZERO_CHANGE_DB, and tune ZERO_CVR_MAX_CONNS, ZERO_CHANGE_MAX_CONNS, and ZERO_UPSTREAM_MAX_CONNS (see zero-cache Config). Provider docs for pooling/proxies: Neon, PlanetScale, Supabase, AWS RDS Proxy, Fly MPG, Render, Cloud SQL. prepared statement ... does not exist: your pooler is likely in transaction mode; use a session pooler (or a pooler that supports prepared statements) for ZERO_CVR_DB and ZERO_CHANGE_DB. permission denied to create event trigger: your provider/role doesn’t grant superuser; Zero will fall back to full resets on schema changes. permission denied creating a publication for a schema/all tables: create a publication listing tables explicitly (see Limiting Replication) and set App Publications. Event Triggers Zero uses Postgres “Event Triggers” when possible to implement high-quality, efficient schema migration. Some hosted Postgres providers don't provide access to Event Triggers. Some managed providers also have incomplete Event Trigger behavior for certain DDL (for example, ALTER PUBLICATION). We call out known provider-specific issues below. Zero still works out of the box with these providers, but for correctness, any schema change triggers a full reset of all server-side and client-side state. For small databases (< 10GB) this can be OK, but for bigger databases we recommend choosing a provider that grants access to Event Triggers. Configuration WAL Level The Postgres wal_level config parameter has to be set to logical. You can check what level your pg has with this command: psql -c 'SHOW wal_level' If it doesn’t output logical then you need to change the wal level. To do this, run: psql -c \"ALTER SYSTEM SET wal_level = 'logical';\" Then restart Postgres. On most pg systems you can do this like so: data_dir=$(psql -t -A -c 'SHOW data_directory') pg_ctl -D \"$data_dir\" restart After your server restarts, show the wal_level again to ensure it has changed: psql -c 'SHOW wal_level' Bounding WAL Size For development databases, you can set a max_slot_wal_keep_size value in Postgres. This will help limit the amount of WAL kept around. This is a configuration parameter that bounds the amount of WAL kept around for replication slots, and invalidates the slots that are too far behind. Zero-cache will automatically detect if the replication slot has been invalidated and re-sync replicas from scratch. This configuration can cause problems like slot has been invalidated because it exceeded the maximum reserved size and is not recommended for production databases. Provider-Specific Notes PlanetScale for Postgres You should use the default role that PlanetScale provides, because PlanetScale user-defined roles cannot create replication slots. Planetscale Postgres defaults max_connections to 25, which can easily be exhausted by Zero's connection pools. This will result in an error like remaining connection slots are reserved for roles with the SUPERUSER attribute. You should update this value in the Parameters section of the PlanetScale dashboard to 100+. Make sure to only use a direct connection for the ZERO_UPSTREAM_DB, and use pooled URLs for ZERO_CVR_DB, ZERO_CHANGE_DB, and your API (see Deployment). Google Cloud SQL Zero works with Google Cloud SQL out of the box. In many configurations, when you connect with a user that has sufficient privileges, zero-cache will create its default publication automatically. If your Cloud SQL user does not have permission to create publications, you can still use Zero by creating a publication manually and then specifying that publication name in App Publications when running zero-cache. On Google Cloud SQL for PostgreSQL, enable logical decoding by turning on the instance flag cloudsql.logical_decoding. You do not set wal_level directly on Cloud SQL. See Google's documentation for details: Configure logical replication. Fly.io Fly Managed Postgres is the latest offering from Fly.io, and it is private-network-only by default. If zero-cache runs outside Fly, connect via Fly WireGuard or run a proxy like fly-mpg-proxy. Fly Managed Postgres does not provide superuser access, so zero-cache cannot create event triggers. Also, some publication operations (like FOR TABLES IN SCHEMA ... / FOR ALL TABLES) can be permission-restricted. If zero-cache can't create its default publication, create one listing tables explicitly and set the app publication. Fly does not support TLS on its private network. If zero-cache connects to Postgres over the Fly private network (including WireGuard), add sslmode=disable to your connection strings. You should use Fly's pgBouncer endpoint for pooled connections. Supabase Postgres Version Supabase requires at least 15.8.1.083 for event trigger support. If you have a lower 15.x, Zero will still work but schema updates will be slower. See Supabase's docs for upgrading your Postgres version. Connection Type (Direct vs Pooler) ZERO_UPSTREAM_DB must use the \"Direct Connection\" string (not the pooler): This is because Zero sets up a logical replication slot, which is only supported with a direct connection. For ZERO_CVR_DB and ZERO_CHANGE_DB, prefer Supabase’s session pooler (not the default transaction pooler). The transaction pooler can break prepared statements and cause errors like 26000 prepared statement ... does not exist. Known limitation: Supabase does not fire ddl_command_start/ddl_command_end event triggers for ALTER PUBLICATION, so Zero may not automatically detect publication changes. IPv4 You may also need to assign an IPv4 address to your Supabase instance: This will be required if you cannot use IPv6 from wherever zero-cache is running. Most cloud providers support IPv6, but some do not. For example, if you are running zero-cache in AWS, it is possible to use IPv6 but difficult. Hetzner offers cheap hosted VPS that supports IPv6. IPv4 addresses are only supported on the Pro plan and are an extra $4/month. Render Render can work with Zero, but commonly requires admin/support-side setup: Ensure wal_level=logical (may require a Render support ticket). App roles typically can’t create event triggers, so schema changes will fall back to full resets. App roles may not be able to create schema/all-table publications; create a publication listing tables explicitly and set App Publications. Neon Neon supports logical replication, but you may need to enable it in the Neon console for your branch/endpoint (otherwise SHOW wal_level may return replica). Enable logical replication Neon fully supports Zero, but you should be aware of how Neon's pricing model and Zero interact: because Zero keeps an open connection to Postgres to replicate changes, as long as zero-cache is running, Postgres will be running and you will be charged by Neon. For production databases that have enough usage to always be running anyway, this is fine. But for smaller applications that would otherwise not always be running, this can create a surprisingly high bill. You may want to choose a provider that charge a flat monthly rate instead. Also some users choose Neon because they hope to use branching for previews. This can work, but if not done with care, Zero can end up keeping each Neon preview branch running too 😳. For the recommended approach to preview URLs, see Previews.", "headings": [ + { + "text": "Common Problems", + "id": "common-problems" + }, { "text": "Event Triggers", "id": "event-triggers" @@ -326,6 +330,10 @@ "text": "Provider-Specific Notes", "id": "provider-specific-notes" }, + { + "text": "PlanetScale for Postgres", + "id": "planetscale-for-postgres" + }, { "text": "Google Cloud SQL", "id": "google-cloud-sql" @@ -343,16 +351,16 @@ "id": "postgres-version" }, { - "text": "Connection Type", - "id": "connection-type" + "text": "Connection Type (Direct vs Pooler)", + "id": "connection-type-direct-vs-pooler" }, { "text": "IPv4", "id": "ipv4" }, { - "text": "PlanetScale for Postgres", - "id": "planetscale-for-postgres" + "text": "Render", + "id": "render" }, { "text": "Neon", @@ -362,27 +370,37 @@ "kind": "page" }, { - "id": "83-connecting-to-postgres#event-triggers", + "id": "84-connecting-to-postgres#common-problems", + "title": "Connecting to Postgres", + "searchTitle": "Common Problems", + "sectionTitle": "Common Problems", + "sectionId": "common-problems", + "url": "/docs/connecting-to-postgres", + "content": "too many connections / remaining connection slots are reserved: use pooled URLs for ZERO_CVR_DB and ZERO_CHANGE_DB, and tune ZERO_CVR_MAX_CONNS, ZERO_CHANGE_MAX_CONNS, and ZERO_UPSTREAM_MAX_CONNS (see zero-cache Config). Provider docs for pooling/proxies: Neon, PlanetScale, Supabase, AWS RDS Proxy, Fly MPG, Render, Cloud SQL. prepared statement ... does not exist: your pooler is likely in transaction mode; use a session pooler (or a pooler that supports prepared statements) for ZERO_CVR_DB and ZERO_CHANGE_DB. permission denied to create event trigger: your provider/role doesn’t grant superuser; Zero will fall back to full resets on schema changes. permission denied creating a publication for a schema/all tables: create a publication listing tables explicitly (see Limiting Replication) and set App Publications.", + "kind": "section" + }, + { + "id": "85-connecting-to-postgres#event-triggers", "title": "Connecting to Postgres", "searchTitle": "Event Triggers", "sectionTitle": "Event Triggers", "sectionId": "event-triggers", "url": "/docs/connecting-to-postgres", - "content": "Zero uses Postgres “Event Triggers” when possible to implement high-quality, efficient schema migration. Some hosted Postgres providers don’t provide access to Event Triggers. Zero still works out of the box with these providers, but for correctness, any schema change triggers a full reset of all server-side and client-side state. For small databases (< 10GB) this can be OK, but for bigger databases we recommend choosing a provider that grants access to Event Triggers.", + "content": "Zero uses Postgres “Event Triggers” when possible to implement high-quality, efficient schema migration. Some hosted Postgres providers don't provide access to Event Triggers. Some managed providers also have incomplete Event Trigger behavior for certain DDL (for example, ALTER PUBLICATION). We call out known provider-specific issues below. Zero still works out of the box with these providers, but for correctness, any schema change triggers a full reset of all server-side and client-side state. For small databases (< 10GB) this can be OK, but for bigger databases we recommend choosing a provider that grants access to Event Triggers.", "kind": "section" }, { - "id": "84-connecting-to-postgres#configuration", + "id": "86-connecting-to-postgres#configuration", "title": "Connecting to Postgres", "searchTitle": "Configuration", "sectionTitle": "Configuration", "sectionId": "configuration", "url": "/docs/connecting-to-postgres", - "content": "WAL Level The Postgres wal_level config parameter has to be set to logical. You can check what level your pg has with this command: psql -c 'SHOW wal_level' If it doesn’t output logical then you need to change the wal level. To do this, run: psql -c \"ALTER SYSTEM SET wal_level = 'logical';\" Then restart Postgres. On most pg systems you can do this like so: data_dir=$(psql -t -A -c 'SHOW data_directory') pg_ctl -D \"$data_dir\" restart After your server restarts, show the wal_level again to ensure it has changed: psql -c 'SHOW wal_level' Bounding WAL Size For development databases, you can set a max_slot_wal_keep_size value in Postgres. This will help limit the amount of WAL kept around. This is a configuration parameter that bounds the amount of WAL kept around for replication slots, and invalidates the slots that are too far behind. zero-cache will automatically detect if the replication slot has been invalidated and re-sync replicas from scratch. This configuration can cause problems like slot has been invalidated because it exceeded the maximum reserved size and is not recommended for production databases.", + "content": "WAL Level The Postgres wal_level config parameter has to be set to logical. You can check what level your pg has with this command: psql -c 'SHOW wal_level' If it doesn’t output logical then you need to change the wal level. To do this, run: psql -c \"ALTER SYSTEM SET wal_level = 'logical';\" Then restart Postgres. On most pg systems you can do this like so: data_dir=$(psql -t -A -c 'SHOW data_directory') pg_ctl -D \"$data_dir\" restart After your server restarts, show the wal_level again to ensure it has changed: psql -c 'SHOW wal_level' Bounding WAL Size For development databases, you can set a max_slot_wal_keep_size value in Postgres. This will help limit the amount of WAL kept around. This is a configuration parameter that bounds the amount of WAL kept around for replication slots, and invalidates the slots that are too far behind. Zero-cache will automatically detect if the replication slot has been invalidated and re-sync replicas from scratch. This configuration can cause problems like slot has been invalidated because it exceeded the maximum reserved size and is not recommended for production databases.", "kind": "section" }, { - "id": "85-connecting-to-postgres#wal-level", + "id": "87-connecting-to-postgres#wal-level", "title": "Connecting to Postgres", "searchTitle": "WAL Level", "sectionTitle": "WAL Level", @@ -392,27 +410,37 @@ "kind": "section" }, { - "id": "86-connecting-to-postgres#bounding-wal-size", + "id": "88-connecting-to-postgres#bounding-wal-size", "title": "Connecting to Postgres", "searchTitle": "Bounding WAL Size", "sectionTitle": "Bounding WAL Size", "sectionId": "bounding-wal-size", "url": "/docs/connecting-to-postgres", - "content": "For development databases, you can set a max_slot_wal_keep_size value in Postgres. This will help limit the amount of WAL kept around. This is a configuration parameter that bounds the amount of WAL kept around for replication slots, and invalidates the slots that are too far behind. zero-cache will automatically detect if the replication slot has been invalidated and re-sync replicas from scratch. This configuration can cause problems like slot has been invalidated because it exceeded the maximum reserved size and is not recommended for production databases.", + "content": "For development databases, you can set a max_slot_wal_keep_size value in Postgres. This will help limit the amount of WAL kept around. This is a configuration parameter that bounds the amount of WAL kept around for replication slots, and invalidates the slots that are too far behind. Zero-cache will automatically detect if the replication slot has been invalidated and re-sync replicas from scratch. This configuration can cause problems like slot has been invalidated because it exceeded the maximum reserved size and is not recommended for production databases.", "kind": "section" }, { - "id": "87-connecting-to-postgres#provider-specific-notes", + "id": "89-connecting-to-postgres#provider-specific-notes", "title": "Connecting to Postgres", "searchTitle": "Provider-Specific Notes", "sectionTitle": "Provider-Specific Notes", "sectionId": "provider-specific-notes", "url": "/docs/connecting-to-postgres", - "content": "Google Cloud SQL Zero works with Google Cloud SQL out of the box. In many configurations, when you connect with a user that has sufficient privileges, zero-cache will create its default publication automatically. If your Cloud SQL user does not have permission to create publications, you can still use Zero by creating a publication manually and then specifying that publication name in App Publications when running zero-cache. On Google Cloud SQL for PostgreSQL, enable logical decoding by turning on the instance flag cloudsql.logical_decoding. You do not set wal_level directly on Cloud SQL. See Google's documentation for details: Configure logical replication. Fly.io Fly does not support TLS on their internal networks. If you run both zero-cache and Postgres on Fly, you need to stop zero-cache from trying to use TLS to talk to Postgres. You can do this by adding the sslmode=disable query parameter to your connection strings from zero-cache. Supabase Postgres Version Supabase requires at least 15.8.1.083 for event trigger support. If you have a lower 15.x, Zero will still work but schema updates will be slower. See Supabase's docs for upgrading your Postgres version. Connection Type In order to connect to Supabase you must use the \"Direct Connection\" style connection string, not the pooler: This is because Zero sets up a logical replication slot, which is only supported with a direct connection. IPv4 You may also need to assign an IPv4 address to your Supabase instance: This will be required if you cannot use IPv6 from wherever zero-cache is running. Most cloud providers support IPv6, but some do not. For example, if you are running zero-cache in AWS, it is possible to use IPv6 but difficult. Hetzner offers cheap hosted VPS that supports IPv6. IPv4 addresses are only supported on the Pro plan and are an extra $4/month. PlanetScale for Postgres You need to connect using the \"default\" role that PlanetScale provides, because PlanetScale's \"User-Defiend Roles\" cannot create replication slots. Be sure to use a direct connection for \"Upstream DB\" and a pg bouncer connnection string for \"CVR DB\" and \"Change DB\". Otherwise you will likely exhaust connection limits to PlanetScale. Neon Neon fully supports Zero, but you should be aware of how Neon's pricing model and Zero interact. Because Zero keeps an open connection to Postgres to replicate changes, as long as zero-cache is running, Postgres will be running and you will be charged by Neon. For production databases that have enough usage to always be running anyway, this is fine. But for smaller applications that would otherwise not always be running, this can create a surprisingly high bill. You may want to choose a provider that charge a flat monthly rate instead. Also some users choose Neon because they hope to use branching for previews. This can work, but if not done with care, Zero can end up keeping each Neon preview branch running too 😳. For the recommended approach to preview URLs, see Preview Deployments.", + "content": "PlanetScale for Postgres You should use the default role that PlanetScale provides, because PlanetScale user-defined roles cannot create replication slots. Planetscale Postgres defaults max_connections to 25, which can easily be exhausted by Zero's connection pools. This will result in an error like remaining connection slots are reserved for roles with the SUPERUSER attribute. You should update this value in the Parameters section of the PlanetScale dashboard to 100+. Make sure to only use a direct connection for the ZERO_UPSTREAM_DB, and use pooled URLs for ZERO_CVR_DB, ZERO_CHANGE_DB, and your API (see Deployment). Google Cloud SQL Zero works with Google Cloud SQL out of the box. In many configurations, when you connect with a user that has sufficient privileges, zero-cache will create its default publication automatically. If your Cloud SQL user does not have permission to create publications, you can still use Zero by creating a publication manually and then specifying that publication name in App Publications when running zero-cache. On Google Cloud SQL for PostgreSQL, enable logical decoding by turning on the instance flag cloudsql.logical_decoding. You do not set wal_level directly on Cloud SQL. See Google's documentation for details: Configure logical replication. Fly.io Fly Managed Postgres is the latest offering from Fly.io, and it is private-network-only by default. If zero-cache runs outside Fly, connect via Fly WireGuard or run a proxy like fly-mpg-proxy. Fly Managed Postgres does not provide superuser access, so zero-cache cannot create event triggers. Also, some publication operations (like FOR TABLES IN SCHEMA ... / FOR ALL TABLES) can be permission-restricted. If zero-cache can't create its default publication, create one listing tables explicitly and set the app publication. Fly does not support TLS on its private network. If zero-cache connects to Postgres over the Fly private network (including WireGuard), add sslmode=disable to your connection strings. You should use Fly's pgBouncer endpoint for pooled connections. Supabase Postgres Version Supabase requires at least 15.8.1.083 for event trigger support. If you have a lower 15.x, Zero will still work but schema updates will be slower. See Supabase's docs for upgrading your Postgres version. Connection Type (Direct vs Pooler) ZERO_UPSTREAM_DB must use the \"Direct Connection\" string (not the pooler): This is because Zero sets up a logical replication slot, which is only supported with a direct connection. For ZERO_CVR_DB and ZERO_CHANGE_DB, prefer Supabase’s session pooler (not the default transaction pooler). The transaction pooler can break prepared statements and cause errors like 26000 prepared statement ... does not exist. Known limitation: Supabase does not fire ddl_command_start/ddl_command_end event triggers for ALTER PUBLICATION, so Zero may not automatically detect publication changes. IPv4 You may also need to assign an IPv4 address to your Supabase instance: This will be required if you cannot use IPv6 from wherever zero-cache is running. Most cloud providers support IPv6, but some do not. For example, if you are running zero-cache in AWS, it is possible to use IPv6 but difficult. Hetzner offers cheap hosted VPS that supports IPv6. IPv4 addresses are only supported on the Pro plan and are an extra $4/month. Render Render can work with Zero, but commonly requires admin/support-side setup: Ensure wal_level=logical (may require a Render support ticket). App roles typically can’t create event triggers, so schema changes will fall back to full resets. App roles may not be able to create schema/all-table publications; create a publication listing tables explicitly and set App Publications. Neon Neon supports logical replication, but you may need to enable it in the Neon console for your branch/endpoint (otherwise SHOW wal_level may return replica). Enable logical replication Neon fully supports Zero, but you should be aware of how Neon's pricing model and Zero interact: because Zero keeps an open connection to Postgres to replicate changes, as long as zero-cache is running, Postgres will be running and you will be charged by Neon. For production databases that have enough usage to always be running anyway, this is fine. But for smaller applications that would otherwise not always be running, this can create a surprisingly high bill. You may want to choose a provider that charge a flat monthly rate instead. Also some users choose Neon because they hope to use branching for previews. This can work, but if not done with care, Zero can end up keeping each Neon preview branch running too 😳. For the recommended approach to preview URLs, see Previews.", "kind": "section" }, { - "id": "88-connecting-to-postgres#google-cloud-sql", + "id": "90-connecting-to-postgres#planetscale-for-postgres", + "title": "Connecting to Postgres", + "searchTitle": "PlanetScale for Postgres", + "sectionTitle": "PlanetScale for Postgres", + "sectionId": "planetscale-for-postgres", + "url": "/docs/connecting-to-postgres", + "content": "You should use the default role that PlanetScale provides, because PlanetScale user-defined roles cannot create replication slots. Planetscale Postgres defaults max_connections to 25, which can easily be exhausted by Zero's connection pools. This will result in an error like remaining connection slots are reserved for roles with the SUPERUSER attribute. You should update this value in the Parameters section of the PlanetScale dashboard to 100+. Make sure to only use a direct connection for the ZERO_UPSTREAM_DB, and use pooled URLs for ZERO_CVR_DB, ZERO_CHANGE_DB, and your API (see Deployment).", + "kind": "section" + }, + { + "id": "91-connecting-to-postgres#google-cloud-sql", "title": "Connecting to Postgres", "searchTitle": "Google Cloud SQL", "sectionTitle": "Google Cloud SQL", @@ -422,27 +450,27 @@ "kind": "section" }, { - "id": "89-connecting-to-postgres#flyio", + "id": "92-connecting-to-postgres#flyio", "title": "Connecting to Postgres", "searchTitle": "Fly.io", "sectionTitle": "Fly.io", "sectionId": "flyio", "url": "/docs/connecting-to-postgres", - "content": "Fly does not support TLS on their internal networks. If you run both zero-cache and Postgres on Fly, you need to stop zero-cache from trying to use TLS to talk to Postgres. You can do this by adding the sslmode=disable query parameter to your connection strings from zero-cache.", + "content": "Fly Managed Postgres is the latest offering from Fly.io, and it is private-network-only by default. If zero-cache runs outside Fly, connect via Fly WireGuard or run a proxy like fly-mpg-proxy. Fly Managed Postgres does not provide superuser access, so zero-cache cannot create event triggers. Also, some publication operations (like FOR TABLES IN SCHEMA ... / FOR ALL TABLES) can be permission-restricted. If zero-cache can't create its default publication, create one listing tables explicitly and set the app publication. Fly does not support TLS on its private network. If zero-cache connects to Postgres over the Fly private network (including WireGuard), add sslmode=disable to your connection strings. You should use Fly's pgBouncer endpoint for pooled connections.", "kind": "section" }, { - "id": "90-connecting-to-postgres#supabase", + "id": "93-connecting-to-postgres#supabase", "title": "Connecting to Postgres", "searchTitle": "Supabase", "sectionTitle": "Supabase", "sectionId": "supabase", "url": "/docs/connecting-to-postgres", - "content": "Postgres Version Supabase requires at least 15.8.1.083 for event trigger support. If you have a lower 15.x, Zero will still work but schema updates will be slower. See Supabase's docs for upgrading your Postgres version. Connection Type In order to connect to Supabase you must use the \"Direct Connection\" style connection string, not the pooler: This is because Zero sets up a logical replication slot, which is only supported with a direct connection. IPv4 You may also need to assign an IPv4 address to your Supabase instance: This will be required if you cannot use IPv6 from wherever zero-cache is running. Most cloud providers support IPv6, but some do not. For example, if you are running zero-cache in AWS, it is possible to use IPv6 but difficult. Hetzner offers cheap hosted VPS that supports IPv6. IPv4 addresses are only supported on the Pro plan and are an extra $4/month.", + "content": "Postgres Version Supabase requires at least 15.8.1.083 for event trigger support. If you have a lower 15.x, Zero will still work but schema updates will be slower. See Supabase's docs for upgrading your Postgres version. Connection Type (Direct vs Pooler) ZERO_UPSTREAM_DB must use the \"Direct Connection\" string (not the pooler): This is because Zero sets up a logical replication slot, which is only supported with a direct connection. For ZERO_CVR_DB and ZERO_CHANGE_DB, prefer Supabase’s session pooler (not the default transaction pooler). The transaction pooler can break prepared statements and cause errors like 26000 prepared statement ... does not exist. Known limitation: Supabase does not fire ddl_command_start/ddl_command_end event triggers for ALTER PUBLICATION, so Zero may not automatically detect publication changes. IPv4 You may also need to assign an IPv4 address to your Supabase instance: This will be required if you cannot use IPv6 from wherever zero-cache is running. Most cloud providers support IPv6, but some do not. For example, if you are running zero-cache in AWS, it is possible to use IPv6 but difficult. Hetzner offers cheap hosted VPS that supports IPv6. IPv4 addresses are only supported on the Pro plan and are an extra $4/month.", "kind": "section" }, { - "id": "91-connecting-to-postgres#postgres-version", + "id": "94-connecting-to-postgres#postgres-version", "title": "Connecting to Postgres", "searchTitle": "Postgres Version", "sectionTitle": "Postgres Version", @@ -452,17 +480,17 @@ "kind": "section" }, { - "id": "92-connecting-to-postgres#connection-type", + "id": "95-connecting-to-postgres#connection-type-direct-vs-pooler", "title": "Connecting to Postgres", - "searchTitle": "Connection Type", - "sectionTitle": "Connection Type", - "sectionId": "connection-type", + "searchTitle": "Connection Type (Direct vs Pooler)", + "sectionTitle": "Connection Type (Direct vs Pooler)", + "sectionId": "connection-type-direct-vs-pooler", "url": "/docs/connecting-to-postgres", - "content": "In order to connect to Supabase you must use the \"Direct Connection\" style connection string, not the pooler: This is because Zero sets up a logical replication slot, which is only supported with a direct connection.", + "content": "ZERO_UPSTREAM_DB must use the \"Direct Connection\" string (not the pooler): This is because Zero sets up a logical replication slot, which is only supported with a direct connection. For ZERO_CVR_DB and ZERO_CHANGE_DB, prefer Supabase’s session pooler (not the default transaction pooler). The transaction pooler can break prepared statements and cause errors like 26000 prepared statement ... does not exist. Known limitation: Supabase does not fire ddl_command_start/ddl_command_end event triggers for ALTER PUBLICATION, so Zero may not automatically detect publication changes.", "kind": "section" }, { - "id": "93-connecting-to-postgres#ipv4", + "id": "96-connecting-to-postgres#ipv4", "title": "Connecting to Postgres", "searchTitle": "IPv4", "sectionTitle": "IPv4", @@ -472,23 +500,23 @@ "kind": "section" }, { - "id": "94-connecting-to-postgres#planetscale-for-postgres", + "id": "97-connecting-to-postgres#render", "title": "Connecting to Postgres", - "searchTitle": "PlanetScale for Postgres", - "sectionTitle": "PlanetScale for Postgres", - "sectionId": "planetscale-for-postgres", + "searchTitle": "Render", + "sectionTitle": "Render", + "sectionId": "render", "url": "/docs/connecting-to-postgres", - "content": "You need to connect using the \"default\" role that PlanetScale provides, because PlanetScale's \"User-Defiend Roles\" cannot create replication slots. Be sure to use a direct connection for \"Upstream DB\" and a pg bouncer connnection string for \"CVR DB\" and \"Change DB\". Otherwise you will likely exhaust connection limits to PlanetScale.", + "content": "Render can work with Zero, but commonly requires admin/support-side setup: Ensure wal_level=logical (may require a Render support ticket). App roles typically can’t create event triggers, so schema changes will fall back to full resets. App roles may not be able to create schema/all-table publications; create a publication listing tables explicitly and set App Publications.", "kind": "section" }, { - "id": "95-connecting-to-postgres#neon", + "id": "98-connecting-to-postgres#neon", "title": "Connecting to Postgres", "searchTitle": "Neon", "sectionTitle": "Neon", "sectionId": "neon", "url": "/docs/connecting-to-postgres", - "content": "Neon fully supports Zero, but you should be aware of how Neon's pricing model and Zero interact. Because Zero keeps an open connection to Postgres to replicate changes, as long as zero-cache is running, Postgres will be running and you will be charged by Neon. For production databases that have enough usage to always be running anyway, this is fine. But for smaller applications that would otherwise not always be running, this can create a surprisingly high bill. You may want to choose a provider that charge a flat monthly rate instead. Also some users choose Neon because they hope to use branching for previews. This can work, but if not done with care, Zero can end up keeping each Neon preview branch running too 😳. For the recommended approach to preview URLs, see Preview Deployments.", + "content": "Neon supports logical replication, but you may need to enable it in the Neon console for your branch/endpoint (otherwise SHOW wal_level may return replica). Enable logical replication Neon fully supports Zero, but you should be aware of how Neon's pricing model and Zero interact: because Zero keeps an open connection to Postgres to replicate changes, as long as zero-cache is running, Postgres will be running and you will be charged by Neon. For production databases that have enough usage to always be running anyway, this is fine. But for smaller applications that would otherwise not always be running, this can create a surprisingly high bill. You may want to choose a provider that charge a flat monthly rate instead. Also some users choose Neon because they hope to use branching for previews. This can work, but if not done with care, Zero can end up keeping each Neon preview branch running too 😳. For the recommended approach to preview URLs, see Previews.", "kind": "section" }, { @@ -562,7 +590,7 @@ "kind": "page" }, { - "id": "96-connection#overview", + "id": "99-connection#overview", "title": "Connection Status", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -572,7 +600,7 @@ "kind": "section" }, { - "id": "97-connection#usage", + "id": "100-connection#usage", "title": "Connection Status", "searchTitle": "Usage", "sectionTitle": "Usage", @@ -582,7 +610,7 @@ "kind": "section" }, { - "id": "98-connection#offline", + "id": "101-connection#offline", "title": "Connection Status", "searchTitle": "Offline", "sectionTitle": "Offline", @@ -592,7 +620,7 @@ "kind": "section" }, { - "id": "99-connection#offline-ui", + "id": "102-connection#offline-ui", "title": "Connection Status", "searchTitle": "Offline UI", "sectionTitle": "Offline UI", @@ -602,7 +630,7 @@ "kind": "section" }, { - "id": "100-connection#details", + "id": "103-connection#details", "title": "Connection Status", "searchTitle": "Details", "sectionTitle": "Details", @@ -612,7 +640,7 @@ "kind": "section" }, { - "id": "101-connection#connecting", + "id": "104-connection#connecting", "title": "Connection Status", "searchTitle": "Connecting", "sectionTitle": "Connecting", @@ -622,7 +650,7 @@ "kind": "section" }, { - "id": "102-connection#connected", + "id": "105-connection#connected", "title": "Connection Status", "searchTitle": "Connected", "sectionTitle": "Connected", @@ -632,7 +660,7 @@ "kind": "section" }, { - "id": "103-connection#disconnected", + "id": "106-connection#disconnected", "title": "Connection Status", "searchTitle": "Disconnected", "sectionTitle": "Disconnected", @@ -642,7 +670,7 @@ "kind": "section" }, { - "id": "104-connection#error", + "id": "107-connection#error", "title": "Connection Status", "searchTitle": "Error", "sectionTitle": "Error", @@ -652,7 +680,7 @@ "kind": "section" }, { - "id": "105-connection#needs-auth", + "id": "108-connection#needs-auth", "title": "Connection Status", "searchTitle": "Needs-Auth", "sectionTitle": "Needs-Auth", @@ -662,7 +690,7 @@ "kind": "section" }, { - "id": "106-connection#closed", + "id": "109-connection#closed", "title": "Connection Status", "searchTitle": "Closed", "sectionTitle": "Closed", @@ -672,7 +700,7 @@ "kind": "section" }, { - "id": "107-connection#why-zero-doesnt-support-offline-writes", + "id": "110-connection#why-zero-doesnt-support-offline-writes", "title": "Connection Status", "searchTitle": "Why Zero Doesn't Support Offline Writes", "sectionTitle": "Why Zero Doesn't Support Offline Writes", @@ -682,7 +710,7 @@ "kind": "section" }, { - "id": "108-connection#example", + "id": "111-connection#example", "title": "Connection Status", "searchTitle": "Example", "sectionTitle": "Example", @@ -692,7 +720,7 @@ "kind": "section" }, { - "id": "109-connection#tradeoffs", + "id": "112-connection#tradeoffs", "title": "Connection Status", "searchTitle": "Tradeoffs", "sectionTitle": "Tradeoffs", @@ -702,7 +730,7 @@ "kind": "section" }, { - "id": "110-connection#zeros-position", + "id": "113-connection#zeros-position", "title": "Connection Status", "searchTitle": "Zero's Position", "sectionTitle": "Zero's Position", @@ -754,7 +782,7 @@ "kind": "page" }, { - "id": "111-debug/inspector#accessing-the-inspector", + "id": "114-debug/inspector#accessing-the-inspector", "title": "Inspector", "searchTitle": "Accessing the Inspector", "sectionTitle": "Accessing the Inspector", @@ -764,7 +792,7 @@ "kind": "section" }, { - "id": "112-debug/inspector#clients-and-groups", + "id": "115-debug/inspector#clients-and-groups", "title": "Inspector", "searchTitle": "Clients and Groups", "sectionTitle": "Clients and Groups", @@ -774,7 +802,7 @@ "kind": "section" }, { - "id": "113-debug/inspector#queries", + "id": "116-debug/inspector#queries", "title": "Inspector", "searchTitle": "Queries", "sectionTitle": "Queries", @@ -784,7 +812,7 @@ "kind": "section" }, { - "id": "114-debug/inspector#analyzing-queries", + "id": "117-debug/inspector#analyzing-queries", "title": "Inspector", "searchTitle": "Analyzing Queries", "sectionTitle": "Analyzing Queries", @@ -794,7 +822,7 @@ "kind": "section" }, { - "id": "115-debug/inspector#analyzing-arbitrary-zql", + "id": "118-debug/inspector#analyzing-arbitrary-zql", "title": "Inspector", "searchTitle": "Analyzing Arbitrary ZQL", "sectionTitle": "Analyzing Arbitrary ZQL", @@ -804,7 +832,7 @@ "kind": "section" }, { - "id": "116-debug/inspector#analyzing-query-plans", + "id": "119-debug/inspector#analyzing-query-plans", "title": "Inspector", "searchTitle": "Analyzing Query Plans", "sectionTitle": "Analyzing Query Plans", @@ -814,7 +842,7 @@ "kind": "section" }, { - "id": "117-debug/inspector#table-data", + "id": "120-debug/inspector#table-data", "title": "Inspector", "searchTitle": "Table Data", "sectionTitle": "Table Data", @@ -824,7 +852,7 @@ "kind": "section" }, { - "id": "118-debug/inspector#server-version", + "id": "121-debug/inspector#server-version", "title": "Inspector", "searchTitle": "Server Version", "sectionTitle": "Server Version", @@ -848,7 +876,7 @@ "kind": "page" }, { - "id": "119-debug/otel#grafana-cloud-walkthrough", + "id": "122-debug/otel#grafana-cloud-walkthrough", "title": "OpenTelemetry", "searchTitle": "Grafana Cloud Walkthrough", "sectionTitle": "Grafana Cloud Walkthrough", @@ -889,7 +917,7 @@ "kind": "page" }, { - "id": "120-debug/replication#resetting", + "id": "123-debug/replication#resetting", "title": "Replication", "searchTitle": "Resetting", "sectionTitle": "Resetting", @@ -899,7 +927,7 @@ "kind": "section" }, { - "id": "121-debug/replication#inspecting", + "id": "124-debug/replication#inspecting", "title": "Replication", "searchTitle": "Inspecting", "sectionTitle": "Inspecting", @@ -909,7 +937,7 @@ "kind": "section" }, { - "id": "122-debug/replication#miscellaneous", + "id": "125-debug/replication#miscellaneous", "title": "Replication", "searchTitle": "Miscellaneous", "sectionTitle": "Miscellaneous", @@ -953,7 +981,7 @@ "kind": "page" }, { - "id": "123-debug/slow-queries#query-plan", + "id": "126-debug/slow-queries#query-plan", "title": "Slow Queries", "searchTitle": "Query Plan", "sectionTitle": "Query Plan", @@ -963,7 +991,7 @@ "kind": "section" }, { - "id": "124-debug/slow-queries#optimizing-the-plan", + "id": "127-debug/slow-queries#optimizing-the-plan", "title": "Slow Queries", "searchTitle": "Optimizing the Plan", "sectionTitle": "Optimizing the Plan", @@ -973,7 +1001,7 @@ "kind": "section" }, { - "id": "125-debug/slow-queries#check-ttl", + "id": "128-debug/slow-queries#check-ttl", "title": "Slow Queries", "searchTitle": "Check ttl", "sectionTitle": "Check ttl", @@ -983,7 +1011,7 @@ "kind": "section" }, { - "id": "126-debug/slow-queries#locality", + "id": "129-debug/slow-queries#locality", "title": "Slow Queries", "searchTitle": "Locality", "sectionTitle": "Locality", @@ -993,7 +1021,7 @@ "kind": "section" }, { - "id": "127-debug/slow-queries#check-storage", + "id": "130-debug/slow-queries#check-storage", "title": "Slow Queries", "searchTitle": "Check Storage", "sectionTitle": "Check Storage", @@ -1003,7 +1031,7 @@ "kind": "section" }, { - "id": "128-debug/slow-queries#statz", + "id": "131-debug/slow-queries#statz", "title": "Slow Queries", "searchTitle": "/statz", "sectionTitle": "/statz", @@ -1026,7 +1054,7 @@ "title": "Deploying Zero", "searchTitle": "Deploying Zero", "url": "/docs/deployment", - "content": "So you've built your app with Zero - congratulations! Now you need to run it on a server somewhere. You will need to deploy zero-cache, a Postgres database, your frontend, and your API server. Zero-cache is made up of two main components: One or more view-syncers: serving client queries using a SQLite replica. One replication-manager: bridge between the Postgres replication stream and view-syncers. These components have the following characteristics: You will also need to deploy a Postgres database, your frontend, and your API server for the query and mutate endpoints. Minimum Viable Strategy The simplest way to deploy Zero is to run everything on a single node. This is the least expensive way to run Zero, and it can take you surprisingly far. Here is an example docker-compose.yml file for a single-node deployment (try it out!): services: upstream-db: image: postgres:18 environment: POSTGRES_DB: zero POSTGRES_PASSWORD: pass ports: - 5432:5432 command: postgres -c wal_level=logical healthcheck: test: pg_isready interval: 10s your-api: build: ./your-api ports: - 3000:3000 environment: # Your API handles mutations and writes to the PG db # Use a transaction pooler (e.g. pgbouncer) in production ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero depends_on: upstream-db: condition: service_healthy zero-cache: image: rocicorp/zero:{version} ports: - 4848:4848 environment: # Used for replication from postgres # This *must* be a direct connection (not via pgbouncer) ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records # Use a transaction pooler (pgbouncer) in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries # Use a transaction pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/zero.db # Password used to access the inspector and /statz ZERO_ADMIN_PASSWORD: pickanewpassword # URLs for your API's query and mutate endpoints ZERO_QUERY_URL: http://your-api:3000/api/zero/query ZERO_MUTATE_URL: http://your-api:3000/api/zero/mutate volumes: # Disk for the SQLite replica should be high IOPS - zero-cache-data:/data depends_on: your-api: condition: service_started healthcheck: test: curl -f http://localhost:4848/keepalive interval: 5s Maximal Strategy Once you reach the limits of the single-node deployment, you can split zero-cache into a multi-node topology. This is more expensive to run, but it gives you more flexibility and scalability. Here is an example docker-compose.yml file for a multi-node deployment (try it out!): services: upstream-db: image: postgres:18 environment: POSTGRES_DB: zero POSTGRES_PASSWORD: pass ports: - 5432:5432 command: postgres -c wal_level=logical healthcheck: test: pg_isready interval: 10s your-api: build: ./your-api ports: - 3000:3000 environment: # Your API handles mutations and writes to the PG db # Use a transaction pooler (e.g. pgbouncer) in production ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero depends_on: upstream-db: condition: service_healthy # \"Mini S3\" (MinIO) provides a working s3://... `ZERO_LITESTREAM_BACKUP_URL` # This should be an S3-compatible object storage service in production. mini-s3: image: minio/minio:latest command: server /data --console-address \":9001\" healthcheck: test: curl -f http://localhost:9000/minio/health/live interval: 5s # Creates the bucket used by `ZERO_LITESTREAM_BACKUP_URL` # This is only needed for local development. mini-s3-create-bucket: image: minio/mc:latest depends_on: mini-s3: condition: service_healthy entrypoint: - /bin/sh - -lc - mc alias set local http://mini-s3:9000 \"minioadmin\" \"minioadmin\" && mc mb -p local/zero-backups || true replication-manager: image: rocicorp/zero:{version} ports: - 4849:4849 depends_on: upstream-db: condition: service_healthy your-api: condition: service_started mini-s3-create-bucket: condition: service_started environment: # Used for replication from postgres # This *must* be a direct connection (not via pgbouncer) ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records # Use a transaction pooler (e.g. pgbouncer) in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries # Use a transaction pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/replica.db # Password used to access the inspector and /statz ZERO_ADMIN_PASSWORD: pickanewpassword # Dedicated replication-manager; disable view syncing. ZERO_NUM_SYNC_WORKERS: 0 # URL for backing up the SQLite replica # (include a simple version number for future cleanup) # Required in multi-node so view-syncers can reserve snapshots. ZERO_LITESTREAM_BACKUP_URL: s3://zero-backups/replica-v1 # S3 creds + Mini S3 endpoint (replication-manager backs up to S3) AWS_ACCESS_KEY_ID: minioadmin AWS_SECRET_ACCESS_KEY: minioadmin ZERO_LITESTREAM_ENDPOINT: http://mini-s3:9000 volumes: # storage for the SQLite replica should be high IOPS - replication-manager-data:/data healthcheck: test: curl -f http://localhost:4849/keepalive interval: 5s # Only one view-syncer in this example, but there can be N. view-syncer: image: rocicorp/zero:{version} ports: - 4848:4848 depends_on: replication-manager: condition: service_healthy environment: # Used for writing to the upstream database # Use a transaction pooler in production ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records # Use a transaction pooler in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries # Use a transaction pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/replica.db # Password used to access the inspector and /statz ZERO_ADMIN_PASSWORD: pickanewpassword # URLs for your API's query and mutate endpoints ZERO_QUERY_URL: http://your-api:3000/api/zero/query ZERO_MUTATE_URL: http://your-api:3000/api/zero/mutate # URL for connecting to the replication-manager ZERO_CHANGE_STREAMER_URI: http://replication-manager:4849 # S3 creds + Mini S3 endpoint (view-syncers restore from S3 on startup) AWS_ACCESS_KEY_ID: minioadmin AWS_SECRET_ACCESS_KEY: minioadmin ZERO_LITESTREAM_ENDPOINT: http://mini-s3:9000 volumes: # Storage for the SQLite replica should be high IOPS - view-syncer-data:/data healthcheck: test: curl -f http://localhost:4848/keepalive interval: 5s The view-syncers in the multi-node topology can be horizontally scaled as needed. You can also override the number of sync workers per view-syncer with ZERO_NUM_SYNC_WORKERS. Replica Lifecycle Zero-cache is backed by a SQLite replica of your database. The SQLite replica uses upstream Postgres as the source of truth. If the replica is missing or a litestream restore fails, the replication-manager will resync the replica from upstream on the next start. Performance You want to optimize disk IOPS for the serving replica, since this is the file that is read by the view-syncers to run IVM-based queries, and one of the main bottlenecks for query hydration performance. View syncer's IVM is \"hydrate once, then incrementally push diffs\" against the ZQL pipeline, so performance is mostly about: How fast the server can materialize a subscription the first time (hydration). How fast it can keep it up to date (IVM advancement). Different bottlenecks dominate each phase. Hydration SQLite read cost: hydration is essentially \"run the query against the replica and stream all matching rows into the pipeline\", so it's bounded by SQLite scan/index performance + result size. Churn / TTL eviction: if queries get evicted (inactive long enough) and then get re-requested, you pay hydration again. Custom query transform latency: the HTTP request from zero-cache to your API at ZERO_QUERY_URL does transform/authorization for queries, adding network + CPU before hydration starts. IVM advancement Replication throughput: the view-syncer can only advance when the replicator commits and emits version-ready. If upstream replication is behind, query advancement is capped by how fast the replica advances. Change volume per transaction: advancement cost scales with number of changed rows, not number of queries. Circuit breaker behavior: if advancement looks like it'll take longer than rehydrating, zero-cache intentionally aborts and resets pipelines (which trades \"slow incremental\" for \"rehydrate\"). System-level Number of client groups per sync worker: each client group has its own pipelines; CPU and memory per group limits how many can be \"fast\" at once. Since Node is single-threaded, one client group can technically starve other groups. This is handled with time slicing and can be configured with the yield parameters, e.g. ZERO_YIELD_THRESHOLD_MS. SQLite concurrency limits: it's designed here for one writer (replicator) + many concurrent readers (view-syncer snapshots). It scales, but very heavy read workloads can still contend on cache/IO. Network to clients: even if IVM is fast, it can take time to send data over websocket. This can be improved by using CDNs (like CloudFront) that improve routing. Network between services: for a single-region deployment, all services should be colocated. Load Balancing View syncers must be publicly reachable by port 4848 by clients, and the replication-manager can have internal networking with the view-syncers on port 4849. The external load balancer must support websockets, and can use the health check at /keepalive to verify view-syncers and replication-managers are healthy. Sticky Sessions View syncers are designed to be disposable, but since they keep hydrated query pipelines in memory, it's important to try to keep clients connected to the same instance. If a reconnect/refresh lands on a different instance, that instance usually has to rehydrate instead of reusing warm state. If you are seeing a lot of Rehome errors, you may need to enable sticky sessions. Two instances can end up doing redundant hydration/advancement work for the same clientGroupID, and the \"loser\" will eventually force clients to reconnect. Rolling Updates You can roll out updates in the following order: Run additive database migrations (the expand/migrate part of the expand/migrate/contract pattern) and wait for replication to catch up. Upgrade replication-manager. Upgrade view-syncers (if they come up before the replication-manager, they'll sit in retry loops until the manager is updated). The replication-manager requires a full handoff, since it is the single owner of the changelog DB state. The view-syncers are simply drained and reconnected, since they are designed to be disposable. Update the API servers (your mutate and query endpoints). Update client(s). After most clients have refreshed, run contract migrations to drop or rename obsolete columns/tables. Contract migrations are destructive (dropping or renaming columns/tables). Make sure the API and clients are already updated and have had time to refresh before you remove columns. For renames, add the new column, backfill, deploy the app to use it, then drop the old column later. Client/Server Version Compatibility Servers are compatible with any client of same major version, and with clients one major version back. So for example: Server 0.2.* is compatible with client 0.2.* Server 0.2.* is compatible with client 0.1.* Server 2.*.* is compatible with client 2.*.* Server 2.*.* is compatible with client 1.*.* To upgrade Zero to a new major version, first deploy the new zero-cache, then the new frontend. Configuration The zero-cache image is configured via environment variables. See zero-cache Config for available options.", + "content": "So you've built your app with Zero - congratulations! Now you need to run it on a server somewhere. You will need to deploy zero-cache, a Postgres database, your frontend, and your API server. Zero-cache is made up of two main components: One or more view-syncers: serving client queries using a SQLite replica. One replication-manager: bridge between the Postgres replication stream and view-syncers. These components have the following characteristics: You will also need to deploy a Postgres database, your frontend, and your API server for the query and mutate endpoints. Before setting up Postgres, read Connecting to Postgres for provider-specific notes. Minimum Viable Strategy The simplest way to deploy Zero is to run everything on a single node. This is the least expensive way to run Zero, and it can take you surprisingly far. Here is an example docker-compose.yml file for a single-node deployment (try it out!): services: upstream-db: image: postgres:18 environment: POSTGRES_DB: zero POSTGRES_PASSWORD: pass ports: - 5432:5432 command: postgres -c wal_level=logical healthcheck: test: pg_isready interval: 10s your-api: build: ./your-api ports: - 3000:3000 environment: # Your API handles mutations and writes to the PG db # Use a pooler (e.g. pgbouncer) in production ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero depends_on: upstream-db: condition: service_healthy zero-cache: image: rocicorp/zero:{version} ports: - 4848:4848 environment: # Used for replication from postgres # This *must* be a direct connection (not via pgbouncer) ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records # Use a pooler (e.g. pgbouncer) in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries # Use a pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/zero.db # Password used to access the inspector and /statz ZERO_ADMIN_PASSWORD: pickanewpassword # URLs for your API's query and mutate endpoints ZERO_QUERY_URL: http://your-api:3000/api/zero/query ZERO_MUTATE_URL: http://your-api:3000/api/zero/mutate volumes: # Disk for the SQLite replica should be high IOPS - zero-cache-data:/data depends_on: your-api: condition: service_started healthcheck: test: curl -f http://localhost:4848/keepalive interval: 5s Maximal Strategy Once you reach the limits of the single-node deployment, you can split zero-cache into a multi-node topology. This is more expensive to run, but it gives you more flexibility and scalability. Here is an example docker-compose.yml file for a multi-node deployment (try it out!): services: upstream-db: image: postgres:18 environment: POSTGRES_DB: zero POSTGRES_PASSWORD: pass ports: - 5432:5432 command: postgres -c wal_level=logical healthcheck: test: pg_isready interval: 10s your-api: build: ./your-api ports: - 3000:3000 environment: # Your API handles mutations and writes to the PG db # Use a pooler (e.g. pgbouncer) in production ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero depends_on: upstream-db: condition: service_healthy # \"Mini S3\" (MinIO) provides a working s3://... `ZERO_LITESTREAM_BACKUP_URL` # This should be an S3-compatible object storage service in production. mini-s3: image: minio/minio:latest command: server /data --console-address \":9001\" healthcheck: test: curl -f http://localhost:9000/minio/health/live interval: 5s # Creates the bucket used by `ZERO_LITESTREAM_BACKUP_URL` # This is only needed for local development. mini-s3-create-bucket: image: minio/mc:latest depends_on: mini-s3: condition: service_healthy entrypoint: - /bin/sh - -lc - mc alias set local http://mini-s3:9000 \"minioadmin\" \"minioadmin\" && mc mb -p local/zero-backups || true replication-manager: image: rocicorp/zero:{version} ports: - 4849:4849 depends_on: upstream-db: condition: service_healthy your-api: condition: service_started mini-s3-create-bucket: condition: service_started environment: # Used for replication from postgres # This *must* be a direct connection (not via pgbouncer) ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records # Use a pooler (e.g. pgbouncer) in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries # Use a pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/replica.db # Password used to access the inspector and /statz ZERO_ADMIN_PASSWORD: pickanewpassword # Dedicated replication-manager; disable view syncing. ZERO_NUM_SYNC_WORKERS: 0 # URL for backing up the SQLite replica # (include a simple version number for future cleanup) # Required in multi-node so view-syncers can reserve snapshots. ZERO_LITESTREAM_BACKUP_URL: s3://zero-backups/replica-v1 # S3 creds + Mini S3 endpoint (replication-manager backs up to S3) AWS_ACCESS_KEY_ID: minioadmin AWS_SECRET_ACCESS_KEY: minioadmin ZERO_LITESTREAM_ENDPOINT: http://mini-s3:9000 volumes: # storage for the SQLite replica should be high IOPS - replication-manager-data:/data healthcheck: test: curl -f http://localhost:4849/keepalive interval: 5s # Only one view-syncer in this example, but there can be N. view-syncer: image: rocicorp/zero:{version} ports: - 4848:4848 depends_on: replication-manager: condition: service_healthy environment: # Used for writing to the upstream database # Use a pooler in production ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records # Use a pooler in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries # Use a pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/replica.db # Password used to access the inspector and /statz ZERO_ADMIN_PASSWORD: pickanewpassword # URLs for your API's query and mutate endpoints ZERO_QUERY_URL: http://your-api:3000/api/zero/query ZERO_MUTATE_URL: http://your-api:3000/api/zero/mutate # URL for connecting to the replication-manager ZERO_CHANGE_STREAMER_URI: http://replication-manager:4849 # S3 creds + Mini S3 endpoint (view-syncers restore from S3 on startup) AWS_ACCESS_KEY_ID: minioadmin AWS_SECRET_ACCESS_KEY: minioadmin ZERO_LITESTREAM_ENDPOINT: http://mini-s3:9000 volumes: # Storage for the SQLite replica should be high IOPS - view-syncer-data:/data healthcheck: test: curl -f http://localhost:4848/keepalive interval: 5s The view-syncers in the multi-node topology can be horizontally scaled as needed. You can also override the number of sync workers per view-syncer with ZERO_NUM_SYNC_WORKERS. Replica Lifecycle Zero-cache is backed by a SQLite replica of your database. The SQLite replica uses upstream Postgres as the source of truth. If the replica is missing or a litestream restore fails, the replication-manager will resync the replica from upstream on the next start. Performance You want to optimize disk IOPS for the serving replica, since this is the file that is read by the view-syncers to run IVM-based queries, and one of the main bottlenecks for query hydration performance. View syncer's IVM is \"hydrate once, then incrementally push diffs\" against the ZQL pipeline, so performance is mostly about: How fast the server can materialize a subscription the first time (hydration). How fast it can keep it up to date (IVM advancement). Different bottlenecks dominate each phase. Hydration SQLite read cost: hydration is essentially \"run the query against the replica and stream all matching rows into the pipeline\", so it's bounded by SQLite scan/index performance + result size. Churn / TTL eviction: if queries get evicted (inactive long enough) and then get re-requested, you pay hydration again. Custom query transform latency: the HTTP request from zero-cache to your API at ZERO_QUERY_URL does transform/authorization for queries, adding network + CPU before hydration starts. IVM advancement Replication throughput: the view-syncer can only advance when the replicator commits and emits version-ready. If upstream replication is behind, query advancement is capped by how fast the replica advances. Change volume per transaction: advancement cost scales with number of changed rows, not number of queries. Circuit breaker behavior: if advancement looks like it'll take longer than rehydrating, zero-cache intentionally aborts and resets pipelines (which trades \"slow incremental\" for \"rehydrate\"). System-level Number of client groups per sync worker: each client group has its own pipelines; CPU and memory per group limits how many can be \"fast\" at once. Since Node is single-threaded, one client group can technically starve other groups. This is handled with time slicing and can be configured with the yield parameters, e.g. ZERO_YIELD_THRESHOLD_MS. SQLite concurrency limits: it's designed here for one writer (replicator) + many concurrent readers (view-syncer snapshots). It scales, but very heavy read workloads can still contend on cache/IO. Network to clients: even if IVM is fast, it can take time to send data over websocket. This can be improved by using CDNs (like CloudFront) that improve routing. Network between services: for a single-region deployment, all services should be colocated. Load Balancing View syncers must be publicly reachable by port 4848 by clients, and the replication-manager can have internal networking with the view-syncers on port 4849. The external load balancer must support websockets, and can use the health check at /keepalive to verify view-syncers and replication-managers are healthy. Sticky Sessions View syncers are designed to be disposable, but since they keep hydrated query pipelines in memory, it's important to try to keep clients connected to the same instance. If a reconnect/refresh lands on a different instance, that instance usually has to rehydrate instead of reusing warm state. If you are seeing a lot of Rehome errors, you may need to enable sticky sessions. Two instances can end up doing redundant hydration/advancement work for the same clientGroupID, and the \"loser\" will eventually force clients to reconnect. Rolling Updates You can roll out updates in the following order: Run additive database migrations (the expand/migrate part of the expand/migrate/contract pattern) and wait for replication to catch up. Upgrade replication-manager. Upgrade view-syncers (if they come up before the replication-manager, they'll sit in retry loops until the manager is updated). The replication-manager requires a full handoff, since it is the single owner of the changelog DB state. The view-syncers are simply drained and reconnected, since they are designed to be disposable. Update the API servers (your mutate and query endpoints). Update client(s). After most clients have refreshed, run contract migrations to drop or rename obsolete columns/tables. Contract migrations are destructive (dropping or renaming columns/tables). Make sure the API and clients are already updated and have had time to refresh before you remove columns. For renames, add the new column, backfill, deploy the app to use it, then drop the old column later. Client/Server Version Compatibility Servers are compatible with any client of same major version, and with clients one major version back. So for example: Server 0.2.* is compatible with client 0.2.* Server 0.2.* is compatible with client 0.1.* Server 2.*.* is compatible with client 2.*.* Server 2.*.* is compatible with client 1.*.* To upgrade Zero to a new major version, first deploy the new zero-cache, then the new frontend. Configuration The zero-cache image is configured via environment variables. See zero-cache Config for available options.", "headings": [ { "text": "Minimum Viable Strategy", @@ -1080,27 +1108,27 @@ "kind": "page" }, { - "id": "129-deployment#minimum-viable-strategy", + "id": "132-deployment#minimum-viable-strategy", "title": "Deploying Zero", "searchTitle": "Minimum Viable Strategy", "sectionTitle": "Minimum Viable Strategy", "sectionId": "minimum-viable-strategy", "url": "/docs/deployment", - "content": "The simplest way to deploy Zero is to run everything on a single node. This is the least expensive way to run Zero, and it can take you surprisingly far. Here is an example docker-compose.yml file for a single-node deployment (try it out!): services: upstream-db: image: postgres:18 environment: POSTGRES_DB: zero POSTGRES_PASSWORD: pass ports: - 5432:5432 command: postgres -c wal_level=logical healthcheck: test: pg_isready interval: 10s your-api: build: ./your-api ports: - 3000:3000 environment: # Your API handles mutations and writes to the PG db # Use a transaction pooler (e.g. pgbouncer) in production ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero depends_on: upstream-db: condition: service_healthy zero-cache: image: rocicorp/zero:{version} ports: - 4848:4848 environment: # Used for replication from postgres # This *must* be a direct connection (not via pgbouncer) ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records # Use a transaction pooler (pgbouncer) in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries # Use a transaction pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/zero.db # Password used to access the inspector and /statz ZERO_ADMIN_PASSWORD: pickanewpassword # URLs for your API's query and mutate endpoints ZERO_QUERY_URL: http://your-api:3000/api/zero/query ZERO_MUTATE_URL: http://your-api:3000/api/zero/mutate volumes: # Disk for the SQLite replica should be high IOPS - zero-cache-data:/data depends_on: your-api: condition: service_started healthcheck: test: curl -f http://localhost:4848/keepalive interval: 5s", + "content": "The simplest way to deploy Zero is to run everything on a single node. This is the least expensive way to run Zero, and it can take you surprisingly far. Here is an example docker-compose.yml file for a single-node deployment (try it out!): services: upstream-db: image: postgres:18 environment: POSTGRES_DB: zero POSTGRES_PASSWORD: pass ports: - 5432:5432 command: postgres -c wal_level=logical healthcheck: test: pg_isready interval: 10s your-api: build: ./your-api ports: - 3000:3000 environment: # Your API handles mutations and writes to the PG db # Use a pooler (e.g. pgbouncer) in production ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero depends_on: upstream-db: condition: service_healthy zero-cache: image: rocicorp/zero:{version} ports: - 4848:4848 environment: # Used for replication from postgres # This *must* be a direct connection (not via pgbouncer) ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records # Use a pooler (e.g. pgbouncer) in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries # Use a pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/zero.db # Password used to access the inspector and /statz ZERO_ADMIN_PASSWORD: pickanewpassword # URLs for your API's query and mutate endpoints ZERO_QUERY_URL: http://your-api:3000/api/zero/query ZERO_MUTATE_URL: http://your-api:3000/api/zero/mutate volumes: # Disk for the SQLite replica should be high IOPS - zero-cache-data:/data depends_on: your-api: condition: service_started healthcheck: test: curl -f http://localhost:4848/keepalive interval: 5s", "kind": "section" }, { - "id": "130-deployment#maximal-strategy", + "id": "133-deployment#maximal-strategy", "title": "Deploying Zero", "searchTitle": "Maximal Strategy", "sectionTitle": "Maximal Strategy", "sectionId": "maximal-strategy", "url": "/docs/deployment", - "content": "Once you reach the limits of the single-node deployment, you can split zero-cache into a multi-node topology. This is more expensive to run, but it gives you more flexibility and scalability. Here is an example docker-compose.yml file for a multi-node deployment (try it out!): services: upstream-db: image: postgres:18 environment: POSTGRES_DB: zero POSTGRES_PASSWORD: pass ports: - 5432:5432 command: postgres -c wal_level=logical healthcheck: test: pg_isready interval: 10s your-api: build: ./your-api ports: - 3000:3000 environment: # Your API handles mutations and writes to the PG db # Use a transaction pooler (e.g. pgbouncer) in production ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero depends_on: upstream-db: condition: service_healthy # \"Mini S3\" (MinIO) provides a working s3://... `ZERO_LITESTREAM_BACKUP_URL` # This should be an S3-compatible object storage service in production. mini-s3: image: minio/minio:latest command: server /data --console-address \":9001\" healthcheck: test: curl -f http://localhost:9000/minio/health/live interval: 5s # Creates the bucket used by `ZERO_LITESTREAM_BACKUP_URL` # This is only needed for local development. mini-s3-create-bucket: image: minio/mc:latest depends_on: mini-s3: condition: service_healthy entrypoint: - /bin/sh - -lc - mc alias set local http://mini-s3:9000 \"minioadmin\" \"minioadmin\" && mc mb -p local/zero-backups || true replication-manager: image: rocicorp/zero:{version} ports: - 4849:4849 depends_on: upstream-db: condition: service_healthy your-api: condition: service_started mini-s3-create-bucket: condition: service_started environment: # Used for replication from postgres # This *must* be a direct connection (not via pgbouncer) ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records # Use a transaction pooler (e.g. pgbouncer) in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries # Use a transaction pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/replica.db # Password used to access the inspector and /statz ZERO_ADMIN_PASSWORD: pickanewpassword # Dedicated replication-manager; disable view syncing. ZERO_NUM_SYNC_WORKERS: 0 # URL for backing up the SQLite replica # (include a simple version number for future cleanup) # Required in multi-node so view-syncers can reserve snapshots. ZERO_LITESTREAM_BACKUP_URL: s3://zero-backups/replica-v1 # S3 creds + Mini S3 endpoint (replication-manager backs up to S3) AWS_ACCESS_KEY_ID: minioadmin AWS_SECRET_ACCESS_KEY: minioadmin ZERO_LITESTREAM_ENDPOINT: http://mini-s3:9000 volumes: # storage for the SQLite replica should be high IOPS - replication-manager-data:/data healthcheck: test: curl -f http://localhost:4849/keepalive interval: 5s # Only one view-syncer in this example, but there can be N. view-syncer: image: rocicorp/zero:{version} ports: - 4848:4848 depends_on: replication-manager: condition: service_healthy environment: # Used for writing to the upstream database # Use a transaction pooler in production ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records # Use a transaction pooler in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries # Use a transaction pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/replica.db # Password used to access the inspector and /statz ZERO_ADMIN_PASSWORD: pickanewpassword # URLs for your API's query and mutate endpoints ZERO_QUERY_URL: http://your-api:3000/api/zero/query ZERO_MUTATE_URL: http://your-api:3000/api/zero/mutate # URL for connecting to the replication-manager ZERO_CHANGE_STREAMER_URI: http://replication-manager:4849 # S3 creds + Mini S3 endpoint (view-syncers restore from S3 on startup) AWS_ACCESS_KEY_ID: minioadmin AWS_SECRET_ACCESS_KEY: minioadmin ZERO_LITESTREAM_ENDPOINT: http://mini-s3:9000 volumes: # Storage for the SQLite replica should be high IOPS - view-syncer-data:/data healthcheck: test: curl -f http://localhost:4848/keepalive interval: 5s The view-syncers in the multi-node topology can be horizontally scaled as needed. You can also override the number of sync workers per view-syncer with ZERO_NUM_SYNC_WORKERS.", + "content": "Once you reach the limits of the single-node deployment, you can split zero-cache into a multi-node topology. This is more expensive to run, but it gives you more flexibility and scalability. Here is an example docker-compose.yml file for a multi-node deployment (try it out!): services: upstream-db: image: postgres:18 environment: POSTGRES_DB: zero POSTGRES_PASSWORD: pass ports: - 5432:5432 command: postgres -c wal_level=logical healthcheck: test: pg_isready interval: 10s your-api: build: ./your-api ports: - 3000:3000 environment: # Your API handles mutations and writes to the PG db # Use a pooler (e.g. pgbouncer) in production ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero depends_on: upstream-db: condition: service_healthy # \"Mini S3\" (MinIO) provides a working s3://... `ZERO_LITESTREAM_BACKUP_URL` # This should be an S3-compatible object storage service in production. mini-s3: image: minio/minio:latest command: server /data --console-address \":9001\" healthcheck: test: curl -f http://localhost:9000/minio/health/live interval: 5s # Creates the bucket used by `ZERO_LITESTREAM_BACKUP_URL` # This is only needed for local development. mini-s3-create-bucket: image: minio/mc:latest depends_on: mini-s3: condition: service_healthy entrypoint: - /bin/sh - -lc - mc alias set local http://mini-s3:9000 \"minioadmin\" \"minioadmin\" && mc mb -p local/zero-backups || true replication-manager: image: rocicorp/zero:{version} ports: - 4849:4849 depends_on: upstream-db: condition: service_healthy your-api: condition: service_started mini-s3-create-bucket: condition: service_started environment: # Used for replication from postgres # This *must* be a direct connection (not via pgbouncer) ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records # Use a pooler (e.g. pgbouncer) in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries # Use a pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/replica.db # Password used to access the inspector and /statz ZERO_ADMIN_PASSWORD: pickanewpassword # Dedicated replication-manager; disable view syncing. ZERO_NUM_SYNC_WORKERS: 0 # URL for backing up the SQLite replica # (include a simple version number for future cleanup) # Required in multi-node so view-syncers can reserve snapshots. ZERO_LITESTREAM_BACKUP_URL: s3://zero-backups/replica-v1 # S3 creds + Mini S3 endpoint (replication-manager backs up to S3) AWS_ACCESS_KEY_ID: minioadmin AWS_SECRET_ACCESS_KEY: minioadmin ZERO_LITESTREAM_ENDPOINT: http://mini-s3:9000 volumes: # storage for the SQLite replica should be high IOPS - replication-manager-data:/data healthcheck: test: curl -f http://localhost:4849/keepalive interval: 5s # Only one view-syncer in this example, but there can be N. view-syncer: image: rocicorp/zero:{version} ports: - 4848:4848 depends_on: replication-manager: condition: service_healthy environment: # Used for writing to the upstream database # Use a pooler in production ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records # Use a pooler in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries # Use a pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/replica.db # Password used to access the inspector and /statz ZERO_ADMIN_PASSWORD: pickanewpassword # URLs for your API's query and mutate endpoints ZERO_QUERY_URL: http://your-api:3000/api/zero/query ZERO_MUTATE_URL: http://your-api:3000/api/zero/mutate # URL for connecting to the replication-manager ZERO_CHANGE_STREAMER_URI: http://replication-manager:4849 # S3 creds + Mini S3 endpoint (view-syncers restore from S3 on startup) AWS_ACCESS_KEY_ID: minioadmin AWS_SECRET_ACCESS_KEY: minioadmin ZERO_LITESTREAM_ENDPOINT: http://mini-s3:9000 volumes: # Storage for the SQLite replica should be high IOPS - view-syncer-data:/data healthcheck: test: curl -f http://localhost:4848/keepalive interval: 5s The view-syncers in the multi-node topology can be horizontally scaled as needed. You can also override the number of sync workers per view-syncer with ZERO_NUM_SYNC_WORKERS.", "kind": "section" }, { - "id": "131-deployment#replica-lifecycle", + "id": "134-deployment#replica-lifecycle", "title": "Deploying Zero", "searchTitle": "Replica Lifecycle", "sectionTitle": "Replica Lifecycle", @@ -1110,7 +1138,7 @@ "kind": "section" }, { - "id": "132-deployment#performance", + "id": "135-deployment#performance", "title": "Deploying Zero", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -1120,7 +1148,7 @@ "kind": "section" }, { - "id": "133-deployment#hydration", + "id": "136-deployment#hydration", "title": "Deploying Zero", "searchTitle": "Hydration", "sectionTitle": "Hydration", @@ -1130,7 +1158,7 @@ "kind": "section" }, { - "id": "134-deployment#ivm-advancement", + "id": "137-deployment#ivm-advancement", "title": "Deploying Zero", "searchTitle": "IVM advancement", "sectionTitle": "IVM advancement", @@ -1140,7 +1168,7 @@ "kind": "section" }, { - "id": "135-deployment#system-level", + "id": "138-deployment#system-level", "title": "Deploying Zero", "searchTitle": "System-level", "sectionTitle": "System-level", @@ -1150,7 +1178,7 @@ "kind": "section" }, { - "id": "136-deployment#load-balancing", + "id": "139-deployment#load-balancing", "title": "Deploying Zero", "searchTitle": "Load Balancing", "sectionTitle": "Load Balancing", @@ -1160,7 +1188,7 @@ "kind": "section" }, { - "id": "137-deployment#sticky-sessions", + "id": "140-deployment#sticky-sessions", "title": "Deploying Zero", "searchTitle": "Sticky Sessions", "sectionTitle": "Sticky Sessions", @@ -1170,7 +1198,7 @@ "kind": "section" }, { - "id": "138-deployment#rolling-updates", + "id": "141-deployment#rolling-updates", "title": "Deploying Zero", "searchTitle": "Rolling Updates", "sectionTitle": "Rolling Updates", @@ -1180,7 +1208,7 @@ "kind": "section" }, { - "id": "139-deployment#clientserver-version-compatibility", + "id": "142-deployment#clientserver-version-compatibility", "title": "Deploying Zero", "searchTitle": "Client/Server Version Compatibility", "sectionTitle": "Client/Server Version Compatibility", @@ -1190,7 +1218,7 @@ "kind": "section" }, { - "id": "140-deployment#configuration", + "id": "143-deployment#configuration", "title": "Deploying Zero", "searchTitle": "Configuration", "sectionTitle": "Configuration", @@ -1214,7 +1242,7 @@ "kind": "page" }, { - "id": "141-deprecated/ad-hoc-queries#overview", + "id": "144-deprecated/ad-hoc-queries#overview", "title": "Ad-Hoc Queries (Deprecated)", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -1238,7 +1266,7 @@ "kind": "page" }, { - "id": "142-deprecated/crud-mutators#overview", + "id": "145-deprecated/crud-mutators#overview", "title": "CRUD Mutators (Deprecated)", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -1310,7 +1338,7 @@ "kind": "page" }, { - "id": "143-deprecated/rls-permissions#define-permissions", + "id": "146-deprecated/rls-permissions#define-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Define Permissions", "sectionTitle": "Define Permissions", @@ -1320,7 +1348,7 @@ "kind": "section" }, { - "id": "144-deprecated/rls-permissions#access-is-denied-by-default", + "id": "147-deprecated/rls-permissions#access-is-denied-by-default", "title": "RLS Permissions (Deprecated)", "searchTitle": "Access is Denied by Default", "sectionTitle": "Access is Denied by Default", @@ -1330,7 +1358,7 @@ "kind": "section" }, { - "id": "145-deprecated/rls-permissions#permission-evaluation", + "id": "148-deprecated/rls-permissions#permission-evaluation", "title": "RLS Permissions (Deprecated)", "searchTitle": "Permission Evaluation", "sectionTitle": "Permission Evaluation", @@ -1340,7 +1368,7 @@ "kind": "section" }, { - "id": "146-deprecated/rls-permissions#permission-deployment", + "id": "149-deprecated/rls-permissions#permission-deployment", "title": "RLS Permissions (Deprecated)", "searchTitle": "Permission Deployment", "sectionTitle": "Permission Deployment", @@ -1350,7 +1378,7 @@ "kind": "section" }, { - "id": "147-deprecated/rls-permissions#rules", + "id": "150-deprecated/rls-permissions#rules", "title": "RLS Permissions (Deprecated)", "searchTitle": "Rules", "sectionTitle": "Rules", @@ -1360,7 +1388,7 @@ "kind": "section" }, { - "id": "148-deprecated/rls-permissions#select-permissions", + "id": "151-deprecated/rls-permissions#select-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Select Permissions", "sectionTitle": "Select Permissions", @@ -1370,7 +1398,7 @@ "kind": "section" }, { - "id": "149-deprecated/rls-permissions#insert-permissions", + "id": "152-deprecated/rls-permissions#insert-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Insert Permissions", "sectionTitle": "Insert Permissions", @@ -1380,7 +1408,7 @@ "kind": "section" }, { - "id": "150-deprecated/rls-permissions#update-permissions", + "id": "153-deprecated/rls-permissions#update-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Update Permissions", "sectionTitle": "Update Permissions", @@ -1390,7 +1418,7 @@ "kind": "section" }, { - "id": "151-deprecated/rls-permissions#delete-permissions", + "id": "154-deprecated/rls-permissions#delete-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Delete Permissions", "sectionTitle": "Delete Permissions", @@ -1400,7 +1428,7 @@ "kind": "section" }, { - "id": "152-deprecated/rls-permissions#permissions-based-on-auth-data", + "id": "155-deprecated/rls-permissions#permissions-based-on-auth-data", "title": "RLS Permissions (Deprecated)", "searchTitle": "Permissions Based on Auth Data", "sectionTitle": "Permissions Based on Auth Data", @@ -1410,7 +1438,7 @@ "kind": "section" }, { - "id": "153-deprecated/rls-permissions#debugging", + "id": "156-deprecated/rls-permissions#debugging", "title": "RLS Permissions (Deprecated)", "searchTitle": "Debugging", "sectionTitle": "Debugging", @@ -1420,7 +1448,7 @@ "kind": "section" }, { - "id": "154-deprecated/rls-permissions#read-permissions", + "id": "157-deprecated/rls-permissions#read-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Read Permissions", "sectionTitle": "Read Permissions", @@ -1430,7 +1458,7 @@ "kind": "section" }, { - "id": "155-deprecated/rls-permissions#write-permissions", + "id": "158-deprecated/rls-permissions#write-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Write Permissions", "sectionTitle": "Write Permissions", @@ -1514,7 +1542,7 @@ "kind": "page" }, { - "id": "156-install#integrate-zero", + "id": "159-install#integrate-zero", "title": "Install Zero", "searchTitle": "Integrate Zero", "sectionTitle": "Integrate Zero", @@ -1524,7 +1552,7 @@ "kind": "section" }, { - "id": "157-install#set-up-your-database", + "id": "160-install#set-up-your-database", "title": "Install Zero", "searchTitle": "Set Up Your Database", "sectionTitle": "Set Up Your Database", @@ -1534,7 +1562,7 @@ "kind": "section" }, { - "id": "158-install#install-and-run-zero-cache", + "id": "161-install#install-and-run-zero-cache", "title": "Install Zero", "searchTitle": "Install and Run Zero-Cache", "sectionTitle": "Install and Run Zero-Cache", @@ -1544,7 +1572,7 @@ "kind": "section" }, { - "id": "159-install#set-up-your-zero-schema", + "id": "162-install#set-up-your-zero-schema", "title": "Install Zero", "searchTitle": "Set Up Your Zero Schema", "sectionTitle": "Set Up Your Zero Schema", @@ -1554,7 +1582,7 @@ "kind": "section" }, { - "id": "160-install#set-up-the-zero-client", + "id": "163-install#set-up-the-zero-client", "title": "Install Zero", "searchTitle": "Set Up the Zero Client", "sectionTitle": "Set Up the Zero Client", @@ -1564,7 +1592,7 @@ "kind": "section" }, { - "id": "161-install#sync-data", + "id": "164-install#sync-data", "title": "Install Zero", "searchTitle": "Sync Data", "sectionTitle": "Sync Data", @@ -1574,7 +1602,7 @@ "kind": "section" }, { - "id": "162-install#define-query", + "id": "165-install#define-query", "title": "Install Zero", "searchTitle": "Define Query", "sectionTitle": "Define Query", @@ -1584,7 +1612,7 @@ "kind": "section" }, { - "id": "163-install#invoke-query", + "id": "166-install#invoke-query", "title": "Install Zero", "searchTitle": "Invoke Query", "sectionTitle": "Invoke Query", @@ -1594,7 +1622,7 @@ "kind": "section" }, { - "id": "164-install#implement-query-backend", + "id": "167-install#implement-query-backend", "title": "Install Zero", "searchTitle": "Implement Query Backend", "sectionTitle": "Implement Query Backend", @@ -1604,7 +1632,7 @@ "kind": "section" }, { - "id": "165-install#more-about-queries", + "id": "168-install#more-about-queries", "title": "Install Zero", "searchTitle": "More about Queries", "sectionTitle": "More about Queries", @@ -1614,7 +1642,7 @@ "kind": "section" }, { - "id": "166-install#mutate-data", + "id": "169-install#mutate-data", "title": "Install Zero", "searchTitle": "Mutate Data", "sectionTitle": "Mutate Data", @@ -1624,7 +1652,7 @@ "kind": "section" }, { - "id": "167-install#define-mutators", + "id": "170-install#define-mutators", "title": "Install Zero", "searchTitle": "Define Mutators", "sectionTitle": "Define Mutators", @@ -1634,7 +1662,7 @@ "kind": "section" }, { - "id": "168-install#invoke-mutators", + "id": "171-install#invoke-mutators", "title": "Install Zero", "searchTitle": "Invoke Mutators", "sectionTitle": "Invoke Mutators", @@ -1644,7 +1672,7 @@ "kind": "section" }, { - "id": "169-install#implement-mutate-endpoint", + "id": "172-install#implement-mutate-endpoint", "title": "Install Zero", "searchTitle": "Implement Mutate Endpoint", "sectionTitle": "Implement Mutate Endpoint", @@ -1654,7 +1682,7 @@ "kind": "section" }, { - "id": "170-install#more-about-mutators", + "id": "173-install#more-about-mutators", "title": "Install Zero", "searchTitle": "More about Mutators", "sectionTitle": "More about Mutators", @@ -1664,7 +1692,7 @@ "kind": "section" }, { - "id": "171-install#thats-it", + "id": "174-install#thats-it", "title": "Install Zero", "searchTitle": "That's It!", "sectionTitle": "That's It!", @@ -1688,7 +1716,7 @@ "kind": "page" }, { - "id": "172-introduction#ready-to-get-started", + "id": "175-introduction#ready-to-get-started", "title": "Welcome to Zero", "searchTitle": "Ready to get started?", "sectionTitle": "Ready to get started?", @@ -1833,7 +1861,7 @@ "kind": "page" }, { - "id": "173-mutators#architecture", + "id": "176-mutators#architecture", "title": "Mutators", "searchTitle": "Architecture", "sectionTitle": "Architecture", @@ -1843,7 +1871,7 @@ "kind": "section" }, { - "id": "174-mutators#life-of-a-mutation", + "id": "177-mutators#life-of-a-mutation", "title": "Mutators", "searchTitle": "Life of a Mutation", "sectionTitle": "Life of a Mutation", @@ -1853,7 +1881,7 @@ "kind": "section" }, { - "id": "175-mutators#defining-mutators", + "id": "178-mutators#defining-mutators", "title": "Mutators", "searchTitle": "Defining Mutators", "sectionTitle": "Defining Mutators", @@ -1863,7 +1891,7 @@ "kind": "section" }, { - "id": "176-mutators#basics", + "id": "179-mutators#basics", "title": "Mutators", "searchTitle": "Basics", "sectionTitle": "Basics", @@ -1873,7 +1901,7 @@ "kind": "section" }, { - "id": "177-mutators#writing-data", + "id": "180-mutators#writing-data", "title": "Mutators", "searchTitle": "Writing Data", "sectionTitle": "Writing Data", @@ -1883,7 +1911,7 @@ "kind": "section" }, { - "id": "178-mutators#insert", + "id": "181-mutators#insert", "title": "Mutators", "searchTitle": "Insert", "sectionTitle": "Insert", @@ -1893,7 +1921,7 @@ "kind": "section" }, { - "id": "179-mutators#upsert", + "id": "182-mutators#upsert", "title": "Mutators", "searchTitle": "Upsert", "sectionTitle": "Upsert", @@ -1903,7 +1931,7 @@ "kind": "section" }, { - "id": "180-mutators#update", + "id": "183-mutators#update", "title": "Mutators", "searchTitle": "Update", "sectionTitle": "Update", @@ -1913,7 +1941,7 @@ "kind": "section" }, { - "id": "181-mutators#delete", + "id": "184-mutators#delete", "title": "Mutators", "searchTitle": "Delete", "sectionTitle": "Delete", @@ -1923,7 +1951,7 @@ "kind": "section" }, { - "id": "182-mutators#arguments", + "id": "185-mutators#arguments", "title": "Mutators", "searchTitle": "Arguments", "sectionTitle": "Arguments", @@ -1933,7 +1961,7 @@ "kind": "section" }, { - "id": "183-mutators#reading-data", + "id": "186-mutators#reading-data", "title": "Mutators", "searchTitle": "Reading Data", "sectionTitle": "Reading Data", @@ -1943,7 +1971,7 @@ "kind": "section" }, { - "id": "184-mutators#context", + "id": "187-mutators#context", "title": "Mutators", "searchTitle": "Context", "sectionTitle": "Context", @@ -1953,7 +1981,7 @@ "kind": "section" }, { - "id": "185-mutators#mutator-registries", + "id": "188-mutators#mutator-registries", "title": "Mutators", "searchTitle": "Mutator Registries", "sectionTitle": "Mutator Registries", @@ -1963,7 +1991,7 @@ "kind": "section" }, { - "id": "186-mutators#mutator-names", + "id": "189-mutators#mutator-names", "title": "Mutators", "searchTitle": "Mutator Names", "sectionTitle": "Mutator Names", @@ -1973,7 +2001,7 @@ "kind": "section" }, { - "id": "187-mutators#mutatorsts", + "id": "190-mutators#mutatorsts", "title": "Mutators", "searchTitle": "mutators.ts", "sectionTitle": "mutators.ts", @@ -1983,7 +2011,7 @@ "kind": "section" }, { - "id": "188-mutators#registration", + "id": "191-mutators#registration", "title": "Mutators", "searchTitle": "Registration", "sectionTitle": "Registration", @@ -1993,7 +2021,7 @@ "kind": "section" }, { - "id": "189-mutators#server-setup", + "id": "192-mutators#server-setup", "title": "Mutators", "searchTitle": "Server Setup", "sectionTitle": "Server Setup", @@ -2003,7 +2031,7 @@ "kind": "section" }, { - "id": "190-mutators#registering-the-endpoint", + "id": "193-mutators#registering-the-endpoint", "title": "Mutators", "searchTitle": "Registering the Endpoint", "sectionTitle": "Registering the Endpoint", @@ -2013,7 +2041,7 @@ "kind": "section" }, { - "id": "191-mutators#implementing-the-endpoint", + "id": "194-mutators#implementing-the-endpoint", "title": "Mutators", "searchTitle": "Implementing the Endpoint", "sectionTitle": "Implementing the Endpoint", @@ -2023,7 +2051,7 @@ "kind": "section" }, { - "id": "192-mutators#handling-errors", + "id": "195-mutators#handling-errors", "title": "Mutators", "searchTitle": "Handling Errors", "sectionTitle": "Handling Errors", @@ -2033,7 +2061,7 @@ "kind": "section" }, { - "id": "193-mutators#custom-mutate-url", + "id": "196-mutators#custom-mutate-url", "title": "Mutators", "searchTitle": "Custom Mutate URL", "sectionTitle": "Custom Mutate URL", @@ -2043,7 +2071,7 @@ "kind": "section" }, { - "id": "194-mutators#url-patterns", + "id": "197-mutators#url-patterns", "title": "Mutators", "searchTitle": "URL Patterns", "sectionTitle": "URL Patterns", @@ -2053,7 +2081,7 @@ "kind": "section" }, { - "id": "195-mutators#server-specific-code", + "id": "198-mutators#server-specific-code", "title": "Mutators", "searchTitle": "Server-Specific Code", "sectionTitle": "Server-Specific Code", @@ -2063,7 +2091,7 @@ "kind": "section" }, { - "id": "196-mutators#running-mutators", + "id": "199-mutators#running-mutators", "title": "Mutators", "searchTitle": "Running Mutators", "sectionTitle": "Running Mutators", @@ -2073,7 +2101,7 @@ "kind": "section" }, { - "id": "197-mutators#waiting-for-results", + "id": "200-mutators#waiting-for-results", "title": "Mutators", "searchTitle": "Waiting for Results", "sectionTitle": "Waiting for Results", @@ -2083,7 +2111,7 @@ "kind": "section" }, { - "id": "198-mutators#permissions", + "id": "201-mutators#permissions", "title": "Mutators", "searchTitle": "Permissions", "sectionTitle": "Permissions", @@ -2093,7 +2121,7 @@ "kind": "section" }, { - "id": "199-mutators#dropping-down-to-raw-sql", + "id": "202-mutators#dropping-down-to-raw-sql", "title": "Mutators", "searchTitle": "Dropping Down to Raw SQL", "sectionTitle": "Dropping Down to Raw SQL", @@ -2103,7 +2131,7 @@ "kind": "section" }, { - "id": "200-mutators#notifications-and-async-work", + "id": "203-mutators#notifications-and-async-work", "title": "Mutators", "searchTitle": "Notifications and Async Work", "sectionTitle": "Notifications and Async Work", @@ -2113,7 +2141,7 @@ "kind": "section" }, { - "id": "201-mutators#custom-mutate-implementation", + "id": "204-mutators#custom-mutate-implementation", "title": "Mutators", "searchTitle": "Custom Mutate Implementation", "sectionTitle": "Custom Mutate Implementation", @@ -2137,7 +2165,7 @@ "kind": "page" }, { - "id": "202-open-source#business-model", + "id": "205-open-source#business-model", "title": "Zero is Open Source Software", "searchTitle": "Business Model", "sectionTitle": "Business Model", @@ -2151,7 +2179,7 @@ "title": "Supported Postgres Features", "searchTitle": "Supported Postgres Features", "url": "/docs/postgres-support", - "content": "Postgres has a massive feature set, and Zero supports a growing subset of it. Object Names Table and column names must begin with a letter or underscore This can be followed by letters, numbers, underscores, and hyphens Regex: /^[A-Za-z_]+[A-Za-z0-9_-]*$/ The column name _0_version is reserved for internal use Object Types Tables are synced. Views are not synced. generated as identity columns are synced. In Postgres 18+, generated stored columns are synced. In lower Postgres versions they aren't. Indexes aren't synced per-se, but we do implicitly add indexes to the replica that match the upstream indexes. In the future, this will be customizable. Column Types Postgres Type Type to put in schema.ts Resulting JS/TS Type All numeric types number number char, varchar, text, uuid string string bool boolean boolean date, timestamp, timestampz number number json, jsonb json JSONValue enum enumeration string T[] where T is a supported Postgres type (but please see ⚠️ below) json where U is the schema.ts type for T V[] where V is the JS/TS type for T Zero will sync arrays to the client, but there is no support for filtering or joining on array elements yet in ZQL. Other Postgres column types aren’t supported. They will be ignored when replicating (the synced data will be missing that column) and you will get a warning when zero-cache starts up. If your schema has a pg type not listed here, you can support it in Zero by using a trigger to map it to some type that Zero can support. For example if you have a GIS polygon type in the column my_poly polygon, you can use a trigger to map it to a my_poly_json json column. You could either use another trigger to map in the reverse direction to support changes for writes, or you could use a mutator to write to the polygon type directly on the server. Let us know if the lack of a particular column type is hindering your use of Zero. It can likely be added. Column Defaults Default values are allowed in the Postgres schema, but there currently is no way to use them from a Zero app. An insert() mutation requires all columns to be specified, except when columns are nullable (in which case, they default to null). Since there is no way to leave non-nullable columns off the insert on the client, there is no way for PG to apply the default. This is a known issue and will be fixed in the future. IDs It is strongly recommended to use client-generated random strings like uuid, ulid, nanoid, etc for primary keys. This makes optimistic creation and updates much easier. Imagine that the PK of your table is an auto-incrementing integer. If you optimistically create an entity of this type, you will have to give it some ID – the type will require it locally, but also if you want to optimistically create relationships to this row you’ll need an ID. You could sync the highest value seen for that table, but there are race conditions and it is possible for that ID to be taken by the time the creation makes it to the server. Your database can resolve this and assign the next ID, but now the relationships you created optimistically will be against the wrong row. Blech. GUIDs makes a lot more sense in synced applications. If your table has a natural key you can use that and it has less problems. But there is still the chance for a conflict. Imagine you are modeling orgs and you choose domainName as the natural key. It is possible for a race to happen and when the creation gets to the server, somebody has already chosen that domain name. In that case, the best thing to do is reject the write and show the user an error. If you want to have a short auto-incrementing numeric ID for UX reasons (i.e., a bug number), that is possible - see this video. Primary Keys Each table synced with Zero must have either a primary key or at least one unique index. This is needed so that Zero can identify rows during sync, to distinguish between an edit and a remove/add. Multi-column primary and foreign keys are supported. Limiting Replication There are two levels of replication to consider with Zero: replicating from Postgres to zero-cache, and from zero-cache to the Zero browser client. zero-cache replication By default, Zero creates a Postgres publication that publishes all tables in the public schema to zero-cache. To limit which tables or columns are replicated to zero-cache, you can create a Postgres publication with the tables and columns you want: CREATE PUBLICATION zero_data FOR TABLE users (col1, col2, col3, ...), issues, comments; Then, specify this publication in the App Publications zero-cache option. Browser client replication You can use Read Permissions to control which rows are synced from the zero-cache replica to actual clients (e.g., web browsers). Currently, Permissions can limit which tables and rows can be replicated to the client. In the near future, you'll also be able to use Permissions to limit syncing individual columns. Until then, you will need to create a publication to control which columns are synced to zero-cache. Schema changes Most Postgres schema changes are supported as is. Two cases require special handling: Adding columns Adding a column with a non-constant DEFAULT value is not supported. This includes any expression with parentheses, as well as the special functions CURRENT_TIME, CURRENT_DATE, and CURRENT_TIMESTAMP (due to a constraint of SQLite). However, the DEFAULT value of an existing column can be changed to any value, including non-constant expressions. To achieve the desired column default: Add the column with no DEFAULT value Backfill the column with desired values Set the column's DEFAULT value BEGIN; ALTER TABLE foo ADD bar ...; -- without a DEFAULT value UPDATE foo SET bar = ...; ALTER TABLE foo ALTER bar SET DEFAULT ...; COMMIT; Changing publications Postgres allows you to change published tables/columns with an ALTER PUBLICATION statement. Zero automatically adjusts the table schemas on the replica, but it does not receive the pre-existing data. To stream the pre-existing data to Zero, make an innocuous UPDATE after adding the tables/columns to the publication: BEGIN; ALTER PUBLICATION zero_data ADD TABLE foo; ALTER TABLE foo REPLICA IDENTITY FULL; UPDATE foo SET id = id; -- For some column \"id\" in \"foo\" ALTER TABLE foo REPLICA IDENTITY DEFAULT; COMMIT; Self-Referential Relationships See schema", + "content": "Postgres has a massive feature set, and Zero supports a growing subset of it. Object Names Table and column names must begin with a letter or underscore This can be followed by letters, numbers, underscores, and hyphens Regex: /^[A-Za-z_]+[A-Za-z0-9_-]*$/ The column name _0_version is reserved for internal use Object Types Tables are synced. Views are not synced. generated as identity columns are synced. In Postgres 18+, generated stored columns are synced. In lower Postgres versions they aren't. Indexes aren't synced per-se, but we do implicitly add indexes to the replica that match the upstream indexes. In the future, this will be customizable. Column Types Postgres Type Type to put in schema.ts Resulting JS/TS Type All numeric types number number char, varchar, text, uuid string string bool boolean boolean date, timestamp, timestampz, time, timetz number number json, jsonb json JSONValue enum enumeration string T[] where T is a supported Postgres type (but please see ⚠️ below) json where U is the schema.ts type for T V[] where V is the JS/TS type for T Zero will sync arrays to the client, but there is no support for filtering or joining on array elements yet in ZQL. Other Postgres column types aren’t supported. They will be ignored when replicating (the synced data will be missing that column) and you will get a warning when zero-cache starts up. If your schema has a pg type not listed here, you can support it in Zero by using a trigger to map it to some type that Zero can support. For example if you have a GIS polygon type in the column my_poly polygon, you can use a trigger to map it to a my_poly_json json column. You could either use another trigger to map in the reverse direction to support changes for writes, or you could use a mutator to write to the polygon type directly on the server. Let us know if the lack of a particular column type is hindering your use of Zero. It can likely be added. Column Defaults Default values are allowed in the Postgres schema, but there currently is no way to use them from a Zero app. An insert() mutation requires all columns to be specified, except when columns are nullable (in which case, they default to null). Since there is no way to leave non-nullable columns off the insert on the client, there is no way for PG to apply the default. This is a known issue and will be fixed in the future. IDs It is strongly recommended to use client-generated random strings like uuid, ulid, nanoid, etc for primary keys. This makes optimistic creation and updates much easier. Imagine that the PK of your table is an auto-incrementing integer. If you optimistically create an entity of this type, you will have to give it some ID – the type will require it locally, but also if you want to optimistically create relationships to this row you’ll need an ID. You could sync the highest value seen for that table, but there are race conditions and it is possible for that ID to be taken by the time the creation makes it to the server. Your database can resolve this and assign the next ID, but now the relationships you created optimistically will be against the wrong row. Blech. GUIDs makes a lot more sense in synced applications. If your table has a natural key you can use that and it has less problems. But there is still the chance for a conflict. Imagine you are modeling orgs and you choose domainName as the natural key. It is possible for a race to happen and when the creation gets to the server, somebody has already chosen that domain name. In that case, the best thing to do is reject the write and show the user an error. If you want to have a short auto-incrementing numeric ID for UX reasons (i.e., a bug number), that is possible - see this video. Primary Keys Each table synced with Zero must have either a primary key or at least one unique index. This is needed so that Zero can identify rows during sync, to distinguish between an edit and a remove/add. Multi-column primary and foreign keys are supported. Limiting Replication There are two levels of replication to consider with Zero: replicating from Postgres to zero-cache, and from zero-cache to the Zero browser client. zero-cache replication By default, Zero creates a Postgres publication that publishes all tables in the public schema to zero-cache. To limit which tables or columns are replicated to zero-cache, you can create a Postgres publication with the tables and columns you want: CREATE PUBLICATION zero_data FOR TABLE users (col1, col2, col3, ...), issues, comments; Then, specify this publication in the App Publications zero-cache option. Browser client replication You can use Read Permissions to control which rows are synced from the zero-cache replica to actual clients (e.g., web browsers). Currently, Permissions can limit which tables and rows can be replicated to the client. In the near future, you'll also be able to use Permissions to limit syncing individual columns. Until then, you will need to create a publication to control which columns are synced to zero-cache. Schema changes All Postgres schema changes are supported. See Schema Migrations.", "headings": [ { "text": "Object Names", @@ -2192,24 +2220,12 @@ { "text": "Schema changes", "id": "schema-changes" - }, - { - "text": "Adding columns", - "id": "adding-columns" - }, - { - "text": "Changing publications", - "id": "changing-publications" - }, - { - "text": "Self-Referential Relationships", - "id": "self-referential-relationships" } ], "kind": "page" }, { - "id": "203-postgres-support#object-names", + "id": "206-postgres-support#object-names", "title": "Supported Postgres Features", "searchTitle": "Object Names", "sectionTitle": "Object Names", @@ -2219,7 +2235,7 @@ "kind": "section" }, { - "id": "204-postgres-support#object-types", + "id": "207-postgres-support#object-types", "title": "Supported Postgres Features", "searchTitle": "Object Types", "sectionTitle": "Object Types", @@ -2229,17 +2245,17 @@ "kind": "section" }, { - "id": "205-postgres-support#column-types", + "id": "208-postgres-support#column-types", "title": "Supported Postgres Features", "searchTitle": "Column Types", "sectionTitle": "Column Types", "sectionId": "column-types", "url": "/docs/postgres-support", - "content": "Postgres Type Type to put in schema.ts Resulting JS/TS Type All numeric types number number char, varchar, text, uuid string string bool boolean boolean date, timestamp, timestampz number number json, jsonb json JSONValue enum enumeration string T[] where T is a supported Postgres type (but please see ⚠️ below) json where U is the schema.ts type for T V[] where V is the JS/TS type for T Zero will sync arrays to the client, but there is no support for filtering or joining on array elements yet in ZQL. Other Postgres column types aren’t supported. They will be ignored when replicating (the synced data will be missing that column) and you will get a warning when zero-cache starts up. If your schema has a pg type not listed here, you can support it in Zero by using a trigger to map it to some type that Zero can support. For example if you have a GIS polygon type in the column my_poly polygon, you can use a trigger to map it to a my_poly_json json column. You could either use another trigger to map in the reverse direction to support changes for writes, or you could use a mutator to write to the polygon type directly on the server. Let us know if the lack of a particular column type is hindering your use of Zero. It can likely be added.", + "content": "Postgres Type Type to put in schema.ts Resulting JS/TS Type All numeric types number number char, varchar, text, uuid string string bool boolean boolean date, timestamp, timestampz, time, timetz number number json, jsonb json JSONValue enum enumeration string T[] where T is a supported Postgres type (but please see ⚠️ below) json where U is the schema.ts type for T V[] where V is the JS/TS type for T Zero will sync arrays to the client, but there is no support for filtering or joining on array elements yet in ZQL. Other Postgres column types aren’t supported. They will be ignored when replicating (the synced data will be missing that column) and you will get a warning when zero-cache starts up. If your schema has a pg type not listed here, you can support it in Zero by using a trigger to map it to some type that Zero can support. For example if you have a GIS polygon type in the column my_poly polygon, you can use a trigger to map it to a my_poly_json json column. You could either use another trigger to map in the reverse direction to support changes for writes, or you could use a mutator to write to the polygon type directly on the server. Let us know if the lack of a particular column type is hindering your use of Zero. It can likely be added.", "kind": "section" }, { - "id": "206-postgres-support#column-defaults", + "id": "209-postgres-support#column-defaults", "title": "Supported Postgres Features", "searchTitle": "Column Defaults", "sectionTitle": "Column Defaults", @@ -2249,7 +2265,7 @@ "kind": "section" }, { - "id": "207-postgres-support#ids", + "id": "210-postgres-support#ids", "title": "Supported Postgres Features", "searchTitle": "IDs", "sectionTitle": "IDs", @@ -2259,7 +2275,7 @@ "kind": "section" }, { - "id": "208-postgres-support#primary-keys", + "id": "211-postgres-support#primary-keys", "title": "Supported Postgres Features", "searchTitle": "Primary Keys", "sectionTitle": "Primary Keys", @@ -2269,7 +2285,7 @@ "kind": "section" }, { - "id": "209-postgres-support#limiting-replication", + "id": "212-postgres-support#limiting-replication", "title": "Supported Postgres Features", "searchTitle": "Limiting Replication", "sectionTitle": "Limiting Replication", @@ -2279,7 +2295,7 @@ "kind": "section" }, { - "id": "210-postgres-support#zero-cache-replication", + "id": "213-postgres-support#zero-cache-replication", "title": "Supported Postgres Features", "searchTitle": "zero-cache replication", "sectionTitle": "zero-cache replication", @@ -2289,7 +2305,7 @@ "kind": "section" }, { - "id": "211-postgres-support#browser-client-replication", + "id": "214-postgres-support#browser-client-replication", "title": "Supported Postgres Features", "searchTitle": "Browser client replication", "sectionTitle": "Browser client replication", @@ -2299,43 +2315,13 @@ "kind": "section" }, { - "id": "212-postgres-support#schema-changes", + "id": "215-postgres-support#schema-changes", "title": "Supported Postgres Features", "searchTitle": "Schema changes", "sectionTitle": "Schema changes", "sectionId": "schema-changes", "url": "/docs/postgres-support", - "content": "Most Postgres schema changes are supported as is. Two cases require special handling: Adding columns Adding a column with a non-constant DEFAULT value is not supported. This includes any expression with parentheses, as well as the special functions CURRENT_TIME, CURRENT_DATE, and CURRENT_TIMESTAMP (due to a constraint of SQLite). However, the DEFAULT value of an existing column can be changed to any value, including non-constant expressions. To achieve the desired column default: Add the column with no DEFAULT value Backfill the column with desired values Set the column's DEFAULT value BEGIN; ALTER TABLE foo ADD bar ...; -- without a DEFAULT value UPDATE foo SET bar = ...; ALTER TABLE foo ALTER bar SET DEFAULT ...; COMMIT; Changing publications Postgres allows you to change published tables/columns with an ALTER PUBLICATION statement. Zero automatically adjusts the table schemas on the replica, but it does not receive the pre-existing data. To stream the pre-existing data to Zero, make an innocuous UPDATE after adding the tables/columns to the publication: BEGIN; ALTER PUBLICATION zero_data ADD TABLE foo; ALTER TABLE foo REPLICA IDENTITY FULL; UPDATE foo SET id = id; -- For some column \"id\" in \"foo\" ALTER TABLE foo REPLICA IDENTITY DEFAULT; COMMIT;", - "kind": "section" - }, - { - "id": "213-postgres-support#adding-columns", - "title": "Supported Postgres Features", - "searchTitle": "Adding columns", - "sectionTitle": "Adding columns", - "sectionId": "adding-columns", - "url": "/docs/postgres-support", - "content": "Adding a column with a non-constant DEFAULT value is not supported. This includes any expression with parentheses, as well as the special functions CURRENT_TIME, CURRENT_DATE, and CURRENT_TIMESTAMP (due to a constraint of SQLite). However, the DEFAULT value of an existing column can be changed to any value, including non-constant expressions. To achieve the desired column default: Add the column with no DEFAULT value Backfill the column with desired values Set the column's DEFAULT value BEGIN; ALTER TABLE foo ADD bar ...; -- without a DEFAULT value UPDATE foo SET bar = ...; ALTER TABLE foo ALTER bar SET DEFAULT ...; COMMIT;", - "kind": "section" - }, - { - "id": "214-postgres-support#changing-publications", - "title": "Supported Postgres Features", - "searchTitle": "Changing publications", - "sectionTitle": "Changing publications", - "sectionId": "changing-publications", - "url": "/docs/postgres-support", - "content": "Postgres allows you to change published tables/columns with an ALTER PUBLICATION statement. Zero automatically adjusts the table schemas on the replica, but it does not receive the pre-existing data. To stream the pre-existing data to Zero, make an innocuous UPDATE after adding the tables/columns to the publication: BEGIN; ALTER PUBLICATION zero_data ADD TABLE foo; ALTER TABLE foo REPLICA IDENTITY FULL; UPDATE foo SET id = id; -- For some column \"id\" in \"foo\" ALTER TABLE foo REPLICA IDENTITY DEFAULT; COMMIT;", - "kind": "section" - }, - { - "id": "215-postgres-support#self-referential-relationships", - "title": "Supported Postgres Features", - "searchTitle": "Self-Referential Relationships", - "sectionTitle": "Self-Referential Relationships", - "sectionId": "self-referential-relationships", - "url": "/docs/postgres-support", - "content": "See schema", + "content": "All Postgres schema changes are supported. See Schema Migrations.", "kind": "section" }, { @@ -4402,7 +4388,73 @@ "kind": "section" }, { - "id": "43-release-notes/0.3", + "id": "43-release-notes/0.26", + "title": "Zero 0.26", + "searchTitle": "Zero 0.26", + "url": "/docs/release-notes/0.26", + "content": "Installation npm install @rocicorp/zero@0.26 Features Schema Backfill: Zero now natively supports adding columns with non-constant defaults, or adding existing columns to a custom publication. There is no longer a need to touch every row in the database to backfill data manually. Scalar Subqueries: Added a new optional optimization for exists queries that filter by related data with a one-to-one relationship. This can improve query performance significantly for cases where the subquery result changes rarely. Virtual Scroll: Added zero-virtual library for efficient infinite scrolling in React. Support for time and timetz columns: Added support for time and timetz columns (thanks GRBurst). Comparing to undefined: Added a new convenience for comparing to undefined. This is useful for filtering by nullable fields. zero.delete(): Added a more convenient way to delete all data from the browser's IndexedDB database. Fixes Updating auth tokens no cycles connection Add configurable back-pressure limit for change streamer Surface replication LSN at /statz?replication Generalize all statz output to be machine-readable Fix unique constraint violations during sync Fix zero-sqlite3 install script to work with bun Warn when mutations are rejected due to offline/error/closed state Fix queries being dropped when other queries with same hash expire Fix incorrect query results when filtering by undefined values Fix connection.connect() being ignored when in error/auth state Extra table name validation Redact sensitive info from logs Do not log mutation args Allow validation of JWT issuer and audience claims Protect against admin password timing attacks Forward origin header to API server Escape publication names Prevent denial of service attacks Fix race condition in notification handling Fix custom kvStoreProvider being ignored in React Native (thanks @kvnkusch!) Allow hybrid permissions with new queries in zero-cache-dev Fix unnecessary reconnection attempts after auth errors Fix \"database connection is not open\" error during shutdown Fix view-syncer crash from stale WebSocket messages Forward fresh auth token with each push message (thanks @jbingen!) Prevent IDB collection from deleting active databases Fix statz double close and ensure sqlite db handles are closed Fix OOM crashes when replicating databases with large rows Fix parsing of json[] and jsonb[] types Fix slow queries on tables with nullable unique columns Fix replication crash from closed WebSocket (thanks @utkarsh-611!) Fix notifications not working in background browser tabs Log WebSocket close code 1006 as info, not error (thanks @jbingen!) Fix replication deadlock on Storer crash (thanks @utkarsh-611!) Downgrade IDBNotFoundError to info log level (thanks @jbingen!) Fix type safety for default context/schema types Fix watermark errors when restoring from litestream backups Fix replica metadata being deleted during non-disruptive resync Fix occasional server crash when Postgres goes down Set idle transaction timeout to prevent hung transactions Fix lock errors under high CDC load Retry on change-db connection errors Detect publication differences during initial sync Prevent concurrent schema migrations from racing Fix stale destroy timers on query resubscribe (thanks @alpkaravil!) Add missing @types/ws dependency (thanks @YevheniiKotyrlo!) Fix constraints not detected when definition changes but name stays same Fix duplicate mutations when retry transaction succeeds Fix analyze-query crash with scalar subqueries Detect tables moved out of published schema Breaking Changes Just two very minor breaking changes this time, that are unlikely to affect most users: If you send custom headers to query or mutate endpoints, you must now allowlist them via ZERO_MUTATE_ALLOWED_CLIENT_HEADERS or ZERO_QUERY_ALLOWED_CLIENT_HEADERS. By default, no client-provided headers are forwarded. If you have very large mutations, note that WebSocket messages are now limited to 10MB by default. Increase ZERO_WEBSOCKET_MAX_PAYLOAD_BYTES if needed.", + "headings": [ + { + "text": "Installation", + "id": "installation" + }, + { + "text": "Features", + "id": "features" + }, + { + "text": "Fixes", + "id": "fixes" + }, + { + "text": "Breaking Changes", + "id": "breaking-changes" + } + ], + "kind": "page" + }, + { + "id": "347-release-notes/0.26#installation", + "title": "Zero 0.26", + "searchTitle": "Installation", + "sectionTitle": "Installation", + "sectionId": "installation", + "url": "/docs/release-notes/0.26", + "content": "npm install @rocicorp/zero@0.26", + "kind": "section" + }, + { + "id": "348-release-notes/0.26#features", + "title": "Zero 0.26", + "searchTitle": "Features", + "sectionTitle": "Features", + "sectionId": "features", + "url": "/docs/release-notes/0.26", + "content": "Schema Backfill: Zero now natively supports adding columns with non-constant defaults, or adding existing columns to a custom publication. There is no longer a need to touch every row in the database to backfill data manually. Scalar Subqueries: Added a new optional optimization for exists queries that filter by related data with a one-to-one relationship. This can improve query performance significantly for cases where the subquery result changes rarely. Virtual Scroll: Added zero-virtual library for efficient infinite scrolling in React. Support for time and timetz columns: Added support for time and timetz columns (thanks GRBurst). Comparing to undefined: Added a new convenience for comparing to undefined. This is useful for filtering by nullable fields. zero.delete(): Added a more convenient way to delete all data from the browser's IndexedDB database.", + "kind": "section" + }, + { + "id": "349-release-notes/0.26#fixes", + "title": "Zero 0.26", + "searchTitle": "Fixes", + "sectionTitle": "Fixes", + "sectionId": "fixes", + "url": "/docs/release-notes/0.26", + "content": "Updating auth tokens no cycles connection Add configurable back-pressure limit for change streamer Surface replication LSN at /statz?replication Generalize all statz output to be machine-readable Fix unique constraint violations during sync Fix zero-sqlite3 install script to work with bun Warn when mutations are rejected due to offline/error/closed state Fix queries being dropped when other queries with same hash expire Fix incorrect query results when filtering by undefined values Fix connection.connect() being ignored when in error/auth state Extra table name validation Redact sensitive info from logs Do not log mutation args Allow validation of JWT issuer and audience claims Protect against admin password timing attacks Forward origin header to API server Escape publication names Prevent denial of service attacks Fix race condition in notification handling Fix custom kvStoreProvider being ignored in React Native (thanks @kvnkusch!) Allow hybrid permissions with new queries in zero-cache-dev Fix unnecessary reconnection attempts after auth errors Fix \"database connection is not open\" error during shutdown Fix view-syncer crash from stale WebSocket messages Forward fresh auth token with each push message (thanks @jbingen!) Prevent IDB collection from deleting active databases Fix statz double close and ensure sqlite db handles are closed Fix OOM crashes when replicating databases with large rows Fix parsing of json[] and jsonb[] types Fix slow queries on tables with nullable unique columns Fix replication crash from closed WebSocket (thanks @utkarsh-611!) Fix notifications not working in background browser tabs Log WebSocket close code 1006 as info, not error (thanks @jbingen!) Fix replication deadlock on Storer crash (thanks @utkarsh-611!) Downgrade IDBNotFoundError to info log level (thanks @jbingen!) Fix type safety for default context/schema types Fix watermark errors when restoring from litestream backups Fix replica metadata being deleted during non-disruptive resync Fix occasional server crash when Postgres goes down Set idle transaction timeout to prevent hung transactions Fix lock errors under high CDC load Retry on change-db connection errors Detect publication differences during initial sync Prevent concurrent schema migrations from racing Fix stale destroy timers on query resubscribe (thanks @alpkaravil!) Add missing @types/ws dependency (thanks @YevheniiKotyrlo!) Fix constraints not detected when definition changes but name stays same Fix duplicate mutations when retry transaction succeeds Fix analyze-query crash with scalar subqueries Detect tables moved out of published schema", + "kind": "section" + }, + { + "id": "350-release-notes/0.26#breaking-changes", + "title": "Zero 0.26", + "searchTitle": "Breaking Changes", + "sectionTitle": "Breaking Changes", + "sectionId": "breaking-changes", + "url": "/docs/release-notes/0.26", + "content": "Just two very minor breaking changes this time, that are unlikely to affect most users: If you send custom headers to query or mutate endpoints, you must now allowlist them via ZERO_MUTATE_ALLOWED_CLIENT_HEADERS or ZERO_QUERY_ALLOWED_CLIENT_HEADERS. By default, no client-provided headers are forwarded. If you have very large mutations, note that WebSocket messages are now limited to 10MB by default. Increase ZERO_WEBSOCKET_MAX_PAYLOAD_BYTES if needed.", + "kind": "section" + }, + { + "id": "44-release-notes/0.3", "title": "Zero 0.3", "searchTitle": "Zero 0.3", "url": "/docs/release-notes/0.3", @@ -4436,7 +4488,7 @@ "kind": "page" }, { - "id": "347-release-notes/0.3#install", + "id": "351-release-notes/0.3#install", "title": "Zero 0.3", "searchTitle": "Install", "sectionTitle": "Install", @@ -4446,7 +4498,7 @@ "kind": "section" }, { - "id": "348-release-notes/0.3#breaking-changes", + "id": "352-release-notes/0.3#breaking-changes", "title": "Zero 0.3", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -4456,7 +4508,7 @@ "kind": "section" }, { - "id": "349-release-notes/0.3#features", + "id": "353-release-notes/0.3#features", "title": "Zero 0.3", "searchTitle": "Features", "sectionTitle": "Features", @@ -4466,7 +4518,7 @@ "kind": "section" }, { - "id": "350-release-notes/0.3#fixes", + "id": "354-release-notes/0.3#fixes", "title": "Zero 0.3", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4476,7 +4528,7 @@ "kind": "section" }, { - "id": "351-release-notes/0.3#docs", + "id": "355-release-notes/0.3#docs", "title": "Zero 0.3", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4486,7 +4538,7 @@ "kind": "section" }, { - "id": "352-release-notes/0.3#zbugs", + "id": "356-release-notes/0.3#zbugs", "title": "Zero 0.3", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4496,7 +4548,7 @@ "kind": "section" }, { - "id": "44-release-notes/0.4", + "id": "45-release-notes/0.4", "title": "Zero 0.4", "searchTitle": "Zero 0.4", "url": "/docs/release-notes/0.4", @@ -4530,7 +4582,7 @@ "kind": "page" }, { - "id": "353-release-notes/0.4#install", + "id": "357-release-notes/0.4#install", "title": "Zero 0.4", "searchTitle": "Install", "sectionTitle": "Install", @@ -4540,7 +4592,7 @@ "kind": "section" }, { - "id": "354-release-notes/0.4#breaking-changes", + "id": "358-release-notes/0.4#breaking-changes", "title": "Zero 0.4", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -4550,7 +4602,7 @@ "kind": "section" }, { - "id": "355-release-notes/0.4#added-or--and--and-not-to-zql-documentation", + "id": "359-release-notes/0.4#added-or--and--and-not-to-zql-documentation", "title": "Zero 0.4", "searchTitle": "Added or , and , and not to ZQL (documentation).", "sectionTitle": "Added or , and , and not to ZQL (documentation).", @@ -4560,7 +4612,7 @@ "kind": "section" }, { - "id": "356-release-notes/0.4#fixes", + "id": "360-release-notes/0.4#fixes", "title": "Zero 0.4", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4570,7 +4622,7 @@ "kind": "section" }, { - "id": "357-release-notes/0.4#docs", + "id": "361-release-notes/0.4#docs", "title": "Zero 0.4", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4580,7 +4632,7 @@ "kind": "section" }, { - "id": "358-release-notes/0.4#zbugs", + "id": "362-release-notes/0.4#zbugs", "title": "Zero 0.4", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4590,7 +4642,7 @@ "kind": "section" }, { - "id": "45-release-notes/0.5", + "id": "46-release-notes/0.5", "title": "Zero 0.5", "searchTitle": "Zero 0.5", "url": "/docs/release-notes/0.5", @@ -4624,7 +4676,7 @@ "kind": "page" }, { - "id": "359-release-notes/0.5#install", + "id": "363-release-notes/0.5#install", "title": "Zero 0.5", "searchTitle": "Install", "sectionTitle": "Install", @@ -4634,7 +4686,7 @@ "kind": "section" }, { - "id": "360-release-notes/0.5#breaking-changes", + "id": "364-release-notes/0.5#breaking-changes", "title": "Zero 0.5", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -4644,7 +4696,7 @@ "kind": "section" }, { - "id": "361-release-notes/0.5#features", + "id": "365-release-notes/0.5#features", "title": "Zero 0.5", "searchTitle": "Features", "sectionTitle": "Features", @@ -4654,7 +4706,7 @@ "kind": "section" }, { - "id": "362-release-notes/0.5#fixes", + "id": "366-release-notes/0.5#fixes", "title": "Zero 0.5", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4664,7 +4716,7 @@ "kind": "section" }, { - "id": "363-release-notes/0.5#docs", + "id": "367-release-notes/0.5#docs", "title": "Zero 0.5", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4674,7 +4726,7 @@ "kind": "section" }, { - "id": "364-release-notes/0.5#zbugs", + "id": "368-release-notes/0.5#zbugs", "title": "Zero 0.5", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4684,7 +4736,7 @@ "kind": "section" }, { - "id": "46-release-notes/0.6", + "id": "47-release-notes/0.6", "title": "Zero 0.6", "searchTitle": "Zero 0.6", "url": "/docs/release-notes/0.6", @@ -4718,7 +4770,7 @@ "kind": "page" }, { - "id": "365-release-notes/0.6#install", + "id": "369-release-notes/0.6#install", "title": "Zero 0.6", "searchTitle": "Install", "sectionTitle": "Install", @@ -4728,7 +4780,7 @@ "kind": "section" }, { - "id": "366-release-notes/0.6#upgrade-guide", + "id": "370-release-notes/0.6#upgrade-guide", "title": "Zero 0.6", "searchTitle": "Upgrade Guide", "sectionTitle": "Upgrade Guide", @@ -4738,7 +4790,7 @@ "kind": "section" }, { - "id": "367-release-notes/0.6#breaking-changes", + "id": "371-release-notes/0.6#breaking-changes", "title": "Zero 0.6", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4748,7 +4800,7 @@ "kind": "section" }, { - "id": "368-release-notes/0.6#features", + "id": "372-release-notes/0.6#features", "title": "Zero 0.6", "searchTitle": "Features", "sectionTitle": "Features", @@ -4758,7 +4810,7 @@ "kind": "section" }, { - "id": "369-release-notes/0.6#zbugs", + "id": "373-release-notes/0.6#zbugs", "title": "Zero 0.6", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4768,7 +4820,7 @@ "kind": "section" }, { - "id": "370-release-notes/0.6#docs", + "id": "374-release-notes/0.6#docs", "title": "Zero 0.6", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4778,7 +4830,7 @@ "kind": "section" }, { - "id": "47-release-notes/0.7", + "id": "48-release-notes/0.7", "title": "Zero 0.7", "searchTitle": "Zero 0.7", "url": "/docs/release-notes/0.7", @@ -4808,7 +4860,7 @@ "kind": "page" }, { - "id": "371-release-notes/0.7#install", + "id": "375-release-notes/0.7#install", "title": "Zero 0.7", "searchTitle": "Install", "sectionTitle": "Install", @@ -4818,7 +4870,7 @@ "kind": "section" }, { - "id": "372-release-notes/0.7#features", + "id": "376-release-notes/0.7#features", "title": "Zero 0.7", "searchTitle": "Features", "sectionTitle": "Features", @@ -4828,7 +4880,7 @@ "kind": "section" }, { - "id": "373-release-notes/0.7#breaking-changes", + "id": "377-release-notes/0.7#breaking-changes", "title": "Zero 0.7", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4838,7 +4890,7 @@ "kind": "section" }, { - "id": "374-release-notes/0.7#zbugs", + "id": "378-release-notes/0.7#zbugs", "title": "Zero 0.7", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4848,7 +4900,7 @@ "kind": "section" }, { - "id": "375-release-notes/0.7#docs", + "id": "379-release-notes/0.7#docs", "title": "Zero 0.7", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4858,7 +4910,7 @@ "kind": "section" }, { - "id": "48-release-notes/0.8", + "id": "49-release-notes/0.8", "title": "Zero 0.8", "searchTitle": "Zero 0.8", "url": "/docs/release-notes/0.8", @@ -4884,7 +4936,7 @@ "kind": "page" }, { - "id": "376-release-notes/0.8#install", + "id": "380-release-notes/0.8#install", "title": "Zero 0.8", "searchTitle": "Install", "sectionTitle": "Install", @@ -4894,7 +4946,7 @@ "kind": "section" }, { - "id": "377-release-notes/0.8#features", + "id": "381-release-notes/0.8#features", "title": "Zero 0.8", "searchTitle": "Features", "sectionTitle": "Features", @@ -4904,7 +4956,7 @@ "kind": "section" }, { - "id": "378-release-notes/0.8#fixes", + "id": "382-release-notes/0.8#fixes", "title": "Zero 0.8", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4914,7 +4966,7 @@ "kind": "section" }, { - "id": "379-release-notes/0.8#breaking-changes", + "id": "383-release-notes/0.8#breaking-changes", "title": "Zero 0.8", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4924,7 +4976,7 @@ "kind": "section" }, { - "id": "49-release-notes/0.9", + "id": "50-release-notes/0.9", "title": "Zero 0.9", "searchTitle": "Zero 0.9", "url": "/docs/release-notes/0.9", @@ -4950,7 +5002,7 @@ "kind": "page" }, { - "id": "380-release-notes/0.9#install", + "id": "384-release-notes/0.9#install", "title": "Zero 0.9", "searchTitle": "Install", "sectionTitle": "Install", @@ -4960,7 +5012,7 @@ "kind": "section" }, { - "id": "381-release-notes/0.9#features", + "id": "385-release-notes/0.9#features", "title": "Zero 0.9", "searchTitle": "Features", "sectionTitle": "Features", @@ -4970,7 +5022,7 @@ "kind": "section" }, { - "id": "382-release-notes/0.9#fixes", + "id": "386-release-notes/0.9#fixes", "title": "Zero 0.9", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4980,7 +5032,7 @@ "kind": "section" }, { - "id": "383-release-notes/0.9#breaking-changes", + "id": "387-release-notes/0.9#breaking-changes", "title": "Zero 0.9", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4990,16 +5042,16 @@ "kind": "section" }, { - "id": "50-release-notes", + "id": "51-release-notes", "title": "Release Notes", "searchTitle": "Release Notes", "url": "/docs/release-notes", - "content": "Zero 0.25: DX Overhaul, Query Planning Zero 0.24: Join Flipping, Cookie Auth, Inspector Updates Zero 0.23: Synced Queries and React Native Support Zero 0.22: Simplified TTLs Zero 0.21: PG arrays, TanStack starter, and more Zero 0.20: Full Supabase support, performance improvements Zero 0.19: Many, many bugfixes and cleanups Zero 0.18: Custom Mutators Zero 0.17: Background Queries Zero 0.16: Lambda-Based Permission Deployment Zero 0.15: Live Permission Updates Zero 0.14: Name Mapping and Multischema Zero 0.13: Multinode and SST Zero 0.12: Circular Relationships Zero 0.11: Windows Zero 0.10: Remove Top-Level Await Zero 0.9: JWK Support Zero 0.8: Schema Autobuild, Result Types, and Enums Zero 0.7: Read Perms and Docker Zero 0.6: Relationship Filters Zero 0.5: JSON Columns Zero 0.4: Compound Filters Zero 0.3: Schema Migrations and Write Perms Zero 0.2: Skip Mode and Computed PKs Zero 0.1: First Release", + "content": "Zero 0.26: Schema Backfill and Scalar Subqueries Zero 0.25: DX Overhaul, Query Planning Zero 0.24: Join Flipping, Cookie Auth, Inspector Updates Zero 0.23: Synced Queries and React Native Support Zero 0.22: Simplified TTLs Zero 0.21: PG arrays, TanStack starter, and more Zero 0.20: Full Supabase support, performance improvements Zero 0.19: Many, many bugfixes and cleanups Zero 0.18: Custom Mutators Zero 0.17: Background Queries Zero 0.16: Lambda-Based Permission Deployment Zero 0.15: Live Permission Updates Zero 0.14: Name Mapping and Multischema Zero 0.13: Multinode and SST Zero 0.12: Circular Relationships Zero 0.11: Windows Zero 0.10: Remove Top-Level Await Zero 0.9: JWK Support Zero 0.8: Schema Autobuild, Result Types, and Enums Zero 0.7: Read Perms and Docker Zero 0.6: Relationship Filters Zero 0.5: JSON Columns Zero 0.4: Compound Filters Zero 0.3: Schema Migrations and Write Perms Zero 0.2: Skip Mode and Computed PKs Zero 0.1: First Release", "headings": [], "kind": "page" }, { - "id": "51-reporting-bugs", + "id": "52-reporting-bugs", "title": "Reporting Bugs", "searchTitle": "Reporting Bugs", "url": "/docs/reporting-bugs", @@ -5017,7 +5069,7 @@ "kind": "page" }, { - "id": "384-reporting-bugs#zbugs", + "id": "388-reporting-bugs#zbugs", "title": "Reporting Bugs", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -5027,7 +5079,7 @@ "kind": "section" }, { - "id": "385-reporting-bugs#discord", + "id": "389-reporting-bugs#discord", "title": "Reporting Bugs", "searchTitle": "Discord", "sectionTitle": "Discord", @@ -5037,11 +5089,11 @@ "kind": "section" }, { - "id": "52-rest-apis", - "title": "REST APIs", - "searchTitle": "REST APIs", - "url": "/docs/rest-apis", - "content": "If you need a traditional REST surface (for webhooks, third-party integrations, CLI tools, etc), you can generate one from your Zero mutator registry. This is optional. Zero clients do not use this API. They still use zero.mutate(...) and your ZERO_MUTATE_URL endpoint. Pattern The standard pattern is: Keep mutators as the source of truth. Add a server route that maps REST paths to mutator names. Look up the mutator with mustGetMutator and execute mutator.fn(...). Reuse the same validator schemas for docs generation (OpenAPI). For example: POST /api/mutators/cart/add maps to mutator name cart.add POST /api/mutators/cart/remove maps to mutator name cart.remove Use POST for mutators, since they are commands and can have side effects. TanStack Start Example // app/routes/api/mutators/$.ts import {createServerFileRoute} from '@tanstack/react-start/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'zero/mutators' export const ServerRoute = createServerFileRoute( '/api/mutators/$' ).methods({ POST: async ({params, request}) => { const name = params._splat?.split('/').join('.') if (!name) { return Response.json( {error: 'Mutator name required'}, {status: 400} ) } const args = await request.json() const mutator = mustGetMutator(mutators, name) await dbProvider.transaction(async tx => { await mutator.fn({ tx, ctx: {userId: '...'}, args }) }) return Response.json({ok: true}) } }) OpenAPI Generation For API discovery, expose an OpenAPI document (for example /api/openapi.json) generated from your mutator registry. Typical setup: discover mutator names at runtime generate one POST operation per mutator path include request/response schemas serve Swagger UI from /api/docs defineMutators() returns callable mutators, but does not expose validator schemas on the resulting registry object. If you want schema-driven docs, export your validator map separately and reuse those schema objects in defineMutator(...). Full Working Example See the ztunes sample for a full implementation: Source: https://github.com/rocicorp/ztunes REST endpoint pattern: app/routes/api/mutators/$.ts OpenAPI generation: app/routes/api/openapi[.]json.ts Swagger docs route: app/routes/api/docs.ts", + "id": "53-rest", + "title": "REST", + "searchTitle": "REST", + "url": "/docs/rest", + "content": "If you need a traditional REST surface (for webhooks, third-party integrations, CLI tools, etc), you can easily generate one from your Zero mutator registry without having to duplicate any code. This is optional. Zero clients do not use this API. They still use zero.mutate(...) and your ZERO_MUTATE_URL endpoint. Pattern Keep mutators as the source of truth. Add a server route that maps REST paths to mutator names. Look up the mutator with mustGetMutator and execute mutator.fn(...). Reuse the same validator schemas for docs generation (OpenAPI). For example: POST /api/mutators/cart/add maps to mutator name cart.add POST /api/mutators/cart/remove maps to mutator name cart.remove This pattern works nicely because Zero mutators have more requirements than regular APIs. Namely they require an open transaction to be passed in. So it's easier to generate REST APIs from mutators than the reverse. TanStack Start Example // app/routes/api/mutators/$.ts import {createServerFileRoute} from '@tanstack/react-start/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'zero/mutators' export const ServerRoute = createServerFileRoute( '/api/mutators/$' ).methods({ POST: async ({params, request}) => { const name = params._splat?.split('/').join('.') if (!name) { return Response.json( {error: 'Mutator name required'}, {status: 400} ) } const args = await request.json() const mutator = mustGetMutator(mutators, name) await dbProvider.transaction(async tx => { await mutator.fn({ tx, ctx: {userId: '...'}, args }) }) return Response.json({ok: true}) } }) OpenAPI Generation For API discovery, expose an OpenAPI document (for example /api/openapi.json) generated from your mutator registry. Typical setup: discover mutator names at runtime generate one POST operation per mutator path include request/response schemas serve Swagger UI from /api/docs defineMutators() returns callable mutators, but does not expose validator schemas on the resulting registry object. If you want schema-driven docs, export your validator map separately and reuse those schema objects in defineMutator(...). Full Working Example See the ztunes sample for a full implementation: Source: https://github.com/rocicorp/ztunes Swagger docs: https://ztunes.rocicorp.dev/api/docs", "headings": [ { "text": "Pattern", @@ -5063,47 +5115,47 @@ "kind": "page" }, { - "id": "386-rest-apis#pattern", - "title": "REST APIs", + "id": "390-rest#pattern", + "title": "REST", "searchTitle": "Pattern", "sectionTitle": "Pattern", "sectionId": "pattern", - "url": "/docs/rest-apis", - "content": "The standard pattern is: Keep mutators as the source of truth. Add a server route that maps REST paths to mutator names. Look up the mutator with mustGetMutator and execute mutator.fn(...). Reuse the same validator schemas for docs generation (OpenAPI). For example: POST /api/mutators/cart/add maps to mutator name cart.add POST /api/mutators/cart/remove maps to mutator name cart.remove Use POST for mutators, since they are commands and can have side effects.", + "url": "/docs/rest", + "content": "Keep mutators as the source of truth. Add a server route that maps REST paths to mutator names. Look up the mutator with mustGetMutator and execute mutator.fn(...). Reuse the same validator schemas for docs generation (OpenAPI). For example: POST /api/mutators/cart/add maps to mutator name cart.add POST /api/mutators/cart/remove maps to mutator name cart.remove This pattern works nicely because Zero mutators have more requirements than regular APIs. Namely they require an open transaction to be passed in. So it's easier to generate REST APIs from mutators than the reverse.", "kind": "section" }, { - "id": "387-rest-apis#tanstack-start-example", - "title": "REST APIs", + "id": "391-rest#tanstack-start-example", + "title": "REST", "searchTitle": "TanStack Start Example", "sectionTitle": "TanStack Start Example", "sectionId": "tanstack-start-example", - "url": "/docs/rest-apis", + "url": "/docs/rest", "content": "// app/routes/api/mutators/$.ts import {createServerFileRoute} from '@tanstack/react-start/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'zero/mutators' export const ServerRoute = createServerFileRoute( '/api/mutators/$' ).methods({ POST: async ({params, request}) => { const name = params._splat?.split('/').join('.') if (!name) { return Response.json( {error: 'Mutator name required'}, {status: 400} ) } const args = await request.json() const mutator = mustGetMutator(mutators, name) await dbProvider.transaction(async tx => { await mutator.fn({ tx, ctx: {userId: '...'}, args }) }) return Response.json({ok: true}) } })", "kind": "section" }, { - "id": "388-rest-apis#openapi-generation", - "title": "REST APIs", + "id": "392-rest#openapi-generation", + "title": "REST", "searchTitle": "OpenAPI Generation", "sectionTitle": "OpenAPI Generation", "sectionId": "openapi-generation", - "url": "/docs/rest-apis", + "url": "/docs/rest", "content": "For API discovery, expose an OpenAPI document (for example /api/openapi.json) generated from your mutator registry. Typical setup: discover mutator names at runtime generate one POST operation per mutator path include request/response schemas serve Swagger UI from /api/docs defineMutators() returns callable mutators, but does not expose validator schemas on the resulting registry object. If you want schema-driven docs, export your validator map separately and reuse those schema objects in defineMutator(...).", "kind": "section" }, { - "id": "389-rest-apis#full-working-example", - "title": "REST APIs", + "id": "393-rest#full-working-example", + "title": "REST", "searchTitle": "Full Working Example", "sectionTitle": "Full Working Example", "sectionId": "full-working-example", - "url": "/docs/rest-apis", - "content": "See the ztunes sample for a full implementation: Source: https://github.com/rocicorp/ztunes REST endpoint pattern: app/routes/api/mutators/$.ts OpenAPI generation: app/routes/api/openapi[.]json.ts Swagger docs route: app/routes/api/docs.ts", + "url": "/docs/rest", + "content": "See the ztunes sample for a full implementation: Source: https://github.com/rocicorp/ztunes Swagger docs: https://ztunes.rocicorp.dev/api/docs", "kind": "section" }, { - "id": "53-roadmap", + "id": "54-roadmap", "title": "Roadmap", "searchTitle": "Roadmap", "url": "/docs/roadmap", @@ -5121,7 +5173,7 @@ "kind": "page" }, { - "id": "390-roadmap#q4-2025", + "id": "394-roadmap#q4-2025", "title": "Roadmap", "searchTitle": "Q4 2025", "sectionTitle": "Q4 2025", @@ -5131,7 +5183,7 @@ "kind": "section" }, { - "id": "391-roadmap#beyond", + "id": "395-roadmap#beyond", "title": "Roadmap", "searchTitle": "Beyond", "sectionTitle": "Beyond", @@ -5141,7 +5193,7 @@ "kind": "section" }, { - "id": "54-samples", + "id": "55-samples", "title": "Samples", "searchTitle": "Samples", "url": "/docs/samples", @@ -5163,7 +5215,7 @@ "kind": "page" }, { - "id": "392-samples#gigabugs", + "id": "396-samples#gigabugs", "title": "Samples", "searchTitle": "Gigabugs", "sectionTitle": "Gigabugs", @@ -5173,7 +5225,7 @@ "kind": "section" }, { - "id": "393-samples#ztunes", + "id": "397-samples#ztunes", "title": "Samples", "searchTitle": "ztunes", "sectionTitle": "ztunes", @@ -5183,7 +5235,7 @@ "kind": "section" }, { - "id": "394-samples#zslack", + "id": "398-samples#zslack", "title": "Samples", "searchTitle": "zslack", "sectionTitle": "zslack", @@ -5193,11 +5245,11 @@ "kind": "section" }, { - "id": "55-schema", + "id": "56-schema", "title": "Zero Schema", "searchTitle": "Zero Schema", "url": "/docs/schema", - "content": "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. Generating from Database If you use Drizzle or Prisma ORM, you can generate schema.ts with drizzle-zero or prisma-zero: npm install -D drizzle-zero npx drizzle-zero generatepnpm add -D drizzle-zero pnpm dlx drizzle-zero generatebun add -D drizzle-zero bunx drizzle-zero generateyarn add -D drizzle-zero yarn dlx drizzle-zero generatenpm install -D prisma-zero # Add this to your prisma schema: # generator zero { # provider = \"prisma-zero\" # } npx prisma generatepnpm add -D prisma-zero # Add this to your prisma schema: # generator zero { # provider = \"prisma-zero\" # } pnpx prisma generatebun add -D prisma-zero # Add this to your prisma schema: # generator zero { # provider = \"prisma-zero\" # } bunx prisma generateyarn add -D prisma-zero # Add this to your prisma schema: # generator zero { # provider = \"prisma-zero\" # } yarn prisma generate We'd love more! See the source for drizzle-zero and prisma-zero as a guide, or reach out on Discord 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: 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. Name Mapping 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') }) Multiple Schemas You can also use from() to access other Postgres schemas: // 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. 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. 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. 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: 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: 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: 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: 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: const issueRelationships = relationships( issue, ({many}) => ({ labels: many( { sourceField: ['id'], destSchema: issueLabel, destField: ['issueID'] }, { sourceField: ['labelID'], destSchema: label, destField: ['id'] } ) }) ) See 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: const messageRelationships = relationships( message, ({one}) => ({ sender: one({ sourceField: ['senderOrgID', 'senderUserID'], destSchema: user, destField: ['orgID', 'userID'] }) }) ) Circular Relationships Circular relationships are fully supported: const commentRelationships = relationships( comment, ({one}) => ({ parent: one({ sourceField: ['parentID'], destSchema: comment, destField: ['id'] }) }) ) Database Schemas Use createSchema to define the entire Zero schema: 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: 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. 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: 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: Implement and run an \"expand\" migration on the backend that is backwards compatible with existing schemas. Add new columns or tables, plus any defaults and triggers needed for compatibility with existing clients. Update and deploy the API and client app to use the new schema. After a grace period, implement and run a \"contract\" migration on the backend to drop or rename obsolete columns/tables. Steps 1 and 2 can generally be done as part of a single deploy in your CI pipeline, but step 3 should be weeks later, when most open clients have refreshed the application. See Rolling Updates for more details. Certain schema changes require special handling in Postgres. See Schema Changes for details.", + "content": "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. Generating from Database If you use Drizzle or Prisma ORM, you can generate schema.ts with drizzle-zero or prisma-zero: npm install -D drizzle-zero npx drizzle-zero generatepnpm add -D drizzle-zero pnpm dlx drizzle-zero generatebun add -D drizzle-zero bunx drizzle-zero generateyarn add -D drizzle-zero yarn dlx drizzle-zero generatenpm install -D prisma-zero # Add this to your prisma schema: # generator zero { # provider = \"prisma-zero\" # } npx prisma generatepnpm add -D prisma-zero # Add this to your prisma schema: # generator zero { # provider = \"prisma-zero\" # } pnpx prisma generatebun add -D prisma-zero # Add this to your prisma schema: # generator zero { # provider = \"prisma-zero\" # } bunx prisma generateyarn add -D prisma-zero # Add this to your prisma schema: # generator zero { # provider = \"prisma-zero\" # } yarn prisma generate We'd love more! See the source for drizzle-zero and prisma-zero as a guide, or reach out on Discord 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: 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. Name Mapping 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') }) Multiple Schemas You can also use from() to access other Postgres schemas: // 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. 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. 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. 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: 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: 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: 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: 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: const issueRelationships = relationships( issue, ({many}) => ({ labels: many( { sourceField: ['id'], destSchema: issueLabel, destField: ['issueID'] }, { sourceField: ['labelID'], destSchema: label, destField: ['id'] } ) }) ) See 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: const messageRelationships = relationships( message, ({one}) => ({ sender: one({ sourceField: ['senderOrgID', 'senderUserID'], destSchema: user, destField: ['orgID', 'userID'] }) }) ) Circular Relationships Circular relationships are fully supported: const commentRelationships = relationships( comment, ({one}) => ({ parent: one({ sourceField: ['parentID'], destSchema: comment, destField: ['id'] }) }) ) Database Schemas Use createSchema to define the entire Zero schema: 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: 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. 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: 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: Implement and run an \"expand\" migration on the backend that is backwards compatible with existing schemas. Add new columns or tables, plus any defaults and triggers needed for compatibility with existing clients. Update and deploy the API and client app to use the new schema. After a grace period, implement and run a \"contract\" migration on the backend to drop or rename obsolete columns/tables. Steps 1 and 2 can generally be done as part of a single deploy in your CI pipeline, but step 3 should be weeks later, when most open clients have refreshed the application. See Rolling Updates for more details. Backfill When you add a new column or table to your schema, initial data (from e.g., GENERATED, DEFAULT, CURRENT_TIMESTAMP, etc.) needs to be replicated to zero-cache and synced to clients. Similarly, when adding an existing column to a custom publication, that column's existing data needs to be replicated. Zero handles both these cases through a process called backfilling. Zero backfills existing data to the replica in the background after detecting a new column. The new column is not exposed to the client until all data has been backfilled, which may take some time depending on the amount of data. Monitoring Backfill Progress To track backfill progress, check your zero-cache logs for messages about backfilling status. If you're using Cloud Zero, backfill progress is displayed directly in the dashboard.", "headings": [ { "text": "Generating from Database", @@ -5270,12 +5322,20 @@ { "text": "Schema Change Process", "id": "schema-change-process" + }, + { + "text": "Backfill", + "id": "backfill" + }, + { + "text": "Monitoring Backfill Progress", + "id": "monitoring-backfill-progress" } ], "kind": "page" }, { - "id": "395-schema#generating-from-database", + "id": "399-schema#generating-from-database", "title": "Zero Schema", "searchTitle": "Generating from Database", "sectionTitle": "Generating from Database", @@ -5285,7 +5345,7 @@ "kind": "section" }, { - "id": "396-schema#writing-by-hand", + "id": "400-schema#writing-by-hand", "title": "Zero Schema", "searchTitle": "Writing by Hand", "sectionTitle": "Writing by Hand", @@ -5295,7 +5355,7 @@ "kind": "section" }, { - "id": "397-schema#table-schemas", + "id": "401-schema#table-schemas", "title": "Zero Schema", "searchTitle": "Table Schemas", "sectionTitle": "Table Schemas", @@ -5305,7 +5365,7 @@ "kind": "section" }, { - "id": "398-schema#name-mapping", + "id": "402-schema#name-mapping", "title": "Zero Schema", "searchTitle": "Name Mapping", "sectionTitle": "Name Mapping", @@ -5315,7 +5375,7 @@ "kind": "section" }, { - "id": "399-schema#multiple-schemas", + "id": "403-schema#multiple-schemas", "title": "Zero Schema", "searchTitle": "Multiple Schemas", "sectionTitle": "Multiple Schemas", @@ -5325,7 +5385,7 @@ "kind": "section" }, { - "id": "400-schema#optional-columns", + "id": "404-schema#optional-columns", "title": "Zero Schema", "searchTitle": "Optional Columns", "sectionTitle": "Optional Columns", @@ -5335,7 +5395,7 @@ "kind": "section" }, { - "id": "401-schema#enumerations", + "id": "405-schema#enumerations", "title": "Zero Schema", "searchTitle": "Enumerations", "sectionTitle": "Enumerations", @@ -5345,7 +5405,7 @@ "kind": "section" }, { - "id": "402-schema#custom-json-types", + "id": "406-schema#custom-json-types", "title": "Zero Schema", "searchTitle": "Custom JSON Types", "sectionTitle": "Custom JSON Types", @@ -5355,7 +5415,7 @@ "kind": "section" }, { - "id": "403-schema#compound-primary-keys", + "id": "407-schema#compound-primary-keys", "title": "Zero Schema", "searchTitle": "Compound Primary Keys", "sectionTitle": "Compound Primary Keys", @@ -5365,7 +5425,7 @@ "kind": "section" }, { - "id": "404-schema#relationships", + "id": "408-schema#relationships", "title": "Zero Schema", "searchTitle": "Relationships", "sectionTitle": "Relationships", @@ -5375,7 +5435,7 @@ "kind": "section" }, { - "id": "405-schema#many-to-many-relationships", + "id": "409-schema#many-to-many-relationships", "title": "Zero Schema", "searchTitle": "Many-to-Many Relationships", "sectionTitle": "Many-to-Many Relationships", @@ -5385,7 +5445,7 @@ "kind": "section" }, { - "id": "406-schema#compound-keys-relationships", + "id": "410-schema#compound-keys-relationships", "title": "Zero Schema", "searchTitle": "Compound Keys Relationships", "sectionTitle": "Compound Keys Relationships", @@ -5395,7 +5455,7 @@ "kind": "section" }, { - "id": "407-schema#circular-relationships", + "id": "411-schema#circular-relationships", "title": "Zero Schema", "searchTitle": "Circular Relationships", "sectionTitle": "Circular Relationships", @@ -5405,7 +5465,7 @@ "kind": "section" }, { - "id": "408-schema#database-schemas", + "id": "412-schema#database-schemas", "title": "Zero Schema", "searchTitle": "Database Schemas", "sectionTitle": "Database Schemas", @@ -5415,7 +5475,7 @@ "kind": "section" }, { - "id": "409-schema#default-type-parameter", + "id": "413-schema#default-type-parameter", "title": "Zero Schema", "searchTitle": "Default Type Parameter", "sectionTitle": "Default Type Parameter", @@ -5425,17 +5485,17 @@ "kind": "section" }, { - "id": "410-schema#migrations", + "id": "414-schema#migrations", "title": "Zero Schema", "searchTitle": "Migrations", "sectionTitle": "Migrations", "sectionId": "migrations", "url": "/docs/schema", - "content": "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. 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: 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: Implement and run an \"expand\" migration on the backend that is backwards compatible with existing schemas. Add new columns or tables, plus any defaults and triggers needed for compatibility with existing clients. Update and deploy the API and client app to use the new schema. After a grace period, implement and run a \"contract\" migration on the backend to drop or rename obsolete columns/tables. Steps 1 and 2 can generally be done as part of a single deploy in your CI pipeline, but step 3 should be weeks later, when most open clients have refreshed the application. See Rolling Updates for more details. Certain schema changes require special handling in Postgres. See Schema Changes for details.", + "content": "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. 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: 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: Implement and run an \"expand\" migration on the backend that is backwards compatible with existing schemas. Add new columns or tables, plus any defaults and triggers needed for compatibility with existing clients. Update and deploy the API and client app to use the new schema. After a grace period, implement and run a \"contract\" migration on the backend to drop or rename obsolete columns/tables. Steps 1 and 2 can generally be done as part of a single deploy in your CI pipeline, but step 3 should be weeks later, when most open clients have refreshed the application. See Rolling Updates for more details. Backfill When you add a new column or table to your schema, initial data (from e.g., GENERATED, DEFAULT, CURRENT_TIMESTAMP, etc.) needs to be replicated to zero-cache and synced to clients. Similarly, when adding an existing column to a custom publication, that column's existing data needs to be replicated. Zero handles both these cases through a process called backfilling. Zero backfills existing data to the replica in the background after detecting a new column. The new column is not exposed to the client until all data has been backfilled, which may take some time depending on the amount of data. Monitoring Backfill Progress To track backfill progress, check your zero-cache logs for messages about backfilling status. If you're using Cloud Zero, backfill progress is displayed directly in the dashboard.", "kind": "section" }, { - "id": "411-schema#how-it-works", + "id": "415-schema#how-it-works", "title": "Zero Schema", "searchTitle": "How it Works", "sectionTitle": "How it Works", @@ -5445,17 +5505,37 @@ "kind": "section" }, { - "id": "412-schema#schema-change-process", + "id": "416-schema#schema-change-process", "title": "Zero Schema", "searchTitle": "Schema Change Process", "sectionTitle": "Schema Change Process", "sectionId": "schema-change-process", "url": "/docs/schema", - "content": "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 columns or tables, plus any defaults and triggers needed for compatibility with existing clients. Update and deploy the API and client app to use the new schema. After a grace period, implement and run a \"contract\" migration on the backend to drop or rename obsolete columns/tables. Steps 1 and 2 can generally be done as part of a single deploy in your CI pipeline, but step 3 should be weeks later, when most open clients have refreshed the application. See Rolling Updates for more details. Certain schema changes require special handling in Postgres. See Schema Changes for details.", + "content": "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 columns or tables, plus any defaults and triggers needed for compatibility with existing clients. Update and deploy the API and client app to use the new schema. After a grace period, implement and run a \"contract\" migration on the backend to drop or rename obsolete columns/tables. Steps 1 and 2 can generally be done as part of a single deploy in your CI pipeline, but step 3 should be weeks later, when most open clients have refreshed the application. See Rolling Updates for more details.", + "kind": "section" + }, + { + "id": "417-schema#backfill", + "title": "Zero Schema", + "searchTitle": "Backfill", + "sectionTitle": "Backfill", + "sectionId": "backfill", + "url": "/docs/schema", + "content": "When you add a new column or table to your schema, initial data (from e.g., GENERATED, DEFAULT, CURRENT_TIMESTAMP, etc.) needs to be replicated to zero-cache and synced to clients. Similarly, when adding an existing column to a custom publication, that column's existing data needs to be replicated. Zero handles both these cases through a process called backfilling. Zero backfills existing data to the replica in the background after detecting a new column. The new column is not exposed to the client until all data has been backfilled, which may take some time depending on the amount of data.", + "kind": "section" + }, + { + "id": "418-schema#monitoring-backfill-progress", + "title": "Zero Schema", + "searchTitle": "Monitoring Backfill Progress", + "sectionTitle": "Monitoring Backfill Progress", + "sectionId": "monitoring-backfill-progress", + "url": "/docs/schema", + "content": "To track backfill progress, check your zero-cache logs for messages about backfilling status. If you're using Cloud Zero, backfill progress is displayed directly in the dashboard.", "kind": "section" }, { - "id": "56-server-zql", + "id": "57-server-zql", "title": "ZQL on the Server", "searchTitle": "ZQL on the Server", "url": "/docs/server-zql", @@ -5481,7 +5561,7 @@ "kind": "page" }, { - "id": "413-server-zql#creating-a-database", + "id": "419-server-zql#creating-a-database", "title": "ZQL on the Server", "searchTitle": "Creating a Database", "sectionTitle": "Creating a Database", @@ -5491,7 +5571,7 @@ "kind": "section" }, { - "id": "414-server-zql#custom-database", + "id": "420-server-zql#custom-database", "title": "ZQL on the Server", "searchTitle": "Custom Database", "sectionTitle": "Custom Database", @@ -5501,7 +5581,7 @@ "kind": "section" }, { - "id": "415-server-zql#running-zql", + "id": "421-server-zql#running-zql", "title": "ZQL on the Server", "searchTitle": "Running ZQL", "sectionTitle": "Running ZQL", @@ -5511,7 +5591,7 @@ "kind": "section" }, { - "id": "416-server-zql#ssr", + "id": "422-server-zql#ssr", "title": "ZQL on the Server", "searchTitle": "SSR", "sectionTitle": "SSR", @@ -5521,7 +5601,7 @@ "kind": "section" }, { - "id": "57-solidjs", + "id": "58-solidjs", "title": "SolidJS", "searchTitle": "SolidJS", "url": "/docs/solidjs", @@ -5543,7 +5623,7 @@ "kind": "page" }, { - "id": "417-solidjs#setup", + "id": "423-solidjs#setup", "title": "SolidJS", "searchTitle": "Setup", "sectionTitle": "Setup", @@ -5553,7 +5633,7 @@ "kind": "section" }, { - "id": "418-solidjs#usage", + "id": "424-solidjs#usage", "title": "SolidJS", "searchTitle": "Usage", "sectionTitle": "Usage", @@ -5563,7 +5643,7 @@ "kind": "section" }, { - "id": "419-solidjs#examples", + "id": "425-solidjs#examples", "title": "SolidJS", "searchTitle": "Examples", "sectionTitle": "Examples", @@ -5573,11 +5653,11 @@ "kind": "section" }, { - "id": "58-status", + "id": "59-status", "title": "Project Status", "searchTitle": "Project Status", "url": "/docs/status", - "content": "Zero is a new sync engine based on a novel streaming query engine. This is an ambitious project at an early stage. You will encounter bugs. You may encounter pathologically slow queries. You are likely to encounter situations where ZQL is not powerful enough to express the query you want. That said, we are building Zero live. It has been running our own bug tracker for months, and is used in production by a small set of customer applications that are an extremely good fit. This page describes the current state of Zero at a high level. To understand whether Zero makes sense for you, please also see When to Use Zero. Platforms and Frameworks React, React Native, and SolidJS are fully supported. Svelte and Vue have community support. We have strong support for TanStack. Databases Most Postgres providers are supported. Drizzle and Prisma are fully supported. API The new APIs are still being refined and have some rough edges. Query Language Filters, sorts, limits, relationships, and exists are supported. Queries can have ttl to keep data synced across sessions. Aggregates (count, min, max, group-by) are not yet supported. Full-text search is not yet supported (you can sometimes simulate with ILIKE, though it scales linearly). Infinite/virtual scroll is possible, but we do not yet have a library/API for it. See zbugs source for how to do this. Performance Zero plans single-table and multi-table queries. You can also manually plan queries using the flip parameter. Zero has a basic console-based inspector that can help to understand query and sync performance. It does not yet have a GUI inspector. We share queries within a \"client group\" (e.g. all tabs in a browser), but not across groups. This means that if you have many users doing the same query, they will duplicate all that work server-side. Miscellaneous Running Zero requires deploying it yourself to AWS or similar. Running in a multinode, zero-downtime way is possible (we do it for zbugs), but significant effort. Running single node is easier, but updating the server takes it down for a minute or so (we are working on a SaaS solution).", + "content": "Zero is a new sync engine based on a novel streaming query engine. This is an ambitious project at an early stage. You will encounter bugs. You may encounter pathologically slow queries. You are likely to encounter situations where ZQL is not powerful enough to express the query you want. That said, we are building Zero live. It has been running our own bug tracker for months, and is used in production by a small set of customer applications that are an extremely good fit. This page describes the current state of Zero at a high level. To understand whether Zero makes sense for you, please also see When to Use Zero. Platforms and Frameworks React, React Native, and SolidJS are fully supported. Svelte and Vue have community support. We have strong support for TanStack. Databases Most Postgres providers are supported. Drizzle and Prisma are fully supported. API The new APIs are still being refined and have some rough edges. Query Language Filters, sorts, limits, relationships, and exists are supported. Queries can have ttl to keep data synced across sessions. Infinite/virtual scroll is possible and we have zero-virtual for React. Aggregates (count, min, max, group-by) are not yet supported. Full-text search is not yet supported (you can sometimes simulate with ILIKE, though it scales linearly). Performance Zero plans single-table and multi-table queries. You can also manually plan queries using the flip parameter. Zero has a basic console-based inspector that can help to understand query and sync performance. It does not yet have a GUI inspector. We share queries within a \"client group\" (e.g. all tabs in a browser), but not across groups. This means that if you have many users doing the same query, they will duplicate all that work server-side. Miscellaneous Running Zero requires deploying it yourself to AWS or similar. Running in a multinode, zero-downtime way is possible (we do it for zbugs), but significant effort. Running single node is easier, but updating the server takes it down for a minute or so (we are working on a SaaS solution).", "headings": [ { "text": "Platforms and Frameworks", @@ -5607,7 +5687,7 @@ "kind": "page" }, { - "id": "420-status#platforms-and-frameworks", + "id": "426-status#platforms-and-frameworks", "title": "Project Status", "searchTitle": "Platforms and Frameworks", "sectionTitle": "Platforms and Frameworks", @@ -5617,7 +5697,7 @@ "kind": "section" }, { - "id": "421-status#databases", + "id": "427-status#databases", "title": "Project Status", "searchTitle": "Databases", "sectionTitle": "Databases", @@ -5627,7 +5707,7 @@ "kind": "section" }, { - "id": "422-status#api", + "id": "428-status#api", "title": "Project Status", "searchTitle": "API", "sectionTitle": "API", @@ -5637,17 +5717,17 @@ "kind": "section" }, { - "id": "423-status#query-language", + "id": "429-status#query-language", "title": "Project Status", "searchTitle": "Query Language", "sectionTitle": "Query Language", "sectionId": "query-language", "url": "/docs/status", - "content": "Filters, sorts, limits, relationships, and exists are supported. Queries can have ttl to keep data synced across sessions. Aggregates (count, min, max, group-by) are not yet supported. Full-text search is not yet supported (you can sometimes simulate with ILIKE, though it scales linearly). Infinite/virtual scroll is possible, but we do not yet have a library/API for it. See zbugs source for how to do this.", + "content": "Filters, sorts, limits, relationships, and exists are supported. Queries can have ttl to keep data synced across sessions. Infinite/virtual scroll is possible and we have zero-virtual for React. Aggregates (count, min, max, group-by) are not yet supported. Full-text search is not yet supported (you can sometimes simulate with ILIKE, though it scales linearly).", "kind": "section" }, { - "id": "424-status#performance", + "id": "430-status#performance", "title": "Project Status", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -5657,7 +5737,7 @@ "kind": "section" }, { - "id": "425-status#miscellaneous", + "id": "431-status#miscellaneous", "title": "Project Status", "searchTitle": "Miscellaneous", "sectionTitle": "Miscellaneous", @@ -5667,7 +5747,7 @@ "kind": "section" }, { - "id": "59-sync", + "id": "60-sync", "title": "What is Sync?", "searchTitle": "What is Sync?", "url": "/docs/sync", @@ -5689,7 +5769,7 @@ "kind": "page" }, { - "id": "426-sync#problem", + "id": "432-sync#problem", "title": "What is Sync?", "searchTitle": "Problem", "sectionTitle": "Problem", @@ -5699,7 +5779,7 @@ "kind": "section" }, { - "id": "427-sync#solution", + "id": "433-sync#solution", "title": "What is Sync?", "searchTitle": "Solution", "sectionTitle": "Solution", @@ -5709,7 +5789,7 @@ "kind": "section" }, { - "id": "428-sync#history-of-sync", + "id": "434-sync#history-of-sync", "title": "What is Sync?", "searchTitle": "History of Sync", "sectionTitle": "History of Sync", @@ -5719,7 +5799,7 @@ "kind": "section" }, { - "id": "60-when-to-use", + "id": "61-when-to-use", "title": "When To Use Zero", "searchTitle": "When To Use Zero", "url": "/docs/when-to-use", @@ -5797,7 +5877,7 @@ "kind": "page" }, { - "id": "429-when-to-use#zero-might-be-a-good-fit", + "id": "435-when-to-use#zero-might-be-a-good-fit", "title": "When To Use Zero", "searchTitle": "Zero Might be a Good Fit", "sectionTitle": "Zero Might be a Good Fit", @@ -5807,7 +5887,7 @@ "kind": "section" }, { - "id": "430-when-to-use#you-want-to-sync-only-a-small-subset-of-data-to-client", + "id": "436-when-to-use#you-want-to-sync-only-a-small-subset-of-data-to-client", "title": "When To Use Zero", "searchTitle": "You want to sync only a small subset of data to client", "sectionTitle": "You want to sync only a small subset of data to client", @@ -5817,7 +5897,7 @@ "kind": "section" }, { - "id": "431-when-to-use#you-need-fine-grained-read-or-write-permissions", + "id": "437-when-to-use#you-need-fine-grained-read-or-write-permissions", "title": "When To Use Zero", "searchTitle": "You need fine-grained read or write permissions", "sectionTitle": "You need fine-grained read or write permissions", @@ -5827,7 +5907,7 @@ "kind": "section" }, { - "id": "432-when-to-use#you-are-building-a-traditional-client-server-web-app", + "id": "438-when-to-use#you-are-building-a-traditional-client-server-web-app", "title": "When To Use Zero", "searchTitle": "You are building a traditional client-server web app", "sectionTitle": "You are building a traditional client-server web app", @@ -5837,7 +5917,7 @@ "kind": "section" }, { - "id": "433-when-to-use#you-use-postgresql", + "id": "439-when-to-use#you-use-postgresql", "title": "When To Use Zero", "searchTitle": "You use PostgreSQL", "sectionTitle": "You use PostgreSQL", @@ -5847,7 +5927,7 @@ "kind": "section" }, { - "id": "434-when-to-use#your-app-is-broadly-like-linear", + "id": "440-when-to-use#your-app-is-broadly-like-linear", "title": "When To Use Zero", "searchTitle": "Your app is broadly \"like Linear\"", "sectionTitle": "Your app is broadly \"like Linear\"", @@ -5857,7 +5937,7 @@ "kind": "section" }, { - "id": "435-when-to-use#interaction-performance-is-very-important-to-you", + "id": "441-when-to-use#interaction-performance-is-very-important-to-you", "title": "When To Use Zero", "searchTitle": "Interaction performance is very important to you", "sectionTitle": "Interaction performance is very important to you", @@ -5867,7 +5947,7 @@ "kind": "section" }, { - "id": "436-when-to-use#zero-might-not-be-a-good-fit", + "id": "442-when-to-use#zero-might-not-be-a-good-fit", "title": "When To Use Zero", "searchTitle": "Zero Might Not be a Good Fit", "sectionTitle": "Zero Might Not be a Good Fit", @@ -5877,7 +5957,7 @@ "kind": "section" }, { - "id": "437-when-to-use#you-need-the-privacy-or-data-ownership-benefits-of-local-first", + "id": "443-when-to-use#you-need-the-privacy-or-data-ownership-benefits-of-local-first", "title": "When To Use Zero", "searchTitle": "You need the privacy or data ownership benefits of local-first", "sectionTitle": "You need the privacy or data ownership benefits of local-first", @@ -5887,7 +5967,7 @@ "kind": "section" }, { - "id": "438-when-to-use#you-need-to-support-offline-writes-or-long-periods-offline", + "id": "444-when-to-use#you-need-to-support-offline-writes-or-long-periods-offline", "title": "When To Use Zero", "searchTitle": "You need to support offline writes or long periods offline", "sectionTitle": "You need to support offline writes or long periods offline", @@ -5897,7 +5977,7 @@ "kind": "section" }, { - "id": "439-when-to-use#you-are-building-a-native-mobile-app", + "id": "445-when-to-use#you-are-building-a-native-mobile-app", "title": "When To Use Zero", "searchTitle": "You are building a native mobile app", "sectionTitle": "You are building a native mobile app", @@ -5907,7 +5987,7 @@ "kind": "section" }, { - "id": "440-when-to-use#the-total-backend-dataset-is--100gb", + "id": "446-when-to-use#the-total-backend-dataset-is--100gb", "title": "When To Use Zero", "searchTitle": "The total backend dataset is > ~100GB", "sectionTitle": "The total backend dataset is > ~100GB", @@ -5917,7 +5997,7 @@ "kind": "section" }, { - "id": "441-when-to-use#zero-might-not-be-a-good-fit-yet", + "id": "447-when-to-use#zero-might-not-be-a-good-fit-yet", "title": "When To Use Zero", "searchTitle": "Zero Might Not be a Good Fit Yet", "sectionTitle": "Zero Might Not be a Good Fit Yet", @@ -5927,7 +6007,7 @@ "kind": "section" }, { - "id": "442-when-to-use#you-dont-want-to-run-server-side-infra", + "id": "448-when-to-use#you-dont-want-to-run-server-side-infra", "title": "When To Use Zero", "searchTitle": "You don't want to run server-side infra", "sectionTitle": "You don't want to run server-side infra", @@ -5937,7 +6017,7 @@ "kind": "section" }, { - "id": "443-when-to-use#you-cant-tolerate-occasional-downtime", + "id": "449-when-to-use#you-cant-tolerate-occasional-downtime", "title": "When To Use Zero", "searchTitle": "You can't tolerate occasional downtime", "sectionTitle": "You can't tolerate occasional downtime", @@ -5947,7 +6027,7 @@ "kind": "section" }, { - "id": "444-when-to-use#you-need-support-for-ssr", + "id": "450-when-to-use#you-need-support-for-ssr", "title": "When To Use Zero", "searchTitle": "You need support for SSR", "sectionTitle": "You need support for SSR", @@ -5957,7 +6037,7 @@ "kind": "section" }, { - "id": "445-when-to-use#alternatives", + "id": "451-when-to-use#alternatives", "title": "When To Use Zero", "searchTitle": "Alternatives", "sectionTitle": "Alternatives", @@ -5967,11 +6047,11 @@ "kind": "section" }, { - "id": "61-zero-cache-config", + "id": "62-zero-cache-config", "title": "zero-cache Config", "searchTitle": "zero-cache Config", "url": "/docs/zero-cache-config", - "content": "zero-cache is configured either via CLI flag or environment variable. There is no separate zero.config file. You can also see all available flags by running zero-cache --help. Required Flags Upstream DB The \"upstream\" authoritative postgres database. In the future we will support other types of upstream besides PG. flag: --upstream-db env: ZERO_UPSTREAM_DB required: true Admin Password A password used to administer zero-cache server, for example to access the /statz endpoint and the inspector. This is required in production (when NODE_ENV=production) because we want all Zero servers to be debuggable using admin tools by default, without needing a restart. But we also don't want to expose sensitive data using them. flag: --admin-password env: ZERO_ADMIN_PASSWORD required: in production (when NODE_ENV=production) Optional Flags App ID Unique identifier for the app. Multiple zero-cache apps can run on a single upstream database, each of which is isolated from the others, with its own permissions, sharding (future feature), and change/cvr databases. The metadata of an app is stored in an upstream schema with the same name, e.g. zero, and the metadata for each app shard, e.g. client and mutation ids, is stored in the {app-id}_{#} schema. (Currently there is only a single \"0\" shard, but this will change with sharding). The CVR and Change data are managed in schemas named {app-id}_{shard-num}/cvr and {app-id}_{shard-num}/cdc, respectively, allowing multiple apps and shards to share the same database instance (e.g. a Postgres \"cluster\") for CVR and Change management. Due to constraints on replication slot names, an App ID may only consist of lower-case letters, numbers, and the underscore character. Note that this option is used by both zero-cache and zero-deploy-permissions. flag: --app-id env: ZERO_APP_ID default: zero App Publications Postgres PUBLICATIONs that define the tables and columns to replicate. Publication names may not begin with an underscore, as zero reserves that prefix for internal use. If unspecified, zero-cache will create and use an internal publication that publishes all tables in the public schema, i.e.: CREATE PUBLICATION _{app-id}_public_0 FOR TABLES IN SCHEMA public; Note that changing the set of publications will result in resyncing the replica, which may involve downtime (replication lag) while the new replica is initializing. To change the set of publications without disrupting an existing app, a new app should be created. To use a custom publication, you can create one with: CREATE PUBLICATION zero_data FOR TABLES IN SCHEMA public; -- or, more selectively: CREATE PUBLICATION zero_data FOR TABLE users, orders; Then set the flag to that publication name, e.g.: ZERO_APP_PUBLICATIONS=zero_data. To specify multiple publications, separate them with commas, e.g.: ZERO_APP_PUBLICATIONS=zero_data1,zero_data2. flag: --app-publications env: ZERO_APP_PUBLICATIONS default: _{app-id}_public_0 Auto Reset Automatically wipe and resync the replica when replication is halted. This situation can occur for configurations in which the upstream database provider prohibits event trigger creation, preventing the zero-cache from being able to correctly replicate schema changes. For such configurations, an upstream schema change will instead result in halting replication with an error indicating that the replica needs to be reset. When auto-reset is enabled, zero-cache will respond to such situations by shutting down, and when restarted, resetting the replica and all synced clients. This is a heavy-weight operation and can result in user-visible slowness or downtime if compute resources are scarce. flag: --auto-reset env: ZERO_AUTO_RESET default: true Change DB The Postgres database used to store recent replication log entries, in order to sync multiple view-syncers without requiring multiple replication slots on the upstream database. If unspecified, the upstream-db will be used. flag: --change-db env: ZERO_CHANGE_DB Change Max Connections The maximum number of connections to open to the change database. This is used by the change-streamer for catching up zero-cache replication subscriptions. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS default: 5 Change Streamer Mode The mode for running or connecting to the change-streamer: dedicated: runs the change-streamer and shuts down when another change-streamer takes over the replication slot. This is appropriate in a single-node configuration, or for the replication-manager in a multi-node configuration. discover: connects to the change-streamer as internally advertised in the change-db. This is appropriate for the view-syncers in a multi-node setup. This may not work in all networking configurations (e.g., some private networking or port forwarding setups). Using ZERO_CHANGE_STREAMER_URI with an explicit routable hostname is recommended instead. This option is ignored if ZERO_CHANGE_STREAMER_URI is set. flag: --change-streamer-mode env: ZERO_CHANGE_STREAMER_MODE default: dedicated Change Streamer Port The port on which the change-streamer runs. This is an internal protocol between the replication-manager and view-syncers, which runs in the same process tree in local development or a single-node configuration. If unspecified, defaults to --port + 1. flag: --change-streamer-port env: ZERO_CHANGE_STREAMER_PORT default: --port + 1 Change Streamer Startup Delay (ms) The delay to wait before the change-streamer takes over the replication stream (i.e. the handoff during replication-manager updates), to allow load balancers to register the task as healthy based on healthcheck parameters. If a change stream request is received during this interval, the delay will be canceled and the takeover will happen immediately, since the incoming request indicates that the task is registered as a target. flag: --change-streamer-startup-delay-ms env: ZERO_CHANGE_STREAMER_STARTUP_DELAY_MS default: 15000 Change Streamer URI When set, connects to the change-streamer at the given URI. In a multi-node setup, this should be specified in view-syncer options, pointing to the replication-manager URI, which runs a change-streamer on port 4849. flag: --change-streamer-uri env: ZERO_CHANGE_STREAMER_URI CVR DB The Postgres database used to store CVRs. CVRs (client view records) keep track of the data synced to clients in order to determine the diff to send on reconnect. If unspecified, the upstream-db will be used. flag: --cvr-db env: ZERO_CVR_DB CVR Garbage Collection Inactivity Threshold Hours The duration after which an inactive CVR is eligible for garbage collection. Garbage collection is incremental and periodic, so eligible CVRs are not necessarily purged immediately. flag: --cvr-garbage-collection-inactivity-threshold-hours env: ZERO_CVR_GARBAGE_COLLECTION_INACTIVITY_THRESHOLD_HOURS default: 48 CVR Garbage Collection Initial Batch Size The initial number of CVRs to purge per garbage collection interval. This number is increased linearly if the rate of new CVRs exceeds the rate of purged CVRs, in order to reach a steady state. Setting this to 0 effectively disables CVR garbage collection. flag: --cvr-garbage-collection-initial-batch-size env: ZERO_CVR_GARBAGE_COLLECTION_INITIAL_BATCH_SIZE default: 25 CVR Garbage Collection Initial Interval Seconds The initial interval at which to check and garbage collect inactive CVRs. This interval is increased exponentially (up to 16 minutes) when there is nothing to purge. flag: --cvr-garbage-collection-initial-interval-seconds env: ZERO_CVR_GARBAGE_COLLECTION_INITIAL_INTERVAL_SECONDS default: 60 CVR Max Connections The maximum number of connections to open to the CVR database. This is divided evenly amongst sync workers. Note that this number must allow for at least one connection per sync worker, or zero-cache will fail to start. See num-sync-workers. flag: --cvr-max-conns env: ZERO_CVR_MAX_CONNS default: 30 Enable Query Planner Enable the query planner for optimizing ZQL queries. The query planner analyzes and optimizes query execution by determining the most efficient join strategies. You can disable the planner if it is picking bad strategies. flag: --enable-query-planner env: ZERO_ENABLE_QUERY_PLANNER default: true Enable Telemetry Zero collects anonymous telemetry data to help us understand usage. We collect: Zero version Uptime General machine information, like the number of CPUs, OS, CI/CD environment, etc. Information about usage, such as number of queries or mutations processed per hour. This is completely optional and can be disabled at any time. You can also opt-out by setting DO_NOT_TRACK=1. flag: --enable-telemetry env: ZERO_ENABLE_TELEMETRY default: true Initial Sync Table Copy Workers The number of parallel workers used to copy tables during initial sync. Each worker uses a database connection, copies a single table at a time, and buffers up to (approximately) 10 MB of table data in memory during initial sync. Increasing the number of workers may improve initial sync speed; however, local disk throughput (IOPS), upstream CPU, and network bandwidth may also be bottlenecks. flag: --initial-sync-table-copy-workers env: ZERO_INITIAL_SYNC_TABLE_COPY_WORKERS default: 5 Lazy Startup Delay starting the majority of zero-cache until first request. This is mainly intended to avoid connecting to Postgres replication stream until the first request is received, which can be useful i.e., for preview instances. Currently only supported in single-node mode. flag: --lazy-startup env: ZERO_LAZY_STARTUP default: false Litestream Backup URL The location of the litestream backup, usually an s3:// URL. This is only consulted by the replication-manager. view-syncers receive this information from the replication-manager. In multi-node deployments, this is required on the replication-manager so view-syncers can reserve snapshots; in single-node deployments it is optional. flag: --litestream-backup-url env: ZERO_LITESTREAM_BACKUP_URL Litestream Checkpoint Threshold MB The size of the WAL file at which to perform an SQlite checkpoint to apply the writes in the WAL to the main database file. Each checkpoint creates a new WAL segment file that will be backed up by litestream. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40 Litestream Config Path Path to the litestream yaml config file. zero-cache will run this with its environment variables, which can be referenced in the file via ${ENV} substitution, for example: ZERO_REPLICA_FILE for the db Path ZERO_LITESTREAM_BACKUP_LOCATION for the db replica url ZERO_LITESTREAM_LOG_LEVEL for the log Level ZERO_LOG_FORMAT for the log type flag: --litestream-config-path env: ZERO_LITESTREAM_CONFIG_PATH default: ./src/services/litestream/config.yml Litestream Executable Path to the litestream executable. This option has no effect if litestream-backup-url is unspecified. flag: --litestream-executable env: ZERO_LITESTREAM_EXECUTABLE Litestream Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Maximum Checkpoint Page Count The WAL page count at which SQLite performs a RESTART checkpoint, which blocks writers until complete. Defaults to minCheckpointPageCount * 10. Set to 0 to disable RESTART checkpoints entirely. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 Litestream Minimum Checkpoint Page Count The WAL page count at which SQLite attempts a PASSIVE checkpoint, which transfers pages to the main database file without blocking writers. Defaults to checkpointThresholdMB * 250 (since SQLite page size is 4KB). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250 Litestream Multipart Concurrency The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY default: 48 Litestream Multipart Size The size of each part when uploading or downloading the snapshot with --litestream-multipart-concurrency. Note that up to concurrency * size bytes of memory are used when backing up or restoring the snapshot. flag: --litestream-multipart-size env: ZERO_LITESTREAM_MULTIPART_SIZE default: 16777216 (16 MiB) Litestream Log Level flag: --litestream-log-level env: ZERO_LITESTREAM_LOG_LEVEL default: warn values: debug, info, warn, error Litestream Port Port on which litestream exports metrics, used to determine the replication watermark up to which it is safe to purge change log records. flag: --litestream-port env: ZERO_LITESTREAM_PORT default: --port + 2 Litestream Restore Parallelism The number of WAL files to download in parallel when performing the initial restore of the replica from the backup. flag: --litestream-restore-parallelism env: ZERO_LITESTREAM_RESTORE_PARALLELISM default: 48 Litestream Snapshot Backup Interval Hours The interval between snapshot backups of the replica. Snapshot backups make a full copy of the database to a new litestream generation. This improves restore time at the expense of bandwidth. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12 Log Format Use text for developer-friendly console logging and json for consumption by structured-logging services. flag: --log-format env: ZERO_LOG_FORMAT default: \"text\" values: text, json Log IVM Sampling How often to collect IVM metrics. 1 out of N requests will be sampled where N is this value. flag: --log-ivm-sampling env: ZERO_LOG_IVM_SAMPLING default: 5000 Log Level Sets the logging level for the application. flag: --log-level env: ZERO_LOG_LEVEL default: \"info\" values: debug, info, warn, error Log Slow Hydrate Threshold The number of milliseconds a query hydration must take to print a slow warning. flag: --log-slow-hydrate-threshold env: ZERO_LOG_SLOW_HYDRATE_THRESHOLD default: 100 Log Slow Row Threshold The number of ms a row must take to fetch from table-source before it is considered slow. flag: --log-slow-row-threshold env: ZERO_LOG_SLOW_ROW_THRESHOLD default: 2 Mutate API Key An optional secret used to authorize zero-cache to call the API server handling writes. This is sent from zero-cache to your mutate endpoint in an X-Api-Key header. flag: --mutate-api-key env: ZERO_MUTATE_API_KEY Mutate Forward Cookies If true, zero-cache will forward cookies from the request to zero-cache to your mutate endpoint. This is useful for passing authentication cookies to the API server. If false, cookies are not forwarded. flag: --mutate-forward-cookies env: ZERO_MUTATE_FORWARD_COOKIES default: false Mutate URL The URL of the API server to which zero-cache will push mutations. URLs are matched using URLPattern, a standard Web API. Pattern syntax (similar to Express routes): Exact URL match: \"https://api.example.com/mutate\" Any subdomain using wildcard: \"https://*.example.com/mutate\" Multiple subdomain levels: \"https://*.*.example.com/mutate\" Any path under a domain: \"https://api.example.com/*\" Named path parameters: \"https://api.example.com/:version/mutate\" Matches https://api.example.com/v1/mutate, https://api.example.com/v2/mutate, etc. Advanced patterns: Optional path segments: \"https://api.example.com/:path?\" Regex in segments (for specific patterns): \"https://api.example.com/:version(v\\\\d+)/mutate\" matches only v followed by digits. Multiple patterns can be specified, for example: https://api1.example.com/mutate,https://api2.example.com/mutate Query parameters and URL fragments (#) are ignored during matching. See URLPattern for full syntax. flag: --mutate-url env: ZERO_MUTATE_URL Number of Sync Workers The number of processes to use for view syncing. Leave this unset to use the maximum available parallelism. If set to 0, the server runs without sync workers, which is the configuration for running the replication-manager in multi-node deployments. flag: --num-sync-workers env: ZERO_NUM_SYNC_WORKERS Per User Mutation Limit Max The maximum mutations per user within the specified windowMs. flag: --per-user-mutation-limit-max env: ZERO_PER_USER_MUTATION_LIMIT_MAX Per User Mutation Limit Window (ms) The sliding window over which the perUserMutationLimitMax is enforced. flag: --per-user-mutation-limit-window-ms env: ZERO_PER_USER_MUTATION_LIMIT_WINDOW_MS default: 60000 Port The port for sync connections. flag: --port env: ZERO_PORT default: 4848 Query API Key An optional secret used to authorize zero-cache to call the API server handling queries. This is sent from zero-cache to your query endpoint in an X-Api-Key header. flag: --query-api-key env: ZERO_QUERY_API_KEY Query Forward Cookies If true, zero-cache will forward cookies from the request to zero-cache to your query endpoint. This is useful for passing authentication cookies to the API server. If false, cookies are not forwarded. flag: --query-forward-cookies env: ZERO_QUERY_FORWARD_COOKIES default: false Query Hydration Stats Track and log the number of rows considered by query hydrations which take longer than log-slow-hydrate-threshold milliseconds. This is useful for debugging and performance tuning. flag: --query-hydration-stats env: ZERO_QUERY_HYDRATION_STATS Query URL The URL of the API server to which zero-cache will send synced queries. URLs are matched using URLPattern, a standard Web API. Pattern syntax (similar to Express routes): Exact URL match: \"https://api.example.com/query\" Any subdomain using wildcard: \"https://*.example.com/query\" Multiple subdomain levels: \"https://*.*.example.com/query\" Any path under a domain: \"https://api.example.com/*\" Named path parameters: \"https://api.example.com/:version/query\" Matches https://api.example.com/v1/query, https://api.example.com/v2/query, etc. Advanced patterns: Optional path segments: \"https://api.example.com/:path?\" Regex in segments (for specific patterns): \"https://api.example.com/:version(v\\\\d+)/query\" matches only v followed by digits. Multiple patterns can be specified, for example: https://api1.example.com/query,https://api2.example.com/query Query parameters and URL fragments (#) are ignored during matching. See URLPattern for full syntax. flag: --query-url env: ZERO_QUERY_URL Replica File File path to the SQLite replica that zero-cache maintains. This can be lost, but if it is, zero-cache will have to re-replicate next time it starts up. flag: --replica-file env: ZERO_REPLICA_FILE default: \"zero.db\" Replica Vacuum Interval Hours Performs a VACUUM at server startup if the specified number of hours has elapsed since the last VACUUM (or initial-sync). The VACUUM operation is heavyweight and requires double the size of the db in disk space. If unspecified, VACUUM operations are not performed. flag: --replica-vacuum-interval-hours env: ZERO_REPLICA_VACUUM_INTERVAL_HOURS Replica Page Cache Size KiB The SQLite page cache size in kibibytes (KiB) for view-syncer connections. The page cache stores recently accessed database pages in memory to reduce disk I/O. Larger cache sizes improve performance for workloads that fit in cache. If unspecified, SQLite's default (~2 MB) is used. Note that the effective memory use of this setting will be: 2 * cache_size * num_cores, as each connection to the replica gets its own cache and each core maintains 2 connections. flag: --replica-page-cache-size-kib env: ZERO_REPLICA_PAGE_CACHE_SIZE_KIB Server Version The version string outputted to logs when the server starts up. flag: --server-version env: ZERO_SERVER_VERSION Storage DB Temp Dir Temporary directory for IVM operator storage. Leave unset to use os.tmpdir(). flag: --storage-db-tmp-dir env: ZERO_STORAGE_DB_TMP_DIR Task ID Globally unique identifier for the zero-cache instance. Setting this to a platform specific task identifier can be useful for debugging. If unspecified, zero-cache will attempt to extract the TaskARN if run from within an AWS ECS container, and otherwise use a random string. flag: --task-id env: ZERO_TASK_ID Upstream Max Connections The maximum number of connections to open to the upstream database for committing mutations. This is divided evenly amongst sync workers. In addition to this number, zero-cache uses one connection for the replication stream. Note that this number must allow for at least one connection per sync worker, or zero-cache will fail to start. See num-sync-workers. flag: --upstream-max-conns env: ZERO_UPSTREAM_MAX_CONNS default: 20 Websocket Compression Enable WebSocket per-message deflate compression. Compression can reduce bandwidth usage for sync traffic but increases CPU usage on both client and server. Disabled by default. See: https://github.com/websockets/ws#websocket-compression flag: --websocket-compression env: ZERO_WEBSOCKET_COMPRESSION default: false Websocket Compression Options JSON string containing WebSocket compression options. Only used if websocket-compression is enabled. Example: {\"zlibDeflateOptions\":{\"level\":3},\"threshold\":1024}. See https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback for available options. flag: --websocket-compression-options env: ZERO_WEBSOCKET_COMPRESSION_OPTIONS Yield Threshold (ms) The maximum amount of time in milliseconds that a sync worker will spend in IVM (processing query hydration and advancement) before yielding to the event loop. Lower values increase responsiveness and fairness at the cost of reduced throughput. flag: --yield-threshold-ms env: ZERO_YIELD_THRESHOLD_MS default: 10 Deprecated Flags Auth JWK A public key in JWK format used to verify JWTs. Only one of jwk, jwksUrl and secret may be set. flag: --auth-jwk env: ZERO_AUTH_JWK Auth JWKS URL A URL that returns a JWK set used to verify JWTs. Only one of jwk, jwksUrl and secret may be set. flag: --auth-jwks-url env: ZERO_AUTH_JWKS_URL Auth Secret A symmetric key used to verify JWTs. Only one of jwk, jwksUrl and secret may be set. flag: --auth-secret env: ZERO_AUTH_SECRET", + "content": "zero-cache is configured either via CLI flag or environment variable. There is no separate zero.config file. You can also see all available flags by running zero-cache --help. Required Flags Upstream DB The \"upstream\" authoritative postgres database. In the future we will support other types of upstream besides PG. flag: --upstream-db env: ZERO_UPSTREAM_DB required: true Admin Password A password used to administer zero-cache server, for example to access the /statz endpoint and the inspector. This is required in production (when NODE_ENV=production) because we want all Zero servers to be debuggable using admin tools by default, without needing a restart. But we also don't want to expose sensitive data using them. flag: --admin-password env: ZERO_ADMIN_PASSWORD required: in production (when NODE_ENV=production) Optional Flags App ID Unique identifier for the app. Multiple zero-cache apps can run on a single upstream database, each of which is isolated from the others, with its own permissions, sharding (future feature), and change/cvr databases. The metadata of an app is stored in an upstream schema with the same name, e.g. zero, and the metadata for each app shard, e.g. client and mutation ids, is stored in the {app-id}_{#} schema. (Currently there is only a single \"0\" shard, but this will change with sharding). The CVR and Change data are managed in schemas named {app-id}_{shard-num}/cvr and {app-id}_{shard-num}/cdc, respectively, allowing multiple apps and shards to share the same database instance (e.g. a Postgres \"cluster\") for CVR and Change management. Due to constraints on replication slot names, an App ID may only consist of lower-case letters, numbers, and the underscore character. Note that this option is used by both zero-cache and zero-deploy-permissions. flag: --app-id env: ZERO_APP_ID default: zero App Publications Postgres PUBLICATIONs that define the tables and columns to replicate. Publication names may not begin with an underscore, as zero reserves that prefix for internal use. If unspecified, zero-cache will create and use an internal publication that publishes all tables in the public schema, i.e.: CREATE PUBLICATION _{app-id}_public_0 FOR TABLES IN SCHEMA public; Note that changing the set of publications will result in resyncing the replica, which may involve downtime (replication lag) while the new replica is initializing. To change the set of publications without disrupting an existing app, a new app should be created. To use a custom publication, you can create one with: CREATE PUBLICATION zero_data FOR TABLES IN SCHEMA public; -- or, more selectively: CREATE PUBLICATION zero_data FOR TABLE users, orders; Then set the flag to that publication name, e.g.: ZERO_APP_PUBLICATIONS=zero_data. To specify multiple publications, separate them with commas, e.g.: ZERO_APP_PUBLICATIONS=zero_data1,zero_data2. flag: --app-publications env: ZERO_APP_PUBLICATIONS default: _{app-id}_public_0 Auto Reset Automatically wipe and resync the replica when replication is halted. This situation can occur for configurations in which the upstream database provider prohibits event trigger creation, preventing the zero-cache from being able to correctly replicate schema changes. For such configurations, an upstream schema change will instead result in halting replication with an error indicating that the replica needs to be reset. When auto-reset is enabled, zero-cache will respond to such situations by shutting down, and when restarted, resetting the replica and all synced clients. This is a heavy-weight operation and can result in user-visible slowness or downtime if compute resources are scarce. flag: --auto-reset env: ZERO_AUTO_RESET default: true Change DB The Postgres database used to store recent replication log entries, in order to sync multiple view-syncers without requiring multiple replication slots on the upstream database. If unspecified, the upstream-db will be used. flag: --change-db env: ZERO_CHANGE_DB Change Max Connections The maximum number of connections to open to the change database. This is used by the change-streamer for catching up zero-cache replication subscriptions. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS default: 5 Change Streamer Back Limit Heap Proportion The percentage of --max-old-space-size to use as a buffer for absorbing replication stream spikes. When the estimated amount of queued data exceeds this threshold, back pressure is applied to the replication stream, delaying downstream sync as a result. The threshold was determined empirically with load testing. Higher thresholds have resulted in OOMs. Note also that the byte-counting logic in the queue is strictly an underestimate of actual memory usage (but importantly, proportionally correct), so the queue is actually using more than what this proportion suggests. This parameter is exported as an emergency knob to reduce the size of the buffer in the event that the server OOMs from back pressure. Resist the urge to increase this proportion, as it is mainly useful for absorbing periodic spikes and does not meaningfully affect steady-state replication throughput; the latter is determined by other factors such as object serialization and PG throughput. In other words, the back pressure limit does not constrain replication throughput; rather, it protects the system when the upstream throughput exceeds the downstream throughput. flag: --change-streamer-back-limit-heap-proportion env: ZERO_CHANGE_STREAMER_BACK_LIMIT_HEAP_PROPORTION default: 0.04 Change Streamer Mode The mode for running or connecting to the change-streamer: dedicated: runs the change-streamer and shuts down when another change-streamer takes over the replication slot. This is appropriate in a single-node configuration, or for the replication-manager in a multi-node configuration. discover: connects to the change-streamer as internally advertised in the change-db. This is appropriate for the view-syncers in a multi-node setup. This may not work in all networking configurations (e.g., some private networking or port forwarding setups). Using ZERO_CHANGE_STREAMER_URI with an explicit routable hostname is recommended instead. This option is ignored if ZERO_CHANGE_STREAMER_URI is set. flag: --change-streamer-mode env: ZERO_CHANGE_STREAMER_MODE default: dedicated Change Streamer Port The port on which the change-streamer runs. This is an internal protocol between the replication-manager and view-syncers, which runs in the same process tree in local development or a single-node configuration. If unspecified, defaults to --port + 1. flag: --change-streamer-port env: ZERO_CHANGE_STREAMER_PORT default: --port + 1 Change Streamer Startup Delay (ms) The delay to wait before the change-streamer takes over the replication stream (i.e. the handoff during replication-manager updates), to allow load balancers to register the task as healthy based on healthcheck parameters. If a change stream request is received during this interval, the delay will be canceled and the takeover will happen immediately, since the incoming request indicates that the task is registered as a target. flag: --change-streamer-startup-delay-ms env: ZERO_CHANGE_STREAMER_STARTUP_DELAY_MS default: 15000 Change Streamer URI When set, connects to the change-streamer at the given URI. In a multi-node setup, this should be specified in view-syncer options, pointing to the replication-manager URI, which runs a change-streamer on port 4849. flag: --change-streamer-uri env: ZERO_CHANGE_STREAMER_URI CVR DB The Postgres database used to store CVRs. CVRs (client view records) keep track of the data synced to clients in order to determine the diff to send on reconnect. If unspecified, the upstream-db will be used. flag: --cvr-db env: ZERO_CVR_DB CVR Garbage Collection Inactivity Threshold Hours The duration after which an inactive CVR is eligible for garbage collection. Garbage collection is incremental and periodic, so eligible CVRs are not necessarily purged immediately. flag: --cvr-garbage-collection-inactivity-threshold-hours env: ZERO_CVR_GARBAGE_COLLECTION_INACTIVITY_THRESHOLD_HOURS default: 48 CVR Garbage Collection Initial Batch Size The initial number of CVRs to purge per garbage collection interval. This number is increased linearly if the rate of new CVRs exceeds the rate of purged CVRs, in order to reach a steady state. Setting this to 0 effectively disables CVR garbage collection. flag: --cvr-garbage-collection-initial-batch-size env: ZERO_CVR_GARBAGE_COLLECTION_INITIAL_BATCH_SIZE default: 25 CVR Garbage Collection Initial Interval Seconds The initial interval at which to check and garbage collect inactive CVRs. This interval is increased exponentially (up to 16 minutes) when there is nothing to purge. flag: --cvr-garbage-collection-initial-interval-seconds env: ZERO_CVR_GARBAGE_COLLECTION_INITIAL_INTERVAL_SECONDS default: 60 CVR Max Connections The maximum number of connections to open to the CVR database. This is divided evenly amongst sync workers. Note that this number must allow for at least one connection per sync worker, or zero-cache will fail to start. See num-sync-workers. flag: --cvr-max-conns env: ZERO_CVR_MAX_CONNS default: 30 Enable Query Planner Enable the query planner for optimizing ZQL queries. The query planner analyzes and optimizes query execution by determining the most efficient join strategies. You can disable the planner if it is picking bad strategies. flag: --enable-query-planner env: ZERO_ENABLE_QUERY_PLANNER default: true Enable Telemetry Zero collects anonymous telemetry data to help us understand usage. We collect: Zero version Uptime General machine information, like the number of CPUs, OS, CI/CD environment, etc. Information about usage, such as number of queries or mutations processed per hour. This is completely optional and can be disabled at any time. You can also opt-out by setting DO_NOT_TRACK=1. flag: --enable-telemetry env: ZERO_ENABLE_TELEMETRY default: true Initial Sync Table Copy Workers The number of parallel workers used to copy tables during initial sync. Each worker uses a database connection, copies a single table at a time, and buffers up to (approximately) 10 MB of table data in memory during initial sync. Increasing the number of workers may improve initial sync speed; however, local disk throughput (IOPS), upstream CPU, and network bandwidth may also be bottlenecks. flag: --initial-sync-table-copy-workers env: ZERO_INITIAL_SYNC_TABLE_COPY_WORKERS default: 5 Lazy Startup Delay starting the majority of zero-cache until first request. This is mainly intended to avoid connecting to Postgres replication stream until the first request is received, which can be useful i.e., for preview instances. Currently only supported in single-node mode. flag: --lazy-startup env: ZERO_LAZY_STARTUP default: false Litestream Backup URL The location of the litestream backup, usually an s3:// URL. This is only consulted by the replication-manager. view-syncers receive this information from the replication-manager. In multi-node deployments, this is required on the replication-manager so view-syncers can reserve snapshots; in single-node deployments it is optional. flag: --litestream-backup-url env: ZERO_LITESTREAM_BACKUP_URL Litestream Checkpoint Threshold MB The size of the WAL file at which to perform an SQlite checkpoint to apply the writes in the WAL to the main database file. Each checkpoint creates a new WAL segment file that will be backed up by litestream. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40 Litestream Config Path Path to the litestream yaml config file. zero-cache will run this with its environment variables, which can be referenced in the file via ${ENV} substitution, for example: ZERO_REPLICA_FILE for the db Path ZERO_LITESTREAM_BACKUP_LOCATION for the db replica url ZERO_LITESTREAM_LOG_LEVEL for the log Level ZERO_LOG_FORMAT for the log type flag: --litestream-config-path env: ZERO_LITESTREAM_CONFIG_PATH default: ./src/services/litestream/config.yml Litestream Executable Path to the litestream executable. This option has no effect if litestream-backup-url is unspecified. flag: --litestream-executable env: ZERO_LITESTREAM_EXECUTABLE Litestream Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Maximum Checkpoint Page Count The WAL page count at which SQLite performs a RESTART checkpoint, which blocks writers until complete. Defaults to minCheckpointPageCount * 10. Set to 0 to disable RESTART checkpoints entirely. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 Litestream Minimum Checkpoint Page Count The WAL page count at which SQLite attempts a PASSIVE checkpoint, which transfers pages to the main database file without blocking writers. Defaults to checkpointThresholdMB * 250 (since SQLite page size is 4KB). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250 Litestream Multipart Concurrency The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY default: 48 Litestream Multipart Size The size of each part when uploading or downloading the snapshot with --litestream-multipart-concurrency. Note that up to concurrency * size bytes of memory are used when backing up or restoring the snapshot. flag: --litestream-multipart-size env: ZERO_LITESTREAM_MULTIPART_SIZE default: 16777216 (16 MiB) Litestream Log Level flag: --litestream-log-level env: ZERO_LITESTREAM_LOG_LEVEL default: warn values: debug, info, warn, error Litestream Port Port on which litestream exports metrics, used to determine the replication watermark up to which it is safe to purge change log records. flag: --litestream-port env: ZERO_LITESTREAM_PORT default: --port + 2 Litestream Restore Parallelism The number of WAL files to download in parallel when performing the initial restore of the replica from the backup. flag: --litestream-restore-parallelism env: ZERO_LITESTREAM_RESTORE_PARALLELISM default: 48 Litestream Snapshot Backup Interval Hours The interval between snapshot backups of the replica. Snapshot backups make a full copy of the database to a new litestream generation. This improves restore time at the expense of bandwidth. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12 Log Format Use text for developer-friendly console logging and json for consumption by structured-logging services. flag: --log-format env: ZERO_LOG_FORMAT default: \"text\" values: text, json Log IVM Sampling How often to collect IVM metrics. 1 out of N requests will be sampled where N is this value. flag: --log-ivm-sampling env: ZERO_LOG_IVM_SAMPLING default: 5000 Log Level Sets the logging level for the application. flag: --log-level env: ZERO_LOG_LEVEL default: \"info\" values: debug, info, warn, error Log Slow Hydrate Threshold The number of milliseconds a query hydration must take to print a slow warning. flag: --log-slow-hydrate-threshold env: ZERO_LOG_SLOW_HYDRATE_THRESHOLD default: 100 Log Slow Row Threshold The number of ms a row must take to fetch from table-source before it is considered slow. flag: --log-slow-row-threshold env: ZERO_LOG_SLOW_ROW_THRESHOLD default: 2 Mutate API Key An optional secret used to authorize zero-cache to call the API server handling writes. This is sent from zero-cache to your mutate endpoint in an X-Api-Key header. flag: --mutate-api-key env: ZERO_MUTATE_API_KEY Mutate Forward Cookies If true, zero-cache will forward cookies from the request to zero-cache to your mutate endpoint. This is useful for passing authentication cookies to the API server. If false, cookies are not forwarded. flag: --mutate-forward-cookies env: ZERO_MUTATE_FORWARD_COOKIES default: false Mutate URL The URL of the API server to which zero-cache will push mutations. URLs are matched using URLPattern, a standard Web API. Pattern syntax (similar to Express routes): Exact URL match: \"https://api.example.com/mutate\" Any subdomain using wildcard: \"https://*.example.com/mutate\" Multiple subdomain levels: \"https://*.*.example.com/mutate\" Any path under a domain: \"https://api.example.com/*\" Named path parameters: \"https://api.example.com/:version/mutate\" Matches https://api.example.com/v1/mutate, https://api.example.com/v2/mutate, etc. Advanced patterns: Optional path segments: \"https://api.example.com/:path?\" Regex in segments (for specific patterns): \"https://api.example.com/:version(v\\\\d+)/mutate\" matches only v followed by digits. Multiple patterns can be specified, for example: https://api1.example.com/mutate,https://api2.example.com/mutate Query parameters and URL fragments (#) are ignored during matching. See URLPattern for full syntax. flag: --mutate-url env: ZERO_MUTATE_URL Number of Sync Workers The number of processes to use for view syncing. Leave this unset to use the maximum available parallelism. If set to 0, the server runs without sync workers, which is the configuration for running the replication-manager in multi-node deployments. flag: --num-sync-workers env: ZERO_NUM_SYNC_WORKERS Per User Mutation Limit Max The maximum mutations per user within the specified windowMs. flag: --per-user-mutation-limit-max env: ZERO_PER_USER_MUTATION_LIMIT_MAX Per User Mutation Limit Window (ms) The sliding window over which the perUserMutationLimitMax is enforced. flag: --per-user-mutation-limit-window-ms env: ZERO_PER_USER_MUTATION_LIMIT_WINDOW_MS default: 60000 Port The port for sync connections. flag: --port env: ZERO_PORT default: 4848 Query API Key An optional secret used to authorize zero-cache to call the API server handling queries. This is sent from zero-cache to your query endpoint in an X-Api-Key header. flag: --query-api-key env: ZERO_QUERY_API_KEY Query Forward Cookies If true, zero-cache will forward cookies from the request to zero-cache to your query endpoint. This is useful for passing authentication cookies to the API server. If false, cookies are not forwarded. flag: --query-forward-cookies env: ZERO_QUERY_FORWARD_COOKIES default: false Query Hydration Stats Track and log the number of rows considered by query hydrations which take longer than log-slow-hydrate-threshold milliseconds. This is useful for debugging and performance tuning. flag: --query-hydration-stats env: ZERO_QUERY_HYDRATION_STATS Query URL The URL of the API server to which zero-cache will send synced queries. URLs are matched using URLPattern, a standard Web API. Pattern syntax (similar to Express routes): Exact URL match: \"https://api.example.com/query\" Any subdomain using wildcard: \"https://*.example.com/query\" Multiple subdomain levels: \"https://*.*.example.com/query\" Any path under a domain: \"https://api.example.com/*\" Named path parameters: \"https://api.example.com/:version/query\" Matches https://api.example.com/v1/query, https://api.example.com/v2/query, etc. Advanced patterns: Optional path segments: \"https://api.example.com/:path?\" Regex in segments (for specific patterns): \"https://api.example.com/:version(v\\\\d+)/query\" matches only v followed by digits. Multiple patterns can be specified, for example: https://api1.example.com/query,https://api2.example.com/query Query parameters and URL fragments (#) are ignored during matching. See URLPattern for full syntax. flag: --query-url env: ZERO_QUERY_URL Replica File File path to the SQLite replica that zero-cache maintains. This can be lost, but if it is, zero-cache will have to re-replicate next time it starts up. flag: --replica-file env: ZERO_REPLICA_FILE default: \"zero.db\" Replica Vacuum Interval Hours Performs a VACUUM at server startup if the specified number of hours has elapsed since the last VACUUM (or initial-sync). The VACUUM operation is heavyweight and requires double the size of the db in disk space. If unspecified, VACUUM operations are not performed. flag: --replica-vacuum-interval-hours env: ZERO_REPLICA_VACUUM_INTERVAL_HOURS Replica Page Cache Size KiB The SQLite page cache size in kibibytes (KiB) for view-syncer connections. The page cache stores recently accessed database pages in memory to reduce disk I/O. Larger cache sizes improve performance for workloads that fit in cache. If unspecified, SQLite's default (~2 MB) is used. Note that the effective memory use of this setting will be: 2 * cache_size * num_cores, as each connection to the replica gets its own cache and each core maintains 2 connections. flag: --replica-page-cache-size-kib env: ZERO_REPLICA_PAGE_CACHE_SIZE_KIB Server Version The version string outputted to logs when the server starts up. flag: --server-version env: ZERO_SERVER_VERSION Storage DB Temp Dir Temporary directory for IVM operator storage. Leave unset to use os.tmpdir(). flag: --storage-db-tmp-dir env: ZERO_STORAGE_DB_TMP_DIR Task ID Globally unique identifier for the zero-cache instance. Setting this to a platform specific task identifier can be useful for debugging. If unspecified, zero-cache will attempt to extract the TaskARN if run from within an AWS ECS container, and otherwise use a random string. flag: --task-id env: ZERO_TASK_ID Upstream Max Connections The maximum number of connections to open to the upstream database for committing mutations. This is divided evenly amongst sync workers. In addition to this number, zero-cache uses one connection for the replication stream. Note that this number must allow for at least one connection per sync worker, or zero-cache will fail to start. See num-sync-workers. flag: --upstream-max-conns env: ZERO_UPSTREAM_MAX_CONNS default: 20 Websocket Compression Enable WebSocket per-message deflate compression. Compression can reduce bandwidth usage for sync traffic but increases CPU usage on both client and server. Disabled by default. See: https://github.com/websockets/ws#websocket-compression flag: --websocket-compression env: ZERO_WEBSOCKET_COMPRESSION default: false Websocket Compression Options JSON string containing WebSocket compression options. Only used if websocket-compression is enabled. Example: {\"zlibDeflateOptions\":{\"level\":3},\"threshold\":1024}. See https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback for available options. flag: --websocket-compression-options env: ZERO_WEBSOCKET_COMPRESSION_OPTIONS Websocket Max Payload Bytes Maximum size of incoming WebSocket messages in bytes. Messages exceeding this limit are rejected before parsing. Default: 10MB (10485760). flag: --websocket-max-payload-bytes env: ZERO_WEBSOCKET_MAX_PAYLOAD_BYTES Yield Threshold (ms) The maximum amount of time in milliseconds that a sync worker will spend in IVM (processing query hydration and advancement) before yielding to the event loop. Lower values increase responsiveness and fairness at the cost of reduced throughput. flag: --yield-threshold-ms env: ZERO_YIELD_THRESHOLD_MS default: 10 Deprecated Flags Auth JWK A public key in JWK format used to verify JWTs. Only one of jwk, jwksUrl and secret may be set. flag: --auth-jwk env: ZERO_AUTH_JWK Auth JWKS URL A URL that returns a JWK set used to verify JWTs. Only one of jwk, jwksUrl and secret may be set. flag: --auth-jwks-url env: ZERO_AUTH_JWKS_URL Auth Secret A symmetric key used to verify JWTs. Only one of jwk, jwksUrl and secret may be set. flag: --auth-secret env: ZERO_AUTH_SECRET", "headings": [ { "text": "Required Flags", @@ -6009,6 +6089,10 @@ "text": "Change Max Connections", "id": "change-max-connections" }, + { + "text": "Change Streamer Back Limit Heap Proportion", + "id": "change-streamer-back-limit-heap-proportion" + }, { "text": "Change Streamer Mode", "id": "change-streamer-mode" @@ -6213,6 +6297,10 @@ "text": "Websocket Compression Options", "id": "websocket-compression-options" }, + { + "text": "Websocket Max Payload Bytes", + "id": "websocket-max-payload-bytes" + }, { "text": "Yield Threshold (ms)", "id": "yield-threshold-ms" @@ -6237,7 +6325,7 @@ "kind": "page" }, { - "id": "446-zero-cache-config#required-flags", + "id": "452-zero-cache-config#required-flags", "title": "zero-cache Config", "searchTitle": "Required Flags", "sectionTitle": "Required Flags", @@ -6247,7 +6335,7 @@ "kind": "section" }, { - "id": "447-zero-cache-config#upstream-db", + "id": "453-zero-cache-config#upstream-db", "title": "zero-cache Config", "searchTitle": "Upstream DB", "sectionTitle": "Upstream DB", @@ -6257,7 +6345,7 @@ "kind": "section" }, { - "id": "448-zero-cache-config#admin-password", + "id": "454-zero-cache-config#admin-password", "title": "zero-cache Config", "searchTitle": "Admin Password", "sectionTitle": "Admin Password", @@ -6267,17 +6355,17 @@ "kind": "section" }, { - "id": "449-zero-cache-config#optional-flags", + "id": "455-zero-cache-config#optional-flags", "title": "zero-cache Config", "searchTitle": "Optional Flags", "sectionTitle": "Optional Flags", "sectionId": "optional-flags", "url": "/docs/zero-cache-config", - "content": "App ID Unique identifier for the app. Multiple zero-cache apps can run on a single upstream database, each of which is isolated from the others, with its own permissions, sharding (future feature), and change/cvr databases. The metadata of an app is stored in an upstream schema with the same name, e.g. zero, and the metadata for each app shard, e.g. client and mutation ids, is stored in the {app-id}_{#} schema. (Currently there is only a single \"0\" shard, but this will change with sharding). The CVR and Change data are managed in schemas named {app-id}_{shard-num}/cvr and {app-id}_{shard-num}/cdc, respectively, allowing multiple apps and shards to share the same database instance (e.g. a Postgres \"cluster\") for CVR and Change management. Due to constraints on replication slot names, an App ID may only consist of lower-case letters, numbers, and the underscore character. Note that this option is used by both zero-cache and zero-deploy-permissions. flag: --app-id env: ZERO_APP_ID default: zero App Publications Postgres PUBLICATIONs that define the tables and columns to replicate. Publication names may not begin with an underscore, as zero reserves that prefix for internal use. If unspecified, zero-cache will create and use an internal publication that publishes all tables in the public schema, i.e.: CREATE PUBLICATION _{app-id}_public_0 FOR TABLES IN SCHEMA public; Note that changing the set of publications will result in resyncing the replica, which may involve downtime (replication lag) while the new replica is initializing. To change the set of publications without disrupting an existing app, a new app should be created. To use a custom publication, you can create one with: CREATE PUBLICATION zero_data FOR TABLES IN SCHEMA public; -- or, more selectively: CREATE PUBLICATION zero_data FOR TABLE users, orders; Then set the flag to that publication name, e.g.: ZERO_APP_PUBLICATIONS=zero_data. To specify multiple publications, separate them with commas, e.g.: ZERO_APP_PUBLICATIONS=zero_data1,zero_data2. flag: --app-publications env: ZERO_APP_PUBLICATIONS default: _{app-id}_public_0 Auto Reset Automatically wipe and resync the replica when replication is halted. This situation can occur for configurations in which the upstream database provider prohibits event trigger creation, preventing the zero-cache from being able to correctly replicate schema changes. For such configurations, an upstream schema change will instead result in halting replication with an error indicating that the replica needs to be reset. When auto-reset is enabled, zero-cache will respond to such situations by shutting down, and when restarted, resetting the replica and all synced clients. This is a heavy-weight operation and can result in user-visible slowness or downtime if compute resources are scarce. flag: --auto-reset env: ZERO_AUTO_RESET default: true Change DB The Postgres database used to store recent replication log entries, in order to sync multiple view-syncers without requiring multiple replication slots on the upstream database. If unspecified, the upstream-db will be used. flag: --change-db env: ZERO_CHANGE_DB Change Max Connections The maximum number of connections to open to the change database. This is used by the change-streamer for catching up zero-cache replication subscriptions. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS default: 5 Change Streamer Mode The mode for running or connecting to the change-streamer: dedicated: runs the change-streamer and shuts down when another change-streamer takes over the replication slot. This is appropriate in a single-node configuration, or for the replication-manager in a multi-node configuration. discover: connects to the change-streamer as internally advertised in the change-db. This is appropriate for the view-syncers in a multi-node setup. This may not work in all networking configurations (e.g., some private networking or port forwarding setups). Using ZERO_CHANGE_STREAMER_URI with an explicit routable hostname is recommended instead. This option is ignored if ZERO_CHANGE_STREAMER_URI is set. flag: --change-streamer-mode env: ZERO_CHANGE_STREAMER_MODE default: dedicated Change Streamer Port The port on which the change-streamer runs. This is an internal protocol between the replication-manager and view-syncers, which runs in the same process tree in local development or a single-node configuration. If unspecified, defaults to --port + 1. flag: --change-streamer-port env: ZERO_CHANGE_STREAMER_PORT default: --port + 1 Change Streamer Startup Delay (ms) The delay to wait before the change-streamer takes over the replication stream (i.e. the handoff during replication-manager updates), to allow load balancers to register the task as healthy based on healthcheck parameters. If a change stream request is received during this interval, the delay will be canceled and the takeover will happen immediately, since the incoming request indicates that the task is registered as a target. flag: --change-streamer-startup-delay-ms env: ZERO_CHANGE_STREAMER_STARTUP_DELAY_MS default: 15000 Change Streamer URI When set, connects to the change-streamer at the given URI. In a multi-node setup, this should be specified in view-syncer options, pointing to the replication-manager URI, which runs a change-streamer on port 4849. flag: --change-streamer-uri env: ZERO_CHANGE_STREAMER_URI CVR DB The Postgres database used to store CVRs. CVRs (client view records) keep track of the data synced to clients in order to determine the diff to send on reconnect. If unspecified, the upstream-db will be used. flag: --cvr-db env: ZERO_CVR_DB CVR Garbage Collection Inactivity Threshold Hours The duration after which an inactive CVR is eligible for garbage collection. Garbage collection is incremental and periodic, so eligible CVRs are not necessarily purged immediately. flag: --cvr-garbage-collection-inactivity-threshold-hours env: ZERO_CVR_GARBAGE_COLLECTION_INACTIVITY_THRESHOLD_HOURS default: 48 CVR Garbage Collection Initial Batch Size The initial number of CVRs to purge per garbage collection interval. This number is increased linearly if the rate of new CVRs exceeds the rate of purged CVRs, in order to reach a steady state. Setting this to 0 effectively disables CVR garbage collection. flag: --cvr-garbage-collection-initial-batch-size env: ZERO_CVR_GARBAGE_COLLECTION_INITIAL_BATCH_SIZE default: 25 CVR Garbage Collection Initial Interval Seconds The initial interval at which to check and garbage collect inactive CVRs. This interval is increased exponentially (up to 16 minutes) when there is nothing to purge. flag: --cvr-garbage-collection-initial-interval-seconds env: ZERO_CVR_GARBAGE_COLLECTION_INITIAL_INTERVAL_SECONDS default: 60 CVR Max Connections The maximum number of connections to open to the CVR database. This is divided evenly amongst sync workers. Note that this number must allow for at least one connection per sync worker, or zero-cache will fail to start. See num-sync-workers. flag: --cvr-max-conns env: ZERO_CVR_MAX_CONNS default: 30 Enable Query Planner Enable the query planner for optimizing ZQL queries. The query planner analyzes and optimizes query execution by determining the most efficient join strategies. You can disable the planner if it is picking bad strategies. flag: --enable-query-planner env: ZERO_ENABLE_QUERY_PLANNER default: true Enable Telemetry Zero collects anonymous telemetry data to help us understand usage. We collect: Zero version Uptime General machine information, like the number of CPUs, OS, CI/CD environment, etc. Information about usage, such as number of queries or mutations processed per hour. This is completely optional and can be disabled at any time. You can also opt-out by setting DO_NOT_TRACK=1. flag: --enable-telemetry env: ZERO_ENABLE_TELEMETRY default: true Initial Sync Table Copy Workers The number of parallel workers used to copy tables during initial sync. Each worker uses a database connection, copies a single table at a time, and buffers up to (approximately) 10 MB of table data in memory during initial sync. Increasing the number of workers may improve initial sync speed; however, local disk throughput (IOPS), upstream CPU, and network bandwidth may also be bottlenecks. flag: --initial-sync-table-copy-workers env: ZERO_INITIAL_SYNC_TABLE_COPY_WORKERS default: 5 Lazy Startup Delay starting the majority of zero-cache until first request. This is mainly intended to avoid connecting to Postgres replication stream until the first request is received, which can be useful i.e., for preview instances. Currently only supported in single-node mode. flag: --lazy-startup env: ZERO_LAZY_STARTUP default: false Litestream Backup URL The location of the litestream backup, usually an s3:// URL. This is only consulted by the replication-manager. view-syncers receive this information from the replication-manager. In multi-node deployments, this is required on the replication-manager so view-syncers can reserve snapshots; in single-node deployments it is optional. flag: --litestream-backup-url env: ZERO_LITESTREAM_BACKUP_URL Litestream Checkpoint Threshold MB The size of the WAL file at which to perform an SQlite checkpoint to apply the writes in the WAL to the main database file. Each checkpoint creates a new WAL segment file that will be backed up by litestream. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40 Litestream Config Path Path to the litestream yaml config file. zero-cache will run this with its environment variables, which can be referenced in the file via ${ENV} substitution, for example: ZERO_REPLICA_FILE for the db Path ZERO_LITESTREAM_BACKUP_LOCATION for the db replica url ZERO_LITESTREAM_LOG_LEVEL for the log Level ZERO_LOG_FORMAT for the log type flag: --litestream-config-path env: ZERO_LITESTREAM_CONFIG_PATH default: ./src/services/litestream/config.yml Litestream Executable Path to the litestream executable. This option has no effect if litestream-backup-url is unspecified. flag: --litestream-executable env: ZERO_LITESTREAM_EXECUTABLE Litestream Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Maximum Checkpoint Page Count The WAL page count at which SQLite performs a RESTART checkpoint, which blocks writers until complete. Defaults to minCheckpointPageCount * 10. Set to 0 to disable RESTART checkpoints entirely. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 Litestream Minimum Checkpoint Page Count The WAL page count at which SQLite attempts a PASSIVE checkpoint, which transfers pages to the main database file without blocking writers. Defaults to checkpointThresholdMB * 250 (since SQLite page size is 4KB). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250 Litestream Multipart Concurrency The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY default: 48 Litestream Multipart Size The size of each part when uploading or downloading the snapshot with --litestream-multipart-concurrency. Note that up to concurrency * size bytes of memory are used when backing up or restoring the snapshot. flag: --litestream-multipart-size env: ZERO_LITESTREAM_MULTIPART_SIZE default: 16777216 (16 MiB) Litestream Log Level flag: --litestream-log-level env: ZERO_LITESTREAM_LOG_LEVEL default: warn values: debug, info, warn, error Litestream Port Port on which litestream exports metrics, used to determine the replication watermark up to which it is safe to purge change log records. flag: --litestream-port env: ZERO_LITESTREAM_PORT default: --port + 2 Litestream Restore Parallelism The number of WAL files to download in parallel when performing the initial restore of the replica from the backup. flag: --litestream-restore-parallelism env: ZERO_LITESTREAM_RESTORE_PARALLELISM default: 48 Litestream Snapshot Backup Interval Hours The interval between snapshot backups of the replica. Snapshot backups make a full copy of the database to a new litestream generation. This improves restore time at the expense of bandwidth. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12 Log Format Use text for developer-friendly console logging and json for consumption by structured-logging services. flag: --log-format env: ZERO_LOG_FORMAT default: \"text\" values: text, json Log IVM Sampling How often to collect IVM metrics. 1 out of N requests will be sampled where N is this value. flag: --log-ivm-sampling env: ZERO_LOG_IVM_SAMPLING default: 5000 Log Level Sets the logging level for the application. flag: --log-level env: ZERO_LOG_LEVEL default: \"info\" values: debug, info, warn, error Log Slow Hydrate Threshold The number of milliseconds a query hydration must take to print a slow warning. flag: --log-slow-hydrate-threshold env: ZERO_LOG_SLOW_HYDRATE_THRESHOLD default: 100 Log Slow Row Threshold The number of ms a row must take to fetch from table-source before it is considered slow. flag: --log-slow-row-threshold env: ZERO_LOG_SLOW_ROW_THRESHOLD default: 2 Mutate API Key An optional secret used to authorize zero-cache to call the API server handling writes. This is sent from zero-cache to your mutate endpoint in an X-Api-Key header. flag: --mutate-api-key env: ZERO_MUTATE_API_KEY Mutate Forward Cookies If true, zero-cache will forward cookies from the request to zero-cache to your mutate endpoint. This is useful for passing authentication cookies to the API server. If false, cookies are not forwarded. flag: --mutate-forward-cookies env: ZERO_MUTATE_FORWARD_COOKIES default: false Mutate URL The URL of the API server to which zero-cache will push mutations. URLs are matched using URLPattern, a standard Web API. Pattern syntax (similar to Express routes): Exact URL match: \"https://api.example.com/mutate\" Any subdomain using wildcard: \"https://*.example.com/mutate\" Multiple subdomain levels: \"https://*.*.example.com/mutate\" Any path under a domain: \"https://api.example.com/*\" Named path parameters: \"https://api.example.com/:version/mutate\" Matches https://api.example.com/v1/mutate, https://api.example.com/v2/mutate, etc. Advanced patterns: Optional path segments: \"https://api.example.com/:path?\" Regex in segments (for specific patterns): \"https://api.example.com/:version(v\\\\d+)/mutate\" matches only v followed by digits. Multiple patterns can be specified, for example: https://api1.example.com/mutate,https://api2.example.com/mutate Query parameters and URL fragments (#) are ignored during matching. See URLPattern for full syntax. flag: --mutate-url env: ZERO_MUTATE_URL Number of Sync Workers The number of processes to use for view syncing. Leave this unset to use the maximum available parallelism. If set to 0, the server runs without sync workers, which is the configuration for running the replication-manager in multi-node deployments. flag: --num-sync-workers env: ZERO_NUM_SYNC_WORKERS Per User Mutation Limit Max The maximum mutations per user within the specified windowMs. flag: --per-user-mutation-limit-max env: ZERO_PER_USER_MUTATION_LIMIT_MAX Per User Mutation Limit Window (ms) The sliding window over which the perUserMutationLimitMax is enforced. flag: --per-user-mutation-limit-window-ms env: ZERO_PER_USER_MUTATION_LIMIT_WINDOW_MS default: 60000 Port The port for sync connections. flag: --port env: ZERO_PORT default: 4848 Query API Key An optional secret used to authorize zero-cache to call the API server handling queries. This is sent from zero-cache to your query endpoint in an X-Api-Key header. flag: --query-api-key env: ZERO_QUERY_API_KEY Query Forward Cookies If true, zero-cache will forward cookies from the request to zero-cache to your query endpoint. This is useful for passing authentication cookies to the API server. If false, cookies are not forwarded. flag: --query-forward-cookies env: ZERO_QUERY_FORWARD_COOKIES default: false Query Hydration Stats Track and log the number of rows considered by query hydrations which take longer than log-slow-hydrate-threshold milliseconds. This is useful for debugging and performance tuning. flag: --query-hydration-stats env: ZERO_QUERY_HYDRATION_STATS Query URL The URL of the API server to which zero-cache will send synced queries. URLs are matched using URLPattern, a standard Web API. Pattern syntax (similar to Express routes): Exact URL match: \"https://api.example.com/query\" Any subdomain using wildcard: \"https://*.example.com/query\" Multiple subdomain levels: \"https://*.*.example.com/query\" Any path under a domain: \"https://api.example.com/*\" Named path parameters: \"https://api.example.com/:version/query\" Matches https://api.example.com/v1/query, https://api.example.com/v2/query, etc. Advanced patterns: Optional path segments: \"https://api.example.com/:path?\" Regex in segments (for specific patterns): \"https://api.example.com/:version(v\\\\d+)/query\" matches only v followed by digits. Multiple patterns can be specified, for example: https://api1.example.com/query,https://api2.example.com/query Query parameters and URL fragments (#) are ignored during matching. See URLPattern for full syntax. flag: --query-url env: ZERO_QUERY_URL Replica File File path to the SQLite replica that zero-cache maintains. This can be lost, but if it is, zero-cache will have to re-replicate next time it starts up. flag: --replica-file env: ZERO_REPLICA_FILE default: \"zero.db\" Replica Vacuum Interval Hours Performs a VACUUM at server startup if the specified number of hours has elapsed since the last VACUUM (or initial-sync). The VACUUM operation is heavyweight and requires double the size of the db in disk space. If unspecified, VACUUM operations are not performed. flag: --replica-vacuum-interval-hours env: ZERO_REPLICA_VACUUM_INTERVAL_HOURS Replica Page Cache Size KiB The SQLite page cache size in kibibytes (KiB) for view-syncer connections. The page cache stores recently accessed database pages in memory to reduce disk I/O. Larger cache sizes improve performance for workloads that fit in cache. If unspecified, SQLite's default (~2 MB) is used. Note that the effective memory use of this setting will be: 2 * cache_size * num_cores, as each connection to the replica gets its own cache and each core maintains 2 connections. flag: --replica-page-cache-size-kib env: ZERO_REPLICA_PAGE_CACHE_SIZE_KIB Server Version The version string outputted to logs when the server starts up. flag: --server-version env: ZERO_SERVER_VERSION Storage DB Temp Dir Temporary directory for IVM operator storage. Leave unset to use os.tmpdir(). flag: --storage-db-tmp-dir env: ZERO_STORAGE_DB_TMP_DIR Task ID Globally unique identifier for the zero-cache instance. Setting this to a platform specific task identifier can be useful for debugging. If unspecified, zero-cache will attempt to extract the TaskARN if run from within an AWS ECS container, and otherwise use a random string. flag: --task-id env: ZERO_TASK_ID Upstream Max Connections The maximum number of connections to open to the upstream database for committing mutations. This is divided evenly amongst sync workers. In addition to this number, zero-cache uses one connection for the replication stream. Note that this number must allow for at least one connection per sync worker, or zero-cache will fail to start. See num-sync-workers. flag: --upstream-max-conns env: ZERO_UPSTREAM_MAX_CONNS default: 20 Websocket Compression Enable WebSocket per-message deflate compression. Compression can reduce bandwidth usage for sync traffic but increases CPU usage on both client and server. Disabled by default. See: https://github.com/websockets/ws#websocket-compression flag: --websocket-compression env: ZERO_WEBSOCKET_COMPRESSION default: false Websocket Compression Options JSON string containing WebSocket compression options. Only used if websocket-compression is enabled. Example: {\"zlibDeflateOptions\":{\"level\":3},\"threshold\":1024}. See https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback for available options. flag: --websocket-compression-options env: ZERO_WEBSOCKET_COMPRESSION_OPTIONS Yield Threshold (ms) The maximum amount of time in milliseconds that a sync worker will spend in IVM (processing query hydration and advancement) before yielding to the event loop. Lower values increase responsiveness and fairness at the cost of reduced throughput. flag: --yield-threshold-ms env: ZERO_YIELD_THRESHOLD_MS default: 10", + "content": "App ID Unique identifier for the app. Multiple zero-cache apps can run on a single upstream database, each of which is isolated from the others, with its own permissions, sharding (future feature), and change/cvr databases. The metadata of an app is stored in an upstream schema with the same name, e.g. zero, and the metadata for each app shard, e.g. client and mutation ids, is stored in the {app-id}_{#} schema. (Currently there is only a single \"0\" shard, but this will change with sharding). The CVR and Change data are managed in schemas named {app-id}_{shard-num}/cvr and {app-id}_{shard-num}/cdc, respectively, allowing multiple apps and shards to share the same database instance (e.g. a Postgres \"cluster\") for CVR and Change management. Due to constraints on replication slot names, an App ID may only consist of lower-case letters, numbers, and the underscore character. Note that this option is used by both zero-cache and zero-deploy-permissions. flag: --app-id env: ZERO_APP_ID default: zero App Publications Postgres PUBLICATIONs that define the tables and columns to replicate. Publication names may not begin with an underscore, as zero reserves that prefix for internal use. If unspecified, zero-cache will create and use an internal publication that publishes all tables in the public schema, i.e.: CREATE PUBLICATION _{app-id}_public_0 FOR TABLES IN SCHEMA public; Note that changing the set of publications will result in resyncing the replica, which may involve downtime (replication lag) while the new replica is initializing. To change the set of publications without disrupting an existing app, a new app should be created. To use a custom publication, you can create one with: CREATE PUBLICATION zero_data FOR TABLES IN SCHEMA public; -- or, more selectively: CREATE PUBLICATION zero_data FOR TABLE users, orders; Then set the flag to that publication name, e.g.: ZERO_APP_PUBLICATIONS=zero_data. To specify multiple publications, separate them with commas, e.g.: ZERO_APP_PUBLICATIONS=zero_data1,zero_data2. flag: --app-publications env: ZERO_APP_PUBLICATIONS default: _{app-id}_public_0 Auto Reset Automatically wipe and resync the replica when replication is halted. This situation can occur for configurations in which the upstream database provider prohibits event trigger creation, preventing the zero-cache from being able to correctly replicate schema changes. For such configurations, an upstream schema change will instead result in halting replication with an error indicating that the replica needs to be reset. When auto-reset is enabled, zero-cache will respond to such situations by shutting down, and when restarted, resetting the replica and all synced clients. This is a heavy-weight operation and can result in user-visible slowness or downtime if compute resources are scarce. flag: --auto-reset env: ZERO_AUTO_RESET default: true Change DB The Postgres database used to store recent replication log entries, in order to sync multiple view-syncers without requiring multiple replication slots on the upstream database. If unspecified, the upstream-db will be used. flag: --change-db env: ZERO_CHANGE_DB Change Max Connections The maximum number of connections to open to the change database. This is used by the change-streamer for catching up zero-cache replication subscriptions. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS default: 5 Change Streamer Back Limit Heap Proportion The percentage of --max-old-space-size to use as a buffer for absorbing replication stream spikes. When the estimated amount of queued data exceeds this threshold, back pressure is applied to the replication stream, delaying downstream sync as a result. The threshold was determined empirically with load testing. Higher thresholds have resulted in OOMs. Note also that the byte-counting logic in the queue is strictly an underestimate of actual memory usage (but importantly, proportionally correct), so the queue is actually using more than what this proportion suggests. This parameter is exported as an emergency knob to reduce the size of the buffer in the event that the server OOMs from back pressure. Resist the urge to increase this proportion, as it is mainly useful for absorbing periodic spikes and does not meaningfully affect steady-state replication throughput; the latter is determined by other factors such as object serialization and PG throughput. In other words, the back pressure limit does not constrain replication throughput; rather, it protects the system when the upstream throughput exceeds the downstream throughput. flag: --change-streamer-back-limit-heap-proportion env: ZERO_CHANGE_STREAMER_BACK_LIMIT_HEAP_PROPORTION default: 0.04 Change Streamer Mode The mode for running or connecting to the change-streamer: dedicated: runs the change-streamer and shuts down when another change-streamer takes over the replication slot. This is appropriate in a single-node configuration, or for the replication-manager in a multi-node configuration. discover: connects to the change-streamer as internally advertised in the change-db. This is appropriate for the view-syncers in a multi-node setup. This may not work in all networking configurations (e.g., some private networking or port forwarding setups). Using ZERO_CHANGE_STREAMER_URI with an explicit routable hostname is recommended instead. This option is ignored if ZERO_CHANGE_STREAMER_URI is set. flag: --change-streamer-mode env: ZERO_CHANGE_STREAMER_MODE default: dedicated Change Streamer Port The port on which the change-streamer runs. This is an internal protocol between the replication-manager and view-syncers, which runs in the same process tree in local development or a single-node configuration. If unspecified, defaults to --port + 1. flag: --change-streamer-port env: ZERO_CHANGE_STREAMER_PORT default: --port + 1 Change Streamer Startup Delay (ms) The delay to wait before the change-streamer takes over the replication stream (i.e. the handoff during replication-manager updates), to allow load balancers to register the task as healthy based on healthcheck parameters. If a change stream request is received during this interval, the delay will be canceled and the takeover will happen immediately, since the incoming request indicates that the task is registered as a target. flag: --change-streamer-startup-delay-ms env: ZERO_CHANGE_STREAMER_STARTUP_DELAY_MS default: 15000 Change Streamer URI When set, connects to the change-streamer at the given URI. In a multi-node setup, this should be specified in view-syncer options, pointing to the replication-manager URI, which runs a change-streamer on port 4849. flag: --change-streamer-uri env: ZERO_CHANGE_STREAMER_URI CVR DB The Postgres database used to store CVRs. CVRs (client view records) keep track of the data synced to clients in order to determine the diff to send on reconnect. If unspecified, the upstream-db will be used. flag: --cvr-db env: ZERO_CVR_DB CVR Garbage Collection Inactivity Threshold Hours The duration after which an inactive CVR is eligible for garbage collection. Garbage collection is incremental and periodic, so eligible CVRs are not necessarily purged immediately. flag: --cvr-garbage-collection-inactivity-threshold-hours env: ZERO_CVR_GARBAGE_COLLECTION_INACTIVITY_THRESHOLD_HOURS default: 48 CVR Garbage Collection Initial Batch Size The initial number of CVRs to purge per garbage collection interval. This number is increased linearly if the rate of new CVRs exceeds the rate of purged CVRs, in order to reach a steady state. Setting this to 0 effectively disables CVR garbage collection. flag: --cvr-garbage-collection-initial-batch-size env: ZERO_CVR_GARBAGE_COLLECTION_INITIAL_BATCH_SIZE default: 25 CVR Garbage Collection Initial Interval Seconds The initial interval at which to check and garbage collect inactive CVRs. This interval is increased exponentially (up to 16 minutes) when there is nothing to purge. flag: --cvr-garbage-collection-initial-interval-seconds env: ZERO_CVR_GARBAGE_COLLECTION_INITIAL_INTERVAL_SECONDS default: 60 CVR Max Connections The maximum number of connections to open to the CVR database. This is divided evenly amongst sync workers. Note that this number must allow for at least one connection per sync worker, or zero-cache will fail to start. See num-sync-workers. flag: --cvr-max-conns env: ZERO_CVR_MAX_CONNS default: 30 Enable Query Planner Enable the query planner for optimizing ZQL queries. The query planner analyzes and optimizes query execution by determining the most efficient join strategies. You can disable the planner if it is picking bad strategies. flag: --enable-query-planner env: ZERO_ENABLE_QUERY_PLANNER default: true Enable Telemetry Zero collects anonymous telemetry data to help us understand usage. We collect: Zero version Uptime General machine information, like the number of CPUs, OS, CI/CD environment, etc. Information about usage, such as number of queries or mutations processed per hour. This is completely optional and can be disabled at any time. You can also opt-out by setting DO_NOT_TRACK=1. flag: --enable-telemetry env: ZERO_ENABLE_TELEMETRY default: true Initial Sync Table Copy Workers The number of parallel workers used to copy tables during initial sync. Each worker uses a database connection, copies a single table at a time, and buffers up to (approximately) 10 MB of table data in memory during initial sync. Increasing the number of workers may improve initial sync speed; however, local disk throughput (IOPS), upstream CPU, and network bandwidth may also be bottlenecks. flag: --initial-sync-table-copy-workers env: ZERO_INITIAL_SYNC_TABLE_COPY_WORKERS default: 5 Lazy Startup Delay starting the majority of zero-cache until first request. This is mainly intended to avoid connecting to Postgres replication stream until the first request is received, which can be useful i.e., for preview instances. Currently only supported in single-node mode. flag: --lazy-startup env: ZERO_LAZY_STARTUP default: false Litestream Backup URL The location of the litestream backup, usually an s3:// URL. This is only consulted by the replication-manager. view-syncers receive this information from the replication-manager. In multi-node deployments, this is required on the replication-manager so view-syncers can reserve snapshots; in single-node deployments it is optional. flag: --litestream-backup-url env: ZERO_LITESTREAM_BACKUP_URL Litestream Checkpoint Threshold MB The size of the WAL file at which to perform an SQlite checkpoint to apply the writes in the WAL to the main database file. Each checkpoint creates a new WAL segment file that will be backed up by litestream. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40 Litestream Config Path Path to the litestream yaml config file. zero-cache will run this with its environment variables, which can be referenced in the file via ${ENV} substitution, for example: ZERO_REPLICA_FILE for the db Path ZERO_LITESTREAM_BACKUP_LOCATION for the db replica url ZERO_LITESTREAM_LOG_LEVEL for the log Level ZERO_LOG_FORMAT for the log type flag: --litestream-config-path env: ZERO_LITESTREAM_CONFIG_PATH default: ./src/services/litestream/config.yml Litestream Executable Path to the litestream executable. This option has no effect if litestream-backup-url is unspecified. flag: --litestream-executable env: ZERO_LITESTREAM_EXECUTABLE Litestream Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Maximum Checkpoint Page Count The WAL page count at which SQLite performs a RESTART checkpoint, which blocks writers until complete. Defaults to minCheckpointPageCount * 10. Set to 0 to disable RESTART checkpoints entirely. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 Litestream Minimum Checkpoint Page Count The WAL page count at which SQLite attempts a PASSIVE checkpoint, which transfers pages to the main database file without blocking writers. Defaults to checkpointThresholdMB * 250 (since SQLite page size is 4KB). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250 Litestream Multipart Concurrency The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY default: 48 Litestream Multipart Size The size of each part when uploading or downloading the snapshot with --litestream-multipart-concurrency. Note that up to concurrency * size bytes of memory are used when backing up or restoring the snapshot. flag: --litestream-multipart-size env: ZERO_LITESTREAM_MULTIPART_SIZE default: 16777216 (16 MiB) Litestream Log Level flag: --litestream-log-level env: ZERO_LITESTREAM_LOG_LEVEL default: warn values: debug, info, warn, error Litestream Port Port on which litestream exports metrics, used to determine the replication watermark up to which it is safe to purge change log records. flag: --litestream-port env: ZERO_LITESTREAM_PORT default: --port + 2 Litestream Restore Parallelism The number of WAL files to download in parallel when performing the initial restore of the replica from the backup. flag: --litestream-restore-parallelism env: ZERO_LITESTREAM_RESTORE_PARALLELISM default: 48 Litestream Snapshot Backup Interval Hours The interval between snapshot backups of the replica. Snapshot backups make a full copy of the database to a new litestream generation. This improves restore time at the expense of bandwidth. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12 Log Format Use text for developer-friendly console logging and json for consumption by structured-logging services. flag: --log-format env: ZERO_LOG_FORMAT default: \"text\" values: text, json Log IVM Sampling How often to collect IVM metrics. 1 out of N requests will be sampled where N is this value. flag: --log-ivm-sampling env: ZERO_LOG_IVM_SAMPLING default: 5000 Log Level Sets the logging level for the application. flag: --log-level env: ZERO_LOG_LEVEL default: \"info\" values: debug, info, warn, error Log Slow Hydrate Threshold The number of milliseconds a query hydration must take to print a slow warning. flag: --log-slow-hydrate-threshold env: ZERO_LOG_SLOW_HYDRATE_THRESHOLD default: 100 Log Slow Row Threshold The number of ms a row must take to fetch from table-source before it is considered slow. flag: --log-slow-row-threshold env: ZERO_LOG_SLOW_ROW_THRESHOLD default: 2 Mutate API Key An optional secret used to authorize zero-cache to call the API server handling writes. This is sent from zero-cache to your mutate endpoint in an X-Api-Key header. flag: --mutate-api-key env: ZERO_MUTATE_API_KEY Mutate Forward Cookies If true, zero-cache will forward cookies from the request to zero-cache to your mutate endpoint. This is useful for passing authentication cookies to the API server. If false, cookies are not forwarded. flag: --mutate-forward-cookies env: ZERO_MUTATE_FORWARD_COOKIES default: false Mutate URL The URL of the API server to which zero-cache will push mutations. URLs are matched using URLPattern, a standard Web API. Pattern syntax (similar to Express routes): Exact URL match: \"https://api.example.com/mutate\" Any subdomain using wildcard: \"https://*.example.com/mutate\" Multiple subdomain levels: \"https://*.*.example.com/mutate\" Any path under a domain: \"https://api.example.com/*\" Named path parameters: \"https://api.example.com/:version/mutate\" Matches https://api.example.com/v1/mutate, https://api.example.com/v2/mutate, etc. Advanced patterns: Optional path segments: \"https://api.example.com/:path?\" Regex in segments (for specific patterns): \"https://api.example.com/:version(v\\\\d+)/mutate\" matches only v followed by digits. Multiple patterns can be specified, for example: https://api1.example.com/mutate,https://api2.example.com/mutate Query parameters and URL fragments (#) are ignored during matching. See URLPattern for full syntax. flag: --mutate-url env: ZERO_MUTATE_URL Number of Sync Workers The number of processes to use for view syncing. Leave this unset to use the maximum available parallelism. If set to 0, the server runs without sync workers, which is the configuration for running the replication-manager in multi-node deployments. flag: --num-sync-workers env: ZERO_NUM_SYNC_WORKERS Per User Mutation Limit Max The maximum mutations per user within the specified windowMs. flag: --per-user-mutation-limit-max env: ZERO_PER_USER_MUTATION_LIMIT_MAX Per User Mutation Limit Window (ms) The sliding window over which the perUserMutationLimitMax is enforced. flag: --per-user-mutation-limit-window-ms env: ZERO_PER_USER_MUTATION_LIMIT_WINDOW_MS default: 60000 Port The port for sync connections. flag: --port env: ZERO_PORT default: 4848 Query API Key An optional secret used to authorize zero-cache to call the API server handling queries. This is sent from zero-cache to your query endpoint in an X-Api-Key header. flag: --query-api-key env: ZERO_QUERY_API_KEY Query Forward Cookies If true, zero-cache will forward cookies from the request to zero-cache to your query endpoint. This is useful for passing authentication cookies to the API server. If false, cookies are not forwarded. flag: --query-forward-cookies env: ZERO_QUERY_FORWARD_COOKIES default: false Query Hydration Stats Track and log the number of rows considered by query hydrations which take longer than log-slow-hydrate-threshold milliseconds. This is useful for debugging and performance tuning. flag: --query-hydration-stats env: ZERO_QUERY_HYDRATION_STATS Query URL The URL of the API server to which zero-cache will send synced queries. URLs are matched using URLPattern, a standard Web API. Pattern syntax (similar to Express routes): Exact URL match: \"https://api.example.com/query\" Any subdomain using wildcard: \"https://*.example.com/query\" Multiple subdomain levels: \"https://*.*.example.com/query\" Any path under a domain: \"https://api.example.com/*\" Named path parameters: \"https://api.example.com/:version/query\" Matches https://api.example.com/v1/query, https://api.example.com/v2/query, etc. Advanced patterns: Optional path segments: \"https://api.example.com/:path?\" Regex in segments (for specific patterns): \"https://api.example.com/:version(v\\\\d+)/query\" matches only v followed by digits. Multiple patterns can be specified, for example: https://api1.example.com/query,https://api2.example.com/query Query parameters and URL fragments (#) are ignored during matching. See URLPattern for full syntax. flag: --query-url env: ZERO_QUERY_URL Replica File File path to the SQLite replica that zero-cache maintains. This can be lost, but if it is, zero-cache will have to re-replicate next time it starts up. flag: --replica-file env: ZERO_REPLICA_FILE default: \"zero.db\" Replica Vacuum Interval Hours Performs a VACUUM at server startup if the specified number of hours has elapsed since the last VACUUM (or initial-sync). The VACUUM operation is heavyweight and requires double the size of the db in disk space. If unspecified, VACUUM operations are not performed. flag: --replica-vacuum-interval-hours env: ZERO_REPLICA_VACUUM_INTERVAL_HOURS Replica Page Cache Size KiB The SQLite page cache size in kibibytes (KiB) for view-syncer connections. The page cache stores recently accessed database pages in memory to reduce disk I/O. Larger cache sizes improve performance for workloads that fit in cache. If unspecified, SQLite's default (~2 MB) is used. Note that the effective memory use of this setting will be: 2 * cache_size * num_cores, as each connection to the replica gets its own cache and each core maintains 2 connections. flag: --replica-page-cache-size-kib env: ZERO_REPLICA_PAGE_CACHE_SIZE_KIB Server Version The version string outputted to logs when the server starts up. flag: --server-version env: ZERO_SERVER_VERSION Storage DB Temp Dir Temporary directory for IVM operator storage. Leave unset to use os.tmpdir(). flag: --storage-db-tmp-dir env: ZERO_STORAGE_DB_TMP_DIR Task ID Globally unique identifier for the zero-cache instance. Setting this to a platform specific task identifier can be useful for debugging. If unspecified, zero-cache will attempt to extract the TaskARN if run from within an AWS ECS container, and otherwise use a random string. flag: --task-id env: ZERO_TASK_ID Upstream Max Connections The maximum number of connections to open to the upstream database for committing mutations. This is divided evenly amongst sync workers. In addition to this number, zero-cache uses one connection for the replication stream. Note that this number must allow for at least one connection per sync worker, or zero-cache will fail to start. See num-sync-workers. flag: --upstream-max-conns env: ZERO_UPSTREAM_MAX_CONNS default: 20 Websocket Compression Enable WebSocket per-message deflate compression. Compression can reduce bandwidth usage for sync traffic but increases CPU usage on both client and server. Disabled by default. See: https://github.com/websockets/ws#websocket-compression flag: --websocket-compression env: ZERO_WEBSOCKET_COMPRESSION default: false Websocket Compression Options JSON string containing WebSocket compression options. Only used if websocket-compression is enabled. Example: {\"zlibDeflateOptions\":{\"level\":3},\"threshold\":1024}. See https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback for available options. flag: --websocket-compression-options env: ZERO_WEBSOCKET_COMPRESSION_OPTIONS Websocket Max Payload Bytes Maximum size of incoming WebSocket messages in bytes. Messages exceeding this limit are rejected before parsing. Default: 10MB (10485760). flag: --websocket-max-payload-bytes env: ZERO_WEBSOCKET_MAX_PAYLOAD_BYTES Yield Threshold (ms) The maximum amount of time in milliseconds that a sync worker will spend in IVM (processing query hydration and advancement) before yielding to the event loop. Lower values increase responsiveness and fairness at the cost of reduced throughput. flag: --yield-threshold-ms env: ZERO_YIELD_THRESHOLD_MS default: 10", "kind": "section" }, { - "id": "450-zero-cache-config#app-id", + "id": "456-zero-cache-config#app-id", "title": "zero-cache Config", "searchTitle": "App ID", "sectionTitle": "App ID", @@ -6287,7 +6375,7 @@ "kind": "section" }, { - "id": "451-zero-cache-config#app-publications", + "id": "457-zero-cache-config#app-publications", "title": "zero-cache Config", "searchTitle": "App Publications", "sectionTitle": "App Publications", @@ -6297,7 +6385,7 @@ "kind": "section" }, { - "id": "452-zero-cache-config#auto-reset", + "id": "458-zero-cache-config#auto-reset", "title": "zero-cache Config", "searchTitle": "Auto Reset", "sectionTitle": "Auto Reset", @@ -6307,7 +6395,7 @@ "kind": "section" }, { - "id": "453-zero-cache-config#change-db", + "id": "459-zero-cache-config#change-db", "title": "zero-cache Config", "searchTitle": "Change DB", "sectionTitle": "Change DB", @@ -6317,7 +6405,7 @@ "kind": "section" }, { - "id": "454-zero-cache-config#change-max-connections", + "id": "460-zero-cache-config#change-max-connections", "title": "zero-cache Config", "searchTitle": "Change Max Connections", "sectionTitle": "Change Max Connections", @@ -6327,7 +6415,17 @@ "kind": "section" }, { - "id": "455-zero-cache-config#change-streamer-mode", + "id": "461-zero-cache-config#change-streamer-back-limit-heap-proportion", + "title": "zero-cache Config", + "searchTitle": "Change Streamer Back Limit Heap Proportion", + "sectionTitle": "Change Streamer Back Limit Heap Proportion", + "sectionId": "change-streamer-back-limit-heap-proportion", + "url": "/docs/zero-cache-config", + "content": "The percentage of --max-old-space-size to use as a buffer for absorbing replication stream spikes. When the estimated amount of queued data exceeds this threshold, back pressure is applied to the replication stream, delaying downstream sync as a result. The threshold was determined empirically with load testing. Higher thresholds have resulted in OOMs. Note also that the byte-counting logic in the queue is strictly an underestimate of actual memory usage (but importantly, proportionally correct), so the queue is actually using more than what this proportion suggests. This parameter is exported as an emergency knob to reduce the size of the buffer in the event that the server OOMs from back pressure. Resist the urge to increase this proportion, as it is mainly useful for absorbing periodic spikes and does not meaningfully affect steady-state replication throughput; the latter is determined by other factors such as object serialization and PG throughput. In other words, the back pressure limit does not constrain replication throughput; rather, it protects the system when the upstream throughput exceeds the downstream throughput. flag: --change-streamer-back-limit-heap-proportion env: ZERO_CHANGE_STREAMER_BACK_LIMIT_HEAP_PROPORTION default: 0.04", + "kind": "section" + }, + { + "id": "462-zero-cache-config#change-streamer-mode", "title": "zero-cache Config", "searchTitle": "Change Streamer Mode", "sectionTitle": "Change Streamer Mode", @@ -6337,7 +6435,7 @@ "kind": "section" }, { - "id": "456-zero-cache-config#change-streamer-port", + "id": "463-zero-cache-config#change-streamer-port", "title": "zero-cache Config", "searchTitle": "Change Streamer Port", "sectionTitle": "Change Streamer Port", @@ -6347,7 +6445,7 @@ "kind": "section" }, { - "id": "457-zero-cache-config#change-streamer-startup-delay-ms", + "id": "464-zero-cache-config#change-streamer-startup-delay-ms", "title": "zero-cache Config", "searchTitle": "Change Streamer Startup Delay (ms)", "sectionTitle": "Change Streamer Startup Delay (ms)", @@ -6357,7 +6455,7 @@ "kind": "section" }, { - "id": "458-zero-cache-config#change-streamer-uri", + "id": "465-zero-cache-config#change-streamer-uri", "title": "zero-cache Config", "searchTitle": "Change Streamer URI", "sectionTitle": "Change Streamer URI", @@ -6367,7 +6465,7 @@ "kind": "section" }, { - "id": "459-zero-cache-config#cvr-db", + "id": "466-zero-cache-config#cvr-db", "title": "zero-cache Config", "searchTitle": "CVR DB", "sectionTitle": "CVR DB", @@ -6377,7 +6475,7 @@ "kind": "section" }, { - "id": "460-zero-cache-config#cvr-garbage-collection-inactivity-threshold-hours", + "id": "467-zero-cache-config#cvr-garbage-collection-inactivity-threshold-hours", "title": "zero-cache Config", "searchTitle": "CVR Garbage Collection Inactivity Threshold Hours", "sectionTitle": "CVR Garbage Collection Inactivity Threshold Hours", @@ -6387,7 +6485,7 @@ "kind": "section" }, { - "id": "461-zero-cache-config#cvr-garbage-collection-initial-batch-size", + "id": "468-zero-cache-config#cvr-garbage-collection-initial-batch-size", "title": "zero-cache Config", "searchTitle": "CVR Garbage Collection Initial Batch Size", "sectionTitle": "CVR Garbage Collection Initial Batch Size", @@ -6397,7 +6495,7 @@ "kind": "section" }, { - "id": "462-zero-cache-config#cvr-garbage-collection-initial-interval-seconds", + "id": "469-zero-cache-config#cvr-garbage-collection-initial-interval-seconds", "title": "zero-cache Config", "searchTitle": "CVR Garbage Collection Initial Interval Seconds", "sectionTitle": "CVR Garbage Collection Initial Interval Seconds", @@ -6407,7 +6505,7 @@ "kind": "section" }, { - "id": "463-zero-cache-config#cvr-max-connections", + "id": "470-zero-cache-config#cvr-max-connections", "title": "zero-cache Config", "searchTitle": "CVR Max Connections", "sectionTitle": "CVR Max Connections", @@ -6417,7 +6515,7 @@ "kind": "section" }, { - "id": "464-zero-cache-config#enable-query-planner", + "id": "471-zero-cache-config#enable-query-planner", "title": "zero-cache Config", "searchTitle": "Enable Query Planner", "sectionTitle": "Enable Query Planner", @@ -6427,7 +6525,7 @@ "kind": "section" }, { - "id": "465-zero-cache-config#enable-telemetry", + "id": "472-zero-cache-config#enable-telemetry", "title": "zero-cache Config", "searchTitle": "Enable Telemetry", "sectionTitle": "Enable Telemetry", @@ -6437,7 +6535,7 @@ "kind": "section" }, { - "id": "466-zero-cache-config#initial-sync-table-copy-workers", + "id": "473-zero-cache-config#initial-sync-table-copy-workers", "title": "zero-cache Config", "searchTitle": "Initial Sync Table Copy Workers", "sectionTitle": "Initial Sync Table Copy Workers", @@ -6447,7 +6545,7 @@ "kind": "section" }, { - "id": "467-zero-cache-config#lazy-startup", + "id": "474-zero-cache-config#lazy-startup", "title": "zero-cache Config", "searchTitle": "Lazy Startup", "sectionTitle": "Lazy Startup", @@ -6457,7 +6555,7 @@ "kind": "section" }, { - "id": "468-zero-cache-config#litestream-backup-url", + "id": "475-zero-cache-config#litestream-backup-url", "title": "zero-cache Config", "searchTitle": "Litestream Backup URL", "sectionTitle": "Litestream Backup URL", @@ -6467,7 +6565,7 @@ "kind": "section" }, { - "id": "469-zero-cache-config#litestream-checkpoint-threshold-mb", + "id": "476-zero-cache-config#litestream-checkpoint-threshold-mb", "title": "zero-cache Config", "searchTitle": "Litestream Checkpoint Threshold MB", "sectionTitle": "Litestream Checkpoint Threshold MB", @@ -6477,7 +6575,7 @@ "kind": "section" }, { - "id": "470-zero-cache-config#litestream-config-path", + "id": "477-zero-cache-config#litestream-config-path", "title": "zero-cache Config", "searchTitle": "Litestream Config Path", "sectionTitle": "Litestream Config Path", @@ -6487,7 +6585,7 @@ "kind": "section" }, { - "id": "471-zero-cache-config#litestream-executable", + "id": "478-zero-cache-config#litestream-executable", "title": "zero-cache Config", "searchTitle": "Litestream Executable", "sectionTitle": "Litestream Executable", @@ -6497,7 +6595,7 @@ "kind": "section" }, { - "id": "472-zero-cache-config#litestream-incremental-backup-interval-minutes", + "id": "479-zero-cache-config#litestream-incremental-backup-interval-minutes", "title": "zero-cache Config", "searchTitle": "Litestream Incremental Backup Interval Minutes", "sectionTitle": "Litestream Incremental Backup Interval Minutes", @@ -6507,7 +6605,7 @@ "kind": "section" }, { - "id": "473-zero-cache-config#litestream-maximum-checkpoint-page-count", + "id": "480-zero-cache-config#litestream-maximum-checkpoint-page-count", "title": "zero-cache Config", "searchTitle": "Litestream Maximum Checkpoint Page Count", "sectionTitle": "Litestream Maximum Checkpoint Page Count", @@ -6517,7 +6615,7 @@ "kind": "section" }, { - "id": "474-zero-cache-config#litestream-minimum-checkpoint-page-count", + "id": "481-zero-cache-config#litestream-minimum-checkpoint-page-count", "title": "zero-cache Config", "searchTitle": "Litestream Minimum Checkpoint Page Count", "sectionTitle": "Litestream Minimum Checkpoint Page Count", @@ -6527,7 +6625,7 @@ "kind": "section" }, { - "id": "475-zero-cache-config#litestream-multipart-concurrency", + "id": "482-zero-cache-config#litestream-multipart-concurrency", "title": "zero-cache Config", "searchTitle": "Litestream Multipart Concurrency", "sectionTitle": "Litestream Multipart Concurrency", @@ -6537,7 +6635,7 @@ "kind": "section" }, { - "id": "476-zero-cache-config#litestream-multipart-size", + "id": "483-zero-cache-config#litestream-multipart-size", "title": "zero-cache Config", "searchTitle": "Litestream Multipart Size", "sectionTitle": "Litestream Multipart Size", @@ -6547,7 +6645,7 @@ "kind": "section" }, { - "id": "477-zero-cache-config#litestream-log-level", + "id": "484-zero-cache-config#litestream-log-level", "title": "zero-cache Config", "searchTitle": "Litestream Log Level", "sectionTitle": "Litestream Log Level", @@ -6557,7 +6655,7 @@ "kind": "section" }, { - "id": "478-zero-cache-config#litestream-port", + "id": "485-zero-cache-config#litestream-port", "title": "zero-cache Config", "searchTitle": "Litestream Port", "sectionTitle": "Litestream Port", @@ -6567,7 +6665,7 @@ "kind": "section" }, { - "id": "479-zero-cache-config#litestream-restore-parallelism", + "id": "486-zero-cache-config#litestream-restore-parallelism", "title": "zero-cache Config", "searchTitle": "Litestream Restore Parallelism", "sectionTitle": "Litestream Restore Parallelism", @@ -6577,7 +6675,7 @@ "kind": "section" }, { - "id": "480-zero-cache-config#litestream-snapshot-backup-interval-hours", + "id": "487-zero-cache-config#litestream-snapshot-backup-interval-hours", "title": "zero-cache Config", "searchTitle": "Litestream Snapshot Backup Interval Hours", "sectionTitle": "Litestream Snapshot Backup Interval Hours", @@ -6587,7 +6685,7 @@ "kind": "section" }, { - "id": "481-zero-cache-config#log-format", + "id": "488-zero-cache-config#log-format", "title": "zero-cache Config", "searchTitle": "Log Format", "sectionTitle": "Log Format", @@ -6597,7 +6695,7 @@ "kind": "section" }, { - "id": "482-zero-cache-config#log-ivm-sampling", + "id": "489-zero-cache-config#log-ivm-sampling", "title": "zero-cache Config", "searchTitle": "Log IVM Sampling", "sectionTitle": "Log IVM Sampling", @@ -6607,7 +6705,7 @@ "kind": "section" }, { - "id": "483-zero-cache-config#log-level", + "id": "490-zero-cache-config#log-level", "title": "zero-cache Config", "searchTitle": "Log Level", "sectionTitle": "Log Level", @@ -6617,7 +6715,7 @@ "kind": "section" }, { - "id": "484-zero-cache-config#log-slow-hydrate-threshold", + "id": "491-zero-cache-config#log-slow-hydrate-threshold", "title": "zero-cache Config", "searchTitle": "Log Slow Hydrate Threshold", "sectionTitle": "Log Slow Hydrate Threshold", @@ -6627,7 +6725,7 @@ "kind": "section" }, { - "id": "485-zero-cache-config#log-slow-row-threshold", + "id": "492-zero-cache-config#log-slow-row-threshold", "title": "zero-cache Config", "searchTitle": "Log Slow Row Threshold", "sectionTitle": "Log Slow Row Threshold", @@ -6637,7 +6735,7 @@ "kind": "section" }, { - "id": "486-zero-cache-config#mutate-api-key", + "id": "493-zero-cache-config#mutate-api-key", "title": "zero-cache Config", "searchTitle": "Mutate API Key", "sectionTitle": "Mutate API Key", @@ -6647,7 +6745,7 @@ "kind": "section" }, { - "id": "487-zero-cache-config#mutate-forward-cookies", + "id": "494-zero-cache-config#mutate-forward-cookies", "title": "zero-cache Config", "searchTitle": "Mutate Forward Cookies", "sectionTitle": "Mutate Forward Cookies", @@ -6657,7 +6755,7 @@ "kind": "section" }, { - "id": "488-zero-cache-config#mutate-url", + "id": "495-zero-cache-config#mutate-url", "title": "zero-cache Config", "searchTitle": "Mutate URL", "sectionTitle": "Mutate URL", @@ -6667,7 +6765,7 @@ "kind": "section" }, { - "id": "489-zero-cache-config#number-of-sync-workers", + "id": "496-zero-cache-config#number-of-sync-workers", "title": "zero-cache Config", "searchTitle": "Number of Sync Workers", "sectionTitle": "Number of Sync Workers", @@ -6677,7 +6775,7 @@ "kind": "section" }, { - "id": "490-zero-cache-config#per-user-mutation-limit-max", + "id": "497-zero-cache-config#per-user-mutation-limit-max", "title": "zero-cache Config", "searchTitle": "Per User Mutation Limit Max", "sectionTitle": "Per User Mutation Limit Max", @@ -6687,7 +6785,7 @@ "kind": "section" }, { - "id": "491-zero-cache-config#per-user-mutation-limit-window-ms", + "id": "498-zero-cache-config#per-user-mutation-limit-window-ms", "title": "zero-cache Config", "searchTitle": "Per User Mutation Limit Window (ms)", "sectionTitle": "Per User Mutation Limit Window (ms)", @@ -6697,7 +6795,7 @@ "kind": "section" }, { - "id": "492-zero-cache-config#port", + "id": "499-zero-cache-config#port", "title": "zero-cache Config", "searchTitle": "Port", "sectionTitle": "Port", @@ -6707,7 +6805,7 @@ "kind": "section" }, { - "id": "493-zero-cache-config#query-api-key", + "id": "500-zero-cache-config#query-api-key", "title": "zero-cache Config", "searchTitle": "Query API Key", "sectionTitle": "Query API Key", @@ -6717,7 +6815,7 @@ "kind": "section" }, { - "id": "494-zero-cache-config#query-forward-cookies", + "id": "501-zero-cache-config#query-forward-cookies", "title": "zero-cache Config", "searchTitle": "Query Forward Cookies", "sectionTitle": "Query Forward Cookies", @@ -6727,7 +6825,7 @@ "kind": "section" }, { - "id": "495-zero-cache-config#query-hydration-stats", + "id": "502-zero-cache-config#query-hydration-stats", "title": "zero-cache Config", "searchTitle": "Query Hydration Stats", "sectionTitle": "Query Hydration Stats", @@ -6737,7 +6835,7 @@ "kind": "section" }, { - "id": "496-zero-cache-config#query-url", + "id": "503-zero-cache-config#query-url", "title": "zero-cache Config", "searchTitle": "Query URL", "sectionTitle": "Query URL", @@ -6747,7 +6845,7 @@ "kind": "section" }, { - "id": "497-zero-cache-config#replica-file", + "id": "504-zero-cache-config#replica-file", "title": "zero-cache Config", "searchTitle": "Replica File", "sectionTitle": "Replica File", @@ -6757,7 +6855,7 @@ "kind": "section" }, { - "id": "498-zero-cache-config#replica-vacuum-interval-hours", + "id": "505-zero-cache-config#replica-vacuum-interval-hours", "title": "zero-cache Config", "searchTitle": "Replica Vacuum Interval Hours", "sectionTitle": "Replica Vacuum Interval Hours", @@ -6767,7 +6865,7 @@ "kind": "section" }, { - "id": "499-zero-cache-config#replica-page-cache-size-kib", + "id": "506-zero-cache-config#replica-page-cache-size-kib", "title": "zero-cache Config", "searchTitle": "Replica Page Cache Size KiB", "sectionTitle": "Replica Page Cache Size KiB", @@ -6777,7 +6875,7 @@ "kind": "section" }, { - "id": "500-zero-cache-config#server-version", + "id": "507-zero-cache-config#server-version", "title": "zero-cache Config", "searchTitle": "Server Version", "sectionTitle": "Server Version", @@ -6787,7 +6885,7 @@ "kind": "section" }, { - "id": "501-zero-cache-config#storage-db-temp-dir", + "id": "508-zero-cache-config#storage-db-temp-dir", "title": "zero-cache Config", "searchTitle": "Storage DB Temp Dir", "sectionTitle": "Storage DB Temp Dir", @@ -6797,7 +6895,7 @@ "kind": "section" }, { - "id": "502-zero-cache-config#task-id", + "id": "509-zero-cache-config#task-id", "title": "zero-cache Config", "searchTitle": "Task ID", "sectionTitle": "Task ID", @@ -6807,7 +6905,7 @@ "kind": "section" }, { - "id": "503-zero-cache-config#upstream-max-connections", + "id": "510-zero-cache-config#upstream-max-connections", "title": "zero-cache Config", "searchTitle": "Upstream Max Connections", "sectionTitle": "Upstream Max Connections", @@ -6817,7 +6915,7 @@ "kind": "section" }, { - "id": "504-zero-cache-config#websocket-compression", + "id": "511-zero-cache-config#websocket-compression", "title": "zero-cache Config", "searchTitle": "Websocket Compression", "sectionTitle": "Websocket Compression", @@ -6827,7 +6925,7 @@ "kind": "section" }, { - "id": "505-zero-cache-config#websocket-compression-options", + "id": "512-zero-cache-config#websocket-compression-options", "title": "zero-cache Config", "searchTitle": "Websocket Compression Options", "sectionTitle": "Websocket Compression Options", @@ -6837,7 +6935,17 @@ "kind": "section" }, { - "id": "506-zero-cache-config#yield-threshold-ms", + "id": "513-zero-cache-config#websocket-max-payload-bytes", + "title": "zero-cache Config", + "searchTitle": "Websocket Max Payload Bytes", + "sectionTitle": "Websocket Max Payload Bytes", + "sectionId": "websocket-max-payload-bytes", + "url": "/docs/zero-cache-config", + "content": "Maximum size of incoming WebSocket messages in bytes. Messages exceeding this limit are rejected before parsing. Default: 10MB (10485760). flag: --websocket-max-payload-bytes env: ZERO_WEBSOCKET_MAX_PAYLOAD_BYTES", + "kind": "section" + }, + { + "id": "514-zero-cache-config#yield-threshold-ms", "title": "zero-cache Config", "searchTitle": "Yield Threshold (ms)", "sectionTitle": "Yield Threshold (ms)", @@ -6847,7 +6955,7 @@ "kind": "section" }, { - "id": "507-zero-cache-config#deprecated-flags", + "id": "515-zero-cache-config#deprecated-flags", "title": "zero-cache Config", "searchTitle": "Deprecated Flags", "sectionTitle": "Deprecated Flags", @@ -6857,7 +6965,7 @@ "kind": "section" }, { - "id": "508-zero-cache-config#auth-jwk", + "id": "516-zero-cache-config#auth-jwk", "title": "zero-cache Config", "searchTitle": "Auth JWK", "sectionTitle": "Auth JWK", @@ -6867,7 +6975,7 @@ "kind": "section" }, { - "id": "509-zero-cache-config#auth-jwks-url", + "id": "517-zero-cache-config#auth-jwks-url", "title": "zero-cache Config", "searchTitle": "Auth JWKS URL", "sectionTitle": "Auth JWKS URL", @@ -6877,7 +6985,7 @@ "kind": "section" }, { - "id": "510-zero-cache-config#auth-secret", + "id": "518-zero-cache-config#auth-secret", "title": "zero-cache Config", "searchTitle": "Auth Secret", "sectionTitle": "Auth Secret", @@ -6887,11 +6995,11 @@ "kind": "section" }, { - "id": "62-zql", + "id": "63-zql", "title": "ZQL", "searchTitle": "ZQL", "url": "/docs/zql", - "content": "Inspired by SQL, ZQL is expressed in TypeScript with heavy use of the builder pattern. If you have used Drizzle or Kysely, ZQL will feel familiar. ZQL queries are composed of one or more clauses that are chained together into a query. Create a Builder To get started, use createBuilder. If you use drizzle-zero or prisma-zero, this happens automatically and an instance is stored in the zql constant exported from schema.ts: import {zql} from 'schema.ts' // zql.myTable.where(...) Otherwise, create an instance manually: // schema.ts // ... export const zql = createBuilder(schema) Select ZQL queries start by selecting a table. There is no way to select a subset of columns; ZQL queries always return the entire row, if permissions allow it. import {zql} from 'zero.ts' // Returns a query that selects all rows and columns from the // issue table. zql.issue This is a design tradeoff that allows Zero to better reuse the row locally for future queries. This also makes it easier to share types between different parts of the code. This means you should not modify the data directly. Instead, clone the data and modify the clone. ZQL caches values and returns them multiple times. If you modify a value returned from ZQL, you will modify it everywhere it is used. This can lead to subtle bugs. JavaScript and TypeScript lack true immutable types so we use readonly to help enforce it. But it's easy to cast away the readonly accidentally. Ordering You can sort query results by adding an orderBy clause: zql.issue.orderBy('created', 'desc') Multiple orderBy clauses can be present, in which case the data is sorted by those clauses in order: // Order by priority descending. For any rows with same priority, // then order by created desc. zql.issue .orderBy('priority', 'desc') .orderBy('created', 'desc') All queries in ZQL have a default final order of their primary key. Assuming the issue table has a primary key on the id column, then: // Actually means: zql.issue.orderBy('id', 'asc'); zql.issue // Actually means: zql.issue.orderBy('priority', 'desc').orderBy('id', 'asc'); zql.issue.orderBy('priority', 'desc') Limit You can limit the number of rows to return with limit(): zql.issue.orderBy('created', 'desc').limit(100) Paging You can start the results at or after a particular row with start(): let start: IssueRow | undefined while (true) { let q = zql.issue .orderBy('created', 'desc') .limit(100) if (start) { q = q.start(start) } const batch = await q.run() console.log('got batch', batch) if (batch.length < 100) { break } start = batch[batch.length - 1] } By default start() is exclusive - it returns rows starting after the supplied reference row. This is what you usually want for paging. If you want inclusive results, you can do: zql.issue.start(row, {inclusive: true}) Getting a Single Result If you want exactly zero or one results, use the one() clause. This causes ZQL to return Row|undefined rather than Row[]. const result = await zql.issue .where('id', 42) .one() .run() if (!result) { console.error('not found') } one() overrides any limit() clause that is also present. Relationships You can query related rows using relationships that are defined in your Zero schema. // Get all issues and their related comments zql.issue.related('comments') Relationships are returned as hierarchical data. In the above example, each row will have a comments field, which is an array of the corresponding comments rows. You can fetch multiple relationships in a single query: zql.issue .related('comments') .related('reactions') .related('assignees') Refining Relationships By default all matching relationship rows are returned, but this can be refined. The related method accepts an optional second function which is itself a query. zql.issue.related( 'comments', // It is common to use the 'q' shorthand variable for this parameter, // but it is a _comment_ query in particular here, exactly as if you // had done zql.comment. q => q .orderBy('modified', 'desc') .limit(100) .start(lastSeenComment) ) This relationship query can have all the same clauses that top-level queries can have. Using orderBy or limit in a relationship that goes through a junction table (i.e., a many-to-many relationship) is not currently supported and will throw a runtime error. See bug 3527. You can sometimes work around this by making the junction relationship explicit, depending on your schema and usage. Nested Relationships You can nest relationships arbitrarily: // Get all issues, first 100 comments for each (ordered by modified,desc), // and for each comment all of its reactions. zql.issue.related('comments', q => q .orderBy('modified', 'desc') .limit(100) .related('reactions') ) Where You can filter a query with where(): zql.issue.where('priority', '=', 'high') The first parameter is always a column name from the table being queried. TypeScript completion will offer available options (sourced from your Zero Schema). Comparison Operators Where supports the following comparison operators: TypeScript will restrict you from using operators with types that don’t make sense – you can’t use > with boolean for example. If you don’t see the comparison operator you need, let us know, many are easy to add. Equals is the Default Comparison Operator Because comparing by = is so common, you can leave it out and where defaults to =. zql.issue.where('priority', 'high') Comparing to null As in SQL, ZQL’s null cannot be compared with =, !=, <, or any other normal comparison operator. Comparing any value to null with such operators is always false: These semantics feel a bit weird, but they are consistent with SQL. The reason SQL does it this way is to make join semantics work: if you’re joining employee.orgID on org.id you do not want an employee in no organization to match an org that hasn’t yet been assigned an ID. For when you purposely do want to compare to null ZQL supports IS and IS NOT operators that also work just like in SQL: // Find employees not in any org. zql.employee.where('orgID', 'IS', null) // Find employees in an org other than 42 OR employees in NO org zql.employee.where('orgID', 'IS NOT', 42) TypeScript will prevent you from comparing to null with other operators. Compound Filters The argument to where can also be a callback that returns a complex expression: // Get all issues that have priority 'critical' or else have both // priority 'medium' and not more than 100 votes. zql.issue.where(({cmp, and, or, not}) => or( cmp('priority', 'critical'), and( cmp('priority', 'medium'), not(cmp('numVotes', '>', 100)) ) ) ) cmp is short for compare and works the same as where at the top-level except that it can’t be chained and it only accepts comparison operators (no relationship filters – see below). Note that chaining where() is also a one-level and: // Find issues with priority 3 or higher, owned by aa zql.issue .where('priority', '>=', 3) .where('owner', 'aa') Comparing Literal Values The where clause always expects its first parameter to be a column name as a string. Same with the cmp helper: // \"foo\" is a column name, not a string: zql.issue.where('foo', 'bar') // \"foo\" is a column name, not a string: zql.issue.where(({cmp}) => cmp('foo', 'bar')) To compare to a literal value, use the cmpLit helper: zql.issue.where(cmpLit('foobar', 'foo' + 'bar')) This is particularly useful for implementing permissions, because the first parameter can be a field of your context: zql.issue.where(cmpLit(ctx.role, 'admin')) Relationship Filters Your filter can also test properties of relationships. Currently the only supported test is existence: // Find all orgs that have at least one employee zql.organization.whereExists('employees') The argument to whereExists is a relationship, so just like other relationships, it can be refined with a query: // Find all orgs that have at least one cool employee zql.organization.whereExists('employees', q => q.where('location', 'Hawaii') ) As with querying relationships, relationship filters can be arbitrarily nested: // Get all issues that have comments that have reactions zql.issue.whereExists('comments', q => q.whereExists('reactions') ) The exists helper is also provided which can be used with and, or, cmp, and not to build compound filters that check relationship existence: // Find issues that have at least one comment or are high priority zql.issue.where({cmp, or, exists} => or( cmp('priority', 'high'), exists('comments'), ), ) Type Helpers You can get the TypeScript type of the result of a query using the QueryResultType helper: import type {QueryResultType} from '@rocicorp/zero' const complexQuery = zql.issue.related( 'comments', q => q.related('author') ) type MyComplexResult = QueryResultType // MyComplexResult is: readonly IssueRow & { // readonly comments: readonly (CommentRow & { // readonly author: readonly AuthorRow|undefined; // })[]; // }[] You can get the type of a single row with QueryRowType: import type {QueryRowType} from '@rocicorp/zero' type MySingleRow = QueryRowType // MySingleRow is: readonly IssueRow & { // readonly comments: readonly (CommentRow & { // readonly author: readonly AuthorRow|undefined; // })[]; // } Planning Zero automatically plans queries, selecting the best indexes and join orders in most cases. Inspecting Query Plans You can inspect the plan that Zero generates for any ZQL query using the inspector. Manually Flipping Joins The process Zero uses to optimize joins is called \"join flipping\", because it involves \"flipping\" the order of joins to minimize the number of rows processed. Typically the Zero planner will pick the joins to flip automatically. But in some rare cases, you may want to manually specify the join order. This can be done by passing the flip:true option to whereExists: // Find the first 100 documents that user 42 can edit, // ordered by created desc. Because each user is an editor // of only a few documents, flip:true is much faster than // flip:false. zql.documents.whereExists('editors', e => e.where('userID', 42), {flip: true} ), .orderBy('created', 'desc') .limit(100) Or with exists: // Find issues created by user 42 or that have a comment // by user 42. Because user 42 has commented on only a // few issues, flip:true is much faster than flip:false. zql.issue.where({cmp, or, exists} => or( cmp('creatorID', 42), exists('comments', c => c.where('creatorID', 42), {flip: true}), ), ) You can manually flip just one or a few of the whereExists clauses in a query, leaving the rest to be planned automatically.", + "content": "Inspired by SQL, ZQL is expressed in TypeScript with heavy use of the builder pattern. If you have used Drizzle or Kysely, ZQL will feel familiar. ZQL queries are composed of one or more clauses that are chained together into a query. Create a Builder To get started, use createBuilder. If you use drizzle-zero or prisma-zero, this happens automatically and an instance is stored in the zql constant exported from schema.ts: import {zql} from 'schema.ts' // zql.myTable.where(...) Otherwise, create an instance manually: // schema.ts // ... export const zql = createBuilder(schema) Select ZQL queries start by selecting a table. There is no way to select a subset of columns; ZQL queries always return the entire row, if permissions allow it. import {zql} from 'zero.ts' // Returns a query that selects all rows and columns from the // issue table. zql.issue This is a design tradeoff that allows Zero to better reuse the row locally for future queries. This also makes it easier to share types between different parts of the code. This means you should not modify the data directly. Instead, clone the data and modify the clone. ZQL caches values and returns them multiple times. If you modify a value returned from ZQL, you will modify it everywhere it is used. This can lead to subtle bugs. JavaScript and TypeScript lack true immutable types so we use readonly to help enforce it. But it's easy to cast away the readonly accidentally. Ordering You can sort query results by adding an orderBy clause: zql.issue.orderBy('created', 'desc') Multiple orderBy clauses can be present, in which case the data is sorted by those clauses in order: // Order by priority descending. For any rows with same priority, // then order by created desc. zql.issue .orderBy('priority', 'desc') .orderBy('created', 'desc') All queries in ZQL have a default final order of their primary key. Assuming the issue table has a primary key on the id column, then: // Actually means: zql.issue.orderBy('id', 'asc'); zql.issue // Actually means: zql.issue.orderBy('priority', 'desc').orderBy('id', 'asc'); zql.issue.orderBy('priority', 'desc') Limit You can limit the number of rows to return with limit(): zql.issue.orderBy('created', 'desc').limit(100) Paging You can start the results at or after a particular row with start(): let start: IssueRow | undefined while (true) { let q = zql.issue .orderBy('created', 'desc') .limit(100) if (start) { q = q.start(start) } const batch = await q.run() console.log('got batch', batch) if (batch.length < 100) { break } start = batch[batch.length - 1] } By default start() is exclusive - it returns rows starting after the supplied reference row. This is what you usually want for paging. If you want inclusive results, you can do: zql.issue.start(row, {inclusive: true}) Getting a Single Result If you want exactly zero or one results, use the one() clause. This causes ZQL to return Row|undefined rather than Row[]. const result = await zql.issue .where('id', 42) .one() .run() if (!result) { console.error('not found') } one() overrides any limit() clause that is also present. Relationships You can query related rows using relationships that are defined in your Zero schema. // Get all issues and their related comments zql.issue.related('comments') Relationships are returned as hierarchical data. In the above example, each row will have a comments field, which is an array of the corresponding comments rows. You can fetch multiple relationships in a single query: zql.issue .related('comments') .related('reactions') .related('assignees') Refining Relationships By default all matching relationship rows are returned, but this can be refined. The related method accepts an optional second function which is itself a query. zql.issue.related( 'comments', // It is common to use the 'q' shorthand variable for this parameter, // but it is a _comment_ query in particular here, exactly as if you // had done zql.comment. q => q .orderBy('modified', 'desc') .limit(100) .start(lastSeenComment) ) This relationship query can have all the same clauses that top-level queries can have. Using orderBy or limit in a relationship that goes through a junction table (i.e., a many-to-many relationship) is not currently supported and will throw a runtime error. See bug 3527. You can sometimes work around this by making the junction relationship explicit, depending on your schema and usage. Nested Relationships You can nest relationships arbitrarily: // Get all issues, first 100 comments for each (ordered by modified,desc), // and for each comment all of its reactions. zql.issue.related('comments', q => q .orderBy('modified', 'desc') .limit(100) .related('reactions') ) Where You can filter a query with where(): zql.issue.where('priority', '=', 'high') The first parameter is always a column name from the table being queried. TypeScript completion will offer available options (sourced from your Zero Schema). Comparison Operators Where supports the following comparison operators: TypeScript will restrict you from using operators with types that don’t make sense – you can’t use > with boolean for example. Let us know! Many are easy to add. Equals is the Default Comparison Operator Because comparing by = is so common, you can leave it out and where defaults to =. zql.issue.where('priority', 'high') Comparing to null As in SQL, ZQL’s null cannot be compared with =, !=, <, or any other normal comparison operator. Comparing any value to null with such operators is always false: These semantics feel a bit weird, but they are consistent with SQL. The reason SQL does it this way is to make join semantics work: if you’re joining employee.orgID on org.id you do not want an employee in no organization to match an org that hasn’t yet been assigned an ID. For when you purposely do want to compare to null ZQL supports IS and IS NOT operators that also work just like in SQL: // Find employees not in any org. zql.employee.where('orgID', 'IS', null) // Find employees in an org other than 42 OR employees in NO org zql.employee.where('orgID', 'IS NOT', 42) TypeScript will prevent you from comparing to null with other operators. Comparing to undefined As a convenience, you can pass undefined to where: zql.issue.where('priority', issue?.priority) This comparison is always false, so the above query always returns no results. Compound Filters The argument to where can also be a callback that returns a complex expression: // Get all issues that have priority 'critical' or else have both // priority 'medium' and not more than 100 votes. zql.issue.where(({cmp, and, or, not}) => or( cmp('priority', 'critical'), and( cmp('priority', 'medium'), not(cmp('numVotes', '>', 100)) ) ) ) cmp is short for compare and works the same as where at the top-level except that it can’t be chained and it only accepts comparison operators (no relationship filters – see below). Note that chaining where() is also a one-level and: // Find issues with priority 3 or higher, owned by aa zql.issue .where('priority', '>=', 3) .where('owner', 'aa') Comparing Literal Values The where clause always expects its first parameter to be a column name as a string. Same with the cmp helper: // \"foo\" is a column name, not a string: zql.issue.where('foo', 'bar') // \"foo\" is a column name, not a string: zql.issue.where(({cmp}) => cmp('foo', 'bar')) To compare to a literal value, use the cmpLit helper: zql.issue.where(cmpLit('foobar', 'foo' + 'bar')) This is particularly useful for implementing permissions, because the first parameter can be a field of your context: zql.issue.where(cmpLit(ctx.role, 'admin')) Relationship Filters Your filter can also test properties of relationships. Currently the only supported test is existence: // Find all orgs that have at least one employee zql.organization.whereExists('employees') The argument to whereExists is a relationship, so just like other relationships, it can be refined with a query: // Find all orgs that have at least one cool employee zql.organization.whereExists('employees', q => q.where('location', 'Hawaii') ) As with querying relationships, relationship filters can be arbitrarily nested: // Get all issues that have comments that have reactions zql.issue.whereExists('comments', q => q.whereExists('reactions') ) The exists helper is also provided which can be used with and, or, cmp, and not to build compound filters that check relationship existence: // Find issues that have at least one comment or are high priority zql.issue.where({cmp, or, exists} => or( cmp('priority', 'high'), exists('comments'), ), ) Type Helpers You can get the TypeScript type of the result of a query using the QueryResultType helper: import type {QueryResultType} from '@rocicorp/zero' const complexQuery = zql.issue.related( 'comments', q => q.related('author') ) type MyComplexResult = QueryResultType // MyComplexResult is: readonly IssueRow & { // readonly comments: readonly (CommentRow & { // readonly author: readonly AuthorRow|undefined; // })[]; // }[] You can get the type of a single row with QueryRowType: import type {QueryRowType} from '@rocicorp/zero' type MySingleRow = QueryRowType // MySingleRow is: readonly IssueRow & { // readonly comments: readonly (CommentRow & { // readonly author: readonly AuthorRow|undefined; // })[]; // } Planning Zero automatically plans queries, selecting the best indexes and join orders in most cases. Inspecting Query Plans You can inspect the plan that Zero generates for any ZQL query using the inspector. Manually Flipping Joins The process Zero uses to optimize joins is called \"join flipping\", because it involves \"flipping\" the order of joins to minimize the number of rows processed. Typically the Zero planner will pick the joins to flip automatically. But in some rare cases, you may want to manually specify the join order. This can be done by passing the flip:true option to whereExists: // Find the first 100 documents that user 42 can edit, // ordered by created desc. Because each user is an editor // of only a few documents, flip:true is much faster than // flip:false. zql.documents.whereExists('editors', e => e.where('userID', 42), {flip: true} ), .orderBy('created', 'desc') .limit(100) Or with exists: // Find issues created by user 42 or that have a comment // by user 42. Because user 42 has commented on only a // few issues, flip:true is much faster than flip:false. zql.issue.where({cmp, or, exists} => or( cmp('creatorID', 42), exists('comments', c => c.where('creatorID', 42), {flip: true}), ), ) You can manually flip just one or a few of the whereExists clauses in a query, leaving the rest to be planned automatically. Scalar Subqueries Scalar subqueries are an optimization for exists queries. Instead of doing a join at query time, Zero pre-resolves the subquery and rewrites it as a simple equality check. To use scalar subqueries, add {scalar: true} to your whereExists call: // Instead of joining to find issues where project.name = 'zero' // Zero resolves this server-side to: where('projectId', '123') zql.issue.whereExists( 'project', q => q.where('name', 'zero'), {scalar: true}, ) Or with exists: zql.issue.where({cmp, exists} => exists('project', q => q.where('name', 'zero'), {scalar: true}, ), ) Why It Matters Joins are expensive. Sometimes they are needed, but for something like \"give me all issues where the owner's name is Alice\", you don't need a full join — you just need Alice's ID. The scalar optimization pre-fetches that ID and rewrites your query as where('ownerId', aliceId). This can improve query performance significantly. It also allows planning to work better. Since the ID is known at planning time, Zero/SQLite can choose better indexes. Trade-offs The query needs to be \"rehydrated\" (re-run) whenever the scalar subquery result changes. This is fine for relatively stable lookup data like user IDs or project IDs, but you probably wouldn't want it for rapidly-changing data. Also, scalar subqueries only work when the subquery is guaranteed to return at most one row (hence \"scalar\"). Zero checks that your subquery constrains a unique index and will throw an error if it doesn't. Future Work Scalar subqueries are not currently integrated with Zero's planner. You need to manually choose when to use them.", "headings": [ { "text": "Create a Builder", @@ -6945,6 +7053,10 @@ "text": "Comparing to null", "id": "comparing-to-null" }, + { + "text": "Comparing to undefined", + "id": "comparing-to-undefined" + }, { "text": "Compound Filters", "id": "compound-filters" @@ -6972,12 +7084,28 @@ { "text": "Manually Flipping Joins", "id": "manually-flipping-joins" + }, + { + "text": "Scalar Subqueries", + "id": "scalar-subqueries" + }, + { + "text": "Why It Matters", + "id": "why-it-matters" + }, + { + "text": "Trade-offs", + "id": "trade-offs" + }, + { + "text": "Future Work", + "id": "future-work" } ], "kind": "page" }, { - "id": "511-zql#create-a-builder", + "id": "519-zql#create-a-builder", "title": "ZQL", "searchTitle": "Create a Builder", "sectionTitle": "Create a Builder", @@ -6987,7 +7115,7 @@ "kind": "section" }, { - "id": "512-zql#select", + "id": "520-zql#select", "title": "ZQL", "searchTitle": "Select", "sectionTitle": "Select", @@ -6997,7 +7125,7 @@ "kind": "section" }, { - "id": "513-zql#ordering", + "id": "521-zql#ordering", "title": "ZQL", "searchTitle": "Ordering", "sectionTitle": "Ordering", @@ -7007,7 +7135,7 @@ "kind": "section" }, { - "id": "514-zql#limit", + "id": "522-zql#limit", "title": "ZQL", "searchTitle": "Limit", "sectionTitle": "Limit", @@ -7017,7 +7145,7 @@ "kind": "section" }, { - "id": "515-zql#paging", + "id": "523-zql#paging", "title": "ZQL", "searchTitle": "Paging", "sectionTitle": "Paging", @@ -7027,7 +7155,7 @@ "kind": "section" }, { - "id": "516-zql#getting-a-single-result", + "id": "524-zql#getting-a-single-result", "title": "ZQL", "searchTitle": "Getting a Single Result", "sectionTitle": "Getting a Single Result", @@ -7037,7 +7165,7 @@ "kind": "section" }, { - "id": "517-zql#relationships", + "id": "525-zql#relationships", "title": "ZQL", "searchTitle": "Relationships", "sectionTitle": "Relationships", @@ -7047,7 +7175,7 @@ "kind": "section" }, { - "id": "518-zql#refining-relationships", + "id": "526-zql#refining-relationships", "title": "ZQL", "searchTitle": "Refining Relationships", "sectionTitle": "Refining Relationships", @@ -7057,7 +7185,7 @@ "kind": "section" }, { - "id": "519-zql#nested-relationships", + "id": "527-zql#nested-relationships", "title": "ZQL", "searchTitle": "Nested Relationships", "sectionTitle": "Nested Relationships", @@ -7067,27 +7195,27 @@ "kind": "section" }, { - "id": "520-zql#where", + "id": "528-zql#where", "title": "ZQL", "searchTitle": "Where", "sectionTitle": "Where", "sectionId": "where", "url": "/docs/zql", - "content": "You can filter a query with where(): zql.issue.where('priority', '=', 'high') The first parameter is always a column name from the table being queried. TypeScript completion will offer available options (sourced from your Zero Schema). Comparison Operators Where supports the following comparison operators: TypeScript will restrict you from using operators with types that don’t make sense – you can’t use > with boolean for example. If you don’t see the comparison operator you need, let us know, many are easy to add. Equals is the Default Comparison Operator Because comparing by = is so common, you can leave it out and where defaults to =. zql.issue.where('priority', 'high') Comparing to null As in SQL, ZQL’s null cannot be compared with =, !=, <, or any other normal comparison operator. Comparing any value to null with such operators is always false: These semantics feel a bit weird, but they are consistent with SQL. The reason SQL does it this way is to make join semantics work: if you’re joining employee.orgID on org.id you do not want an employee in no organization to match an org that hasn’t yet been assigned an ID. For when you purposely do want to compare to null ZQL supports IS and IS NOT operators that also work just like in SQL: // Find employees not in any org. zql.employee.where('orgID', 'IS', null) // Find employees in an org other than 42 OR employees in NO org zql.employee.where('orgID', 'IS NOT', 42) TypeScript will prevent you from comparing to null with other operators. Compound Filters The argument to where can also be a callback that returns a complex expression: // Get all issues that have priority 'critical' or else have both // priority 'medium' and not more than 100 votes. zql.issue.where(({cmp, and, or, not}) => or( cmp('priority', 'critical'), and( cmp('priority', 'medium'), not(cmp('numVotes', '>', 100)) ) ) ) cmp is short for compare and works the same as where at the top-level except that it can’t be chained and it only accepts comparison operators (no relationship filters – see below). Note that chaining where() is also a one-level and: // Find issues with priority 3 or higher, owned by aa zql.issue .where('priority', '>=', 3) .where('owner', 'aa') Comparing Literal Values The where clause always expects its first parameter to be a column name as a string. Same with the cmp helper: // \"foo\" is a column name, not a string: zql.issue.where('foo', 'bar') // \"foo\" is a column name, not a string: zql.issue.where(({cmp}) => cmp('foo', 'bar')) To compare to a literal value, use the cmpLit helper: zql.issue.where(cmpLit('foobar', 'foo' + 'bar')) This is particularly useful for implementing permissions, because the first parameter can be a field of your context: zql.issue.where(cmpLit(ctx.role, 'admin')) Relationship Filters Your filter can also test properties of relationships. Currently the only supported test is existence: // Find all orgs that have at least one employee zql.organization.whereExists('employees') The argument to whereExists is a relationship, so just like other relationships, it can be refined with a query: // Find all orgs that have at least one cool employee zql.organization.whereExists('employees', q => q.where('location', 'Hawaii') ) As with querying relationships, relationship filters can be arbitrarily nested: // Get all issues that have comments that have reactions zql.issue.whereExists('comments', q => q.whereExists('reactions') ) The exists helper is also provided which can be used with and, or, cmp, and not to build compound filters that check relationship existence: // Find issues that have at least one comment or are high priority zql.issue.where({cmp, or, exists} => or( cmp('priority', 'high'), exists('comments'), ), )", + "content": "You can filter a query with where(): zql.issue.where('priority', '=', 'high') The first parameter is always a column name from the table being queried. TypeScript completion will offer available options (sourced from your Zero Schema). Comparison Operators Where supports the following comparison operators: TypeScript will restrict you from using operators with types that don’t make sense – you can’t use > with boolean for example. Let us know! Many are easy to add. Equals is the Default Comparison Operator Because comparing by = is so common, you can leave it out and where defaults to =. zql.issue.where('priority', 'high') Comparing to null As in SQL, ZQL’s null cannot be compared with =, !=, <, or any other normal comparison operator. Comparing any value to null with such operators is always false: These semantics feel a bit weird, but they are consistent with SQL. The reason SQL does it this way is to make join semantics work: if you’re joining employee.orgID on org.id you do not want an employee in no organization to match an org that hasn’t yet been assigned an ID. For when you purposely do want to compare to null ZQL supports IS and IS NOT operators that also work just like in SQL: // Find employees not in any org. zql.employee.where('orgID', 'IS', null) // Find employees in an org other than 42 OR employees in NO org zql.employee.where('orgID', 'IS NOT', 42) TypeScript will prevent you from comparing to null with other operators. Comparing to undefined As a convenience, you can pass undefined to where: zql.issue.where('priority', issue?.priority) This comparison is always false, so the above query always returns no results. Compound Filters The argument to where can also be a callback that returns a complex expression: // Get all issues that have priority 'critical' or else have both // priority 'medium' and not more than 100 votes. zql.issue.where(({cmp, and, or, not}) => or( cmp('priority', 'critical'), and( cmp('priority', 'medium'), not(cmp('numVotes', '>', 100)) ) ) ) cmp is short for compare and works the same as where at the top-level except that it can’t be chained and it only accepts comparison operators (no relationship filters – see below). Note that chaining where() is also a one-level and: // Find issues with priority 3 or higher, owned by aa zql.issue .where('priority', '>=', 3) .where('owner', 'aa') Comparing Literal Values The where clause always expects its first parameter to be a column name as a string. Same with the cmp helper: // \"foo\" is a column name, not a string: zql.issue.where('foo', 'bar') // \"foo\" is a column name, not a string: zql.issue.where(({cmp}) => cmp('foo', 'bar')) To compare to a literal value, use the cmpLit helper: zql.issue.where(cmpLit('foobar', 'foo' + 'bar')) This is particularly useful for implementing permissions, because the first parameter can be a field of your context: zql.issue.where(cmpLit(ctx.role, 'admin')) Relationship Filters Your filter can also test properties of relationships. Currently the only supported test is existence: // Find all orgs that have at least one employee zql.organization.whereExists('employees') The argument to whereExists is a relationship, so just like other relationships, it can be refined with a query: // Find all orgs that have at least one cool employee zql.organization.whereExists('employees', q => q.where('location', 'Hawaii') ) As with querying relationships, relationship filters can be arbitrarily nested: // Get all issues that have comments that have reactions zql.issue.whereExists('comments', q => q.whereExists('reactions') ) The exists helper is also provided which can be used with and, or, cmp, and not to build compound filters that check relationship existence: // Find issues that have at least one comment or are high priority zql.issue.where({cmp, or, exists} => or( cmp('priority', 'high'), exists('comments'), ), )", "kind": "section" }, { - "id": "521-zql#comparison-operators", + "id": "529-zql#comparison-operators", "title": "ZQL", "searchTitle": "Comparison Operators", "sectionTitle": "Comparison Operators", "sectionId": "comparison-operators", "url": "/docs/zql", - "content": "Where supports the following comparison operators: TypeScript will restrict you from using operators with types that don’t make sense – you can’t use > with boolean for example. If you don’t see the comparison operator you need, let us know, many are easy to add.", + "content": "Where supports the following comparison operators: TypeScript will restrict you from using operators with types that don’t make sense – you can’t use > with boolean for example. Let us know! Many are easy to add.", "kind": "section" }, { - "id": "522-zql#equals-is-the-default-comparison-operator", + "id": "530-zql#equals-is-the-default-comparison-operator", "title": "ZQL", "searchTitle": "Equals is the Default Comparison Operator", "sectionTitle": "Equals is the Default Comparison Operator", @@ -7097,7 +7225,7 @@ "kind": "section" }, { - "id": "523-zql#comparing-to-null", + "id": "531-zql#comparing-to-null", "title": "ZQL", "searchTitle": "Comparing to null", "sectionTitle": "Comparing to null", @@ -7107,7 +7235,17 @@ "kind": "section" }, { - "id": "524-zql#compound-filters", + "id": "532-zql#comparing-to-undefined", + "title": "ZQL", + "searchTitle": "Comparing to undefined", + "sectionTitle": "Comparing to undefined", + "sectionId": "comparing-to-undefined", + "url": "/docs/zql", + "content": "As a convenience, you can pass undefined to where: zql.issue.where('priority', issue?.priority) This comparison is always false, so the above query always returns no results.", + "kind": "section" + }, + { + "id": "533-zql#compound-filters", "title": "ZQL", "searchTitle": "Compound Filters", "sectionTitle": "Compound Filters", @@ -7117,7 +7255,7 @@ "kind": "section" }, { - "id": "525-zql#comparing-literal-values", + "id": "534-zql#comparing-literal-values", "title": "ZQL", "searchTitle": "Comparing Literal Values", "sectionTitle": "Comparing Literal Values", @@ -7127,7 +7265,7 @@ "kind": "section" }, { - "id": "526-zql#relationship-filters", + "id": "535-zql#relationship-filters", "title": "ZQL", "searchTitle": "Relationship Filters", "sectionTitle": "Relationship Filters", @@ -7137,7 +7275,7 @@ "kind": "section" }, { - "id": "527-zql#type-helpers", + "id": "536-zql#type-helpers", "title": "ZQL", "searchTitle": "Type Helpers", "sectionTitle": "Type Helpers", @@ -7147,7 +7285,7 @@ "kind": "section" }, { - "id": "528-zql#planning", + "id": "537-zql#planning", "title": "ZQL", "searchTitle": "Planning", "sectionTitle": "Planning", @@ -7157,7 +7295,7 @@ "kind": "section" }, { - "id": "529-zql#inspecting-query-plans", + "id": "538-zql#inspecting-query-plans", "title": "ZQL", "searchTitle": "Inspecting Query Plans", "sectionTitle": "Inspecting Query Plans", @@ -7167,7 +7305,7 @@ "kind": "section" }, { - "id": "530-zql#manually-flipping-joins", + "id": "539-zql#manually-flipping-joins", "title": "ZQL", "searchTitle": "Manually Flipping Joins", "sectionTitle": "Manually Flipping Joins", @@ -7175,5 +7313,45 @@ "url": "/docs/zql", "content": "The process Zero uses to optimize joins is called \"join flipping\", because it involves \"flipping\" the order of joins to minimize the number of rows processed. Typically the Zero planner will pick the joins to flip automatically. But in some rare cases, you may want to manually specify the join order. This can be done by passing the flip:true option to whereExists: // Find the first 100 documents that user 42 can edit, // ordered by created desc. Because each user is an editor // of only a few documents, flip:true is much faster than // flip:false. zql.documents.whereExists('editors', e => e.where('userID', 42), {flip: true} ), .orderBy('created', 'desc') .limit(100) Or with exists: // Find issues created by user 42 or that have a comment // by user 42. Because user 42 has commented on only a // few issues, flip:true is much faster than flip:false. zql.issue.where({cmp, or, exists} => or( cmp('creatorID', 42), exists('comments', c => c.where('creatorID', 42), {flip: true}), ), ) You can manually flip just one or a few of the whereExists clauses in a query, leaving the rest to be planned automatically.", "kind": "section" + }, + { + "id": "540-zql#scalar-subqueries", + "title": "ZQL", + "searchTitle": "Scalar Subqueries", + "sectionTitle": "Scalar Subqueries", + "sectionId": "scalar-subqueries", + "url": "/docs/zql", + "content": "Scalar subqueries are an optimization for exists queries. Instead of doing a join at query time, Zero pre-resolves the subquery and rewrites it as a simple equality check. To use scalar subqueries, add {scalar: true} to your whereExists call: // Instead of joining to find issues where project.name = 'zero' // Zero resolves this server-side to: where('projectId', '123') zql.issue.whereExists( 'project', q => q.where('name', 'zero'), {scalar: true}, ) Or with exists: zql.issue.where({cmp, exists} => exists('project', q => q.where('name', 'zero'), {scalar: true}, ), ) Why It Matters Joins are expensive. Sometimes they are needed, but for something like \"give me all issues where the owner's name is Alice\", you don't need a full join — you just need Alice's ID. The scalar optimization pre-fetches that ID and rewrites your query as where('ownerId', aliceId). This can improve query performance significantly. It also allows planning to work better. Since the ID is known at planning time, Zero/SQLite can choose better indexes. Trade-offs The query needs to be \"rehydrated\" (re-run) whenever the scalar subquery result changes. This is fine for relatively stable lookup data like user IDs or project IDs, but you probably wouldn't want it for rapidly-changing data. Also, scalar subqueries only work when the subquery is guaranteed to return at most one row (hence \"scalar\"). Zero checks that your subquery constrains a unique index and will throw an error if it doesn't. Future Work Scalar subqueries are not currently integrated with Zero's planner. You need to manually choose when to use them.", + "kind": "section" + }, + { + "id": "541-zql#why-it-matters", + "title": "ZQL", + "searchTitle": "Why It Matters", + "sectionTitle": "Why It Matters", + "sectionId": "why-it-matters", + "url": "/docs/zql", + "content": "Joins are expensive. Sometimes they are needed, but for something like \"give me all issues where the owner's name is Alice\", you don't need a full join — you just need Alice's ID. The scalar optimization pre-fetches that ID and rewrites your query as where('ownerId', aliceId). This can improve query performance significantly. It also allows planning to work better. Since the ID is known at planning time, Zero/SQLite can choose better indexes.", + "kind": "section" + }, + { + "id": "542-zql#trade-offs", + "title": "ZQL", + "searchTitle": "Trade-offs", + "sectionTitle": "Trade-offs", + "sectionId": "trade-offs", + "url": "/docs/zql", + "content": "The query needs to be \"rehydrated\" (re-run) whenever the scalar subquery result changes. This is fine for relatively stable lookup data like user IDs or project IDs, but you probably wouldn't want it for rapidly-changing data. Also, scalar subqueries only work when the subquery is guaranteed to return at most one row (hence \"scalar\"). Zero checks that your subquery constrains a unique index and will throw an error if it doesn't.", + "kind": "section" + }, + { + "id": "543-zql#future-work", + "title": "ZQL", + "searchTitle": "Future Work", + "sectionTitle": "Future Work", + "sectionId": "future-work", + "url": "/docs/zql", + "content": "Scalar subqueries are not currently integrated with Zero's planner. You need to manually choose when to use them.", + "kind": "section" } ] \ No newline at end of file diff --git a/contents/docs/connecting-to-postgres.mdx b/contents/docs/connecting-to-postgres.mdx index f5530881..fe85d85c 100644 --- a/contents/docs/connecting-to-postgres.mdx +++ b/contents/docs/connecting-to-postgres.mdx @@ -6,25 +6,27 @@ In the future, Zero will work with many different backend databases. Today only Here are some common Postgres options and what we know about their support level: -| Postgres | Support Status | -| --------------------------------- | ---------------------------------------------------------- | -| AWS RDS | ✅ | -| AWS Aurora | ✅  v15.6+ | -| Google Cloud SQL | ✅  See [notes below](#google-cloud-sql) | -| [Fly.io](https://fly.io) Postgres | ✅  See [notes below](#flyio) | -| Neon | ✅  See [notes below](#neon) | -| PlanetScale for Postgres | ✅  See [notes below](#planetscale-for-postgres) | -| Postgres.app | ✅ | -| postgres:16.2-alpine docker image | ✅ | -| Supabase | ✅  See [notes below](#supabase) | -| Render | 🤷‍♂️  No [event triggers](#event-triggers) | -| Heroku | 🤷‍♂️  No [event triggers](#event-triggers) | +| Postgres | Support Status | +| ------------------------ | ---------------------------------------------------------- | +| AWS RDS | ✅ | +| AWS Aurora | ✅  v15.6+ | +| PlanetScale for Postgres | ✅  See [notes below](#planetscale-for-postgres) | +| Neon | ✅  See [notes below](#neon) | +| Google Cloud SQL | ✅  See [notes below](#google-cloud-sql) | +| Postgres.app | ✅ | +| Postgres 15+ Docker | ✅ | +| Supabase | ⚠️  See [notes below](#supabase) | +| Fly.io Managed Postgres | ⚠️  See [notes below](#flyio) | +| Render | ⚠️  See [notes below](#render) | +| Heroku | 🤷‍♂️  No [event triggers](#event-triggers) | ## Event Triggers Zero uses Postgres “[Event Triggers](https://www.postgresql.org/docs/current/sql-createeventtrigger.html)” when possible to implement high-quality, efficient [schema migration](schema/#migrations). -Some hosted Postgres providers don’t provide access to Event Triggers. +Some hosted Postgres providers don't provide access to Event Triggers. + +Some managed providers also have incomplete Event Trigger behavior for certain DDL (for example, `ALTER PUBLICATION`). We call out known provider-specific issues below. Zero still works out of the box with these providers, but for correctness, any schema change triggers a full reset of all server-side and client-side state. For small databases (< 10GB) this can be OK, but for bigger databases we recommend choosing a provider that grants access to Event Triggers. @@ -63,46 +65,98 @@ For development databases, you can set a `max_slot_wal_keep_size` value in Postg This is a configuration parameter that bounds the amount of WAL kept around for replication slots, and [invalidates the slots that are too far behind](https://www.postgresql.org/docs/current/runtime-config-replication.html#GUC-MAX-SLOT-WAL-KEEP-SIZE). -`zero-cache` will automatically detect if the replication slot has been invalidated and re-sync replicas from scratch. +Zero-cache will automatically detect if the replication slot has been invalidated and re-sync replicas from scratch. This configuration can cause problems like `slot has been invalidated because it exceeded the maximum reserved size` and is not recommended for production databases. ## Provider-Specific Notes -### Google Cloud SQL +### PlanetScale for Postgres -Zero works with Google Cloud SQL out of the box. In many configurations, when you connect with a user that has sufficient privileges, `zero-cache` will create its default publication automatically. +You should use the `default` role that PlanetScale provides, because PlanetScale user-defined roles cannot create replication slots. -If your Cloud SQL user does not have permission to create publications, you can still use Zero by [creating a publication manually](/docs/postgres-support#limiting-replication) and then specifying that publication name in [App Publications](/docs/zero-cache-config#app-publications) when running `zero-cache`. +Planetscale Postgres defaults `max_connections` to 25, which can easily be exhausted by Zero's connection pools. This will result in an error like `remaining connection slots are reserved for roles with the SUPERUSER attribute`. +You should increase this value in the Parameters section of the PlanetScale dashboard to 100 or more. -On Google Cloud SQL for PostgreSQL, enable logical decoding by turning on the instance flag `cloudsql.logical_decoding`. -You do not set `wal_level` directly on Cloud SQL. -See Google's documentation for details: [Configure logical replication](https://cloud.google.com/sql/docs/postgres/replication/configure-logical-replication). +Make sure to only use a direct connection for the `ZERO_UPSTREAM_DB`, and use pooled URLs for `ZERO_CVR_DB`, `ZERO_CHANGE_DB`, and your API (see [Deployment](/docs/deployment)). + +### Neon + +#### Logical Replication + +Neon supports logical replication, but you need to enable it in the Neon console for your branch/endpoint. + +![Enable logical replication](/images/connecting-to-postgres/neon-enable.png) + +#### Branching + +Neon fully supports Zero, but you should be aware of how Neon's pricing model and Zero interact: because Zero keeps an open connection to Postgres to replicate changes, as long as zero-cache is running, Postgres will be running and you will be charged by Neon. + +For production databases that have enough usage to always be running anyway, this is fine. But for smaller applications that would otherwise not always be running, this can create a surprisingly high bill. You may want to choose a provider that charge a flat monthly rate instead. + +Also some users choose Neon because they hope to use branching for previews. This can work, but if not done with care, Zero can end up keeping each Neon _preview_ branch running too 😳. + +For the recommended approach to preview URLs, see [Previews](/docs/previews). ### Fly.io -Fly does not support TLS on their internal networks. If you run both `zero-cache` and Postgres on Fly, you need -to stop `zero-cache` from trying to use TLS to talk to Postgres. You can do this by adding the `sslmode=disable` -query parameter to your connection strings from `zero-cache`. +#### Networking -### Supabase +Fly Managed Postgres is the latest offering from Fly.io, and it is private-network-only by default. If zero-cache runs outside Fly, connect via Fly WireGuard or run a proxy like [fly-mpg-proxy](https://github.com/fly-apps/fly-mpg-proxy). -#### Postgres Version +Fly does not support TLS on its private network. If `zero-cache` connects to Postgres over the Fly private network (including WireGuard), add `sslmode=disable` to your connection strings. -Supabase requires at least 15.8.1.083 for event trigger support. If you have a lower 15.x, Zero will still work but [schema updates will be slower](#event-triggers). See Supabase's docs for upgrading your Postgres version. +#### Permissions + +Fly Managed Postgres does not provide superuser access, so `zero-cache` cannot create [event triggers](#event-triggers). + +Also, some publication operations (like `FOR TABLES IN SCHEMA ...` / `FOR ALL TABLES`) can be permission-restricted. If `zero-cache` can't create its default publication, create one listing tables explicitly and set the [app publication](/docs/zero-cache-config#app-publications). -#### Connection Type +#### Pooling -In order to connect to Supabase you must use the "Direct Connection" style connection string, not the pooler: +You should use Fly's pgBouncer endpoint for `ZERO_CVR_DB` and `ZERO_CHANGE_DB`. + +### Supabase + +Supabase requires at least 15.8.1.083 for event trigger support. If you have a lower 15.x, Zero will still work but [schema updates will be slower](#event-triggers). See Supabase's docs for upgrading your Postgres version. + +Zero must use the "Direct Connection" string: This is because Zero sets up a logical replication slot, which is only supported with a direct connection. +For `ZERO_CVR_DB` and `ZERO_CHANGE_DB`, prefer Supabase's **session** pooler. The transaction pooler can break prepared statements and cause errors like `26000 prepared statement ... does not exist`. + +#### Publication Changes + +Supabase [does not fire DDL event triggers](https://github.com/supabase/supautils/issues/123) for `ALTER PUBLICATION` directly. + +In Zero `>=v0.26.3`, you can work around this by bookending each `ALTER PUBLICATION` statement with `COMMENT ON PUBLICATION` statements in the same transaction: + +```sql +BEGIN; + +COMMENT ON PUBLICATION zero_pub IS 'anything'; + +ALTER PUBLICATION zero_pub ADD TABLE ...; + +COMMENT ON PUBLICATION zero_pub IS 'anything'; + +-- ... other statements ... + +COMMIT; +``` + +Both `COMMENT ON PUBLICATION` statements must target the publication being modified. All three statements must be in the same transaction, and the comment value can be anything. + +On non-Supabase Postgres, these `COMMENT ON PUBLICATION` statements are harmless when publication event triggers already work. +Also, the event trigger messages emitted for this workaround are backwards compatible with the previous minor version of the processing code, so rolling back one minor version is safe. + #### IPv4 You may also need to assign an IPv4 address to your Supabase instance: @@ -120,19 +174,22 @@ difficult. [Hetzner](https://www.hetzner.com/) offers cheap hosted VPS that supp IPv4 addresses are only supported on the Pro plan and are an extra $4/month. -### PlanetScale for Postgres +### Render -* You need to connect using the "default" role that PlanetScale provides, because PlanetScale's "User-Defiend Roles" cannot create replication slots. -* Be sure to use a direct connection for "Upstream DB" and a pg bouncer connnection string for "CVR DB" and "Change DB". Otherwise you will likely exhaust connection limits to PlanetScale. +Render _can_ work with Zero, but requires admin/support-side setup, and does not support a few core Zero features. -### Neon +App roles can't create [event triggers](#event-triggers), so schema changes will fall back to full resets. -Neon fully supports Zero, but you should be aware of how Neon's pricing model and Zero interact. +You also must ensure `wal_level=logical` by creating a Render support ticket. -Because Zero keeps an open connection to Postgres to replicate changes, as long as zero-cache is running, Postgres will be running and you will be charged by Neon. +Render does not provide superuser access, but you can submit another support ticket to ask Render to create a publication with `FOR ALL TABLES` for you, and then set that publication in [App Publications](/docs/zero-cache-config#app-publications). -For production databases that have enough usage to always be running anyway, this is fine. But for smaller applications that would otherwise not always be running, this can create a surprisingly high bill. You may want to choose a provider that charge a flat monthly rate instead. +### Google Cloud SQL -Also some users choose Neon because they hope to use branching for previews. This can work, but if not done with care, Zero can end up keeping each Neon _preview_ branch running too 😳. +Zero works with Google Cloud SQL out of the box. In many configurations, when you connect with a user that has sufficient privileges, `zero-cache` will create its default publication automatically. + +If your Cloud SQL user does not have permission to create publications, you can still use Zero by [creating a publication manually](/docs/postgres-support#limiting-replication) and then specifying that publication name in [App Publications](/docs/zero-cache-config#app-publications) when running `zero-cache`. -For the recommended approach to preview URLs, see [Preview Deployments](/docs/preview-deployments). +On Google Cloud SQL for PostgreSQL, enable logical decoding by turning on the instance flag `cloudsql.logical_decoding`. +You do not set `wal_level` directly on Cloud SQL. +See Google's documentation for details: [Configure logical replication](https://cloud.google.com/sql/docs/postgres/replication/configure-logical-replication). diff --git a/contents/docs/deployment.mdx b/contents/docs/deployment.mdx index 12c57d13..a4dfa8e8 100644 --- a/contents/docs/deployment.mdx +++ b/contents/docs/deployment.mdx @@ -25,6 +25,8 @@ These components have the following characteristics: You will also need to deploy a Postgres database, your frontend, and your API server for the [query](/docs/queries#server-setup) and [mutate](/docs/mutators#server-setup) endpoints. +Before setting up Postgres, read [Connecting to Postgres](/docs/connecting-to-postgres) for provider-specific notes. + ## Minimum Viable Strategy The simplest way to deploy Zero is to run everything on a single node. This is the least expensive way to run Zero, and it can take you surprisingly far. @@ -56,7 +58,7 @@ services: - 3000:3000 environment: # Your API handles mutations and writes to the PG db - # Use a transaction pooler (e.g. pgbouncer) in production + # Use a pooler (e.g. pgbouncer) in production ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero depends_on: upstream-db: @@ -71,10 +73,10 @@ services: # This *must* be a direct connection (not via pgbouncer) ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records - # Use a transaction pooler (pgbouncer) in production + # Use a pooler (e.g. pgbouncer) in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries - # Use a transaction pooler in production + # Use a pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/zero.db @@ -125,7 +127,7 @@ services: - 3000:3000 environment: # Your API handles mutations and writes to the PG db - # Use a transaction pooler (e.g. pgbouncer) in production + # Use a pooler (e.g. pgbouncer) in production ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero depends_on: upstream-db: @@ -169,10 +171,10 @@ services: # This *must* be a direct connection (not via pgbouncer) ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records - # Use a transaction pooler (e.g. pgbouncer) in production + # Use a pooler (e.g. pgbouncer) in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries - # Use a transaction pooler in production + # Use a pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica @@ -208,13 +210,13 @@ services: condition: service_healthy environment: # Used for writing to the upstream database - # Use a transaction pooler in production + # Use a pooler in production ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records - # Use a transaction pooler in production + # Use a pooler in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries - # Use a transaction pooler in production + # Use a pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica diff --git a/public/images/connecting-to-postgres/neon-enable.png b/public/images/connecting-to-postgres/neon-enable.png new file mode 100644 index 0000000000000000000000000000000000000000..b4c2235c699aa8e549708e5073a74701d8c1145a GIT binary patch literal 110873 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 Ai~s-t literal 0 HcmV?d00001 From 9522dbf76e627721f0bd245d428a321eb15dfd8e Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Mon, 23 Mar 2026 11:54:00 -0700 Subject: [PATCH 2/3] chore: comment out docs until release --- contents/docs/connecting-to-postgres.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contents/docs/connecting-to-postgres.mdx b/contents/docs/connecting-to-postgres.mdx index fe85d85c..0ff143c9 100644 --- a/contents/docs/connecting-to-postgres.mdx +++ b/contents/docs/connecting-to-postgres.mdx @@ -136,7 +136,7 @@ For `ZERO_CVR_DB` and `ZERO_CHANGE_DB`, prefer Supabase's **session** pooler. Th Supabase [does not fire DDL event triggers](https://github.com/supabase/supautils/issues/123) for `ALTER PUBLICATION` directly. -In Zero `>=v0.26.3`, you can work around this by bookending each `ALTER PUBLICATION` statement with `COMMENT ON PUBLICATION` statements in the same transaction: +{/* In Zero `>=v0.26.3`, you can work around this by bookending each `ALTER PUBLICATION` statement with `COMMENT ON PUBLICATION` statements in the same transaction: ```sql BEGIN; @@ -155,7 +155,7 @@ COMMIT; Both `COMMENT ON PUBLICATION` statements must target the publication being modified. All three statements must be in the same transaction, and the comment value can be anything. On non-Supabase Postgres, these `COMMENT ON PUBLICATION` statements are harmless when publication event triggers already work. -Also, the event trigger messages emitted for this workaround are backwards compatible with the previous minor version of the processing code, so rolling back one minor version is safe. +Also, the event trigger messages emitted for this workaround are backwards compatible with the previous minor version of the processing code, so rolling back one minor version is safe. */} #### IPv4 From 717674bb4cd9503a41d52fc6e132c9d32a321093 Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Mon, 23 Mar 2026 11:58:48 -0700 Subject: [PATCH 3/3] chore: update --- contents/docs/connecting-to-postgres.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/contents/docs/connecting-to-postgres.mdx b/contents/docs/connecting-to-postgres.mdx index 0ff143c9..af01f802 100644 --- a/contents/docs/connecting-to-postgres.mdx +++ b/contents/docs/connecting-to-postgres.mdx @@ -134,7 +134,9 @@ For `ZERO_CVR_DB` and `ZERO_CHANGE_DB`, prefer Supabase's **session** pooler. Th #### Publication Changes -Supabase [does not fire DDL event triggers](https://github.com/supabase/supautils/issues/123) for `ALTER PUBLICATION` directly. +Supabase [does not fire DDL event triggers](https://github.com/supabase/supautils/issues/123) for `ALTER PUBLICATION`. + +You must use a `FOR ALL TABLES` publication, so that these missing DDL event triggers do not cause errors with zero-cache. {/* In Zero `>=v0.26.3`, you can work around this by bookending each `ALTER PUBLICATION` statement with `COMMENT ON PUBLICATION` statements in the same transaction: