default: return null } }import {useConnectionState} from '@rocicorp/zero/solid' function ConnectionStatus() { const state = useConnectionState() return (
Connecting...
Connected
Offline
Error
Session expired
) }zero.connection.state.subscribe(state => { switch (state.name) { case 'connecting': console.log(`Connecting... ${state.reason}`) break case 'connected': console.log('Connected') break case 'disconnected': console.log(`Disconnected ${state.reason}`) break case 'error': console.log(`Error ${state.reason}`) break case 'needs-auth': console.log('Session expired') break default: return null } }) Offline Zero does not support offline writes. When the client is in the disconnected, error, or needs-auth states, reads from synced data continue to work, but writes are rejected. Offline UI While Zero is in the disconnected, error, or needs-auth states, you should prevent the user from inputting data to your application to avoid data loss. Zero automates this as best it can by rejecting writes in these states. But there can still be cases where the user can lose work – for example by typing into a textarea that is only written to Zero when the user presses a button. The easiest way to implement this is with a modal overlay that covers the entire screen and tells the user to reconnect. However, you could also continue to let the user use the app read-only, and only disable inputs. Details Connecting Zero starts in the connecting state. While connecting, Zero repeatedly tries to connect to zero-cache. After 1 minute of failed attempts, it transitions to disconnected. This timeout can be configured with the disconnectTimeoutMs constructor parameter: const opts: ZeroOptions = { // ... disconnectTimeoutMs: 1000 * 60 * 10 // 10 minutes } Reads and writes are allowed to Zero mutators while connecting. The writes are queued and are sent when the connection succeeds. If the connection fails, the writes remain queued and are sent the next time Zero connects. This is intended to paper over short connectivity glitches, such as server restarts, walking into an elevator, etc. While you can increase the disconnectTimeoutMs to allow for longer periods of offline operation, this has caveats and is not recommended. Please see offline for more information. Connected Once Zero connects to zero-cache and syncs the first time, it transitions to the connected state. Disconnected After the disconnectTimeoutMs elapses while in the connecting state, Zero transitions to disconnected. Zero also transitions to disconnected when the tab is hidden for hiddenTabDisconnectDelay (default 5 minutes). While disconnected, Zero continues to try to reconnect to zero-cache every 5 seconds. Reads are allowed while disconnected, but writes are rejected and return an offline error. See Offline for more information. Error If zero-cache crashes, or mutate or query endpoints fail, Zero enters the error state. If the response code is 5xx, zero-cache will retry up to four times. Zero does not retry from the error state. Call zero.connection.connect() to retry manually. Reads are allowed while in the error state, but writes are rejected. You can forward connection errors to Sentry (or any error-monitoring tool) by subscribing to zero.connection.state. You can wrap reason in an Error and report it: import * as Sentry from '@sentry/browser' zero.connection.state.subscribe(state => { if (state.name !== 'error') return Sentry.withScope(scope => { scope.setTag('zero.connection.state', state.name) scope.setExtra('zero.connection.reason', state.reason) Sentry.captureException( new Error(`Zero connection error: ${state.reason}`) ) }) }) Needs-Auth If the mutate or query endpoints return a 401 or 403 status code, Zero transitions to the needs-auth state. For cookie auth, refresh the cookie and call zero.connection.connect(). For token auth, fetch a new token and call zero.connection.connect({auth: newToken}) to refresh the token in place without recreating the client. If you are using ZeroProvider, it will do this for you when the auth value changes from one token to another. Reads are allowed while in the needs-auth state, but writes are rejected. See Authentication for more information. Closed Zero transitions to the closed state when you call zero.close(). Most applications will never call close(), and even if they do, they should not still be using Zero at that time. So in practice, you should never see this state in a running application. Reads and writes are both rejected while Zero is in the closed state. Why Zero Doesn't Support Offline Writes Supporting offline writes in collaborative applications is inherently difficult, and no sync engine or CRDT algorithm can automatically solve it for you. Despite what their marketing says 😉. Example Imagine two users are editing an article about cats. One goes offline and does a bunch of work on the article, while the other decides that the article should actually be about dogs and rewrites it. When the offline user reconnects, there is no way that any software algorithm can automatically resolve their conflict. One or the other of them is going to be upset. This is a trivial data model with a single field, and is already unsolvable. Real-world applications are much worse: Foreign keys and other constraints can pass while offline, but break when the user reconnects. Custom business logic and authorization rules can pass while offline, but break when the user reconnects. The application's schema can change while offline, and the user's data may not be processable by the new schema. Just take your own schema and ask yourself what should really happen if one user takes their device offline for a week and makes arbitrarily complex changes while other users are working online. Tradeoffs It is of course possible to create applications that support offline writes well (Git exists!). But it requires significant tradeoffs. For example, you could: Disallow destructive operations (i.e., users can create tasks while offline, but cannot edit or delete them). Support custom UX to allow users to fork and merge conflicts when they occur. Restrict offline writes to a single device. Accept potential user data loss. Zero's Position While we recognize that offline writes would be useful, the reality is that for most of the apps we want to support, the user is online the vast majority of the time and the cost to support offline is extremely high. There is simply more value in making the online experience great first, and that's where we're focused right now. We would like to revisit this in the future, but it's not a priority right now.",
+ "content": "Overview Zero manages a persistent connection to zero-cache with the following lifecycle: Before any of this, Zero is initializing while it loads local data. After that it moves to connecting and never goes back. Usage The current connection state is available in the zero.connection.state property. This is subscribable and also has reactive hooks for React and SolidJS: import {useConnectionState} from '@rocicorp/zero/react' function ConnectionStatus() { const state = useConnectionState() switch (state.name) { case 'initializing': return
Loading...
case 'connecting': return
Connecting...
case 'connected': return
Connected
case 'disconnected': return
Offline
case 'error': return
Error
case 'needs-auth': return
Session expired
default: return null } }import {useConnectionState} from '@rocicorp/zero/solid' function ConnectionStatus() { const state = useConnectionState() return (
Loading...
Connecting...
Connected
Offline
Error
Session expired
) }zero.connection.state.subscribe(state => { switch (state.name) { case 'initializing': console.log('Loading local data...') break case 'connecting': console.log(`Connecting... ${state.reason}`) break case 'connected': console.log('Connected') break case 'disconnected': console.log(`Disconnected ${state.reason}`) break case 'error': console.log(`Error ${state.reason}`) break case 'needs-auth': console.log('Session expired') break default: return null } }) Offline Zero does not support offline writes. When the client is in the disconnected, error, or needs-auth states, reads from synced data continue to work, but writes are rejected. Offline UI While Zero is in the disconnected, error, or needs-auth states, you should prevent the user from inputting data to your application to avoid data loss. Zero automates this as best it can by rejecting writes in these states. But there can still be cases where the user can lose work – for example by typing into a textarea that is only written to Zero when the user presses a button. The easiest way to implement this is with a modal overlay that covers the entire screen and tells the user to reconnect. However, you could also continue to let the user use the app read-only, and only disable inputs. Details Initializing Zero starts in the initializing state, where it loads its local data. It hasn't contacted zero-cache yet, so the network plays no part. How long this takes depends on the device and on how much data is stored locally. On a slow device with a lot of data, it can take many seconds. When the data is loaded, Zero moves to connecting. This only happens once, right after Zero is created. Later reconnects go straight to connecting. While initializing, show a loading indicator rather than an offline or error message. If your app warns the user when Zero takes too long to connect, start that timer when Zero enters connecting. There is no timeout while initializing, and the time doesn't count against disconnectTimeoutMs. If the local data never finishes loading, Zero stays in initializing, so add your own timeout if you need one. Reads and writes are allowed. Queries return results once the local data is loaded, and writes are queued until Zero connects. Connecting Zero enters the connecting state after initializing. While connecting, Zero repeatedly tries to connect to zero-cache. After 1 minute of failed attempts, it transitions to disconnected. Time spent initializing does not count toward this minute. This timeout can be configured with the disconnectTimeoutMs constructor parameter: const opts: ZeroOptions = { // ... disconnectTimeoutMs: 1000 * 60 * 10 // 10 minutes } Reads and writes are allowed to Zero mutators while connecting. The writes are queued and are sent when the connection succeeds. If the connection fails, the writes remain queued and are sent the next time Zero connects. This is intended to paper over short connectivity glitches, such as server restarts, walking into an elevator, etc. While you can increase the disconnectTimeoutMs to allow for longer periods of offline operation, this has caveats and is not recommended. Please see offline for more information. Connected Once Zero connects to zero-cache and syncs the first time, it transitions to the connected state. Disconnected After the disconnectTimeoutMs elapses while in the connecting state, Zero transitions to disconnected. Zero also transitions to disconnected when the tab is hidden for hiddenTabDisconnectDelay (default 5 minutes). While disconnected, Zero continues to try to reconnect to zero-cache every 5 seconds. After a hidden-tab disconnect, Zero instead waits until the tab is visible again, then goes back to connecting. Reads are allowed while disconnected, but writes are rejected and return an offline error. See Offline for more information. Error If zero-cache crashes, or mutate or query endpoints fail, Zero enters the error state. If the response code is 5xx, zero-cache will retry up to four times. Zero does not retry from the error state. Call zero.connection.connect() to retry manually. Reads are allowed while in the error state, but writes are rejected. You can forward connection errors to Sentry (or any error-monitoring tool) by subscribing to zero.connection.state. You can wrap reason in an Error and report it: import * as Sentry from '@sentry/browser' zero.connection.state.subscribe(state => { if (state.name !== 'error') return Sentry.withScope(scope => { scope.setTag('zero.connection.state', state.name) scope.setExtra('zero.connection.reason', state.reason) Sentry.captureException( new Error(`Zero connection error: ${state.reason}`) ) }) }) Needs-Auth If the mutate or query endpoints return a 401 or 403 status code, Zero transitions to the needs-auth state. For cookie auth, refresh the cookie and call zero.connection.connect(). For token auth, fetch a new token and call zero.connection.connect({auth: newToken}) to refresh the token in place without recreating the client. If you are using ZeroProvider, it will do this for you when the auth value changes from one token to another. Reads are allowed while in the needs-auth state, but writes are rejected. See Authentication for more information. Closed Zero transitions to the closed state when you call zero.close(). Most applications will never call close(), and even if they do, they should not still be using Zero at that time. So in practice, you should never see this state in a running application. Reads and writes are both rejected while Zero is in the closed state. Storage Failures Zero stores synced data on the device, in IndexedDB in the browser or SQLite on React Native. Sometimes that fails: the device runs out of space, or the database won't open. When it does, Zero calls onStorageFailure once with a StorageFailureError. Its kind says what went wrong: In the browser, Zero reports a storage failure only when IndexedDB fails to open: full if the browser says the disk is full, otherwise cannot-open. Zero keeps working either way. If storage fails while Zero is starting, Zero runs in memory for the rest of the session, as if you had set kvStore: 'mem', and nothing is saved on the device. If it fails later, Zero stops saving to the device. Queries and mutations keep working on the data in memory and mutations are still sent to zero-cache, but a read that needs data Zero hasn't loaded into memory can still fail. Zero only tries the device's storage again when the app creates a new Zero instance, for example after a restart. By default, Zero only logs the failure. Use onStorageFailure to tell the user what to do: import type {ZeroOptions} from '@rocicorp/zero' const opts: ZeroOptions = { // ... onStorageFailure: failure => { // showBanner stands in for your app's own UI. if (failure.kind === 'full') { showBanner( 'Your device is out of space. Free up some space, then restart the app.' ) } else { showBanner( 'Data could not be saved on this device. Restart the app to try again.' ) } } } StorageFailureError is exported from @rocicorp/zero. Its cause is the original error from the browser or the SQLite driver. A custom kvStore can throw a StorageFailureError to get the same handling. Checking the Store zero.kvStore.kind tells you which storage Zero is using: 'idb', 'mem', 'expo-sqlite', or 'op-sqlite'. A custom store reports its own kind, if it sets one. if (zero.kvStore.kind === 'mem') { // Data is not saved on this device. } Without IndexedDB, kind is 'mem' from the start and onStorageFailure isn't called. When IndexedDB fails to open, kind changes to 'mem' only when Zero first reads or writes the store, so check kind when you need it, not once at startup. Why Zero Doesn't Support Offline Writes Supporting offline writes in collaborative applications is inherently difficult, and no sync engine or CRDT algorithm can automatically solve it for you. Despite what their marketing says 😉. Example Imagine two users are editing an article about cats. One goes offline and does a bunch of work on the article, while the other decides that the article should actually be about dogs and rewrites it. When the offline user reconnects, there is no way that any software algorithm can automatically resolve their conflict. One or the other of them is going to be upset. This is a trivial data model with a single field, and is already unsolvable. Real-world applications are much worse: Foreign keys and other constraints can pass while offline, but break when the user reconnects. Custom business logic and authorization rules can pass while offline, but break when the user reconnects. The application's schema can change while offline, and the user's data may not be processable by the new schema. Just take your own schema and ask yourself what should really happen if one user takes their device offline for a week and makes arbitrarily complex changes while other users are working online. Tradeoffs It is of course possible to create applications that support offline writes well (Git exists!). But it requires significant tradeoffs. For example, you could: Disallow destructive operations (i.e., users can create tasks while offline, but cannot edit or delete them). Support custom UX to allow users to fork and merge conflicts when they occur. Restrict offline writes to a single device. Accept potential user data loss. Zero's Position While we recognize that offline writes would be useful, the reality is that for most of the apps we want to support, the user is online the vast majority of the time and the cost to support offline is extremely high. There is simply more value in making the online experience great first, and that's where we're focused right now. We would like to revisit this in the future, but it's not a priority right now.",
"headings": [
{
"text": "Overview",
@@ -803,6 +803,10 @@
"text": "Details",
"id": "details"
},
+ {
+ "text": "Initializing",
+ "id": "initializing"
+ },
{
"text": "Connecting",
"id": "connecting"
@@ -827,6 +831,14 @@
"text": "Closed",
"id": "closed"
},
+ {
+ "text": "Storage Failures",
+ "id": "storage-failures"
+ },
+ {
+ "text": "Checking the Store",
+ "id": "checking-the-store"
+ },
{
"text": "Why Zero Doesn't Support Offline Writes",
"id": "why-zero-doesnt-support-offline-writes"
@@ -847,27 +859,27 @@
"kind": "page"
},
{
- "id": "130-connection#overview",
+ "id": "131-connection#overview",
"title": "Connection Status",
"searchTitle": "Overview",
"sectionTitle": "Overview",
"sectionId": "overview",
"url": "/docs/connection",
- "content": "Zero manages a persistent connection to zero-cache with the following lifecycle:",
+ "content": "Zero manages a persistent connection to zero-cache with the following lifecycle: Before any of this, Zero is initializing while it loads local data. After that it moves to connecting and never goes back.",
"kind": "section"
},
{
- "id": "131-connection#usage",
+ "id": "132-connection#usage",
"title": "Connection Status",
"searchTitle": "Usage",
"sectionTitle": "Usage",
"sectionId": "usage",
"url": "/docs/connection",
- "content": "The current connection state is available in the zero.connection.state property. This is subscribable and also has reactive hooks for React and SolidJS: import {useConnectionState} from '@rocicorp/zero/react' function ConnectionStatus() { const state = useConnectionState() switch (state.name) { case 'connecting': return
Connecting...
case 'connected': return
Connected
case 'disconnected': return
Offline
case 'error': return
Error
case 'needs-auth': return
Session expired
default: return null } }import {useConnectionState} from '@rocicorp/zero/solid' function ConnectionStatus() { const state = useConnectionState() return (
Connecting...
Connected
Offline
Error
Session expired
) }zero.connection.state.subscribe(state => { switch (state.name) { case 'connecting': console.log(`Connecting... ${state.reason}`) break case 'connected': console.log('Connected') break case 'disconnected': console.log(`Disconnected ${state.reason}`) break case 'error': console.log(`Error ${state.reason}`) break case 'needs-auth': console.log('Session expired') break default: return null } })",
+ "content": "The current connection state is available in the zero.connection.state property. This is subscribable and also has reactive hooks for React and SolidJS: import {useConnectionState} from '@rocicorp/zero/react' function ConnectionStatus() { const state = useConnectionState() switch (state.name) { case 'initializing': return
Loading...
case 'connecting': return
Connecting...
case 'connected': return
Connected
case 'disconnected': return
Offline
case 'error': return
Error
case 'needs-auth': return
Session expired
default: return null } }import {useConnectionState} from '@rocicorp/zero/solid' function ConnectionStatus() { const state = useConnectionState() return (
Loading...
Connecting...
Connected
Offline
Error
Session expired
) }zero.connection.state.subscribe(state => { switch (state.name) { case 'initializing': console.log('Loading local data...') break case 'connecting': console.log(`Connecting... ${state.reason}`) break case 'connected': console.log('Connected') break case 'disconnected': console.log(`Disconnected ${state.reason}`) break case 'error': console.log(`Error ${state.reason}`) break case 'needs-auth': console.log('Session expired') break default: return null } })",
"kind": "section"
},
{
- "id": "132-connection#offline",
+ "id": "133-connection#offline",
"title": "Connection Status",
"searchTitle": "Offline",
"sectionTitle": "Offline",
@@ -877,7 +889,7 @@
"kind": "section"
},
{
- "id": "133-connection#offline-ui",
+ "id": "134-connection#offline-ui",
"title": "Connection Status",
"searchTitle": "Offline UI",
"sectionTitle": "Offline UI",
@@ -887,27 +899,37 @@
"kind": "section"
},
{
- "id": "134-connection#details",
+ "id": "135-connection#details",
"title": "Connection Status",
"searchTitle": "Details",
"sectionTitle": "Details",
"sectionId": "details",
"url": "/docs/connection",
- "content": "Connecting Zero starts in the connecting state. While connecting, Zero repeatedly tries to connect to zero-cache. After 1 minute of failed attempts, it transitions to disconnected. This timeout can be configured with the disconnectTimeoutMs constructor parameter: const opts: ZeroOptions = { // ... disconnectTimeoutMs: 1000 * 60 * 10 // 10 minutes } Reads and writes are allowed to Zero mutators while connecting. The writes are queued and are sent when the connection succeeds. If the connection fails, the writes remain queued and are sent the next time Zero connects. This is intended to paper over short connectivity glitches, such as server restarts, walking into an elevator, etc. While you can increase the disconnectTimeoutMs to allow for longer periods of offline operation, this has caveats and is not recommended. Please see offline for more information. Connected Once Zero connects to zero-cache and syncs the first time, it transitions to the connected state. Disconnected After the disconnectTimeoutMs elapses while in the connecting state, Zero transitions to disconnected. Zero also transitions to disconnected when the tab is hidden for hiddenTabDisconnectDelay (default 5 minutes). While disconnected, Zero continues to try to reconnect to zero-cache every 5 seconds. Reads are allowed while disconnected, but writes are rejected and return an offline error. See Offline for more information. Error If zero-cache crashes, or mutate or query endpoints fail, Zero enters the error state. If the response code is 5xx, zero-cache will retry up to four times. Zero does not retry from the error state. Call zero.connection.connect() to retry manually. Reads are allowed while in the error state, but writes are rejected. You can forward connection errors to Sentry (or any error-monitoring tool) by subscribing to zero.connection.state. You can wrap reason in an Error and report it: import * as Sentry from '@sentry/browser' zero.connection.state.subscribe(state => { if (state.name !== 'error') return Sentry.withScope(scope => { scope.setTag('zero.connection.state', state.name) scope.setExtra('zero.connection.reason', state.reason) Sentry.captureException( new Error(`Zero connection error: ${state.reason}`) ) }) }) Needs-Auth If the mutate or query endpoints return a 401 or 403 status code, Zero transitions to the needs-auth state. For cookie auth, refresh the cookie and call zero.connection.connect(). For token auth, fetch a new token and call zero.connection.connect({auth: newToken}) to refresh the token in place without recreating the client. If you are using ZeroProvider, it will do this for you when the auth value changes from one token to another. Reads are allowed while in the needs-auth state, but writes are rejected. See Authentication for more information. Closed Zero transitions to the closed state when you call zero.close(). Most applications will never call close(), and even if they do, they should not still be using Zero at that time. So in practice, you should never see this state in a running application. Reads and writes are both rejected while Zero is in the closed state.",
+ "content": "Initializing Zero starts in the initializing state, where it loads its local data. It hasn't contacted zero-cache yet, so the network plays no part. How long this takes depends on the device and on how much data is stored locally. On a slow device with a lot of data, it can take many seconds. When the data is loaded, Zero moves to connecting. This only happens once, right after Zero is created. Later reconnects go straight to connecting. While initializing, show a loading indicator rather than an offline or error message. If your app warns the user when Zero takes too long to connect, start that timer when Zero enters connecting. There is no timeout while initializing, and the time doesn't count against disconnectTimeoutMs. If the local data never finishes loading, Zero stays in initializing, so add your own timeout if you need one. Reads and writes are allowed. Queries return results once the local data is loaded, and writes are queued until Zero connects. Connecting Zero enters the connecting state after initializing. While connecting, Zero repeatedly tries to connect to zero-cache. After 1 minute of failed attempts, it transitions to disconnected. Time spent initializing does not count toward this minute. This timeout can be configured with the disconnectTimeoutMs constructor parameter: const opts: ZeroOptions = { // ... disconnectTimeoutMs: 1000 * 60 * 10 // 10 minutes } Reads and writes are allowed to Zero mutators while connecting. The writes are queued and are sent when the connection succeeds. If the connection fails, the writes remain queued and are sent the next time Zero connects. This is intended to paper over short connectivity glitches, such as server restarts, walking into an elevator, etc. While you can increase the disconnectTimeoutMs to allow for longer periods of offline operation, this has caveats and is not recommended. Please see offline for more information. Connected Once Zero connects to zero-cache and syncs the first time, it transitions to the connected state. Disconnected After the disconnectTimeoutMs elapses while in the connecting state, Zero transitions to disconnected. Zero also transitions to disconnected when the tab is hidden for hiddenTabDisconnectDelay (default 5 minutes). While disconnected, Zero continues to try to reconnect to zero-cache every 5 seconds. After a hidden-tab disconnect, Zero instead waits until the tab is visible again, then goes back to connecting. Reads are allowed while disconnected, but writes are rejected and return an offline error. See Offline for more information. Error If zero-cache crashes, or mutate or query endpoints fail, Zero enters the error state. If the response code is 5xx, zero-cache will retry up to four times. Zero does not retry from the error state. Call zero.connection.connect() to retry manually. Reads are allowed while in the error state, but writes are rejected. You can forward connection errors to Sentry (or any error-monitoring tool) by subscribing to zero.connection.state. You can wrap reason in an Error and report it: import * as Sentry from '@sentry/browser' zero.connection.state.subscribe(state => { if (state.name !== 'error') return Sentry.withScope(scope => { scope.setTag('zero.connection.state', state.name) scope.setExtra('zero.connection.reason', state.reason) Sentry.captureException( new Error(`Zero connection error: ${state.reason}`) ) }) }) Needs-Auth If the mutate or query endpoints return a 401 or 403 status code, Zero transitions to the needs-auth state. For cookie auth, refresh the cookie and call zero.connection.connect(). For token auth, fetch a new token and call zero.connection.connect({auth: newToken}) to refresh the token in place without recreating the client. If you are using ZeroProvider, it will do this for you when the auth value changes from one token to another. Reads are allowed while in the needs-auth state, but writes are rejected. See Authentication for more information. Closed Zero transitions to the closed state when you call zero.close(). Most applications will never call close(), and even if they do, they should not still be using Zero at that time. So in practice, you should never see this state in a running application. Reads and writes are both rejected while Zero is in the closed state.",
"kind": "section"
},
{
- "id": "135-connection#connecting",
+ "id": "136-connection#initializing",
+ "title": "Connection Status",
+ "searchTitle": "Initializing",
+ "sectionTitle": "Initializing",
+ "sectionId": "initializing",
+ "url": "/docs/connection",
+ "content": "Zero starts in the initializing state, where it loads its local data. It hasn't contacted zero-cache yet, so the network plays no part. How long this takes depends on the device and on how much data is stored locally. On a slow device with a lot of data, it can take many seconds. When the data is loaded, Zero moves to connecting. This only happens once, right after Zero is created. Later reconnects go straight to connecting. While initializing, show a loading indicator rather than an offline or error message. If your app warns the user when Zero takes too long to connect, start that timer when Zero enters connecting. There is no timeout while initializing, and the time doesn't count against disconnectTimeoutMs. If the local data never finishes loading, Zero stays in initializing, so add your own timeout if you need one. Reads and writes are allowed. Queries return results once the local data is loaded, and writes are queued until Zero connects.",
+ "kind": "section"
+ },
+ {
+ "id": "137-connection#connecting",
"title": "Connection Status",
"searchTitle": "Connecting",
"sectionTitle": "Connecting",
"sectionId": "connecting",
"url": "/docs/connection",
- "content": "Zero starts in the connecting state. While connecting, Zero repeatedly tries to connect to zero-cache. After 1 minute of failed attempts, it transitions to disconnected. This timeout can be configured with the disconnectTimeoutMs constructor parameter: const opts: ZeroOptions = { // ... disconnectTimeoutMs: 1000 * 60 * 10 // 10 minutes } Reads and writes are allowed to Zero mutators while connecting. The writes are queued and are sent when the connection succeeds. If the connection fails, the writes remain queued and are sent the next time Zero connects. This is intended to paper over short connectivity glitches, such as server restarts, walking into an elevator, etc. While you can increase the disconnectTimeoutMs to allow for longer periods of offline operation, this has caveats and is not recommended. Please see offline for more information.",
+ "content": "Zero enters the connecting state after initializing. While connecting, Zero repeatedly tries to connect to zero-cache. After 1 minute of failed attempts, it transitions to disconnected. Time spent initializing does not count toward this minute. This timeout can be configured with the disconnectTimeoutMs constructor parameter: const opts: ZeroOptions = { // ... disconnectTimeoutMs: 1000 * 60 * 10 // 10 minutes } Reads and writes are allowed to Zero mutators while connecting. The writes are queued and are sent when the connection succeeds. If the connection fails, the writes remain queued and are sent the next time Zero connects. This is intended to paper over short connectivity glitches, such as server restarts, walking into an elevator, etc. While you can increase the disconnectTimeoutMs to allow for longer periods of offline operation, this has caveats and is not recommended. Please see offline for more information.",
"kind": "section"
},
{
- "id": "136-connection#connected",
+ "id": "138-connection#connected",
"title": "Connection Status",
"searchTitle": "Connected",
"sectionTitle": "Connected",
@@ -917,17 +939,17 @@
"kind": "section"
},
{
- "id": "137-connection#disconnected",
+ "id": "139-connection#disconnected",
"title": "Connection Status",
"searchTitle": "Disconnected",
"sectionTitle": "Disconnected",
"sectionId": "disconnected",
"url": "/docs/connection",
- "content": "After the disconnectTimeoutMs elapses while in the connecting state, Zero transitions to disconnected. Zero also transitions to disconnected when the tab is hidden for hiddenTabDisconnectDelay (default 5 minutes). While disconnected, Zero continues to try to reconnect to zero-cache every 5 seconds. Reads are allowed while disconnected, but writes are rejected and return an offline error. See Offline for more information.",
+ "content": "After the disconnectTimeoutMs elapses while in the connecting state, Zero transitions to disconnected. Zero also transitions to disconnected when the tab is hidden for hiddenTabDisconnectDelay (default 5 minutes). While disconnected, Zero continues to try to reconnect to zero-cache every 5 seconds. After a hidden-tab disconnect, Zero instead waits until the tab is visible again, then goes back to connecting. Reads are allowed while disconnected, but writes are rejected and return an offline error. See Offline for more information.",
"kind": "section"
},
{
- "id": "138-connection#error",
+ "id": "140-connection#error",
"title": "Connection Status",
"searchTitle": "Error",
"sectionTitle": "Error",
@@ -937,7 +959,7 @@
"kind": "section"
},
{
- "id": "139-connection#needs-auth",
+ "id": "141-connection#needs-auth",
"title": "Connection Status",
"searchTitle": "Needs-Auth",
"sectionTitle": "Needs-Auth",
@@ -947,7 +969,7 @@
"kind": "section"
},
{
- "id": "140-connection#closed",
+ "id": "142-connection#closed",
"title": "Connection Status",
"searchTitle": "Closed",
"sectionTitle": "Closed",
@@ -957,7 +979,27 @@
"kind": "section"
},
{
- "id": "141-connection#why-zero-doesnt-support-offline-writes",
+ "id": "143-connection#storage-failures",
+ "title": "Connection Status",
+ "searchTitle": "Storage Failures",
+ "sectionTitle": "Storage Failures",
+ "sectionId": "storage-failures",
+ "url": "/docs/connection",
+ "content": "Zero stores synced data on the device, in IndexedDB in the browser or SQLite on React Native. Sometimes that fails: the device runs out of space, or the database won't open. When it does, Zero calls onStorageFailure once with a StorageFailureError. Its kind says what went wrong: In the browser, Zero reports a storage failure only when IndexedDB fails to open: full if the browser says the disk is full, otherwise cannot-open. Zero keeps working either way. If storage fails while Zero is starting, Zero runs in memory for the rest of the session, as if you had set kvStore: 'mem', and nothing is saved on the device. If it fails later, Zero stops saving to the device. Queries and mutations keep working on the data in memory and mutations are still sent to zero-cache, but a read that needs data Zero hasn't loaded into memory can still fail. Zero only tries the device's storage again when the app creates a new Zero instance, for example after a restart. By default, Zero only logs the failure. Use onStorageFailure to tell the user what to do: import type {ZeroOptions} from '@rocicorp/zero' const opts: ZeroOptions = { // ... onStorageFailure: failure => { // showBanner stands in for your app's own UI. if (failure.kind === 'full') { showBanner( 'Your device is out of space. Free up some space, then restart the app.' ) } else { showBanner( 'Data could not be saved on this device. Restart the app to try again.' ) } } } StorageFailureError is exported from @rocicorp/zero. Its cause is the original error from the browser or the SQLite driver. A custom kvStore can throw a StorageFailureError to get the same handling. Checking the Store zero.kvStore.kind tells you which storage Zero is using: 'idb', 'mem', 'expo-sqlite', or 'op-sqlite'. A custom store reports its own kind, if it sets one. if (zero.kvStore.kind === 'mem') { // Data is not saved on this device. } Without IndexedDB, kind is 'mem' from the start and onStorageFailure isn't called. When IndexedDB fails to open, kind changes to 'mem' only when Zero first reads or writes the store, so check kind when you need it, not once at startup.",
+ "kind": "section"
+ },
+ {
+ "id": "144-connection#checking-the-store",
+ "title": "Connection Status",
+ "searchTitle": "Checking the Store",
+ "sectionTitle": "Checking the Store",
+ "sectionId": "checking-the-store",
+ "url": "/docs/connection",
+ "content": "zero.kvStore.kind tells you which storage Zero is using: 'idb', 'mem', 'expo-sqlite', or 'op-sqlite'. A custom store reports its own kind, if it sets one. if (zero.kvStore.kind === 'mem') { // Data is not saved on this device. } Without IndexedDB, kind is 'mem' from the start and onStorageFailure isn't called. When IndexedDB fails to open, kind changes to 'mem' only when Zero first reads or writes the store, so check kind when you need it, not once at startup.",
+ "kind": "section"
+ },
+ {
+ "id": "145-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",
@@ -967,7 +1009,7 @@
"kind": "section"
},
{
- "id": "142-connection#example",
+ "id": "146-connection#example",
"title": "Connection Status",
"searchTitle": "Example",
"sectionTitle": "Example",
@@ -977,7 +1019,7 @@
"kind": "section"
},
{
- "id": "143-connection#tradeoffs",
+ "id": "147-connection#tradeoffs",
"title": "Connection Status",
"searchTitle": "Tradeoffs",
"sectionTitle": "Tradeoffs",
@@ -987,7 +1029,7 @@
"kind": "section"
},
{
- "id": "144-connection#zeros-position",
+ "id": "148-connection#zeros-position",
"title": "Connection Status",
"searchTitle": "Zero's Position",
"sectionTitle": "Zero's Position",
@@ -1035,7 +1077,7 @@
"kind": "page"
},
{
- "id": "145-debug/analyze-query-cli#set-up",
+ "id": "149-debug/analyze-query-cli#set-up",
"title": "Analyze Query CLI",
"searchTitle": "Set Up",
"sectionTitle": "Set Up",
@@ -1045,7 +1087,7 @@
"kind": "section"
},
{
- "id": "146-debug/analyze-query-cli#run-zql-queries",
+ "id": "150-debug/analyze-query-cli#run-zql-queries",
"title": "Analyze Query CLI",
"searchTitle": "Run ZQL Queries",
"sectionTitle": "Run ZQL Queries",
@@ -1055,7 +1097,7 @@
"kind": "section"
},
{
- "id": "147-debug/analyze-query-cli#production-use",
+ "id": "151-debug/analyze-query-cli#production-use",
"title": "Analyze Query CLI",
"searchTitle": "Production Use",
"sectionTitle": "Production Use",
@@ -1065,7 +1107,7 @@
"kind": "section"
},
{
- "id": "148-debug/analyze-query-cli#env-var-shorthand",
+ "id": "152-debug/analyze-query-cli#env-var-shorthand",
"title": "Analyze Query CLI",
"searchTitle": "Env Var Shorthand",
"sectionTitle": "Env Var Shorthand",
@@ -1075,7 +1117,7 @@
"kind": "section"
},
{
- "id": "149-debug/analyze-query-cli#other-input-modes",
+ "id": "153-debug/analyze-query-cli#other-input-modes",
"title": "Analyze Query CLI",
"searchTitle": "Other Input Modes",
"sectionTitle": "Other Input Modes",
@@ -1085,7 +1127,7 @@
"kind": "section"
},
{
- "id": "150-debug/analyze-query-cli#output",
+ "id": "154-debug/analyze-query-cli#output",
"title": "Analyze Query CLI",
"searchTitle": "Output",
"sectionTitle": "Output",
@@ -1095,7 +1137,7 @@
"kind": "section"
},
{
- "id": "151-debug/analyze-query-cli#optional-output",
+ "id": "155-debug/analyze-query-cli#optional-output",
"title": "Analyze Query CLI",
"searchTitle": "Optional Output",
"sectionTitle": "Optional Output",
@@ -1155,7 +1197,7 @@
"kind": "page"
},
{
- "id": "152-debug/inspector#accessing-the-inspector",
+ "id": "156-debug/inspector#accessing-the-inspector",
"title": "Inspector",
"searchTitle": "Accessing the Inspector",
"sectionTitle": "Accessing the Inspector",
@@ -1165,7 +1207,7 @@
"kind": "section"
},
{
- "id": "153-debug/inspector#clients-and-groups",
+ "id": "157-debug/inspector#clients-and-groups",
"title": "Inspector",
"searchTitle": "Clients and Groups",
"sectionTitle": "Clients and Groups",
@@ -1175,7 +1217,7 @@
"kind": "section"
},
{
- "id": "154-debug/inspector#queries",
+ "id": "158-debug/inspector#queries",
"title": "Inspector",
"searchTitle": "Queries",
"sectionTitle": "Queries",
@@ -1185,7 +1227,7 @@
"kind": "section"
},
{
- "id": "155-debug/inspector#analyzing-queries",
+ "id": "159-debug/inspector#analyzing-queries",
"title": "Inspector",
"searchTitle": "Analyzing Queries",
"sectionTitle": "Analyzing Queries",
@@ -1195,7 +1237,7 @@
"kind": "section"
},
{
- "id": "156-debug/inspector#interpreting-query-analysis",
+ "id": "160-debug/inspector#interpreting-query-analysis",
"title": "Inspector",
"searchTitle": "Interpreting Query Analysis",
"sectionTitle": "Interpreting Query Analysis",
@@ -1205,7 +1247,7 @@
"kind": "section"
},
{
- "id": "157-debug/inspector#viewing-sqlite-plans",
+ "id": "161-debug/inspector#viewing-sqlite-plans",
"title": "Inspector",
"searchTitle": "Viewing SQLite Plans",
"sectionTitle": "Viewing SQLite Plans",
@@ -1215,7 +1257,7 @@
"kind": "section"
},
{
- "id": "158-debug/inspector#viewing-zero-plans",
+ "id": "162-debug/inspector#viewing-zero-plans",
"title": "Inspector",
"searchTitle": "Viewing Zero Plans",
"sectionTitle": "Viewing Zero Plans",
@@ -1225,7 +1267,7 @@
"kind": "section"
},
{
- "id": "159-debug/inspector#analyzing-arbitrary-zql",
+ "id": "163-debug/inspector#analyzing-arbitrary-zql",
"title": "Inspector",
"searchTitle": "Analyzing Arbitrary ZQL",
"sectionTitle": "Analyzing Arbitrary ZQL",
@@ -1235,7 +1277,7 @@
"kind": "section"
},
{
- "id": "160-debug/inspector#table-data",
+ "id": "164-debug/inspector#table-data",
"title": "Inspector",
"searchTitle": "Table Data",
"sectionTitle": "Table Data",
@@ -1245,7 +1287,7 @@
"kind": "section"
},
{
- "id": "161-debug/inspector#server-version",
+ "id": "165-debug/inspector#server-version",
"title": "Inspector",
"searchTitle": "Server Version",
"sectionTitle": "Server Version",
@@ -1286,7 +1328,7 @@
"kind": "page"
},
{
- "id": "162-debug/replication#resetting",
+ "id": "166-debug/replication#resetting",
"title": "Replication",
"searchTitle": "Resetting",
"sectionTitle": "Resetting",
@@ -1296,7 +1338,7 @@
"kind": "section"
},
{
- "id": "163-debug/replication#inspecting",
+ "id": "167-debug/replication#inspecting",
"title": "Replication",
"searchTitle": "Inspecting",
"sectionTitle": "Inspecting",
@@ -1306,7 +1348,7 @@
"kind": "section"
},
{
- "id": "164-debug/replication#miscellaneous",
+ "id": "168-debug/replication#miscellaneous",
"title": "Replication",
"searchTitle": "Miscellaneous",
"sectionTitle": "Miscellaneous",
@@ -1320,7 +1362,7 @@
"title": "Slow Queries",
"searchTitle": "Slow Queries",
"url": "/docs/debug/slow-queries",
- "content": "In the zero-cache logs, you may see statements indicating a query is slow: hash=3rhuw19xt9vry transformationHash=1nv7ot74gxfl7 Slow query materialization 325.46865100000286 Or, you may just notice queries taking longer than expected in the UI. Here are some tips to help debug such slow queries. Analyze Queries Use the inspector or analyze query CLI to analyze queries and get detailed information about the query plan and performance. Check ttl If you are seeing unexpected UI flicker when moving between views, it is possible that the queries backing these views have a ttl of never. Set the ttl to something like 5m to keep data cached across navigations. You may alternately want to preload some data at app startup. Conversely, if you are setting ttl to long values, then you may have many backgrounded queries running that the app is not using. You can see which queries are running using the inspector. Ensure that only expected queries are running. Locality If you see log lines like: flushed cvr ... (124ms) this indicates that zero-cache is likely deployed too far away from your CVR database. If you did not configure a CVR database URL then this will be your product's Postgres DB. A slow CVR flush can slow down Zero, since it must complete the flush before sending query result(s) to clients. Try moving zero-cache to be deployed as close as possible to the CVR database. Check Storage zero-cache is effectively a database. It requires fast (low latency and high bandwidth) disk access to perform well. If you're running on network attached storage with high latency, or on AWS with low IOPS, then this is the most likely culprit. Some hosting providers scale IOPS with vCPU. Increasing the vCPU will increase storage throughput and likely resolve the issue. Fly.io provides physically attached SSDs, even for their smallest VMs. Deploying zero-cache there (or any other provider that offers physically attached SSDs) is another option. /statz zero-cache makes some internal health statistics available via the /statz endpoint of zero-cache. In order to access this, you must configure an admin password.",
+ "content": "In the zero-cache logs, you may see statements indicating a query is slow: hash=3rhuw19xt9vry transformationHash=1nv7ot74gxfl7 Slow query materialization 325.46865100000286 Or, you may just notice queries taking longer than expected in the UI. Here are some tips to help debug such slow queries. Analyze Queries Use the inspector or analyze query CLI to analyze queries and get detailed information about the query plan and performance. Check ttl If you are seeing unexpected UI flicker when moving between views, it is possible that the queries backing these views have a ttl of never. Set the ttl to something like 5m to keep data cached across navigations. You may alternately want to preload some data at app startup. Conversely, if you are setting ttl to long values, then you may have many backgrounded queries running that the app is not using. You can see which queries are running using the inspector. Ensure that only expected queries are running. Locality If you see log lines like: flushed cvr ... (124ms) this indicates that zero-cache is likely deployed too far away from your CVR database. If you did not configure a CVR database URL then this will be your product's Postgres DB. A slow CVR flush can slow down Zero, since it must complete the flush before sending query result(s) to clients. Try moving zero-cache to be deployed as close as possible to the CVR database. Check Storage zero-cache is effectively a database. It requires fast (low latency and high bandwidth) disk access to perform well. If you're running on network attached storage with high latency, or on AWS with low IOPS, then this is the most likely culprit. Some hosting providers scale IOPS with vCPU. Increasing the vCPU will increase storage throughput and likely resolve the issue. Fly.io provides physically attached SSDs, even for their smallest VMs. Deploying zero-cache there (or any other provider that offers physically attached SSDs) is another option. /statz zero-cache makes some internal health statistics available via the /statz endpoint of zero-cache. In order to access this, you must configure an admin password or an operator password.",
"headings": [
{
"text": "Analyze Queries",
@@ -1346,7 +1388,7 @@
"kind": "page"
},
{
- "id": "165-debug/slow-queries#analyze-queries",
+ "id": "169-debug/slow-queries#analyze-queries",
"title": "Slow Queries",
"searchTitle": "Analyze Queries",
"sectionTitle": "Analyze Queries",
@@ -1356,7 +1398,7 @@
"kind": "section"
},
{
- "id": "166-debug/slow-queries#check-ttl",
+ "id": "170-debug/slow-queries#check-ttl",
"title": "Slow Queries",
"searchTitle": "Check ttl",
"sectionTitle": "Check ttl",
@@ -1366,7 +1408,7 @@
"kind": "section"
},
{
- "id": "167-debug/slow-queries#locality",
+ "id": "171-debug/slow-queries#locality",
"title": "Slow Queries",
"searchTitle": "Locality",
"sectionTitle": "Locality",
@@ -1376,7 +1418,7 @@
"kind": "section"
},
{
- "id": "168-debug/slow-queries#check-storage",
+ "id": "172-debug/slow-queries#check-storage",
"title": "Slow Queries",
"searchTitle": "Check Storage",
"sectionTitle": "Check Storage",
@@ -1386,13 +1428,13 @@
"kind": "section"
},
{
- "id": "169-debug/slow-queries#statz",
+ "id": "173-debug/slow-queries#statz",
"title": "Slow Queries",
"searchTitle": "/statz",
"sectionTitle": "/statz",
"sectionId": "statz",
"url": "/docs/debug/slow-queries",
- "content": "zero-cache makes some internal health statistics available via the /statz endpoint of zero-cache. In order to access this, you must configure an admin password.",
+ "content": "zero-cache makes some internal health statistics available via the /statz endpoint of zero-cache. In order to access this, you must configure an admin password or an operator password.",
"kind": "section"
},
{
@@ -1409,7 +1451,7 @@
"title": "Ad-Hoc Queries (Deprecated)",
"searchTitle": "Ad-Hoc Queries (Deprecated)",
"url": "/docs/deprecated/ad-hoc-queries",
- "content": "It will be removed in a future release of Zero. Please move to the new queries API. Overview Zero generates a query API for every table you sync. To enable, set the enableLegacyQueries option to true in your schema: const schema = createSchema({ // ... enableLegacyQueries: true }) Once enabled, queries are available at z.query.. const zero = new Zero(...); const issues = await zero.query.issue.where('priority', 'high').run(); Each table is a ZQL builder object. See ZQL for details.",
+ "content": "It will be removed in a future release of Zero. Please move to the new queries API. Overview Zero generates a query API for every table you sync. To enable, set the enableLegacyQueries option to true in your schema: const schema = createSchema({ // ... enableLegacyQueries: true }) Ad-hoc queries send their query ASTs directly to zero-cache, which rejects them by default. To allow them, also set ZERO_ALLOW_LEGACY_QUERIES to true on zero-cache. Once enabled, queries are available at z.query.. const zero = new Zero(...); const issues = await zero.query.issue.where('priority', 'high').run(); Each table is a ZQL builder object. See ZQL for details.",
"headings": [
{
"text": "Overview",
@@ -1419,13 +1461,13 @@
"kind": "page"
},
{
- "id": "170-deprecated/ad-hoc-queries#overview",
+ "id": "174-deprecated/ad-hoc-queries#overview",
"title": "Ad-Hoc Queries (Deprecated)",
"searchTitle": "Overview",
"sectionTitle": "Overview",
"sectionId": "overview",
"url": "/docs/deprecated/ad-hoc-queries",
- "content": "Zero generates a query API for every table you sync. To enable, set the enableLegacyQueries option to true in your schema: const schema = createSchema({ // ... enableLegacyQueries: true }) Once enabled, queries are available at z.query.. const zero = new Zero(...); const issues = await zero.query.issue.where('priority', 'high').run(); Each table is a ZQL builder object. See ZQL for details.",
+ "content": "Zero generates a query API for every table you sync. To enable, set the enableLegacyQueries option to true in your schema: const schema = createSchema({ // ... enableLegacyQueries: true }) Ad-hoc queries send their query ASTs directly to zero-cache, which rejects them by default. To allow them, also set ZERO_ALLOW_LEGACY_QUERIES to true on zero-cache. Once enabled, queries are available at z.query.. const zero = new Zero(...); const issues = await zero.query.issue.where('priority', 'high').run(); Each table is a ZQL builder object. See ZQL for details.",
"kind": "section"
},
{
@@ -1443,7 +1485,7 @@
"kind": "page"
},
{
- "id": "171-deprecated/crud-mutators#overview",
+ "id": "175-deprecated/crud-mutators#overview",
"title": "CRUD Mutators (Deprecated)",
"searchTitle": "Overview",
"sectionTitle": "Overview",
@@ -1515,7 +1557,7 @@
"kind": "page"
},
{
- "id": "172-deprecated/rls-permissions#define-permissions",
+ "id": "176-deprecated/rls-permissions#define-permissions",
"title": "RLS Permissions (Deprecated)",
"searchTitle": "Define Permissions",
"sectionTitle": "Define Permissions",
@@ -1525,7 +1567,7 @@
"kind": "section"
},
{
- "id": "173-deprecated/rls-permissions#access-is-denied-by-default",
+ "id": "177-deprecated/rls-permissions#access-is-denied-by-default",
"title": "RLS Permissions (Deprecated)",
"searchTitle": "Access is Denied by Default",
"sectionTitle": "Access is Denied by Default",
@@ -1535,7 +1577,7 @@
"kind": "section"
},
{
- "id": "174-deprecated/rls-permissions#permission-evaluation",
+ "id": "178-deprecated/rls-permissions#permission-evaluation",
"title": "RLS Permissions (Deprecated)",
"searchTitle": "Permission Evaluation",
"sectionTitle": "Permission Evaluation",
@@ -1545,7 +1587,7 @@
"kind": "section"
},
{
- "id": "175-deprecated/rls-permissions#permission-deployment",
+ "id": "179-deprecated/rls-permissions#permission-deployment",
"title": "RLS Permissions (Deprecated)",
"searchTitle": "Permission Deployment",
"sectionTitle": "Permission Deployment",
@@ -1555,7 +1597,7 @@
"kind": "section"
},
{
- "id": "176-deprecated/rls-permissions#rules",
+ "id": "180-deprecated/rls-permissions#rules",
"title": "RLS Permissions (Deprecated)",
"searchTitle": "Rules",
"sectionTitle": "Rules",
@@ -1565,7 +1607,7 @@
"kind": "section"
},
{
- "id": "177-deprecated/rls-permissions#select-permissions",
+ "id": "181-deprecated/rls-permissions#select-permissions",
"title": "RLS Permissions (Deprecated)",
"searchTitle": "Select Permissions",
"sectionTitle": "Select Permissions",
@@ -1575,7 +1617,7 @@
"kind": "section"
},
{
- "id": "178-deprecated/rls-permissions#insert-permissions",
+ "id": "182-deprecated/rls-permissions#insert-permissions",
"title": "RLS Permissions (Deprecated)",
"searchTitle": "Insert Permissions",
"sectionTitle": "Insert Permissions",
@@ -1585,7 +1627,7 @@
"kind": "section"
},
{
- "id": "179-deprecated/rls-permissions#update-permissions",
+ "id": "183-deprecated/rls-permissions#update-permissions",
"title": "RLS Permissions (Deprecated)",
"searchTitle": "Update Permissions",
"sectionTitle": "Update Permissions",
@@ -1595,7 +1637,7 @@
"kind": "section"
},
{
- "id": "180-deprecated/rls-permissions#delete-permissions",
+ "id": "184-deprecated/rls-permissions#delete-permissions",
"title": "RLS Permissions (Deprecated)",
"searchTitle": "Delete Permissions",
"sectionTitle": "Delete Permissions",
@@ -1605,7 +1647,7 @@
"kind": "section"
},
{
- "id": "181-deprecated/rls-permissions#permissions-based-on-auth-data",
+ "id": "185-deprecated/rls-permissions#permissions-based-on-auth-data",
"title": "RLS Permissions (Deprecated)",
"searchTitle": "Permissions Based on Auth Data",
"sectionTitle": "Permissions Based on Auth Data",
@@ -1615,7 +1657,7 @@
"kind": "section"
},
{
- "id": "182-deprecated/rls-permissions#debugging",
+ "id": "186-deprecated/rls-permissions#debugging",
"title": "RLS Permissions (Deprecated)",
"searchTitle": "Debugging",
"sectionTitle": "Debugging",
@@ -1625,7 +1667,7 @@
"kind": "section"
},
{
- "id": "183-deprecated/rls-permissions#read-permissions",
+ "id": "187-deprecated/rls-permissions#read-permissions",
"title": "RLS Permissions (Deprecated)",
"searchTitle": "Read Permissions",
"sectionTitle": "Read Permissions",
@@ -1635,7 +1677,7 @@
"kind": "section"
},
{
- "id": "184-deprecated/rls-permissions#write-permissions",
+ "id": "188-deprecated/rls-permissions#write-permissions",
"title": "RLS Permissions (Deprecated)",
"searchTitle": "Write Permissions",
"sectionTitle": "Write Permissions",
@@ -1715,7 +1757,7 @@
"kind": "page"
},
{
- "id": "185-install#integrate-zero",
+ "id": "189-install#integrate-zero",
"title": "Install Zero",
"searchTitle": "Integrate Zero",
"sectionTitle": "Integrate Zero",
@@ -1725,7 +1767,7 @@
"kind": "section"
},
{
- "id": "186-install#set-up-your-database",
+ "id": "190-install#set-up-your-database",
"title": "Install Zero",
"searchTitle": "Set Up Your Database",
"sectionTitle": "Set Up Your Database",
@@ -1735,7 +1777,7 @@
"kind": "section"
},
{
- "id": "187-install#install-zero",
+ "id": "191-install#install-zero",
"title": "Install Zero",
"searchTitle": "Install Zero",
"sectionTitle": "Install Zero",
@@ -1745,7 +1787,7 @@
"kind": "section"
},
{
- "id": "188-install#set-up-your-zero-schema",
+ "id": "192-install#set-up-your-zero-schema",
"title": "Install Zero",
"searchTitle": "Set Up Your Zero Schema",
"sectionTitle": "Set Up Your Zero Schema",
@@ -1755,7 +1797,7 @@
"kind": "section"
},
{
- "id": "189-install#set-up-the-zero-client",
+ "id": "193-install#set-up-the-zero-client",
"title": "Install Zero",
"searchTitle": "Set Up the Zero Client",
"sectionTitle": "Set Up the Zero Client",
@@ -1765,7 +1807,7 @@
"kind": "section"
},
{
- "id": "190-install#sync-data",
+ "id": "194-install#sync-data",
"title": "Install Zero",
"searchTitle": "Sync Data",
"sectionTitle": "Sync Data",
@@ -1775,7 +1817,7 @@
"kind": "section"
},
{
- "id": "191-install#define-query",
+ "id": "195-install#define-query",
"title": "Install Zero",
"searchTitle": "Define Query",
"sectionTitle": "Define Query",
@@ -1785,7 +1827,7 @@
"kind": "section"
},
{
- "id": "192-install#add-query-endpoint",
+ "id": "196-install#add-query-endpoint",
"title": "Install Zero",
"searchTitle": "Add Query Endpoint",
"sectionTitle": "Add Query Endpoint",
@@ -1795,7 +1837,7 @@
"kind": "section"
},
{
- "id": "193-install#invoke-query",
+ "id": "197-install#invoke-query",
"title": "Install Zero",
"searchTitle": "Invoke Query",
"sectionTitle": "Invoke Query",
@@ -1805,7 +1847,7 @@
"kind": "section"
},
{
- "id": "194-install#more-about-queries",
+ "id": "198-install#more-about-queries",
"title": "Install Zero",
"searchTitle": "More about Queries",
"sectionTitle": "More about Queries",
@@ -1815,7 +1857,7 @@
"kind": "section"
},
{
- "id": "195-install#mutate-data",
+ "id": "199-install#mutate-data",
"title": "Install Zero",
"searchTitle": "Mutate Data",
"sectionTitle": "Mutate Data",
@@ -1825,7 +1867,7 @@
"kind": "section"
},
{
- "id": "196-install#define-mutators",
+ "id": "200-install#define-mutators",
"title": "Install Zero",
"searchTitle": "Define Mutators",
"sectionTitle": "Define Mutators",
@@ -1835,7 +1877,7 @@
"kind": "section"
},
{
- "id": "197-install#add-mutate-endpoint",
+ "id": "201-install#add-mutate-endpoint",
"title": "Install Zero",
"searchTitle": "Add Mutate Endpoint",
"sectionTitle": "Add Mutate Endpoint",
@@ -1845,7 +1887,7 @@
"kind": "section"
},
{
- "id": "198-install#invoke-mutators",
+ "id": "202-install#invoke-mutators",
"title": "Install Zero",
"searchTitle": "Invoke Mutators",
"sectionTitle": "Invoke Mutators",
@@ -1855,7 +1897,7 @@
"kind": "section"
},
{
- "id": "199-install#more-about-mutators",
+ "id": "203-install#more-about-mutators",
"title": "Install Zero",
"searchTitle": "More about Mutators",
"sectionTitle": "More about Mutators",
@@ -1878,7 +1920,7 @@
"title": "Mutators",
"searchTitle": "Mutators",
"url": "/docs/mutators",
- "content": "Mutators are how you write data with Zero. Here's a simple example: // src/mutators.ts import {defineMutators, defineMutator} from '@rocicorp/zero' import {z} from 'zod' export const mutators = defineMutators({ updateIssue: defineMutator( z.object({ id: z.string(), title: z.string() }), async ({tx, args: {id, title}}) => { if (title.length > 100) { throw new Error(`Title is too long`) } await tx.mutate.issue.update({ id, title }) } ) }) Architecture A copy of each mutator exists on both the client and on your server: Often the implementations will be the same, and you can just share their code. This is easy with full-stack frameworks like TanStack Start or Next.js. But the implementations don't have to be the same, or even compute the same result. For example, the server can add extra checks to enforce permissions, or send notifications or interact with other systems. Life of a Mutation When a mutator is invoked, it initially runs on the client, against the client-side datastore. Any changes are immediately applied to open queries and the user sees the changes. In the background, Zero sends a mutation (a record of the mutator having run with certain arguments) to your server's push endpoint. Your push endpoint runs the push protocol, executing the server-side mutator in a transaction against your database and recording the fact that the mutation ran. The @rocicorp/zero package contains utilities to make it easy to implement this endpoint in TypeScript. The changes to the database are then replicated to zero-cache using logical replication. zero-cache calculates the updates to active queries and sends rows that have changed to each client. It also sends information about the mutations that have been applied to the database. Clients receive row updates and apply them to their local cache. Any pending mutations which have been applied to the server have their local effects rolled back. Client-side queries are updated and the user sees the changes. Defining Mutators Basics Create a mutator using defineMutator. The only required argument is a MutatorFn, which must be async: import {defineMutator} from '@rocicorp/zero' const myMutator = defineMutator(async () => { // ... }) Mutators almost always complete in the same frame on the client, within milliseconds. The reason they are marked async is because on the server, reading from the tx object goes over the network to Postgres. Writing Data The MutatorFn receives a tx parameter which can be used to write data with a CRUD-style API. Each table in your Zero schema has a corresponding field on tx.mutate: const myMutator = defineMutator(async ({tx}) => { // This is here because there's a `user` table in your schema. await tx.mutate.user.insert(...) }) Mutators almost always run in the same frame on the client, against local data. The reason mutators are marked async is because on the server, reading from the tx object goes over the network to Postgres. Also, in edge cases on the client, reads and writes can go to local storage (IndexedDB or SQLite). Insert Create new records with insert: tx.mutate.user.insert({ id: 'user-123', username: 'sam', language: 'js' }) If the Zero primary key already exists, insert will succeed without changing the row - use upsert to update an existing row. Optional fields can be set to null to explicitly set the new field to null. They can also be set to undefined to take the default value (which is often null but can also be some generated value server-side): // Sets language to `null` specifically tx.mutate.user.insert({ id: 'user-123', username: 'sam', language: null }) // Sets language to the default server-side value. // Could be null, or some generated or constant default value too. tx.mutate.user.insert({ id: 'user-123', username: 'sam' }) // Same as above tx.mutate.user.insert({ id: 'user-123', username: 'sam', language: undefined }) Upsert Create new records or update existing ones with upsert: tx.mutate.user.upsert({ id: samID, username: 'sam', language: 'ts' }) upsert supports the same null / undefined semantics for optional fields that insert does (see above). Update Update an existing record. Does nothing if the specified record (by PK) does not exist. You can pass a partial object, leaving fields out that you don’t want to change. For example here we leave the username the same: // Leaves username field to previous value. tx.mutate.user.update({ id: samID, language: 'golang' }) // Same as above tx.mutate.user.update({ id: samID, username: undefined, language: 'haskell' }) // Reset language field to `null` tx.mutate.user.update({ id: samID, language: null }) Delete Delete an existing record. Does nothing if specified record does not exist. tx.mutate.user.delete({ id: samID }) Arguments The MutatorFn can take a single args parameter. To enable this, pass a validator to defineMutator: import {defineMutator} from '@rocicorp/zero' const initStats = defineMutator( z.object({issueCount: z.number()}), async ({tx, args: {issueCount}}) => { if (issueCount < 0) { throw new Error(`issueCount cannot be negative`) } await tx.mutate.stats.insert({ id: 'global', issueCount }) } ) We use Zod in these examples, but you can use any validation library that implements Standard Schema. It's most common for mutators to be a pure function of the database state plus arguments. But it's not required. Impure mutators can be useful, e.g., to consult some external system on the server for authorization or validation. Reading Data You can read data within a mutator by passing ZQL to tx.run: const updateIssue = defineMutator( z.object({id: z.string(), title: z.string()}), async ({tx, args: {id, title}}) => { const issue = await tx.run( zql.issue.where('id', id).one() ) if (issue?.status === 'closed') { throw new Error(`Cannot update closed issue`) } await tx.mutate.issue.update({ id, title }) } ) You have the full power of ZQL at your disposal, including relationships, filters, ordering, and limits. Reads and writes within a mutator are transactional, meaning that the datastore is guaranteed to not change while your mutator is running. And if the mutator throws, the entire mutation is rolled back. Unlike zero.run(), there is no type parameter that can be used to wait for server results inside mutators. This is because waiting for server results in mutators makes no sense – it would defeat the purpose of running optimistically to begin with. When a mutator runs on the client (tx.location === \"client\"), ZQL reads only return data already cached on the client. When mutators run on the server (tx.location === \"server\"), ZQL reads always return all data. Context Mutator parameters are supplied by the client application and passed to the server automatically by Zero. This makes them unsuitable for credentials, since the user could modify them. For this reason, Zero mutators also support the concept of a context object. Access your context with the ctx parameter to your mutator: const createIssue = defineMutator( z.object({id: z.string(), title: z.string()}), async ({tx, ctx: {userID}, args: {id, title}}) => { // Note: User cannot control ctx.userID, so this // enforces authorship of created issue. await tx.mutate.issue.insert({ id, title, authorID: userID }) } ) If you don't want to register your Context and Schema types globally, you can use defineMutatorWithType and defineMutatorsWithType: import { defineMutatorWithType, defineMutatorsWithType } from '@rocicorp/zero' import type {ZeroContext} from 'context.ts' import type {Schema} from 'schema.ts' import type {DrizzleTransaction} from '@rocicorp/zero/server/adapters/drizzle' import type {drizzleClient} from 'db-provider.ts' const defineMutator = defineMutatorWithType< Schema, ZeroContext, DrizzleTransaction >() const defineMutators = defineMutatorsWithType()import { defineMutatorWithType, defineMutatorsWithType } from '@rocicorp/zero' import type {ZeroContext} from 'context.ts' import type {Schema} from 'schema.ts' import type {KyselyTransaction} from '@rocicorp/zero/server/adapters/kysely' import type {Database} from 'db-provider.ts' const defineMutator = defineMutatorWithType< Schema, ZeroContext, KyselyTransaction >() const defineMutators = defineMutatorsWithType()import { defineMutatorWithType, defineMutatorsWithType } from '@rocicorp/zero' import type {ZeroContext} from 'context.ts' import type {Schema} from 'schema.ts' import type {PrismaTransaction} from '@rocicorp/zero/server/adapters/prisma' import type {PrismaClient} from '@prisma/client' const defineMutator = defineMutatorWithType< Schema, ZeroContext, PrismaTransaction >() const defineMutators = defineMutatorsWithType()import { defineMutatorWithType, defineMutatorsWithType } from '@rocicorp/zero' import type {ZeroContext} from 'context.ts' import type {Schema} from 'schema.ts' import type {NodePgTransaction} from '@rocicorp/zero/server/adapters/pg' const defineMutator = defineMutatorWithType< Schema, ZeroContext, NodePgTransaction >() const defineMutators = defineMutatorsWithType()import { defineMutatorWithType, defineMutatorsWithType } from '@rocicorp/zero' import type {ZeroContext} from 'context.ts' import type {Schema} from 'schema.ts' import type {PostgresJsTransaction} from '@rocicorp/zero/server/adapters/postgresjs' const defineMutator = defineMutatorWithType< Schema, ZeroContext, PostgresJsTransaction >() const defineMutators = defineMutatorsWithType() Mutator Registries The result of defineMutator is a MutatorDefinition. By itself this isn't super useful. You need to register it using defineMutators: export const mutators = defineMutators({ issue: { update: updateIssue } }) Typically these are done together in one step: export const mutators = defineMutators({ issue: { update: defineMutator( z.object({id: z.string(), title: z.string()}), async ({tx, args: {id, title}}) => { await tx.mutate.issue.update({ id, title }) } ) } }) The result of defineMutators is called a MutatorRegistry. Each field in the registry is a callable Mutator that you can use to perform mutations: import {mutators} from 'mutators.ts' zero.mutate( mutators.issue.update({ id: 'issue-123', title: 'New title' }) ) Mutator Names Each Mutator has a mutatorName which is computed by defineMutators. When you run a mutator, Zero sends this name along with the arguments to your server to execute the server-side mutation. console.log(mutators.issue.update.mutatorName) // \"issue.update\" mutators.ts By convention, mutators are listed in a central mutators.ts file. This allows them to be easily used on both the client and server: import {defineMutators, defineMutator} from '@rocicorp/zero' import {zql} from './schema.ts' import {z} from 'zod' export const mutators = defineMutators({ posts: { create: defineMutator( z.object({ id: z.string(), title: z.string() }), async ({ tx, context: {userID}, args: {id, title} }) => { await tx.mutate.post.insert({ id, title, authorID: userID }) } ), update: defineMutator( z.object({ id: z.string(), title: z.string().optional() }), async ({ tx, context: {userID}, args: {id, title} }) => { const prev = await tx.run( zql.post.where('id', id).one() ) if (prev?.authorID !== userID) { throw new Error(`Access denied`) } await tx.mutate.post.update({ id, title, authorID: userID }) } ) } }) You can use as many levels of nesting as you want to organize your mutators. As your application grows, you can move mutators to different files to keep them organized: // posts.ts export const postMutators = { create: defineMutator( z.object({ id: z.string(), title: z.string(), }), async ({tx, context: {userID}, args: {id, title}}) => { await tx.mutate.post.insert({ id, title, authorID: userID, }) }, ), } // user.ts export const userMutators = { updateRole: defineMutator( z.object({ role: z.string(), }), async ({tx, ctx: {userID}, args: {role}}) => { await tx.mutate.user.update({ id: userID, role, }) }, ), } // mutators.ts import {postMutators} from 'zero/mutators/posts.ts' import {userMutators} from 'zero/mutators/users.ts' export const mutators = defineMutators{{ posts: postMutators, users: userMutators, }) defineMutators establishes the full name for each mutator (i.e., posts.create, users.updateRole), which is later sent to the server. So this should only be used once at the top level of your mutators.ts file. Registration Before you can use your mutators, you need to register them with Zero: import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from 'zero/mutators.ts' const opts: ZeroOptions = { // ... cacheURL, schema, etc. mutators } return ( )import {ZeroProvider} from '@rocicorp/zero/solid' import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from 'zero/mutators.ts' const opts: ZeroOptions = { // ... cacheURL, schema, etc. mutators } return ( )import {Zero} from '@rocicorp/zero' import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from 'zero/mutators.ts' const opts: ZeroOptions = { // ... cacheURL, schema, etc. mutators } const zero = new Zero(opts) Mutators need to be registered with Zero because Zero calls them during sync for conflict resolution. If you invoke a mutator that is not registered, Zero will throw an error. Server Setup In order for mutations to sync, you must provide an implementation of the mutate endpoint on your server. zero-cache calls this endpoint to process each mutation. Registering the Endpoint Use ZERO_MUTATE_URL to tell zero-cache where to find your mutate implementation: export ZERO_MUTATE_URL=\"http://localhost:3000/api/zero/mutate\" # run zero-cache, e.g. `npx zero-cache-dev` Implementing the Endpoint You can use the handleMutateRequest and mustGetMutator functions to implement the endpoint. Plug in whatever dbProvider you set up (see server-zql or the install guide). // src/routes/api/zero/mutate.ts import {createFileRoute} from '@tanstack/react-router' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'mutators.ts' import {dbProvider} from 'db-provider.ts' export const Route = createFileRoute('/api/zero/mutate')({ server: { handlers: { POST: async ({request}) => { const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({ args, tx }) }), request, userID: null }) return Response.json(result) } } } })// app/api/zero/mutate/route.ts import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'mutators.ts' import {dbProvider} from 'db-provider.ts' export async function POST(request: Request) { const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({args, tx}) }), request, userID: null }) return Response.json(result) }// src/routes/api/zero/mutate.ts import type {APIEvent} from '@solidjs/start/server' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'mutators.ts' import {dbProvider} from 'db-provider.ts' export async function POST(event: APIEvent) { const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({args, tx}) }), request: event.request, userID: null }) return Response.json(result) }// api/app.ts import {Hono} from 'hono' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'mutators.ts' import {dbProvider} from './db-provider.ts' const app = new Hono() app.post('/api/zero/mutate', async c => { const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({ args, tx }) }), request: c.req.raw, userID: null }) return c.json(result) }) Zero includes several built-in database adapters. You can also easily create your own. See ZQL on the Server for more information. handleMutateRequest accepts a standard Request and returns a JSON object which can be serialized and returned by your server framework of choice. mustGetMutator looks up the mutator in the registry and throws an error if not found. The mutator.fn function is your mutator implementation wrapped in the validator you provided. These examples have only public mutators, so they do not pass a context. In authenticated apps, validate auth in the request, derive context from the session, and pass it to the mutate handler. See Authentication. Handling Errors The handleMutateRequest function skips any mutations that throw: const result = await handleMutateRequest({ dbProvider, handler: transact => transact(async (tx, name, args) => { // The mutation is skipped and the next mutation runs as normal. // The optimistic mutation on the client will be reverted. throw new Error('bonk') }), request: c.req.raw, userID: null }) handleMutateRequest catches such errors and turns them into a structured response that gets sent back to the client. You can recover the errors and show UI if you want. It is also of course possible for the entire push endpoint to return an HTTP error, or to not reply at all: export const Route = createFileRoute('/api/zero/mutate')({ server: { handlers: { POST: async () => { throw new Error('zonk') // will trigger resend } } } })export async function POST() { throw new Error('zonk') // will trigger resend }export async function POST() { throw new Error('zonk') // will trigger resend }app.post('/api/zero/mutate', async c => { // This will cause the client to resend all queued mutations. throw new Error('zonk') }) Responses other than 200, 401, or 403 enter the error state. zero-cache will retry on 5xx up to four times before returning an error. If Zero receives HTTP 401 or 403, the client will enter the needs auth state and require a manual reconnect. Use zero.connection.connect() for cookie auth or zero.connection.connect({auth: newToken}) for token auth, then Zero will retry all queued mutations. If you want a different behavior, it is possible to implement the mutate endpoint yourself and handle errors differently. Custom Mutate URL By default, Zero sends mutations to the URL specified in the ZERO_MUTATE_URL parameter. However you can customize this on a per-client basis. To do so, list multiple comma-separated URLs in the ZERO_MUTATE_URL parameter: export ZERO_MUTATE_URL=\"https://api.example.com/mutate,https://api.staging.example.com/mutate\" Then choose one of those URLs by passing it to mutateURL on the Zero constructor: const opts: ZeroOptions = { // ... mutateURL: 'https://api.staging.example.com/mutate' } URL Patterns The strings listed in ZERO_MUTATE_URL can also be URLPatterns: export ZERO_MUTATE_URL=\"https://mybranch-*.preview.myapp.com/mutate\" For more information, see the URLPattern section of the Queries docs. It works the same way for mutations. If you're configuring per-branch preview URLs (for example on Vercel), see Preview Deployments for the complete setup across both query and mutate endpoints. Server-Specific Code To implement server-specific code, just run different mutators in your mutate endpoint. Server authority to the rescue! defineMutators accepts a baseMutators parameter that makes this easy. The returned mutator registry will contain all the mutators from baseMutators, plus any new ones you define or override: // server-mutators.ts import {defineMutators, defineMutator} from '@rocicorp/zero' import {z} from 'zod' import {zql} from 'schema.ts' import {mutators as sharedMutators} from 'mutators.ts' export const serverMutators = defineMutators( sharedMutators, { posts: { // Overrides the shared mutator definition with same name. update: defineMutator( z.object({ id: z.string(), title: z.string().optional(), priority: z.number().optional() }), async ({ tx, ctx: {userID}, args: {id, title, priority} }) => { // Run the shared mutator first. await sharedMutators.posts.update.fn({ tx, ctx, args }) // Record a history of this operation happening in an audit log table. await tx.mutate.auditLog.insert({ issueId: id, action: 'update-title', timestamp: Date.getTime() }) } ) } } ) For simple things, we also expose a location field on the transaction object that you can use to branch your code: const myMutator = defineMutator(async ({tx}) => { if (tx.location === 'client') { // Client-side code } else { // Server-side code } }) Running Mutators Once you have registered your mutators, you can invoke them with zero.mutate: import {mutators} from 'mutators.ts' zero.mutate( mutators.issue.update({ id: crypto.randomUUID(), title: 'New title' }) ) Client-generated random IDs from crypto.randomUUID(), uuid, ulid, or nanoid work much better with sync engines like Zero. See IDs for more details. Waiting for Results We typically recommend that you \"fire and forget\" mutators. Optimistic mutations make sense when the common case is that a mutation succeeds. If a mutation frequently fails, then showing the user an optimistic result isn't very useful, because it will likely be wrong. That said there are cases where it is nice to know when a write succeeded on either the client or server. One example is if you need to read a row directly after writing it. Zero's local writes are very fast (almost always < 1 frame), but because Zero is backed by IndexedDB, writes are still technically asynchronous and reads directly after a write may not return the new data. You can use the .client promise in this case to wait for a write to complete on the client side: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) // issue-123 not guaranteed to be present here. read1 may be undefined. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await client write – almost always less than 1 frame, and same // macrotask, so no browser paint will occur here. const res = await write.client if (res.type === 'error') { console.error('Mutator failed on client', res.error) } // issue-123 definitely can be read now. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) You can also await .server for the server result: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) const clientRes = await write.client if (clientRes.type === 'error') { throw new Error(`Mutator failed on client`, { cause: clientRes.error }) } // optimistic write guaranteed to be present here, but not // server write. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await the server result/acknowledgment. This requires a round trip. const serverRes = await write.server if (serverRes.type === 'error') { throw new Error(`Mutator failed on server`, { cause: serverRes.error }) } // The server acknowledged the mutation, but its Postgres changes // may not have replicated to this client yet. This read can still // reflect optimistic rather than authoritative state. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) If the client-side mutator fails, .server also resolves to an error result. Awaiting .server therefore covers both client- and server-side failures. There is not yet a way to return data from mutators in the success case. Let us know if you need this. Permissions Because mutators are just normal TypeScript functions that run server-side, there is no need for a special permissions system. You can implement whatever permission checks you want using plain TypeScript code. See Permissions for more information. Dropping Down to Raw SQL The ServerTransaction interface has a dbTransaction property that exposes the underlying database connection. This allows you to run raw SQL queries directly against the database. This is useful for complex queries, or for using Postgres features that Zero doesn't support yet: const markAllAsRead = defineMutator( z.object({ userId: z.string() }), async ({tx, args: {userId}}) => { // shared stuff ... if (tx.location === 'server') { // `tx` is now narrowed to `ServerTransaction`. // Do special server-only stuff with raw SQL. await tx.dbTransaction.query( ` UPDATE notification SET read = true WHERE user_id = $1 `, [userId] ) } } ) See ZQL on the Server for more information. Notifications and Async Work The best way to handle notifications and async work is a transactional outbox. This ensures that notifications actually do eventually get sent, without holding open database transactions to talk over the network. This can be implemented very easily in Zero by writing notifications to an outbox table as part of your mutator, then processing that table periodically with a background job. However sometimes it's still nice to do a quick and dirty async send as part of a mutation, for example early on in development, or to record metrics. For this, the createMutators pattern is useful: // server-mutators.ts import {defineMutator} from '@rocicorp/zero' import z from 'zod' import {zql} from 'schema.ts' import {mutators as clientMutators} from 'mutators.ts' // Instead of defining server mutators as a constant, // define them as a function of a list of async tasks. export function createMutators( asyncTasks: Array<() => Promise> ) { return defineMutators(clientMutators, { issue: { update: defineMutator( z.object({ id: z.string(), title: z.string() }), async (tx, {id, title}) => { await tx.mutate.issue.update({id, title}) asyncTasks.push(() => sendEmailToSubscribers(id)) } ) } }) } Then in your mutate handler: export const Route = createFileRoute('/api/zero/mutate')({ server: { handlers: { POST: async ({request}) => { const asyncTasks: Array<() => Promise> = [] const mutators = createMutators(asyncTasks) const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({ tx, args }) }), request, userID: null }) // Run all async tasks // If any fail, do not block the response, since the // mutation result has already been written to the database. await Promise.allSettled( asyncTasks.map(task => task()) ) return Response.json(result) } } } })export async function POST(request: Request) { const asyncTasks: Array<() => Promise> = [] const mutators = createMutators(asyncTasks) const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({tx, args}) }), request, userID: null }) // Run all async tasks // If any fail, do not block the response, since the // mutation result has already been written to the database. await Promise.allSettled(asyncTasks.map(task => task())) return Response.json(result) }export async function POST(event: APIEvent) { const asyncTasks: Array<() => Promise> = [] const mutators = createMutators(asyncTasks) const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({tx, args}) }), request: event.request, userID: null }) // Run all async tasks // If any fail, do not block the response, since the // mutation result has already been written to the database. await Promise.allSettled(asyncTasks.map(task => task())) return Response.json(result) }app.post('/api/zero/mutate', async c => { const asyncTasks: Array<() => Promise> = [] const mutators = createMutators(asyncTasks) const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({ tx, args }) }), request: c.req.raw, userID: null }) // Run all async tasks // If any fail, do not block the response, since the // mutation result has already been written to the database. await Promise.allSettled(asyncTasks.map(task => task())) return c.json(result) }) Custom Mutate Implementation You can manually implement the mutate endpoint in any programming language. This will be documented in the future, but you can refer to the handleMutateRequest source code for an example for now.",
+ "content": "Mutators are how you write data with Zero. Here's a simple example: // src/mutators.ts import {defineMutators, defineMutator} from '@rocicorp/zero' import {z} from 'zod' export const mutators = defineMutators({ updateIssue: defineMutator( z.object({ id: z.string(), title: z.string() }), async ({tx, args: {id, title}}) => { if (title.length > 100) { throw new Error(`Title is too long`) } await tx.mutate.issue.update({ id, title }) } ) }) Architecture A copy of each mutator exists on both the client and on your server: Often the implementations will be the same, and you can just share their code. This is easy with full-stack frameworks like TanStack Start or Next.js. But the implementations don't have to be the same, or even compute the same result. For example, the server can add extra checks to enforce permissions, or send notifications or interact with other systems. Life of a Mutation When a mutator is invoked, it initially runs on the client, against the client-side datastore. Any changes are immediately applied to open queries and the user sees the changes. In the background, Zero sends a mutation (a record of the mutator having run with certain arguments) to your server's push endpoint. Your push endpoint runs the push protocol, executing the server-side mutator in a transaction against your database and recording the fact that the mutation ran. The @rocicorp/zero package contains utilities to make it easy to implement this endpoint in TypeScript. The changes to the database are then replicated to zero-cache using logical replication. zero-cache calculates the updates to active queries and sends rows that have changed to each client. It also sends information about the mutations that have been applied to the database. Clients receive row updates and apply them to their local cache. Any pending mutations which have been applied to the server have their local effects rolled back. Client-side queries are updated and the user sees the changes. Defining Mutators Basics Create a mutator using defineMutator. The only required argument is a MutatorFn, which must be async: import {defineMutator} from '@rocicorp/zero' const myMutator = defineMutator(async () => { // ... }) Mutators almost always complete in the same frame on the client, within milliseconds. The reason they are marked async is because on the server, reading from the tx object goes over the network to Postgres. Writing Data The MutatorFn receives a tx parameter which can be used to write data with a CRUD-style API. Each table in your Zero schema has a corresponding field on tx.mutate: const myMutator = defineMutator(async ({tx}) => { // This is here because there's a `user` table in your schema. await tx.mutate.user.insert(...) }) Mutators almost always run in the same frame on the client, against local data. The reason mutators are marked async is because on the server, reading from the tx object goes over the network to Postgres. Also, in edge cases on the client, reads and writes can go to local storage (IndexedDB or SQLite). Insert Create new records with insert: tx.mutate.user.insert({ id: 'user-123', username: 'sam', language: 'js' }) If the Zero primary key already exists, insert will succeed without changing the row - use upsert to update an existing row. Optional fields can be set to null to explicitly set the new field to null. They can also be set to undefined to take the default value (which is often null but can also be some generated value server-side): // Sets language to `null` specifically tx.mutate.user.insert({ id: 'user-123', username: 'sam', language: null }) // Sets language to the default server-side value. // Could be null, or some generated or constant default value too. tx.mutate.user.insert({ id: 'user-123', username: 'sam' }) // Same as above tx.mutate.user.insert({ id: 'user-123', username: 'sam', language: undefined }) Upsert Create new records or update existing ones with upsert: tx.mutate.user.upsert({ id: samID, username: 'sam', language: 'ts' }) upsert supports the same null / undefined semantics for optional fields that insert does (see above). Update Update an existing record. Does nothing if the specified record (by PK) does not exist. You can pass a partial object, leaving fields out that you don’t want to change. For example here we leave the username the same: // Leaves username field to previous value. tx.mutate.user.update({ id: samID, language: 'golang' }) // Same as above tx.mutate.user.update({ id: samID, username: undefined, language: 'haskell' }) // Reset language field to `null` tx.mutate.user.update({ id: samID, language: null }) Delete Delete an existing record. Does nothing if specified record does not exist. tx.mutate.user.delete({ id: samID }) Arguments The MutatorFn can take a single args parameter. To enable this, pass a validator to defineMutator: import {defineMutator} from '@rocicorp/zero' const initStats = defineMutator( z.object({issueCount: z.number()}), async ({tx, args: {issueCount}}) => { if (issueCount < 0) { throw new Error(`issueCount cannot be negative`) } await tx.mutate.stats.insert({ id: 'global', issueCount }) } ) We use Zod in these examples, but you can use any validation library that implements Standard Schema. It's most common for mutators to be a pure function of the database state plus arguments. But it's not required. Impure mutators can be useful, e.g., to consult some external system on the server for authorization or validation. Reading Data You can read data within a mutator by passing ZQL to tx.run: const updateIssue = defineMutator( z.object({id: z.string(), title: z.string()}), async ({tx, args: {id, title}}) => { const issue = await tx.run( zql.issue.where('id', id).one() ) if (issue?.status === 'closed') { throw new Error(`Cannot update closed issue`) } await tx.mutate.issue.update({ id, title }) } ) You have the full power of ZQL at your disposal, including relationships, filters, ordering, and limits. Reads and writes within a mutator are transactional, meaning that the datastore is guaranteed to not change while your mutator is running. And if the mutator throws, the entire mutation is rolled back. Unlike zero.run(), there is no type parameter that can be used to wait for server results inside mutators. This is because waiting for server results in mutators makes no sense – it would defeat the purpose of running optimistically to begin with. When a mutator runs on the client (tx.location === \"client\"), ZQL reads only return data already cached on the client. When mutators run on the server (tx.location === \"server\"), ZQL reads always return all data. Context Mutator parameters are supplied by the client application and passed to the server automatically by Zero. This makes them unsuitable for credentials, since the user could modify them. For this reason, Zero mutators also support the concept of a context object. Access your context with the ctx parameter to your mutator: const createIssue = defineMutator( z.object({id: z.string(), title: z.string()}), async ({tx, ctx: {userID}, args: {id, title}}) => { // Note: User cannot control ctx.userID, so this // enforces authorship of created issue. await tx.mutate.issue.insert({ id, title, authorID: userID }) } ) If you don't want to register your Context and Schema types globally, you can use defineMutatorWithType and defineMutatorsWithType: import { defineMutatorWithType, defineMutatorsWithType } from '@rocicorp/zero' import type {ZeroContext} from 'context.ts' import type {Schema} from 'schema.ts' import type {DrizzleTransaction} from '@rocicorp/zero/server/adapters/drizzle' import type {drizzleClient} from 'db-provider.ts' const defineMutator = defineMutatorWithType< Schema, ZeroContext, DrizzleTransaction >() const defineMutators = defineMutatorsWithType()import { defineMutatorWithType, defineMutatorsWithType } from '@rocicorp/zero' import type {ZeroContext} from 'context.ts' import type {Schema} from 'schema.ts' import type {KyselyTransaction} from '@rocicorp/zero/server/adapters/kysely' import type {Database} from 'db-provider.ts' const defineMutator = defineMutatorWithType< Schema, ZeroContext, KyselyTransaction >() const defineMutators = defineMutatorsWithType()import { defineMutatorWithType, defineMutatorsWithType } from '@rocicorp/zero' import type {ZeroContext} from 'context.ts' import type {Schema} from 'schema.ts' import type {PrismaTransaction} from '@rocicorp/zero/server/adapters/prisma' import type {PrismaClient} from '@prisma/client' const defineMutator = defineMutatorWithType< Schema, ZeroContext, PrismaTransaction >() const defineMutators = defineMutatorsWithType()import { defineMutatorWithType, defineMutatorsWithType } from '@rocicorp/zero' import type {ZeroContext} from 'context.ts' import type {Schema} from 'schema.ts' import type {NodePgTransaction} from '@rocicorp/zero/server/adapters/pg' const defineMutator = defineMutatorWithType< Schema, ZeroContext, NodePgTransaction >() const defineMutators = defineMutatorsWithType()import { defineMutatorWithType, defineMutatorsWithType } from '@rocicorp/zero' import type {ZeroContext} from 'context.ts' import type {Schema} from 'schema.ts' import type {PostgresJsTransaction} from '@rocicorp/zero/server/adapters/postgresjs' const defineMutator = defineMutatorWithType< Schema, ZeroContext, PostgresJsTransaction >() const defineMutators = defineMutatorsWithType() Mutator Registries The result of defineMutator is a MutatorDefinition. By itself this isn't super useful. You need to register it using defineMutators: export const mutators = defineMutators({ issue: { update: updateIssue } }) Typically these are done together in one step: export const mutators = defineMutators({ issue: { update: defineMutator( z.object({id: z.string(), title: z.string()}), async ({tx, args: {id, title}}) => { await tx.mutate.issue.update({ id, title }) } ) } }) The result of defineMutators is called a MutatorRegistry. Each field in the registry is a callable Mutator that you can use to perform mutations: import {mutators} from 'mutators.ts' zero.mutate( mutators.issue.update({ id: 'issue-123', title: 'New title' }) ) Mutator Names Each Mutator has a mutatorName which is computed by defineMutators. When you run a mutator, Zero sends this name along with the arguments to your server to execute the server-side mutation. console.log(mutators.issue.update.mutatorName) // \"issue.update\" mutators.ts By convention, mutators are listed in a central mutators.ts file. This allows them to be easily used on both the client and server: import {defineMutators, defineMutator} from '@rocicorp/zero' import {zql} from './schema.ts' import {z} from 'zod' export const mutators = defineMutators({ posts: { create: defineMutator( z.object({ id: z.string(), title: z.string() }), async ({ tx, context: {userID}, args: {id, title} }) => { await tx.mutate.post.insert({ id, title, authorID: userID }) } ), update: defineMutator( z.object({ id: z.string(), title: z.string().optional() }), async ({ tx, context: {userID}, args: {id, title} }) => { const prev = await tx.run( zql.post.where('id', id).one() ) if (prev?.authorID !== userID) { throw new Error(`Access denied`) } await tx.mutate.post.update({ id, title, authorID: userID }) } ) } }) You can use as many levels of nesting as you want to organize your mutators. As your application grows, you can move mutators to different files to keep them organized: // posts.ts export const postMutators = { create: defineMutator( z.object({ id: z.string(), title: z.string(), }), async ({tx, context: {userID}, args: {id, title}}) => { await tx.mutate.post.insert({ id, title, authorID: userID, }) }, ), } // user.ts export const userMutators = { updateRole: defineMutator( z.object({ role: z.string(), }), async ({tx, ctx: {userID}, args: {role}}) => { await tx.mutate.user.update({ id: userID, role, }) }, ), } // mutators.ts import {postMutators} from 'zero/mutators/posts.ts' import {userMutators} from 'zero/mutators/users.ts' export const mutators = defineMutators{{ posts: postMutators, users: userMutators, }) defineMutators establishes the full name for each mutator (i.e., posts.create, users.updateRole), which is later sent to the server. So this should only be used once at the top level of your mutators.ts file. Registration Before you can use your mutators, you need to register them with Zero: import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from 'zero/mutators.ts' const opts: ZeroOptions = { // ... cacheURL, schema, etc. mutators } return ( )import {ZeroProvider} from '@rocicorp/zero/solid' import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from 'zero/mutators.ts' const opts: ZeroOptions = { // ... cacheURL, schema, etc. mutators } return ( )import {Zero} from '@rocicorp/zero' import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from 'zero/mutators.ts' const opts: ZeroOptions = { // ... cacheURL, schema, etc. mutators } const zero = new Zero(opts) Mutators need to be registered with Zero because Zero calls them during sync for conflict resolution. If you invoke a mutator that is not registered, Zero will throw an error. Server Setup In order for mutations to sync, you must provide an implementation of the mutate endpoint on your server. zero-cache calls this endpoint to process each mutation. Registering the Endpoint Use ZERO_MUTATE_URL to tell zero-cache where to find your mutate implementation: export ZERO_MUTATE_URL=\"http://localhost:3000/api/zero/mutate\" # run zero-cache, e.g. `npx zero-cache-dev` Implementing the Endpoint You can use the handleMutateRequest and mustGetMutator functions to implement the endpoint. Plug in whatever dbProvider you set up (see server-zql or the install guide). // src/routes/api/zero/mutate.ts import {createFileRoute} from '@tanstack/react-router' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'mutators.ts' import {dbProvider} from 'db-provider.ts' export const Route = createFileRoute('/api/zero/mutate')({ server: { handlers: { POST: async ({request}) => { const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({ args, tx }) }), request, userID: null }) return Response.json(result) } } } })// app/api/zero/mutate/route.ts import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'mutators.ts' import {dbProvider} from 'db-provider.ts' export async function POST(request: Request) { const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({args, tx}) }), request, userID: null }) return Response.json(result) }// src/routes/api/zero/mutate.ts import type {APIEvent} from '@solidjs/start/server' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'mutators.ts' import {dbProvider} from 'db-provider.ts' export async function POST(event: APIEvent) { const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({args, tx}) }), request: event.request, userID: null }) return Response.json(result) }// api/app.ts import {Hono} from 'hono' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'mutators.ts' import {dbProvider} from './db-provider.ts' const app = new Hono() app.post('/api/zero/mutate', async c => { const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({ args, tx }) }), request: c.req.raw, userID: null }) return c.json(result) }) Zero includes several built-in database adapters. You can also easily create your own. See ZQL on the Server for more information. handleMutateRequest accepts a standard Request and returns a JSON object which can be serialized and returned by your server framework of choice. mustGetMutator looks up the mutator in the registry and throws an error if not found. The mutator.fn function is your mutator implementation wrapped in the validator you provided. These examples have only public mutators, so they do not pass a context. In authenticated apps, validate auth in the request, derive context from the session, and pass it to the mutate handler. See Authentication. Handling Errors The handleMutateRequest function skips any mutations that throw: const result = await handleMutateRequest({ dbProvider, handler: transact => transact(async (tx, name, args) => { // The mutation is skipped and the next mutation runs as normal. // The optimistic mutation on the client will be reverted. throw new Error('bonk') }), request: c.req.raw, userID: null }) handleMutateRequest catches such errors and turns them into a structured response that gets sent back to the client. You can recover the errors and show UI if you want. It is also of course possible for the entire push endpoint to return an HTTP error, or to not reply at all: export const Route = createFileRoute('/api/zero/mutate')({ server: { handlers: { POST: async () => { throw new Error('zonk') // will trigger resend } } } })export async function POST() { throw new Error('zonk') // will trigger resend }export async function POST() { throw new Error('zonk') // will trigger resend }app.post('/api/zero/mutate', async c => { // This will cause the client to resend all queued mutations. throw new Error('zonk') }) Responses other than 200, 401, or 403 enter the error state. zero-cache will retry on 5xx up to four times before returning an error. If Zero receives HTTP 401 or 403, the client will enter the needs auth state and require a manual reconnect. Use zero.connection.connect() for cookie auth or zero.connection.connect({auth: newToken}) for token auth, then Zero will retry all queued mutations. If you want a different behavior, it is possible to implement the mutate endpoint yourself and handle errors differently. Retrying Mutators Some errors go away on a second try. At the repeatable read and serializable isolation levels, for example, Postgres can abort a transaction that conflicts with a concurrent one. It fails with a serialization failure, code 40001. To have Zero run a server mutator again in a fresh transaction when it fails with an error like that, call tx.retryOn(): function isSerializationFailure(error: unknown) { return ( error instanceof Error && 'code' in error && error.code === '40001' ) } const updateIssue = defineMutator( z.object({id: z.string(), title: z.string()}), async ({tx, args: {id, title}}) => { if (tx.location === 'server') { tx.retryOn(isSerializationFailure) } await tx.mutate.issue.update({id, title}) } ) retryOn() takes a predicate. It gets the error and the attempt number: 1 when deciding the first re-run, 2 for the second, and so on. Return true to run the mutator again. If it returns anything else or throws, Zero records the error and skips the mutation, as with any other error. The predicate sees errors the mutator throws, and also errors Postgres raises at COMMIT, which the mutator can't catch. Everything the failed run wrote is rolled back, and the re-run reads fresh data. The error is passed as it was thrown. node-postgres and postgres.js errors have the Postgres error code in code. Other libraries can wrap the error, so check what yours throws. The predicate can also return a promise. Zero waits for it, so you can use it to back off before the next run: tx.retryOn(async (error, attempt) => { if (!isSerializationFailure(error)) { return false } // Wait 50ms, then 100ms, then 150ms, and so on. await new Promise(resolve => setTimeout(resolve, attempt * 50) ) return true }) retryOn() only applies to the current run. The re-run starts the mutator from the top and calls retryOn() again, so call it before anything that can fail. You can call it more than once, and Zero runs the mutator again if any predicate returns true. Zero runs a mutator at most 5 times per mutation in each request to your mutate endpoint. If the last run also fails, Zero records the error. The predicate runs after the transaction has ended, so it can't use tx: reading or writing through tx there throws. Only retry mutators that are safe to run more than once. Work outside the database, such as sending an email, isn't rolled back. retryOn() only exists on the server, when tx.location === 'server'. The built-in adapters and ZQLDatabase support retryOn(). If you pass your own Database to handleMutateRequest, it must pass the addRetryPredicate it receives in TransactionProviderInput on to the transaction it creates with CRUDMutatorFactory.createTransaction(). Otherwise tx.retryOn() throws. Custom Mutate URL By default, Zero sends mutations to the URL specified in the ZERO_MUTATE_URL parameter. However you can customize this on a per-client basis. To do so, list multiple comma-separated URLs in the ZERO_MUTATE_URL parameter: export ZERO_MUTATE_URL=\"https://api.example.com/mutate,https://api.staging.example.com/mutate\" Then choose one of those URLs by passing it to mutateURL on the Zero constructor: const opts: ZeroOptions = { // ... mutateURL: 'https://api.staging.example.com/mutate' } URL Patterns The strings listed in ZERO_MUTATE_URL can also be URLPatterns: export ZERO_MUTATE_URL=\"https://mybranch-*.preview.myapp.com/mutate\" For more information, see the URLPattern section of the Queries docs. It works the same way for mutations. If you're configuring per-branch preview URLs (for example on Vercel), see Preview Deployments for the complete setup across both query and mutate endpoints. Server-Specific Code To implement server-specific code, just run different mutators in your mutate endpoint. Server authority to the rescue! defineMutators accepts a baseMutators parameter that makes this easy. The returned mutator registry will contain all the mutators from baseMutators, plus any new ones you define or override: // server-mutators.ts import {defineMutators, defineMutator} from '@rocicorp/zero' import {z} from 'zod' import {zql} from 'schema.ts' import {mutators as sharedMutators} from 'mutators.ts' export const serverMutators = defineMutators( sharedMutators, { posts: { // Overrides the shared mutator definition with same name. update: defineMutator( z.object({ id: z.string(), title: z.string().optional(), priority: z.number().optional() }), async ({ tx, ctx: {userID}, args: {id, title, priority} }) => { // Run the shared mutator first. await sharedMutators.posts.update.fn({ tx, ctx, args }) // Record a history of this operation happening in an audit log table. await tx.mutate.auditLog.insert({ issueId: id, action: 'update-title', timestamp: Date.getTime() }) } ) } } ) For simple things, we also expose a location field on the transaction object that you can use to branch your code: const myMutator = defineMutator(async ({tx}) => { if (tx.location === 'client') { // Client-side code } else { // Server-side code } }) Running Mutators Once you have registered your mutators, you can invoke them with zero.mutate: import {mutators} from 'mutators.ts' zero.mutate( mutators.issue.update({ id: crypto.randomUUID(), title: 'New title' }) ) Client-generated random IDs from crypto.randomUUID(), uuid, ulid, or nanoid work much better with sync engines like Zero. See IDs for more details. Waiting for Results We typically recommend that you \"fire and forget\" mutators. Optimistic mutations make sense when the common case is that a mutation succeeds. If a mutation frequently fails, then showing the user an optimistic result isn't very useful, because it will likely be wrong. That said there are cases where it is nice to know when a write succeeded on either the client or server. One example is if you need to read a row directly after writing it. Zero's local writes are very fast (almost always < 1 frame), but because Zero is backed by IndexedDB, writes are still technically asynchronous and reads directly after a write may not return the new data. You can use the .client promise in this case to wait for a write to complete on the client side: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) // issue-123 not guaranteed to be present here. read1 may be undefined. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await client write – almost always less than 1 frame, and same // macrotask, so no browser paint will occur here. const res = await write.client if (res.type === 'error') { console.error('Mutator failed on client', res.error) } // issue-123 definitely can be read now. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) You can also await .server for the server result: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) const clientRes = await write.client if (clientRes.type === 'error') { throw new Error(`Mutator failed on client`, { cause: clientRes.error }) } // optimistic write guaranteed to be present here, but not // server write. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await the server result/acknowledgment. This requires a round trip. const serverRes = await write.server if (serverRes.type === 'error') { throw new Error(`Mutator failed on server`, { cause: serverRes.error }) } // The server acknowledged the mutation, but its Postgres changes // may not have replicated to this client yet. This read can still // reflect optimistic rather than authoritative state. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) If the client-side mutator fails, .server also resolves to an error result. Awaiting .server therefore covers both client- and server-side failures. Returning Data A mutator can return a JSON value. The caller gets it in the data field of the success result. The client and server runs can return different values. For example, the server can return a value that only the database knows: const createIssue = defineMutator( z.object({id: z.string(), title: z.string()}), async ({tx, args: {id, title}}) => { await tx.mutate.issue.insert({id, title}) if (tx.location === 'client') { // The client doesn't know the short ID yet. return {shortID: null} } // Postgres assigns shortID when the row is inserted. const issue = await tx.run( zql.issue.where('id', id).one() ) return {shortID: issue?.shortID ?? null} } ) The .client result's data is what the client run returned, and the .server result's data is what the server run returned: const write = zero.mutate( mutators.issue.create({ id: crypto.randomUUID(), title: 'New title' }) ) const serverRes = await write.server if (serverRes.type === 'success') { // `data` is a plain JSON value, so check its shape. const {shortID} = z .object({shortID: z.number().nullable()}) .parse(serverRes.data) console.log('Short ID:', shortID) } data is typed as ReadonlyJSONValue | undefined rather than as the mutator's return type, so check its shape before you use it. A mutator that returns nothing gives undefined. The server's value only reaches the client if your mutate handler returns the mutator's result, as return mutator.fn({args, tx}) does in the examples above. Zero also saves that value in your database with the mutation's result, so the client still gets it if the response from your mutate endpoint is lost. Nothing is saved for a mutator that returns nothing. Permissions Because mutators are just normal TypeScript functions that run server-side, there is no need for a special permissions system. You can implement whatever permission checks you want using plain TypeScript code. See Permissions for more information. Dropping Down to Raw SQL The ServerTransaction interface has a dbTransaction property that exposes the underlying database connection. This allows you to run raw SQL queries directly against the database. This is useful for complex queries, or for using Postgres features that Zero doesn't support yet: const markAllAsRead = defineMutator( z.object({ userId: z.string() }), async ({tx, args: {userId}}) => { // shared stuff ... if (tx.location === 'server') { // `tx` is now narrowed to `ServerTransaction`. // Do special server-only stuff with raw SQL. await tx.dbTransaction.query( ` UPDATE notification SET read = true WHERE user_id = $1 `, [userId] ) } } ) See ZQL on the Server for more information. Notifications and Async Work The best way to handle notifications and async work is a transactional outbox. This ensures that notifications actually do eventually get sent, without holding open database transactions to talk over the network. This can be implemented very easily in Zero by writing notifications to an outbox table as part of your mutator, then processing that table periodically with a background job. However sometimes it's still nice to do a quick and dirty async send as part of a mutation, for example early on in development, or to record metrics. For this, the createMutators pattern is useful: // server-mutators.ts import {defineMutator} from '@rocicorp/zero' import z from 'zod' import {zql} from 'schema.ts' import {mutators as clientMutators} from 'mutators.ts' // Instead of defining server mutators as a constant, // define them as a function of a list of async tasks. export function createMutators( asyncTasks: Array<() => Promise> ) { return defineMutators(clientMutators, { issue: { update: defineMutator( z.object({ id: z.string(), title: z.string() }), async (tx, {id, title}) => { await tx.mutate.issue.update({id, title}) asyncTasks.push(() => sendEmailToSubscribers(id)) } ) } }) } Then in your mutate handler: export const Route = createFileRoute('/api/zero/mutate')({ server: { handlers: { POST: async ({request}) => { const asyncTasks: Array<() => Promise> = [] const mutators = createMutators(asyncTasks) const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({ tx, args }) }), request, userID: null }) // Run all async tasks // If any fail, do not block the response, since the // mutation result has already been written to the database. await Promise.allSettled( asyncTasks.map(task => task()) ) return Response.json(result) } } } })export async function POST(request: Request) { const asyncTasks: Array<() => Promise> = [] const mutators = createMutators(asyncTasks) const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({tx, args}) }), request, userID: null }) // Run all async tasks // If any fail, do not block the response, since the // mutation result has already been written to the database. await Promise.allSettled(asyncTasks.map(task => task())) return Response.json(result) }export async function POST(event: APIEvent) { const asyncTasks: Array<() => Promise> = [] const mutators = createMutators(asyncTasks) const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({tx, args}) }), request: event.request, userID: null }) // Run all async tasks // If any fail, do not block the response, since the // mutation result has already been written to the database. await Promise.allSettled(asyncTasks.map(task => task())) return Response.json(result) }app.post('/api/zero/mutate', async c => { const asyncTasks: Array<() => Promise> = [] const mutators = createMutators(asyncTasks) const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({ tx, args }) }), request: c.req.raw, userID: null }) // Run all async tasks // If any fail, do not block the response, since the // mutation result has already been written to the database. await Promise.allSettled(asyncTasks.map(task => task())) return c.json(result) }) Custom Mutate Implementation You can manually implement the mutate endpoint in any programming language. This will be documented in the future, but you can refer to the handleMutateRequest source code for an example for now.",
"headings": [
{
"text": "Architecture",
@@ -1960,6 +2002,10 @@
"text": "Handling Errors",
"id": "handling-errors"
},
+ {
+ "text": "Retrying Mutators",
+ "id": "retrying-mutators"
+ },
{
"text": "Custom Mutate URL",
"id": "custom-mutate-url"
@@ -1980,6 +2026,10 @@
"text": "Waiting for Results",
"id": "waiting-for-results"
},
+ {
+ "text": "Returning Data",
+ "id": "returning-data"
+ },
{
"text": "Permissions",
"id": "permissions"
@@ -2000,7 +2050,7 @@
"kind": "page"
},
{
- "id": "200-mutators#architecture",
+ "id": "204-mutators#architecture",
"title": "Mutators",
"searchTitle": "Architecture",
"sectionTitle": "Architecture",
@@ -2010,7 +2060,7 @@
"kind": "section"
},
{
- "id": "201-mutators#life-of-a-mutation",
+ "id": "205-mutators#life-of-a-mutation",
"title": "Mutators",
"searchTitle": "Life of a Mutation",
"sectionTitle": "Life of a Mutation",
@@ -2020,7 +2070,7 @@
"kind": "section"
},
{
- "id": "202-mutators#defining-mutators",
+ "id": "206-mutators#defining-mutators",
"title": "Mutators",
"searchTitle": "Defining Mutators",
"sectionTitle": "Defining Mutators",
@@ -2030,7 +2080,7 @@
"kind": "section"
},
{
- "id": "203-mutators#basics",
+ "id": "207-mutators#basics",
"title": "Mutators",
"searchTitle": "Basics",
"sectionTitle": "Basics",
@@ -2040,7 +2090,7 @@
"kind": "section"
},
{
- "id": "204-mutators#writing-data",
+ "id": "208-mutators#writing-data",
"title": "Mutators",
"searchTitle": "Writing Data",
"sectionTitle": "Writing Data",
@@ -2050,7 +2100,7 @@
"kind": "section"
},
{
- "id": "205-mutators#insert",
+ "id": "209-mutators#insert",
"title": "Mutators",
"searchTitle": "Insert",
"sectionTitle": "Insert",
@@ -2060,7 +2110,7 @@
"kind": "section"
},
{
- "id": "206-mutators#upsert",
+ "id": "210-mutators#upsert",
"title": "Mutators",
"searchTitle": "Upsert",
"sectionTitle": "Upsert",
@@ -2070,7 +2120,7 @@
"kind": "section"
},
{
- "id": "207-mutators#update",
+ "id": "211-mutators#update",
"title": "Mutators",
"searchTitle": "Update",
"sectionTitle": "Update",
@@ -2080,7 +2130,7 @@
"kind": "section"
},
{
- "id": "208-mutators#delete",
+ "id": "212-mutators#delete",
"title": "Mutators",
"searchTitle": "Delete",
"sectionTitle": "Delete",
@@ -2090,7 +2140,7 @@
"kind": "section"
},
{
- "id": "209-mutators#arguments",
+ "id": "213-mutators#arguments",
"title": "Mutators",
"searchTitle": "Arguments",
"sectionTitle": "Arguments",
@@ -2100,7 +2150,7 @@
"kind": "section"
},
{
- "id": "210-mutators#reading-data",
+ "id": "214-mutators#reading-data",
"title": "Mutators",
"searchTitle": "Reading Data",
"sectionTitle": "Reading Data",
@@ -2110,7 +2160,7 @@
"kind": "section"
},
{
- "id": "211-mutators#context",
+ "id": "215-mutators#context",
"title": "Mutators",
"searchTitle": "Context",
"sectionTitle": "Context",
@@ -2120,7 +2170,7 @@
"kind": "section"
},
{
- "id": "212-mutators#mutator-registries",
+ "id": "216-mutators#mutator-registries",
"title": "Mutators",
"searchTitle": "Mutator Registries",
"sectionTitle": "Mutator Registries",
@@ -2130,7 +2180,7 @@
"kind": "section"
},
{
- "id": "213-mutators#mutator-names",
+ "id": "217-mutators#mutator-names",
"title": "Mutators",
"searchTitle": "Mutator Names",
"sectionTitle": "Mutator Names",
@@ -2140,7 +2190,7 @@
"kind": "section"
},
{
- "id": "214-mutators#mutatorsts",
+ "id": "218-mutators#mutatorsts",
"title": "Mutators",
"searchTitle": "mutators.ts",
"sectionTitle": "mutators.ts",
@@ -2150,7 +2200,7 @@
"kind": "section"
},
{
- "id": "215-mutators#registration",
+ "id": "219-mutators#registration",
"title": "Mutators",
"searchTitle": "Registration",
"sectionTitle": "Registration",
@@ -2160,17 +2210,17 @@
"kind": "section"
},
{
- "id": "216-mutators#server-setup",
+ "id": "220-mutators#server-setup",
"title": "Mutators",
"searchTitle": "Server Setup",
"sectionTitle": "Server Setup",
"sectionId": "server-setup",
"url": "/docs/mutators",
- "content": "In order for mutations to sync, you must provide an implementation of the mutate endpoint on your server. zero-cache calls this endpoint to process each mutation. Registering the Endpoint Use ZERO_MUTATE_URL to tell zero-cache where to find your mutate implementation: export ZERO_MUTATE_URL=\"http://localhost:3000/api/zero/mutate\" # run zero-cache, e.g. `npx zero-cache-dev` Implementing the Endpoint You can use the handleMutateRequest and mustGetMutator functions to implement the endpoint. Plug in whatever dbProvider you set up (see server-zql or the install guide). // src/routes/api/zero/mutate.ts import {createFileRoute} from '@tanstack/react-router' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'mutators.ts' import {dbProvider} from 'db-provider.ts' export const Route = createFileRoute('/api/zero/mutate')({ server: { handlers: { POST: async ({request}) => { const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({ args, tx }) }), request, userID: null }) return Response.json(result) } } } })// app/api/zero/mutate/route.ts import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'mutators.ts' import {dbProvider} from 'db-provider.ts' export async function POST(request: Request) { const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({args, tx}) }), request, userID: null }) return Response.json(result) }// src/routes/api/zero/mutate.ts import type {APIEvent} from '@solidjs/start/server' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'mutators.ts' import {dbProvider} from 'db-provider.ts' export async function POST(event: APIEvent) { const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({args, tx}) }), request: event.request, userID: null }) return Response.json(result) }// api/app.ts import {Hono} from 'hono' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'mutators.ts' import {dbProvider} from './db-provider.ts' const app = new Hono() app.post('/api/zero/mutate', async c => { const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({ args, tx }) }), request: c.req.raw, userID: null }) return c.json(result) }) Zero includes several built-in database adapters. You can also easily create your own. See ZQL on the Server for more information. handleMutateRequest accepts a standard Request and returns a JSON object which can be serialized and returned by your server framework of choice. mustGetMutator looks up the mutator in the registry and throws an error if not found. The mutator.fn function is your mutator implementation wrapped in the validator you provided. These examples have only public mutators, so they do not pass a context. In authenticated apps, validate auth in the request, derive context from the session, and pass it to the mutate handler. See Authentication. Handling Errors The handleMutateRequest function skips any mutations that throw: const result = await handleMutateRequest({ dbProvider, handler: transact => transact(async (tx, name, args) => { // The mutation is skipped and the next mutation runs as normal. // The optimistic mutation on the client will be reverted. throw new Error('bonk') }), request: c.req.raw, userID: null }) handleMutateRequest catches such errors and turns them into a structured response that gets sent back to the client. You can recover the errors and show UI if you want. It is also of course possible for the entire push endpoint to return an HTTP error, or to not reply at all: export const Route = createFileRoute('/api/zero/mutate')({ server: { handlers: { POST: async () => { throw new Error('zonk') // will trigger resend } } } })export async function POST() { throw new Error('zonk') // will trigger resend }export async function POST() { throw new Error('zonk') // will trigger resend }app.post('/api/zero/mutate', async c => { // This will cause the client to resend all queued mutations. throw new Error('zonk') }) Responses other than 200, 401, or 403 enter the error state. zero-cache will retry on 5xx up to four times before returning an error. If Zero receives HTTP 401 or 403, the client will enter the needs auth state and require a manual reconnect. Use zero.connection.connect() for cookie auth or zero.connection.connect({auth: newToken}) for token auth, then Zero will retry all queued mutations. If you want a different behavior, it is possible to implement the mutate endpoint yourself and handle errors differently. Custom Mutate URL By default, Zero sends mutations to the URL specified in the ZERO_MUTATE_URL parameter. However you can customize this on a per-client basis. To do so, list multiple comma-separated URLs in the ZERO_MUTATE_URL parameter: export ZERO_MUTATE_URL=\"https://api.example.com/mutate,https://api.staging.example.com/mutate\" Then choose one of those URLs by passing it to mutateURL on the Zero constructor: const opts: ZeroOptions = { // ... mutateURL: 'https://api.staging.example.com/mutate' } URL Patterns The strings listed in ZERO_MUTATE_URL can also be URLPatterns: export ZERO_MUTATE_URL=\"https://mybranch-*.preview.myapp.com/mutate\" For more information, see the URLPattern section of the Queries docs. It works the same way for mutations. If you're configuring per-branch preview URLs (for example on Vercel), see Preview Deployments for the complete setup across both query and mutate endpoints. Server-Specific Code To implement server-specific code, just run different mutators in your mutate endpoint. Server authority to the rescue! defineMutators accepts a baseMutators parameter that makes this easy. The returned mutator registry will contain all the mutators from baseMutators, plus any new ones you define or override: // server-mutators.ts import {defineMutators, defineMutator} from '@rocicorp/zero' import {z} from 'zod' import {zql} from 'schema.ts' import {mutators as sharedMutators} from 'mutators.ts' export const serverMutators = defineMutators( sharedMutators, { posts: { // Overrides the shared mutator definition with same name. update: defineMutator( z.object({ id: z.string(), title: z.string().optional(), priority: z.number().optional() }), async ({ tx, ctx: {userID}, args: {id, title, priority} }) => { // Run the shared mutator first. await sharedMutators.posts.update.fn({ tx, ctx, args }) // Record a history of this operation happening in an audit log table. await tx.mutate.auditLog.insert({ issueId: id, action: 'update-title', timestamp: Date.getTime() }) } ) } } ) For simple things, we also expose a location field on the transaction object that you can use to branch your code: const myMutator = defineMutator(async ({tx}) => { if (tx.location === 'client') { // Client-side code } else { // Server-side code } })",
+ "content": "In order for mutations to sync, you must provide an implementation of the mutate endpoint on your server. zero-cache calls this endpoint to process each mutation. Registering the Endpoint Use ZERO_MUTATE_URL to tell zero-cache where to find your mutate implementation: export ZERO_MUTATE_URL=\"http://localhost:3000/api/zero/mutate\" # run zero-cache, e.g. `npx zero-cache-dev` Implementing the Endpoint You can use the handleMutateRequest and mustGetMutator functions to implement the endpoint. Plug in whatever dbProvider you set up (see server-zql or the install guide). // src/routes/api/zero/mutate.ts import {createFileRoute} from '@tanstack/react-router' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'mutators.ts' import {dbProvider} from 'db-provider.ts' export const Route = createFileRoute('/api/zero/mutate')({ server: { handlers: { POST: async ({request}) => { const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({ args, tx }) }), request, userID: null }) return Response.json(result) } } } })// app/api/zero/mutate/route.ts import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'mutators.ts' import {dbProvider} from 'db-provider.ts' export async function POST(request: Request) { const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({args, tx}) }), request, userID: null }) return Response.json(result) }// src/routes/api/zero/mutate.ts import type {APIEvent} from '@solidjs/start/server' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'mutators.ts' import {dbProvider} from 'db-provider.ts' export async function POST(event: APIEvent) { const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({args, tx}) }), request: event.request, userID: null }) return Response.json(result) }// api/app.ts import {Hono} from 'hono' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from 'mutators.ts' import {dbProvider} from './db-provider.ts' const app = new Hono() app.post('/api/zero/mutate', async c => { const result = await handleMutateRequest({ dbProvider, handler: transact => transact((tx, name, args) => { const mutator = mustGetMutator(mutators, name) return mutator.fn({ args, tx }) }), request: c.req.raw, userID: null }) return c.json(result) }) Zero includes several built-in database adapters. You can also easily create your own. See ZQL on the Server for more information. handleMutateRequest accepts a standard Request and returns a JSON object which can be serialized and returned by your server framework of choice. mustGetMutator looks up the mutator in the registry and throws an error if not found. The mutator.fn function is your mutator implementation wrapped in the validator you provided. These examples have only public mutators, so they do not pass a context. In authenticated apps, validate auth in the request, derive context from the session, and pass it to the mutate handler. See Authentication. Handling Errors The handleMutateRequest function skips any mutations that throw: const result = await handleMutateRequest({ dbProvider, handler: transact => transact(async (tx, name, args) => { // The mutation is skipped and the next mutation runs as normal. // The optimistic mutation on the client will be reverted. throw new Error('bonk') }), request: c.req.raw, userID: null }) handleMutateRequest catches such errors and turns them into a structured response that gets sent back to the client. You can recover the errors and show UI if you want. It is also of course possible for the entire push endpoint to return an HTTP error, or to not reply at all: export const Route = createFileRoute('/api/zero/mutate')({ server: { handlers: { POST: async () => { throw new Error('zonk') // will trigger resend } } } })export async function POST() { throw new Error('zonk') // will trigger resend }export async function POST() { throw new Error('zonk') // will trigger resend }app.post('/api/zero/mutate', async c => { // This will cause the client to resend all queued mutations. throw new Error('zonk') }) Responses other than 200, 401, or 403 enter the error state. zero-cache will retry on 5xx up to four times before returning an error. If Zero receives HTTP 401 or 403, the client will enter the needs auth state and require a manual reconnect. Use zero.connection.connect() for cookie auth or zero.connection.connect({auth: newToken}) for token auth, then Zero will retry all queued mutations. If you want a different behavior, it is possible to implement the mutate endpoint yourself and handle errors differently. Retrying Mutators Some errors go away on a second try. At the repeatable read and serializable isolation levels, for example, Postgres can abort a transaction that conflicts with a concurrent one. It fails with a serialization failure, code 40001. To have Zero run a server mutator again in a fresh transaction when it fails with an error like that, call tx.retryOn(): function isSerializationFailure(error: unknown) { return ( error instanceof Error && 'code' in error && error.code === '40001' ) } const updateIssue = defineMutator( z.object({id: z.string(), title: z.string()}), async ({tx, args: {id, title}}) => { if (tx.location === 'server') { tx.retryOn(isSerializationFailure) } await tx.mutate.issue.update({id, title}) } ) retryOn() takes a predicate. It gets the error and the attempt number: 1 when deciding the first re-run, 2 for the second, and so on. Return true to run the mutator again. If it returns anything else or throws, Zero records the error and skips the mutation, as with any other error. The predicate sees errors the mutator throws, and also errors Postgres raises at COMMIT, which the mutator can't catch. Everything the failed run wrote is rolled back, and the re-run reads fresh data. The error is passed as it was thrown. node-postgres and postgres.js errors have the Postgres error code in code. Other libraries can wrap the error, so check what yours throws. The predicate can also return a promise. Zero waits for it, so you can use it to back off before the next run: tx.retryOn(async (error, attempt) => { if (!isSerializationFailure(error)) { return false } // Wait 50ms, then 100ms, then 150ms, and so on. await new Promise(resolve => setTimeout(resolve, attempt * 50) ) return true }) retryOn() only applies to the current run. The re-run starts the mutator from the top and calls retryOn() again, so call it before anything that can fail. You can call it more than once, and Zero runs the mutator again if any predicate returns true. Zero runs a mutator at most 5 times per mutation in each request to your mutate endpoint. If the last run also fails, Zero records the error. The predicate runs after the transaction has ended, so it can't use tx: reading or writing through tx there throws. Only retry mutators that are safe to run more than once. Work outside the database, such as sending an email, isn't rolled back. retryOn() only exists on the server, when tx.location === 'server'. The built-in adapters and ZQLDatabase support retryOn(). If you pass your own Database to handleMutateRequest, it must pass the addRetryPredicate it receives in TransactionProviderInput on to the transaction it creates with CRUDMutatorFactory.createTransaction(). Otherwise tx.retryOn() throws. Custom Mutate URL By default, Zero sends mutations to the URL specified in the ZERO_MUTATE_URL parameter. However you can customize this on a per-client basis. To do so, list multiple comma-separated URLs in the ZERO_MUTATE_URL parameter: export ZERO_MUTATE_URL=\"https://api.example.com/mutate,https://api.staging.example.com/mutate\" Then choose one of those URLs by passing it to mutateURL on the Zero constructor: const opts: ZeroOptions = { // ... mutateURL: 'https://api.staging.example.com/mutate' } URL Patterns The strings listed in ZERO_MUTATE_URL can also be URLPatterns: export ZERO_MUTATE_URL=\"https://mybranch-*.preview.myapp.com/mutate\" For more information, see the URLPattern section of the Queries docs. It works the same way for mutations. If you're configuring per-branch preview URLs (for example on Vercel), see Preview Deployments for the complete setup across both query and mutate endpoints. Server-Specific Code To implement server-specific code, just run different mutators in your mutate endpoint. Server authority to the rescue! defineMutators accepts a baseMutators parameter that makes this easy. The returned mutator registry will contain all the mutators from baseMutators, plus any new ones you define or override: // server-mutators.ts import {defineMutators, defineMutator} from '@rocicorp/zero' import {z} from 'zod' import {zql} from 'schema.ts' import {mutators as sharedMutators} from 'mutators.ts' export const serverMutators = defineMutators( sharedMutators, { posts: { // Overrides the shared mutator definition with same name. update: defineMutator( z.object({ id: z.string(), title: z.string().optional(), priority: z.number().optional() }), async ({ tx, ctx: {userID}, args: {id, title, priority} }) => { // Run the shared mutator first. await sharedMutators.posts.update.fn({ tx, ctx, args }) // Record a history of this operation happening in an audit log table. await tx.mutate.auditLog.insert({ issueId: id, action: 'update-title', timestamp: Date.getTime() }) } ) } } ) For simple things, we also expose a location field on the transaction object that you can use to branch your code: const myMutator = defineMutator(async ({tx}) => { if (tx.location === 'client') { // Client-side code } else { // Server-side code } })",
"kind": "section"
},
{
- "id": "217-mutators#registering-the-endpoint",
+ "id": "221-mutators#registering-the-endpoint",
"title": "Mutators",
"searchTitle": "Registering the Endpoint",
"sectionTitle": "Registering the Endpoint",
@@ -2180,7 +2230,7 @@
"kind": "section"
},
{
- "id": "218-mutators#implementing-the-endpoint",
+ "id": "222-mutators#implementing-the-endpoint",
"title": "Mutators",
"searchTitle": "Implementing the Endpoint",
"sectionTitle": "Implementing the Endpoint",
@@ -2190,7 +2240,7 @@
"kind": "section"
},
{
- "id": "219-mutators#handling-errors",
+ "id": "223-mutators#handling-errors",
"title": "Mutators",
"searchTitle": "Handling Errors",
"sectionTitle": "Handling Errors",
@@ -2200,7 +2250,17 @@
"kind": "section"
},
{
- "id": "220-mutators#custom-mutate-url",
+ "id": "224-mutators#retrying-mutators",
+ "title": "Mutators",
+ "searchTitle": "Retrying Mutators",
+ "sectionTitle": "Retrying Mutators",
+ "sectionId": "retrying-mutators",
+ "url": "/docs/mutators",
+ "content": "Some errors go away on a second try. At the repeatable read and serializable isolation levels, for example, Postgres can abort a transaction that conflicts with a concurrent one. It fails with a serialization failure, code 40001. To have Zero run a server mutator again in a fresh transaction when it fails with an error like that, call tx.retryOn(): function isSerializationFailure(error: unknown) { return ( error instanceof Error && 'code' in error && error.code === '40001' ) } const updateIssue = defineMutator( z.object({id: z.string(), title: z.string()}), async ({tx, args: {id, title}}) => { if (tx.location === 'server') { tx.retryOn(isSerializationFailure) } await tx.mutate.issue.update({id, title}) } ) retryOn() takes a predicate. It gets the error and the attempt number: 1 when deciding the first re-run, 2 for the second, and so on. Return true to run the mutator again. If it returns anything else or throws, Zero records the error and skips the mutation, as with any other error. The predicate sees errors the mutator throws, and also errors Postgres raises at COMMIT, which the mutator can't catch. Everything the failed run wrote is rolled back, and the re-run reads fresh data. The error is passed as it was thrown. node-postgres and postgres.js errors have the Postgres error code in code. Other libraries can wrap the error, so check what yours throws. The predicate can also return a promise. Zero waits for it, so you can use it to back off before the next run: tx.retryOn(async (error, attempt) => { if (!isSerializationFailure(error)) { return false } // Wait 50ms, then 100ms, then 150ms, and so on. await new Promise(resolve => setTimeout(resolve, attempt * 50) ) return true }) retryOn() only applies to the current run. The re-run starts the mutator from the top and calls retryOn() again, so call it before anything that can fail. You can call it more than once, and Zero runs the mutator again if any predicate returns true. Zero runs a mutator at most 5 times per mutation in each request to your mutate endpoint. If the last run also fails, Zero records the error. The predicate runs after the transaction has ended, so it can't use tx: reading or writing through tx there throws. Only retry mutators that are safe to run more than once. Work outside the database, such as sending an email, isn't rolled back. retryOn() only exists on the server, when tx.location === 'server'. The built-in adapters and ZQLDatabase support retryOn(). If you pass your own Database to handleMutateRequest, it must pass the addRetryPredicate it receives in TransactionProviderInput on to the transaction it creates with CRUDMutatorFactory.createTransaction(). Otherwise tx.retryOn() throws.",
+ "kind": "section"
+ },
+ {
+ "id": "225-mutators#custom-mutate-url",
"title": "Mutators",
"searchTitle": "Custom Mutate URL",
"sectionTitle": "Custom Mutate URL",
@@ -2210,7 +2270,7 @@
"kind": "section"
},
{
- "id": "221-mutators#url-patterns",
+ "id": "226-mutators#url-patterns",
"title": "Mutators",
"searchTitle": "URL Patterns",
"sectionTitle": "URL Patterns",
@@ -2220,7 +2280,7 @@
"kind": "section"
},
{
- "id": "222-mutators#server-specific-code",
+ "id": "227-mutators#server-specific-code",
"title": "Mutators",
"searchTitle": "Server-Specific Code",
"sectionTitle": "Server-Specific Code",
@@ -2230,27 +2290,37 @@
"kind": "section"
},
{
- "id": "223-mutators#running-mutators",
+ "id": "228-mutators#running-mutators",
"title": "Mutators",
"searchTitle": "Running Mutators",
"sectionTitle": "Running Mutators",
"sectionId": "running-mutators",
"url": "/docs/mutators",
- "content": "Once you have registered your mutators, you can invoke them with zero.mutate: import {mutators} from 'mutators.ts' zero.mutate( mutators.issue.update({ id: crypto.randomUUID(), title: 'New title' }) ) Client-generated random IDs from crypto.randomUUID(), uuid, ulid, or nanoid work much better with sync engines like Zero. See IDs for more details. Waiting for Results We typically recommend that you \"fire and forget\" mutators. Optimistic mutations make sense when the common case is that a mutation succeeds. If a mutation frequently fails, then showing the user an optimistic result isn't very useful, because it will likely be wrong. That said there are cases where it is nice to know when a write succeeded on either the client or server. One example is if you need to read a row directly after writing it. Zero's local writes are very fast (almost always < 1 frame), but because Zero is backed by IndexedDB, writes are still technically asynchronous and reads directly after a write may not return the new data. You can use the .client promise in this case to wait for a write to complete on the client side: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) // issue-123 not guaranteed to be present here. read1 may be undefined. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await client write – almost always less than 1 frame, and same // macrotask, so no browser paint will occur here. const res = await write.client if (res.type === 'error') { console.error('Mutator failed on client', res.error) } // issue-123 definitely can be read now. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) You can also await .server for the server result: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) const clientRes = await write.client if (clientRes.type === 'error') { throw new Error(`Mutator failed on client`, { cause: clientRes.error }) } // optimistic write guaranteed to be present here, but not // server write. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await the server result/acknowledgment. This requires a round trip. const serverRes = await write.server if (serverRes.type === 'error') { throw new Error(`Mutator failed on server`, { cause: serverRes.error }) } // The server acknowledged the mutation, but its Postgres changes // may not have replicated to this client yet. This read can still // reflect optimistic rather than authoritative state. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) If the client-side mutator fails, .server also resolves to an error result. Awaiting .server therefore covers both client- and server-side failures. There is not yet a way to return data from mutators in the success case. Let us know if you need this.",
+ "content": "Once you have registered your mutators, you can invoke them with zero.mutate: import {mutators} from 'mutators.ts' zero.mutate( mutators.issue.update({ id: crypto.randomUUID(), title: 'New title' }) ) Client-generated random IDs from crypto.randomUUID(), uuid, ulid, or nanoid work much better with sync engines like Zero. See IDs for more details. Waiting for Results We typically recommend that you \"fire and forget\" mutators. Optimistic mutations make sense when the common case is that a mutation succeeds. If a mutation frequently fails, then showing the user an optimistic result isn't very useful, because it will likely be wrong. That said there are cases where it is nice to know when a write succeeded on either the client or server. One example is if you need to read a row directly after writing it. Zero's local writes are very fast (almost always < 1 frame), but because Zero is backed by IndexedDB, writes are still technically asynchronous and reads directly after a write may not return the new data. You can use the .client promise in this case to wait for a write to complete on the client side: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) // issue-123 not guaranteed to be present here. read1 may be undefined. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await client write – almost always less than 1 frame, and same // macrotask, so no browser paint will occur here. const res = await write.client if (res.type === 'error') { console.error('Mutator failed on client', res.error) } // issue-123 definitely can be read now. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) You can also await .server for the server result: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) const clientRes = await write.client if (clientRes.type === 'error') { throw new Error(`Mutator failed on client`, { cause: clientRes.error }) } // optimistic write guaranteed to be present here, but not // server write. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await the server result/acknowledgment. This requires a round trip. const serverRes = await write.server if (serverRes.type === 'error') { throw new Error(`Mutator failed on server`, { cause: serverRes.error }) } // The server acknowledged the mutation, but its Postgres changes // may not have replicated to this client yet. This read can still // reflect optimistic rather than authoritative state. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) If the client-side mutator fails, .server also resolves to an error result. Awaiting .server therefore covers both client- and server-side failures. Returning Data A mutator can return a JSON value. The caller gets it in the data field of the success result. The client and server runs can return different values. For example, the server can return a value that only the database knows: const createIssue = defineMutator( z.object({id: z.string(), title: z.string()}), async ({tx, args: {id, title}}) => { await tx.mutate.issue.insert({id, title}) if (tx.location === 'client') { // The client doesn't know the short ID yet. return {shortID: null} } // Postgres assigns shortID when the row is inserted. const issue = await tx.run( zql.issue.where('id', id).one() ) return {shortID: issue?.shortID ?? null} } ) The .client result's data is what the client run returned, and the .server result's data is what the server run returned: const write = zero.mutate( mutators.issue.create({ id: crypto.randomUUID(), title: 'New title' }) ) const serverRes = await write.server if (serverRes.type === 'success') { // `data` is a plain JSON value, so check its shape. const {shortID} = z .object({shortID: z.number().nullable()}) .parse(serverRes.data) console.log('Short ID:', shortID) } data is typed as ReadonlyJSONValue | undefined rather than as the mutator's return type, so check its shape before you use it. A mutator that returns nothing gives undefined. The server's value only reaches the client if your mutate handler returns the mutator's result, as return mutator.fn({args, tx}) does in the examples above. Zero also saves that value in your database with the mutation's result, so the client still gets it if the response from your mutate endpoint is lost. Nothing is saved for a mutator that returns nothing.",
"kind": "section"
},
{
- "id": "224-mutators#waiting-for-results",
+ "id": "229-mutators#waiting-for-results",
"title": "Mutators",
"searchTitle": "Waiting for Results",
"sectionTitle": "Waiting for Results",
"sectionId": "waiting-for-results",
"url": "/docs/mutators",
- "content": "We typically recommend that you \"fire and forget\" mutators. Optimistic mutations make sense when the common case is that a mutation succeeds. If a mutation frequently fails, then showing the user an optimistic result isn't very useful, because it will likely be wrong. That said there are cases where it is nice to know when a write succeeded on either the client or server. One example is if you need to read a row directly after writing it. Zero's local writes are very fast (almost always < 1 frame), but because Zero is backed by IndexedDB, writes are still technically asynchronous and reads directly after a write may not return the new data. You can use the .client promise in this case to wait for a write to complete on the client side: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) // issue-123 not guaranteed to be present here. read1 may be undefined. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await client write – almost always less than 1 frame, and same // macrotask, so no browser paint will occur here. const res = await write.client if (res.type === 'error') { console.error('Mutator failed on client', res.error) } // issue-123 definitely can be read now. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) You can also await .server for the server result: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) const clientRes = await write.client if (clientRes.type === 'error') { throw new Error(`Mutator failed on client`, { cause: clientRes.error }) } // optimistic write guaranteed to be present here, but not // server write. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await the server result/acknowledgment. This requires a round trip. const serverRes = await write.server if (serverRes.type === 'error') { throw new Error(`Mutator failed on server`, { cause: serverRes.error }) } // The server acknowledged the mutation, but its Postgres changes // may not have replicated to this client yet. This read can still // reflect optimistic rather than authoritative state. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) If the client-side mutator fails, .server also resolves to an error result. Awaiting .server therefore covers both client- and server-side failures. There is not yet a way to return data from mutators in the success case. Let us know if you need this.",
+ "content": "We typically recommend that you \"fire and forget\" mutators. Optimistic mutations make sense when the common case is that a mutation succeeds. If a mutation frequently fails, then showing the user an optimistic result isn't very useful, because it will likely be wrong. That said there are cases where it is nice to know when a write succeeded on either the client or server. One example is if you need to read a row directly after writing it. Zero's local writes are very fast (almost always < 1 frame), but because Zero is backed by IndexedDB, writes are still technically asynchronous and reads directly after a write may not return the new data. You can use the .client promise in this case to wait for a write to complete on the client side: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) // issue-123 not guaranteed to be present here. read1 may be undefined. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await client write – almost always less than 1 frame, and same // macrotask, so no browser paint will occur here. const res = await write.client if (res.type === 'error') { console.error('Mutator failed on client', res.error) } // issue-123 definitely can be read now. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) You can also await .server for the server result: const write = zero.mutate( mutators.issue.insert({ id: crypto.randomUUID(), title: 'New title' }) ) const clientRes = await write.client if (clientRes.type === 'error') { throw new Error(`Mutator failed on client`, { cause: clientRes.error }) } // optimistic write guaranteed to be present here, but not // server write. const read1 = await zero.run( queries.issue.byId('issue-123').one() ) // Await the server result/acknowledgment. This requires a round trip. const serverRes = await write.server if (serverRes.type === 'error') { throw new Error(`Mutator failed on server`, { cause: serverRes.error }) } // The server acknowledged the mutation, but its Postgres changes // may not have replicated to this client yet. This read can still // reflect optimistic rather than authoritative state. const read2 = await zero.run( queries.issue.byId('issue-123').one() ) If the client-side mutator fails, .server also resolves to an error result. Awaiting .server therefore covers both client- and server-side failures.",
"kind": "section"
},
{
- "id": "225-mutators#permissions",
+ "id": "230-mutators#returning-data",
+ "title": "Mutators",
+ "searchTitle": "Returning Data",
+ "sectionTitle": "Returning Data",
+ "sectionId": "returning-data",
+ "url": "/docs/mutators",
+ "content": "A mutator can return a JSON value. The caller gets it in the data field of the success result. The client and server runs can return different values. For example, the server can return a value that only the database knows: const createIssue = defineMutator( z.object({id: z.string(), title: z.string()}), async ({tx, args: {id, title}}) => { await tx.mutate.issue.insert({id, title}) if (tx.location === 'client') { // The client doesn't know the short ID yet. return {shortID: null} } // Postgres assigns shortID when the row is inserted. const issue = await tx.run( zql.issue.where('id', id).one() ) return {shortID: issue?.shortID ?? null} } ) The .client result's data is what the client run returned, and the .server result's data is what the server run returned: const write = zero.mutate( mutators.issue.create({ id: crypto.randomUUID(), title: 'New title' }) ) const serverRes = await write.server if (serverRes.type === 'success') { // `data` is a plain JSON value, so check its shape. const {shortID} = z .object({shortID: z.number().nullable()}) .parse(serverRes.data) console.log('Short ID:', shortID) } data is typed as ReadonlyJSONValue | undefined rather than as the mutator's return type, so check its shape before you use it. A mutator that returns nothing gives undefined. The server's value only reaches the client if your mutate handler returns the mutator's result, as return mutator.fn({args, tx}) does in the examples above. Zero also saves that value in your database with the mutation's result, so the client still gets it if the response from your mutate endpoint is lost. Nothing is saved for a mutator that returns nothing.",
+ "kind": "section"
+ },
+ {
+ "id": "231-mutators#permissions",
"title": "Mutators",
"searchTitle": "Permissions",
"sectionTitle": "Permissions",
@@ -2260,7 +2330,7 @@
"kind": "section"
},
{
- "id": "226-mutators#dropping-down-to-raw-sql",
+ "id": "232-mutators#dropping-down-to-raw-sql",
"title": "Mutators",
"searchTitle": "Dropping Down to Raw SQL",
"sectionTitle": "Dropping Down to Raw SQL",
@@ -2270,7 +2340,7 @@
"kind": "section"
},
{
- "id": "227-mutators#notifications-and-async-work",
+ "id": "233-mutators#notifications-and-async-work",
"title": "Mutators",
"searchTitle": "Notifications and Async Work",
"sectionTitle": "Notifications and Async Work",
@@ -2280,7 +2350,7 @@
"kind": "section"
},
{
- "id": "228-mutators#custom-mutate-implementation",
+ "id": "234-mutators#custom-mutate-implementation",
"title": "Mutators",
"searchTitle": "Custom Mutate Implementation",
"sectionTitle": "Custom Mutate Implementation",
@@ -2304,7 +2374,7 @@
"kind": "page"
},
{
- "id": "229-open-source#business-model",
+ "id": "235-open-source#business-model",
"title": "Zero is Open Source Software",
"searchTitle": "Business Model",
"sectionTitle": "Business Model",
@@ -2356,7 +2426,7 @@
"kind": "page"
},
{
- "id": "230-otel#grafana-cloud-walkthrough",
+ "id": "236-otel#grafana-cloud-walkthrough",
"title": "OpenTelemetry",
"searchTitle": "Grafana Cloud Walkthrough",
"sectionTitle": "Grafana Cloud Walkthrough",
@@ -2366,7 +2436,7 @@
"kind": "section"
},
{
- "id": "231-otel#distributed-tracing",
+ "id": "237-otel#distributed-tracing",
"title": "OpenTelemetry",
"searchTitle": "Distributed Tracing",
"sectionTitle": "Distributed Tracing",
@@ -2376,7 +2446,7 @@
"kind": "section"
},
{
- "id": "232-otel#metrics-reference",
+ "id": "238-otel#metrics-reference",
"title": "OpenTelemetry",
"searchTitle": "Metrics Reference",
"sectionTitle": "Metrics Reference",
@@ -2386,7 +2456,7 @@
"kind": "section"
},
{
- "id": "233-otel#zeroserver",
+ "id": "239-otel#zeroserver",
"title": "OpenTelemetry",
"searchTitle": "zero.server",
"sectionTitle": "zero.server",
@@ -2396,7 +2466,7 @@
"kind": "section"
},
{
- "id": "234-otel#zeroreplica",
+ "id": "240-otel#zeroreplica",
"title": "OpenTelemetry",
"searchTitle": "zero.replica",
"sectionTitle": "zero.replica",
@@ -2406,7 +2476,7 @@
"kind": "section"
},
{
- "id": "235-otel#zeroreplication",
+ "id": "241-otel#zeroreplication",
"title": "OpenTelemetry",
"searchTitle": "zero.replication",
"sectionTitle": "zero.replication",
@@ -2416,7 +2486,7 @@
"kind": "section"
},
{
- "id": "236-otel#zerosync",
+ "id": "242-otel#zerosync",
"title": "OpenTelemetry",
"searchTitle": "zero.sync",
"sectionTitle": "zero.sync",
@@ -2426,7 +2496,7 @@
"kind": "section"
},
{
- "id": "237-otel#zeromutation",
+ "id": "243-otel#zeromutation",
"title": "OpenTelemetry",
"searchTitle": "zero.mutation",
"sectionTitle": "zero.mutation",
@@ -2486,7 +2556,7 @@
"kind": "page"
},
{
- "id": "238-postgres-support#object-names",
+ "id": "244-postgres-support#object-names",
"title": "Supported Postgres Features",
"searchTitle": "Object Names",
"sectionTitle": "Object Names",
@@ -2496,7 +2566,7 @@
"kind": "section"
},
{
- "id": "239-postgres-support#object-types",
+ "id": "245-postgres-support#object-types",
"title": "Supported Postgres Features",
"searchTitle": "Object Types",
"sectionTitle": "Object Types",
@@ -2506,7 +2576,7 @@
"kind": "section"
},
{
- "id": "240-postgres-support#column-types",
+ "id": "246-postgres-support#column-types",
"title": "Supported Postgres Features",
"searchTitle": "Column Types",
"sectionTitle": "Column Types",
@@ -2516,7 +2586,7 @@
"kind": "section"
},
{
- "id": "241-postgres-support#column-defaults",
+ "id": "247-postgres-support#column-defaults",
"title": "Supported Postgres Features",
"searchTitle": "Column Defaults",
"sectionTitle": "Column Defaults",
@@ -2526,7 +2596,7 @@
"kind": "section"
},
{
- "id": "242-postgres-support#ids",
+ "id": "248-postgres-support#ids",
"title": "Supported Postgres Features",
"searchTitle": "IDs",
"sectionTitle": "IDs",
@@ -2536,7 +2606,7 @@
"kind": "section"
},
{
- "id": "243-postgres-support#primary-keys",
+ "id": "249-postgres-support#primary-keys",
"title": "Supported Postgres Features",
"searchTitle": "Primary Keys",
"sectionTitle": "Primary Keys",
@@ -2546,7 +2616,7 @@
"kind": "section"
},
{
- "id": "244-postgres-support#limiting-replication",
+ "id": "250-postgres-support#limiting-replication",
"title": "Supported Postgres Features",
"searchTitle": "Limiting Replication",
"sectionTitle": "Limiting Replication",
@@ -2556,7 +2626,7 @@
"kind": "section"
},
{
- "id": "245-postgres-support#zero-cache-replication",
+ "id": "251-postgres-support#zero-cache-replication",
"title": "Supported Postgres Features",
"searchTitle": "zero-cache replication",
"sectionTitle": "zero-cache replication",
@@ -2566,7 +2636,7 @@
"kind": "section"
},
{
- "id": "246-postgres-support#browser-client-replication",
+ "id": "252-postgres-support#browser-client-replication",
"title": "Supported Postgres Features",
"searchTitle": "Browser client replication",
"sectionTitle": "Browser client replication",
@@ -2576,7 +2646,7 @@
"kind": "section"
},
{
- "id": "247-postgres-support#schema-changes",
+ "id": "253-postgres-support#schema-changes",
"title": "Supported Postgres Features",
"searchTitle": "Schema changes",
"sectionTitle": "Schema changes",
@@ -2612,7 +2682,7 @@
"kind": "page"
},
{
- "id": "248-previews#overview",
+ "id": "254-previews#overview",
"title": "Previews",
"searchTitle": "Overview",
"sectionTitle": "Overview",
@@ -2622,7 +2692,7 @@
"kind": "section"
},
{
- "id": "249-previews#configure-allowed-endpoint-patterns",
+ "id": "255-previews#configure-allowed-endpoint-patterns",
"title": "Previews",
"searchTitle": "Configure Allowed Endpoint Patterns",
"sectionTitle": "Configure Allowed Endpoint Patterns",
@@ -2632,7 +2702,7 @@
"kind": "section"
},
{
- "id": "250-previews#choose-endpoint-urls-in-the-client",
+ "id": "256-previews#choose-endpoint-urls-in-the-client",
"title": "Previews",
"searchTitle": "Choose Endpoint URLs in the Client",
"sectionTitle": "Choose Endpoint URLs in the Client",
@@ -2642,7 +2712,7 @@
"kind": "section"
},
{
- "id": "251-previews#schema-changes-in-previews",
+ "id": "257-previews#schema-changes-in-previews",
"title": "Previews",
"searchTitle": "Schema Changes in Previews",
"sectionTitle": "Schema Changes in Previews",
@@ -2656,7 +2726,7 @@
"title": "Queries",
"searchTitle": "Queries",
"url": "/docs/queries",
- "content": "Queries are how you read and sync data with Zero. Here's a simple example: // src/queries.ts import {defineQueries, defineQuery} from '@rocicorp/zero' import {z} from 'zod' import {zql} from 'schema.ts' export const queries = defineQueries({ postsByAuthor: defineQuery( z.object({authorID: z.string()}), ({args: {authorID}}) => zql.post.where('authorID', authorID) ) }) Architecture A copy of each query exists on both the client and on your server: Often the implementations will be the same, and you can just share their code. This is easy with full-stack frameworks like TanStack Start or Next.js. But the implementations don't have to be the same, or even compute the same result. For example, the server can add extra filters to enforce permissions that the client query does not. Life of a Query When a query is invoked, it initially runs on the client, against the client-side datastore. Any matching data is returned immediately and the user sees instant results. In the background, the name and arguments for the query are sent to zero-cache. Zero-cache calls the queries endpoint on your server to get the ZQL for the query. Your server looks up its implementation of the query, invokes it, and returns the resulting ZQL expression to zero-cache. Zero-cache then runs this ZQL against the server-side data. The initial server result is sent back to the client and the client query updates in response. zero-cache receives updates from Postgres via logical replication. It updates affected queries and sends row changes back to the client, which updates the client query, and the user sees the changes. Defining Queries Basics Create a query using defineQuery. The only required argument is a QueryFn, which must return a ZQL expression: import {zql} from 'schema.ts' const allPostsQueryDef = defineQuery(() => zql.post) Arguments The QueryFn can take a single args parameter. To enable this, pass a validator to defineQuery: import {zql} from 'schema.ts' const postsByAuthor = defineQuery( z.object({authorID: z.string().optional()}), ({args: {authorID}}) => { let q = zql.post if (authorID !== undefined) { q = q.where('authorID', authorID) } return q } ) We use Zod in these examples, but you can use any validation library that implements Standard Schema. Zero queries run on both the client and on your server. In the server case, the parameters come from the client and are untrusted. The validator ensures the data passed to your query is of the expected type. Query Registries The result of defineQuery is a QueryDefinition. By itself this isn't super useful. You need to register it using defineQueries: export const queries = defineQueries({ posts: { all: allPostsQueryDef } }) Typically these are done together in one step: export const queries = defineQueries({ posts: { all: defineQuery(() => zql.post) } }) The result of defineQueries is called a QueryRegistry. Each field in the registry is a callable Query that you can use to read data: import {zero} from 'zero.ts' import {queries} from 'queries.ts' const allPosts = await zero.run(queries.posts.all()) Query Names Each Query has a queryName which is computed by defineQueries. This name is later sent to your server to identify the query to run: console.log(queries.posts.all.queryName) // \"posts.all\" Context Query parameters are supplied by the client application and passed to the server automatically by Zero. This makes them unsuitable for credentials, since the user could modify them. For this reason, Zero queries also support the concept of a context object. Access your context with the ctx parameter to your query: const myPostsQuery = defineQuery(({ctx: {userID}}) => { // User cannot control context.userID, so this safely // restricts the query to the user's own posts. return zql.post.where('authorID', userID) }) If you don't want to register your Context and Schema types globally, you can use defineQueryWithType and defineQueriesWithType: import { defineQueriesWithType, defineQueryWithType } from '@rocicorp/zero' import type {Schema} from 'schema.ts' import type {ZeroContext} from 'context.ts' const defineQuery = defineQueryWithType< Schema, ZeroContext >() const defineQueries = defineQueriesWithType() queries.ts By convention, all queries for an application are listed in a central queries.ts file. This allows them to be easily used on both the client and server: import {defineQueries, defineQuery} from '@rocicorp/zero' import {z} from 'zod' import {zql} from './schema.ts' export const queries = defineQueries({ posts: { get: defineQuery(z.string(), id => zql.post.where('id', id) ), byAuthor: defineQuery( z.object({ authorID: z.string(), includeDrafts: z.boolean().optional() }), ({args: {authorID, includeDrafts}}) => { let q = zql.post.where('authorID', authorID) if (!includeDrafts) { q = q.where('isDraft', false) } return q } ) } }) You can use as many levels of nesting as you want to organize your queries. As your application grows, you can move queries to different files to keep them organized: // posts.ts export const postQueries = { get: defineQuery(z.string(), id => zql.post.where('id', id) ) // ... } // users.ts export const userQueries = { byRole: defineQuery(z.string(), role => zql.user.where('role', role) ) // ... } // queries.ts import {postQueries} from './posts.ts' import {userQueries} from './users.ts' export const queries = defineQueries({ posts: postQueries, users: userQueries }) Because defineQueries establishes the full name for each query (i.e., posts.get, users.byRole), it should only be used once at the top level of your queries.ts file. Server Setup In order for queries to sync, you must provide an implementation of the query endpoint on your server. zero-cache calls this endpoint to resolve each query to ZQL that it can run. Registering the Endpoint Use ZERO_QUERY_URL to tell zero-cache where to find your query implementation: export ZERO_QUERY_URL=\"http://localhost:3000/api/zero/query\" # run zero-cache, e.g. `npx zero-cache-dev` Implementing the Endpoint You can use the handleQueryRequest and mustGetQuery functions to implement the endpoint. // src/routes/api/zero/query.ts import {createFileRoute} from '@tanstack/react-router' import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from 'queries.ts' import {schema} from 'schema.ts' export const Route = createFileRoute('/api/zero/query')({ server: { handlers: { POST: async ({request}) => { const result = await handleQueryRequest({ handler: (name, args) => { const query = mustGetQuery(queries, name) return query.fn({args}) }, schema, request, userID: null }) return Response.json(result) } } } })// app/api/zero/query/route.ts import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from 'queries.ts' import {schema} from 'schema.ts' export async function POST(request: Request) { const result = await handleQueryRequest({ handler: (name, args) => { const query = mustGetQuery(queries, name) return query.fn({args}) }, schema, request, userID: null }) return Response.json(result) }// src/routes/api/zero/query.ts import type {APIEvent} from '@solidjs/start/server' import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from 'queries.ts' import {schema} from 'schema.ts' export async function POST(event: APIEvent) { const result = await handleQueryRequest({ handler: (name, args) => { const query = mustGetQuery(queries, name) return query.fn({args}) }, schema, request: event.request, userID: null }) return Response.json(result) }// api/app.ts import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from 'queries.ts' import {schema} from 'schema.ts' app.post('/api/zero/query', async c => { const result = await handleQueryRequest({ handler: (name, args) => { const query = mustGetQuery(queries, name) return query.fn({args}) }, schema, request: c.req.raw, userID: null }) return c.json(result) }) handleQueryRequest accepts a standard Request and returns a JSON object which can be serialized and returned by your server framework of choice. mustGetQuery looks up the query in the registry and throws an error if not found. The query.fn function is your query implementation wrapped in the validator you provided. These examples have only public queries, so they do not pass a context. In authenticated apps, validate auth in the request, derive context from the session, and pass it to the query handler. See Authentication. Custom Query URL By default, Zero sends queries to the URL specified in the ZERO_QUERY_URL parameter in the zero-cache config. However you can customize this on a per-client basis. To do so, list multiple comma-separated URLs in ZERO_QUERY_URL: ZERO_QUERY_URL='https://api.example.com/query,https://api.staging.example.com/query' Then choose one of those URLs by passing it to queryURL on the Zero constructor: const zero = new Zero({ schema, queries, queryURL: 'https://api.staging.example.com/query' }) URL Patterns The strings listed in ZERO_QUERY_URL can also be URLPatterns: ZERO_QUERY_URL=\"https://mybranch-*.preview.myapp.com/query\" This queries URL will allow clients to choose URLs like: https://mybranch-aaa.preview.myapp.com/query ✅ https://mybranch-bbb.preview.myapp.com/query ✅ But rejects URLs like: https://preview.myapp.com/query ❌ (missing subdomain) https://malicious.com/query ❌ (different domain) https://mybranch-123.preview.myapp.com/query/extra ❌ (extra path) https://mybranch-123.preview.myapp.com/other ❌ (different path) Because URLPattern is a web standard, you can test them right in your browser: For more information, see the URLPattern docs. If you're configuring per-branch preview URLs (for example on Vercel), see Preview Deployments for the complete setup across both query and mutate endpoints. Running Queries Reactively The most common way to use queries is with the useQuery reactive hooks from the React or SolidJS bindings (or the equivalent low-level API): import {useQuery} from '@rocicorp/zero/react' import {queries} from 'zero/queries.ts' function App() { const [posts] = useQuery(queries.posts.get('user123')) return posts.map(post => (
{post.title}
)) }import {useQuery} from '@rocicorp/zero/solid' import {queries} from 'zero/queries.ts' function App() { const [posts] = useQuery(() => queries.posts.get('user123') ) return ( {post =>
{post.title}
} ) }import {queries} from 'zero/queries.ts' import {zero} from 'zero.ts' const postsView = zero.materialize( queries.posts.byAuthorID('user123') ) for (let post of postsView.data) { console.log(post.title) } // updates as the underlying data changes postsView.addListener(posts => { console.log('posts', posts) }) These functions allow you to automatically re-render UI when a query changes. Conditionally Sometimes the inputs needed to construct a query are not available on the first render. For example, auth state or a route param might still be loading after a page refresh. Both React and Solid support conditional queries by passing undefined until the query can be constructed: import {useQuery} from '@rocicorp/zero/react' import {queries} from 'zero/queries.ts' function Username({userID}: {userID: string | undefined}) { const [user] = useQuery( userID ? queries.users.getUser({ userID }) : undefined ) return user ?
{user.username}
: null }import {useQuery} from '@rocicorp/zero/solid' import {Show} from 'solid-js' import {queries} from 'zero/queries.ts' function Username(props: {userID: string | undefined}) { const [user] = useQuery(() => props.userID ? queries.users.getUser({ userID: props.userID }) : undefined ) return ( {user =>
{user().username}
} ) } Once You usually want to subscribe to a query in a reactive UI, but every so often you'll need to run a query just once. To do this, use zero.run(): const results = await zero.run( queries.issues.byPriority('high') ) By default, run() only returns results that are currently available on the client. That is, it returns the data that would be given for result.type === 'unknown'. If you want to wait for the server to return results, pass {type: 'complete'} to run: const results = await zero.run( queries.issues.byPriority('high'), {type: 'complete'} ) For Preloading Almost all Zero apps will want to preload some data in order to maximize the feel of instantaneous UI transitions. Because preload queries are often much larger than a screenful of UI, Zero provides a special zero.preload() method to avoid the overhead of materializing the result into JS objects: // Preload a large number of the inbox query results. zero.preload( queries.issues.inbox({ sort: 'created', sortDirection: 'desc', limit: 1000 }) ) Missing Data Because Zero returns local results immediately and server results asynchronously, displaying \"not found\" / 404 UI can be slightly tricky. If you just use a simple existence check, you will often see the 404 UI flicker while the server result loads: const [issue] = useQuery(queries.issues.get('some-id')) // ❌ This causes flickering of the UI if (!issue) { return
)} )const postsView = zero.materialize( queries.posts.byAuthorID('user123') ) postsView.addListener(posts => { // ❌ This is updated as data comes in console.log('posts', posts) }) To do this correctly, only display the \"not found\" UI when the result type is complete. This way the 404 page is slow but pages with data are still just as fast: const [issue, issueResult] = useQuery( queries.issues.get('some-id') ) if (!issue && issueResult.type === 'complete') { return
)const postsView = zero.materialize( queries.posts.byAuthorID('user123') ) postsView.addListener((posts, resultType) => { if (resultType === 'complete') { console.log('posts', posts) } }) Partial Data Zero immediately returns the data for a query it has on the client, then falls back to the server for any missing data. Sometimes it's useful to know the difference between these two types of results. To do so, use the result from useQuery: const [issues, issuesResult] = useQuery( queries.issues.inbox() ) if (issuesResult.type === 'complete') { console.log('All data is present') } else { console.log('Some data is missing') }const [issues, issuesResult] = useQuery(() => queries.issues.inbox() ) if (issuesResult().type === 'complete') { console.log('All data is present') } else { console.log('Some data is missing') }const view = zero.materialize(queries.issues.inbox()) view.addListener((issues, resultType) => { if (resultType === 'complete') { console.log('All data is present') } else { console.log('Some data is missing') } }) The possible values of result.type are currently complete and unknown. The complete value is currently only returned when Zero has received the server result. In the future, Zero will be able to return this result type when it knows that all possible data for this query is already available locally. Additionally, we plan to add a prefix result for when the data is known to be a prefix of the complete result. See Consistency for more information. Handling Errors If the queries endpoint throws an application or parse error, zero-cache will report it to the client using the type and error fields on the query details object: const [posts, postsResult] = useQuery( queries.posts.byAuthorID('user123') ) if (postsResult.type === 'error') { return (
)// Materialize a view of a query const postsView = queries.posts .byAuthorID('user123') .materialize() postsView.addListener((posts, resultType, error) => { if (resultType === 'error') { console.error('Error loading posts', error) } }) See Connection Status for how HTTP or network errors from the queries endpoint are handled. Granular Updates You can use the materialize() method to create a view that you can listen to for changes. However, this will only tell you when the view has changed and give you the complete new result. It won't tell you what changed. To know what changed, you can create your own custom View implementation: // Inside the View class // Instead of storing the change, we invoke some callback push(change: Change): void { switch (change.type) { case 'add': this.#onAdd?.(change) break case 'remove': this.#onRemove?.(change) break case 'edit': this.#onEdit?.(change) break case 'child': this.#onChild?.(change) break default: throw new Error(`Unknown change type: ${change['type']}`) } } For examples, see the View implementations in zero-vue or zero-solid. Query Caching Queries can be either active or cached. An active query is one that is currently being used by the application. Cached queries are not currently in use, but continue syncing in case they are needed again soon. Queries are deactivated according to how they were created: For useQuery(), the UI unmounts the component (which calls destroy() under the covers). For preload(), the UI calls cleanup() on the return value of preload(). For run(), queries are automatically deactivated immediately after the result is returned. For materialize() queries, the UI calls destroy() on the view. Additionally when a Zero instance closes, all active queries are automatically deactivated. This also happens when the containing page or script is unloaded. TTLs Each query has a ttl that controls how long it stays cached. If the user closes all tabs for your app, Zero stops running and the time that elapses doesn't count toward any TTLs. You do not need to account for such time when choosing a TTL – you only need to account for time your app is running without a query. TTL Defaults In most cases, the default TTL should work well: preload() queries default to ttl:'none', meaning they are not cached at all, and will stop syncing immediately when deactivated. But because preload() queries are typically registered at app startup and never shutdown, and because the ttl clock only ticks while Zero is running, this means that preload queries never get unregistered. Other queries have a default ttl of 5m (five minutes). Setting Different TTLs You can override the default TTL with the ttl parameter: const [user] = useQuery( queries.posts.byAuthorID('user123'), {ttl: '5m'} ) // preload() zero.preload(queries.posts.byAuthorID('user123'), { ttl: '5m' })const [user] = useQuery( () => queries.posts.byAuthorID('user123'), {ttl: '5m'} ) // preload() zero().preload(queries.posts.byAuthorID('user123'), { ttl: '5m' })// run() const user = await zero.run( queries.posts.byAuthorID('user123'), {ttl: '5m'} ) // materialize() const view = zero.materialize( queries.posts.byAuthorID('user123'), {ttl: '5m'} ) // preload() zero.preload(queries.posts.byAuthorID('user123'), { ttl: '5m' }) TTLs up to 10m (ten minutes) are currently supported. The following formats are allowed: Why Zero TTLs are Short Zero queries are not free. Just as in any database, queries consume resources on both the client and server. Memory is used to keep metadata about the query, and disk storage is used to keep the query's current state. We do drop this state after we haven't heard from a client for awhile, but this is only a partial improvement. If the client returns, we have to re-run the query to get the latest data. This means that we do not actually want to keep queries active unless there is a good chance they will be needed again soon. The default Zero TTL values might initially seem too short, but they are designed to work well with the way Zero's TTL clock works and strike a good balance between keeping queries alive long enough to be useful, while not keeping them alive so long that they consume resources unnecessarily. Local-Only Queries It can sometimes be useful to run queries only on the client. For example, to implement typeahead search, it really doesn't make sense to register a query with the server for every single keystroke. Zero doesn't yet have a way to run named queries local-only, but you can run ZQL expressions locally by passing them anywhere a query is supported. For example, to subscribe to a local-only query: // Queries the already synced data for issues, // without syncing more data. const [issues] = useQuery( zql.issue.orderBy('created', 'desc').limit(10) )// Queries the already synced data for issues, // without syncing more data. const [issues] = useQuery(() => zql.issue.orderBy('created', 'desc').limit(10) )// Queries the already synced data for issues, // without syncing more data. const view = z.materialize( zql.issue.orderBy('created', 'desc').limit(10) ) view.addListener(issues => { console.log('issues', issues) }) Custom Server Implementation It is possible to implement the ZERO_QUERY_URL endpoint without using Zero's TypeScript libraries, or even in a different language entirely. The endpoint receives a POST request with a JSON body of the form: type QueriesRequestBody = { id: string name: string args: readonly ReadonlyJSONValue[] }[] And responds with: type QueriesResponseBody = ( | { id: string name: string // See https://github.com/rocicorp/mono/blob/main/packages/zero-protocol/src/ast.ts ast: AST } | { error: 'app' id: string name: string details: ReadonlyJSONValue } | { error: 'zero' id: string name: string details: ReadonlyJSONValue } | { error: 'http' id: string name: string status: number details: ReadonlyJSONValue } )[] Consistency Zero always syncs a consistent partial replica of the backend database to the client. This avoids many common consistency issues that come up in classic web applications. But there are still some consistency issues to be aware of when using Zero. For example, imagine that you have a bug database w/ 10k issues. You preload the first 1k issues sorted by created. The user then does a query of issues assigned to themselves, sorted by created. Among the 1k issues that were preloaded imagine 100 are found that match the query. Since the data we preloaded is in the same order as this query, we are guaranteed that any local results found will be a prefix of the server results. The UX that result is nice: the user will see initial results to the query instantly. If more results are found server-side, those results are guaranteed to sort below the local results. There's no shuffling of results when the server response comes in. Now imagine that the user switches the sort to ‘sort by modified’. This new query will run locally, and will again find some local matches. But it is now unlikely that the local results found are a prefix of the server results. When the server result comes in, the user will probably see the results shuffle around. To avoid this annoying effect, what you should do in this example is also preload the first 1k issues sorted by modified desc. In general for any query shape you intend to do, you should preload the first n results for that query shape with no filters, in each sort you intend to use. Zero syncs the union of all active queries' results. You don't have to worry about syncing many sorts of the same query when it's likely the results will overlap heavily. In the future, we will be implementing a consistency model that fixes these issues automatically. We will prevent Zero from returning local data when that data is not known to be a prefix of the server result. Once the consistency model is implemented, preloading can be thought of as purely a performance thing, and not required to avoid unsightly flickering.",
+ "content": "Queries are how you read and sync data with Zero. Here's a simple example: // src/queries.ts import {defineQueries, defineQuery} from '@rocicorp/zero' import {z} from 'zod' import {zql} from 'schema.ts' export const queries = defineQueries({ postsByAuthor: defineQuery( z.object({authorID: z.string()}), ({args: {authorID}}) => zql.post.where('authorID', authorID) ) }) Architecture A copy of each query exists on both the client and on your server: Often the implementations will be the same, and you can just share their code. This is easy with full-stack frameworks like TanStack Start or Next.js. But the implementations don't have to be the same, or even compute the same result. For example, the server can add extra filters to enforce permissions that the client query does not. Life of a Query When a query is invoked, it initially runs on the client, against the client-side datastore. Any matching data is returned immediately and the user sees instant results. In the background, the name and arguments for the query are sent to zero-cache. Zero-cache calls the queries endpoint on your server to get the ZQL for the query. Your server looks up its implementation of the query, invokes it, and returns the resulting ZQL expression to zero-cache. Zero-cache then runs this ZQL against the server-side data. The initial server result is sent back to the client and the client query updates in response. zero-cache receives updates from Postgres via logical replication. It updates affected queries and sends row changes back to the client, which updates the client query, and the user sees the changes. Defining Queries Basics Create a query using defineQuery. The only required argument is a QueryFn, which must return a ZQL expression: import {zql} from 'schema.ts' const allPostsQueryDef = defineQuery(() => zql.post) Arguments The QueryFn can take a single args parameter. To enable this, pass a validator to defineQuery: import {zql} from 'schema.ts' const postsByAuthor = defineQuery( z.object({authorID: z.string().optional()}), ({args: {authorID}}) => { let q = zql.post if (authorID !== undefined) { q = q.where('authorID', authorID) } return q } ) We use Zod in these examples, but you can use any validation library that implements Standard Schema. Zero queries run on both the client and on your server. In the server case, the parameters come from the client and are untrusted. The validator ensures the data passed to your query is of the expected type. Query Registries The result of defineQuery is a QueryDefinition. By itself this isn't super useful. You need to register it using defineQueries: export const queries = defineQueries({ posts: { all: allPostsQueryDef } }) Typically these are done together in one step: export const queries = defineQueries({ posts: { all: defineQuery(() => zql.post) } }) The result of defineQueries is called a QueryRegistry. Each field in the registry is a callable Query that you can use to read data: import {zero} from 'zero.ts' import {queries} from 'queries.ts' const allPosts = await zero.run(queries.posts.all()) Query Names Each Query has a queryName which is computed by defineQueries. This name is later sent to your server to identify the query to run: console.log(queries.posts.all.queryName) // \"posts.all\" Context Query parameters are supplied by the client application and passed to the server automatically by Zero. This makes them unsuitable for credentials, since the user could modify them. For this reason, Zero queries also support the concept of a context object. Access your context with the ctx parameter to your query: const myPostsQuery = defineQuery(({ctx: {userID}}) => { // User cannot control context.userID, so this safely // restricts the query to the user's own posts. return zql.post.where('authorID', userID) }) If you don't want to register your Context and Schema types globally, you can use defineQueryWithType and defineQueriesWithType: import { defineQueriesWithType, defineQueryWithType } from '@rocicorp/zero' import type {Schema} from 'schema.ts' import type {ZeroContext} from 'context.ts' const defineQuery = defineQueryWithType< Schema, ZeroContext >() const defineQueries = defineQueriesWithType() queries.ts By convention, all queries for an application are listed in a central queries.ts file. This allows them to be easily used on both the client and server: import {defineQueries, defineQuery} from '@rocicorp/zero' import {z} from 'zod' import {zql} from './schema.ts' export const queries = defineQueries({ posts: { get: defineQuery(z.string(), id => zql.post.where('id', id) ), byAuthor: defineQuery( z.object({ authorID: z.string(), includeDrafts: z.boolean().optional() }), ({args: {authorID, includeDrafts}}) => { let q = zql.post.where('authorID', authorID) if (!includeDrafts) { q = q.where('isDraft', false) } return q } ) } }) You can use as many levels of nesting as you want to organize your queries. As your application grows, you can move queries to different files to keep them organized: // posts.ts export const postQueries = { get: defineQuery(z.string(), id => zql.post.where('id', id) ) // ... } // users.ts export const userQueries = { byRole: defineQuery(z.string(), role => zql.user.where('role', role) ) // ... } // queries.ts import {postQueries} from './posts.ts' import {userQueries} from './users.ts' export const queries = defineQueries({ posts: postQueries, users: userQueries }) Because defineQueries establishes the full name for each query (i.e., posts.get, users.byRole), it should only be used once at the top level of your queries.ts file. Server Setup In order for queries to sync, you must provide an implementation of the query endpoint on your server. zero-cache calls this endpoint to resolve each query to ZQL that it can run. Registering the Endpoint Use ZERO_QUERY_URL to tell zero-cache where to find your query implementation: export ZERO_QUERY_URL=\"http://localhost:3000/api/zero/query\" # run zero-cache, e.g. `npx zero-cache-dev` Implementing the Endpoint You can use the handleQueryRequest and mustGetQuery functions to implement the endpoint. // src/routes/api/zero/query.ts import {createFileRoute} from '@tanstack/react-router' import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from 'queries.ts' import {schema} from 'schema.ts' export const Route = createFileRoute('/api/zero/query')({ server: { handlers: { POST: async ({request}) => { const result = await handleQueryRequest({ handler: (name, args) => { const query = mustGetQuery(queries, name) return query.fn({args}) }, schema, request, userID: null }) return Response.json(result) } } } })// app/api/zero/query/route.ts import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from 'queries.ts' import {schema} from 'schema.ts' export async function POST(request: Request) { const result = await handleQueryRequest({ handler: (name, args) => { const query = mustGetQuery(queries, name) return query.fn({args}) }, schema, request, userID: null }) return Response.json(result) }// src/routes/api/zero/query.ts import type {APIEvent} from '@solidjs/start/server' import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from 'queries.ts' import {schema} from 'schema.ts' export async function POST(event: APIEvent) { const result = await handleQueryRequest({ handler: (name, args) => { const query = mustGetQuery(queries, name) return query.fn({args}) }, schema, request: event.request, userID: null }) return Response.json(result) }// api/app.ts import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from 'queries.ts' import {schema} from 'schema.ts' app.post('/api/zero/query', async c => { const result = await handleQueryRequest({ handler: (name, args) => { const query = mustGetQuery(queries, name) return query.fn({args}) }, schema, request: c.req.raw, userID: null }) return c.json(result) }) handleQueryRequest accepts a standard Request and returns a JSON object which can be serialized and returned by your server framework of choice. mustGetQuery looks up the query in the registry and throws an error if not found. The query.fn function is your query implementation wrapped in the validator you provided. These examples have only public queries, so they do not pass a context. In authenticated apps, validate auth in the request, derive context from the session, and pass it to the query handler. See Authentication. Custom Query URL By default, Zero sends queries to the URL specified in the ZERO_QUERY_URL parameter in the zero-cache config. However you can customize this on a per-client basis. To do so, list multiple comma-separated URLs in ZERO_QUERY_URL: ZERO_QUERY_URL='https://api.example.com/query,https://api.staging.example.com/query' Then choose one of those URLs by passing it to queryURL on the Zero constructor: const zero = new Zero({ schema, queries, queryURL: 'https://api.staging.example.com/query' }) URL Patterns The strings listed in ZERO_QUERY_URL can also be URLPatterns: ZERO_QUERY_URL=\"https://mybranch-*.preview.myapp.com/query\" This queries URL will allow clients to choose URLs like: https://mybranch-aaa.preview.myapp.com/query ✅ https://mybranch-bbb.preview.myapp.com/query ✅ But rejects URLs like: https://preview.myapp.com/query ❌ (missing subdomain) https://malicious.com/query ❌ (different domain) https://mybranch-123.preview.myapp.com/query/extra ❌ (extra path) https://mybranch-123.preview.myapp.com/other ❌ (different path) Because URLPattern is a web standard, you can test them right in your browser: For more information, see the URLPattern docs. If you're configuring per-branch preview URLs (for example on Vercel), see Preview Deployments for the complete setup across both query and mutate endpoints. Running Queries Reactively The most common way to use queries is with the useQuery reactive hooks from the React or SolidJS bindings (or the equivalent low-level API): import {useQuery} from '@rocicorp/zero/react' import {queries} from 'zero/queries.ts' function App() { const [posts] = useQuery(queries.posts.get('user123')) return posts.map(post => (
{post.title}
)) }import {useQuery} from '@rocicorp/zero/solid' import {queries} from 'zero/queries.ts' function App() { const [posts] = useQuery(() => queries.posts.get('user123') ) return ( {post =>
{post.title}
} ) }import {queries} from 'zero/queries.ts' import {zero} from 'zero.ts' const postsView = zero.materialize( queries.posts.byAuthorID('user123') ) for (let post of postsView.data) { console.log(post.title) } // updates as the underlying data changes postsView.addListener(posts => { console.log('posts', posts) }) These functions allow you to automatically re-render UI when a query changes. Conditionally Sometimes the inputs needed to construct a query are not available on the first render. For example, auth state or a route param might still be loading after a page refresh. Both React and Solid support conditional queries by passing undefined until the query can be constructed: import {useQuery} from '@rocicorp/zero/react' import {queries} from 'zero/queries.ts' function Username({userID}: {userID: string | undefined}) { const [user] = useQuery( userID ? queries.users.getUser({ userID }) : undefined ) return user ?
{user.username}
: null }import {useQuery} from '@rocicorp/zero/solid' import {Show} from 'solid-js' import {queries} from 'zero/queries.ts' function Username(props: {userID: string | undefined}) { const [user] = useQuery(() => props.userID ? queries.users.getUser({ userID: props.userID }) : undefined ) return ( {user =>
{user().username}
} ) } Once You usually want to subscribe to a query in a reactive UI, but every so often you'll need to run a query just once. To do this, use zero.run(): const results = await zero.run( queries.issues.byPriority('high') ) By default, run() only returns results that are currently available on the client. That is, it returns the data that would be given for result.type === 'unknown'. If you want to wait for the server to return results, pass {type: 'complete'} to run: const results = await zero.run( queries.issues.byPriority('high'), {type: 'complete'} ) Pass {type: 'cached'} to also accept a result the server confirmed in an earlier session. It resolves as soon as the result is cached or complete, so it can return without waiting for the server: const results = await zero.run( queries.issues.byPriority('high'), {type: 'cached'} ) For Preloading Almost all Zero apps will want to preload some data in order to maximize the feel of instantaneous UI transitions. Because preload queries are often much larger than a screenful of UI, Zero provides a special zero.preload() method to avoid the overhead of materializing the result into JS objects: // Preload a large number of the inbox query results. zero.preload( queries.issues.inbox({ sort: 'created', sortDirection: 'desc', limit: 1000 }) ) preload() returns three things. cleanup ends the preload, and Zero keeps the rows for the query's TTL. complete is a promise that resolves once the server returns the result, and cached resolves once the result is cached or complete. Missing Data Because Zero returns local results immediately and server results asynchronously, displaying \"not found\" / 404 UI can be slightly tricky. If you just use a simple existence check, you will often see the 404 UI flicker while the server result loads: const [issue] = useQuery(queries.issues.get('some-id')) // ❌ This causes flickering of the UI if (!issue) { return
)} )const postsView = zero.materialize( queries.posts.byAuthorID('user123') ) postsView.addListener(posts => { // ❌ This is updated as data comes in console.log('posts', posts) }) To do this correctly, only display the \"not found\" UI when the result type is complete. This way the 404 page is slow but pages with data are still just as fast: const [issue, issueResult] = useQuery( queries.issues.get('some-id') ) if (!issue && issueResult.type === 'complete') { return
)const postsView = zero.materialize( queries.posts.byAuthorID('user123') ) postsView.addListener((posts, resultType) => { if (resultType === 'complete') { console.log('posts', posts) } }) Partial Data Zero immediately returns the data for a query it has on the client, then falls back to the server for any missing data. Sometimes it's useful to know the difference between these two types of results. To do so, use the result from useQuery: const [issues, issuesResult] = useQuery( queries.issues.inbox() ) if (issuesResult.type === 'complete') { console.log('All data is present') } else { console.log('Some data is missing') }const [issues, issuesResult] = useQuery(() => queries.issues.inbox() ) if (issuesResult().type === 'complete') { console.log('All data is present') } else { console.log('Some data is missing') }const view = zero.materialize(queries.issues.inbox()) view.addListener((issues, resultType) => { if (resultType === 'complete') { console.log('All data is present') } else { console.log('Some data is missing') } }) The possible values of result.type are: unknown: whatever data the client has right now, which may be incomplete. cached: the client stores a result that the server confirmed during a previous session. See Cached Results. complete: the server has returned the result during this session. error: the query failed. See Handling Errors. The complete value is currently only returned when Zero has received the server result. In the future, Zero will be able to return this result type when it knows that all possible data for this query is already available locally. Additionally, we plan to add a prefix result for when the data is known to be a prefix of the complete result. See Consistency for more information. Cached Results Along with the data it stores on the client (IndexedDB in the browser, SQLite on React Native), Zero remembers which query results the server has confirmed. When the app starts again and the client still has one of those results, the query starts out cached instead of unknown. It becomes complete once the server sends the result again. If Zero evicts the stored result first, the query drops back to unknown. A cached result was complete when the server sent it, but it may be stale by now. It's good for showing stored data, or an empty state, without waiting for the server, and that works offline too. If the data has to be current, wait for complete. For example, to show an empty inbox without waiting for the server: const [issues, issuesResult] = useQuery( queries.issues.inbox() ) const confirmed = issuesResult.type === 'cached' || issuesResult.type === 'complete' if (issues.length === 0 && confirmed) { return
} > {/* ... */} )const view = zero.materialize(queries.issues.inbox()) view.addListener((issues, resultType) => { if ( issues.length === 0 && (resultType === 'cached' || resultType === 'complete') ) { console.log('Inbox is empty') } }) zero.run() and zero.preload() can also return as soon as a result is cached: pass {type: 'cached'} to run(), or wait on the cached promise that preload() returns. Handling Errors If the queries endpoint throws an application or parse error, zero-cache will report it to the client using the type and error fields on the query details object: const [posts, postsResult] = useQuery( queries.posts.byAuthorID('user123') ) if (postsResult.type === 'error') { return (
)// Materialize a view of a query const postsView = queries.posts .byAuthorID('user123') .materialize() postsView.addListener((posts, resultType, error) => { if (resultType === 'error') { console.error('Error loading posts', error) } }) See Connection Status for how HTTP or network errors from the queries endpoint are handled. Granular Updates You can use the materialize() method to create a view that you can listen to for changes. However, this will only tell you when the view has changed and give you the complete new result. It won't tell you what changed. To know what changed, you can create your own custom View implementation: // Inside the View class // Instead of storing the change, we invoke some callback push(change: Change): void { switch (change.type) { case 'add': this.#onAdd?.(change) break case 'remove': this.#onRemove?.(change) break case 'edit': this.#onEdit?.(change) break case 'child': this.#onChild?.(change) break default: throw new Error(`Unknown change type: ${change['type']}`) } } For examples, see the View implementations in zero-vue or zero-solid. Query Caching Queries can be either active or cached. An active query is one that is currently being used by the application. Cached queries are not currently in use, but continue syncing in case they are needed again soon. Queries are deactivated according to how they were created: For useQuery(), the UI unmounts the component (which calls destroy() under the covers). For preload(), the UI calls cleanup() on the return value of preload(). For run(), queries are automatically deactivated immediately after the result is returned. For materialize() queries, the UI calls destroy() on the view. Additionally when a Zero instance closes, all active queries are automatically deactivated. This also happens when the containing page or script is unloaded. TTLs Each query has a ttl that controls how long it stays cached. If the user closes all tabs for your app, Zero stops running and the time that elapses doesn't count toward any TTLs. You do not need to account for such time when choosing a TTL – you only need to account for time your app is running without a query. TTL Defaults In most cases, the default TTL should work well: preload() queries default to ttl:'none', meaning they are not cached at all, and will stop syncing immediately when deactivated. But because preload() queries are typically registered at app startup and never shutdown, and because the ttl clock only ticks while Zero is running, this means that preload queries never get unregistered. Other queries have a default ttl of 5m (five minutes). Setting Different TTLs You can override the default TTL with the ttl parameter: const [user] = useQuery( queries.posts.byAuthorID('user123'), {ttl: '5m'} ) // preload() zero.preload(queries.posts.byAuthorID('user123'), { ttl: '5m' })const [user] = useQuery( () => queries.posts.byAuthorID('user123'), {ttl: '5m'} ) // preload() zero().preload(queries.posts.byAuthorID('user123'), { ttl: '5m' })// run() const user = await zero.run( queries.posts.byAuthorID('user123'), {ttl: '5m'} ) // materialize() const view = zero.materialize( queries.posts.byAuthorID('user123'), {ttl: '5m'} ) // preload() zero.preload(queries.posts.byAuthorID('user123'), { ttl: '5m' }) TTLs up to 10m (ten minutes) are currently supported. The following formats are allowed: Why Zero TTLs are Short Zero queries are not free. Just as in any database, queries consume resources on both the client and server. Memory is used to keep metadata about the query, and disk storage is used to keep the query's current state. We do drop this state after we haven't heard from a client for awhile, but this is only a partial improvement. If the client returns, we have to re-run the query to get the latest data. This means that we do not actually want to keep queries active unless there is a good chance they will be needed again soon. The default Zero TTL values might initially seem too short, but they are designed to work well with the way Zero's TTL clock works and strike a good balance between keeping queries alive long enough to be useful, while not keeping them alive so long that they consume resources unnecessarily. Local-Only Queries It can sometimes be useful to run queries only on the client. For example, to implement typeahead search, it really doesn't make sense to register a query with the server for every single keystroke. Zero doesn't yet have a way to run named queries local-only, but you can run ZQL expressions locally by passing them anywhere a query is supported. For example, to subscribe to a local-only query: // Queries the already synced data for issues, // without syncing more data. const [issues] = useQuery( zql.issue.orderBy('created', 'desc').limit(10) )// Queries the already synced data for issues, // without syncing more data. const [issues] = useQuery(() => zql.issue.orderBy('created', 'desc').limit(10) )// Queries the already synced data for issues, // without syncing more data. const view = z.materialize( zql.issue.orderBy('created', 'desc').limit(10) ) view.addListener(issues => { console.log('issues', issues) }) Custom Server Implementation It is possible to implement the ZERO_QUERY_URL endpoint without using Zero's TypeScript libraries, or even in a different language entirely. The endpoint receives a POST request with a JSON body of the form: type QueriesRequestBody = { id: string name: string args: readonly ReadonlyJSONValue[] }[] And responds with: type QueriesResponseBody = ( | { id: string name: string // See https://github.com/rocicorp/mono/blob/main/packages/zero-protocol/src/ast.ts ast: AST } | { error: 'app' id: string name: string details: ReadonlyJSONValue } | { error: 'zero' id: string name: string details: ReadonlyJSONValue } | { error: 'http' id: string name: string status: number details: ReadonlyJSONValue } )[] Consistency Zero always syncs a consistent partial replica of the backend database to the client. This avoids many common consistency issues that come up in classic web applications. But there are still some consistency issues to be aware of when using Zero. For example, imagine that you have a bug database w/ 10k issues. You preload the first 1k issues sorted by created. The user then does a query of issues assigned to themselves, sorted by created. Among the 1k issues that were preloaded imagine 100 are found that match the query. Since the data we preloaded is in the same order as this query, we are guaranteed that any local results found will be a prefix of the server results. The UX that result is nice: the user will see initial results to the query instantly. If more results are found server-side, those results are guaranteed to sort below the local results. There's no shuffling of results when the server response comes in. Now imagine that the user switches the sort to ‘sort by modified’. This new query will run locally, and will again find some local matches. But it is now unlikely that the local results found are a prefix of the server results. When the server result comes in, the user will probably see the results shuffle around. To avoid this annoying effect, what you should do in this example is also preload the first 1k issues sorted by modified desc. In general for any query shape you intend to do, you should preload the first n results for that query shape with no filters, in each sort you intend to use. Zero syncs the union of all active queries' results. You don't have to worry about syncing many sorts of the same query when it's likely the results will overlap heavily. In the future, we will be implementing a consistency model that fixes these issues automatically. We will prevent Zero from returning local data when that data is not known to be a prefix of the server result. Once the consistency model is implemented, preloading can be thought of as purely a performance thing, and not required to avoid unsightly flickering.",
"headings": [
{
"text": "Architecture",
@@ -2742,6 +2812,10 @@
"text": "Partial Data",
"id": "partial-data"
},
+ {
+ "text": "Cached Results",
+ "id": "cached-results"
+ },
{
"text": "Handling Errors",
"id": "handling-errors"
@@ -2786,7 +2860,7 @@
"kind": "page"
},
{
- "id": "252-queries#architecture",
+ "id": "258-queries#architecture",
"title": "Queries",
"searchTitle": "Architecture",
"sectionTitle": "Architecture",
@@ -2796,7 +2870,7 @@
"kind": "section"
},
{
- "id": "253-queries#life-of-a-query",
+ "id": "259-queries#life-of-a-query",
"title": "Queries",
"searchTitle": "Life of a Query",
"sectionTitle": "Life of a Query",
@@ -2806,7 +2880,7 @@
"kind": "section"
},
{
- "id": "254-queries#defining-queries",
+ "id": "260-queries#defining-queries",
"title": "Queries",
"searchTitle": "Defining Queries",
"sectionTitle": "Defining Queries",
@@ -2816,7 +2890,7 @@
"kind": "section"
},
{
- "id": "255-queries#basics",
+ "id": "261-queries#basics",
"title": "Queries",
"searchTitle": "Basics",
"sectionTitle": "Basics",
@@ -2826,7 +2900,7 @@
"kind": "section"
},
{
- "id": "256-queries#arguments",
+ "id": "262-queries#arguments",
"title": "Queries",
"searchTitle": "Arguments",
"sectionTitle": "Arguments",
@@ -2836,7 +2910,7 @@
"kind": "section"
},
{
- "id": "257-queries#query-registries",
+ "id": "263-queries#query-registries",
"title": "Queries",
"searchTitle": "Query Registries",
"sectionTitle": "Query Registries",
@@ -2846,7 +2920,7 @@
"kind": "section"
},
{
- "id": "258-queries#query-names",
+ "id": "264-queries#query-names",
"title": "Queries",
"searchTitle": "Query Names",
"sectionTitle": "Query Names",
@@ -2856,7 +2930,7 @@
"kind": "section"
},
{
- "id": "259-queries#context",
+ "id": "265-queries#context",
"title": "Queries",
"searchTitle": "Context",
"sectionTitle": "Context",
@@ -2866,7 +2940,7 @@
"kind": "section"
},
{
- "id": "260-queries#queriests",
+ "id": "266-queries#queriests",
"title": "Queries",
"searchTitle": "queries.ts",
"sectionTitle": "queries.ts",
@@ -2876,7 +2950,7 @@
"kind": "section"
},
{
- "id": "261-queries#server-setup",
+ "id": "267-queries#server-setup",
"title": "Queries",
"searchTitle": "Server Setup",
"sectionTitle": "Server Setup",
@@ -2886,7 +2960,7 @@
"kind": "section"
},
{
- "id": "262-queries#registering-the-endpoint",
+ "id": "268-queries#registering-the-endpoint",
"title": "Queries",
"searchTitle": "Registering the Endpoint",
"sectionTitle": "Registering the Endpoint",
@@ -2896,7 +2970,7 @@
"kind": "section"
},
{
- "id": "263-queries#implementing-the-endpoint",
+ "id": "269-queries#implementing-the-endpoint",
"title": "Queries",
"searchTitle": "Implementing the Endpoint",
"sectionTitle": "Implementing the Endpoint",
@@ -2906,7 +2980,7 @@
"kind": "section"
},
{
- "id": "264-queries#custom-query-url",
+ "id": "270-queries#custom-query-url",
"title": "Queries",
"searchTitle": "Custom Query URL",
"sectionTitle": "Custom Query URL",
@@ -2916,7 +2990,7 @@
"kind": "section"
},
{
- "id": "265-queries#url-patterns",
+ "id": "271-queries#url-patterns",
"title": "Queries",
"searchTitle": "URL Patterns",
"sectionTitle": "URL Patterns",
@@ -2926,17 +3000,17 @@
"kind": "section"
},
{
- "id": "266-queries#running-queries",
+ "id": "272-queries#running-queries",
"title": "Queries",
"searchTitle": "Running Queries",
"sectionTitle": "Running Queries",
"sectionId": "running-queries",
"url": "/docs/queries",
- "content": "Reactively The most common way to use queries is with the useQuery reactive hooks from the React or SolidJS bindings (or the equivalent low-level API): import {useQuery} from '@rocicorp/zero/react' import {queries} from 'zero/queries.ts' function App() { const [posts] = useQuery(queries.posts.get('user123')) return posts.map(post => (
{post.title}
)) }import {useQuery} from '@rocicorp/zero/solid' import {queries} from 'zero/queries.ts' function App() { const [posts] = useQuery(() => queries.posts.get('user123') ) return ( {post =>
{post.title}
} ) }import {queries} from 'zero/queries.ts' import {zero} from 'zero.ts' const postsView = zero.materialize( queries.posts.byAuthorID('user123') ) for (let post of postsView.data) { console.log(post.title) } // updates as the underlying data changes postsView.addListener(posts => { console.log('posts', posts) }) These functions allow you to automatically re-render UI when a query changes. Conditionally Sometimes the inputs needed to construct a query are not available on the first render. For example, auth state or a route param might still be loading after a page refresh. Both React and Solid support conditional queries by passing undefined until the query can be constructed: import {useQuery} from '@rocicorp/zero/react' import {queries} from 'zero/queries.ts' function Username({userID}: {userID: string | undefined}) { const [user] = useQuery( userID ? queries.users.getUser({ userID }) : undefined ) return user ?
{user.username}
: null }import {useQuery} from '@rocicorp/zero/solid' import {Show} from 'solid-js' import {queries} from 'zero/queries.ts' function Username(props: {userID: string | undefined}) { const [user] = useQuery(() => props.userID ? queries.users.getUser({ userID: props.userID }) : undefined ) return ( {user =>
{user().username}
} ) } Once You usually want to subscribe to a query in a reactive UI, but every so often you'll need to run a query just once. To do this, use zero.run(): const results = await zero.run( queries.issues.byPriority('high') ) By default, run() only returns results that are currently available on the client. That is, it returns the data that would be given for result.type === 'unknown'. If you want to wait for the server to return results, pass {type: 'complete'} to run: const results = await zero.run( queries.issues.byPriority('high'), {type: 'complete'} ) For Preloading Almost all Zero apps will want to preload some data in order to maximize the feel of instantaneous UI transitions. Because preload queries are often much larger than a screenful of UI, Zero provides a special zero.preload() method to avoid the overhead of materializing the result into JS objects: // Preload a large number of the inbox query results. zero.preload( queries.issues.inbox({ sort: 'created', sortDirection: 'desc', limit: 1000 }) )",
+ "content": "Reactively The most common way to use queries is with the useQuery reactive hooks from the React or SolidJS bindings (or the equivalent low-level API): import {useQuery} from '@rocicorp/zero/react' import {queries} from 'zero/queries.ts' function App() { const [posts] = useQuery(queries.posts.get('user123')) return posts.map(post => (
{post.title}
)) }import {useQuery} from '@rocicorp/zero/solid' import {queries} from 'zero/queries.ts' function App() { const [posts] = useQuery(() => queries.posts.get('user123') ) return ( {post =>
{post.title}
} ) }import {queries} from 'zero/queries.ts' import {zero} from 'zero.ts' const postsView = zero.materialize( queries.posts.byAuthorID('user123') ) for (let post of postsView.data) { console.log(post.title) } // updates as the underlying data changes postsView.addListener(posts => { console.log('posts', posts) }) These functions allow you to automatically re-render UI when a query changes. Conditionally Sometimes the inputs needed to construct a query are not available on the first render. For example, auth state or a route param might still be loading after a page refresh. Both React and Solid support conditional queries by passing undefined until the query can be constructed: import {useQuery} from '@rocicorp/zero/react' import {queries} from 'zero/queries.ts' function Username({userID}: {userID: string | undefined}) { const [user] = useQuery( userID ? queries.users.getUser({ userID }) : undefined ) return user ?
{user.username}
: null }import {useQuery} from '@rocicorp/zero/solid' import {Show} from 'solid-js' import {queries} from 'zero/queries.ts' function Username(props: {userID: string | undefined}) { const [user] = useQuery(() => props.userID ? queries.users.getUser({ userID: props.userID }) : undefined ) return ( {user =>
{user().username}
} ) } Once You usually want to subscribe to a query in a reactive UI, but every so often you'll need to run a query just once. To do this, use zero.run(): const results = await zero.run( queries.issues.byPriority('high') ) By default, run() only returns results that are currently available on the client. That is, it returns the data that would be given for result.type === 'unknown'. If you want to wait for the server to return results, pass {type: 'complete'} to run: const results = await zero.run( queries.issues.byPriority('high'), {type: 'complete'} ) Pass {type: 'cached'} to also accept a result the server confirmed in an earlier session. It resolves as soon as the result is cached or complete, so it can return without waiting for the server: const results = await zero.run( queries.issues.byPriority('high'), {type: 'cached'} ) For Preloading Almost all Zero apps will want to preload some data in order to maximize the feel of instantaneous UI transitions. Because preload queries are often much larger than a screenful of UI, Zero provides a special zero.preload() method to avoid the overhead of materializing the result into JS objects: // Preload a large number of the inbox query results. zero.preload( queries.issues.inbox({ sort: 'created', sortDirection: 'desc', limit: 1000 }) ) preload() returns three things. cleanup ends the preload, and Zero keeps the rows for the query's TTL. complete is a promise that resolves once the server returns the result, and cached resolves once the result is cached or complete.",
"kind": "section"
},
{
- "id": "267-queries#reactively",
+ "id": "273-queries#reactively",
"title": "Queries",
"searchTitle": "Reactively",
"sectionTitle": "Reactively",
@@ -2946,7 +3020,7 @@
"kind": "section"
},
{
- "id": "268-queries#conditionally",
+ "id": "274-queries#conditionally",
"title": "Queries",
"searchTitle": "Conditionally",
"sectionTitle": "Conditionally",
@@ -2956,27 +3030,27 @@
"kind": "section"
},
{
- "id": "269-queries#once",
+ "id": "275-queries#once",
"title": "Queries",
"searchTitle": "Once",
"sectionTitle": "Once",
"sectionId": "once",
"url": "/docs/queries",
- "content": "You usually want to subscribe to a query in a reactive UI, but every so often you'll need to run a query just once. To do this, use zero.run(): const results = await zero.run( queries.issues.byPriority('high') ) By default, run() only returns results that are currently available on the client. That is, it returns the data that would be given for result.type === 'unknown'. If you want to wait for the server to return results, pass {type: 'complete'} to run: const results = await zero.run( queries.issues.byPriority('high'), {type: 'complete'} )",
+ "content": "You usually want to subscribe to a query in a reactive UI, but every so often you'll need to run a query just once. To do this, use zero.run(): const results = await zero.run( queries.issues.byPriority('high') ) By default, run() only returns results that are currently available on the client. That is, it returns the data that would be given for result.type === 'unknown'. If you want to wait for the server to return results, pass {type: 'complete'} to run: const results = await zero.run( queries.issues.byPriority('high'), {type: 'complete'} ) Pass {type: 'cached'} to also accept a result the server confirmed in an earlier session. It resolves as soon as the result is cached or complete, so it can return without waiting for the server: const results = await zero.run( queries.issues.byPriority('high'), {type: 'cached'} )",
"kind": "section"
},
{
- "id": "270-queries#for-preloading",
+ "id": "276-queries#for-preloading",
"title": "Queries",
"searchTitle": "For Preloading",
"sectionTitle": "For Preloading",
"sectionId": "for-preloading",
"url": "/docs/queries",
- "content": "Almost all Zero apps will want to preload some data in order to maximize the feel of instantaneous UI transitions. Because preload queries are often much larger than a screenful of UI, Zero provides a special zero.preload() method to avoid the overhead of materializing the result into JS objects: // Preload a large number of the inbox query results. zero.preload( queries.issues.inbox({ sort: 'created', sortDirection: 'desc', limit: 1000 }) )",
+ "content": "Almost all Zero apps will want to preload some data in order to maximize the feel of instantaneous UI transitions. Because preload queries are often much larger than a screenful of UI, Zero provides a special zero.preload() method to avoid the overhead of materializing the result into JS objects: // Preload a large number of the inbox query results. zero.preload( queries.issues.inbox({ sort: 'created', sortDirection: 'desc', limit: 1000 }) ) preload() returns three things. cleanup ends the preload, and Zero keeps the rows for the query's TTL. complete is a promise that resolves once the server returns the result, and cached resolves once the result is cached or complete.",
"kind": "section"
},
{
- "id": "271-queries#missing-data",
+ "id": "277-queries#missing-data",
"title": "Queries",
"searchTitle": "Missing Data",
"sectionTitle": "Missing Data",
@@ -2986,17 +3060,27 @@
"kind": "section"
},
{
- "id": "272-queries#partial-data",
+ "id": "278-queries#partial-data",
"title": "Queries",
"searchTitle": "Partial Data",
"sectionTitle": "Partial Data",
"sectionId": "partial-data",
"url": "/docs/queries",
- "content": "Zero immediately returns the data for a query it has on the client, then falls back to the server for any missing data. Sometimes it's useful to know the difference between these two types of results. To do so, use the result from useQuery: const [issues, issuesResult] = useQuery( queries.issues.inbox() ) if (issuesResult.type === 'complete') { console.log('All data is present') } else { console.log('Some data is missing') }const [issues, issuesResult] = useQuery(() => queries.issues.inbox() ) if (issuesResult().type === 'complete') { console.log('All data is present') } else { console.log('Some data is missing') }const view = zero.materialize(queries.issues.inbox()) view.addListener((issues, resultType) => { if (resultType === 'complete') { console.log('All data is present') } else { console.log('Some data is missing') } }) The possible values of result.type are currently complete and unknown. The complete value is currently only returned when Zero has received the server result. In the future, Zero will be able to return this result type when it knows that all possible data for this query is already available locally. Additionally, we plan to add a prefix result for when the data is known to be a prefix of the complete result. See Consistency for more information.",
+ "content": "Zero immediately returns the data for a query it has on the client, then falls back to the server for any missing data. Sometimes it's useful to know the difference between these two types of results. To do so, use the result from useQuery: const [issues, issuesResult] = useQuery( queries.issues.inbox() ) if (issuesResult.type === 'complete') { console.log('All data is present') } else { console.log('Some data is missing') }const [issues, issuesResult] = useQuery(() => queries.issues.inbox() ) if (issuesResult().type === 'complete') { console.log('All data is present') } else { console.log('Some data is missing') }const view = zero.materialize(queries.issues.inbox()) view.addListener((issues, resultType) => { if (resultType === 'complete') { console.log('All data is present') } else { console.log('Some data is missing') } }) The possible values of result.type are: unknown: whatever data the client has right now, which may be incomplete. cached: the client stores a result that the server confirmed during a previous session. See Cached Results. complete: the server has returned the result during this session. error: the query failed. See Handling Errors. The complete value is currently only returned when Zero has received the server result. In the future, Zero will be able to return this result type when it knows that all possible data for this query is already available locally. Additionally, we plan to add a prefix result for when the data is known to be a prefix of the complete result. See Consistency for more information. Cached Results Along with the data it stores on the client (IndexedDB in the browser, SQLite on React Native), Zero remembers which query results the server has confirmed. When the app starts again and the client still has one of those results, the query starts out cached instead of unknown. It becomes complete once the server sends the result again. If Zero evicts the stored result first, the query drops back to unknown. A cached result was complete when the server sent it, but it may be stale by now. It's good for showing stored data, or an empty state, without waiting for the server, and that works offline too. If the data has to be current, wait for complete. For example, to show an empty inbox without waiting for the server: const [issues, issuesResult] = useQuery( queries.issues.inbox() ) const confirmed = issuesResult.type === 'cached' || issuesResult.type === 'complete' if (issues.length === 0 && confirmed) { return
} > {/* ... */} )const view = zero.materialize(queries.issues.inbox()) view.addListener((issues, resultType) => { if ( issues.length === 0 && (resultType === 'cached' || resultType === 'complete') ) { console.log('Inbox is empty') } }) zero.run() and zero.preload() can also return as soon as a result is cached: pass {type: 'cached'} to run(), or wait on the cached promise that preload() returns.",
"kind": "section"
},
{
- "id": "273-queries#handling-errors",
+ "id": "279-queries#cached-results",
+ "title": "Queries",
+ "searchTitle": "Cached Results",
+ "sectionTitle": "Cached Results",
+ "sectionId": "cached-results",
+ "url": "/docs/queries",
+ "content": "Along with the data it stores on the client (IndexedDB in the browser, SQLite on React Native), Zero remembers which query results the server has confirmed. When the app starts again and the client still has one of those results, the query starts out cached instead of unknown. It becomes complete once the server sends the result again. If Zero evicts the stored result first, the query drops back to unknown. A cached result was complete when the server sent it, but it may be stale by now. It's good for showing stored data, or an empty state, without waiting for the server, and that works offline too. If the data has to be current, wait for complete. For example, to show an empty inbox without waiting for the server: const [issues, issuesResult] = useQuery( queries.issues.inbox() ) const confirmed = issuesResult.type === 'cached' || issuesResult.type === 'complete' if (issues.length === 0 && confirmed) { return
} > {/* ... */} )const view = zero.materialize(queries.issues.inbox()) view.addListener((issues, resultType) => { if ( issues.length === 0 && (resultType === 'cached' || resultType === 'complete') ) { console.log('Inbox is empty') } }) zero.run() and zero.preload() can also return as soon as a result is cached: pass {type: 'cached'} to run(), or wait on the cached promise that preload() returns.",
+ "kind": "section"
+ },
+ {
+ "id": "280-queries#handling-errors",
"title": "Queries",
"searchTitle": "Handling Errors",
"sectionTitle": "Handling Errors",
@@ -3006,7 +3090,7 @@
"kind": "section"
},
{
- "id": "274-queries#granular-updates",
+ "id": "281-queries#granular-updates",
"title": "Queries",
"searchTitle": "Granular Updates",
"sectionTitle": "Granular Updates",
@@ -3016,7 +3100,7 @@
"kind": "section"
},
{
- "id": "275-queries#query-caching",
+ "id": "282-queries#query-caching",
"title": "Queries",
"searchTitle": "Query Caching",
"sectionTitle": "Query Caching",
@@ -3026,7 +3110,7 @@
"kind": "section"
},
{
- "id": "276-queries#ttls",
+ "id": "283-queries#ttls",
"title": "Queries",
"searchTitle": "TTLs",
"sectionTitle": "TTLs",
@@ -3036,7 +3120,7 @@
"kind": "section"
},
{
- "id": "277-queries#ttl-defaults",
+ "id": "284-queries#ttl-defaults",
"title": "Queries",
"searchTitle": "TTL Defaults",
"sectionTitle": "TTL Defaults",
@@ -3046,7 +3130,7 @@
"kind": "section"
},
{
- "id": "278-queries#setting-different-ttls",
+ "id": "285-queries#setting-different-ttls",
"title": "Queries",
"searchTitle": "Setting Different TTLs",
"sectionTitle": "Setting Different TTLs",
@@ -3056,7 +3140,7 @@
"kind": "section"
},
{
- "id": "279-queries#why-zero-ttls-are-short",
+ "id": "286-queries#why-zero-ttls-are-short",
"title": "Queries",
"searchTitle": "Why Zero TTLs are Short",
"sectionTitle": "Why Zero TTLs are Short",
@@ -3066,7 +3150,7 @@
"kind": "section"
},
{
- "id": "280-queries#local-only-queries",
+ "id": "287-queries#local-only-queries",
"title": "Queries",
"searchTitle": "Local-Only Queries",
"sectionTitle": "Local-Only Queries",
@@ -3076,7 +3160,7 @@
"kind": "section"
},
{
- "id": "281-queries#custom-server-implementation",
+ "id": "288-queries#custom-server-implementation",
"title": "Queries",
"searchTitle": "Custom Server Implementation",
"sectionTitle": "Custom Server Implementation",
@@ -3086,7 +3170,7 @@
"kind": "section"
},
{
- "id": "282-queries#consistency",
+ "id": "289-queries#consistency",
"title": "Queries",
"searchTitle": "Consistency",
"sectionTitle": "Consistency",
@@ -3118,7 +3202,7 @@
"kind": "page"
},
{
- "id": "283-quickstart#hello-zero-solid",
+ "id": "290-quickstart#hello-zero-solid",
"title": "Quickstart",
"searchTitle": "hello-zero-solid",
"sectionTitle": "hello-zero-solid",
@@ -3128,7 +3212,7 @@
"kind": "section"
},
{
- "id": "284-quickstart#hello-zero-cf",
+ "id": "291-quickstart#hello-zero-cf",
"title": "Quickstart",
"searchTitle": "hello-zero-cf",
"sectionTitle": "hello-zero-cf",
@@ -3138,7 +3222,7 @@
"kind": "section"
},
{
- "id": "285-quickstart#hello-zero",
+ "id": "292-quickstart#hello-zero",
"title": "Quickstart",
"searchTitle": "hello-zero",
"sectionTitle": "hello-zero",
@@ -3152,7 +3236,7 @@
"title": "React Native",
"searchTitle": "React Native",
"url": "/docs/react-native",
- "content": "Zero has built-in support for React Native and Expo. Usage is identical to React on the web, except you must provide a kvStore implementation. Choose the storage adapter you prefer: import {ZeroProvider} from '@rocicorp/zero/react' import {expoSQLiteStoreProvider} from '@rocicorp/zero/expo-sqlite' export function RootLayout() { return ( ) }import {ZeroProvider} from '@rocicorp/zero/react' import {opSQLiteStoreProvider} from '@rocicorp/zero/op-sqlite' export default function RootLayout() { return ( ) } For a complete example, see zslack. op-sqlite is much faster than expo-sqlite but does not work with Expo Go. However, it is supported with expo prebuild and development builds.",
+ "content": "Zero has built-in support for React Native and Expo. Usage is identical to React on the web, except you must provide a kvStore implementation. Choose the storage adapter you prefer: import {ZeroProvider} from '@rocicorp/zero/react' import {expoSQLiteStoreProvider} from '@rocicorp/zero/expo-sqlite' export function RootLayout() { return ( ) }import {ZeroProvider} from '@rocicorp/zero/react' import {opSQLiteStoreProvider} from '@rocicorp/zero/op-sqlite' export default function RootLayout() { return ( ) } For a complete example, see zslack. To handle a full disk or a database that fails to open, see Storage Failures. op-sqlite is much faster than expo-sqlite but does not work with Expo Go. However, it is supported with expo prebuild and development builds.",
"headings": [],
"kind": "page"
},
@@ -3161,7 +3245,7 @@
"title": "React",
"searchTitle": "React",
"url": "/docs/react",
- "content": "Zero has built-in support for React. Here's what basic usage looks like. Setup Use the ZeroProvider component to setup Zero. It takes care of creating and destroying Zero instances reactively: import {createRoot} from 'react-dom/client' import {ZeroProvider} from '@rocicorp/zero/react' import {useSession} from 'my-session-provider' import App from './App.tsx' import {schema} from 'schema.ts' import {mutators} from 'mutators.ts' const cacheURL = import.meta.env.VITE_PUBLIC_ZERO_CACHE_URL! export default function Root() { const session = useSession() const userID = session?.userID const auth = session?.accessToken const context = userID ? {userID} : undefined return ( ) } If you use token auth, pass it with auth={session?.jwt}. If you use cookie auth, omit the parameter entirely. When auth changes from one string to another, ZeroProvider refreshes auth in place with zero.connection.connect({auth}). When auth is added or removed, or userID changes, it recreates the Zero instance. When the user is logged out, omit the userID entirely. You can also pass a Zero instance to the ZeroProvider if you want to control the lifecycle of the Zero instance yourself: // ZeroProvider just sets up the context, it doesn't manage // the lifecycle of the Zero instance. When you pass zero={zero}, ZeroProvider only provides React Context. It does not manage auth updates for that instance, so if you are using token auth, call zero.connection.connect({auth: newToken}) yourself or recreate the instance when the user changes. Usage Use useQuery to run queries: import {useQuery} from '@rocicorp/zero/react' import {queries} from 'queries.ts' function Posts() { const [posts] = useQuery( queries.posts.byStatus({status: 'draft'}) ) return ( <> {posts.map(p => (
{p.title} ({p.comments.length} comments)
))} > ) } For conditional queries, such as queries that depend on auth state or route params that may not be loaded yet, see Conditional Queries. Use useZero to get access to the Zero instance, for example to run mutators: import {useZero} from '@rocicorp/zero/react' import {mutators} from 'mutators.ts' function CompleteButton({issueID}: {issueID: string}) { const zero = useZero() const onClick = () => { zero.mutate(mutators.issues.complete({id: issueID})) } return } Suspense The useSuspenseQuery hook is exactly like useQuery, except it supports React Suspense. const [issues] = useSuspenseQuery(issueQuery, { suspendUntil: 'complete' // 'partial' or 'complete' }) Use the suspendUntil parameter to control how long to suspend for. The value complete suspends until authoritative results from the server are received. The partial value suspends until any non-empty data is received, or for a empty result that is complete. Examples See the sample directory for more complete React examples.",
+ "content": "Zero has built-in support for React. Here's what basic usage looks like. Setup Use the ZeroProvider component to setup Zero. It takes care of creating and destroying Zero instances reactively: import {createRoot} from 'react-dom/client' import {ZeroProvider} from '@rocicorp/zero/react' import {useSession} from 'my-session-provider' import App from './App.tsx' import {schema} from 'schema.ts' import {mutators} from 'mutators.ts' const cacheURL = import.meta.env.VITE_PUBLIC_ZERO_CACHE_URL! export default function Root() { const session = useSession() const userID = session?.userID const auth = session?.accessToken const context = userID ? {userID} : undefined return ( ) } If you use token auth, pass it with auth={session?.jwt}. If you use cookie auth, omit the parameter entirely. When auth changes from one string to another, ZeroProvider refreshes auth in place with zero.connection.connect({auth}). When auth is added or removed, or userID changes, it recreates the Zero instance. When the user is logged out, omit the userID entirely. You can also pass a Zero instance to the ZeroProvider if you want to control the lifecycle of the Zero instance yourself: // ZeroProvider just sets up the context, it doesn't manage // the lifecycle of the Zero instance. When you pass zero={zero}, ZeroProvider only provides React Context. It does not manage auth updates for that instance, so if you are using token auth, call zero.connection.connect({auth: newToken}) yourself or recreate the instance when the user changes. Usage Use useQuery to run queries: import {useQuery} from '@rocicorp/zero/react' import {queries} from 'queries.ts' function Posts() { const [posts] = useQuery( queries.posts.byStatus({status: 'draft'}) ) return ( <> {posts.map(p => (
{p.title} ({p.comments.length} comments)
))} > ) } For conditional queries, such as queries that depend on auth state or route params that may not be loaded yet, see Conditional Queries. Use useZero to get access to the Zero instance, for example to run mutators: import {useZero} from '@rocicorp/zero/react' import {mutators} from 'mutators.ts' function CompleteButton({issueID}: {issueID: string}) { const zero = useZero() const onClick = () => { zero.mutate(mutators.issues.complete({id: issueID})) } return } Suspense The useSuspenseQuery hook is exactly like useQuery, except it supports React Suspense. const [issues] = useSuspenseQuery(issueQuery, { suspendUntil: 'complete' // 'partial' or 'complete' }) Use the suspendUntil parameter to control how long to suspend for. The value complete suspends until authoritative results from the server are received. The partial value suspends until any non-empty data is received, or for a empty result that is complete. Query Identity You don't need to memoize queries. Build them inline, where you use them, and pass them straight to useQuery. Zero caches the queries you build with ZQL. Build the same query again and you usually get back the same Query object, even when it has related() or whereExists() callbacks written inline. So rebuilding a query on every render is cheap. You can also use a query as a dependency of useEffect or useMemo, or pass it as a prop to a component wrapped in React.memo: import {useEffect} from 'react' import {useQuery} from '@rocicorp/zero/react' import {zql} from 'schema.ts' function IssueSearch({text}: {text: string}) { // Rebuilt on every render. No useMemo needed. const query = zql.issue .where('title', 'ILIKE', `%${text}%`) .orderBy('created', 'desc') const [issues] = useQuery(query) // Scroll back to the top when the search changes. useEffect(() => { window.scrollTo(0, 0) }, [query]) return issues.map(issue => (
{issue.title}
)) } This example passes ZQL to useQuery directly, so it runs only on the client. Calls to registered queries work a little differently. Each call like queries.posts.byStatus({status}) returns a new object. You can still pass it inline: useQuery sees that it's the same query with the same arguments and reuses its previous work. But don't use the returned object as a dependency. Use its arguments instead. Identity follows the order you build a query in. zql.issue.where('status', 'open').where('priority', 'high') and zql.issue.where('priority', 'high').where('status', 'open') return the same rows but can be different objects. A different value, such as another ID in where(), always gives a different object. Zero doesn't guarantee identity, though. It can hand back a new object for a query it has built before. When that happens, an effect or memo that depends on the query runs again, so make sure that's safe. Examples See the sample directory for more complete React examples.",
"headings": [
{
"text": "Setup",
@@ -3175,6 +3259,10 @@
"text": "Suspense",
"id": "suspense"
},
+ {
+ "text": "Query Identity",
+ "id": "query-identity"
+ },
{
"text": "Examples",
"id": "examples"
@@ -3183,7 +3271,7 @@
"kind": "page"
},
{
- "id": "286-react#setup",
+ "id": "293-react#setup",
"title": "React",
"searchTitle": "Setup",
"sectionTitle": "Setup",
@@ -3193,7 +3281,7 @@
"kind": "section"
},
{
- "id": "287-react#usage",
+ "id": "294-react#usage",
"title": "React",
"searchTitle": "Usage",
"sectionTitle": "Usage",
@@ -3203,7 +3291,7 @@
"kind": "section"
},
{
- "id": "288-react#suspense",
+ "id": "295-react#suspense",
"title": "React",
"searchTitle": "Suspense",
"sectionTitle": "Suspense",
@@ -3213,7 +3301,17 @@
"kind": "section"
},
{
- "id": "289-react#examples",
+ "id": "296-react#query-identity",
+ "title": "React",
+ "searchTitle": "Query Identity",
+ "sectionTitle": "Query Identity",
+ "sectionId": "query-identity",
+ "url": "/docs/react",
+ "content": "You don't need to memoize queries. Build them inline, where you use them, and pass them straight to useQuery. Zero caches the queries you build with ZQL. Build the same query again and you usually get back the same Query object, even when it has related() or whereExists() callbacks written inline. So rebuilding a query on every render is cheap. You can also use a query as a dependency of useEffect or useMemo, or pass it as a prop to a component wrapped in React.memo: import {useEffect} from 'react' import {useQuery} from '@rocicorp/zero/react' import {zql} from 'schema.ts' function IssueSearch({text}: {text: string}) { // Rebuilt on every render. No useMemo needed. const query = zql.issue .where('title', 'ILIKE', `%${text}%`) .orderBy('created', 'desc') const [issues] = useQuery(query) // Scroll back to the top when the search changes. useEffect(() => { window.scrollTo(0, 0) }, [query]) return issues.map(issue => (
{issue.title}
)) } This example passes ZQL to useQuery directly, so it runs only on the client. Calls to registered queries work a little differently. Each call like queries.posts.byStatus({status}) returns a new object. You can still pass it inline: useQuery sees that it's the same query with the same arguments and reuses its previous work. But don't use the returned object as a dependency. Use its arguments instead. Identity follows the order you build a query in. zql.issue.where('status', 'open').where('priority', 'high') and zql.issue.where('priority', 'high').where('status', 'open') return the same rows but can be different objects. A different value, such as another ID in where(), always gives a different object. Zero doesn't guarantee identity, though. It can hand back a new object for a query it has built before. When that happens, an effect or memo that depends on the query runs again, so make sure that's safe.",
+ "kind": "section"
+ },
+ {
+ "id": "297-react#examples",
"title": "React",
"searchTitle": "Examples",
"sectionTitle": "Examples",
@@ -3245,7 +3343,7 @@
"kind": "page"
},
{
- "id": "290-release-notes/0.1#breaking-changes",
+ "id": "298-release-notes/0.1#breaking-changes",
"title": "Zero 0.1",
"searchTitle": "Breaking changes",
"sectionTitle": "Breaking changes",
@@ -3255,7 +3353,7 @@
"kind": "section"
},
{
- "id": "291-release-notes/0.1#features",
+ "id": "299-release-notes/0.1#features",
"title": "Zero 0.1",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -3265,7 +3363,7 @@
"kind": "section"
},
{
- "id": "292-release-notes/0.1#source-tree-fixes",
+ "id": "300-release-notes/0.1#source-tree-fixes",
"title": "Zero 0.1",
"searchTitle": "Source tree fixes",
"sectionTitle": "Source tree fixes",
@@ -3301,7 +3399,7 @@
"kind": "page"
},
{
- "id": "293-release-notes/0.10#install",
+ "id": "301-release-notes/0.10#install",
"title": "Zero 0.10",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -3311,7 +3409,7 @@
"kind": "section"
},
{
- "id": "294-release-notes/0.10#features",
+ "id": "302-release-notes/0.10#features",
"title": "Zero 0.10",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -3321,7 +3419,7 @@
"kind": "section"
},
{
- "id": "295-release-notes/0.10#fixes",
+ "id": "303-release-notes/0.10#fixes",
"title": "Zero 0.10",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -3331,7 +3429,7 @@
"kind": "section"
},
{
- "id": "296-release-notes/0.10#breaking-changes",
+ "id": "304-release-notes/0.10#breaking-changes",
"title": "Zero 0.10",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -3367,7 +3465,7 @@
"kind": "page"
},
{
- "id": "297-release-notes/0.11#install",
+ "id": "305-release-notes/0.11#install",
"title": "Zero 0.11",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -3377,7 +3475,7 @@
"kind": "section"
},
{
- "id": "298-release-notes/0.11#features",
+ "id": "306-release-notes/0.11#features",
"title": "Zero 0.11",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -3387,7 +3485,7 @@
"kind": "section"
},
{
- "id": "299-release-notes/0.11#fixes",
+ "id": "307-release-notes/0.11#fixes",
"title": "Zero 0.11",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -3397,7 +3495,7 @@
"kind": "section"
},
{
- "id": "300-release-notes/0.11#breaking-changes",
+ "id": "308-release-notes/0.11#breaking-changes",
"title": "Zero 0.11",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -3433,7 +3531,7 @@
"kind": "page"
},
{
- "id": "301-release-notes/0.12#install",
+ "id": "309-release-notes/0.12#install",
"title": "Zero 0.12",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -3443,7 +3541,7 @@
"kind": "section"
},
{
- "id": "302-release-notes/0.12#features",
+ "id": "310-release-notes/0.12#features",
"title": "Zero 0.12",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -3453,7 +3551,7 @@
"kind": "section"
},
{
- "id": "303-release-notes/0.12#fixes",
+ "id": "311-release-notes/0.12#fixes",
"title": "Zero 0.12",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -3463,7 +3561,7 @@
"kind": "section"
},
{
- "id": "304-release-notes/0.12#breaking-changes",
+ "id": "312-release-notes/0.12#breaking-changes",
"title": "Zero 0.12",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -3499,7 +3597,7 @@
"kind": "page"
},
{
- "id": "305-release-notes/0.13#install",
+ "id": "313-release-notes/0.13#install",
"title": "Zero 0.13",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -3509,7 +3607,7 @@
"kind": "section"
},
{
- "id": "306-release-notes/0.13#features",
+ "id": "314-release-notes/0.13#features",
"title": "Zero 0.13",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -3519,7 +3617,7 @@
"kind": "section"
},
{
- "id": "307-release-notes/0.13#fixes",
+ "id": "315-release-notes/0.13#fixes",
"title": "Zero 0.13",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -3529,7 +3627,7 @@
"kind": "section"
},
{
- "id": "308-release-notes/0.13#breaking-changes",
+ "id": "316-release-notes/0.13#breaking-changes",
"title": "Zero 0.13",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -3565,7 +3663,7 @@
"kind": "page"
},
{
- "id": "309-release-notes/0.14#install",
+ "id": "317-release-notes/0.14#install",
"title": "Zero 0.14",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -3575,7 +3673,7 @@
"kind": "section"
},
{
- "id": "310-release-notes/0.14#features",
+ "id": "318-release-notes/0.14#features",
"title": "Zero 0.14",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -3585,7 +3683,7 @@
"kind": "section"
},
{
- "id": "311-release-notes/0.14#fixes",
+ "id": "319-release-notes/0.14#fixes",
"title": "Zero 0.14",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -3595,7 +3693,7 @@
"kind": "section"
},
{
- "id": "312-release-notes/0.14#breaking-changes",
+ "id": "320-release-notes/0.14#breaking-changes",
"title": "Zero 0.14",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -3635,7 +3733,7 @@
"kind": "page"
},
{
- "id": "313-release-notes/0.15#install",
+ "id": "321-release-notes/0.15#install",
"title": "Zero 0.15",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -3645,7 +3743,7 @@
"kind": "section"
},
{
- "id": "314-release-notes/0.15#upgrade-guide",
+ "id": "322-release-notes/0.15#upgrade-guide",
"title": "Zero 0.15",
"searchTitle": "Upgrade Guide",
"sectionTitle": "Upgrade Guide",
@@ -3655,7 +3753,7 @@
"kind": "section"
},
{
- "id": "315-release-notes/0.15#features",
+ "id": "323-release-notes/0.15#features",
"title": "Zero 0.15",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -3665,7 +3763,7 @@
"kind": "section"
},
{
- "id": "316-release-notes/0.15#fixes",
+ "id": "324-release-notes/0.15#fixes",
"title": "Zero 0.15",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -3675,7 +3773,7 @@
"kind": "section"
},
{
- "id": "317-release-notes/0.15#breaking-changes",
+ "id": "325-release-notes/0.15#breaking-changes",
"title": "Zero 0.15",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -3715,7 +3813,7 @@
"kind": "page"
},
{
- "id": "318-release-notes/0.16#install",
+ "id": "326-release-notes/0.16#install",
"title": "Zero 0.16",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -3725,7 +3823,7 @@
"kind": "section"
},
{
- "id": "319-release-notes/0.16#upgrading",
+ "id": "327-release-notes/0.16#upgrading",
"title": "Zero 0.16",
"searchTitle": "Upgrading",
"sectionTitle": "Upgrading",
@@ -3735,7 +3833,7 @@
"kind": "section"
},
{
- "id": "320-release-notes/0.16#features",
+ "id": "328-release-notes/0.16#features",
"title": "Zero 0.16",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -3745,7 +3843,7 @@
"kind": "section"
},
{
- "id": "321-release-notes/0.16#fixes",
+ "id": "329-release-notes/0.16#fixes",
"title": "Zero 0.16",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -3755,7 +3853,7 @@
"kind": "section"
},
{
- "id": "322-release-notes/0.16#breaking-changes",
+ "id": "330-release-notes/0.16#breaking-changes",
"title": "Zero 0.16",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -3795,7 +3893,7 @@
"kind": "page"
},
{
- "id": "323-release-notes/0.17#install",
+ "id": "331-release-notes/0.17#install",
"title": "Zero 0.17",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -3805,7 +3903,7 @@
"kind": "section"
},
{
- "id": "324-release-notes/0.17#upgrading",
+ "id": "332-release-notes/0.17#upgrading",
"title": "Zero 0.17",
"searchTitle": "Upgrading",
"sectionTitle": "Upgrading",
@@ -3815,7 +3913,7 @@
"kind": "section"
},
{
- "id": "325-release-notes/0.17#features",
+ "id": "333-release-notes/0.17#features",
"title": "Zero 0.17",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -3825,7 +3923,7 @@
"kind": "section"
},
{
- "id": "326-release-notes/0.17#fixes",
+ "id": "334-release-notes/0.17#fixes",
"title": "Zero 0.17",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -3835,7 +3933,7 @@
"kind": "section"
},
{
- "id": "327-release-notes/0.17#breaking-changes",
+ "id": "335-release-notes/0.17#breaking-changes",
"title": "Zero 0.17",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -3875,7 +3973,7 @@
"kind": "page"
},
{
- "id": "328-release-notes/0.18#install",
+ "id": "336-release-notes/0.18#install",
"title": "Zero 0.18",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -3885,7 +3983,7 @@
"kind": "section"
},
{
- "id": "329-release-notes/0.18#upgrading",
+ "id": "337-release-notes/0.18#upgrading",
"title": "Zero 0.18",
"searchTitle": "Upgrading",
"sectionTitle": "Upgrading",
@@ -3895,7 +3993,7 @@
"kind": "section"
},
{
- "id": "330-release-notes/0.18#features",
+ "id": "338-release-notes/0.18#features",
"title": "Zero 0.18",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -3905,7 +4003,7 @@
"kind": "section"
},
{
- "id": "331-release-notes/0.18#fixes",
+ "id": "339-release-notes/0.18#fixes",
"title": "Zero 0.18",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -3915,7 +4013,7 @@
"kind": "section"
},
{
- "id": "332-release-notes/0.18#breaking-changes",
+ "id": "340-release-notes/0.18#breaking-changes",
"title": "Zero 0.18",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -3955,7 +4053,7 @@
"kind": "page"
},
{
- "id": "333-release-notes/0.19#install",
+ "id": "341-release-notes/0.19#install",
"title": "Zero 0.19",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -3965,7 +4063,7 @@
"kind": "section"
},
{
- "id": "334-release-notes/0.19#upgrading",
+ "id": "342-release-notes/0.19#upgrading",
"title": "Zero 0.19",
"searchTitle": "Upgrading",
"sectionTitle": "Upgrading",
@@ -3975,7 +4073,7 @@
"kind": "section"
},
{
- "id": "335-release-notes/0.19#features",
+ "id": "343-release-notes/0.19#features",
"title": "Zero 0.19",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -3985,7 +4083,7 @@
"kind": "section"
},
{
- "id": "336-release-notes/0.19#fixes",
+ "id": "344-release-notes/0.19#fixes",
"title": "Zero 0.19",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -3995,7 +4093,7 @@
"kind": "section"
},
{
- "id": "337-release-notes/0.19#breaking-changes",
+ "id": "345-release-notes/0.19#breaking-changes",
"title": "Zero 0.19",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -4039,7 +4137,7 @@
"kind": "page"
},
{
- "id": "338-release-notes/0.2#breaking-changes",
+ "id": "346-release-notes/0.2#breaking-changes",
"title": "Zero 0.2",
"searchTitle": "Breaking changes",
"sectionTitle": "Breaking changes",
@@ -4049,7 +4147,7 @@
"kind": "section"
},
{
- "id": "339-release-notes/0.2#features",
+ "id": "347-release-notes/0.2#features",
"title": "Zero 0.2",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -4059,7 +4157,7 @@
"kind": "section"
},
{
- "id": "340-release-notes/0.2#fixes",
+ "id": "348-release-notes/0.2#fixes",
"title": "Zero 0.2",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -4069,7 +4167,7 @@
"kind": "section"
},
{
- "id": "341-release-notes/0.2#docs",
+ "id": "349-release-notes/0.2#docs",
"title": "Zero 0.2",
"searchTitle": "Docs",
"sectionTitle": "Docs",
@@ -4079,7 +4177,7 @@
"kind": "section"
},
{
- "id": "342-release-notes/0.2#source-tree-fixes",
+ "id": "350-release-notes/0.2#source-tree-fixes",
"title": "Zero 0.2",
"searchTitle": "Source tree fixes",
"sectionTitle": "Source tree fixes",
@@ -4089,7 +4187,7 @@
"kind": "section"
},
{
- "id": "343-release-notes/0.2#zbugs",
+ "id": "351-release-notes/0.2#zbugs",
"title": "Zero 0.2",
"searchTitle": "zbugs",
"sectionTitle": "zbugs",
@@ -4129,7 +4227,7 @@
"kind": "page"
},
{
- "id": "344-release-notes/0.20#install",
+ "id": "352-release-notes/0.20#install",
"title": "Zero 0.20",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -4139,7 +4237,7 @@
"kind": "section"
},
{
- "id": "345-release-notes/0.20#upgrading",
+ "id": "353-release-notes/0.20#upgrading",
"title": "Zero 0.20",
"searchTitle": "Upgrading",
"sectionTitle": "Upgrading",
@@ -4149,7 +4247,7 @@
"kind": "section"
},
{
- "id": "346-release-notes/0.20#features",
+ "id": "354-release-notes/0.20#features",
"title": "Zero 0.20",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -4159,7 +4257,7 @@
"kind": "section"
},
{
- "id": "347-release-notes/0.20#fixes",
+ "id": "355-release-notes/0.20#fixes",
"title": "Zero 0.20",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -4169,7 +4267,7 @@
"kind": "section"
},
{
- "id": "348-release-notes/0.20#breaking-changes",
+ "id": "356-release-notes/0.20#breaking-changes",
"title": "Zero 0.20",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -4209,7 +4307,7 @@
"kind": "page"
},
{
- "id": "349-release-notes/0.21#install",
+ "id": "357-release-notes/0.21#install",
"title": "Zero 0.21",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -4219,7 +4317,7 @@
"kind": "section"
},
{
- "id": "350-release-notes/0.21#upgrading",
+ "id": "358-release-notes/0.21#upgrading",
"title": "Zero 0.21",
"searchTitle": "Upgrading",
"sectionTitle": "Upgrading",
@@ -4229,7 +4327,7 @@
"kind": "section"
},
{
- "id": "351-release-notes/0.21#features",
+ "id": "359-release-notes/0.21#features",
"title": "Zero 0.21",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -4239,7 +4337,7 @@
"kind": "section"
},
{
- "id": "352-release-notes/0.21#fixes",
+ "id": "360-release-notes/0.21#fixes",
"title": "Zero 0.21",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -4249,7 +4347,7 @@
"kind": "section"
},
{
- "id": "353-release-notes/0.21#breaking-changes",
+ "id": "361-release-notes/0.21#breaking-changes",
"title": "Zero 0.21",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -4301,7 +4399,7 @@
"kind": "page"
},
{
- "id": "354-release-notes/0.22#install",
+ "id": "362-release-notes/0.22#install",
"title": "Zero 0.22",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -4311,7 +4409,7 @@
"kind": "section"
},
{
- "id": "355-release-notes/0.22#upgrading",
+ "id": "363-release-notes/0.22#upgrading",
"title": "Zero 0.22",
"searchTitle": "Upgrading",
"sectionTitle": "Upgrading",
@@ -4321,7 +4419,7 @@
"kind": "section"
},
{
- "id": "356-release-notes/0.22#how-ttls-used-to-work",
+ "id": "364-release-notes/0.22#how-ttls-used-to-work",
"title": "Zero 0.22",
"searchTitle": "How TTLs Used to Work",
"sectionTitle": "How TTLs Used to Work",
@@ -4331,7 +4429,7 @@
"kind": "section"
},
{
- "id": "357-release-notes/0.22#how-ttls-work-now",
+ "id": "365-release-notes/0.22#how-ttls-work-now",
"title": "Zero 0.22",
"searchTitle": "How TTLs Work Now",
"sectionTitle": "How TTLs Work Now",
@@ -4341,7 +4439,7 @@
"kind": "section"
},
{
- "id": "358-release-notes/0.22#using-new-ttls",
+ "id": "366-release-notes/0.22#using-new-ttls",
"title": "Zero 0.22",
"searchTitle": "Using New TTLs",
"sectionTitle": "Using New TTLs",
@@ -4351,7 +4449,7 @@
"kind": "section"
},
{
- "id": "359-release-notes/0.22#features",
+ "id": "367-release-notes/0.22#features",
"title": "Zero 0.22",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -4361,7 +4459,7 @@
"kind": "section"
},
{
- "id": "360-release-notes/0.22#fixes",
+ "id": "368-release-notes/0.22#fixes",
"title": "Zero 0.22",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -4371,7 +4469,7 @@
"kind": "section"
},
{
- "id": "361-release-notes/0.22#breaking-changes",
+ "id": "369-release-notes/0.22#breaking-changes",
"title": "Zero 0.22",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -4415,7 +4513,7 @@
"kind": "page"
},
{
- "id": "362-release-notes/0.23#install",
+ "id": "370-release-notes/0.23#install",
"title": "Zero 0.23",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -4425,7 +4523,7 @@
"kind": "section"
},
{
- "id": "363-release-notes/0.23#upgrading",
+ "id": "371-release-notes/0.23#upgrading",
"title": "Zero 0.23",
"searchTitle": "Upgrading",
"sectionTitle": "Upgrading",
@@ -4435,7 +4533,7 @@
"kind": "section"
},
{
- "id": "364-release-notes/0.23#features",
+ "id": "372-release-notes/0.23#features",
"title": "Zero 0.23",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -4445,7 +4543,7 @@
"kind": "section"
},
{
- "id": "365-release-notes/0.23#fixes",
+ "id": "373-release-notes/0.23#fixes",
"title": "Zero 0.23",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -4455,7 +4553,7 @@
"kind": "section"
},
{
- "id": "366-release-notes/0.23#zbugs",
+ "id": "374-release-notes/0.23#zbugs",
"title": "Zero 0.23",
"searchTitle": "zbugs",
"sectionTitle": "zbugs",
@@ -4465,7 +4563,7 @@
"kind": "section"
},
{
- "id": "367-release-notes/0.23#breaking-changes",
+ "id": "375-release-notes/0.23#breaking-changes",
"title": "Zero 0.23",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -4505,7 +4603,7 @@
"kind": "page"
},
{
- "id": "368-release-notes/0.24#installation",
+ "id": "376-release-notes/0.24#installation",
"title": "Zero 0.24",
"searchTitle": "Installation",
"sectionTitle": "Installation",
@@ -4515,7 +4613,7 @@
"kind": "section"
},
{
- "id": "369-release-notes/0.24#features",
+ "id": "377-release-notes/0.24#features",
"title": "Zero 0.24",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -4525,7 +4623,7 @@
"kind": "section"
},
{
- "id": "370-release-notes/0.24#fixes",
+ "id": "378-release-notes/0.24#fixes",
"title": "Zero 0.24",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -4535,7 +4633,7 @@
"kind": "section"
},
{
- "id": "371-release-notes/0.24#breaking-changes",
+ "id": "379-release-notes/0.24#breaking-changes",
"title": "Zero 0.24",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -4545,7 +4643,7 @@
"kind": "section"
},
{
- "id": "372-release-notes/0.24#example-upgrades",
+ "id": "380-release-notes/0.24#example-upgrades",
"title": "Zero 0.24",
"searchTitle": "Example Upgrades",
"sectionTitle": "Example Upgrades",
@@ -4593,7 +4691,7 @@
"kind": "page"
},
{
- "id": "373-release-notes/0.25#installation",
+ "id": "381-release-notes/0.25#installation",
"title": "Zero 0.25",
"searchTitle": "Installation",
"sectionTitle": "Installation",
@@ -4603,7 +4701,7 @@
"kind": "section"
},
{
- "id": "374-release-notes/0.25#overview",
+ "id": "382-release-notes/0.25#overview",
"title": "Zero 0.25",
"searchTitle": "Overview",
"sectionTitle": "Overview",
@@ -4613,7 +4711,7 @@
"kind": "section"
},
{
- "id": "375-release-notes/0.25#upgrading",
+ "id": "383-release-notes/0.25#upgrading",
"title": "Zero 0.25",
"searchTitle": "Upgrading",
"sectionTitle": "Upgrading",
@@ -4623,7 +4721,7 @@
"kind": "section"
},
{
- "id": "376-release-notes/0.25#features",
+ "id": "384-release-notes/0.25#features",
"title": "Zero 0.25",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -4633,7 +4731,7 @@
"kind": "section"
},
{
- "id": "377-release-notes/0.25#performance",
+ "id": "385-release-notes/0.25#performance",
"title": "Zero 0.25",
"searchTitle": "Performance",
"sectionTitle": "Performance",
@@ -4643,7 +4741,7 @@
"kind": "section"
},
{
- "id": "378-release-notes/0.25#fixes",
+ "id": "386-release-notes/0.25#fixes",
"title": "Zero 0.25",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -4653,7 +4751,7 @@
"kind": "section"
},
{
- "id": "379-release-notes/0.25#breaking-changes",
+ "id": "387-release-notes/0.25#breaking-changes",
"title": "Zero 0.25",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -4689,7 +4787,7 @@
"kind": "page"
},
{
- "id": "380-release-notes/0.26#installation",
+ "id": "388-release-notes/0.26#installation",
"title": "Zero 0.26",
"searchTitle": "Installation",
"sectionTitle": "Installation",
@@ -4699,7 +4797,7 @@
"kind": "section"
},
{
- "id": "381-release-notes/0.26#features",
+ "id": "389-release-notes/0.26#features",
"title": "Zero 0.26",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -4709,7 +4807,7 @@
"kind": "section"
},
{
- "id": "382-release-notes/0.26#fixes",
+ "id": "390-release-notes/0.26#fixes",
"title": "Zero 0.26",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -4719,7 +4817,7 @@
"kind": "section"
},
{
- "id": "383-release-notes/0.26#breaking-changes",
+ "id": "391-release-notes/0.26#breaking-changes",
"title": "Zero 0.26",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -4763,7 +4861,7 @@
"kind": "page"
},
{
- "id": "384-release-notes/0.3#install",
+ "id": "392-release-notes/0.3#install",
"title": "Zero 0.3",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -4773,7 +4871,7 @@
"kind": "section"
},
{
- "id": "385-release-notes/0.3#breaking-changes",
+ "id": "393-release-notes/0.3#breaking-changes",
"title": "Zero 0.3",
"searchTitle": "Breaking changes",
"sectionTitle": "Breaking changes",
@@ -4783,7 +4881,7 @@
"kind": "section"
},
{
- "id": "386-release-notes/0.3#features",
+ "id": "394-release-notes/0.3#features",
"title": "Zero 0.3",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -4793,7 +4891,7 @@
"kind": "section"
},
{
- "id": "387-release-notes/0.3#fixes",
+ "id": "395-release-notes/0.3#fixes",
"title": "Zero 0.3",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -4803,7 +4901,7 @@
"kind": "section"
},
{
- "id": "388-release-notes/0.3#docs",
+ "id": "396-release-notes/0.3#docs",
"title": "Zero 0.3",
"searchTitle": "Docs",
"sectionTitle": "Docs",
@@ -4813,7 +4911,7 @@
"kind": "section"
},
{
- "id": "389-release-notes/0.3#zbugs",
+ "id": "397-release-notes/0.3#zbugs",
"title": "Zero 0.3",
"searchTitle": "zbugs",
"sectionTitle": "zbugs",
@@ -4857,7 +4955,7 @@
"kind": "page"
},
{
- "id": "390-release-notes/0.4#install",
+ "id": "398-release-notes/0.4#install",
"title": "Zero 0.4",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -4867,7 +4965,7 @@
"kind": "section"
},
{
- "id": "391-release-notes/0.4#breaking-changes",
+ "id": "399-release-notes/0.4#breaking-changes",
"title": "Zero 0.4",
"searchTitle": "Breaking changes",
"sectionTitle": "Breaking changes",
@@ -4877,7 +4975,7 @@
"kind": "section"
},
{
- "id": "392-release-notes/0.4#added-or--and--and-not-to-zql-documentation",
+ "id": "400-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).",
@@ -4887,7 +4985,7 @@
"kind": "section"
},
{
- "id": "393-release-notes/0.4#fixes",
+ "id": "401-release-notes/0.4#fixes",
"title": "Zero 0.4",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -4897,7 +4995,7 @@
"kind": "section"
},
{
- "id": "394-release-notes/0.4#docs",
+ "id": "402-release-notes/0.4#docs",
"title": "Zero 0.4",
"searchTitle": "Docs",
"sectionTitle": "Docs",
@@ -4907,7 +5005,7 @@
"kind": "section"
},
{
- "id": "395-release-notes/0.4#zbugs",
+ "id": "403-release-notes/0.4#zbugs",
"title": "Zero 0.4",
"searchTitle": "zbugs",
"sectionTitle": "zbugs",
@@ -4951,7 +5049,7 @@
"kind": "page"
},
{
- "id": "396-release-notes/0.5#install",
+ "id": "404-release-notes/0.5#install",
"title": "Zero 0.5",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -4961,7 +5059,7 @@
"kind": "section"
},
{
- "id": "397-release-notes/0.5#breaking-changes",
+ "id": "405-release-notes/0.5#breaking-changes",
"title": "Zero 0.5",
"searchTitle": "Breaking changes",
"sectionTitle": "Breaking changes",
@@ -4971,7 +5069,7 @@
"kind": "section"
},
{
- "id": "398-release-notes/0.5#features",
+ "id": "406-release-notes/0.5#features",
"title": "Zero 0.5",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -4981,7 +5079,7 @@
"kind": "section"
},
{
- "id": "399-release-notes/0.5#fixes",
+ "id": "407-release-notes/0.5#fixes",
"title": "Zero 0.5",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -4991,7 +5089,7 @@
"kind": "section"
},
{
- "id": "400-release-notes/0.5#docs",
+ "id": "408-release-notes/0.5#docs",
"title": "Zero 0.5",
"searchTitle": "Docs",
"sectionTitle": "Docs",
@@ -5001,7 +5099,7 @@
"kind": "section"
},
{
- "id": "401-release-notes/0.5#zbugs",
+ "id": "409-release-notes/0.5#zbugs",
"title": "Zero 0.5",
"searchTitle": "zbugs",
"sectionTitle": "zbugs",
@@ -5045,7 +5143,7 @@
"kind": "page"
},
{
- "id": "402-release-notes/0.6#install",
+ "id": "410-release-notes/0.6#install",
"title": "Zero 0.6",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -5055,7 +5153,7 @@
"kind": "section"
},
{
- "id": "403-release-notes/0.6#upgrade-guide",
+ "id": "411-release-notes/0.6#upgrade-guide",
"title": "Zero 0.6",
"searchTitle": "Upgrade Guide",
"sectionTitle": "Upgrade Guide",
@@ -5065,7 +5163,7 @@
"kind": "section"
},
{
- "id": "404-release-notes/0.6#breaking-changes",
+ "id": "412-release-notes/0.6#breaking-changes",
"title": "Zero 0.6",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -5075,7 +5173,7 @@
"kind": "section"
},
{
- "id": "405-release-notes/0.6#features",
+ "id": "413-release-notes/0.6#features",
"title": "Zero 0.6",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -5085,7 +5183,7 @@
"kind": "section"
},
{
- "id": "406-release-notes/0.6#zbugs",
+ "id": "414-release-notes/0.6#zbugs",
"title": "Zero 0.6",
"searchTitle": "zbugs",
"sectionTitle": "zbugs",
@@ -5095,7 +5193,7 @@
"kind": "section"
},
{
- "id": "407-release-notes/0.6#docs",
+ "id": "415-release-notes/0.6#docs",
"title": "Zero 0.6",
"searchTitle": "Docs",
"sectionTitle": "Docs",
@@ -5135,7 +5233,7 @@
"kind": "page"
},
{
- "id": "408-release-notes/0.7#install",
+ "id": "416-release-notes/0.7#install",
"title": "Zero 0.7",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -5145,7 +5243,7 @@
"kind": "section"
},
{
- "id": "409-release-notes/0.7#features",
+ "id": "417-release-notes/0.7#features",
"title": "Zero 0.7",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -5155,7 +5253,7 @@
"kind": "section"
},
{
- "id": "410-release-notes/0.7#breaking-changes",
+ "id": "418-release-notes/0.7#breaking-changes",
"title": "Zero 0.7",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -5165,7 +5263,7 @@
"kind": "section"
},
{
- "id": "411-release-notes/0.7#zbugs",
+ "id": "419-release-notes/0.7#zbugs",
"title": "Zero 0.7",
"searchTitle": "zbugs",
"sectionTitle": "zbugs",
@@ -5175,7 +5273,7 @@
"kind": "section"
},
{
- "id": "412-release-notes/0.7#docs",
+ "id": "420-release-notes/0.7#docs",
"title": "Zero 0.7",
"searchTitle": "Docs",
"sectionTitle": "Docs",
@@ -5211,7 +5309,7 @@
"kind": "page"
},
{
- "id": "413-release-notes/0.8#install",
+ "id": "421-release-notes/0.8#install",
"title": "Zero 0.8",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -5221,7 +5319,7 @@
"kind": "section"
},
{
- "id": "414-release-notes/0.8#features",
+ "id": "422-release-notes/0.8#features",
"title": "Zero 0.8",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -5231,7 +5329,7 @@
"kind": "section"
},
{
- "id": "415-release-notes/0.8#fixes",
+ "id": "423-release-notes/0.8#fixes",
"title": "Zero 0.8",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -5241,7 +5339,7 @@
"kind": "section"
},
{
- "id": "416-release-notes/0.8#breaking-changes",
+ "id": "424-release-notes/0.8#breaking-changes",
"title": "Zero 0.8",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -5277,7 +5375,7 @@
"kind": "page"
},
{
- "id": "417-release-notes/0.9#install",
+ "id": "425-release-notes/0.9#install",
"title": "Zero 0.9",
"searchTitle": "Install",
"sectionTitle": "Install",
@@ -5287,7 +5385,7 @@
"kind": "section"
},
{
- "id": "418-release-notes/0.9#features",
+ "id": "426-release-notes/0.9#features",
"title": "Zero 0.9",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -5297,7 +5395,7 @@
"kind": "section"
},
{
- "id": "419-release-notes/0.9#fixes",
+ "id": "427-release-notes/0.9#fixes",
"title": "Zero 0.9",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -5307,7 +5405,7 @@
"kind": "section"
},
{
- "id": "420-release-notes/0.9#breaking-changes",
+ "id": "428-release-notes/0.9#breaking-changes",
"title": "Zero 0.9",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -5347,7 +5445,7 @@
"kind": "page"
},
{
- "id": "421-release-notes/1.0#installation",
+ "id": "429-release-notes/1.0#installation",
"title": "Zero 1.0",
"searchTitle": "Installation",
"sectionTitle": "Installation",
@@ -5357,7 +5455,7 @@
"kind": "section"
},
{
- "id": "422-release-notes/1.0#overview",
+ "id": "430-release-notes/1.0#overview",
"title": "Zero 1.0",
"searchTitle": "Overview",
"sectionTitle": "Overview",
@@ -5367,7 +5465,7 @@
"kind": "section"
},
{
- "id": "423-release-notes/1.0#features",
+ "id": "431-release-notes/1.0#features",
"title": "Zero 1.0",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -5377,7 +5475,7 @@
"kind": "section"
},
{
- "id": "424-release-notes/1.0#fixes",
+ "id": "432-release-notes/1.0#fixes",
"title": "Zero 1.0",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -5387,7 +5485,7 @@
"kind": "section"
},
{
- "id": "425-release-notes/1.0#breaking-changes",
+ "id": "433-release-notes/1.0#breaking-changes",
"title": "Zero 1.0",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -5423,7 +5521,7 @@
"kind": "page"
},
{
- "id": "426-release-notes/1.1#installation",
+ "id": "434-release-notes/1.1#installation",
"title": "Zero 1.1",
"searchTitle": "Installation",
"sectionTitle": "Installation",
@@ -5433,7 +5531,7 @@
"kind": "section"
},
{
- "id": "427-release-notes/1.1#features",
+ "id": "435-release-notes/1.1#features",
"title": "Zero 1.1",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -5443,7 +5541,7 @@
"kind": "section"
},
{
- "id": "428-release-notes/1.1#fixes",
+ "id": "436-release-notes/1.1#fixes",
"title": "Zero 1.1",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -5453,7 +5551,7 @@
"kind": "section"
},
{
- "id": "429-release-notes/1.1#breaking-changes",
+ "id": "437-release-notes/1.1#breaking-changes",
"title": "Zero 1.1",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -5505,7 +5603,7 @@
"kind": "page"
},
{
- "id": "430-release-notes/1.10#installation",
+ "id": "438-release-notes/1.10#installation",
"title": "Zero 1.10",
"searchTitle": "Installation",
"sectionTitle": "Installation",
@@ -5515,7 +5613,7 @@
"kind": "section"
},
{
- "id": "431-release-notes/1.10#overview",
+ "id": "439-release-notes/1.10#overview",
"title": "Zero 1.10",
"searchTitle": "Overview",
"sectionTitle": "Overview",
@@ -5525,7 +5623,7 @@
"kind": "section"
},
{
- "id": "432-release-notes/1.10#features",
+ "id": "440-release-notes/1.10#features",
"title": "Zero 1.10",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -5535,7 +5633,7 @@
"kind": "section"
},
{
- "id": "433-release-notes/1.10#fixes",
+ "id": "441-release-notes/1.10#fixes",
"title": "Zero 1.10",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -5545,7 +5643,7 @@
"kind": "section"
},
{
- "id": "434-release-notes/1.10#queries-and-connections",
+ "id": "442-release-notes/1.10#queries-and-connections",
"title": "Zero 1.10",
"searchTitle": "Queries and Connections",
"sectionTitle": "Queries and Connections",
@@ -5555,7 +5653,7 @@
"kind": "section"
},
{
- "id": "435-release-notes/1.10#replication-and-backfills",
+ "id": "443-release-notes/1.10#replication-and-backfills",
"title": "Zero 1.10",
"searchTitle": "Replication and Backfills",
"sectionTitle": "Replication and Backfills",
@@ -5565,7 +5663,7 @@
"kind": "section"
},
{
- "id": "436-release-notes/1.10#backups-and-startup",
+ "id": "444-release-notes/1.10#backups-and-startup",
"title": "Zero 1.10",
"searchTitle": "Backups and Startup",
"sectionTitle": "Backups and Startup",
@@ -5575,7 +5673,7 @@
"kind": "section"
},
{
- "id": "437-release-notes/1.10#breaking-changes",
+ "id": "445-release-notes/1.10#breaking-changes",
"title": "Zero 1.10",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -5585,7 +5683,241 @@
"kind": "section"
},
{
- "id": "55-release-notes/1.2",
+ "id": "55-release-notes/1.11",
+ "title": "Zero 1.11",
+ "searchTitle": "Zero 1.11",
+ "url": "/docs/release-notes/1.11",
+ "content": "Installation npm install @rocicorp/zero@1.11 Deploy zero-cache before updating clients. Zero 1.11 clients cannot connect to an older server. To roll back, roll back clients first. In multi-node deployments, upgrade the replication-manager before the view-syncers, and roll back the view-syncers first. Overview Zero 1.11 tells you when local query results were already confirmed by the server, keeps working when local storage fails, and makes Litestream v5 the default for backups. Client cold start is about 7x faster in our benchmarks. There are also a lot of fixes, especially in queries and replication. Features Queries now tell you when their local data is a result the server confirmed in an earlier session: they start with the new 'cached' result type instead of 'unknown'. That's enough to show stored data or an empty state without waiting for the server, even offline. (#6578) (thanks @pawarren!) Building the same query again usually gives you the same Query object. useQuery no longer rebuilds and rehashes its query on every render, and queries built inline work as React dependencies. (#6469, #6496) (thanks @charpeni!) Zero starts in a new 'initializing' connection state while it loads local data, and slow loading doesn't count against the connect timeout anymore. (#6561) If local storage can't be opened, fills up, or hits I/O errors, Zero reports it once through the new onStorageFailure option and keeps running in memory. zero.kvStore tells you which store is in use. (#6585, #6657, #6686) (thanks @pawarren and @Karavil!) Clients upgrading from 1.10 keep their local data and pending mutations. Future protocol changes won't reset clients to an empty local database either. (d597c69e8, 28e581483) Server mutators can return a JSON value to the client, run again in a new transaction when tx.retryOn() accepts the error, and run at a chosen transaction isolation level. (7756eb9bb, 4fe91c0d8, cb2821f3a) (thanks @pawarren!) Litestream v5 backups are now the default. A replica restored from another replica's backup continues that backup incrementally. Periodic snapshots keep backups compact, and the replica's WAL stays bounded. (fb536f686, c1a586810, #6476, #6539, #6717) A zero-cache that comes online now waits for replication to catch up before it reports ready. (1c5ddcdb4) Inactive client groups are kept for 7 days instead of 48 hours, so users who come back within a week don't need a full resync. (#6484) zero-cache now logs slow queries with the reason and suggested indexes, and periodically logs per-query statistics. A new operator password gives access to CPU profiles (/profz) and replica planner statistics (/plannerz) without the admin password. (#6446, #6639, #6673, #6675, #6699) A new hydration budget caps the time a view-syncer spends re-hydrating a reconnecting client's inactive queries, and a per-query hydration timeout aborts any query that takes too long to hydrate. Both are off by default. (#6477, #6499) New OpenTelemetry metrics track WAL usage. Replication status events now include index-build progress, and a new view-syncer status event reports restore progress. (#6591, #6621, #6714) Server mutations can send their logs to your own LogSink. (#6677) (thanks @pawarren!) The zero Docker image is down to 807 MB from about 1.09 GB. (#6695) Performance Client Cold Start Apps usually register queries before Zero has finished loading local data, for example when useQuery runs at startup. Zero 1.10 then pushed every loaded row through every registered query. Zero 1.11 waits for the data to load and hydrates each query once (#6472). With 13 queries and about 30,000 local rows, startup is about 7x faster, and about 8x faster on React Native. Server-Side Incremental Updates When a transaction replicates, zero-cache has to bring every client group's queries up to date. Zero 1.11 does this with about 2.5x less CPU. We measured 100 client groups on one sync worker, each syncing a query with a related subquery. React Native Zero's query engine is about 1.7x faster on React Native. That's the geometric mean of 53 query engine benchmarks on Hermes, and 48 of them got more than 5% faster. Hydrating queries is 1.6x to 4.8x faster, and applying changes 1.2x to 2.2x faster. Building and Hashing Queries Components rebuild their queries on every render, and Zero hashes each query to identify it. Building the same query again now usually returns the same object, with its hash already computed (#6469). That makes building and hashing a query about 7x to 17x faster, depending on its size. Client Writes With Many Joined Queries Got Slower This is the one benchmark that got slower. With 300 open queries, each joining the same four tables, applying one write in the client's query engine takes about 1.2x longer. Joins now keep an index of their parent rows so they can skip changes no parent can see (#6645, #6708). In this benchmark every change reaches some parent, so keeping the index up to date is pure overhead. Fixes Query Results Queries using or with a flipped exists could return wrong rows. (#6636) Joins could leave a deleted row on clients, keep a stale row in a limit() query, or fail with node does not exist. (#6660) Rows behind an exists inside a scalar subquery weren't synced, and changes to them didn't propagate. (913c0665f) Catching up a client after a large update could fail with Missing row, and a schema change during rehydration could fail with table has no column named. (#6662, #6616) A SQLite plan that nests one multi-index OR inside another could crash zero-cache in a loop. (6ea17bfcd) (thanks @robertoborgone!) zero-cache called ZERO_QUERY_URL again for unchanged custom queries whenever it rebuilt its query pipelines. A permission change still re-transforms every query. (#6601) In React and SolidJS, two custom queries with the same query but different names or arguments could share one view, and a SolidJS view rebuilt after its query changed kept the old TTL. (#6461, #6497) In mutators, a row inserted without its optional columns didn't read those columns back as null. (d4816752c) Clients and React Native SQLite-backed stores (expo-sqlite, op-sqlite) could return corrupted data under concurrent reads, hang after a failed transaction, or keep part of a failed commit. (#6495, #6655, #6683) (thanks @pawarren!) Healthy clients logged ChunkNotFoundError during persist. A write that would corrupt the local store now fails instead. (#6670, #6559) (thanks @pawarren!) With op-sqlite and a custom location or encryptionKey, dropping the database didn't delete the right file. (#6656) (thanks @pawarren!) Clients could go offline after the app resumed from the background or the computer woke from sleep, and React Native connect timeouts left the client stuck in an error state instead of retrying. (#6510, #6485) (thanks @pawarren!) Slow local startup could make a healthy server look unreachable. (#6475) A mutator that threw while being replayed disconnected the client in a loop. The client now stays connected, and the mutation is still sent to the server. (#6555) Client memory grew with every query used in a session. (#6474) useSuspenseQuery with an options object and no suspendUntil didn't suspend until the first rows arrived, contrary to the docs (see Breaking Changes). (ffe67b23a) (thanks @charpeni!) Routine reconnects, auth errors and server rebalancing were logged as errors. They're now warnings or info, and fatal errors include their cause. (#6581, #6692, #6498, #6682, #6691, 42316d521, 075b4215e, 79adc09fb) (thanks @pawarren!) zero-cache Reliability zero-cache leaked memory and other resources around closed connections, stopped view-syncers, abandoned hydrations, and inspector and profiling requests. (#6500, #6514, #6515, #6516, #6525, #6526, #6527, #6528, #6540, #6541, #6542, #6698) View-syncers could crash or return Internal error when another view-syncer advanced the same client group, when a client connection was missing, or when a push arrived on a closing connection. (#6481, #6530, #6600, #6614, #6631, d545bc7e2) (thanks @jdnvn!) Servers using zeroNodePg crashed when Postgres closed a pooled connection. (1b220fa07) (thanks @pawarren!) Analyzing a query in the Inspector could run a view-syncer out of memory on large tables. (#6596) Error messages could include row values, query literals and mutation arguments. (#6413) Replication, Backfills, and Backups Replication could roll back or stop after an interrupted stream, and a replication-manager whose slot was invalidated crash-looped instead of resyncing. (#6562, #6590) Backfills could apply rows before replication had caught up to them, didn't resume when a view-syncer moved to another replication-manager, and leaked a Postgres connection that held back vacuum. (#6685, #6684, #6513) Replication slots are more robust. Slot creation retries while long transactions block it, slots stay active during long initial syncs, slots dropped outside Zero are handled, and inactive slots are cleaned up. (25a9ea1ac, f3bba7d73, 7a998b566, caf79b161, #6568) Replication lag was measured wrong on Aurora. (e6a2590db) After initial sync, the replication-manager could serve before a backup covered its replica, so restoring view-syncers fell back to Postgres. It now waits for that backup. (#6465) Query pipelines were reset more often than needed, and dropping an upstream index didn't refresh table schemas. (4341cdcbd, 89787f827, bb71cc931) View-syncers now fail fast when the replication-manager is unreachable, and a slow handshake is no longer mistaken for a missed heartbeat. (0af1a7109, #6598) During a resync, the old replication-manager now keeps serving until the new one takes over. (21f349135) Change-database connections leaked after a failed catch-up, and a change-database reset could hang. (#6529, #6632) Litestream could run out of memory in small containers. Its upload concurrency now scales with available memory. (240c05730) Configuration values that start with a dash, such as some shard IDs, were parsed wrong. (81e6ac570) In the zero Docker image, initial sync and backfills could leave a stalled Postgres connection open after copying a table. Other installs keep the 1.10 behavior. (3f139f52a, 22fa3c27e) Type Checking A rejected whereExists(..., {scalar: true}) now explains why, and a misspelled relationship name lists the valid ones. (cc020b368, 12a36dcfe) Breaking Changes Legacy queries: zero-cache now rejects queries sent directly by clients. If you still use legacy queries, set ZERO_ALLOW_LEGACY_QUERIES=true or move to custom queries. (#6399) New states: if your code switches over every result type or connection state, handle 'cached' and 'initializing'. Treat 'cached' like 'unknown': the data may be out of date. (#6578, #6561) Litestream v5 by default: remove the ZERO_LITESTREAM_VFS_* options; they no longer exist. Each replication-manager backs up to its own folder under the backup URL, so end the URL with / and add a lifecycle policy to your bucket. To keep writing legacy WAL backups with Litestream v0.3, set ZERO_LITESTREAM_BACKUP_USING_V5=false. If you set ZERO_LITESTREAM_EXECUTABLE_V5, use a Litestream build with -fork-to-url. (fb536f686, c1a586810) Changed defaults: inactive client groups are kept for 168 hours (was 48), replication lag is reported every 10 seconds (was 30), and ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_TIMEOUT_PROPORTION is 4 (was 2). Startup can take longer while zero-cache waits for a backup and for replication to catch up, so give readiness probes more time. (#6484, 1c5ddcdb4, #6521, #6465) Server mutators: return values must be JSON. A custom Database must pass addRetryPredicate on to the transaction it creates, or tx.retryOn() won't work. Using tx after its transaction has ended now throws. (7756eb9bb, 4fe91c0d8) ZQLDatabase.run() no longer wraps reads in a transaction, so per-transaction settings such as SET LOCAL don't apply to it. (#6577) useSuspenseQuery with an options object and no suspendUntil now suspends until the first rows arrive, as documented. Pass suspendUntil: 'complete' for the old behavior. (ffe67b23a) Upgrading from 1.9 or earlier: clients start with a new local database, and mutations they hadn't sent yet are lost. Clients upgrading from 1.10 keep theirs. (d597c69e8, 28e581483) Client storage: a client whose local store is corrupt now deletes it and calls onClientStateNotFound. Unsent mutations are lost. (#6558) One-time query reload: queries are identified differently in 1.11, so after upgrading, and again after a rollback, zero-cache transforms and hydrates each client's queries once more. Expect a burst of requests to ZERO_QUERY_URL. (#6450, #6462) Deprecated: ZERO_CHANGE_STREAMER_MODE. Use ZERO_CHANGE_STREAMER_URI instead. (698286b55) Events: there's a new zero/events/status/view-syncer/v1 event type. Event consumers that reject unknown types must accept it. (#6621)",
+ "headings": [
+ {
+ "text": "Installation",
+ "id": "installation"
+ },
+ {
+ "text": "Overview",
+ "id": "overview"
+ },
+ {
+ "text": "Features",
+ "id": "features"
+ },
+ {
+ "text": "Performance",
+ "id": "performance"
+ },
+ {
+ "text": "Client Cold Start",
+ "id": "client-cold-start"
+ },
+ {
+ "text": "Server-Side Incremental Updates",
+ "id": "server-side-incremental-updates"
+ },
+ {
+ "text": "React Native",
+ "id": "react-native"
+ },
+ {
+ "text": "Building and Hashing Queries",
+ "id": "building-and-hashing-queries"
+ },
+ {
+ "text": "Client Writes With Many Joined Queries Got Slower",
+ "id": "client-writes-with-many-joined-queries-got-slower"
+ },
+ {
+ "text": "Fixes",
+ "id": "fixes"
+ },
+ {
+ "text": "Query Results",
+ "id": "query-results"
+ },
+ {
+ "text": "Clients and React Native",
+ "id": "clients-and-react-native"
+ },
+ {
+ "text": "zero-cache Reliability",
+ "id": "zero-cache-reliability"
+ },
+ {
+ "text": "Replication, Backfills, and Backups",
+ "id": "replication-backfills-and-backups"
+ },
+ {
+ "text": "Type Checking",
+ "id": "type-checking"
+ },
+ {
+ "text": "Breaking Changes",
+ "id": "breaking-changes"
+ }
+ ],
+ "kind": "page"
+ },
+ {
+ "id": "446-release-notes/1.11#installation",
+ "title": "Zero 1.11",
+ "searchTitle": "Installation",
+ "sectionTitle": "Installation",
+ "sectionId": "installation",
+ "url": "/docs/release-notes/1.11",
+ "content": "npm install @rocicorp/zero@1.11 Deploy zero-cache before updating clients. Zero 1.11 clients cannot connect to an older server. To roll back, roll back clients first. In multi-node deployments, upgrade the replication-manager before the view-syncers, and roll back the view-syncers first.",
+ "kind": "section"
+ },
+ {
+ "id": "447-release-notes/1.11#overview",
+ "title": "Zero 1.11",
+ "searchTitle": "Overview",
+ "sectionTitle": "Overview",
+ "sectionId": "overview",
+ "url": "/docs/release-notes/1.11",
+ "content": "Zero 1.11 tells you when local query results were already confirmed by the server, keeps working when local storage fails, and makes Litestream v5 the default for backups. Client cold start is about 7x faster in our benchmarks. There are also a lot of fixes, especially in queries and replication.",
+ "kind": "section"
+ },
+ {
+ "id": "448-release-notes/1.11#features",
+ "title": "Zero 1.11",
+ "searchTitle": "Features",
+ "sectionTitle": "Features",
+ "sectionId": "features",
+ "url": "/docs/release-notes/1.11",
+ "content": "Queries now tell you when their local data is a result the server confirmed in an earlier session: they start with the new 'cached' result type instead of 'unknown'. That's enough to show stored data or an empty state without waiting for the server, even offline. (#6578) (thanks @pawarren!) Building the same query again usually gives you the same Query object. useQuery no longer rebuilds and rehashes its query on every render, and queries built inline work as React dependencies. (#6469, #6496) (thanks @charpeni!) Zero starts in a new 'initializing' connection state while it loads local data, and slow loading doesn't count against the connect timeout anymore. (#6561) If local storage can't be opened, fills up, or hits I/O errors, Zero reports it once through the new onStorageFailure option and keeps running in memory. zero.kvStore tells you which store is in use. (#6585, #6657, #6686) (thanks @pawarren and @Karavil!) Clients upgrading from 1.10 keep their local data and pending mutations. Future protocol changes won't reset clients to an empty local database either. (d597c69e8, 28e581483) Server mutators can return a JSON value to the client, run again in a new transaction when tx.retryOn() accepts the error, and run at a chosen transaction isolation level. (7756eb9bb, 4fe91c0d8, cb2821f3a) (thanks @pawarren!) Litestream v5 backups are now the default. A replica restored from another replica's backup continues that backup incrementally. Periodic snapshots keep backups compact, and the replica's WAL stays bounded. (fb536f686, c1a586810, #6476, #6539, #6717) A zero-cache that comes online now waits for replication to catch up before it reports ready. (1c5ddcdb4) Inactive client groups are kept for 7 days instead of 48 hours, so users who come back within a week don't need a full resync. (#6484) zero-cache now logs slow queries with the reason and suggested indexes, and periodically logs per-query statistics. A new operator password gives access to CPU profiles (/profz) and replica planner statistics (/plannerz) without the admin password. (#6446, #6639, #6673, #6675, #6699) A new hydration budget caps the time a view-syncer spends re-hydrating a reconnecting client's inactive queries, and a per-query hydration timeout aborts any query that takes too long to hydrate. Both are off by default. (#6477, #6499) New OpenTelemetry metrics track WAL usage. Replication status events now include index-build progress, and a new view-syncer status event reports restore progress. (#6591, #6621, #6714) Server mutations can send their logs to your own LogSink. (#6677) (thanks @pawarren!) The zero Docker image is down to 807 MB from about 1.09 GB. (#6695)",
+ "kind": "section"
+ },
+ {
+ "id": "449-release-notes/1.11#performance",
+ "title": "Zero 1.11",
+ "searchTitle": "Performance",
+ "sectionTitle": "Performance",
+ "sectionId": "performance",
+ "url": "/docs/release-notes/1.11",
+ "content": "Client Cold Start Apps usually register queries before Zero has finished loading local data, for example when useQuery runs at startup. Zero 1.10 then pushed every loaded row through every registered query. Zero 1.11 waits for the data to load and hydrates each query once (#6472). With 13 queries and about 30,000 local rows, startup is about 7x faster, and about 8x faster on React Native. Server-Side Incremental Updates When a transaction replicates, zero-cache has to bring every client group's queries up to date. Zero 1.11 does this with about 2.5x less CPU. We measured 100 client groups on one sync worker, each syncing a query with a related subquery. React Native Zero's query engine is about 1.7x faster on React Native. That's the geometric mean of 53 query engine benchmarks on Hermes, and 48 of them got more than 5% faster. Hydrating queries is 1.6x to 4.8x faster, and applying changes 1.2x to 2.2x faster. Building and Hashing Queries Components rebuild their queries on every render, and Zero hashes each query to identify it. Building the same query again now usually returns the same object, with its hash already computed (#6469). That makes building and hashing a query about 7x to 17x faster, depending on its size. Client Writes With Many Joined Queries Got Slower This is the one benchmark that got slower. With 300 open queries, each joining the same four tables, applying one write in the client's query engine takes about 1.2x longer. Joins now keep an index of their parent rows so they can skip changes no parent can see (#6645, #6708). In this benchmark every change reaches some parent, so keeping the index up to date is pure overhead.",
+ "kind": "section"
+ },
+ {
+ "id": "450-release-notes/1.11#client-cold-start",
+ "title": "Zero 1.11",
+ "searchTitle": "Client Cold Start",
+ "sectionTitle": "Client Cold Start",
+ "sectionId": "client-cold-start",
+ "url": "/docs/release-notes/1.11",
+ "content": "Apps usually register queries before Zero has finished loading local data, for example when useQuery runs at startup. Zero 1.10 then pushed every loaded row through every registered query. Zero 1.11 waits for the data to load and hydrates each query once (#6472). With 13 queries and about 30,000 local rows, startup is about 7x faster, and about 8x faster on React Native.",
+ "kind": "section"
+ },
+ {
+ "id": "451-release-notes/1.11#server-side-incremental-updates",
+ "title": "Zero 1.11",
+ "searchTitle": "Server-Side Incremental Updates",
+ "sectionTitle": "Server-Side Incremental Updates",
+ "sectionId": "server-side-incremental-updates",
+ "url": "/docs/release-notes/1.11",
+ "content": "When a transaction replicates, zero-cache has to bring every client group's queries up to date. Zero 1.11 does this with about 2.5x less CPU. We measured 100 client groups on one sync worker, each syncing a query with a related subquery.",
+ "kind": "section"
+ },
+ {
+ "id": "452-release-notes/1.11#react-native",
+ "title": "Zero 1.11",
+ "searchTitle": "React Native",
+ "sectionTitle": "React Native",
+ "sectionId": "react-native",
+ "url": "/docs/release-notes/1.11",
+ "content": "Zero's query engine is about 1.7x faster on React Native. That's the geometric mean of 53 query engine benchmarks on Hermes, and 48 of them got more than 5% faster. Hydrating queries is 1.6x to 4.8x faster, and applying changes 1.2x to 2.2x faster.",
+ "kind": "section"
+ },
+ {
+ "id": "453-release-notes/1.11#building-and-hashing-queries",
+ "title": "Zero 1.11",
+ "searchTitle": "Building and Hashing Queries",
+ "sectionTitle": "Building and Hashing Queries",
+ "sectionId": "building-and-hashing-queries",
+ "url": "/docs/release-notes/1.11",
+ "content": "Components rebuild their queries on every render, and Zero hashes each query to identify it. Building the same query again now usually returns the same object, with its hash already computed (#6469). That makes building and hashing a query about 7x to 17x faster, depending on its size.",
+ "kind": "section"
+ },
+ {
+ "id": "454-release-notes/1.11#client-writes-with-many-joined-queries-got-slower",
+ "title": "Zero 1.11",
+ "searchTitle": "Client Writes With Many Joined Queries Got Slower",
+ "sectionTitle": "Client Writes With Many Joined Queries Got Slower",
+ "sectionId": "client-writes-with-many-joined-queries-got-slower",
+ "url": "/docs/release-notes/1.11",
+ "content": "This is the one benchmark that got slower. With 300 open queries, each joining the same four tables, applying one write in the client's query engine takes about 1.2x longer. Joins now keep an index of their parent rows so they can skip changes no parent can see (#6645, #6708). In this benchmark every change reaches some parent, so keeping the index up to date is pure overhead.",
+ "kind": "section"
+ },
+ {
+ "id": "455-release-notes/1.11#fixes",
+ "title": "Zero 1.11",
+ "searchTitle": "Fixes",
+ "sectionTitle": "Fixes",
+ "sectionId": "fixes",
+ "url": "/docs/release-notes/1.11",
+ "content": "Query Results Queries using or with a flipped exists could return wrong rows. (#6636) Joins could leave a deleted row on clients, keep a stale row in a limit() query, or fail with node does not exist. (#6660) Rows behind an exists inside a scalar subquery weren't synced, and changes to them didn't propagate. (913c0665f) Catching up a client after a large update could fail with Missing row, and a schema change during rehydration could fail with table has no column named. (#6662, #6616) A SQLite plan that nests one multi-index OR inside another could crash zero-cache in a loop. (6ea17bfcd) (thanks @robertoborgone!) zero-cache called ZERO_QUERY_URL again for unchanged custom queries whenever it rebuilt its query pipelines. A permission change still re-transforms every query. (#6601) In React and SolidJS, two custom queries with the same query but different names or arguments could share one view, and a SolidJS view rebuilt after its query changed kept the old TTL. (#6461, #6497) In mutators, a row inserted without its optional columns didn't read those columns back as null. (d4816752c) Clients and React Native SQLite-backed stores (expo-sqlite, op-sqlite) could return corrupted data under concurrent reads, hang after a failed transaction, or keep part of a failed commit. (#6495, #6655, #6683) (thanks @pawarren!) Healthy clients logged ChunkNotFoundError during persist. A write that would corrupt the local store now fails instead. (#6670, #6559) (thanks @pawarren!) With op-sqlite and a custom location or encryptionKey, dropping the database didn't delete the right file. (#6656) (thanks @pawarren!) Clients could go offline after the app resumed from the background or the computer woke from sleep, and React Native connect timeouts left the client stuck in an error state instead of retrying. (#6510, #6485) (thanks @pawarren!) Slow local startup could make a healthy server look unreachable. (#6475) A mutator that threw while being replayed disconnected the client in a loop. The client now stays connected, and the mutation is still sent to the server. (#6555) Client memory grew with every query used in a session. (#6474) useSuspenseQuery with an options object and no suspendUntil didn't suspend until the first rows arrived, contrary to the docs (see Breaking Changes). (ffe67b23a) (thanks @charpeni!) Routine reconnects, auth errors and server rebalancing were logged as errors. They're now warnings or info, and fatal errors include their cause. (#6581, #6692, #6498, #6682, #6691, 42316d521, 075b4215e, 79adc09fb) (thanks @pawarren!) zero-cache Reliability zero-cache leaked memory and other resources around closed connections, stopped view-syncers, abandoned hydrations, and inspector and profiling requests. (#6500, #6514, #6515, #6516, #6525, #6526, #6527, #6528, #6540, #6541, #6542, #6698) View-syncers could crash or return Internal error when another view-syncer advanced the same client group, when a client connection was missing, or when a push arrived on a closing connection. (#6481, #6530, #6600, #6614, #6631, d545bc7e2) (thanks @jdnvn!) Servers using zeroNodePg crashed when Postgres closed a pooled connection. (1b220fa07) (thanks @pawarren!) Analyzing a query in the Inspector could run a view-syncer out of memory on large tables. (#6596) Error messages could include row values, query literals and mutation arguments. (#6413) Replication, Backfills, and Backups Replication could roll back or stop after an interrupted stream, and a replication-manager whose slot was invalidated crash-looped instead of resyncing. (#6562, #6590) Backfills could apply rows before replication had caught up to them, didn't resume when a view-syncer moved to another replication-manager, and leaked a Postgres connection that held back vacuum. (#6685, #6684, #6513) Replication slots are more robust. Slot creation retries while long transactions block it, slots stay active during long initial syncs, slots dropped outside Zero are handled, and inactive slots are cleaned up. (25a9ea1ac, f3bba7d73, 7a998b566, caf79b161, #6568) Replication lag was measured wrong on Aurora. (e6a2590db) After initial sync, the replication-manager could serve before a backup covered its replica, so restoring view-syncers fell back to Postgres. It now waits for that backup. (#6465) Query pipelines were reset more often than needed, and dropping an upstream index didn't refresh table schemas. (4341cdcbd, 89787f827, bb71cc931) View-syncers now fail fast when the replication-manager is unreachable, and a slow handshake is no longer mistaken for a missed heartbeat. (0af1a7109, #6598) During a resync, the old replication-manager now keeps serving until the new one takes over. (21f349135) Change-database connections leaked after a failed catch-up, and a change-database reset could hang. (#6529, #6632) Litestream could run out of memory in small containers. Its upload concurrency now scales with available memory. (240c05730) Configuration values that start with a dash, such as some shard IDs, were parsed wrong. (81e6ac570) In the zero Docker image, initial sync and backfills could leave a stalled Postgres connection open after copying a table. Other installs keep the 1.10 behavior. (3f139f52a, 22fa3c27e) Type Checking A rejected whereExists(..., {scalar: true}) now explains why, and a misspelled relationship name lists the valid ones. (cc020b368, 12a36dcfe)",
+ "kind": "section"
+ },
+ {
+ "id": "456-release-notes/1.11#query-results",
+ "title": "Zero 1.11",
+ "searchTitle": "Query Results",
+ "sectionTitle": "Query Results",
+ "sectionId": "query-results",
+ "url": "/docs/release-notes/1.11",
+ "content": "Queries using or with a flipped exists could return wrong rows. (#6636) Joins could leave a deleted row on clients, keep a stale row in a limit() query, or fail with node does not exist. (#6660) Rows behind an exists inside a scalar subquery weren't synced, and changes to them didn't propagate. (913c0665f) Catching up a client after a large update could fail with Missing row, and a schema change during rehydration could fail with table has no column named. (#6662, #6616) A SQLite plan that nests one multi-index OR inside another could crash zero-cache in a loop. (6ea17bfcd) (thanks @robertoborgone!) zero-cache called ZERO_QUERY_URL again for unchanged custom queries whenever it rebuilt its query pipelines. A permission change still re-transforms every query. (#6601) In React and SolidJS, two custom queries with the same query but different names or arguments could share one view, and a SolidJS view rebuilt after its query changed kept the old TTL. (#6461, #6497) In mutators, a row inserted without its optional columns didn't read those columns back as null. (d4816752c)",
+ "kind": "section"
+ },
+ {
+ "id": "457-release-notes/1.11#clients-and-react-native",
+ "title": "Zero 1.11",
+ "searchTitle": "Clients and React Native",
+ "sectionTitle": "Clients and React Native",
+ "sectionId": "clients-and-react-native",
+ "url": "/docs/release-notes/1.11",
+ "content": "SQLite-backed stores (expo-sqlite, op-sqlite) could return corrupted data under concurrent reads, hang after a failed transaction, or keep part of a failed commit. (#6495, #6655, #6683) (thanks @pawarren!) Healthy clients logged ChunkNotFoundError during persist. A write that would corrupt the local store now fails instead. (#6670, #6559) (thanks @pawarren!) With op-sqlite and a custom location or encryptionKey, dropping the database didn't delete the right file. (#6656) (thanks @pawarren!) Clients could go offline after the app resumed from the background or the computer woke from sleep, and React Native connect timeouts left the client stuck in an error state instead of retrying. (#6510, #6485) (thanks @pawarren!) Slow local startup could make a healthy server look unreachable. (#6475) A mutator that threw while being replayed disconnected the client in a loop. The client now stays connected, and the mutation is still sent to the server. (#6555) Client memory grew with every query used in a session. (#6474) useSuspenseQuery with an options object and no suspendUntil didn't suspend until the first rows arrived, contrary to the docs (see Breaking Changes). (ffe67b23a) (thanks @charpeni!) Routine reconnects, auth errors and server rebalancing were logged as errors. They're now warnings or info, and fatal errors include their cause. (#6581, #6692, #6498, #6682, #6691, 42316d521, 075b4215e, 79adc09fb) (thanks @pawarren!)",
+ "kind": "section"
+ },
+ {
+ "id": "458-release-notes/1.11#zero-cache-reliability",
+ "title": "Zero 1.11",
+ "searchTitle": "zero-cache Reliability",
+ "sectionTitle": "zero-cache Reliability",
+ "sectionId": "zero-cache-reliability",
+ "url": "/docs/release-notes/1.11",
+ "content": "zero-cache leaked memory and other resources around closed connections, stopped view-syncers, abandoned hydrations, and inspector and profiling requests. (#6500, #6514, #6515, #6516, #6525, #6526, #6527, #6528, #6540, #6541, #6542, #6698) View-syncers could crash or return Internal error when another view-syncer advanced the same client group, when a client connection was missing, or when a push arrived on a closing connection. (#6481, #6530, #6600, #6614, #6631, d545bc7e2) (thanks @jdnvn!) Servers using zeroNodePg crashed when Postgres closed a pooled connection. (1b220fa07) (thanks @pawarren!) Analyzing a query in the Inspector could run a view-syncer out of memory on large tables. (#6596) Error messages could include row values, query literals and mutation arguments. (#6413)",
+ "kind": "section"
+ },
+ {
+ "id": "459-release-notes/1.11#replication-backfills-and-backups",
+ "title": "Zero 1.11",
+ "searchTitle": "Replication, Backfills, and Backups",
+ "sectionTitle": "Replication, Backfills, and Backups",
+ "sectionId": "replication-backfills-and-backups",
+ "url": "/docs/release-notes/1.11",
+ "content": "Replication could roll back or stop after an interrupted stream, and a replication-manager whose slot was invalidated crash-looped instead of resyncing. (#6562, #6590) Backfills could apply rows before replication had caught up to them, didn't resume when a view-syncer moved to another replication-manager, and leaked a Postgres connection that held back vacuum. (#6685, #6684, #6513) Replication slots are more robust. Slot creation retries while long transactions block it, slots stay active during long initial syncs, slots dropped outside Zero are handled, and inactive slots are cleaned up. (25a9ea1ac, f3bba7d73, 7a998b566, caf79b161, #6568) Replication lag was measured wrong on Aurora. (e6a2590db) After initial sync, the replication-manager could serve before a backup covered its replica, so restoring view-syncers fell back to Postgres. It now waits for that backup. (#6465) Query pipelines were reset more often than needed, and dropping an upstream index didn't refresh table schemas. (4341cdcbd, 89787f827, bb71cc931) View-syncers now fail fast when the replication-manager is unreachable, and a slow handshake is no longer mistaken for a missed heartbeat. (0af1a7109, #6598) During a resync, the old replication-manager now keeps serving until the new one takes over. (21f349135) Change-database connections leaked after a failed catch-up, and a change-database reset could hang. (#6529, #6632) Litestream could run out of memory in small containers. Its upload concurrency now scales with available memory. (240c05730) Configuration values that start with a dash, such as some shard IDs, were parsed wrong. (81e6ac570) In the zero Docker image, initial sync and backfills could leave a stalled Postgres connection open after copying a table. Other installs keep the 1.10 behavior. (3f139f52a, 22fa3c27e)",
+ "kind": "section"
+ },
+ {
+ "id": "460-release-notes/1.11#type-checking",
+ "title": "Zero 1.11",
+ "searchTitle": "Type Checking",
+ "sectionTitle": "Type Checking",
+ "sectionId": "type-checking",
+ "url": "/docs/release-notes/1.11",
+ "content": "A rejected whereExists(..., {scalar: true}) now explains why, and a misspelled relationship name lists the valid ones. (cc020b368, 12a36dcfe)",
+ "kind": "section"
+ },
+ {
+ "id": "461-release-notes/1.11#breaking-changes",
+ "title": "Zero 1.11",
+ "searchTitle": "Breaking Changes",
+ "sectionTitle": "Breaking Changes",
+ "sectionId": "breaking-changes",
+ "url": "/docs/release-notes/1.11",
+ "content": "Legacy queries: zero-cache now rejects queries sent directly by clients. If you still use legacy queries, set ZERO_ALLOW_LEGACY_QUERIES=true or move to custom queries. (#6399) New states: if your code switches over every result type or connection state, handle 'cached' and 'initializing'. Treat 'cached' like 'unknown': the data may be out of date. (#6578, #6561) Litestream v5 by default: remove the ZERO_LITESTREAM_VFS_* options; they no longer exist. Each replication-manager backs up to its own folder under the backup URL, so end the URL with / and add a lifecycle policy to your bucket. To keep writing legacy WAL backups with Litestream v0.3, set ZERO_LITESTREAM_BACKUP_USING_V5=false. If you set ZERO_LITESTREAM_EXECUTABLE_V5, use a Litestream build with -fork-to-url. (fb536f686, c1a586810) Changed defaults: inactive client groups are kept for 168 hours (was 48), replication lag is reported every 10 seconds (was 30), and ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_TIMEOUT_PROPORTION is 4 (was 2). Startup can take longer while zero-cache waits for a backup and for replication to catch up, so give readiness probes more time. (#6484, 1c5ddcdb4, #6521, #6465) Server mutators: return values must be JSON. A custom Database must pass addRetryPredicate on to the transaction it creates, or tx.retryOn() won't work. Using tx after its transaction has ended now throws. (7756eb9bb, 4fe91c0d8) ZQLDatabase.run() no longer wraps reads in a transaction, so per-transaction settings such as SET LOCAL don't apply to it. (#6577) useSuspenseQuery with an options object and no suspendUntil now suspends until the first rows arrive, as documented. Pass suspendUntil: 'complete' for the old behavior. (ffe67b23a) Upgrading from 1.9 or earlier: clients start with a new local database, and mutations they hadn't sent yet are lost. Clients upgrading from 1.10 keep theirs. (d597c69e8, 28e581483) Client storage: a client whose local store is corrupt now deletes it and calls onClientStateNotFound. Unsent mutations are lost. (#6558) One-time query reload: queries are identified differently in 1.11, so after upgrading, and again after a rollback, zero-cache transforms and hydrates each client's queries once more. Expect a burst of requests to ZERO_QUERY_URL. (#6450, #6462) Deprecated: ZERO_CHANGE_STREAMER_MODE. Use ZERO_CHANGE_STREAMER_URI instead. (698286b55) Events: there's a new zero/events/status/view-syncer/v1 event type. Event consumers that reject unknown types must accept it. (#6621)",
+ "kind": "section"
+ },
+ {
+ "id": "56-release-notes/1.2",
"title": "Zero 1.2",
"searchTitle": "Zero 1.2",
"url": "/docs/release-notes/1.2",
@@ -5615,7 +5947,7 @@
"kind": "page"
},
{
- "id": "438-release-notes/1.2#installation",
+ "id": "462-release-notes/1.2#installation",
"title": "Zero 1.2",
"searchTitle": "Installation",
"sectionTitle": "Installation",
@@ -5625,7 +5957,7 @@
"kind": "section"
},
{
- "id": "439-release-notes/1.2#features",
+ "id": "463-release-notes/1.2#features",
"title": "Zero 1.2",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -5635,7 +5967,7 @@
"kind": "section"
},
{
- "id": "440-release-notes/1.2#performance",
+ "id": "464-release-notes/1.2#performance",
"title": "Zero 1.2",
"searchTitle": "Performance",
"sectionTitle": "Performance",
@@ -5645,7 +5977,7 @@
"kind": "section"
},
{
- "id": "441-release-notes/1.2#fixes",
+ "id": "465-release-notes/1.2#fixes",
"title": "Zero 1.2",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -5655,7 +5987,7 @@
"kind": "section"
},
{
- "id": "442-release-notes/1.2#breaking-changes",
+ "id": "466-release-notes/1.2#breaking-changes",
"title": "Zero 1.2",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -5665,7 +5997,7 @@
"kind": "section"
},
{
- "id": "56-release-notes/1.3",
+ "id": "57-release-notes/1.3",
"title": "Zero 1.3",
"searchTitle": "Zero 1.3",
"url": "/docs/release-notes/1.3",
@@ -5695,7 +6027,7 @@
"kind": "page"
},
{
- "id": "443-release-notes/1.3#installation",
+ "id": "467-release-notes/1.3#installation",
"title": "Zero 1.3",
"searchTitle": "Installation",
"sectionTitle": "Installation",
@@ -5705,7 +6037,7 @@
"kind": "section"
},
{
- "id": "444-release-notes/1.3#features",
+ "id": "468-release-notes/1.3#features",
"title": "Zero 1.3",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -5715,7 +6047,7 @@
"kind": "section"
},
{
- "id": "445-release-notes/1.3#performance",
+ "id": "469-release-notes/1.3#performance",
"title": "Zero 1.3",
"searchTitle": "Performance",
"sectionTitle": "Performance",
@@ -5725,7 +6057,7 @@
"kind": "section"
},
{
- "id": "446-release-notes/1.3#fixes",
+ "id": "470-release-notes/1.3#fixes",
"title": "Zero 1.3",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -5735,7 +6067,7 @@
"kind": "section"
},
{
- "id": "447-release-notes/1.3#breaking-changes",
+ "id": "471-release-notes/1.3#breaking-changes",
"title": "Zero 1.3",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -5745,7 +6077,7 @@
"kind": "section"
},
{
- "id": "57-release-notes/1.4",
+ "id": "58-release-notes/1.4",
"title": "Zero 1.4",
"searchTitle": "Zero 1.4",
"url": "/docs/release-notes/1.4",
@@ -5783,7 +6115,7 @@
"kind": "page"
},
{
- "id": "448-release-notes/1.4#installation",
+ "id": "472-release-notes/1.4#installation",
"title": "Zero 1.4",
"searchTitle": "Installation",
"sectionTitle": "Installation",
@@ -5793,7 +6125,7 @@
"kind": "section"
},
{
- "id": "449-release-notes/1.4#upgrading",
+ "id": "473-release-notes/1.4#upgrading",
"title": "Zero 1.4",
"searchTitle": "Upgrading",
"sectionTitle": "Upgrading",
@@ -5803,7 +6135,7 @@
"kind": "section"
},
{
- "id": "450-release-notes/1.4#userid-anon",
+ "id": "474-release-notes/1.4#userid-anon",
"title": "Zero 1.4",
"searchTitle": "userID: \"anon\"",
"sectionTitle": "userID: \"anon\"",
@@ -5813,7 +6145,7 @@
"kind": "section"
},
{
- "id": "451-release-notes/1.4#features",
+ "id": "475-release-notes/1.4#features",
"title": "Zero 1.4",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -5823,7 +6155,7 @@
"kind": "section"
},
{
- "id": "452-release-notes/1.4#performance",
+ "id": "476-release-notes/1.4#performance",
"title": "Zero 1.4",
"searchTitle": "Performance",
"sectionTitle": "Performance",
@@ -5833,7 +6165,7 @@
"kind": "section"
},
{
- "id": "453-release-notes/1.4#fixes",
+ "id": "477-release-notes/1.4#fixes",
"title": "Zero 1.4",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -5843,7 +6175,7 @@
"kind": "section"
},
{
- "id": "454-release-notes/1.4#breaking-changes",
+ "id": "478-release-notes/1.4#breaking-changes",
"title": "Zero 1.4",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -5853,7 +6185,7 @@
"kind": "section"
},
{
- "id": "58-release-notes/1.5",
+ "id": "59-release-notes/1.5",
"title": "Zero 1.5",
"searchTitle": "Zero 1.5",
"url": "/docs/release-notes/1.5",
@@ -5895,7 +6227,7 @@
"kind": "page"
},
{
- "id": "455-release-notes/1.5#installation",
+ "id": "479-release-notes/1.5#installation",
"title": "Zero 1.5",
"searchTitle": "Installation",
"sectionTitle": "Installation",
@@ -5905,7 +6237,7 @@
"kind": "section"
},
{
- "id": "456-release-notes/1.5#upgrading",
+ "id": "480-release-notes/1.5#upgrading",
"title": "Zero 1.5",
"searchTitle": "Upgrading",
"sectionTitle": "Upgrading",
@@ -5915,7 +6247,7 @@
"kind": "section"
},
{
- "id": "457-release-notes/1.5#authenticated-client-groups",
+ "id": "481-release-notes/1.5#authenticated-client-groups",
"title": "Zero 1.5",
"searchTitle": "Authenticated Client Groups",
"sectionTitle": "Authenticated Client Groups",
@@ -5925,7 +6257,7 @@
"kind": "section"
},
{
- "id": "458-release-notes/1.5#deploy-order",
+ "id": "482-release-notes/1.5#deploy-order",
"title": "Zero 1.5",
"searchTitle": "Deploy Order",
"sectionTitle": "Deploy Order",
@@ -5935,7 +6267,7 @@
"kind": "section"
},
{
- "id": "459-release-notes/1.5#features",
+ "id": "483-release-notes/1.5#features",
"title": "Zero 1.5",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -5945,7 +6277,7 @@
"kind": "section"
},
{
- "id": "460-release-notes/1.5#performance",
+ "id": "484-release-notes/1.5#performance",
"title": "Zero 1.5",
"searchTitle": "Performance",
"sectionTitle": "Performance",
@@ -5955,7 +6287,7 @@
"kind": "section"
},
{
- "id": "461-release-notes/1.5#fixes",
+ "id": "485-release-notes/1.5#fixes",
"title": "Zero 1.5",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -5965,7 +6297,7 @@
"kind": "section"
},
{
- "id": "462-release-notes/1.5#breaking-changes",
+ "id": "486-release-notes/1.5#breaking-changes",
"title": "Zero 1.5",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -5975,7 +6307,7 @@
"kind": "section"
},
{
- "id": "59-release-notes/1.6",
+ "id": "60-release-notes/1.6",
"title": "Zero 1.6",
"searchTitle": "Zero 1.6",
"url": "/docs/release-notes/1.6",
@@ -6013,7 +6345,7 @@
"kind": "page"
},
{
- "id": "463-release-notes/1.6#installation",
+ "id": "487-release-notes/1.6#installation",
"title": "Zero 1.6",
"searchTitle": "Installation",
"sectionTitle": "Installation",
@@ -6023,7 +6355,7 @@
"kind": "section"
},
{
- "id": "464-release-notes/1.6#upgrading",
+ "id": "488-release-notes/1.6#upgrading",
"title": "Zero 1.6",
"searchTitle": "Upgrading",
"sectionTitle": "Upgrading",
@@ -6033,7 +6365,7 @@
"kind": "section"
},
{
- "id": "465-release-notes/1.6#planetscale-failover",
+ "id": "489-release-notes/1.6#planetscale-failover",
"title": "Zero 1.6",
"searchTitle": "PlanetScale Failover",
"sectionTitle": "PlanetScale Failover",
@@ -6043,7 +6375,7 @@
"kind": "section"
},
{
- "id": "466-release-notes/1.6#features",
+ "id": "490-release-notes/1.6#features",
"title": "Zero 1.6",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -6053,7 +6385,7 @@
"kind": "section"
},
{
- "id": "467-release-notes/1.6#performance",
+ "id": "491-release-notes/1.6#performance",
"title": "Zero 1.6",
"searchTitle": "Performance",
"sectionTitle": "Performance",
@@ -6063,7 +6395,7 @@
"kind": "section"
},
{
- "id": "468-release-notes/1.6#fixes",
+ "id": "492-release-notes/1.6#fixes",
"title": "Zero 1.6",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -6073,7 +6405,7 @@
"kind": "section"
},
{
- "id": "469-release-notes/1.6#breaking-changes",
+ "id": "493-release-notes/1.6#breaking-changes",
"title": "Zero 1.6",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -6083,7 +6415,7 @@
"kind": "section"
},
{
- "id": "60-release-notes/1.7",
+ "id": "61-release-notes/1.7",
"title": "Zero 1.7",
"searchTitle": "Zero 1.7",
"url": "/docs/release-notes/1.7",
@@ -6125,7 +6457,7 @@
"kind": "page"
},
{
- "id": "470-release-notes/1.7#installation",
+ "id": "494-release-notes/1.7#installation",
"title": "Zero 1.7",
"searchTitle": "Installation",
"sectionTitle": "Installation",
@@ -6135,7 +6467,7 @@
"kind": "section"
},
{
- "id": "471-release-notes/1.7#overview",
+ "id": "495-release-notes/1.7#overview",
"title": "Zero 1.7",
"searchTitle": "Overview",
"sectionTitle": "Overview",
@@ -6145,7 +6477,7 @@
"kind": "section"
},
{
- "id": "472-release-notes/1.7#features",
+ "id": "496-release-notes/1.7#features",
"title": "Zero 1.7",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -6155,7 +6487,7 @@
"kind": "section"
},
{
- "id": "473-release-notes/1.7#performance",
+ "id": "497-release-notes/1.7#performance",
"title": "Zero 1.7",
"searchTitle": "Performance",
"sectionTitle": "Performance",
@@ -6165,7 +6497,7 @@
"kind": "section"
},
{
- "id": "474-release-notes/1.7#replication",
+ "id": "498-release-notes/1.7#replication",
"title": "Zero 1.7",
"searchTitle": "Replication",
"sectionTitle": "Replication",
@@ -6175,7 +6507,7 @@
"kind": "section"
},
{
- "id": "475-release-notes/1.7#flipped-exists-queries",
+ "id": "499-release-notes/1.7#flipped-exists-queries",
"title": "Zero 1.7",
"searchTitle": "Flipped Exists Queries",
"sectionTitle": "Flipped Exists Queries",
@@ -6185,7 +6517,7 @@
"kind": "section"
},
{
- "id": "476-release-notes/1.7#fixes",
+ "id": "500-release-notes/1.7#fixes",
"title": "Zero 1.7",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -6195,7 +6527,7 @@
"kind": "section"
},
{
- "id": "477-release-notes/1.7#breaking-changes",
+ "id": "501-release-notes/1.7#breaking-changes",
"title": "Zero 1.7",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -6205,7 +6537,7 @@
"kind": "section"
},
{
- "id": "61-release-notes/1.8",
+ "id": "62-release-notes/1.8",
"title": "Zero 1.8",
"searchTitle": "Zero 1.8",
"url": "/docs/release-notes/1.8",
@@ -6251,7 +6583,7 @@
"kind": "page"
},
{
- "id": "478-release-notes/1.8#installation",
+ "id": "502-release-notes/1.8#installation",
"title": "Zero 1.8",
"searchTitle": "Installation",
"sectionTitle": "Installation",
@@ -6261,7 +6593,7 @@
"kind": "section"
},
{
- "id": "479-release-notes/1.8#overview",
+ "id": "503-release-notes/1.8#overview",
"title": "Zero 1.8",
"searchTitle": "Overview",
"sectionTitle": "Overview",
@@ -6271,7 +6603,7 @@
"kind": "section"
},
{
- "id": "480-release-notes/1.8#features",
+ "id": "504-release-notes/1.8#features",
"title": "Zero 1.8",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -6281,7 +6613,7 @@
"kind": "section"
},
{
- "id": "481-release-notes/1.8#performance",
+ "id": "505-release-notes/1.8#performance",
"title": "Zero 1.8",
"searchTitle": "Performance",
"sectionTitle": "Performance",
@@ -6291,7 +6623,7 @@
"kind": "section"
},
{
- "id": "482-release-notes/1.8#replicating-large-transactions",
+ "id": "506-release-notes/1.8#replicating-large-transactions",
"title": "Zero 1.8",
"searchTitle": "Replicating Large Transactions",
"sectionTitle": "Replicating Large Transactions",
@@ -6301,7 +6633,7 @@
"kind": "section"
},
{
- "id": "483-release-notes/1.8#maintaining-limit-queries",
+ "id": "507-release-notes/1.8#maintaining-limit-queries",
"title": "Zero 1.8",
"searchTitle": "Maintaining limit() Queries",
"sectionTitle": "Maintaining limit() Queries",
@@ -6311,7 +6643,7 @@
"kind": "section"
},
{
- "id": "484-release-notes/1.8#client-side-hydration",
+ "id": "508-release-notes/1.8#client-side-hydration",
"title": "Zero 1.8",
"searchTitle": "Client-Side Hydration",
"sectionTitle": "Client-Side Hydration",
@@ -6321,7 +6653,7 @@
"kind": "section"
},
{
- "id": "485-release-notes/1.8#fixes",
+ "id": "509-release-notes/1.8#fixes",
"title": "Zero 1.8",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -6331,7 +6663,7 @@
"kind": "section"
},
{
- "id": "486-release-notes/1.8#breaking-changes",
+ "id": "510-release-notes/1.8#breaking-changes",
"title": "Zero 1.8",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -6341,7 +6673,7 @@
"kind": "section"
},
{
- "id": "62-release-notes/1.9",
+ "id": "63-release-notes/1.9",
"title": "Zero 1.9",
"searchTitle": "Zero 1.9",
"url": "/docs/release-notes/1.9",
@@ -6375,7 +6707,7 @@
"kind": "page"
},
{
- "id": "487-release-notes/1.9#installation",
+ "id": "511-release-notes/1.9#installation",
"title": "Zero 1.9",
"searchTitle": "Installation",
"sectionTitle": "Installation",
@@ -6385,7 +6717,7 @@
"kind": "section"
},
{
- "id": "488-release-notes/1.9#overview",
+ "id": "512-release-notes/1.9#overview",
"title": "Zero 1.9",
"searchTitle": "Overview",
"sectionTitle": "Overview",
@@ -6395,7 +6727,7 @@
"kind": "section"
},
{
- "id": "489-release-notes/1.9#features",
+ "id": "513-release-notes/1.9#features",
"title": "Zero 1.9",
"searchTitle": "Features",
"sectionTitle": "Features",
@@ -6405,7 +6737,7 @@
"kind": "section"
},
{
- "id": "490-release-notes/1.9#performance",
+ "id": "514-release-notes/1.9#performance",
"title": "Zero 1.9",
"searchTitle": "Performance",
"sectionTitle": "Performance",
@@ -6415,7 +6747,7 @@
"kind": "section"
},
{
- "id": "491-release-notes/1.9#cold-mutation-latency",
+ "id": "515-release-notes/1.9#cold-mutation-latency",
"title": "Zero 1.9",
"searchTitle": "Cold Mutation Latency",
"sectionTitle": "Cold Mutation Latency",
@@ -6425,7 +6757,7 @@
"kind": "section"
},
{
- "id": "492-release-notes/1.9#fixes",
+ "id": "516-release-notes/1.9#fixes",
"title": "Zero 1.9",
"searchTitle": "Fixes",
"sectionTitle": "Fixes",
@@ -6435,16 +6767,16 @@
"kind": "section"
},
{
- "id": "63-release-notes",
+ "id": "64-release-notes",
"title": "Release Notes",
"searchTitle": "Release Notes",
"url": "/docs/release-notes",
- "content": "Zero 1.10: Unique Keys and More Reliable Replication Zero 1.9: Stability and Query Correctness Zero 1.8: Observability and Reliability Zero 1.7: Query Correctness and Performance Zero 1.6: PlanetScale Failover Support Zero 1.5: Schema Change Improvements and Client Group Auth Zero 1.4: Performance and Reliability Improvements Zero 1.3: Faster Initial Sync and Other Perf Improvements Zero 1.2: IVM Performance and Bug Fixes Zero 1.1: Replication Monitoring Zero 1.0: First Stable Release 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",
+ "content": "Zero 1.11: Cached Results and Sturdier Client Storage Zero 1.10: Unique Keys and More Reliable Replication Zero 1.9: Stability and Query Correctness Zero 1.8: Observability and Reliability Zero 1.7: Query Correctness and Performance Zero 1.6: PlanetScale Failover Support Zero 1.5: Schema Change Improvements and Client Group Auth Zero 1.4: Performance and Reliability Improvements Zero 1.3: Faster Initial Sync and Other Perf Improvements Zero 1.2: IVM Performance and Bug Fixes Zero 1.1: Replication Monitoring Zero 1.0: First Stable Release 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": "64-reporting-bugs",
+ "id": "65-reporting-bugs",
"title": "Reporting Bugs",
"searchTitle": "Reporting Bugs",
"url": "/docs/reporting-bugs",
@@ -6462,7 +6794,7 @@
"kind": "page"
},
{
- "id": "493-reporting-bugs#zbugs",
+ "id": "517-reporting-bugs#zbugs",
"title": "Reporting Bugs",
"searchTitle": "zbugs",
"sectionTitle": "zbugs",
@@ -6472,7 +6804,7 @@
"kind": "section"
},
{
- "id": "494-reporting-bugs#discord",
+ "id": "518-reporting-bugs#discord",
"title": "Reporting Bugs",
"searchTitle": "Discord",
"sectionTitle": "Discord",
@@ -6482,7 +6814,7 @@
"kind": "section"
},
{
- "id": "65-rest",
+ "id": "66-rest",
"title": "REST",
"searchTitle": "REST",
"url": "/docs/rest",
@@ -6508,7 +6840,7 @@
"kind": "page"
},
{
- "id": "495-rest#pattern",
+ "id": "519-rest#pattern",
"title": "REST",
"searchTitle": "Pattern",
"sectionTitle": "Pattern",
@@ -6518,7 +6850,7 @@
"kind": "section"
},
{
- "id": "496-rest#tanstack-start-example",
+ "id": "520-rest#tanstack-start-example",
"title": "REST",
"searchTitle": "TanStack Start Example",
"sectionTitle": "TanStack Start Example",
@@ -6528,7 +6860,7 @@
"kind": "section"
},
{
- "id": "497-rest#openapi-generation",
+ "id": "521-rest#openapi-generation",
"title": "REST",
"searchTitle": "OpenAPI Generation",
"sectionTitle": "OpenAPI Generation",
@@ -6538,7 +6870,7 @@
"kind": "section"
},
{
- "id": "498-rest#full-working-example",
+ "id": "522-rest#full-working-example",
"title": "REST",
"searchTitle": "Full Working Example",
"sectionTitle": "Full Working Example",
@@ -6548,7 +6880,7 @@
"kind": "section"
},
{
- "id": "66-roadmap",
+ "id": "67-roadmap",
"title": "Roadmap",
"searchTitle": "Roadmap",
"url": "/docs/roadmap",
@@ -6566,7 +6898,7 @@
"kind": "page"
},
{
- "id": "499-roadmap#q4-2025",
+ "id": "523-roadmap#q4-2025",
"title": "Roadmap",
"searchTitle": "Q4 2025",
"sectionTitle": "Q4 2025",
@@ -6576,7 +6908,7 @@
"kind": "section"
},
{
- "id": "500-roadmap#beyond",
+ "id": "524-roadmap#beyond",
"title": "Roadmap",
"searchTitle": "Beyond",
"sectionTitle": "Beyond",
@@ -6586,7 +6918,7 @@
"kind": "section"
},
{
- "id": "67-samples",
+ "id": "68-samples",
"title": "Samples",
"searchTitle": "Samples",
"url": "/docs/samples",
@@ -6612,7 +6944,7 @@
"kind": "page"
},
{
- "id": "501-samples#gigabugs",
+ "id": "525-samples#gigabugs",
"title": "Samples",
"searchTitle": "Gigabugs",
"sectionTitle": "Gigabugs",
@@ -6622,7 +6954,7 @@
"kind": "section"
},
{
- "id": "502-samples#ztunes",
+ "id": "526-samples#ztunes",
"title": "Samples",
"searchTitle": "ztunes",
"sectionTitle": "ztunes",
@@ -6632,7 +6964,7 @@
"kind": "section"
},
{
- "id": "503-samples#zslack",
+ "id": "527-samples#zslack",
"title": "Samples",
"searchTitle": "zslack",
"sectionTitle": "zslack",
@@ -6642,7 +6974,7 @@
"kind": "section"
},
{
- "id": "504-samples#zero-music",
+ "id": "528-samples#zero-music",
"title": "Samples",
"searchTitle": "zero-music",
"sectionTitle": "zero-music",
@@ -6652,7 +6984,7 @@
"kind": "section"
},
{
- "id": "68-schema",
+ "id": "69-schema",
"title": "Zero Schema",
"searchTitle": "Zero Schema",
"url": "/docs/schema",
@@ -6782,7 +7114,7 @@
"kind": "page"
},
{
- "id": "505-schema#generating-from-database",
+ "id": "529-schema#generating-from-database",
"title": "Zero Schema",
"searchTitle": "Generating from Database",
"sectionTitle": "Generating from Database",
@@ -6792,7 +7124,7 @@
"kind": "section"
},
{
- "id": "506-schema#writing-by-hand",
+ "id": "530-schema#writing-by-hand",
"title": "Zero Schema",
"searchTitle": "Writing by Hand",
"sectionTitle": "Writing by Hand",
@@ -6802,7 +7134,7 @@
"kind": "section"
},
{
- "id": "507-schema#table-schemas",
+ "id": "531-schema#table-schemas",
"title": "Zero Schema",
"searchTitle": "Table Schemas",
"sectionTitle": "Table Schemas",
@@ -6812,7 +7144,7 @@
"kind": "section"
},
{
- "id": "508-schema#name-mapping",
+ "id": "532-schema#name-mapping",
"title": "Zero Schema",
"searchTitle": "Name Mapping",
"sectionTitle": "Name Mapping",
@@ -6822,7 +7154,7 @@
"kind": "section"
},
{
- "id": "509-schema#multiple-schemas",
+ "id": "533-schema#multiple-schemas",
"title": "Zero Schema",
"searchTitle": "Multiple Schemas",
"sectionTitle": "Multiple Schemas",
@@ -6832,7 +7164,7 @@
"kind": "section"
},
{
- "id": "510-schema#optional-columns",
+ "id": "534-schema#optional-columns",
"title": "Zero Schema",
"searchTitle": "Optional Columns",
"sectionTitle": "Optional Columns",
@@ -6842,7 +7174,7 @@
"kind": "section"
},
{
- "id": "511-schema#enumerations",
+ "id": "535-schema#enumerations",
"title": "Zero Schema",
"searchTitle": "Enumerations",
"sectionTitle": "Enumerations",
@@ -6852,7 +7184,7 @@
"kind": "section"
},
{
- "id": "512-schema#custom-json-types",
+ "id": "536-schema#custom-json-types",
"title": "Zero Schema",
"searchTitle": "Custom JSON Types",
"sectionTitle": "Custom JSON Types",
@@ -6862,7 +7194,7 @@
"kind": "section"
},
{
- "id": "513-schema#compound-primary-keys",
+ "id": "537-schema#compound-primary-keys",
"title": "Zero Schema",
"searchTitle": "Compound Primary Keys",
"sectionTitle": "Compound Primary Keys",
@@ -6872,7 +7204,7 @@
"kind": "section"
},
{
- "id": "514-schema#unique-keys",
+ "id": "538-schema#unique-keys",
"title": "Zero Schema",
"searchTitle": "Unique Keys",
"sectionTitle": "Unique Keys",
@@ -6882,7 +7214,7 @@
"kind": "section"
},
{
- "id": "515-schema#relationships",
+ "id": "539-schema#relationships",
"title": "Zero Schema",
"searchTitle": "Relationships",
"sectionTitle": "Relationships",
@@ -6892,7 +7224,7 @@
"kind": "section"
},
{
- "id": "516-schema#many-to-many-relationships",
+ "id": "540-schema#many-to-many-relationships",
"title": "Zero Schema",
"searchTitle": "Many-to-Many Relationships",
"sectionTitle": "Many-to-Many Relationships",
@@ -6902,7 +7234,7 @@
"kind": "section"
},
{
- "id": "517-schema#compound-keys-relationships",
+ "id": "541-schema#compound-keys-relationships",
"title": "Zero Schema",
"searchTitle": "Compound Keys Relationships",
"sectionTitle": "Compound Keys Relationships",
@@ -6912,7 +7244,7 @@
"kind": "section"
},
{
- "id": "518-schema#circular-relationships",
+ "id": "542-schema#circular-relationships",
"title": "Zero Schema",
"searchTitle": "Circular Relationships",
"sectionTitle": "Circular Relationships",
@@ -6922,7 +7254,7 @@
"kind": "section"
},
{
- "id": "519-schema#database-schemas",
+ "id": "543-schema#database-schemas",
"title": "Zero Schema",
"searchTitle": "Database Schemas",
"sectionTitle": "Database Schemas",
@@ -6932,7 +7264,7 @@
"kind": "section"
},
{
- "id": "520-schema#register-schema-type",
+ "id": "544-schema#register-schema-type",
"title": "Zero Schema",
"searchTitle": "Register Schema Type",
"sectionTitle": "Register Schema Type",
@@ -6942,7 +7274,7 @@
"kind": "section"
},
{
- "id": "521-schema#schema-changes",
+ "id": "545-schema#schema-changes",
"title": "Zero Schema",
"searchTitle": "Schema Changes",
"sectionTitle": "Schema Changes",
@@ -6952,7 +7284,7 @@
"kind": "section"
},
{
- "id": "522-schema#development",
+ "id": "546-schema#development",
"title": "Zero Schema",
"searchTitle": "Development",
"sectionTitle": "Development",
@@ -6962,7 +7294,7 @@
"kind": "section"
},
{
- "id": "523-schema#production",
+ "id": "547-schema#production",
"title": "Zero Schema",
"searchTitle": "Production",
"sectionTitle": "Production",
@@ -6972,7 +7304,7 @@
"kind": "section"
},
{
- "id": "524-schema#expand-changes",
+ "id": "548-schema#expand-changes",
"title": "Zero Schema",
"searchTitle": "Expand Changes",
"sectionTitle": "Expand Changes",
@@ -6982,7 +7314,7 @@
"kind": "section"
},
{
- "id": "525-schema#contract-changes",
+ "id": "549-schema#contract-changes",
"title": "Zero Schema",
"searchTitle": "Contract Changes",
"sectionTitle": "Contract Changes",
@@ -6992,7 +7324,7 @@
"kind": "section"
},
{
- "id": "526-schema#compound-changes",
+ "id": "550-schema#compound-changes",
"title": "Zero Schema",
"searchTitle": "Compound Changes",
"sectionTitle": "Compound Changes",
@@ -7002,7 +7334,7 @@
"kind": "section"
},
{
- "id": "527-schema#examples",
+ "id": "551-schema#examples",
"title": "Zero Schema",
"searchTitle": "Examples",
"sectionTitle": "Examples",
@@ -7012,7 +7344,7 @@
"kind": "section"
},
{
- "id": "528-schema#adding-a-column",
+ "id": "552-schema#adding-a-column",
"title": "Zero Schema",
"searchTitle": "Adding a Column",
"sectionTitle": "Adding a Column",
@@ -7022,7 +7354,7 @@
"kind": "section"
},
{
- "id": "529-schema#removing-a-column",
+ "id": "553-schema#removing-a-column",
"title": "Zero Schema",
"searchTitle": "Removing a Column",
"sectionTitle": "Removing a Column",
@@ -7032,7 +7364,7 @@
"kind": "section"
},
{
- "id": "530-schema#renaming-a-column",
+ "id": "554-schema#renaming-a-column",
"title": "Zero Schema",
"searchTitle": "Renaming a Column",
"sectionTitle": "Renaming a Column",
@@ -7042,7 +7374,7 @@
"kind": "section"
},
{
- "id": "531-schema#making-a-column-optional",
+ "id": "555-schema#making-a-column-optional",
"title": "Zero Schema",
"searchTitle": "Making a Column Optional",
"sectionTitle": "Making a Column Optional",
@@ -7052,7 +7384,7 @@
"kind": "section"
},
{
- "id": "532-schema#quick-reference",
+ "id": "556-schema#quick-reference",
"title": "Zero Schema",
"searchTitle": "Quick Reference",
"sectionTitle": "Quick Reference",
@@ -7062,7 +7394,7 @@
"kind": "section"
},
{
- "id": "533-schema#backfill",
+ "id": "557-schema#backfill",
"title": "Zero Schema",
"searchTitle": "Backfill",
"sectionTitle": "Backfill",
@@ -7072,7 +7404,7 @@
"kind": "section"
},
{
- "id": "534-schema#monitoring-backfill-progress",
+ "id": "558-schema#monitoring-backfill-progress",
"title": "Zero Schema",
"searchTitle": "Monitoring Backfill Progress",
"sectionTitle": "Monitoring Backfill Progress",
@@ -7082,7 +7414,7 @@
"kind": "section"
},
{
- "id": "69-self-host",
+ "id": "70-self-host",
"title": "Self-Hosting Zero",
"searchTitle": "Self-Hosting Zero",
"url": "/docs/self-host",
@@ -7144,7 +7476,7 @@
"kind": "page"
},
{
- "id": "535-self-host#docker-images",
+ "id": "559-self-host#docker-images",
"title": "Self-Hosting Zero",
"searchTitle": "Docker Images",
"sectionTitle": "Docker Images",
@@ -7154,7 +7486,7 @@
"kind": "section"
},
{
- "id": "536-self-host#minimum-viable-strategy",
+ "id": "560-self-host#minimum-viable-strategy",
"title": "Self-Hosting Zero",
"searchTitle": "Minimum Viable Strategy",
"sectionTitle": "Minimum Viable Strategy",
@@ -7164,7 +7496,7 @@
"kind": "section"
},
{
- "id": "537-self-host#maximal-strategy",
+ "id": "561-self-host#maximal-strategy",
"title": "Self-Hosting Zero",
"searchTitle": "Maximal Strategy",
"sectionTitle": "Maximal Strategy",
@@ -7174,7 +7506,7 @@
"kind": "section"
},
{
- "id": "538-self-host#replica-lifecycle",
+ "id": "562-self-host#replica-lifecycle",
"title": "Self-Hosting Zero",
"searchTitle": "Replica Lifecycle",
"sectionTitle": "Replica Lifecycle",
@@ -7184,7 +7516,7 @@
"kind": "section"
},
{
- "id": "539-self-host#performance",
+ "id": "563-self-host#performance",
"title": "Self-Hosting Zero",
"searchTitle": "Performance",
"sectionTitle": "Performance",
@@ -7194,7 +7526,7 @@
"kind": "section"
},
{
- "id": "540-self-host#hydration",
+ "id": "564-self-host#hydration",
"title": "Self-Hosting Zero",
"searchTitle": "Hydration",
"sectionTitle": "Hydration",
@@ -7204,7 +7536,7 @@
"kind": "section"
},
{
- "id": "541-self-host#ivm-advancement",
+ "id": "565-self-host#ivm-advancement",
"title": "Self-Hosting Zero",
"searchTitle": "IVM advancement",
"sectionTitle": "IVM advancement",
@@ -7214,7 +7546,7 @@
"kind": "section"
},
{
- "id": "542-self-host#system-level",
+ "id": "566-self-host#system-level",
"title": "Self-Hosting Zero",
"searchTitle": "System-level",
"sectionTitle": "System-level",
@@ -7224,7 +7556,7 @@
"kind": "section"
},
{
- "id": "543-self-host#networking",
+ "id": "567-self-host#networking",
"title": "Self-Hosting Zero",
"searchTitle": "Networking",
"sectionTitle": "Networking",
@@ -7234,7 +7566,7 @@
"kind": "section"
},
{
- "id": "544-self-host#sticky-sessions",
+ "id": "568-self-host#sticky-sessions",
"title": "Self-Hosting Zero",
"searchTitle": "Sticky Sessions",
"sectionTitle": "Sticky Sessions",
@@ -7244,7 +7576,7 @@
"kind": "section"
},
{
- "id": "545-self-host#rolling-updates",
+ "id": "569-self-host#rolling-updates",
"title": "Self-Hosting Zero",
"searchTitle": "Rolling Updates",
"sectionTitle": "Rolling Updates",
@@ -7254,7 +7586,7 @@
"kind": "section"
},
{
- "id": "546-self-host#clientserver-version-compatibility",
+ "id": "570-self-host#clientserver-version-compatibility",
"title": "Self-Hosting Zero",
"searchTitle": "Client/Server Version Compatibility",
"sectionTitle": "Client/Server Version Compatibility",
@@ -7264,7 +7596,7 @@
"kind": "section"
},
{
- "id": "547-self-host#configuration",
+ "id": "571-self-host#configuration",
"title": "Self-Hosting Zero",
"searchTitle": "Configuration",
"sectionTitle": "Configuration",
@@ -7274,16 +7606,20 @@
"kind": "section"
},
{
- "id": "70-server-zql",
+ "id": "71-server-zql",
"title": "ZQL on the Server",
"searchTitle": "ZQL on the Server",
"url": "/docs/server-zql",
- "content": "The Zero package includes utilities to run ZQL on the server directly against your upstream Postgres database. This is useful for many reasons: It allows mutators to read data using ZQL to check permissions or invariants. You can use ZQL to implement standard REST endpoints, allowing you to share code with mutators. In the future (but not yet implemented), this can support server-side rendering. ZQLDatabase currently does a read of your postgres schema before every transaction. This is fine for most usages, but for high scale it may become a problem. Let us know if you need a fix for this. Creating a Database To run ZQL on the server, you will create a ZQLDatabase instance. Zero ships with several built-in factories for popular Postgres libraries and ORMs. // app/api/mutate/db-provider.ts import {zeroDrizzle} from '@rocicorp/zero/server/adapters/drizzle' import {schema} from '../../zero/schema.ts' import * as drizzleSchema from '../../drizzle/schema.ts' // pass a drizzle client instance. for example: export const drizzleClient = drizzle(pool, { schema: drizzleSchema }) export const dbProvider = zeroDrizzle(schema, drizzleClient) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// app/api/mutate/db-provider.ts import {Kysely, PostgresDialect} from 'kysely' import {zeroKysely} from '@rocicorp/zero/server/adapters/kysely' import {Pool} from 'pg' import {schema} from '../../zero/schema.ts' interface Database { user: { id: string name: string | null status: 'active' | 'inactive' } } const kysely = new Kysely({ dialect: new PostgresDialect({ pool: new Pool({ connectionString: process.env.ZERO_UPSTREAM_DB! }) }) }) export const dbProvider = zeroKysely(schema, kysely) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// app/api/mutate/db-provider.ts import {PrismaPg} from '@prisma/adapter-pg' import {PrismaClient} from '@prisma/client' import {zeroPrisma} from '@rocicorp/zero/server/adapters/prisma' import {schema} from '../../zero/schema.ts' const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.ZERO_UPSTREAM_DB! }) }) export const dbProvider = zeroPrisma(schema, prisma) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// app/api/mutate/db-provider.ts import {zeroNodePg} from '@rocicorp/zero/server/adapters/pg' import {Pool} from 'pg' import {schema} from '../../zero/schema.ts' const pool = new Pool({ connectionString: process.env.ZERO_UPSTREAM_DB! }) export const dbProvider = zeroNodePg(schema, pool) // You can also pass a client instead of a pool: // // const client = new Client({ // connectionString: process.env.ZERO_UPSTREAM_DB! // }) // await client.connect() // export const dbProvider = zeroNodePg(schema, client) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// app/api/mutate/db-provider.ts import {zeroPostgresJS} from '@rocicorp/zero/server/adapters/postgresjs' import postgres from 'postgres' import {schema} from '../../zero/schema.ts' const sql = postgres(process.env.ZERO_UPSTREAM_DB!) export const dbProvider = zeroPostgresJS(schema, sql) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } } Within your mutators, you can access the underlying transaction via tx.dbTransaction.wrappedTransaction: // mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction .insert(drizzleSchema.user) .values({id, name}) } } ) })// mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction .insertInto('user') .values({id, name, status: 'active'}) .execute() } } ) })// mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction.user.create( { data: { id, name, status: 'active' } } ) } } ) })// mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction.query( 'insert into \"user\" (id, name) values ($1, $2) returning *', [id, name] ) } } ) })// mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction< {id: string; name: string}[] >`insert into \"user\" (id, name) values (${id}, ${name}) returning *` } } ) }) Custom Database To implement support for some other Postgres bindings library, you will implement the DBConnection interface. See the implementations for the existing adapters for examples. Running ZQL Once you have an instance of ZQLDatabase, use the transaction() method to run ZQL: await dbProvider.transaction(async tx => { // await tx.mutate... // await tx.query... // await myMutator.fn({tx, ctx, args}) }) SSR Zero doesn't yet have the wiring setup in its bindings layers to really nicely support server-side rendering (patches welcome though!). For now, we don't recommend using Zero with SSR. Use your framework's recommended pattern to prevent SSR execution: import {lazy} from 'react' // Use React lazy to defer loading the ZeroProvider const ZeroProvider = lazy(() => import('@rocicorp/zero/react').then(mod => ({ default: mod.ZeroProvider })) ) function Root() { return ( ) }// Mark client-only components 'use client' import {ZeroProvider} from '@rocicorp/zero/react' export default function Root() { return ( ) }import {clientOnly} from '@solidjs/start' const ZeroProvider = clientOnly(async () => { // Optionally dynamic import to code-split return import('@rocicorp/zero/solid').then(mod => ({ default: mod.ZeroProvider })) }) export default function Root() { return ( ) }",
+ "content": "The Zero package includes utilities to run ZQL on the server directly against your upstream Postgres database. This is useful for many reasons: It allows mutators to read data using ZQL to check permissions or invariants. You can use ZQL to implement standard REST endpoints, allowing you to share code with mutators. In the future (but not yet implemented), this can support server-side rendering. ZQLDatabase currently does a read of your postgres schema before every transaction. This is fine for most usages, but for high scale it may become a problem. Let us know if you need a fix for this. Creating a Database To run ZQL on the server, you will create a ZQLDatabase instance. Zero ships with several built-in factories for popular Postgres libraries and ORMs. // app/api/mutate/db-provider.ts import {zeroDrizzle} from '@rocicorp/zero/server/adapters/drizzle' import {schema} from '../../zero/schema.ts' import * as drizzleSchema from '../../drizzle/schema.ts' // pass a drizzle client instance. for example: export const drizzleClient = drizzle(pool, { schema: drizzleSchema }) export const dbProvider = zeroDrizzle(schema, drizzleClient) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// app/api/mutate/db-provider.ts import {Kysely, PostgresDialect} from 'kysely' import {zeroKysely} from '@rocicorp/zero/server/adapters/kysely' import {Pool} from 'pg' import {schema} from '../../zero/schema.ts' interface Database { user: { id: string name: string | null status: 'active' | 'inactive' } } const kysely = new Kysely({ dialect: new PostgresDialect({ pool: new Pool({ connectionString: process.env.ZERO_UPSTREAM_DB! }) }) }) export const dbProvider = zeroKysely(schema, kysely) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// app/api/mutate/db-provider.ts import {PrismaPg} from '@prisma/adapter-pg' import {PrismaClient} from '@prisma/client' import {zeroPrisma} from '@rocicorp/zero/server/adapters/prisma' import {schema} from '../../zero/schema.ts' const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.ZERO_UPSTREAM_DB! }) }) export const dbProvider = zeroPrisma(schema, prisma) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// app/api/mutate/db-provider.ts import {zeroNodePg} from '@rocicorp/zero/server/adapters/pg' import {Pool} from 'pg' import {schema} from '../../zero/schema.ts' const pool = new Pool({ connectionString: process.env.ZERO_UPSTREAM_DB! }) export const dbProvider = zeroNodePg(schema, pool) // You can also pass a client instead of a pool: // // const client = new Client({ // connectionString: process.env.ZERO_UPSTREAM_DB! // }) // await client.connect() // export const dbProvider = zeroNodePg(schema, client) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// app/api/mutate/db-provider.ts import {zeroPostgresJS} from '@rocicorp/zero/server/adapters/postgresjs' import postgres from 'postgres' import {schema} from '../../zero/schema.ts' const sql = postgres(process.env.ZERO_UPSTREAM_DB!) export const dbProvider = zeroPostgresJS(schema, sql) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } } Within your mutators, you can access the underlying transaction via tx.dbTransaction.wrappedTransaction: // mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction .insert(drizzleSchema.user) .values({id, name}) } } ) })// mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction .insertInto('user') .values({id, name, status: 'active'}) .execute() } } ) })// mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction.user.create( { data: { id, name, status: 'active' } } ) } } ) })// mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction.query( 'insert into \"user\" (id, name) values ($1, $2) returning *', [id, name] ) } } ) })// mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction< {id: string; name: string}[] >`insert into \"user\" (id, name) values (${id}, ${name}) returning *` } } ) }) Isolation Level By default, each transaction starts with a plain BEGIN, so Postgres uses its default isolation level. On a stock Postgres, that is read committed. To use a different level, pass isolationLevel in the options argument of the adapter factory. It can be 'read committed', 'repeatable read', or 'serializable': export const dbProvider = zeroDrizzle( schema, drizzleClient, { isolationLevel: 'serializable' } )export const dbProvider = zeroKysely(schema, kysely, { isolationLevel: 'serializable' })// Use the Postgres spelling, not Prisma's 'Serializable'. export const dbProvider = zeroPrisma(schema, prisma, { isolationLevel: 'serializable' })// The third argument is an optional logger. export const dbProvider = zeroNodePg( schema, pool, undefined, { isolationLevel: 'serializable' } )export const dbProvider = zeroPostgresJS(schema, sql, { isolationLevel: 'serializable' }) Every transaction the adapter opens uses this level, including the ones your mutators run in. The TransactionOptions and IsolationLevel types are exported from @rocicorp/zero/server. At repeatable read and serializable, Postgres can abort a transaction that conflicts with a concurrent one. Use tx.retryOn() to run the mutator again when that happens. Custom Database To implement support for some other Postgres bindings library, you will implement the DBConnection interface. See the implementations for the existing adapters for examples. Running ZQL Once you have an instance of ZQLDatabase, use the transaction() method to run ZQL: await dbProvider.transaction(async tx => { // await tx.mutate... // await tx.query... // await myMutator.fn({tx, ctx, args}) }) SSR Zero doesn't yet have the wiring setup in its bindings layers to really nicely support server-side rendering (patches welcome though!). For now, we don't recommend using Zero with SSR. Use your framework's recommended pattern to prevent SSR execution: import {lazy} from 'react' // Use React lazy to defer loading the ZeroProvider const ZeroProvider = lazy(() => import('@rocicorp/zero/react').then(mod => ({ default: mod.ZeroProvider })) ) function Root() { return ( ) }// Mark client-only components 'use client' import {ZeroProvider} from '@rocicorp/zero/react' export default function Root() { return ( ) }import {clientOnly} from '@solidjs/start' const ZeroProvider = clientOnly(async () => { // Optionally dynamic import to code-split return import('@rocicorp/zero/solid').then(mod => ({ default: mod.ZeroProvider })) }) export default function Root() { return ( ) }",
"headings": [
{
"text": "Creating a Database",
"id": "creating-a-database"
},
+ {
+ "text": "Isolation Level",
+ "id": "isolation-level"
+ },
{
"text": "Custom Database",
"id": "custom-database"
@@ -7300,17 +7636,27 @@
"kind": "page"
},
{
- "id": "548-server-zql#creating-a-database",
+ "id": "572-server-zql#creating-a-database",
"title": "ZQL on the Server",
"searchTitle": "Creating a Database",
"sectionTitle": "Creating a Database",
"sectionId": "creating-a-database",
"url": "/docs/server-zql",
- "content": "To run ZQL on the server, you will create a ZQLDatabase instance. Zero ships with several built-in factories for popular Postgres libraries and ORMs. // app/api/mutate/db-provider.ts import {zeroDrizzle} from '@rocicorp/zero/server/adapters/drizzle' import {schema} from '../../zero/schema.ts' import * as drizzleSchema from '../../drizzle/schema.ts' // pass a drizzle client instance. for example: export const drizzleClient = drizzle(pool, { schema: drizzleSchema }) export const dbProvider = zeroDrizzle(schema, drizzleClient) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// app/api/mutate/db-provider.ts import {Kysely, PostgresDialect} from 'kysely' import {zeroKysely} from '@rocicorp/zero/server/adapters/kysely' import {Pool} from 'pg' import {schema} from '../../zero/schema.ts' interface Database { user: { id: string name: string | null status: 'active' | 'inactive' } } const kysely = new Kysely({ dialect: new PostgresDialect({ pool: new Pool({ connectionString: process.env.ZERO_UPSTREAM_DB! }) }) }) export const dbProvider = zeroKysely(schema, kysely) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// app/api/mutate/db-provider.ts import {PrismaPg} from '@prisma/adapter-pg' import {PrismaClient} from '@prisma/client' import {zeroPrisma} from '@rocicorp/zero/server/adapters/prisma' import {schema} from '../../zero/schema.ts' const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.ZERO_UPSTREAM_DB! }) }) export const dbProvider = zeroPrisma(schema, prisma) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// app/api/mutate/db-provider.ts import {zeroNodePg} from '@rocicorp/zero/server/adapters/pg' import {Pool} from 'pg' import {schema} from '../../zero/schema.ts' const pool = new Pool({ connectionString: process.env.ZERO_UPSTREAM_DB! }) export const dbProvider = zeroNodePg(schema, pool) // You can also pass a client instead of a pool: // // const client = new Client({ // connectionString: process.env.ZERO_UPSTREAM_DB! // }) // await client.connect() // export const dbProvider = zeroNodePg(schema, client) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// app/api/mutate/db-provider.ts import {zeroPostgresJS} from '@rocicorp/zero/server/adapters/postgresjs' import postgres from 'postgres' import {schema} from '../../zero/schema.ts' const sql = postgres(process.env.ZERO_UPSTREAM_DB!) export const dbProvider = zeroPostgresJS(schema, sql) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } } Within your mutators, you can access the underlying transaction via tx.dbTransaction.wrappedTransaction: // mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction .insert(drizzleSchema.user) .values({id, name}) } } ) })// mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction .insertInto('user') .values({id, name, status: 'active'}) .execute() } } ) })// mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction.user.create( { data: { id, name, status: 'active' } } ) } } ) })// mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction.query( 'insert into \"user\" (id, name) values ($1, $2) returning *', [id, name] ) } } ) })// mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction< {id: string; name: string}[] >`insert into \"user\" (id, name) values (${id}, ${name}) returning *` } } ) }) Custom Database To implement support for some other Postgres bindings library, you will implement the DBConnection interface. See the implementations for the existing adapters for examples.",
+ "content": "To run ZQL on the server, you will create a ZQLDatabase instance. Zero ships with several built-in factories for popular Postgres libraries and ORMs. // app/api/mutate/db-provider.ts import {zeroDrizzle} from '@rocicorp/zero/server/adapters/drizzle' import {schema} from '../../zero/schema.ts' import * as drizzleSchema from '../../drizzle/schema.ts' // pass a drizzle client instance. for example: export const drizzleClient = drizzle(pool, { schema: drizzleSchema }) export const dbProvider = zeroDrizzle(schema, drizzleClient) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// app/api/mutate/db-provider.ts import {Kysely, PostgresDialect} from 'kysely' import {zeroKysely} from '@rocicorp/zero/server/adapters/kysely' import {Pool} from 'pg' import {schema} from '../../zero/schema.ts' interface Database { user: { id: string name: string | null status: 'active' | 'inactive' } } const kysely = new Kysely({ dialect: new PostgresDialect({ pool: new Pool({ connectionString: process.env.ZERO_UPSTREAM_DB! }) }) }) export const dbProvider = zeroKysely(schema, kysely) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// app/api/mutate/db-provider.ts import {PrismaPg} from '@prisma/adapter-pg' import {PrismaClient} from '@prisma/client' import {zeroPrisma} from '@rocicorp/zero/server/adapters/prisma' import {schema} from '../../zero/schema.ts' const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.ZERO_UPSTREAM_DB! }) }) export const dbProvider = zeroPrisma(schema, prisma) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// app/api/mutate/db-provider.ts import {zeroNodePg} from '@rocicorp/zero/server/adapters/pg' import {Pool} from 'pg' import {schema} from '../../zero/schema.ts' const pool = new Pool({ connectionString: process.env.ZERO_UPSTREAM_DB! }) export const dbProvider = zeroNodePg(schema, pool) // You can also pass a client instead of a pool: // // const client = new Client({ // connectionString: process.env.ZERO_UPSTREAM_DB! // }) // await client.connect() // export const dbProvider = zeroNodePg(schema, client) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// app/api/mutate/db-provider.ts import {zeroPostgresJS} from '@rocicorp/zero/server/adapters/postgresjs' import postgres from 'postgres' import {schema} from '../../zero/schema.ts' const sql = postgres(process.env.ZERO_UPSTREAM_DB!) export const dbProvider = zeroPostgresJS(schema, sql) // Register the database provider for type safety declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } } Within your mutators, you can access the underlying transaction via tx.dbTransaction.wrappedTransaction: // mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction .insert(drizzleSchema.user) .values({id, name}) } } ) })// mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction .insertInto('user') .values({id, name, status: 'active'}) .execute() } } ) })// mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction.user.create( { data: { id, name, status: 'active' } } ) } } ) })// mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction.query( 'insert into \"user\" (id, name) values ($1, $2) returning *', [id, name] ) } } ) })// mutators.ts export const mutators = defineMutators({ createUser: defineMutator( z.object({id: z.string(), name: z.string()}), async ({tx, args: {id, name}}) => { if (tx.location === 'server') { await tx.dbTransaction.wrappedTransaction< {id: string; name: string}[] >`insert into \"user\" (id, name) values (${id}, ${name}) returning *` } } ) }) Isolation Level By default, each transaction starts with a plain BEGIN, so Postgres uses its default isolation level. On a stock Postgres, that is read committed. To use a different level, pass isolationLevel in the options argument of the adapter factory. It can be 'read committed', 'repeatable read', or 'serializable': export const dbProvider = zeroDrizzle( schema, drizzleClient, { isolationLevel: 'serializable' } )export const dbProvider = zeroKysely(schema, kysely, { isolationLevel: 'serializable' })// Use the Postgres spelling, not Prisma's 'Serializable'. export const dbProvider = zeroPrisma(schema, prisma, { isolationLevel: 'serializable' })// The third argument is an optional logger. export const dbProvider = zeroNodePg( schema, pool, undefined, { isolationLevel: 'serializable' } )export const dbProvider = zeroPostgresJS(schema, sql, { isolationLevel: 'serializable' }) Every transaction the adapter opens uses this level, including the ones your mutators run in. The TransactionOptions and IsolationLevel types are exported from @rocicorp/zero/server. At repeatable read and serializable, Postgres can abort a transaction that conflicts with a concurrent one. Use tx.retryOn() to run the mutator again when that happens. Custom Database To implement support for some other Postgres bindings library, you will implement the DBConnection interface. See the implementations for the existing adapters for examples.",
"kind": "section"
},
{
- "id": "549-server-zql#custom-database",
+ "id": "573-server-zql#isolation-level",
+ "title": "ZQL on the Server",
+ "searchTitle": "Isolation Level",
+ "sectionTitle": "Isolation Level",
+ "sectionId": "isolation-level",
+ "url": "/docs/server-zql",
+ "content": "By default, each transaction starts with a plain BEGIN, so Postgres uses its default isolation level. On a stock Postgres, that is read committed. To use a different level, pass isolationLevel in the options argument of the adapter factory. It can be 'read committed', 'repeatable read', or 'serializable': export const dbProvider = zeroDrizzle( schema, drizzleClient, { isolationLevel: 'serializable' } )export const dbProvider = zeroKysely(schema, kysely, { isolationLevel: 'serializable' })// Use the Postgres spelling, not Prisma's 'Serializable'. export const dbProvider = zeroPrisma(schema, prisma, { isolationLevel: 'serializable' })// The third argument is an optional logger. export const dbProvider = zeroNodePg( schema, pool, undefined, { isolationLevel: 'serializable' } )export const dbProvider = zeroPostgresJS(schema, sql, { isolationLevel: 'serializable' }) Every transaction the adapter opens uses this level, including the ones your mutators run in. The TransactionOptions and IsolationLevel types are exported from @rocicorp/zero/server. At repeatable read and serializable, Postgres can abort a transaction that conflicts with a concurrent one. Use tx.retryOn() to run the mutator again when that happens.",
+ "kind": "section"
+ },
+ {
+ "id": "574-server-zql#custom-database",
"title": "ZQL on the Server",
"searchTitle": "Custom Database",
"sectionTitle": "Custom Database",
@@ -7320,7 +7666,7 @@
"kind": "section"
},
{
- "id": "550-server-zql#running-zql",
+ "id": "575-server-zql#running-zql",
"title": "ZQL on the Server",
"searchTitle": "Running ZQL",
"sectionTitle": "Running ZQL",
@@ -7330,7 +7676,7 @@
"kind": "section"
},
{
- "id": "551-server-zql#ssr",
+ "id": "576-server-zql#ssr",
"title": "ZQL on the Server",
"searchTitle": "SSR",
"sectionTitle": "SSR",
@@ -7340,7 +7686,7 @@
"kind": "section"
},
{
- "id": "71-solidjs",
+ "id": "72-solidjs",
"title": "SolidJS",
"searchTitle": "SolidJS",
"url": "/docs/solidjs",
@@ -7362,7 +7708,7 @@
"kind": "page"
},
{
- "id": "552-solidjs#setup",
+ "id": "577-solidjs#setup",
"title": "SolidJS",
"searchTitle": "Setup",
"sectionTitle": "Setup",
@@ -7372,7 +7718,7 @@
"kind": "section"
},
{
- "id": "553-solidjs#usage",
+ "id": "578-solidjs#usage",
"title": "SolidJS",
"searchTitle": "Usage",
"sectionTitle": "Usage",
@@ -7382,7 +7728,7 @@
"kind": "section"
},
{
- "id": "554-solidjs#examples",
+ "id": "579-solidjs#examples",
"title": "SolidJS",
"searchTitle": "Examples",
"sectionTitle": "Examples",
@@ -7392,7 +7738,7 @@
"kind": "section"
},
{
- "id": "72-status",
+ "id": "73-status",
"title": "Project Status",
"searchTitle": "Project Status",
"url": "/docs/status",
@@ -7418,7 +7764,7 @@
"kind": "page"
},
{
- "id": "555-status#breaking-changes",
+ "id": "580-status#breaking-changes",
"title": "Project Status",
"searchTitle": "Breaking Changes",
"sectionTitle": "Breaking Changes",
@@ -7428,7 +7774,7 @@
"kind": "section"
},
{
- "id": "556-status#roadmap",
+ "id": "581-status#roadmap",
"title": "Project Status",
"searchTitle": "Roadmap",
"sectionTitle": "Roadmap",
@@ -7438,7 +7784,7 @@
"kind": "section"
},
{
- "id": "557-status#2026",
+ "id": "582-status#2026",
"title": "Project Status",
"searchTitle": "2026",
"sectionTitle": "2026",
@@ -7448,7 +7794,7 @@
"kind": "section"
},
{
- "id": "558-status#soon",
+ "id": "583-status#soon",
"title": "Project Status",
"searchTitle": "Soon",
"sectionTitle": "Soon",
@@ -7458,7 +7804,7 @@
"kind": "section"
},
{
- "id": "73-sync",
+ "id": "74-sync",
"title": "What is Sync?",
"searchTitle": "What is Sync?",
"url": "/docs/sync",
@@ -7480,7 +7826,7 @@
"kind": "page"
},
{
- "id": "559-sync#problem",
+ "id": "584-sync#problem",
"title": "What is Sync?",
"searchTitle": "Problem",
"sectionTitle": "Problem",
@@ -7490,7 +7836,7 @@
"kind": "section"
},
{
- "id": "560-sync#solution",
+ "id": "585-sync#solution",
"title": "What is Sync?",
"searchTitle": "Solution",
"sectionTitle": "Solution",
@@ -7500,7 +7846,7 @@
"kind": "section"
},
{
- "id": "561-sync#history-of-sync",
+ "id": "586-sync#history-of-sync",
"title": "What is Sync?",
"searchTitle": "History of Sync",
"sectionTitle": "History of Sync",
@@ -7510,7 +7856,7 @@
"kind": "section"
},
{
- "id": "74-tutorial",
+ "id": "75-tutorial",
"title": "Tutorial",
"searchTitle": "Tutorial",
"url": "/docs/tutorial",
@@ -7584,7 +7930,7 @@
"kind": "page"
},
{
- "id": "562-tutorial#setup",
+ "id": "587-tutorial#setup",
"title": "Tutorial",
"searchTitle": "Setup",
"sectionTitle": "Setup",
@@ -7594,7 +7940,7 @@
"kind": "section"
},
{
- "id": "563-tutorial#create-a-project",
+ "id": "588-tutorial#create-a-project",
"title": "Tutorial",
"searchTitle": "Create a Project",
"sectionTitle": "Create a Project",
@@ -7604,7 +7950,7 @@
"kind": "section"
},
{
- "id": "564-tutorial#set-up-your-database",
+ "id": "589-tutorial#set-up-your-database",
"title": "Tutorial",
"searchTitle": "Set Up Your Database",
"sectionTitle": "Set Up Your Database",
@@ -7614,7 +7960,7 @@
"kind": "section"
},
{
- "id": "565-tutorial#install-and-run-zero-cache",
+ "id": "590-tutorial#install-and-run-zero-cache",
"title": "Tutorial",
"searchTitle": "Install and Run Zero-Cache",
"sectionTitle": "Install and Run Zero-Cache",
@@ -7624,7 +7970,7 @@
"kind": "section"
},
{
- "id": "566-tutorial#integrate-zero",
+ "id": "591-tutorial#integrate-zero",
"title": "Tutorial",
"searchTitle": "Integrate Zero",
"sectionTitle": "Integrate Zero",
@@ -7634,7 +7980,7 @@
"kind": "section"
},
{
- "id": "567-tutorial#set-up-your-zero-schema",
+ "id": "592-tutorial#set-up-your-zero-schema",
"title": "Tutorial",
"searchTitle": "Set Up Your Zero Schema",
"sectionTitle": "Set Up Your Zero Schema",
@@ -7644,7 +7990,7 @@
"kind": "section"
},
{
- "id": "568-tutorial#set-up-the-zero-client",
+ "id": "593-tutorial#set-up-the-zero-client",
"title": "Tutorial",
"searchTitle": "Set Up the Zero Client",
"sectionTitle": "Set Up the Zero Client",
@@ -7654,7 +8000,7 @@
"kind": "section"
},
{
- "id": "569-tutorial#sync-data",
+ "id": "594-tutorial#sync-data",
"title": "Tutorial",
"searchTitle": "Sync Data",
"sectionTitle": "Sync Data",
@@ -7664,7 +8010,7 @@
"kind": "section"
},
{
- "id": "570-tutorial#define-query",
+ "id": "595-tutorial#define-query",
"title": "Tutorial",
"searchTitle": "Define Query",
"sectionTitle": "Define Query",
@@ -7674,7 +8020,7 @@
"kind": "section"
},
{
- "id": "571-tutorial#add-query-endpoint",
+ "id": "596-tutorial#add-query-endpoint",
"title": "Tutorial",
"searchTitle": "Add Query Endpoint",
"sectionTitle": "Add Query Endpoint",
@@ -7684,7 +8030,7 @@
"kind": "section"
},
{
- "id": "572-tutorial#invoke-query",
+ "id": "597-tutorial#invoke-query",
"title": "Tutorial",
"searchTitle": "Invoke Query",
"sectionTitle": "Invoke Query",
@@ -7694,7 +8040,7 @@
"kind": "section"
},
{
- "id": "573-tutorial#mutate-data",
+ "id": "598-tutorial#mutate-data",
"title": "Tutorial",
"searchTitle": "Mutate Data",
"sectionTitle": "Mutate Data",
@@ -7704,7 +8050,7 @@
"kind": "section"
},
{
- "id": "574-tutorial#define-mutators",
+ "id": "599-tutorial#define-mutators",
"title": "Tutorial",
"searchTitle": "Define Mutators",
"sectionTitle": "Define Mutators",
@@ -7714,7 +8060,7 @@
"kind": "section"
},
{
- "id": "575-tutorial#add-mutate-endpoint",
+ "id": "600-tutorial#add-mutate-endpoint",
"title": "Tutorial",
"searchTitle": "Add Mutate Endpoint",
"sectionTitle": "Add Mutate Endpoint",
@@ -7724,7 +8070,7 @@
"kind": "section"
},
{
- "id": "576-tutorial#invoke-mutators",
+ "id": "601-tutorial#invoke-mutators",
"title": "Tutorial",
"searchTitle": "Invoke Mutators",
"sectionTitle": "Invoke Mutators",
@@ -7734,7 +8080,7 @@
"kind": "section"
},
{
- "id": "577-tutorial#next-steps",
+ "id": "602-tutorial#next-steps",
"title": "Tutorial",
"searchTitle": "Next Steps",
"sectionTitle": "Next Steps",
@@ -7744,7 +8090,7 @@
"kind": "section"
},
{
- "id": "75-when-to-use",
+ "id": "76-when-to-use",
"title": "When To Use Zero",
"searchTitle": "When To Use Zero",
"url": "/docs/when-to-use",
@@ -7810,7 +8156,7 @@
"kind": "page"
},
{
- "id": "578-when-to-use#zero-might-be-a-good-fit",
+ "id": "603-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",
@@ -7820,7 +8166,7 @@
"kind": "section"
},
{
- "id": "579-when-to-use#you-want-to-sync-only-a-small-subset-of-data-to-client",
+ "id": "604-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",
@@ -7830,7 +8176,7 @@
"kind": "section"
},
{
- "id": "580-when-to-use#you-need-fine-grained-read-or-write-permissions",
+ "id": "605-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",
@@ -7840,7 +8186,7 @@
"kind": "section"
},
{
- "id": "581-when-to-use#you-are-building-a-traditional-client-server-web-app",
+ "id": "606-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",
@@ -7850,7 +8196,7 @@
"kind": "section"
},
{
- "id": "582-when-to-use#you-use-postgresql",
+ "id": "607-when-to-use#you-use-postgresql",
"title": "When To Use Zero",
"searchTitle": "You use PostgreSQL",
"sectionTitle": "You use PostgreSQL",
@@ -7860,7 +8206,7 @@
"kind": "section"
},
{
- "id": "583-when-to-use#your-app-is-broadly-like-linear",
+ "id": "608-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\"",
@@ -7870,7 +8216,7 @@
"kind": "section"
},
{
- "id": "584-when-to-use#interaction-performance-is-very-important-to-you",
+ "id": "609-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",
@@ -7880,7 +8226,7 @@
"kind": "section"
},
{
- "id": "585-when-to-use#zero-might-not-be-a-good-fit",
+ "id": "610-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",
@@ -7890,7 +8236,7 @@
"kind": "section"
},
{
- "id": "586-when-to-use#you-need-the-privacy-or-data-ownership-benefits-of-local-first",
+ "id": "611-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",
@@ -7900,7 +8246,7 @@
"kind": "section"
},
{
- "id": "587-when-to-use#you-need-to-support-offline-writes-or-long-periods-offline",
+ "id": "612-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",
@@ -7910,7 +8256,7 @@
"kind": "section"
},
{
- "id": "588-when-to-use#you-are-building-a-native-mobile-app",
+ "id": "613-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",
@@ -7920,7 +8266,7 @@
"kind": "section"
},
{
- "id": "589-when-to-use#the-total-backend-dataset-is--100gb",
+ "id": "614-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",
@@ -7930,7 +8276,7 @@
"kind": "section"
},
{
- "id": "590-when-to-use#zero-might-not-be-a-good-fit-yet",
+ "id": "615-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",
@@ -7940,7 +8286,7 @@
"kind": "section"
},
{
- "id": "591-when-to-use#alternatives",
+ "id": "616-when-to-use#alternatives",
"title": "When To Use Zero",
"searchTitle": "Alternatives",
"sectionTitle": "Alternatives",
@@ -7950,11 +8296,11 @@
"kind": "section"
},
{
- "id": "76-zero-cache-config",
+ "id": "77-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 Auth Revalidate Interval Seconds How often zero-cache re-checks that each live connection is still authorized to use your /query endpoint. On each interval, zero-cache sends a lightweight validation request using that connection's current auth context, such as forwarded cookies or an opaque auth token. If your query endpoint rejects that auth with a 401/403, the connection is disconnected. Use this to bound how long already-open connections can continue after logout, session expiry, token revocation, or other server-side auth changes that happen without a reconnect. Lower values enforce auth changes faster, but send more validation requests to /query. flag: --auth-revalidate-interval-seconds env: ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS default: unset Auth Retransform Interval Seconds How often zero-cache refreshes a client group's synced or named query transformations using one validated connection from that group. This re-runs auth-sensitive query expansion even when the query set itself has not changed. It is useful when your query endpoint generates different ZQL based on current auth or server-side session state, such as roles, organization membership, feature flags, or other permissions-derived context. Use this to bound how long a client group can keep using stale auth-derived query shapes after backend auth state changes. Lower values pick up those changes faster, but do more /query transform work. If clients already call updateAuth whenever auth changes, this mainly serves as a background safety net for out-of-band auth changes. flag: --auth-retransform-interval-seconds env: ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS default: unset 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 Streamer Back Pressure 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-pressure-limit-heap-proportion env: ZERO_CHANGE_STREAMER_BACK_PRESSURE_LIMIT_HEAP_PROPORTION default: 0.04 Change Streamer Flow Control Consensus Timeout Proportion During periodic flow-control checks, this is the amount of additional time to wait after the majority of serving replicas have acknowledged a checkpoint, expressed as a proportion of the time the majority took. Replication continues after that timeout even if some serving replicas have not acknowledged the checkpoint. A backup replica that is still catching up does not delay caught-up serving replicas; once caught up, the backup must acknowledge the checkpoint before replication continues. For example, if the majority responds in 2.5 ms, a proportion of 2 waits up to another 5 ms. The default lets a subscriber take roughly three times as long as the majority before early release. This bounds the effect of a lagging or unresponsive subscriber while allowing for normal processing-time variance. This value is a ratio, not a duration in seconds. Set it to a negative number to disable early releases and wait for all subscribers. flag: --change-streamer-flow-control-consensus-timeout-proportion env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_TIMEOUT_PROPORTION default: 2 Change Streamer Flow Control Slow Subscriber Grace Period Seconds The time a serving subscriber may continuously miss the consensus timeout while processing changes more slowly than healthy subscribers before Zero disconnects it and instructs it to restart. This protects healthy subscribers from zombie or chronically slow tasks while allowing temporary catch-up work to complete. Only serving replicas are reset; the replication-manager's backup replica is not disconnected by this setting. Set this to 0 or a negative number to disable slow-subscriber resets. flag: --change-streamer-flow-control-slow-subscriber-grace-period-seconds env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_SLOW_SUBSCRIBER_GRACE_PERIOD_SECONDS default: 30 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 CRUD Mutations Enables support for legacy CRUD mutations. When this is false, view-syncers do not connect to the upstream database for CRUD writes, and push messages with CRUD mutations return an error response. flag: --enable-crud-mutations env: ZERO_ENABLE_CRUD_MUTATIONS 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. When enabling Litestream v5 backups, configure this as a folder URL ending in /, for example s3://acme-zero-backups/v1/. Zero resolves each replica-specific backup path relative to this URL; without the trailing slash, URL resolution replaces the final path segment instead of nesting the replica backup under it. flag: --litestream-backup-url env: ZERO_LITESTREAM_BACKUP_URL Litestream Endpoint The S3-compatible endpoint URL to use for the litestream backup. This is only required for non-AWS services. The replication-manager and view-syncers must have the same endpoint. For example, to use Cloudflare R2: https://.r2.cloudflarestorage.com. flag: --litestream-endpoint env: ZERO_LITESTREAM_ENDPOINT 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 v3. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. This setting is ignored when backing up with Litestream v5. 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 Config Path V5 Path to the Litestream v5 YAML config file. zero-cache runs this file with the same environment-variable substitution described for ZERO_LITESTREAM_CONFIG_PATH. flag: --litestream-config-path-v5 env: ZERO_LITESTREAM_CONFIG_PATH_V5 default: ./src/services/litestream/config-v5.yml Litestream Executable Path to the litestream executable. This must be built from the rocicorp/litestream fork. This option has no effect if litestream-backup-url is unspecified. flag: --litestream-executable env: ZERO_LITESTREAM_EXECUTABLE Litestream V5 Executable Path to the official Litestream v0.5.x executable used for v5 restores and backups. Litestream v0.5.8 and later can restore both legacy WAL backups and LTX backups, choosing the format with the latest data. When a backup URL is configured and ZERO_LITESTREAM_RESTORE_USING_V5 is enabled, this executable is required. The official Zero Docker image includes Litestream 0.5.16. flag: --litestream-executable-v5 env: ZERO_LITESTREAM_EXECUTABLE_V5 Litestream Restore Using V5 Use ZERO_LITESTREAM_EXECUTABLE_V5 for restores. Set this to false to force legacy restore behavior. When a backup URL is configured, disabling this option is also the way to run without a v5 executable. Litestream v0.5 cannot restore legacy backups encrypted with Age. Keep legacy restore enabled for those backups or migrate them before enabling v5 restore. flag: --litestream-restore-using-v5 env: ZERO_LITESTREAM_RESTORE_USING_V5 default: true Litestream Backup Using V5 Write LTX backups with Litestream v0.5.x. This is disabled by default to continue writing legacy WAL backups. Enabling it requires v5 restore and ZERO_LITESTREAM_VFS_QUERY_EXECUTABLE. With v5 backup enabled, Zero acknowledges upstream changes only after they are applied to the replica and confirmed in the backup. Each replica uses a distinct backup subpath under the folder URL configured by ZERO_LITESTREAM_BACKUP_URL. Configure an object-store lifecycle policy to remove old subpaths; Zero does not delete them automatically. Rolling back to a server that does not understand replica-specific paths can require a full resync. flag: --litestream-backup-using-v5 env: ZERO_LITESTREAM_BACKUP_USING_V5 default: false Litestream VFS Query Executable Path to the vfs-query executable used to monitor Litestream v5 backup progress. This is required when ZERO_LITESTREAM_BACKUP_USING_V5 is enabled. The official Zero Docker image includes this executable. flag: --litestream-vfs-query-executable env: ZERO_LITESTREAM_VFS_QUERY_EXECUTABLE Litestream VFS Poll Interval (ms) How often the Litestream VFS polls backup storage for the latest backup. This controls how quickly Zero can confirm a backup and acknowledge the corresponding upstream changes. flag: --litestream-vfs-poll-interval-ms env: ZERO_LITESTREAM_VFS_POLL_INTERVAL_MS default: 15000 Litestream VFS Poll Timeout (ms) Reserved for configuring request timeouts to the Litestream VFS poller. In this release, vfs-query uses its internal 30-second timeout regardless of this value. flag: --litestream-vfs-poll-timeout-ms env: ZERO_LITESTREAM_VFS_POLL_TIMEOUT_MS default: 10000 Litestream VFS Log File Reserved for redirecting Litestream VFS logs. In this release, vfs-query writes logs to the child process's standard error regardless of this value. flag: --litestream-vfs-log-file env: ZERO_LITESTREAM_VFS_LOG_FILE Litestream Incremental Backup Interval Minutes The interval between incremental Litestream v3 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 restore. For v5 backups, use ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_SECONDS. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Incremental Backup Interval Seconds The interval between incremental Litestream v5 backups. Zero does not acknowledge upstream changes until the corresponding replica changes are confirmed in backup storage. Shorter intervals increase storage requests; longer intervals retain more upstream WAL while confirmation is pending. flag: --litestream-incremental-backup-interval-seconds env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_SECONDS 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. This setting is ignored when backing up with Litestream v5. 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). This setting is ignored when backing up with Litestream v5. 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 Region The AWS region for the litestream backup bucket. Required for non-standard AWS partitions (e.g. GovCloud us-gov-west-1) where Litestream cannot auto-detect the region. The replication-manager and view-syncers must have the same region. flag: --litestream-region env: ZERO_LITESTREAM_REGION 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. Zero retains the previous generation for six additional hours so an active restore can finish before its snapshot and WAL files are removed. This improves restore time and safety at the expense of bandwidth and temporary backup storage. The setting applies to v3 and v5 backups, although v5 generally does not need frequent snapshots for restore speed. 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 Allowed Client Headers Comma-separated allowlist of client-provided custom headers to forward to your mutate endpoint. Header names are matched case-insensitively. By default, no client-provided custom headers are forwarded. flag: --mutate-allowed-client-headers env: ZERO_MUTATE_ALLOWED_CLIENT_HEADERS default: none Mutate Allowed Request Headers Comma-separated allowlist of HTTP headers from the request that opened the WebSocket to forward to your mutate endpoint. Use this for proxy- or load-balancer-injected headers such as x-forwarded-for or cf-ray. Unlike mutate allowed client headers, these values come from the request that established the connection. Header names are matched case-insensitively. Values are retained for the WebSocket's lifetime, so clients must reconnect to receive changes. The allowlist does not verify the header source - only allow headers that a trusted proxy overwrites or removes from untrusted requests. No request headers are forwarded by default. flag: --mutate-allowed-request-headers env: ZERO_MUTATE_ALLOWED_REQUEST_HEADERS default: none 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 max(1, availableParallelism() - 1), reserving one core for the replicator. 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 PG Replication Slot Failover For upstream Postgres 17+, creates replication slots with the failover flag enabled so they can be synchronized to a standby and survive a failover. This requires additional Postgres-side configuration on your provider; see High Availability. Has no effect on Postgres versions before 17. flag: --upstream-pg-replication-slot-failover env: ZERO_UPSTREAM_PG_REPLICATION_SLOT_FAILOVER default: false 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 Allowed Client Headers Comma-separated allowlist of client-provided custom headers to forward to your query endpoint. Header names are matched case-insensitively. By default, no client-provided custom headers are forwarded. flag: --query-allowed-client-headers env: ZERO_QUERY_ALLOWED_CLIENT_HEADERS default: none Query Allowed Request Headers Comma-separated allowlist of HTTP headers from the request that opened the WebSocket to forward to your query endpoint. Use this for proxy- or load-balancer-injected headers such as x-forwarded-for or cf-ray. Unlike query allowed client headers, these values come from the request that established the connection. Header names are matched case-insensitively. Values are retained for the WebSocket's lifetime, so clients must reconnect to receive changes. The allowlist does not verify the header source - only allow headers that a trusted proxy overwrites or removes from untrusted requests. No request headers are forwarded by default. flag: --query-allowed-request-headers env: ZERO_QUERY_ALLOWED_REQUEST_HEADERS default: none 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 Replication Lag Report Interval (ms) The minimum interval at which replication lag reports are written upstream and reported via the zero.replication.total_lag OpenTelemetry metric. If an expected report is not received before the next interval, Zero emits a new report and increments zero.replication.lag_report_retries. This feature requires write access to upstream Postgres (uses pg_logical_emit_message()). For PostgreSQL 17+, lag measurements accurately reflect committed write latency (single-digit milliseconds). For PostgreSQL 16 and earlier, measurements may appear 50-100ms longer due to flush behavior. A negative or 0 value disables lag reporting. Even if otel is not enabled, info and warn-level logs are emitted for large lag values. flag: --replication-lag-report-interval-ms env: ZERO_REPLICATION_LAG_REPORT_INTERVAL_MS default: 30_000 Server Version The version string outputted to logs when the server starts up. flag: --server-version env: ZERO_SERVER_VERSION Shadow Sync Enabled Periodically exercises the initial-sync code path against a sample of rows from every published table, writing to a throwaway SQLite database. This acts as a canary: if the real initial-sync path breaks because of schema drift, Postgres version quirks, or another full-resync issue, the shadow run fails before a customer actually needs a full reset. flag: --shadow-sync-enabled env: ZERO_SHADOW_SYNC_ENABLED default: false Shadow Sync Interval Hours The interval between shadow initial-sync runs, in hours. The first run fires within [2/3, 1) of this interval after startup, so the canary completes at least once per task lifetime while still jittering fleet restarts. flag: --shadow-sync-interval-hours env: ZERO_SHADOW_SYNC_INTERVAL_HOURS default: 12 Shadow Sync Sample Rate The Bernoulli sampling rate for each table, where 0 < rate <= 1. A value of 1 disables sampling and copies all rows, still subject to --shadow-sync-max-rows-per-table. flag: --shadow-sync-sample-rate env: ZERO_SHADOW_SYNC_SAMPLE_RATE default: 0.1 Shadow Sync Max Rows Per Table The hard upper bound on rows copied per table per shadow run. This guards against unexpectedly large tables consuming too much disk or upstream bandwidth. flag: --shadow-sync-max-rows-per-table env: ZERO_SHADOW_SYNC_MAX_ROWS_PER_TABLE default: 10000 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 Upstream PG Replication Slot Failover For upstream PostgreSQL 17 and later, create replication slots with the failover parameter set to true to enable slot synchronization and failover. Additional Postgres-level configuration is required when enabling this option. This option has no effect for PostgreSQL versions before 17. See the PostgreSQL docs for details: https://www.postgresql.org/docs/current/logicaldecoding-explanation.html#LOGICALDECODING-REPLICATION-SLOTS-SYNCHRONIZATION flag: --upstream-pg-replication-slot-failover env: ZERO_UPSTREAM_PG_REPLICATION_SLOT_FAILOVER default: false Upstream PG Stream Inbound Timeout The time, in milliseconds, without an inbound message from the upstream WAL sender after which zero-cache tears down the replication stream to force a reconnect. By default, the threshold is twice the server's wal_sender_timeout. Increase this value when a healthy WAL sender can remain silent while decoding unpublished WAL or assembling a large transaction. This changes only Zero's inbound timeout; keepalive timing remains derived from wal_sender_timeout. The option has no effect when wal_sender_timeout is 0, which disables inbound liveness detection. See WAL Sender Timeout. flag: --upstream-pg-stream-inbound-timeout-ms env: ZERO_UPSTREAM_PG_STREAM_INBOUND_TIMEOUT_MS 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. flag: --websocket-max-payload-bytes env: ZERO_WEBSOCKET_MAX_PAYLOAD_BYTES default: 10485760 (10 MiB) 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 Change Max Connections ZERO_CHANGE_MAX_CONNS is deprecated and no longer controls change database connection usage. Size the database or pooler as described under Change DB. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS 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. The admin password also grants access to everything that the operator password does. flag: --admin-password env: ZERO_ADMIN_PASSWORD required: in production (when NODE_ENV=production) Optional Flags Allow Legacy Queries Allows clients to send query ASTs directly to zero-cache. Ad-hoc queries (also called legacy queries) work this way. When this is false, zero-cache rejects client-sent ASTs without parsing them and closes the connection with an error. Keep it disabled when you use queries. flag: --allow-legacy-queries env: ZERO_ALLOW_LEGACY_QUERIES default: false 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 Auth Revalidate Interval Seconds How often zero-cache re-checks that each live connection is still authorized to use your /query endpoint. On each interval, zero-cache sends a lightweight validation request using that connection's current auth context, such as forwarded cookies or an opaque auth token. If your query endpoint rejects that auth with a 401/403, the connection is disconnected. Use this to bound how long already-open connections can continue after logout, session expiry, token revocation, or other server-side auth changes that happen without a reconnect. Lower values enforce auth changes faster, but send more validation requests to /query. flag: --auth-revalidate-interval-seconds env: ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS default: 300 Auth Retransform Interval Seconds How often zero-cache refreshes a client group's synced or named query transformations using one validated connection from that group. This re-runs auth-sensitive query expansion even when the query set itself has not changed. It is useful when your query endpoint generates different ZQL based on current auth or server-side session state, such as roles, organization membership, feature flags, or other permissions-derived context. Use this to bound how long a client group can keep using stale auth-derived query shapes after backend auth state changes. Lower values pick up those changes faster, but do more /query transform work. If clients already call updateAuth whenever auth changes, this mainly serves as a background safety net for out-of-band auth changes. flag: --auth-retransform-interval-seconds env: ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS default: 300 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 Streamer Back Pressure 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-pressure-limit-heap-proportion env: ZERO_CHANGE_STREAMER_BACK_PRESSURE_LIMIT_HEAP_PROPORTION default: 0.04 Change Streamer Flow Control Consensus Timeout Proportion During periodic flow-control checks, this is the amount of additional time to wait after the majority of serving replicas have acknowledged a checkpoint, expressed as a proportion of the time the majority took. Replication continues after that timeout even if some serving replicas have not acknowledged the checkpoint. A backup replica that is still catching up does not delay caught-up serving replicas; once caught up, the backup must acknowledge the checkpoint before replication continues. For example, if the majority responds in 2.5 ms, a proportion of 2 waits up to another 5 ms. The default lets a subscriber take roughly five times as long as the majority before early release. This bounds the effect of a lagging or unresponsive subscriber while allowing for normal processing-time variance. This value is a ratio, not a duration in seconds. Set it to a negative number to disable early releases and wait for all subscribers. flag: --change-streamer-flow-control-consensus-timeout-proportion env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_TIMEOUT_PROPORTION default: 4 Change Streamer Flow Control Slow Subscriber Grace Period Seconds The time a serving subscriber may continuously miss the consensus timeout while processing changes more slowly than healthy subscribers before Zero disconnects it and instructs it to restart. This protects healthy subscribers from zombie or chronically slow tasks while allowing temporary catch-up work to complete. Only serving replicas are reset; the replication-manager's backup replica is not disconnected by this setting. Set this to 0 or a negative number to disable slow-subscriber resets. flag: --change-streamer-flow-control-slow-subscriber-grace-period-seconds env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_SLOW_SUBSCRIBER_GRACE_PERIOD_SECONDS default: 30 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. Purging a CVR forces the next connection from that client group to sync from scratch. Set this well above how long a typical user goes between sessions. flag: --cvr-garbage-collection-inactivity-threshold-hours env: ZERO_CVR_GARBAGE_COLLECTION_INACTIVITY_THRESHOLD_HOURS default: 168 (one week) 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 CRUD Mutations Enables support for legacy CRUD mutations. When this is false, view-syncers do not connect to the upstream database for CRUD writes, and push messages with CRUD mutations return an error response. flag: --enable-crud-mutations env: ZERO_ENABLE_CRUD_MUTATIONS 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. With Litestream v5 backups (the default), configure this as a folder URL ending in /, for example s3://acme-zero-backups/v1/. Zero resolves each replica-specific backup path relative to this URL; without the trailing slash, URL resolution replaces the final path segment instead of nesting the replica backup under it. flag: --litestream-backup-url env: ZERO_LITESTREAM_BACKUP_URL Litestream Endpoint The S3-compatible endpoint URL to use for the litestream backup. This is only required for non-AWS services. The replication-manager and view-syncers must have the same endpoint. For example, to use Cloudflare R2: https://.r2.cloudflarestorage.com. flag: --litestream-endpoint env: ZERO_LITESTREAM_ENDPOINT Litestream Checkpoint Threshold MB The size of the WAL file at which Litestream performs background (PASSIVE) SQLite checkpoints to apply the writes in the WAL to the main database file. Each checkpoint creates new files (WAL segments with Litestream v3, LTX files with v5) that are then backed up. PASSIVE checkpoints are skipped while a write is in progress, so high write rates can make the WAL grow. 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 Config Path V5 Path to the Litestream v5 YAML config file. zero-cache runs this file with the same environment-variable substitution described for ZERO_LITESTREAM_CONFIG_PATH. flag: --litestream-config-path-v5 env: ZERO_LITESTREAM_CONFIG_PATH_V5 default: ./src/services/litestream/config-v5.yml Litestream Executable Path to the litestream executable. This must be built from the rocicorp/litestream fork. This option has no effect if litestream-backup-url is unspecified. flag: --litestream-executable env: ZERO_LITESTREAM_EXECUTABLE Litestream V5 Executable Path to the official Litestream v0.5.x executable used for v5 restores and backups. Litestream v0.5.8 and later can restore both legacy WAL backups and LTX backups, choosing the format with the latest data. When a backup URL is configured and ZERO_LITESTREAM_RESTORE_USING_V5 or ZERO_LITESTREAM_BACKUP_USING_V5 is enabled (both are by default), this executable is required. The official Zero Docker image includes Rocicorp's Litestream build 0.5.18-zero.16. Restores fork the source backup with litestream restore -fork-to-url, so a custom executable must support that flag. flag: --litestream-executable-v5 env: ZERO_LITESTREAM_EXECUTABLE_V5 Litestream Restore Using V5 Use ZERO_LITESTREAM_EXECUTABLE_V5 for restores. Set this to false to force legacy restore behavior. Because v5 backups require v5 restore, running without a v5 executable when a backup URL is configured requires setting both this option and ZERO_LITESTREAM_BACKUP_USING_V5 to false. Litestream v0.5 cannot restore legacy backups encrypted with Age. Keep legacy restore enabled for those backups or migrate them before enabling v5 restore. flag: --litestream-restore-using-v5 env: ZERO_LITESTREAM_RESTORE_USING_V5 default: true Litestream Backup Using V5 Write LTX backups with Litestream v0.5.x. Set this to false to write legacy WAL backups with Litestream v0.3.x instead. This requires ZERO_LITESTREAM_RESTORE_USING_V5, which is enabled by default. Zero requests an incremental backup every ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_SECONDS, and acknowledges upstream changes only after a backup containing them has completed. Each replication-manager backs up to its own subfolder of the folder URL configured by ZERO_LITESTREAM_BACKUP_URL. Zero does not delete subfolders left by previous replication-managers. The simplest way to remove them is an object-store lifecycle expiration rule. Every file a restore needs is at most about ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS old, so an expiration of at least twice that interval is safe. Rolling back to a server that does not understand replica-specific paths can require a full resync. flag: --litestream-backup-using-v5 env: ZERO_LITESTREAM_BACKUP_USING_V5 default: true Litestream Incremental Backup Interval Minutes The interval between incremental Litestream v3 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 restore. For v5 backups, use ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_SECONDS. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Incremental Backup Interval Seconds The interval between incremental Litestream v5 backups. Zero does not acknowledge upstream changes until the corresponding replica changes are confirmed in backup storage. Shorter intervals increase storage requests; longer intervals retain more upstream WAL while confirmation is pending. flag: --litestream-incremental-backup-interval-seconds env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_SECONDS 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. This setting is ignored when backing up with Litestream v5. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 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. Leave this unset to use 48, lowered as needed so that the parts fit in a quarter of the memory available to the container. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY 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 Region The AWS region for the litestream backup bucket. Required for non-standard AWS partitions (e.g. GovCloud us-gov-west-1) where Litestream cannot auto-detect the region. The replication-manager and view-syncers must have the same region. flag: --litestream-region env: ZERO_LITESTREAM_REGION 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. Zero retains the previous generation for six additional hours so an active restore can finish before its snapshot and WAL files are removed. This improves restore time and safety at the expense of bandwidth and temporary backup storage. The setting applies to v3 and v5 backups, although v5 generally does not need frequent snapshots for restore speed. 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 Allowed Client Headers Comma-separated allowlist of client-provided custom headers to forward to your mutate endpoint. Header names are matched case-insensitively. By default, no client-provided custom headers are forwarded. flag: --mutate-allowed-client-headers env: ZERO_MUTATE_ALLOWED_CLIENT_HEADERS default: none Mutate Allowed Request Headers Comma-separated allowlist of HTTP headers from the request that opened the WebSocket to forward to your mutate endpoint. Use this for proxy- or load-balancer-injected headers such as x-forwarded-for or cf-ray. Unlike mutate allowed client headers, these values come from the request that established the connection. Header names are matched case-insensitively. Values are retained for the WebSocket's lifetime, so clients must reconnect to receive changes. The allowlist does not verify the header source - only allow headers that a trusted proxy overwrites or removes from untrusted requests. No request headers are forwarded by default. flag: --mutate-allowed-request-headers env: ZERO_MUTATE_ALLOWED_REQUEST_HEADERS default: none 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 max(1, availableParallelism() - 1), reserving one core for the replicator. 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 Operator Password A password for the diagnostic endpoints that can't expose application data, so the people who run zero-cache can debug it without being able to read the data it serves. It grants access to: /profz and /profrmz: CPU profiles. Profiles served for the operator password have the source of every regular expression redacted, since those can contain LIKE patterns from client queries. /statz: counts and sizes. /plannerz: the replica's schema and planner statistics. The other protected endpoints, the inspector and /heapz, still require the admin password. For /profrmz in a multi-node deployment, the replication-manager must be configured with the same operator password. If the operator password is the same as the admin password, it grants admin access. flag: --operator-password env: ZERO_OPERATOR_PASSWORD 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 PG Replication Slot Failover For upstream Postgres 17+, creates replication slots with the failover flag enabled so they can be synchronized to a standby and survive a failover. This requires additional Postgres-side configuration on your provider; see High Availability. Has no effect on Postgres versions before 17. flag: --upstream-pg-replication-slot-failover env: ZERO_UPSTREAM_PG_REPLICATION_SLOT_FAILOVER default: false 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 Allowed Client Headers Comma-separated allowlist of client-provided custom headers to forward to your query endpoint. Header names are matched case-insensitively. By default, no client-provided custom headers are forwarded. flag: --query-allowed-client-headers env: ZERO_QUERY_ALLOWED_CLIENT_HEADERS default: none Query Allowed Request Headers Comma-separated allowlist of HTTP headers from the request that opened the WebSocket to forward to your query endpoint. Use this for proxy- or load-balancer-injected headers such as x-forwarded-for or cf-ray. Unlike query allowed client headers, these values come from the request that established the connection. Header names are matched case-insensitively. Values are retained for the WebSocket's lifetime, so clients must reconnect to receive changes. The allowlist does not verify the header source - only allow headers that a trusted proxy overwrites or removes from untrusted requests. No request headers are forwarded by default. flag: --query-allowed-request-headers env: ZERO_QUERY_ALLOWED_REQUEST_HEADERS default: none 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 Replication Lag Initial Readiness Threshold (ms) The maximum replication lag that a server can have before it reports that it is ready to serve clients. This keeps a server that is starting up from serving stale data. This only applies at startup. Once a server is ready, it keeps serving even if replication lag later goes above this threshold. When lag reporting is disabled, readiness does not wait for replication lag. flag: --replication-lag-initial-readiness-threshold-ms env: ZERO_REPLICATION_LAG_INITIAL_READINESS_THRESHOLD_MS default: 1000 Replication Lag Max Readiness Delay (ms) The maximum time that a server waits for replication lag to fall below ZERO_REPLICATION_LAG_INITIAL_READINESS_THRESHOLD_MS before it reports ready anyway. This keeps a server from waiting forever, for example when the upstream database is under heavy load or there is a network partition. flag: --replication-lag-max-readiness-delay-ms env: ZERO_REPLICATION_LAG_MAX_READINESS_DELAY_MS default: 600000 (10 minutes) Replication Lag Report Interval (ms) The approximate interval at which replication lag reports are written upstream and reported via the zero.replication.total_lag OpenTelemetry metric. If an expected report is not received before the next interval, Zero emits a new report and increments zero.replication.lag_report_retries. Lag reports are also used at startup: a new server waits for replication lag to fall below ZERO_REPLICATION_LAG_INITIAL_READINESS_THRESHOLD_MS before it reports ready. This interval sets how long the server waits for the next report. This feature requires write access to upstream Postgres (uses pg_logical_emit_message()). For PostgreSQL 17+, lag measurements accurately reflect committed write latency (single-digit milliseconds). For PostgreSQL 16 and earlier, measurements may appear 50-100ms longer due to flush behavior. A negative or 0 value disables lag reporting and the readiness wait. Even if otel is not enabled, info and warn-level logs are emitted for large lag values. flag: --replication-lag-report-interval-ms env: ZERO_REPLICATION_LAG_REPORT_INTERVAL_MS default: 10000 Server Version The version string outputted to logs when the server starts up. flag: --server-version env: ZERO_SERVER_VERSION Shadow Sync Enabled Periodically exercises the initial-sync code path against a sample of rows from every published table, writing to a throwaway SQLite database. This acts as a canary: if the real initial-sync path breaks because of schema drift, Postgres version quirks, or another full-resync issue, the shadow run fails before a customer actually needs a full reset. flag: --shadow-sync-enabled env: ZERO_SHADOW_SYNC_ENABLED default: false Shadow Sync Interval Hours The interval between shadow initial-sync runs, in hours. The first run fires within [2/3, 1) of this interval after startup, so the canary completes at least once per task lifetime while still jittering fleet restarts. flag: --shadow-sync-interval-hours env: ZERO_SHADOW_SYNC_INTERVAL_HOURS default: 12 Shadow Sync Sample Rate The Bernoulli sampling rate for each table, where 0 < rate <= 1. A value of 1 disables sampling and copies all rows, still subject to --shadow-sync-max-rows-per-table. flag: --shadow-sync-sample-rate env: ZERO_SHADOW_SYNC_SAMPLE_RATE default: 0.1 Shadow Sync Max Rows Per Table The hard upper bound on rows copied per table per shadow run. This guards against unexpectedly large tables consuming too much disk or upstream bandwidth. flag: --shadow-sync-max-rows-per-table env: ZERO_SHADOW_SYNC_MAX_ROWS_PER_TABLE default: 10000 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 Upstream PG Replication Slot Failover For upstream PostgreSQL 17 and later, create replication slots with the failover parameter set to true to enable slot synchronization and failover. Additional Postgres-level configuration is required when enabling this option. This option has no effect for PostgreSQL versions before 17. See the PostgreSQL docs for details: https://www.postgresql.org/docs/current/logicaldecoding-explanation.html#LOGICALDECODING-REPLICATION-SLOTS-SYNCHRONIZATION flag: --upstream-pg-replication-slot-failover env: ZERO_UPSTREAM_PG_REPLICATION_SLOT_FAILOVER default: false Upstream PG Stream Inbound Timeout The time, in milliseconds, without an inbound message from the upstream WAL sender after which zero-cache tears down the replication stream to force a reconnect. By default, the threshold is twice the server's wal_sender_timeout. Increase this value when a healthy WAL sender can remain silent while decoding unpublished WAL or assembling a large transaction. This changes only Zero's inbound timeout; keepalive timing remains derived from wal_sender_timeout. The option has no effect when wal_sender_timeout is 0, which disables inbound liveness detection. See WAL Sender Timeout. flag: --upstream-pg-stream-inbound-timeout-ms env: ZERO_UPSTREAM_PG_STREAM_INBOUND_TIMEOUT_MS View Syncer Hydration Budget (ms) A soft time limit for hydrating inactive (cached) queries when a view-syncer hydrates a client group's queries, for example after a client reconnects. Active queries always finish. Inactive queries are hydrated after them, starting with the ones whose TTL expires last. Once the budget is spent, the inactive queries that were not reached are removed, along with the remaining TTL that would have kept them cached. The budget is only checked between queries, so a query that has started is not cut short by it. Time spent waiting on your query endpoint does not count toward the budget. A value of 0 disables the budget. flag: --view-syncer-hydration-budget-ms env: ZERO_VIEW_SYNCER_HYDRATION_BUDGET_MS default: 0 View Syncer Query Hydration Timeout (ms) The maximum processing time that a view-syncer spends hydrating a single query. Time spent yielding to other work is not counted. If hydrating a query takes longer than this, the hydration is aborted and the query is removed from the client's view. Clients that use the query receive an error for it (see Handling Errors). For the next five minutes, the query is rejected without being run again. After that, it can be retried. A value of 0 disables the limit. flag: --view-syncer-query-hydration-timeout-ms env: ZERO_VIEW_SYNCER_QUERY_HYDRATION_TIMEOUT_MS default: 0 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. flag: --websocket-max-payload-bytes env: ZERO_WEBSOCKET_MAX_PAYLOAD_BYTES default: 10485760 (10 MiB) 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 Change Max Connections ZERO_CHANGE_MAX_CONNS is deprecated and no longer controls change database connection usage. Size the database or pooler as described under Change DB. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS Change Streamer Mode ZERO_CHANGE_STREAMER_MODE is deprecated. Set ZERO_CHANGE_STREAMER_URI on view-syncers instead. 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). This option is ignored if ZERO_CHANGE_STREAMER_URI is set. flag: --change-streamer-mode env: ZERO_CHANGE_STREAMER_MODE default: dedicated Litestream Minimum Checkpoint Page Count ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT is deprecated. Use ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB instead. The WAL page count at which SQLite attempts a PASSIVE checkpoint. If unset, it is derived from the checkpoint threshold as checkpointThresholdMB * 256 (4 KiB pages). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 256 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",
@@ -7972,6 +8318,10 @@
"text": "Optional Flags",
"id": "optional-flags"
},
+ {
+ "text": "Allow Legacy Queries",
+ "id": "allow-legacy-queries"
+ },
{
"text": "App ID",
"id": "app-id"
@@ -8008,10 +8358,6 @@
"text": "Change Streamer Flow Control Slow Subscriber Grace Period Seconds",
"id": "change-streamer-flow-control-slow-subscriber-grace-period-seconds"
},
- {
- "text": "Change Streamer Mode",
- "id": "change-streamer-mode"
- },
{
"text": "Change Streamer Port",
"id": "change-streamer-port"
@@ -8100,22 +8446,6 @@
"text": "Litestream Backup Using V5",
"id": "litestream-backup-using-v5"
},
- {
- "text": "Litestream VFS Query Executable",
- "id": "litestream-vfs-query-executable"
- },
- {
- "text": "Litestream VFS Poll Interval (ms)",
- "id": "litestream-vfs-poll-interval-ms"
- },
- {
- "text": "Litestream VFS Poll Timeout (ms)",
- "id": "litestream-vfs-poll-timeout-ms"
- },
- {
- "text": "Litestream VFS Log File",
- "id": "litestream-vfs-log-file"
- },
{
"text": "Litestream Incremental Backup Interval Minutes",
"id": "litestream-incremental-backup-interval-minutes"
@@ -8128,10 +8458,6 @@
"text": "Litestream Maximum Checkpoint Page Count",
"id": "litestream-maximum-checkpoint-page-count"
},
- {
- "text": "Litestream Minimum Checkpoint Page Count",
- "id": "litestream-minimum-checkpoint-page-count"
- },
{
"text": "Litestream Multipart Concurrency",
"id": "litestream-multipart-concurrency"
@@ -8204,6 +8530,10 @@
"text": "Number of Sync Workers",
"id": "number-of-sync-workers"
},
+ {
+ "text": "Operator Password",
+ "id": "operator-password"
+ },
{
"text": "Per User Mutation Limit Max",
"id": "per-user-mutation-limit-max"
@@ -8252,6 +8582,14 @@
"text": "Replica Vacuum Interval Hours",
"id": "replica-vacuum-interval-hours"
},
+ {
+ "text": "Replication Lag Initial Readiness Threshold (ms)",
+ "id": "replication-lag-initial-readiness-threshold-ms"
+ },
+ {
+ "text": "Replication Lag Max Readiness Delay (ms)",
+ "id": "replication-lag-max-readiness-delay-ms"
+ },
{
"text": "Replication Lag Report Interval (ms)",
"id": "replication-lag-report-interval-ms"
@@ -8296,6 +8634,14 @@
"text": "Upstream PG Stream Inbound Timeout",
"id": "upstream-pg-stream-inbound-timeout"
},
+ {
+ "text": "View Syncer Hydration Budget (ms)",
+ "id": "view-syncer-hydration-budget-ms"
+ },
+ {
+ "text": "View Syncer Query Hydration Timeout (ms)",
+ "id": "view-syncer-query-hydration-timeout-ms"
+ },
{
"text": "Websocket Compression",
"id": "websocket-compression"
@@ -8320,6 +8666,14 @@
"text": "Change Max Connections",
"id": "change-max-connections"
},
+ {
+ "text": "Change Streamer Mode",
+ "id": "change-streamer-mode"
+ },
+ {
+ "text": "Litestream Minimum Checkpoint Page Count",
+ "id": "litestream-minimum-checkpoint-page-count"
+ },
{
"text": "Auth JWK",
"id": "auth-jwk"
@@ -8336,17 +8690,17 @@
"kind": "page"
},
{
- "id": "592-zero-cache-config#required-flags",
+ "id": "617-zero-cache-config#required-flags",
"title": "zero-cache Config",
"searchTitle": "Required Flags",
"sectionTitle": "Required Flags",
"sectionId": "required-flags",
"url": "/docs/zero-cache-config",
- "content": "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)",
+ "content": "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. The admin password also grants access to everything that the operator password does. flag: --admin-password env: ZERO_ADMIN_PASSWORD required: in production (when NODE_ENV=production)",
"kind": "section"
},
{
- "id": "593-zero-cache-config#upstream-db",
+ "id": "618-zero-cache-config#upstream-db",
"title": "zero-cache Config",
"searchTitle": "Upstream DB",
"sectionTitle": "Upstream DB",
@@ -8356,27 +8710,37 @@
"kind": "section"
},
{
- "id": "594-zero-cache-config#admin-password",
+ "id": "619-zero-cache-config#admin-password",
"title": "zero-cache Config",
"searchTitle": "Admin Password",
"sectionTitle": "Admin Password",
"sectionId": "admin-password",
"url": "/docs/zero-cache-config",
- "content": "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)",
+ "content": "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. The admin password also grants access to everything that the operator password does. flag: --admin-password env: ZERO_ADMIN_PASSWORD required: in production (when NODE_ENV=production)",
"kind": "section"
},
{
- "id": "595-zero-cache-config#optional-flags",
+ "id": "620-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 Auth Revalidate Interval Seconds How often zero-cache re-checks that each live connection is still authorized to use your /query endpoint. On each interval, zero-cache sends a lightweight validation request using that connection's current auth context, such as forwarded cookies or an opaque auth token. If your query endpoint rejects that auth with a 401/403, the connection is disconnected. Use this to bound how long already-open connections can continue after logout, session expiry, token revocation, or other server-side auth changes that happen without a reconnect. Lower values enforce auth changes faster, but send more validation requests to /query. flag: --auth-revalidate-interval-seconds env: ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS default: unset Auth Retransform Interval Seconds How often zero-cache refreshes a client group's synced or named query transformations using one validated connection from that group. This re-runs auth-sensitive query expansion even when the query set itself has not changed. It is useful when your query endpoint generates different ZQL based on current auth or server-side session state, such as roles, organization membership, feature flags, or other permissions-derived context. Use this to bound how long a client group can keep using stale auth-derived query shapes after backend auth state changes. Lower values pick up those changes faster, but do more /query transform work. If clients already call updateAuth whenever auth changes, this mainly serves as a background safety net for out-of-band auth changes. flag: --auth-retransform-interval-seconds env: ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS default: unset 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 Streamer Back Pressure 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-pressure-limit-heap-proportion env: ZERO_CHANGE_STREAMER_BACK_PRESSURE_LIMIT_HEAP_PROPORTION default: 0.04 Change Streamer Flow Control Consensus Timeout Proportion During periodic flow-control checks, this is the amount of additional time to wait after the majority of serving replicas have acknowledged a checkpoint, expressed as a proportion of the time the majority took. Replication continues after that timeout even if some serving replicas have not acknowledged the checkpoint. A backup replica that is still catching up does not delay caught-up serving replicas; once caught up, the backup must acknowledge the checkpoint before replication continues. For example, if the majority responds in 2.5 ms, a proportion of 2 waits up to another 5 ms. The default lets a subscriber take roughly three times as long as the majority before early release. This bounds the effect of a lagging or unresponsive subscriber while allowing for normal processing-time variance. This value is a ratio, not a duration in seconds. Set it to a negative number to disable early releases and wait for all subscribers. flag: --change-streamer-flow-control-consensus-timeout-proportion env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_TIMEOUT_PROPORTION default: 2 Change Streamer Flow Control Slow Subscriber Grace Period Seconds The time a serving subscriber may continuously miss the consensus timeout while processing changes more slowly than healthy subscribers before Zero disconnects it and instructs it to restart. This protects healthy subscribers from zombie or chronically slow tasks while allowing temporary catch-up work to complete. Only serving replicas are reset; the replication-manager's backup replica is not disconnected by this setting. Set this to 0 or a negative number to disable slow-subscriber resets. flag: --change-streamer-flow-control-slow-subscriber-grace-period-seconds env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_SLOW_SUBSCRIBER_GRACE_PERIOD_SECONDS default: 30 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 CRUD Mutations Enables support for legacy CRUD mutations. When this is false, view-syncers do not connect to the upstream database for CRUD writes, and push messages with CRUD mutations return an error response. flag: --enable-crud-mutations env: ZERO_ENABLE_CRUD_MUTATIONS 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. When enabling Litestream v5 backups, configure this as a folder URL ending in /, for example s3://acme-zero-backups/v1/. Zero resolves each replica-specific backup path relative to this URL; without the trailing slash, URL resolution replaces the final path segment instead of nesting the replica backup under it. flag: --litestream-backup-url env: ZERO_LITESTREAM_BACKUP_URL Litestream Endpoint The S3-compatible endpoint URL to use for the litestream backup. This is only required for non-AWS services. The replication-manager and view-syncers must have the same endpoint. For example, to use Cloudflare R2: https://.r2.cloudflarestorage.com. flag: --litestream-endpoint env: ZERO_LITESTREAM_ENDPOINT 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 v3. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. This setting is ignored when backing up with Litestream v5. 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 Config Path V5 Path to the Litestream v5 YAML config file. zero-cache runs this file with the same environment-variable substitution described for ZERO_LITESTREAM_CONFIG_PATH. flag: --litestream-config-path-v5 env: ZERO_LITESTREAM_CONFIG_PATH_V5 default: ./src/services/litestream/config-v5.yml Litestream Executable Path to the litestream executable. This must be built from the rocicorp/litestream fork. This option has no effect if litestream-backup-url is unspecified. flag: --litestream-executable env: ZERO_LITESTREAM_EXECUTABLE Litestream V5 Executable Path to the official Litestream v0.5.x executable used for v5 restores and backups. Litestream v0.5.8 and later can restore both legacy WAL backups and LTX backups, choosing the format with the latest data. When a backup URL is configured and ZERO_LITESTREAM_RESTORE_USING_V5 is enabled, this executable is required. The official Zero Docker image includes Litestream 0.5.16. flag: --litestream-executable-v5 env: ZERO_LITESTREAM_EXECUTABLE_V5 Litestream Restore Using V5 Use ZERO_LITESTREAM_EXECUTABLE_V5 for restores. Set this to false to force legacy restore behavior. When a backup URL is configured, disabling this option is also the way to run without a v5 executable. Litestream v0.5 cannot restore legacy backups encrypted with Age. Keep legacy restore enabled for those backups or migrate them before enabling v5 restore. flag: --litestream-restore-using-v5 env: ZERO_LITESTREAM_RESTORE_USING_V5 default: true Litestream Backup Using V5 Write LTX backups with Litestream v0.5.x. This is disabled by default to continue writing legacy WAL backups. Enabling it requires v5 restore and ZERO_LITESTREAM_VFS_QUERY_EXECUTABLE. With v5 backup enabled, Zero acknowledges upstream changes only after they are applied to the replica and confirmed in the backup. Each replica uses a distinct backup subpath under the folder URL configured by ZERO_LITESTREAM_BACKUP_URL. Configure an object-store lifecycle policy to remove old subpaths; Zero does not delete them automatically. Rolling back to a server that does not understand replica-specific paths can require a full resync. flag: --litestream-backup-using-v5 env: ZERO_LITESTREAM_BACKUP_USING_V5 default: false Litestream VFS Query Executable Path to the vfs-query executable used to monitor Litestream v5 backup progress. This is required when ZERO_LITESTREAM_BACKUP_USING_V5 is enabled. The official Zero Docker image includes this executable. flag: --litestream-vfs-query-executable env: ZERO_LITESTREAM_VFS_QUERY_EXECUTABLE Litestream VFS Poll Interval (ms) How often the Litestream VFS polls backup storage for the latest backup. This controls how quickly Zero can confirm a backup and acknowledge the corresponding upstream changes. flag: --litestream-vfs-poll-interval-ms env: ZERO_LITESTREAM_VFS_POLL_INTERVAL_MS default: 15000 Litestream VFS Poll Timeout (ms) Reserved for configuring request timeouts to the Litestream VFS poller. In this release, vfs-query uses its internal 30-second timeout regardless of this value. flag: --litestream-vfs-poll-timeout-ms env: ZERO_LITESTREAM_VFS_POLL_TIMEOUT_MS default: 10000 Litestream VFS Log File Reserved for redirecting Litestream VFS logs. In this release, vfs-query writes logs to the child process's standard error regardless of this value. flag: --litestream-vfs-log-file env: ZERO_LITESTREAM_VFS_LOG_FILE Litestream Incremental Backup Interval Minutes The interval between incremental Litestream v3 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 restore. For v5 backups, use ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_SECONDS. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Incremental Backup Interval Seconds The interval between incremental Litestream v5 backups. Zero does not acknowledge upstream changes until the corresponding replica changes are confirmed in backup storage. Shorter intervals increase storage requests; longer intervals retain more upstream WAL while confirmation is pending. flag: --litestream-incremental-backup-interval-seconds env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_SECONDS 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. This setting is ignored when backing up with Litestream v5. 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). This setting is ignored when backing up with Litestream v5. 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 Region The AWS region for the litestream backup bucket. Required for non-standard AWS partitions (e.g. GovCloud us-gov-west-1) where Litestream cannot auto-detect the region. The replication-manager and view-syncers must have the same region. flag: --litestream-region env: ZERO_LITESTREAM_REGION 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. Zero retains the previous generation for six additional hours so an active restore can finish before its snapshot and WAL files are removed. This improves restore time and safety at the expense of bandwidth and temporary backup storage. The setting applies to v3 and v5 backups, although v5 generally does not need frequent snapshots for restore speed. 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 Allowed Client Headers Comma-separated allowlist of client-provided custom headers to forward to your mutate endpoint. Header names are matched case-insensitively. By default, no client-provided custom headers are forwarded. flag: --mutate-allowed-client-headers env: ZERO_MUTATE_ALLOWED_CLIENT_HEADERS default: none Mutate Allowed Request Headers Comma-separated allowlist of HTTP headers from the request that opened the WebSocket to forward to your mutate endpoint. Use this for proxy- or load-balancer-injected headers such as x-forwarded-for or cf-ray. Unlike mutate allowed client headers, these values come from the request that established the connection. Header names are matched case-insensitively. Values are retained for the WebSocket's lifetime, so clients must reconnect to receive changes. The allowlist does not verify the header source - only allow headers that a trusted proxy overwrites or removes from untrusted requests. No request headers are forwarded by default. flag: --mutate-allowed-request-headers env: ZERO_MUTATE_ALLOWED_REQUEST_HEADERS default: none 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 max(1, availableParallelism() - 1), reserving one core for the replicator. 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 PG Replication Slot Failover For upstream Postgres 17+, creates replication slots with the failover flag enabled so they can be synchronized to a standby and survive a failover. This requires additional Postgres-side configuration on your provider; see High Availability. Has no effect on Postgres versions before 17. flag: --upstream-pg-replication-slot-failover env: ZERO_UPSTREAM_PG_REPLICATION_SLOT_FAILOVER default: false 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 Allowed Client Headers Comma-separated allowlist of client-provided custom headers to forward to your query endpoint. Header names are matched case-insensitively. By default, no client-provided custom headers are forwarded. flag: --query-allowed-client-headers env: ZERO_QUERY_ALLOWED_CLIENT_HEADERS default: none Query Allowed Request Headers Comma-separated allowlist of HTTP headers from the request that opened the WebSocket to forward to your query endpoint. Use this for proxy- or load-balancer-injected headers such as x-forwarded-for or cf-ray. Unlike query allowed client headers, these values come from the request that established the connection. Header names are matched case-insensitively. Values are retained for the WebSocket's lifetime, so clients must reconnect to receive changes. The allowlist does not verify the header source - only allow headers that a trusted proxy overwrites or removes from untrusted requests. No request headers are forwarded by default. flag: --query-allowed-request-headers env: ZERO_QUERY_ALLOWED_REQUEST_HEADERS default: none 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 Replication Lag Report Interval (ms) The minimum interval at which replication lag reports are written upstream and reported via the zero.replication.total_lag OpenTelemetry metric. If an expected report is not received before the next interval, Zero emits a new report and increments zero.replication.lag_report_retries. This feature requires write access to upstream Postgres (uses pg_logical_emit_message()). For PostgreSQL 17+, lag measurements accurately reflect committed write latency (single-digit milliseconds). For PostgreSQL 16 and earlier, measurements may appear 50-100ms longer due to flush behavior. A negative or 0 value disables lag reporting. Even if otel is not enabled, info and warn-level logs are emitted for large lag values. flag: --replication-lag-report-interval-ms env: ZERO_REPLICATION_LAG_REPORT_INTERVAL_MS default: 30_000 Server Version The version string outputted to logs when the server starts up. flag: --server-version env: ZERO_SERVER_VERSION Shadow Sync Enabled Periodically exercises the initial-sync code path against a sample of rows from every published table, writing to a throwaway SQLite database. This acts as a canary: if the real initial-sync path breaks because of schema drift, Postgres version quirks, or another full-resync issue, the shadow run fails before a customer actually needs a full reset. flag: --shadow-sync-enabled env: ZERO_SHADOW_SYNC_ENABLED default: false Shadow Sync Interval Hours The interval between shadow initial-sync runs, in hours. The first run fires within [2/3, 1) of this interval after startup, so the canary completes at least once per task lifetime while still jittering fleet restarts. flag: --shadow-sync-interval-hours env: ZERO_SHADOW_SYNC_INTERVAL_HOURS default: 12 Shadow Sync Sample Rate The Bernoulli sampling rate for each table, where 0 < rate <= 1. A value of 1 disables sampling and copies all rows, still subject to --shadow-sync-max-rows-per-table. flag: --shadow-sync-sample-rate env: ZERO_SHADOW_SYNC_SAMPLE_RATE default: 0.1 Shadow Sync Max Rows Per Table The hard upper bound on rows copied per table per shadow run. This guards against unexpectedly large tables consuming too much disk or upstream bandwidth. flag: --shadow-sync-max-rows-per-table env: ZERO_SHADOW_SYNC_MAX_ROWS_PER_TABLE default: 10000 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 Upstream PG Replication Slot Failover For upstream PostgreSQL 17 and later, create replication slots with the failover parameter set to true to enable slot synchronization and failover. Additional Postgres-level configuration is required when enabling this option. This option has no effect for PostgreSQL versions before 17. See the PostgreSQL docs for details: https://www.postgresql.org/docs/current/logicaldecoding-explanation.html#LOGICALDECODING-REPLICATION-SLOTS-SYNCHRONIZATION flag: --upstream-pg-replication-slot-failover env: ZERO_UPSTREAM_PG_REPLICATION_SLOT_FAILOVER default: false Upstream PG Stream Inbound Timeout The time, in milliseconds, without an inbound message from the upstream WAL sender after which zero-cache tears down the replication stream to force a reconnect. By default, the threshold is twice the server's wal_sender_timeout. Increase this value when a healthy WAL sender can remain silent while decoding unpublished WAL or assembling a large transaction. This changes only Zero's inbound timeout; keepalive timing remains derived from wal_sender_timeout. The option has no effect when wal_sender_timeout is 0, which disables inbound liveness detection. See WAL Sender Timeout. flag: --upstream-pg-stream-inbound-timeout-ms env: ZERO_UPSTREAM_PG_STREAM_INBOUND_TIMEOUT_MS 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. flag: --websocket-max-payload-bytes env: ZERO_WEBSOCKET_MAX_PAYLOAD_BYTES default: 10485760 (10 MiB) 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": "Allow Legacy Queries Allows clients to send query ASTs directly to zero-cache. Ad-hoc queries (also called legacy queries) work this way. When this is false, zero-cache rejects client-sent ASTs without parsing them and closes the connection with an error. Keep it disabled when you use queries. flag: --allow-legacy-queries env: ZERO_ALLOW_LEGACY_QUERIES default: false 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 Auth Revalidate Interval Seconds How often zero-cache re-checks that each live connection is still authorized to use your /query endpoint. On each interval, zero-cache sends a lightweight validation request using that connection's current auth context, such as forwarded cookies or an opaque auth token. If your query endpoint rejects that auth with a 401/403, the connection is disconnected. Use this to bound how long already-open connections can continue after logout, session expiry, token revocation, or other server-side auth changes that happen without a reconnect. Lower values enforce auth changes faster, but send more validation requests to /query. flag: --auth-revalidate-interval-seconds env: ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS default: 300 Auth Retransform Interval Seconds How often zero-cache refreshes a client group's synced or named query transformations using one validated connection from that group. This re-runs auth-sensitive query expansion even when the query set itself has not changed. It is useful when your query endpoint generates different ZQL based on current auth or server-side session state, such as roles, organization membership, feature flags, or other permissions-derived context. Use this to bound how long a client group can keep using stale auth-derived query shapes after backend auth state changes. Lower values pick up those changes faster, but do more /query transform work. If clients already call updateAuth whenever auth changes, this mainly serves as a background safety net for out-of-band auth changes. flag: --auth-retransform-interval-seconds env: ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS default: 300 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 Streamer Back Pressure 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-pressure-limit-heap-proportion env: ZERO_CHANGE_STREAMER_BACK_PRESSURE_LIMIT_HEAP_PROPORTION default: 0.04 Change Streamer Flow Control Consensus Timeout Proportion During periodic flow-control checks, this is the amount of additional time to wait after the majority of serving replicas have acknowledged a checkpoint, expressed as a proportion of the time the majority took. Replication continues after that timeout even if some serving replicas have not acknowledged the checkpoint. A backup replica that is still catching up does not delay caught-up serving replicas; once caught up, the backup must acknowledge the checkpoint before replication continues. For example, if the majority responds in 2.5 ms, a proportion of 2 waits up to another 5 ms. The default lets a subscriber take roughly five times as long as the majority before early release. This bounds the effect of a lagging or unresponsive subscriber while allowing for normal processing-time variance. This value is a ratio, not a duration in seconds. Set it to a negative number to disable early releases and wait for all subscribers. flag: --change-streamer-flow-control-consensus-timeout-proportion env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_TIMEOUT_PROPORTION default: 4 Change Streamer Flow Control Slow Subscriber Grace Period Seconds The time a serving subscriber may continuously miss the consensus timeout while processing changes more slowly than healthy subscribers before Zero disconnects it and instructs it to restart. This protects healthy subscribers from zombie or chronically slow tasks while allowing temporary catch-up work to complete. Only serving replicas are reset; the replication-manager's backup replica is not disconnected by this setting. Set this to 0 or a negative number to disable slow-subscriber resets. flag: --change-streamer-flow-control-slow-subscriber-grace-period-seconds env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_SLOW_SUBSCRIBER_GRACE_PERIOD_SECONDS default: 30 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. Purging a CVR forces the next connection from that client group to sync from scratch. Set this well above how long a typical user goes between sessions. flag: --cvr-garbage-collection-inactivity-threshold-hours env: ZERO_CVR_GARBAGE_COLLECTION_INACTIVITY_THRESHOLD_HOURS default: 168 (one week) 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 CRUD Mutations Enables support for legacy CRUD mutations. When this is false, view-syncers do not connect to the upstream database for CRUD writes, and push messages with CRUD mutations return an error response. flag: --enable-crud-mutations env: ZERO_ENABLE_CRUD_MUTATIONS 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. With Litestream v5 backups (the default), configure this as a folder URL ending in /, for example s3://acme-zero-backups/v1/. Zero resolves each replica-specific backup path relative to this URL; without the trailing slash, URL resolution replaces the final path segment instead of nesting the replica backup under it. flag: --litestream-backup-url env: ZERO_LITESTREAM_BACKUP_URL Litestream Endpoint The S3-compatible endpoint URL to use for the litestream backup. This is only required for non-AWS services. The replication-manager and view-syncers must have the same endpoint. For example, to use Cloudflare R2: https://.r2.cloudflarestorage.com. flag: --litestream-endpoint env: ZERO_LITESTREAM_ENDPOINT Litestream Checkpoint Threshold MB The size of the WAL file at which Litestream performs background (PASSIVE) SQLite checkpoints to apply the writes in the WAL to the main database file. Each checkpoint creates new files (WAL segments with Litestream v3, LTX files with v5) that are then backed up. PASSIVE checkpoints are skipped while a write is in progress, so high write rates can make the WAL grow. 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 Config Path V5 Path to the Litestream v5 YAML config file. zero-cache runs this file with the same environment-variable substitution described for ZERO_LITESTREAM_CONFIG_PATH. flag: --litestream-config-path-v5 env: ZERO_LITESTREAM_CONFIG_PATH_V5 default: ./src/services/litestream/config-v5.yml Litestream Executable Path to the litestream executable. This must be built from the rocicorp/litestream fork. This option has no effect if litestream-backup-url is unspecified. flag: --litestream-executable env: ZERO_LITESTREAM_EXECUTABLE Litestream V5 Executable Path to the official Litestream v0.5.x executable used for v5 restores and backups. Litestream v0.5.8 and later can restore both legacy WAL backups and LTX backups, choosing the format with the latest data. When a backup URL is configured and ZERO_LITESTREAM_RESTORE_USING_V5 or ZERO_LITESTREAM_BACKUP_USING_V5 is enabled (both are by default), this executable is required. The official Zero Docker image includes Rocicorp's Litestream build 0.5.18-zero.16. Restores fork the source backup with litestream restore -fork-to-url, so a custom executable must support that flag. flag: --litestream-executable-v5 env: ZERO_LITESTREAM_EXECUTABLE_V5 Litestream Restore Using V5 Use ZERO_LITESTREAM_EXECUTABLE_V5 for restores. Set this to false to force legacy restore behavior. Because v5 backups require v5 restore, running without a v5 executable when a backup URL is configured requires setting both this option and ZERO_LITESTREAM_BACKUP_USING_V5 to false. Litestream v0.5 cannot restore legacy backups encrypted with Age. Keep legacy restore enabled for those backups or migrate them before enabling v5 restore. flag: --litestream-restore-using-v5 env: ZERO_LITESTREAM_RESTORE_USING_V5 default: true Litestream Backup Using V5 Write LTX backups with Litestream v0.5.x. Set this to false to write legacy WAL backups with Litestream v0.3.x instead. This requires ZERO_LITESTREAM_RESTORE_USING_V5, which is enabled by default. Zero requests an incremental backup every ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_SECONDS, and acknowledges upstream changes only after a backup containing them has completed. Each replication-manager backs up to its own subfolder of the folder URL configured by ZERO_LITESTREAM_BACKUP_URL. Zero does not delete subfolders left by previous replication-managers. The simplest way to remove them is an object-store lifecycle expiration rule. Every file a restore needs is at most about ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS old, so an expiration of at least twice that interval is safe. Rolling back to a server that does not understand replica-specific paths can require a full resync. flag: --litestream-backup-using-v5 env: ZERO_LITESTREAM_BACKUP_USING_V5 default: true Litestream Incremental Backup Interval Minutes The interval between incremental Litestream v3 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 restore. For v5 backups, use ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_SECONDS. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Incremental Backup Interval Seconds The interval between incremental Litestream v5 backups. Zero does not acknowledge upstream changes until the corresponding replica changes are confirmed in backup storage. Shorter intervals increase storage requests; longer intervals retain more upstream WAL while confirmation is pending. flag: --litestream-incremental-backup-interval-seconds env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_SECONDS 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. This setting is ignored when backing up with Litestream v5. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 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. Leave this unset to use 48, lowered as needed so that the parts fit in a quarter of the memory available to the container. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY 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 Region The AWS region for the litestream backup bucket. Required for non-standard AWS partitions (e.g. GovCloud us-gov-west-1) where Litestream cannot auto-detect the region. The replication-manager and view-syncers must have the same region. flag: --litestream-region env: ZERO_LITESTREAM_REGION 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. Zero retains the previous generation for six additional hours so an active restore can finish before its snapshot and WAL files are removed. This improves restore time and safety at the expense of bandwidth and temporary backup storage. The setting applies to v3 and v5 backups, although v5 generally does not need frequent snapshots for restore speed. 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 Allowed Client Headers Comma-separated allowlist of client-provided custom headers to forward to your mutate endpoint. Header names are matched case-insensitively. By default, no client-provided custom headers are forwarded. flag: --mutate-allowed-client-headers env: ZERO_MUTATE_ALLOWED_CLIENT_HEADERS default: none Mutate Allowed Request Headers Comma-separated allowlist of HTTP headers from the request that opened the WebSocket to forward to your mutate endpoint. Use this for proxy- or load-balancer-injected headers such as x-forwarded-for or cf-ray. Unlike mutate allowed client headers, these values come from the request that established the connection. Header names are matched case-insensitively. Values are retained for the WebSocket's lifetime, so clients must reconnect to receive changes. The allowlist does not verify the header source - only allow headers that a trusted proxy overwrites or removes from untrusted requests. No request headers are forwarded by default. flag: --mutate-allowed-request-headers env: ZERO_MUTATE_ALLOWED_REQUEST_HEADERS default: none 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 max(1, availableParallelism() - 1), reserving one core for the replicator. 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 Operator Password A password for the diagnostic endpoints that can't expose application data, so the people who run zero-cache can debug it without being able to read the data it serves. It grants access to: /profz and /profrmz: CPU profiles. Profiles served for the operator password have the source of every regular expression redacted, since those can contain LIKE patterns from client queries. /statz: counts and sizes. /plannerz: the replica's schema and planner statistics. The other protected endpoints, the inspector and /heapz, still require the admin password. For /profrmz in a multi-node deployment, the replication-manager must be configured with the same operator password. If the operator password is the same as the admin password, it grants admin access. flag: --operator-password env: ZERO_OPERATOR_PASSWORD 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 PG Replication Slot Failover For upstream Postgres 17+, creates replication slots with the failover flag enabled so they can be synchronized to a standby and survive a failover. This requires additional Postgres-side configuration on your provider; see High Availability. Has no effect on Postgres versions before 17. flag: --upstream-pg-replication-slot-failover env: ZERO_UPSTREAM_PG_REPLICATION_SLOT_FAILOVER default: false 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 Allowed Client Headers Comma-separated allowlist of client-provided custom headers to forward to your query endpoint. Header names are matched case-insensitively. By default, no client-provided custom headers are forwarded. flag: --query-allowed-client-headers env: ZERO_QUERY_ALLOWED_CLIENT_HEADERS default: none Query Allowed Request Headers Comma-separated allowlist of HTTP headers from the request that opened the WebSocket to forward to your query endpoint. Use this for proxy- or load-balancer-injected headers such as x-forwarded-for or cf-ray. Unlike query allowed client headers, these values come from the request that established the connection. Header names are matched case-insensitively. Values are retained for the WebSocket's lifetime, so clients must reconnect to receive changes. The allowlist does not verify the header source - only allow headers that a trusted proxy overwrites or removes from untrusted requests. No request headers are forwarded by default. flag: --query-allowed-request-headers env: ZERO_QUERY_ALLOWED_REQUEST_HEADERS default: none 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 Replication Lag Initial Readiness Threshold (ms) The maximum replication lag that a server can have before it reports that it is ready to serve clients. This keeps a server that is starting up from serving stale data. This only applies at startup. Once a server is ready, it keeps serving even if replication lag later goes above this threshold. When lag reporting is disabled, readiness does not wait for replication lag. flag: --replication-lag-initial-readiness-threshold-ms env: ZERO_REPLICATION_LAG_INITIAL_READINESS_THRESHOLD_MS default: 1000 Replication Lag Max Readiness Delay (ms) The maximum time that a server waits for replication lag to fall below ZERO_REPLICATION_LAG_INITIAL_READINESS_THRESHOLD_MS before it reports ready anyway. This keeps a server from waiting forever, for example when the upstream database is under heavy load or there is a network partition. flag: --replication-lag-max-readiness-delay-ms env: ZERO_REPLICATION_LAG_MAX_READINESS_DELAY_MS default: 600000 (10 minutes) Replication Lag Report Interval (ms) The approximate interval at which replication lag reports are written upstream and reported via the zero.replication.total_lag OpenTelemetry metric. If an expected report is not received before the next interval, Zero emits a new report and increments zero.replication.lag_report_retries. Lag reports are also used at startup: a new server waits for replication lag to fall below ZERO_REPLICATION_LAG_INITIAL_READINESS_THRESHOLD_MS before it reports ready. This interval sets how long the server waits for the next report. This feature requires write access to upstream Postgres (uses pg_logical_emit_message()). For PostgreSQL 17+, lag measurements accurately reflect committed write latency (single-digit milliseconds). For PostgreSQL 16 and earlier, measurements may appear 50-100ms longer due to flush behavior. A negative or 0 value disables lag reporting and the readiness wait. Even if otel is not enabled, info and warn-level logs are emitted for large lag values. flag: --replication-lag-report-interval-ms env: ZERO_REPLICATION_LAG_REPORT_INTERVAL_MS default: 10000 Server Version The version string outputted to logs when the server starts up. flag: --server-version env: ZERO_SERVER_VERSION Shadow Sync Enabled Periodically exercises the initial-sync code path against a sample of rows from every published table, writing to a throwaway SQLite database. This acts as a canary: if the real initial-sync path breaks because of schema drift, Postgres version quirks, or another full-resync issue, the shadow run fails before a customer actually needs a full reset. flag: --shadow-sync-enabled env: ZERO_SHADOW_SYNC_ENABLED default: false Shadow Sync Interval Hours The interval between shadow initial-sync runs, in hours. The first run fires within [2/3, 1) of this interval after startup, so the canary completes at least once per task lifetime while still jittering fleet restarts. flag: --shadow-sync-interval-hours env: ZERO_SHADOW_SYNC_INTERVAL_HOURS default: 12 Shadow Sync Sample Rate The Bernoulli sampling rate for each table, where 0 < rate <= 1. A value of 1 disables sampling and copies all rows, still subject to --shadow-sync-max-rows-per-table. flag: --shadow-sync-sample-rate env: ZERO_SHADOW_SYNC_SAMPLE_RATE default: 0.1 Shadow Sync Max Rows Per Table The hard upper bound on rows copied per table per shadow run. This guards against unexpectedly large tables consuming too much disk or upstream bandwidth. flag: --shadow-sync-max-rows-per-table env: ZERO_SHADOW_SYNC_MAX_ROWS_PER_TABLE default: 10000 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 Upstream PG Replication Slot Failover For upstream PostgreSQL 17 and later, create replication slots with the failover parameter set to true to enable slot synchronization and failover. Additional Postgres-level configuration is required when enabling this option. This option has no effect for PostgreSQL versions before 17. See the PostgreSQL docs for details: https://www.postgresql.org/docs/current/logicaldecoding-explanation.html#LOGICALDECODING-REPLICATION-SLOTS-SYNCHRONIZATION flag: --upstream-pg-replication-slot-failover env: ZERO_UPSTREAM_PG_REPLICATION_SLOT_FAILOVER default: false Upstream PG Stream Inbound Timeout The time, in milliseconds, without an inbound message from the upstream WAL sender after which zero-cache tears down the replication stream to force a reconnect. By default, the threshold is twice the server's wal_sender_timeout. Increase this value when a healthy WAL sender can remain silent while decoding unpublished WAL or assembling a large transaction. This changes only Zero's inbound timeout; keepalive timing remains derived from wal_sender_timeout. The option has no effect when wal_sender_timeout is 0, which disables inbound liveness detection. See WAL Sender Timeout. flag: --upstream-pg-stream-inbound-timeout-ms env: ZERO_UPSTREAM_PG_STREAM_INBOUND_TIMEOUT_MS View Syncer Hydration Budget (ms) A soft time limit for hydrating inactive (cached) queries when a view-syncer hydrates a client group's queries, for example after a client reconnects. Active queries always finish. Inactive queries are hydrated after them, starting with the ones whose TTL expires last. Once the budget is spent, the inactive queries that were not reached are removed, along with the remaining TTL that would have kept them cached. The budget is only checked between queries, so a query that has started is not cut short by it. Time spent waiting on your query endpoint does not count toward the budget. A value of 0 disables the budget. flag: --view-syncer-hydration-budget-ms env: ZERO_VIEW_SYNCER_HYDRATION_BUDGET_MS default: 0 View Syncer Query Hydration Timeout (ms) The maximum processing time that a view-syncer spends hydrating a single query. Time spent yielding to other work is not counted. If hydrating a query takes longer than this, the hydration is aborted and the query is removed from the client's view. Clients that use the query receive an error for it (see Handling Errors). For the next five minutes, the query is rejected without being run again. After that, it can be retried. A value of 0 disables the limit. flag: --view-syncer-query-hydration-timeout-ms env: ZERO_VIEW_SYNCER_QUERY_HYDRATION_TIMEOUT_MS default: 0 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. flag: --websocket-max-payload-bytes env: ZERO_WEBSOCKET_MAX_PAYLOAD_BYTES default: 10485760 (10 MiB) 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": "621-zero-cache-config#allow-legacy-queries",
+ "title": "zero-cache Config",
+ "searchTitle": "Allow Legacy Queries",
+ "sectionTitle": "Allow Legacy Queries",
+ "sectionId": "allow-legacy-queries",
+ "url": "/docs/zero-cache-config",
+ "content": "Allows clients to send query ASTs directly to zero-cache. Ad-hoc queries (also called legacy queries) work this way. When this is false, zero-cache rejects client-sent ASTs without parsing them and closes the connection with an error. Keep it disabled when you use queries. flag: --allow-legacy-queries env: ZERO_ALLOW_LEGACY_QUERIES default: false",
"kind": "section"
},
{
- "id": "596-zero-cache-config#app-id",
+ "id": "622-zero-cache-config#app-id",
"title": "zero-cache Config",
"searchTitle": "App ID",
"sectionTitle": "App ID",
@@ -8386,7 +8750,7 @@
"kind": "section"
},
{
- "id": "597-zero-cache-config#app-publications",
+ "id": "623-zero-cache-config#app-publications",
"title": "zero-cache Config",
"searchTitle": "App Publications",
"sectionTitle": "App Publications",
@@ -8396,27 +8760,27 @@
"kind": "section"
},
{
- "id": "598-zero-cache-config#auth-revalidate-interval-seconds",
+ "id": "624-zero-cache-config#auth-revalidate-interval-seconds",
"title": "zero-cache Config",
"searchTitle": "Auth Revalidate Interval Seconds",
"sectionTitle": "Auth Revalidate Interval Seconds",
"sectionId": "auth-revalidate-interval-seconds",
"url": "/docs/zero-cache-config",
- "content": "How often zero-cache re-checks that each live connection is still authorized to use your /query endpoint. On each interval, zero-cache sends a lightweight validation request using that connection's current auth context, such as forwarded cookies or an opaque auth token. If your query endpoint rejects that auth with a 401/403, the connection is disconnected. Use this to bound how long already-open connections can continue after logout, session expiry, token revocation, or other server-side auth changes that happen without a reconnect. Lower values enforce auth changes faster, but send more validation requests to /query. flag: --auth-revalidate-interval-seconds env: ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS default: unset",
+ "content": "How often zero-cache re-checks that each live connection is still authorized to use your /query endpoint. On each interval, zero-cache sends a lightweight validation request using that connection's current auth context, such as forwarded cookies or an opaque auth token. If your query endpoint rejects that auth with a 401/403, the connection is disconnected. Use this to bound how long already-open connections can continue after logout, session expiry, token revocation, or other server-side auth changes that happen without a reconnect. Lower values enforce auth changes faster, but send more validation requests to /query. flag: --auth-revalidate-interval-seconds env: ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS default: 300",
"kind": "section"
},
{
- "id": "599-zero-cache-config#auth-retransform-interval-seconds",
+ "id": "625-zero-cache-config#auth-retransform-interval-seconds",
"title": "zero-cache Config",
"searchTitle": "Auth Retransform Interval Seconds",
"sectionTitle": "Auth Retransform Interval Seconds",
"sectionId": "auth-retransform-interval-seconds",
"url": "/docs/zero-cache-config",
- "content": "How often zero-cache refreshes a client group's synced or named query transformations using one validated connection from that group. This re-runs auth-sensitive query expansion even when the query set itself has not changed. It is useful when your query endpoint generates different ZQL based on current auth or server-side session state, such as roles, organization membership, feature flags, or other permissions-derived context. Use this to bound how long a client group can keep using stale auth-derived query shapes after backend auth state changes. Lower values pick up those changes faster, but do more /query transform work. If clients already call updateAuth whenever auth changes, this mainly serves as a background safety net for out-of-band auth changes. flag: --auth-retransform-interval-seconds env: ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS default: unset",
+ "content": "How often zero-cache refreshes a client group's synced or named query transformations using one validated connection from that group. This re-runs auth-sensitive query expansion even when the query set itself has not changed. It is useful when your query endpoint generates different ZQL based on current auth or server-side session state, such as roles, organization membership, feature flags, or other permissions-derived context. Use this to bound how long a client group can keep using stale auth-derived query shapes after backend auth state changes. Lower values pick up those changes faster, but do more /query transform work. If clients already call updateAuth whenever auth changes, this mainly serves as a background safety net for out-of-band auth changes. flag: --auth-retransform-interval-seconds env: ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS default: 300",
"kind": "section"
},
{
- "id": "600-zero-cache-config#auto-reset",
+ "id": "626-zero-cache-config#auto-reset",
"title": "zero-cache Config",
"searchTitle": "Auto Reset",
"sectionTitle": "Auto Reset",
@@ -8426,7 +8790,7 @@
"kind": "section"
},
{
- "id": "601-zero-cache-config#change-db",
+ "id": "627-zero-cache-config#change-db",
"title": "zero-cache Config",
"searchTitle": "Change DB",
"sectionTitle": "Change DB",
@@ -8436,7 +8800,7 @@
"kind": "section"
},
{
- "id": "602-zero-cache-config#change-streamer-back-pressure-limit-heap-proportion",
+ "id": "628-zero-cache-config#change-streamer-back-pressure-limit-heap-proportion",
"title": "zero-cache Config",
"searchTitle": "Change Streamer Back Pressure Limit Heap Proportion",
"sectionTitle": "Change Streamer Back Pressure Limit Heap Proportion",
@@ -8446,17 +8810,17 @@
"kind": "section"
},
{
- "id": "603-zero-cache-config#change-streamer-flow-control-consensus-timeout-proportion",
+ "id": "629-zero-cache-config#change-streamer-flow-control-consensus-timeout-proportion",
"title": "zero-cache Config",
"searchTitle": "Change Streamer Flow Control Consensus Timeout Proportion",
"sectionTitle": "Change Streamer Flow Control Consensus Timeout Proportion",
"sectionId": "change-streamer-flow-control-consensus-timeout-proportion",
"url": "/docs/zero-cache-config",
- "content": "During periodic flow-control checks, this is the amount of additional time to wait after the majority of serving replicas have acknowledged a checkpoint, expressed as a proportion of the time the majority took. Replication continues after that timeout even if some serving replicas have not acknowledged the checkpoint. A backup replica that is still catching up does not delay caught-up serving replicas; once caught up, the backup must acknowledge the checkpoint before replication continues. For example, if the majority responds in 2.5 ms, a proportion of 2 waits up to another 5 ms. The default lets a subscriber take roughly three times as long as the majority before early release. This bounds the effect of a lagging or unresponsive subscriber while allowing for normal processing-time variance. This value is a ratio, not a duration in seconds. Set it to a negative number to disable early releases and wait for all subscribers. flag: --change-streamer-flow-control-consensus-timeout-proportion env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_TIMEOUT_PROPORTION default: 2",
+ "content": "During periodic flow-control checks, this is the amount of additional time to wait after the majority of serving replicas have acknowledged a checkpoint, expressed as a proportion of the time the majority took. Replication continues after that timeout even if some serving replicas have not acknowledged the checkpoint. A backup replica that is still catching up does not delay caught-up serving replicas; once caught up, the backup must acknowledge the checkpoint before replication continues. For example, if the majority responds in 2.5 ms, a proportion of 2 waits up to another 5 ms. The default lets a subscriber take roughly five times as long as the majority before early release. This bounds the effect of a lagging or unresponsive subscriber while allowing for normal processing-time variance. This value is a ratio, not a duration in seconds. Set it to a negative number to disable early releases and wait for all subscribers. flag: --change-streamer-flow-control-consensus-timeout-proportion env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_TIMEOUT_PROPORTION default: 4",
"kind": "section"
},
{
- "id": "604-zero-cache-config#change-streamer-flow-control-slow-subscriber-grace-period-seconds",
+ "id": "630-zero-cache-config#change-streamer-flow-control-slow-subscriber-grace-period-seconds",
"title": "zero-cache Config",
"searchTitle": "Change Streamer Flow Control Slow Subscriber Grace Period Seconds",
"sectionTitle": "Change Streamer Flow Control Slow Subscriber Grace Period Seconds",
@@ -8466,17 +8830,7 @@
"kind": "section"
},
{
- "id": "605-zero-cache-config#change-streamer-mode",
- "title": "zero-cache Config",
- "searchTitle": "Change Streamer Mode",
- "sectionTitle": "Change Streamer Mode",
- "sectionId": "change-streamer-mode",
- "url": "/docs/zero-cache-config",
- "content": "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",
- "kind": "section"
- },
- {
- "id": "606-zero-cache-config#change-streamer-port",
+ "id": "631-zero-cache-config#change-streamer-port",
"title": "zero-cache Config",
"searchTitle": "Change Streamer Port",
"sectionTitle": "Change Streamer Port",
@@ -8486,7 +8840,7 @@
"kind": "section"
},
{
- "id": "607-zero-cache-config#change-streamer-startup-delay-ms",
+ "id": "632-zero-cache-config#change-streamer-startup-delay-ms",
"title": "zero-cache Config",
"searchTitle": "Change Streamer Startup Delay (ms)",
"sectionTitle": "Change Streamer Startup Delay (ms)",
@@ -8496,7 +8850,7 @@
"kind": "section"
},
{
- "id": "608-zero-cache-config#change-streamer-uri",
+ "id": "633-zero-cache-config#change-streamer-uri",
"title": "zero-cache Config",
"searchTitle": "Change Streamer URI",
"sectionTitle": "Change Streamer URI",
@@ -8506,7 +8860,7 @@
"kind": "section"
},
{
- "id": "609-zero-cache-config#cvr-db",
+ "id": "634-zero-cache-config#cvr-db",
"title": "zero-cache Config",
"searchTitle": "CVR DB",
"sectionTitle": "CVR DB",
@@ -8516,17 +8870,17 @@
"kind": "section"
},
{
- "id": "610-zero-cache-config#cvr-garbage-collection-inactivity-threshold-hours",
+ "id": "635-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",
"sectionId": "cvr-garbage-collection-inactivity-threshold-hours",
"url": "/docs/zero-cache-config",
- "content": "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",
+ "content": "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. Purging a CVR forces the next connection from that client group to sync from scratch. Set this well above how long a typical user goes between sessions. flag: --cvr-garbage-collection-inactivity-threshold-hours env: ZERO_CVR_GARBAGE_COLLECTION_INACTIVITY_THRESHOLD_HOURS default: 168 (one week)",
"kind": "section"
},
{
- "id": "611-zero-cache-config#cvr-garbage-collection-initial-batch-size",
+ "id": "636-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",
@@ -8536,7 +8890,7 @@
"kind": "section"
},
{
- "id": "612-zero-cache-config#cvr-garbage-collection-initial-interval-seconds",
+ "id": "637-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",
@@ -8546,7 +8900,7 @@
"kind": "section"
},
{
- "id": "613-zero-cache-config#cvr-max-connections",
+ "id": "638-zero-cache-config#cvr-max-connections",
"title": "zero-cache Config",
"searchTitle": "CVR Max Connections",
"sectionTitle": "CVR Max Connections",
@@ -8556,7 +8910,7 @@
"kind": "section"
},
{
- "id": "614-zero-cache-config#enable-query-planner",
+ "id": "639-zero-cache-config#enable-query-planner",
"title": "zero-cache Config",
"searchTitle": "Enable Query Planner",
"sectionTitle": "Enable Query Planner",
@@ -8566,7 +8920,7 @@
"kind": "section"
},
{
- "id": "615-zero-cache-config#enable-crud-mutations",
+ "id": "640-zero-cache-config#enable-crud-mutations",
"title": "zero-cache Config",
"searchTitle": "Enable CRUD Mutations",
"sectionTitle": "Enable CRUD Mutations",
@@ -8576,7 +8930,7 @@
"kind": "section"
},
{
- "id": "616-zero-cache-config#enable-telemetry",
+ "id": "641-zero-cache-config#enable-telemetry",
"title": "zero-cache Config",
"searchTitle": "Enable Telemetry",
"sectionTitle": "Enable Telemetry",
@@ -8586,7 +8940,7 @@
"kind": "section"
},
{
- "id": "617-zero-cache-config#initial-sync-table-copy-workers",
+ "id": "642-zero-cache-config#initial-sync-table-copy-workers",
"title": "zero-cache Config",
"searchTitle": "Initial Sync Table Copy Workers",
"sectionTitle": "Initial Sync Table Copy Workers",
@@ -8596,7 +8950,7 @@
"kind": "section"
},
{
- "id": "618-zero-cache-config#lazy-startup",
+ "id": "643-zero-cache-config#lazy-startup",
"title": "zero-cache Config",
"searchTitle": "Lazy Startup",
"sectionTitle": "Lazy Startup",
@@ -8606,17 +8960,17 @@
"kind": "section"
},
{
- "id": "619-zero-cache-config#litestream-backup-url",
+ "id": "644-zero-cache-config#litestream-backup-url",
"title": "zero-cache Config",
"searchTitle": "Litestream Backup URL",
"sectionTitle": "Litestream Backup URL",
"sectionId": "litestream-backup-url",
"url": "/docs/zero-cache-config",
- "content": "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. When enabling Litestream v5 backups, configure this as a folder URL ending in /, for example s3://acme-zero-backups/v1/. Zero resolves each replica-specific backup path relative to this URL; without the trailing slash, URL resolution replaces the final path segment instead of nesting the replica backup under it. flag: --litestream-backup-url env: ZERO_LITESTREAM_BACKUP_URL",
+ "content": "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. With Litestream v5 backups (the default), configure this as a folder URL ending in /, for example s3://acme-zero-backups/v1/. Zero resolves each replica-specific backup path relative to this URL; without the trailing slash, URL resolution replaces the final path segment instead of nesting the replica backup under it. flag: --litestream-backup-url env: ZERO_LITESTREAM_BACKUP_URL",
"kind": "section"
},
{
- "id": "620-zero-cache-config#litestream-endpoint",
+ "id": "645-zero-cache-config#litestream-endpoint",
"title": "zero-cache Config",
"searchTitle": "Litestream Endpoint",
"sectionTitle": "Litestream Endpoint",
@@ -8626,17 +8980,17 @@
"kind": "section"
},
{
- "id": "621-zero-cache-config#litestream-checkpoint-threshold-mb",
+ "id": "646-zero-cache-config#litestream-checkpoint-threshold-mb",
"title": "zero-cache Config",
"searchTitle": "Litestream Checkpoint Threshold MB",
"sectionTitle": "Litestream Checkpoint Threshold MB",
"sectionId": "litestream-checkpoint-threshold-mb",
"url": "/docs/zero-cache-config",
- "content": "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 v3. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. This setting is ignored when backing up with Litestream v5. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40",
+ "content": "The size of the WAL file at which Litestream performs background (PASSIVE) SQLite checkpoints to apply the writes in the WAL to the main database file. Each checkpoint creates new files (WAL segments with Litestream v3, LTX files with v5) that are then backed up. PASSIVE checkpoints are skipped while a write is in progress, so high write rates can make the WAL grow. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40",
"kind": "section"
},
{
- "id": "622-zero-cache-config#litestream-config-path",
+ "id": "647-zero-cache-config#litestream-config-path",
"title": "zero-cache Config",
"searchTitle": "Litestream Config Path",
"sectionTitle": "Litestream Config Path",
@@ -8646,7 +9000,7 @@
"kind": "section"
},
{
- "id": "623-zero-cache-config#litestream-config-path-v5",
+ "id": "648-zero-cache-config#litestream-config-path-v5",
"title": "zero-cache Config",
"searchTitle": "Litestream Config Path V5",
"sectionTitle": "Litestream Config Path V5",
@@ -8656,7 +9010,7 @@
"kind": "section"
},
{
- "id": "624-zero-cache-config#litestream-executable",
+ "id": "649-zero-cache-config#litestream-executable",
"title": "zero-cache Config",
"searchTitle": "Litestream Executable",
"sectionTitle": "Litestream Executable",
@@ -8666,77 +9020,37 @@
"kind": "section"
},
{
- "id": "625-zero-cache-config#litestream-v5-executable",
+ "id": "650-zero-cache-config#litestream-v5-executable",
"title": "zero-cache Config",
"searchTitle": "Litestream V5 Executable",
"sectionTitle": "Litestream V5 Executable",
"sectionId": "litestream-v5-executable",
"url": "/docs/zero-cache-config",
- "content": "Path to the official Litestream v0.5.x executable used for v5 restores and backups. Litestream v0.5.8 and later can restore both legacy WAL backups and LTX backups, choosing the format with the latest data. When a backup URL is configured and ZERO_LITESTREAM_RESTORE_USING_V5 is enabled, this executable is required. The official Zero Docker image includes Litestream 0.5.16. flag: --litestream-executable-v5 env: ZERO_LITESTREAM_EXECUTABLE_V5",
+ "content": "Path to the official Litestream v0.5.x executable used for v5 restores and backups. Litestream v0.5.8 and later can restore both legacy WAL backups and LTX backups, choosing the format with the latest data. When a backup URL is configured and ZERO_LITESTREAM_RESTORE_USING_V5 or ZERO_LITESTREAM_BACKUP_USING_V5 is enabled (both are by default), this executable is required. The official Zero Docker image includes Rocicorp's Litestream build 0.5.18-zero.16. Restores fork the source backup with litestream restore -fork-to-url, so a custom executable must support that flag. flag: --litestream-executable-v5 env: ZERO_LITESTREAM_EXECUTABLE_V5",
"kind": "section"
},
{
- "id": "626-zero-cache-config#litestream-restore-using-v5",
+ "id": "651-zero-cache-config#litestream-restore-using-v5",
"title": "zero-cache Config",
"searchTitle": "Litestream Restore Using V5",
"sectionTitle": "Litestream Restore Using V5",
"sectionId": "litestream-restore-using-v5",
"url": "/docs/zero-cache-config",
- "content": "Use ZERO_LITESTREAM_EXECUTABLE_V5 for restores. Set this to false to force legacy restore behavior. When a backup URL is configured, disabling this option is also the way to run without a v5 executable. Litestream v0.5 cannot restore legacy backups encrypted with Age. Keep legacy restore enabled for those backups or migrate them before enabling v5 restore. flag: --litestream-restore-using-v5 env: ZERO_LITESTREAM_RESTORE_USING_V5 default: true",
+ "content": "Use ZERO_LITESTREAM_EXECUTABLE_V5 for restores. Set this to false to force legacy restore behavior. Because v5 backups require v5 restore, running without a v5 executable when a backup URL is configured requires setting both this option and ZERO_LITESTREAM_BACKUP_USING_V5 to false. Litestream v0.5 cannot restore legacy backups encrypted with Age. Keep legacy restore enabled for those backups or migrate them before enabling v5 restore. flag: --litestream-restore-using-v5 env: ZERO_LITESTREAM_RESTORE_USING_V5 default: true",
"kind": "section"
},
{
- "id": "627-zero-cache-config#litestream-backup-using-v5",
+ "id": "652-zero-cache-config#litestream-backup-using-v5",
"title": "zero-cache Config",
"searchTitle": "Litestream Backup Using V5",
"sectionTitle": "Litestream Backup Using V5",
"sectionId": "litestream-backup-using-v5",
"url": "/docs/zero-cache-config",
- "content": "Write LTX backups with Litestream v0.5.x. This is disabled by default to continue writing legacy WAL backups. Enabling it requires v5 restore and ZERO_LITESTREAM_VFS_QUERY_EXECUTABLE. With v5 backup enabled, Zero acknowledges upstream changes only after they are applied to the replica and confirmed in the backup. Each replica uses a distinct backup subpath under the folder URL configured by ZERO_LITESTREAM_BACKUP_URL. Configure an object-store lifecycle policy to remove old subpaths; Zero does not delete them automatically. Rolling back to a server that does not understand replica-specific paths can require a full resync. flag: --litestream-backup-using-v5 env: ZERO_LITESTREAM_BACKUP_USING_V5 default: false",
- "kind": "section"
- },
- {
- "id": "628-zero-cache-config#litestream-vfs-query-executable",
- "title": "zero-cache Config",
- "searchTitle": "Litestream VFS Query Executable",
- "sectionTitle": "Litestream VFS Query Executable",
- "sectionId": "litestream-vfs-query-executable",
- "url": "/docs/zero-cache-config",
- "content": "Path to the vfs-query executable used to monitor Litestream v5 backup progress. This is required when ZERO_LITESTREAM_BACKUP_USING_V5 is enabled. The official Zero Docker image includes this executable. flag: --litestream-vfs-query-executable env: ZERO_LITESTREAM_VFS_QUERY_EXECUTABLE",
- "kind": "section"
- },
- {
- "id": "629-zero-cache-config#litestream-vfs-poll-interval-ms",
- "title": "zero-cache Config",
- "searchTitle": "Litestream VFS Poll Interval (ms)",
- "sectionTitle": "Litestream VFS Poll Interval (ms)",
- "sectionId": "litestream-vfs-poll-interval-ms",
- "url": "/docs/zero-cache-config",
- "content": "How often the Litestream VFS polls backup storage for the latest backup. This controls how quickly Zero can confirm a backup and acknowledge the corresponding upstream changes. flag: --litestream-vfs-poll-interval-ms env: ZERO_LITESTREAM_VFS_POLL_INTERVAL_MS default: 15000",
- "kind": "section"
- },
- {
- "id": "630-zero-cache-config#litestream-vfs-poll-timeout-ms",
- "title": "zero-cache Config",
- "searchTitle": "Litestream VFS Poll Timeout (ms)",
- "sectionTitle": "Litestream VFS Poll Timeout (ms)",
- "sectionId": "litestream-vfs-poll-timeout-ms",
- "url": "/docs/zero-cache-config",
- "content": "Reserved for configuring request timeouts to the Litestream VFS poller. In this release, vfs-query uses its internal 30-second timeout regardless of this value. flag: --litestream-vfs-poll-timeout-ms env: ZERO_LITESTREAM_VFS_POLL_TIMEOUT_MS default: 10000",
+ "content": "Write LTX backups with Litestream v0.5.x. Set this to false to write legacy WAL backups with Litestream v0.3.x instead. This requires ZERO_LITESTREAM_RESTORE_USING_V5, which is enabled by default. Zero requests an incremental backup every ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_SECONDS, and acknowledges upstream changes only after a backup containing them has completed. Each replication-manager backs up to its own subfolder of the folder URL configured by ZERO_LITESTREAM_BACKUP_URL. Zero does not delete subfolders left by previous replication-managers. The simplest way to remove them is an object-store lifecycle expiration rule. Every file a restore needs is at most about ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS old, so an expiration of at least twice that interval is safe. Rolling back to a server that does not understand replica-specific paths can require a full resync. flag: --litestream-backup-using-v5 env: ZERO_LITESTREAM_BACKUP_USING_V5 default: true",
"kind": "section"
},
{
- "id": "631-zero-cache-config#litestream-vfs-log-file",
- "title": "zero-cache Config",
- "searchTitle": "Litestream VFS Log File",
- "sectionTitle": "Litestream VFS Log File",
- "sectionId": "litestream-vfs-log-file",
- "url": "/docs/zero-cache-config",
- "content": "Reserved for redirecting Litestream VFS logs. In this release, vfs-query writes logs to the child process's standard error regardless of this value. flag: --litestream-vfs-log-file env: ZERO_LITESTREAM_VFS_LOG_FILE",
- "kind": "section"
- },
- {
- "id": "632-zero-cache-config#litestream-incremental-backup-interval-minutes",
+ "id": "653-zero-cache-config#litestream-incremental-backup-interval-minutes",
"title": "zero-cache Config",
"searchTitle": "Litestream Incremental Backup Interval Minutes",
"sectionTitle": "Litestream Incremental Backup Interval Minutes",
@@ -8746,7 +9060,7 @@
"kind": "section"
},
{
- "id": "633-zero-cache-config#litestream-incremental-backup-interval-seconds",
+ "id": "654-zero-cache-config#litestream-incremental-backup-interval-seconds",
"title": "zero-cache Config",
"searchTitle": "Litestream Incremental Backup Interval Seconds",
"sectionTitle": "Litestream Incremental Backup Interval Seconds",
@@ -8756,7 +9070,7 @@
"kind": "section"
},
{
- "id": "634-zero-cache-config#litestream-maximum-checkpoint-page-count",
+ "id": "655-zero-cache-config#litestream-maximum-checkpoint-page-count",
"title": "zero-cache Config",
"searchTitle": "Litestream Maximum Checkpoint Page Count",
"sectionTitle": "Litestream Maximum Checkpoint Page Count",
@@ -8766,27 +9080,17 @@
"kind": "section"
},
{
- "id": "635-zero-cache-config#litestream-minimum-checkpoint-page-count",
- "title": "zero-cache Config",
- "searchTitle": "Litestream Minimum Checkpoint Page Count",
- "sectionTitle": "Litestream Minimum Checkpoint Page Count",
- "sectionId": "litestream-minimum-checkpoint-page-count",
- "url": "/docs/zero-cache-config",
- "content": "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). This setting is ignored when backing up with Litestream v5. flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250",
- "kind": "section"
- },
- {
- "id": "636-zero-cache-config#litestream-multipart-concurrency",
+ "id": "656-zero-cache-config#litestream-multipart-concurrency",
"title": "zero-cache Config",
"searchTitle": "Litestream Multipart Concurrency",
"sectionTitle": "Litestream Multipart Concurrency",
"sectionId": "litestream-multipart-concurrency",
"url": "/docs/zero-cache-config",
- "content": "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",
+ "content": "The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. Leave this unset to use 48, lowered as needed so that the parts fit in a quarter of the memory available to the container. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY",
"kind": "section"
},
{
- "id": "637-zero-cache-config#litestream-multipart-size",
+ "id": "657-zero-cache-config#litestream-multipart-size",
"title": "zero-cache Config",
"searchTitle": "Litestream Multipart Size",
"sectionTitle": "Litestream Multipart Size",
@@ -8796,7 +9100,7 @@
"kind": "section"
},
{
- "id": "638-zero-cache-config#litestream-log-level",
+ "id": "658-zero-cache-config#litestream-log-level",
"title": "zero-cache Config",
"searchTitle": "Litestream Log Level",
"sectionTitle": "Litestream Log Level",
@@ -8806,7 +9110,7 @@
"kind": "section"
},
{
- "id": "639-zero-cache-config#litestream-port",
+ "id": "659-zero-cache-config#litestream-port",
"title": "zero-cache Config",
"searchTitle": "Litestream Port",
"sectionTitle": "Litestream Port",
@@ -8816,7 +9120,7 @@
"kind": "section"
},
{
- "id": "640-zero-cache-config#litestream-region",
+ "id": "660-zero-cache-config#litestream-region",
"title": "zero-cache Config",
"searchTitle": "Litestream Region",
"sectionTitle": "Litestream Region",
@@ -8826,7 +9130,7 @@
"kind": "section"
},
{
- "id": "641-zero-cache-config#litestream-restore-parallelism",
+ "id": "661-zero-cache-config#litestream-restore-parallelism",
"title": "zero-cache Config",
"searchTitle": "Litestream Restore Parallelism",
"sectionTitle": "Litestream Restore Parallelism",
@@ -8836,7 +9140,7 @@
"kind": "section"
},
{
- "id": "642-zero-cache-config#litestream-snapshot-backup-interval-hours",
+ "id": "662-zero-cache-config#litestream-snapshot-backup-interval-hours",
"title": "zero-cache Config",
"searchTitle": "Litestream Snapshot Backup Interval Hours",
"sectionTitle": "Litestream Snapshot Backup Interval Hours",
@@ -8846,7 +9150,7 @@
"kind": "section"
},
{
- "id": "643-zero-cache-config#log-format",
+ "id": "663-zero-cache-config#log-format",
"title": "zero-cache Config",
"searchTitle": "Log Format",
"sectionTitle": "Log Format",
@@ -8856,7 +9160,7 @@
"kind": "section"
},
{
- "id": "644-zero-cache-config#log-ivm-sampling",
+ "id": "664-zero-cache-config#log-ivm-sampling",
"title": "zero-cache Config",
"searchTitle": "Log IVM Sampling",
"sectionTitle": "Log IVM Sampling",
@@ -8866,7 +9170,7 @@
"kind": "section"
},
{
- "id": "645-zero-cache-config#log-level",
+ "id": "665-zero-cache-config#log-level",
"title": "zero-cache Config",
"searchTitle": "Log Level",
"sectionTitle": "Log Level",
@@ -8876,7 +9180,7 @@
"kind": "section"
},
{
- "id": "646-zero-cache-config#log-slow-hydrate-threshold",
+ "id": "666-zero-cache-config#log-slow-hydrate-threshold",
"title": "zero-cache Config",
"searchTitle": "Log Slow Hydrate Threshold",
"sectionTitle": "Log Slow Hydrate Threshold",
@@ -8886,7 +9190,7 @@
"kind": "section"
},
{
- "id": "647-zero-cache-config#log-slow-row-threshold",
+ "id": "667-zero-cache-config#log-slow-row-threshold",
"title": "zero-cache Config",
"searchTitle": "Log Slow Row Threshold",
"sectionTitle": "Log Slow Row Threshold",
@@ -8896,7 +9200,7 @@
"kind": "section"
},
{
- "id": "648-zero-cache-config#mutate-api-key",
+ "id": "668-zero-cache-config#mutate-api-key",
"title": "zero-cache Config",
"searchTitle": "Mutate API Key",
"sectionTitle": "Mutate API Key",
@@ -8906,7 +9210,7 @@
"kind": "section"
},
{
- "id": "649-zero-cache-config#mutate-allowed-client-headers",
+ "id": "669-zero-cache-config#mutate-allowed-client-headers",
"title": "zero-cache Config",
"searchTitle": "Mutate Allowed Client Headers",
"sectionTitle": "Mutate Allowed Client Headers",
@@ -8916,7 +9220,7 @@
"kind": "section"
},
{
- "id": "650-zero-cache-config#mutate-allowed-request-headers",
+ "id": "670-zero-cache-config#mutate-allowed-request-headers",
"title": "zero-cache Config",
"searchTitle": "Mutate Allowed Request Headers",
"sectionTitle": "Mutate Allowed Request Headers",
@@ -8926,7 +9230,7 @@
"kind": "section"
},
{
- "id": "651-zero-cache-config#mutate-forward-cookies",
+ "id": "671-zero-cache-config#mutate-forward-cookies",
"title": "zero-cache Config",
"searchTitle": "Mutate Forward Cookies",
"sectionTitle": "Mutate Forward Cookies",
@@ -8936,7 +9240,7 @@
"kind": "section"
},
{
- "id": "652-zero-cache-config#mutate-url",
+ "id": "672-zero-cache-config#mutate-url",
"title": "zero-cache Config",
"searchTitle": "Mutate URL",
"sectionTitle": "Mutate URL",
@@ -8946,7 +9250,7 @@
"kind": "section"
},
{
- "id": "653-zero-cache-config#number-of-sync-workers",
+ "id": "673-zero-cache-config#number-of-sync-workers",
"title": "zero-cache Config",
"searchTitle": "Number of Sync Workers",
"sectionTitle": "Number of Sync Workers",
@@ -8956,7 +9260,17 @@
"kind": "section"
},
{
- "id": "654-zero-cache-config#per-user-mutation-limit-max",
+ "id": "674-zero-cache-config#operator-password",
+ "title": "zero-cache Config",
+ "searchTitle": "Operator Password",
+ "sectionTitle": "Operator Password",
+ "sectionId": "operator-password",
+ "url": "/docs/zero-cache-config",
+ "content": "A password for the diagnostic endpoints that can't expose application data, so the people who run zero-cache can debug it without being able to read the data it serves. It grants access to: /profz and /profrmz: CPU profiles. Profiles served for the operator password have the source of every regular expression redacted, since those can contain LIKE patterns from client queries. /statz: counts and sizes. /plannerz: the replica's schema and planner statistics. The other protected endpoints, the inspector and /heapz, still require the admin password. For /profrmz in a multi-node deployment, the replication-manager must be configured with the same operator password. If the operator password is the same as the admin password, it grants admin access. flag: --operator-password env: ZERO_OPERATOR_PASSWORD",
+ "kind": "section"
+ },
+ {
+ "id": "675-zero-cache-config#per-user-mutation-limit-max",
"title": "zero-cache Config",
"searchTitle": "Per User Mutation Limit Max",
"sectionTitle": "Per User Mutation Limit Max",
@@ -8966,7 +9280,7 @@
"kind": "section"
},
{
- "id": "655-zero-cache-config#per-user-mutation-limit-window-ms",
+ "id": "676-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)",
@@ -8976,7 +9290,7 @@
"kind": "section"
},
{
- "id": "656-zero-cache-config#pg-replication-slot-failover",
+ "id": "677-zero-cache-config#pg-replication-slot-failover",
"title": "zero-cache Config",
"searchTitle": "PG Replication Slot Failover",
"sectionTitle": "PG Replication Slot Failover",
@@ -8986,7 +9300,7 @@
"kind": "section"
},
{
- "id": "657-zero-cache-config#port",
+ "id": "678-zero-cache-config#port",
"title": "zero-cache Config",
"searchTitle": "Port",
"sectionTitle": "Port",
@@ -8996,7 +9310,7 @@
"kind": "section"
},
{
- "id": "658-zero-cache-config#query-api-key",
+ "id": "679-zero-cache-config#query-api-key",
"title": "zero-cache Config",
"searchTitle": "Query API Key",
"sectionTitle": "Query API Key",
@@ -9006,7 +9320,7 @@
"kind": "section"
},
{
- "id": "659-zero-cache-config#query-allowed-client-headers",
+ "id": "680-zero-cache-config#query-allowed-client-headers",
"title": "zero-cache Config",
"searchTitle": "Query Allowed Client Headers",
"sectionTitle": "Query Allowed Client Headers",
@@ -9016,7 +9330,7 @@
"kind": "section"
},
{
- "id": "660-zero-cache-config#query-allowed-request-headers",
+ "id": "681-zero-cache-config#query-allowed-request-headers",
"title": "zero-cache Config",
"searchTitle": "Query Allowed Request Headers",
"sectionTitle": "Query Allowed Request Headers",
@@ -9026,7 +9340,7 @@
"kind": "section"
},
{
- "id": "661-zero-cache-config#query-forward-cookies",
+ "id": "682-zero-cache-config#query-forward-cookies",
"title": "zero-cache Config",
"searchTitle": "Query Forward Cookies",
"sectionTitle": "Query Forward Cookies",
@@ -9036,7 +9350,7 @@
"kind": "section"
},
{
- "id": "662-zero-cache-config#query-hydration-stats",
+ "id": "683-zero-cache-config#query-hydration-stats",
"title": "zero-cache Config",
"searchTitle": "Query Hydration Stats",
"sectionTitle": "Query Hydration Stats",
@@ -9046,7 +9360,7 @@
"kind": "section"
},
{
- "id": "663-zero-cache-config#query-url",
+ "id": "684-zero-cache-config#query-url",
"title": "zero-cache Config",
"searchTitle": "Query URL",
"sectionTitle": "Query URL",
@@ -9056,7 +9370,7 @@
"kind": "section"
},
{
- "id": "664-zero-cache-config#replica-file",
+ "id": "685-zero-cache-config#replica-file",
"title": "zero-cache Config",
"searchTitle": "Replica File",
"sectionTitle": "Replica File",
@@ -9066,7 +9380,7 @@
"kind": "section"
},
{
- "id": "665-zero-cache-config#replica-vacuum-interval-hours",
+ "id": "686-zero-cache-config#replica-vacuum-interval-hours",
"title": "zero-cache Config",
"searchTitle": "Replica Vacuum Interval Hours",
"sectionTitle": "Replica Vacuum Interval Hours",
@@ -9076,17 +9390,37 @@
"kind": "section"
},
{
- "id": "666-zero-cache-config#replication-lag-report-interval-ms",
+ "id": "687-zero-cache-config#replication-lag-initial-readiness-threshold-ms",
+ "title": "zero-cache Config",
+ "searchTitle": "Replication Lag Initial Readiness Threshold (ms)",
+ "sectionTitle": "Replication Lag Initial Readiness Threshold (ms)",
+ "sectionId": "replication-lag-initial-readiness-threshold-ms",
+ "url": "/docs/zero-cache-config",
+ "content": "The maximum replication lag that a server can have before it reports that it is ready to serve clients. This keeps a server that is starting up from serving stale data. This only applies at startup. Once a server is ready, it keeps serving even if replication lag later goes above this threshold. When lag reporting is disabled, readiness does not wait for replication lag. flag: --replication-lag-initial-readiness-threshold-ms env: ZERO_REPLICATION_LAG_INITIAL_READINESS_THRESHOLD_MS default: 1000",
+ "kind": "section"
+ },
+ {
+ "id": "688-zero-cache-config#replication-lag-max-readiness-delay-ms",
+ "title": "zero-cache Config",
+ "searchTitle": "Replication Lag Max Readiness Delay (ms)",
+ "sectionTitle": "Replication Lag Max Readiness Delay (ms)",
+ "sectionId": "replication-lag-max-readiness-delay-ms",
+ "url": "/docs/zero-cache-config",
+ "content": "The maximum time that a server waits for replication lag to fall below ZERO_REPLICATION_LAG_INITIAL_READINESS_THRESHOLD_MS before it reports ready anyway. This keeps a server from waiting forever, for example when the upstream database is under heavy load or there is a network partition. flag: --replication-lag-max-readiness-delay-ms env: ZERO_REPLICATION_LAG_MAX_READINESS_DELAY_MS default: 600000 (10 minutes)",
+ "kind": "section"
+ },
+ {
+ "id": "689-zero-cache-config#replication-lag-report-interval-ms",
"title": "zero-cache Config",
"searchTitle": "Replication Lag Report Interval (ms)",
"sectionTitle": "Replication Lag Report Interval (ms)",
"sectionId": "replication-lag-report-interval-ms",
"url": "/docs/zero-cache-config",
- "content": "The minimum interval at which replication lag reports are written upstream and reported via the zero.replication.total_lag OpenTelemetry metric. If an expected report is not received before the next interval, Zero emits a new report and increments zero.replication.lag_report_retries. This feature requires write access to upstream Postgres (uses pg_logical_emit_message()). For PostgreSQL 17+, lag measurements accurately reflect committed write latency (single-digit milliseconds). For PostgreSQL 16 and earlier, measurements may appear 50-100ms longer due to flush behavior. A negative or 0 value disables lag reporting. Even if otel is not enabled, info and warn-level logs are emitted for large lag values. flag: --replication-lag-report-interval-ms env: ZERO_REPLICATION_LAG_REPORT_INTERVAL_MS default: 30_000",
+ "content": "The approximate interval at which replication lag reports are written upstream and reported via the zero.replication.total_lag OpenTelemetry metric. If an expected report is not received before the next interval, Zero emits a new report and increments zero.replication.lag_report_retries. Lag reports are also used at startup: a new server waits for replication lag to fall below ZERO_REPLICATION_LAG_INITIAL_READINESS_THRESHOLD_MS before it reports ready. This interval sets how long the server waits for the next report. This feature requires write access to upstream Postgres (uses pg_logical_emit_message()). For PostgreSQL 17+, lag measurements accurately reflect committed write latency (single-digit milliseconds). For PostgreSQL 16 and earlier, measurements may appear 50-100ms longer due to flush behavior. A negative or 0 value disables lag reporting and the readiness wait. Even if otel is not enabled, info and warn-level logs are emitted for large lag values. flag: --replication-lag-report-interval-ms env: ZERO_REPLICATION_LAG_REPORT_INTERVAL_MS default: 10000",
"kind": "section"
},
{
- "id": "667-zero-cache-config#server-version",
+ "id": "690-zero-cache-config#server-version",
"title": "zero-cache Config",
"searchTitle": "Server Version",
"sectionTitle": "Server Version",
@@ -9096,7 +9430,7 @@
"kind": "section"
},
{
- "id": "668-zero-cache-config#shadow-sync-enabled",
+ "id": "691-zero-cache-config#shadow-sync-enabled",
"title": "zero-cache Config",
"searchTitle": "Shadow Sync Enabled",
"sectionTitle": "Shadow Sync Enabled",
@@ -9106,7 +9440,7 @@
"kind": "section"
},
{
- "id": "669-zero-cache-config#shadow-sync-interval-hours",
+ "id": "692-zero-cache-config#shadow-sync-interval-hours",
"title": "zero-cache Config",
"searchTitle": "Shadow Sync Interval Hours",
"sectionTitle": "Shadow Sync Interval Hours",
@@ -9116,7 +9450,7 @@
"kind": "section"
},
{
- "id": "670-zero-cache-config#shadow-sync-sample-rate",
+ "id": "693-zero-cache-config#shadow-sync-sample-rate",
"title": "zero-cache Config",
"searchTitle": "Shadow Sync Sample Rate",
"sectionTitle": "Shadow Sync Sample Rate",
@@ -9126,7 +9460,7 @@
"kind": "section"
},
{
- "id": "671-zero-cache-config#shadow-sync-max-rows-per-table",
+ "id": "694-zero-cache-config#shadow-sync-max-rows-per-table",
"title": "zero-cache Config",
"searchTitle": "Shadow Sync Max Rows Per Table",
"sectionTitle": "Shadow Sync Max Rows Per Table",
@@ -9136,7 +9470,7 @@
"kind": "section"
},
{
- "id": "672-zero-cache-config#storage-db-temp-dir",
+ "id": "695-zero-cache-config#storage-db-temp-dir",
"title": "zero-cache Config",
"searchTitle": "Storage DB Temp Dir",
"sectionTitle": "Storage DB Temp Dir",
@@ -9146,7 +9480,7 @@
"kind": "section"
},
{
- "id": "673-zero-cache-config#task-id",
+ "id": "696-zero-cache-config#task-id",
"title": "zero-cache Config",
"searchTitle": "Task ID",
"sectionTitle": "Task ID",
@@ -9156,7 +9490,7 @@
"kind": "section"
},
{
- "id": "674-zero-cache-config#upstream-max-connections",
+ "id": "697-zero-cache-config#upstream-max-connections",
"title": "zero-cache Config",
"searchTitle": "Upstream Max Connections",
"sectionTitle": "Upstream Max Connections",
@@ -9166,7 +9500,7 @@
"kind": "section"
},
{
- "id": "675-zero-cache-config#upstream-pg-replication-slot-failover",
+ "id": "698-zero-cache-config#upstream-pg-replication-slot-failover",
"title": "zero-cache Config",
"searchTitle": "Upstream PG Replication Slot Failover",
"sectionTitle": "Upstream PG Replication Slot Failover",
@@ -9176,7 +9510,7 @@
"kind": "section"
},
{
- "id": "676-zero-cache-config#upstream-pg-stream-inbound-timeout",
+ "id": "699-zero-cache-config#upstream-pg-stream-inbound-timeout",
"title": "zero-cache Config",
"searchTitle": "Upstream PG Stream Inbound Timeout",
"sectionTitle": "Upstream PG Stream Inbound Timeout",
@@ -9186,7 +9520,27 @@
"kind": "section"
},
{
- "id": "677-zero-cache-config#websocket-compression",
+ "id": "700-zero-cache-config#view-syncer-hydration-budget-ms",
+ "title": "zero-cache Config",
+ "searchTitle": "View Syncer Hydration Budget (ms)",
+ "sectionTitle": "View Syncer Hydration Budget (ms)",
+ "sectionId": "view-syncer-hydration-budget-ms",
+ "url": "/docs/zero-cache-config",
+ "content": "A soft time limit for hydrating inactive (cached) queries when a view-syncer hydrates a client group's queries, for example after a client reconnects. Active queries always finish. Inactive queries are hydrated after them, starting with the ones whose TTL expires last. Once the budget is spent, the inactive queries that were not reached are removed, along with the remaining TTL that would have kept them cached. The budget is only checked between queries, so a query that has started is not cut short by it. Time spent waiting on your query endpoint does not count toward the budget. A value of 0 disables the budget. flag: --view-syncer-hydration-budget-ms env: ZERO_VIEW_SYNCER_HYDRATION_BUDGET_MS default: 0",
+ "kind": "section"
+ },
+ {
+ "id": "701-zero-cache-config#view-syncer-query-hydration-timeout-ms",
+ "title": "zero-cache Config",
+ "searchTitle": "View Syncer Query Hydration Timeout (ms)",
+ "sectionTitle": "View Syncer Query Hydration Timeout (ms)",
+ "sectionId": "view-syncer-query-hydration-timeout-ms",
+ "url": "/docs/zero-cache-config",
+ "content": "The maximum processing time that a view-syncer spends hydrating a single query. Time spent yielding to other work is not counted. If hydrating a query takes longer than this, the hydration is aborted and the query is removed from the client's view. Clients that use the query receive an error for it (see Handling Errors). For the next five minutes, the query is rejected without being run again. After that, it can be retried. A value of 0 disables the limit. flag: --view-syncer-query-hydration-timeout-ms env: ZERO_VIEW_SYNCER_QUERY_HYDRATION_TIMEOUT_MS default: 0",
+ "kind": "section"
+ },
+ {
+ "id": "702-zero-cache-config#websocket-compression",
"title": "zero-cache Config",
"searchTitle": "Websocket Compression",
"sectionTitle": "Websocket Compression",
@@ -9196,7 +9550,7 @@
"kind": "section"
},
{
- "id": "678-zero-cache-config#websocket-compression-options",
+ "id": "703-zero-cache-config#websocket-compression-options",
"title": "zero-cache Config",
"searchTitle": "Websocket Compression Options",
"sectionTitle": "Websocket Compression Options",
@@ -9206,7 +9560,7 @@
"kind": "section"
},
{
- "id": "679-zero-cache-config#websocket-max-payload-bytes",
+ "id": "704-zero-cache-config#websocket-max-payload-bytes",
"title": "zero-cache Config",
"searchTitle": "Websocket Max Payload Bytes",
"sectionTitle": "Websocket Max Payload Bytes",
@@ -9216,7 +9570,7 @@
"kind": "section"
},
{
- "id": "680-zero-cache-config#yield-threshold-ms",
+ "id": "705-zero-cache-config#yield-threshold-ms",
"title": "zero-cache Config",
"searchTitle": "Yield Threshold (ms)",
"sectionTitle": "Yield Threshold (ms)",
@@ -9226,17 +9580,17 @@
"kind": "section"
},
{
- "id": "681-zero-cache-config#deprecated-flags",
+ "id": "706-zero-cache-config#deprecated-flags",
"title": "zero-cache Config",
"searchTitle": "Deprecated Flags",
"sectionTitle": "Deprecated Flags",
"sectionId": "deprecated-flags",
"url": "/docs/zero-cache-config",
- "content": "Change Max Connections ZERO_CHANGE_MAX_CONNS is deprecated and no longer controls change database connection usage. Size the database or pooler as described under Change DB. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS 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": "Change Max Connections ZERO_CHANGE_MAX_CONNS is deprecated and no longer controls change database connection usage. Size the database or pooler as described under Change DB. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS Change Streamer Mode ZERO_CHANGE_STREAMER_MODE is deprecated. Set ZERO_CHANGE_STREAMER_URI on view-syncers instead. 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). This option is ignored if ZERO_CHANGE_STREAMER_URI is set. flag: --change-streamer-mode env: ZERO_CHANGE_STREAMER_MODE default: dedicated Litestream Minimum Checkpoint Page Count ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT is deprecated. Use ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB instead. The WAL page count at which SQLite attempts a PASSIVE checkpoint. If unset, it is derived from the checkpoint threshold as checkpointThresholdMB * 256 (4 KiB pages). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 256 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",
"kind": "section"
},
{
- "id": "682-zero-cache-config#change-max-connections",
+ "id": "707-zero-cache-config#change-max-connections",
"title": "zero-cache Config",
"searchTitle": "Change Max Connections",
"sectionTitle": "Change Max Connections",
@@ -9246,7 +9600,27 @@
"kind": "section"
},
{
- "id": "683-zero-cache-config#auth-jwk",
+ "id": "708-zero-cache-config#change-streamer-mode",
+ "title": "zero-cache Config",
+ "searchTitle": "Change Streamer Mode",
+ "sectionTitle": "Change Streamer Mode",
+ "sectionId": "change-streamer-mode",
+ "url": "/docs/zero-cache-config",
+ "content": "ZERO_CHANGE_STREAMER_MODE is deprecated. Set ZERO_CHANGE_STREAMER_URI on view-syncers instead. 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). This option is ignored if ZERO_CHANGE_STREAMER_URI is set. flag: --change-streamer-mode env: ZERO_CHANGE_STREAMER_MODE default: dedicated",
+ "kind": "section"
+ },
+ {
+ "id": "709-zero-cache-config#litestream-minimum-checkpoint-page-count",
+ "title": "zero-cache Config",
+ "searchTitle": "Litestream Minimum Checkpoint Page Count",
+ "sectionTitle": "Litestream Minimum Checkpoint Page Count",
+ "sectionId": "litestream-minimum-checkpoint-page-count",
+ "url": "/docs/zero-cache-config",
+ "content": "ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT is deprecated. Use ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB instead. The WAL page count at which SQLite attempts a PASSIVE checkpoint. If unset, it is derived from the checkpoint threshold as checkpointThresholdMB * 256 (4 KiB pages). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 256",
+ "kind": "section"
+ },
+ {
+ "id": "710-zero-cache-config#auth-jwk",
"title": "zero-cache Config",
"searchTitle": "Auth JWK",
"sectionTitle": "Auth JWK",
@@ -9256,7 +9630,7 @@
"kind": "section"
},
{
- "id": "684-zero-cache-config#auth-jwks-url",
+ "id": "711-zero-cache-config#auth-jwks-url",
"title": "zero-cache Config",
"searchTitle": "Auth JWKS URL",
"sectionTitle": "Auth JWKS URL",
@@ -9266,7 +9640,7 @@
"kind": "section"
},
{
- "id": "685-zero-cache-config#auth-secret",
+ "id": "712-zero-cache-config#auth-secret",
"title": "zero-cache Config",
"searchTitle": "Auth Secret",
"sectionTitle": "Auth Secret",
@@ -9276,7 +9650,7 @@
"kind": "section"
},
{
- "id": "77-zql",
+ "id": "78-zql",
"title": "ZQL",
"searchTitle": "ZQL",
"url": "/docs/zql",
@@ -9386,7 +9760,7 @@
"kind": "page"
},
{
- "id": "686-zql#create-a-builder",
+ "id": "713-zql#create-a-builder",
"title": "ZQL",
"searchTitle": "Create a Builder",
"sectionTitle": "Create a Builder",
@@ -9396,7 +9770,7 @@
"kind": "section"
},
{
- "id": "687-zql#select",
+ "id": "714-zql#select",
"title": "ZQL",
"searchTitle": "Select",
"sectionTitle": "Select",
@@ -9406,7 +9780,7 @@
"kind": "section"
},
{
- "id": "688-zql#ordering",
+ "id": "715-zql#ordering",
"title": "ZQL",
"searchTitle": "Ordering",
"sectionTitle": "Ordering",
@@ -9416,7 +9790,7 @@
"kind": "section"
},
{
- "id": "689-zql#limit",
+ "id": "716-zql#limit",
"title": "ZQL",
"searchTitle": "Limit",
"sectionTitle": "Limit",
@@ -9426,7 +9800,7 @@
"kind": "section"
},
{
- "id": "690-zql#paging",
+ "id": "717-zql#paging",
"title": "ZQL",
"searchTitle": "Paging",
"sectionTitle": "Paging",
@@ -9436,7 +9810,7 @@
"kind": "section"
},
{
- "id": "691-zql#getting-a-single-result",
+ "id": "718-zql#getting-a-single-result",
"title": "ZQL",
"searchTitle": "Getting a Single Result",
"sectionTitle": "Getting a Single Result",
@@ -9446,7 +9820,7 @@
"kind": "section"
},
{
- "id": "692-zql#relationships",
+ "id": "719-zql#relationships",
"title": "ZQL",
"searchTitle": "Relationships",
"sectionTitle": "Relationships",
@@ -9456,7 +9830,7 @@
"kind": "section"
},
{
- "id": "693-zql#refining-relationships",
+ "id": "720-zql#refining-relationships",
"title": "ZQL",
"searchTitle": "Refining Relationships",
"sectionTitle": "Refining Relationships",
@@ -9466,7 +9840,7 @@
"kind": "section"
},
{
- "id": "694-zql#nested-relationships",
+ "id": "721-zql#nested-relationships",
"title": "ZQL",
"searchTitle": "Nested Relationships",
"sectionTitle": "Nested Relationships",
@@ -9476,7 +9850,7 @@
"kind": "section"
},
{
- "id": "695-zql#where",
+ "id": "722-zql#where",
"title": "ZQL",
"searchTitle": "Where",
"sectionTitle": "Where",
@@ -9486,7 +9860,7 @@
"kind": "section"
},
{
- "id": "696-zql#comparison-operators",
+ "id": "723-zql#comparison-operators",
"title": "ZQL",
"searchTitle": "Comparison Operators",
"sectionTitle": "Comparison Operators",
@@ -9496,7 +9870,7 @@
"kind": "section"
},
{
- "id": "697-zql#equals-is-the-default-comparison-operator",
+ "id": "724-zql#equals-is-the-default-comparison-operator",
"title": "ZQL",
"searchTitle": "Equals is the Default Comparison Operator",
"sectionTitle": "Equals is the Default Comparison Operator",
@@ -9506,7 +9880,7 @@
"kind": "section"
},
{
- "id": "698-zql#comparing-to-null",
+ "id": "725-zql#comparing-to-null",
"title": "ZQL",
"searchTitle": "Comparing to null",
"sectionTitle": "Comparing to null",
@@ -9516,7 +9890,7 @@
"kind": "section"
},
{
- "id": "699-zql#comparing-to-undefined",
+ "id": "726-zql#comparing-to-undefined",
"title": "ZQL",
"searchTitle": "Comparing to undefined",
"sectionTitle": "Comparing to undefined",
@@ -9526,7 +9900,7 @@
"kind": "section"
},
{
- "id": "700-zql#compound-filters",
+ "id": "727-zql#compound-filters",
"title": "ZQL",
"searchTitle": "Compound Filters",
"sectionTitle": "Compound Filters",
@@ -9536,7 +9910,7 @@
"kind": "section"
},
{
- "id": "701-zql#comparing-literal-values",
+ "id": "728-zql#comparing-literal-values",
"title": "ZQL",
"searchTitle": "Comparing Literal Values",
"sectionTitle": "Comparing Literal Values",
@@ -9546,7 +9920,7 @@
"kind": "section"
},
{
- "id": "702-zql#relationship-filters",
+ "id": "729-zql#relationship-filters",
"title": "ZQL",
"searchTitle": "Relationship Filters",
"sectionTitle": "Relationship Filters",
@@ -9556,7 +9930,7 @@
"kind": "section"
},
{
- "id": "703-zql#type-helpers",
+ "id": "730-zql#type-helpers",
"title": "ZQL",
"searchTitle": "Type Helpers",
"sectionTitle": "Type Helpers",
@@ -9566,7 +9940,7 @@
"kind": "section"
},
{
- "id": "704-zql#planning",
+ "id": "731-zql#planning",
"title": "ZQL",
"searchTitle": "Planning",
"sectionTitle": "Planning",
@@ -9576,7 +9950,7 @@
"kind": "section"
},
{
- "id": "705-zql#inspecting-query-plans",
+ "id": "732-zql#inspecting-query-plans",
"title": "ZQL",
"searchTitle": "Inspecting Query Plans",
"sectionTitle": "Inspecting Query Plans",
@@ -9586,7 +9960,7 @@
"kind": "section"
},
{
- "id": "706-zql#manually-flipping-joins",
+ "id": "733-zql#manually-flipping-joins",
"title": "ZQL",
"searchTitle": "Manually Flipping Joins",
"sectionTitle": "Manually Flipping Joins",
@@ -9596,7 +9970,7 @@
"kind": "section"
},
{
- "id": "707-zql#scalar-subqueries",
+ "id": "734-zql#scalar-subqueries",
"title": "ZQL",
"searchTitle": "Scalar Subqueries",
"sectionTitle": "Scalar Subqueries",
@@ -9606,7 +9980,7 @@
"kind": "section"
},
{
- "id": "708-zql#why-it-matters",
+ "id": "735-zql#why-it-matters",
"title": "ZQL",
"searchTitle": "Why It Matters",
"sectionTitle": "Why It Matters",
@@ -9616,7 +9990,7 @@
"kind": "section"
},
{
- "id": "709-zql#trade-offs",
+ "id": "736-zql#trade-offs",
"title": "ZQL",
"searchTitle": "Trade-offs",
"sectionTitle": "Trade-offs",
@@ -9626,7 +10000,7 @@
"kind": "section"
},
{
- "id": "710-zql#future-work",
+ "id": "737-zql#future-work",
"title": "ZQL",
"searchTitle": "Future Work",
"sectionTitle": "Future Work",
diff --git a/contents/docs/connection.mdx b/contents/docs/connection.mdx
index 7a197c73..dbb0f0c2 100644
--- a/contents/docs/connection.mdx
+++ b/contents/docs/connection.mdx
@@ -7,11 +7,13 @@ title: Connection Status
Zero manages a persistent connection to `zero-cache` with the following lifecycle:
+Before any of this, Zero is [`initializing`](#initializing) while it loads local data. After that it moves to `connecting` and never goes back.
+
## Usage
The current connection state is available in the `zero.connection.state` property. This is subscribable and also has reactive hooks for React and SolidJS:
@@ -40,6 +42,8 @@ function ConnectionStatus() {
const state = useConnectionState()
switch (state.name) {
+ case 'initializing':
+ return
Loading...
case 'connecting':
return
Connecting...
case 'connected':
@@ -64,6 +68,10 @@ function ConnectionStatus() {
return (
+
+
Loading...
+
+
Connecting...
@@ -91,6 +99,9 @@ function ConnectionStatus() {
```ts
zero.connection.state.subscribe(state => {
switch (state.name) {
+ case 'initializing':
+ console.log('Loading local data...')
+ break
case 'connecting':
console.log(`Connecting... ${state.reason}`)
break
@@ -120,6 +131,7 @@ Zero [does not support offline writes](#why-zero-doesnt-support-offline-writes).
| State | Reads | Writes |
| -------------- | ----- | ----------- |
+| `initializing` | ✅ | ✅ (queued) |
| `connecting` | ✅ | ✅ (queued) |
| `connected` | ✅ | ✅ |
| `disconnected` | ✅ | ❌ |
@@ -137,11 +149,23 @@ The easiest way to implement this is with a modal overlay that covers the entire
## Details
+### Initializing
+
+Zero starts in the `initializing` state, where it loads its local data. It hasn't contacted `zero-cache` yet, so the network plays no part. How long this takes depends on the device and on how much data is stored locally. On a slow device with a lot of data, it can take many seconds.
+
+When the data is loaded, Zero moves to `connecting`. This only happens once, right after Zero is created. Later reconnects go straight to `connecting`.
+
+While `initializing`, show a loading indicator rather than an offline or error message. If your app warns the user when Zero takes too long to connect, start that timer when Zero enters `connecting`.
+
+There is no timeout while `initializing`, and the time doesn't count against `disconnectTimeoutMs`. If the local data never finishes loading, Zero stays in `initializing`, so add your own timeout if you need one.
+
+Reads and writes are allowed. Queries return results once the local data is loaded, and writes are queued until Zero connects.
+
### Connecting
-Zero starts in the `connecting` state.
+Zero enters the `connecting` state after [`initializing`](#initializing).
-While `connecting`, Zero repeatedly tries to connect to `zero-cache`. After 1 minute of failed attempts, it transitions to `disconnected`. This timeout can be configured with the `disconnectTimeoutMs` constructor parameter:
+While `connecting`, Zero repeatedly tries to connect to `zero-cache`. After 1 minute of failed attempts, it transitions to `disconnected`. Time spent `initializing` does not count toward this minute. This timeout can be configured with the `disconnectTimeoutMs` constructor parameter:
```tsx
const opts: ZeroOptions = {
@@ -177,7 +201,7 @@ Once Zero connects to `zero-cache` and syncs the first time, it transitions to t
After the `disconnectTimeoutMs` elapses while in the `connecting` state, Zero transitions to `disconnected`. Zero also transitions to `disconnected` when the tab is hidden for `hiddenTabDisconnectDelay` (default 5 minutes).
-While `disconnected`, Zero continues to try to reconnect to `zero-cache` every 5 seconds.
+While `disconnected`, Zero continues to try to reconnect to `zero-cache` every 5 seconds. After a hidden-tab disconnect, Zero instead waits until the tab is visible again, then goes back to `connecting`.
Reads are allowed while `disconnected`, but writes are rejected and return an offline error. See [Offline](#offline) for more information.
@@ -229,6 +253,60 @@ Most applications will never call `close()`, and even if they do, they should no
Reads and writes are both rejected while Zero is in the `closed` state.
+## Storage Failures
+
+Zero stores synced data on the device, in IndexedDB in the browser or SQLite on [React Native](/docs/react-native). Sometimes that fails: the device runs out of space, or the database won't open.
+
+When it does, Zero calls `onStorageFailure` once with a `StorageFailureError`. Its `kind` says what went wrong:
+
+| `kind` | Meaning |
+| ------------- | --------------------------------------- |
+| `full` | The device is out of space. |
+| `cannot-open` | The database can't be opened. |
+| `io-error` | A read or write to the database failed. |
+
+In the browser, Zero reports a storage failure only when IndexedDB fails to open: `full` if the browser says the disk is full, otherwise `cannot-open`.
+
+Zero keeps working either way. If storage fails while Zero is starting, Zero runs in memory for the rest of the session, as if you had set `kvStore: 'mem'`, and nothing is saved on the device. If it fails later, Zero stops saving to the device. Queries and mutations keep working on the data in memory and mutations are still sent to `zero-cache`, but a read that needs data Zero hasn't loaded into memory can still fail.
+
+Zero only tries the device's storage again when the app creates a new `Zero` instance, for example after a restart.
+
+By default, Zero only logs the failure. Use `onStorageFailure` to tell the user what to do:
+
+```ts
+import type {ZeroOptions} from '@rocicorp/zero'
+
+const opts: ZeroOptions = {
+ // ...
+ onStorageFailure: failure => {
+ // showBanner stands in for your app's own UI.
+ if (failure.kind === 'full') {
+ showBanner(
+ 'Your device is out of space. Free up some space, then restart the app.'
+ )
+ } else {
+ showBanner(
+ 'Data could not be saved on this device. Restart the app to try again.'
+ )
+ }
+ }
+}
+```
+
+`StorageFailureError` is exported from `@rocicorp/zero`. Its `cause` is the original error from the browser or the SQLite driver. A custom `kvStore` can throw a `StorageFailureError` to get the same handling.
+
+### Checking the Store
+
+`zero.kvStore.kind` tells you which storage Zero is using: `'idb'`, `'mem'`, `'expo-sqlite'`, or `'op-sqlite'`. A custom store reports its own `kind`, if it sets one.
+
+```ts
+if (zero.kvStore.kind === 'mem') {
+ // Data is not saved on this device.
+}
+```
+
+Without IndexedDB, `kind` is `'mem'` from the start and `onStorageFailure` isn't called. When IndexedDB fails to open, `kind` changes to `'mem'` only when Zero first reads or writes the store, so check `kind` when you need it, not once at startup.
+
## Why Zero Doesn't Support Offline Writes
Supporting offline writes in collaborative applications is inherently difficult, and no sync engine or CRDT algorithm can automatically solve it for you. Despite what their marketing says 😉.
diff --git a/contents/docs/debug/slow-queries.mdx b/contents/docs/debug/slow-queries.mdx
index 7b248531..6792a297 100644
--- a/contents/docs/debug/slow-queries.mdx
+++ b/contents/docs/debug/slow-queries.mdx
@@ -45,4 +45,4 @@ Fly.io provides physically attached SSDs, even for their smallest VMs. Deploying
## /statz
-`zero-cache` makes some internal health statistics available via the `/statz` endpoint of `zero-cache`. In order to access this, you must configure an [admin password](/docs/zero-cache-config#admin-password).
+`zero-cache` makes some internal health statistics available via the `/statz` endpoint of `zero-cache`. In order to access this, you must configure an [admin password](/docs/zero-cache-config#admin-password) or an [operator password](/docs/zero-cache-config#operator-password).
diff --git a/contents/docs/deprecated/ad-hoc-queries.mdx b/contents/docs/deprecated/ad-hoc-queries.mdx
index 91eab209..4cd46201 100644
--- a/contents/docs/deprecated/ad-hoc-queries.mdx
+++ b/contents/docs/deprecated/ad-hoc-queries.mdx
@@ -23,6 +23,8 @@ const schema = createSchema({
})
```
+Ad-hoc queries send their query ASTs directly to `zero-cache`, which rejects them by default. To allow them, also set [`ZERO_ALLOW_LEGACY_QUERIES`](/docs/zero-cache-config#allow-legacy-queries) to `true` on `zero-cache`.
+
Once enabled, queries are available at `z.query.`.
```tsx
diff --git a/contents/docs/mutators.mdx b/contents/docs/mutators.mdx
index 588bf923..e1866c79 100644
--- a/contents/docs/mutators.mdx
+++ b/contents/docs/mutators.mdx
@@ -864,6 +864,74 @@ If Zero receives HTTP 401 or 403, the client will enter the needs auth state and
If you want a different behavior, it is possible to [implement the mutate endpoint](#custom-mutate-implementation) yourself and handle errors differently.
+### Retrying Mutators
+
+Some errors go away on a second try. At the `repeatable read` and `serializable` [isolation levels](/docs/server-zql#isolation-level), for example, Postgres can abort a transaction that conflicts with a concurrent one. It fails with a serialization failure, code `40001`.
+
+To have Zero run a server mutator again in a fresh transaction when it fails with an error like that, call `tx.retryOn()`:
+
+```ts
+function isSerializationFailure(error: unknown) {
+ return (
+ error instanceof Error &&
+ 'code' in error &&
+ error.code === '40001'
+ )
+}
+
+const updateIssue = defineMutator(
+ z.object({id: z.string(), title: z.string()}),
+ async ({tx, args: {id, title}}) => {
+ if (tx.location === 'server') {
+ tx.retryOn(isSerializationFailure)
+ }
+ await tx.mutate.issue.update({id, title})
+ }
+)
+```
+
+`retryOn()` takes a predicate. It gets the error and the attempt number: `1` when deciding the first re-run, `2` for the second, and so on. Return `true` to run the mutator again. If it returns anything else or throws, Zero records the error and skips the mutation, as with any [other error](#handling-errors).
+
+The predicate sees errors the mutator throws, and also errors Postgres raises at `COMMIT`, which the mutator can't catch. Everything the failed run wrote is rolled back, and the re-run reads fresh data.
+
+The error is passed as it was thrown. node-postgres and postgres.js errors have the Postgres error code in `code`. Other libraries can wrap the error, so check what yours throws.
+
+The predicate can also return a promise. Zero waits for it, so you can use it to back off before the next run:
+
+```ts
+tx.retryOn(async (error, attempt) => {
+ if (!isSerializationFailure(error)) {
+ return false
+ }
+ // Wait 50ms, then 100ms, then 150ms, and so on.
+ await new Promise(resolve =>
+ setTimeout(resolve, attempt * 50)
+ )
+ return true
+})
+```
+
+`retryOn()` only applies to the current run. The re-run starts the mutator from the top and calls `retryOn()` again, so call it before anything that can fail. You can call it more than once, and Zero runs the mutator again if any predicate returns `true`.
+
+Zero runs a mutator at most 5 times per mutation in each request to your mutate endpoint. If the last run also fails, Zero records the error.
+
+The predicate runs after the transaction has ended, so it can't use `tx`: reading or writing through `tx` there throws.
+
+Only retry mutators that are safe to run more than once. Work outside the database, such as sending an email, isn't rolled back. `retryOn()` only exists on the server, when `tx.location === 'server'`.
+
+
+ The built-in adapters and `ZQLDatabase` support
+ `retryOn()`. If you pass your own `Database` to
+ `handleMutateRequest`, it must pass the
+ `addRetryPredicate` it receives in
+ `TransactionProviderInput` on to the transaction it
+ creates with `CRUDMutatorFactory.createTransaction()`.
+ Otherwise `tx.retryOn()` throws.
+
+
### Custom Mutate URL
By default, Zero sends mutations to the URL specified in the `ZERO_MUTATE_URL` parameter.
@@ -1064,14 +1132,55 @@ const read2 = await zero.run(
If the client-side mutator fails, `.server` also resolves to an error result. Awaiting `.server` therefore covers both client- and server-side failures.
-
- There is not yet a way to return data from mutators in the
- success case. [Let us know](https://discord.rocicorp.dev/)
- if you need this.
-
+### Returning Data
+
+A mutator can return a JSON value. The caller gets it in the `data` field of the success result.
+
+The client and server runs can return different values. For example, the server can return a value that only the database knows:
+
+```ts
+const createIssue = defineMutator(
+ z.object({id: z.string(), title: z.string()}),
+ async ({tx, args: {id, title}}) => {
+ await tx.mutate.issue.insert({id, title})
+
+ if (tx.location === 'client') {
+ // The client doesn't know the short ID yet.
+ return {shortID: null}
+ }
+
+ // Postgres assigns shortID when the row is inserted.
+ const issue = await tx.run(
+ zql.issue.where('id', id).one()
+ )
+ return {shortID: issue?.shortID ?? null}
+ }
+)
+```
+
+The `.client` result's `data` is what the client run returned, and the `.server` result's `data` is what the server run returned:
+
+```ts
+const write = zero.mutate(
+ mutators.issue.create({
+ id: crypto.randomUUID(),
+ title: 'New title'
+ })
+)
+
+const serverRes = await write.server
+if (serverRes.type === 'success') {
+ // `data` is a plain JSON value, so check its shape.
+ const {shortID} = z
+ .object({shortID: z.number().nullable()})
+ .parse(serverRes.data)
+ console.log('Short ID:', shortID)
+}
+```
+
+`data` is typed as `ReadonlyJSONValue | undefined` rather than as the mutator's return type, so check its shape before you use it. A mutator that returns nothing gives `undefined`.
+
+The server's value only reaches the client if your [mutate handler](#implementing-the-endpoint) returns the mutator's result, as `return mutator.fn({args, tx})` does in the examples above. Zero also saves that value in your database with the mutation's result, so the client still gets it if the response from your mutate endpoint is lost. Nothing is saved for a mutator that returns nothing.
## Permissions
diff --git a/contents/docs/queries.mdx b/contents/docs/queries.mdx
index a95342db..6fa06128 100644
--- a/contents/docs/queries.mdx
+++ b/contents/docs/queries.mdx
@@ -584,6 +584,15 @@ const results = await zero.run(
)
```
+Pass `{type: 'cached'}` to also accept a result the server confirmed in an earlier session. It resolves as soon as the result is [`cached`](#cached-results) or `complete`, so it can return without waiting for the server:
+
+```tsx
+const results = await zero.run(
+ queries.issues.byPriority('high'),
+ {type: 'cached'}
+)
+```
+
### For Preloading
Almost all Zero apps will want to preload some data in order to maximize the feel of instantaneous UI transitions.
@@ -601,6 +610,8 @@ zero.preload(
)
```
+`preload()` returns three things. `cleanup` ends the preload, and Zero keeps the rows for the query's [TTL](#ttls). `complete` is a promise that resolves once the server returns the result, and `cached` resolves once the result is [`cached`](#cached-results) or `complete`.
+
## Missing Data
Because Zero returns local results immediately and server results asynchronously, displaying "not found" / 404 UI can be slightly tricky.
@@ -765,10 +776,81 @@ view.addListener((issues, resultType) => {
-The possible values of `result.type` are currently `complete` and `unknown`.
+The possible values of `result.type` are:
+
+- `unknown`: whatever data the client has right now, which may be incomplete.
+- `cached`: the client stores a result that the server confirmed during a previous session. See [Cached Results](#cached-results).
+- `complete`: the server has returned the result during this session.
+- `error`: the query failed. See [Handling Errors](#handling-errors).
The `complete` value is currently only returned when Zero has received the server result. In the future, Zero will be able to return this result type when it _knows_ that all possible data for this query is already available locally. Additionally, we plan to add a `prefix` result for when the data is known to be a prefix of the complete result. See [Consistency](#consistency) for more information.
+### Cached Results
+
+Along with the data it stores on the client (IndexedDB in the browser, SQLite on React Native), Zero remembers which query results the server has confirmed. When the app starts again and the client still has one of those results, the query starts out `cached` instead of `unknown`. It becomes `complete` once the server sends the result again. If Zero evicts the stored result first, the query drops back to `unknown`.
+
+A `cached` result was complete when the server sent it, but it may be stale by now. It's good for showing stored data, or an empty state, without waiting for the server, and that works offline too. If the data has to be current, wait for `complete`.
+
+For example, to show an empty inbox without waiting for the server:
+
+
+
+```tsx
+const [issues, issuesResult] = useQuery(
+ queries.issues.inbox()
+)
+
+const confirmed =
+ issuesResult.type === 'cached' ||
+ issuesResult.type === 'complete'
+
+if (issues.length === 0 && confirmed) {
+ return
}
+ >
+ {/* ... */}
+
+)
+```
+
+```ts
+const view = zero.materialize(queries.issues.inbox())
+
+view.addListener((issues, resultType) => {
+ if (
+ issues.length === 0 &&
+ (resultType === 'cached' || resultType === 'complete')
+ ) {
+ console.log('Inbox is empty')
+ }
+})
+```
+
+
+
+`zero.run()` and `zero.preload()` can also return as soon as a result is `cached`: pass `{type: 'cached'}` to `run()`, or wait on the `cached` promise that `preload()` returns.
+
## Handling Errors
If the queries endpoint throws an application or parse error, `zero-cache` will report it to the client using the `type` and `error` fields on the query details object:
diff --git a/contents/docs/react-native.mdx b/contents/docs/react-native.mdx
index cdf5097d..b7b5f26e 100644
--- a/contents/docs/react-native.mdx
+++ b/contents/docs/react-native.mdx
@@ -55,6 +55,8 @@ export default function RootLayout() {
For a complete example, see [zslack](./samples#zslack).
+To handle a full disk or a database that fails to open, see [Storage Failures](./connection#storage-failures).
+
{
+ window.scrollTo(0, 0)
+ }, [query])
+
+ return issues.map(issue => (
+
{issue.title}
+ ))
+}
+```
+
+This example passes ZQL to `useQuery` directly, so it runs [only on the client](/docs/queries#local-only-queries).
+
+Calls to [registered queries](/docs/queries#query-registries) work a little differently. Each call like `queries.posts.byStatus({status})` returns a new object. You can still pass it inline: `useQuery` sees that it's the same query with the same arguments and reuses its previous work. But don't use the returned object as a dependency. Use its arguments instead.
+
+Identity follows the order you build a query in. `zql.issue.where('status', 'open').where('priority', 'high')` and `zql.issue.where('priority', 'high').where('status', 'open')` return the same rows but can be different objects. A different value, such as another ID in `where()`, always gives a different object.
+
+Zero doesn't guarantee identity, though. It can hand back a new object for a query it has built before. When that happens, an effect or memo that depends on the query runs again, so make sure that's safe.
+
## Examples
See [the sample directory](/docs/samples) for more complete React examples.
diff --git a/contents/docs/release-notes/1.11.mdx b/contents/docs/release-notes/1.11.mdx
new file mode 100644
index 00000000..4b6d7988
--- /dev/null
+++ b/contents/docs/release-notes/1.11.mdx
@@ -0,0 +1,277 @@
+---
+title: Zero 1.11
+description: Cached Results and Sturdier Client Storage
+---
+
+## Installation
+
+```bash
+npm install @rocicorp/zero@1.11
+```
+
+
+ [Deploy `zero-cache` before updating
+ clients](/docs/self-host#rolling-updates). Zero 1.11
+ clients cannot connect to an older server. To roll back,
+ roll back clients first. In multi-node deployments,
+ upgrade the replication-manager before the view-syncers,
+ and roll back the view-syncers first.
+
+
+## Overview
+
+Zero 1.11 tells you when local query results were already confirmed by the server, keeps working when local storage fails, and makes Litestream v5 the default for backups. Client cold start is about 7x faster in our benchmarks. There are also a lot of fixes, especially in queries and replication.
+
+## Features
+
+- Queries now tell you when their local data is a result the server confirmed in an earlier session: they start with the new [`'cached'` result type](/docs/queries#cached-results) instead of `'unknown'`. That's enough to show stored data or an empty state without waiting for the server, even offline. ([#6578](https://github.com/rocicorp/mono/pull/6578)) (thanks [@pawarren](https://github.com/pawarren)!)
+- Building the same query again usually gives you the same `Query` object. `useQuery` no longer rebuilds and rehashes its query on every render, and queries built inline work as [React dependencies](/docs/react#query-identity). ([#6469](https://github.com/rocicorp/mono/pull/6469), [#6496](https://github.com/rocicorp/mono/pull/6496)) (thanks [@charpeni](https://github.com/charpeni)!)
+- Zero starts in a new [`'initializing'` connection state](/docs/connection#initializing) while it loads local data, and slow loading doesn't count against the connect timeout anymore. ([#6561](https://github.com/rocicorp/mono/pull/6561))
+- If local storage can't be opened, fills up, or hits I/O errors, Zero reports it once through the new [`onStorageFailure`](/docs/connection#storage-failures) option and keeps running in memory. `zero.kvStore` tells you which store is in use. ([#6585](https://github.com/rocicorp/mono/pull/6585), [#6657](https://github.com/rocicorp/mono/pull/6657), [#6686](https://github.com/rocicorp/mono/pull/6686)) (thanks [@pawarren](https://github.com/pawarren) and [@Karavil](https://github.com/Karavil)!)
+- Clients upgrading from 1.10 keep their local data and pending mutations. Future protocol changes won't reset clients to an empty local database either. ([d597c69e8](https://github.com/rocicorp/mono/commit/d597c69e8), [28e581483](https://github.com/rocicorp/mono/commit/28e581483))
+- Server mutators can [return a JSON value](/docs/mutators#returning-data) to the client, [run again](/docs/mutators#retrying-mutators) in a new transaction when `tx.retryOn()` accepts the error, and run at a chosen [transaction isolation level](/docs/server-zql#isolation-level). ([7756eb9bb](https://github.com/rocicorp/mono/commit/7756eb9bb), [4fe91c0d8](https://github.com/rocicorp/mono/commit/4fe91c0d8), [cb2821f3a](https://github.com/rocicorp/mono/commit/cb2821f3a)) (thanks [@pawarren](https://github.com/pawarren)!)
+- [Litestream v5 backups](/docs/zero-cache-config#litestream-backup-using-v5) are now the default. A replica restored from another replica's backup continues that backup incrementally. Periodic snapshots keep backups compact, and the replica's WAL stays bounded. ([fb536f686](https://github.com/rocicorp/mono/commit/fb536f686), [c1a586810](https://github.com/rocicorp/mono/commit/c1a586810), [#6476](https://github.com/rocicorp/mono/pull/6476), [#6539](https://github.com/rocicorp/mono/pull/6539), [#6717](https://github.com/rocicorp/mono/pull/6717))
+- A `zero-cache` that comes online now [waits for replication to catch up](/docs/zero-cache-config#replication-lag-initial-readiness-threshold-ms) before it reports ready. ([1c5ddcdb4](https://github.com/rocicorp/mono/commit/1c5ddcdb4))
+- Inactive client groups are kept for [7 days instead of 48 hours](/docs/zero-cache-config#cvr-garbage-collection-inactivity-threshold-hours), so users who come back within a week don't need a full resync. ([#6484](https://github.com/rocicorp/mono/pull/6484))
+- `zero-cache` now logs slow queries with the reason and suggested indexes, and periodically logs per-query statistics. A new [operator password](/docs/zero-cache-config#operator-password) gives access to CPU profiles (`/profz`) and replica planner statistics (`/plannerz`) without the admin password. ([#6446](https://github.com/rocicorp/mono/pull/6446), [#6639](https://github.com/rocicorp/mono/pull/6639), [#6673](https://github.com/rocicorp/mono/pull/6673), [#6675](https://github.com/rocicorp/mono/pull/6675), [#6699](https://github.com/rocicorp/mono/pull/6699))
+- A new [hydration budget](/docs/zero-cache-config#view-syncer-hydration-budget-ms) caps the time a view-syncer spends re-hydrating a reconnecting client's inactive queries, and a [per-query hydration timeout](/docs/zero-cache-config#view-syncer-query-hydration-timeout-ms) aborts any query that takes too long to hydrate. Both are off by default. ([#6477](https://github.com/rocicorp/mono/pull/6477), [#6499](https://github.com/rocicorp/mono/pull/6499))
+- New [OpenTelemetry metrics](/docs/otel) track WAL usage. Replication status events now include index-build progress, and a new view-syncer status event reports restore progress. ([#6591](https://github.com/rocicorp/mono/pull/6591), [#6621](https://github.com/rocicorp/mono/pull/6621), [#6714](https://github.com/rocicorp/mono/pull/6714))
+- Server mutations can send their logs to your own `LogSink`. ([#6677](https://github.com/rocicorp/mono/pull/6677)) (thanks [@pawarren](https://github.com/pawarren)!)
+- The `zero` Docker image is down to 807 MB from about 1.09 GB. ([#6695](https://github.com/rocicorp/mono/pull/6695))
+
+## Performance
+
+### Client Cold Start
+
+Apps usually register queries before Zero has finished loading local data, for example when `useQuery` runs at startup. Zero 1.10 then pushed every loaded row through every registered query. Zero 1.11 waits for the data to load and hydrates each query once ([#6472](https://github.com/rocicorp/mono/pull/6472)). With 13 queries and about 30,000 local rows, startup is about **7x faster**, and about **8x faster** on React Native.
+
+
+
+### Server-Side Incremental Updates
+
+When a transaction replicates, `zero-cache` has to bring every client group's queries up to date. Zero 1.11 does this with about **2.5x less CPU**. We measured 100 client groups on one sync worker, each syncing a query with a related subquery.
+
+
+
+### React Native
+
+Zero's query engine is about **1.7x faster** on React Native. That's the geometric mean of 53 query engine benchmarks on Hermes, and 48 of them got more than 5% faster. Hydrating queries is 1.6x to 4.8x faster, and applying changes 1.2x to 2.2x faster.
+
+
+
+### Building and Hashing Queries
+
+Components rebuild their queries on every render, and Zero hashes each query to identify it. Building the same query again now usually returns the same object, with its hash already computed ([#6469](https://github.com/rocicorp/mono/pull/6469)). That makes building and hashing a query about **7x to 17x faster**, depending on its size.
+
+
+
+### Client Writes With Many Joined Queries Got Slower
+
+This is the one benchmark that got slower. With 300 open queries, each joining the same four tables, applying one write in the client's query engine takes about **1.2x longer**. Joins now keep an index of their parent rows so they can skip changes no parent can see ([#6645](https://github.com/rocicorp/mono/pull/6645), [#6708](https://github.com/rocicorp/mono/pull/6708)). In this benchmark every change reaches some parent, so keeping the index up to date is pure overhead.
+
+
+
+## Fixes
+
+### Query Results
+
+- Queries using `or` with a flipped `exists` could return wrong rows. ([#6636](https://github.com/rocicorp/mono/pull/6636))
+- Joins could leave a deleted row on clients, keep a stale row in a `limit()` query, or fail with `node does not exist`. ([#6660](https://github.com/rocicorp/mono/pull/6660))
+- Rows behind an `exists` inside a [scalar subquery](/docs/zql#scalar-subqueries) weren't synced, and changes to them didn't propagate. ([913c0665f](https://github.com/rocicorp/mono/commit/913c0665f))
+- Catching up a client after a large update could fail with `Missing row`, and a schema change during rehydration could fail with `table has no column named`. ([#6662](https://github.com/rocicorp/mono/pull/6662), [#6616](https://github.com/rocicorp/mono/pull/6616))
+- A SQLite plan that nests one multi-index `OR` inside another could crash `zero-cache` in a loop. ([6ea17bfcd](https://github.com/rocicorp/mono/commit/6ea17bfcd)) (thanks [@robertoborgone](https://github.com/robertoborgone)!)
+- `zero-cache` called [`ZERO_QUERY_URL`](/docs/queries#custom-query-url) again for unchanged custom queries whenever it rebuilt its query pipelines. A permission change still re-transforms every query. ([#6601](https://github.com/rocicorp/mono/pull/6601))
+- In React and SolidJS, two custom queries with the same query but different names or arguments could share one view, and a SolidJS view rebuilt after its query changed kept the old TTL. ([#6461](https://github.com/rocicorp/mono/pull/6461), [#6497](https://github.com/rocicorp/mono/pull/6497))
+- In mutators, a row inserted without its optional columns didn't read those columns back as `null`. ([d4816752c](https://github.com/rocicorp/mono/commit/d4816752c))
+
+### Clients and React Native
+
+- SQLite-backed stores (expo-sqlite, op-sqlite) could return corrupted data under concurrent reads, hang after a failed transaction, or keep part of a failed commit. ([#6495](https://github.com/rocicorp/mono/pull/6495), [#6655](https://github.com/rocicorp/mono/pull/6655), [#6683](https://github.com/rocicorp/mono/pull/6683)) (thanks [@pawarren](https://github.com/pawarren)!)
+- Healthy clients logged `ChunkNotFoundError` during persist. A write that would corrupt the local store now fails instead. ([#6670](https://github.com/rocicorp/mono/pull/6670), [#6559](https://github.com/rocicorp/mono/pull/6559)) (thanks [@pawarren](https://github.com/pawarren)!)
+- With op-sqlite and a custom `location` or `encryptionKey`, dropping the database didn't delete the right file. ([#6656](https://github.com/rocicorp/mono/pull/6656)) (thanks [@pawarren](https://github.com/pawarren)!)
+- Clients could go offline after the app resumed from the background or the computer woke from sleep, and React Native connect timeouts left the client stuck in an error state instead of retrying. ([#6510](https://github.com/rocicorp/mono/pull/6510), [#6485](https://github.com/rocicorp/mono/pull/6485)) (thanks [@pawarren](https://github.com/pawarren)!)
+- Slow local startup could make a healthy server look unreachable. ([#6475](https://github.com/rocicorp/mono/pull/6475))
+- A mutator that threw while being replayed disconnected the client in a loop. The client now stays connected, and the mutation is still sent to the server. ([#6555](https://github.com/rocicorp/mono/pull/6555))
+- Client memory grew with every query used in a session. ([#6474](https://github.com/rocicorp/mono/pull/6474))
+- `useSuspenseQuery` with an options object and no `suspendUntil` didn't suspend until the first rows arrived, contrary to the docs (see Breaking Changes). ([ffe67b23a](https://github.com/rocicorp/mono/commit/ffe67b23a)) (thanks [@charpeni](https://github.com/charpeni)!)
+- Routine reconnects, auth errors and server rebalancing were logged as errors. They're now warnings or info, and fatal errors include their cause. ([#6581](https://github.com/rocicorp/mono/pull/6581), [#6692](https://github.com/rocicorp/mono/pull/6692), [#6498](https://github.com/rocicorp/mono/pull/6498), [#6682](https://github.com/rocicorp/mono/pull/6682), [#6691](https://github.com/rocicorp/mono/pull/6691), [42316d521](https://github.com/rocicorp/mono/commit/42316d521), [075b4215e](https://github.com/rocicorp/mono/commit/075b4215e), [79adc09fb](https://github.com/rocicorp/mono/commit/79adc09fb)) (thanks [@pawarren](https://github.com/pawarren)!)
+
+### zero-cache Reliability
+
+- `zero-cache` leaked memory and other resources around closed connections, stopped view-syncers, abandoned hydrations, and inspector and profiling requests. ([#6500](https://github.com/rocicorp/mono/pull/6500), [#6514](https://github.com/rocicorp/mono/pull/6514), [#6515](https://github.com/rocicorp/mono/pull/6515), [#6516](https://github.com/rocicorp/mono/pull/6516), [#6525](https://github.com/rocicorp/mono/pull/6525), [#6526](https://github.com/rocicorp/mono/pull/6526), [#6527](https://github.com/rocicorp/mono/pull/6527), [#6528](https://github.com/rocicorp/mono/pull/6528), [#6540](https://github.com/rocicorp/mono/pull/6540), [#6541](https://github.com/rocicorp/mono/pull/6541), [#6542](https://github.com/rocicorp/mono/pull/6542), [#6698](https://github.com/rocicorp/mono/pull/6698))
+- View-syncers could crash or return `Internal error` when another view-syncer advanced the same client group, when a client connection was missing, or when a push arrived on a closing connection. ([#6481](https://github.com/rocicorp/mono/pull/6481), [#6530](https://github.com/rocicorp/mono/pull/6530), [#6600](https://github.com/rocicorp/mono/pull/6600), [#6614](https://github.com/rocicorp/mono/pull/6614), [#6631](https://github.com/rocicorp/mono/pull/6631), [d545bc7e2](https://github.com/rocicorp/mono/commit/d545bc7e2)) (thanks [@jdnvn](https://github.com/jdnvn)!)
+- Servers using `zeroNodePg` crashed when Postgres closed a pooled connection. ([1b220fa07](https://github.com/rocicorp/mono/commit/1b220fa07)) (thanks [@pawarren](https://github.com/pawarren)!)
+- Analyzing a query in the [Inspector](/docs/debug/inspector) could run a view-syncer out of memory on large tables. ([#6596](https://github.com/rocicorp/mono/pull/6596))
+- Error messages could include row values, query literals and mutation arguments. ([#6413](https://github.com/rocicorp/mono/pull/6413))
+
+### Replication, Backfills, and Backups
+
+- Replication could roll back or stop after an interrupted stream, and a replication-manager whose slot was invalidated crash-looped instead of resyncing. ([#6562](https://github.com/rocicorp/mono/pull/6562), [#6590](https://github.com/rocicorp/mono/pull/6590))
+- Backfills could apply rows before replication had caught up to them, didn't resume when a view-syncer moved to another replication-manager, and leaked a Postgres connection that held back vacuum. ([#6685](https://github.com/rocicorp/mono/pull/6685), [#6684](https://github.com/rocicorp/mono/pull/6684), [#6513](https://github.com/rocicorp/mono/pull/6513))
+- Replication slots are more robust. Slot creation retries while long transactions block it, slots stay active during long initial syncs, slots dropped outside Zero are handled, and inactive slots are cleaned up. ([25a9ea1ac](https://github.com/rocicorp/mono/commit/25a9ea1ac), [f3bba7d73](https://github.com/rocicorp/mono/commit/f3bba7d73), [7a998b566](https://github.com/rocicorp/mono/commit/7a998b566), [caf79b161](https://github.com/rocicorp/mono/commit/caf79b161), [#6568](https://github.com/rocicorp/mono/pull/6568))
+- Replication lag was measured wrong on Aurora. ([e6a2590db](https://github.com/rocicorp/mono/commit/e6a2590db))
+- After initial sync, the replication-manager could serve before a backup covered its replica, so restoring view-syncers fell back to Postgres. It now waits for that backup. ([#6465](https://github.com/rocicorp/mono/pull/6465))
+- Query pipelines were reset more often than needed, and dropping an upstream index didn't refresh table schemas. ([4341cdcbd](https://github.com/rocicorp/mono/commit/4341cdcbd), [89787f827](https://github.com/rocicorp/mono/commit/89787f827), [bb71cc931](https://github.com/rocicorp/mono/commit/bb71cc931))
+- View-syncers now fail fast when the replication-manager is unreachable, and a slow handshake is no longer mistaken for a missed heartbeat. ([0af1a7109](https://github.com/rocicorp/mono/commit/0af1a7109), [#6598](https://github.com/rocicorp/mono/pull/6598))
+- During a resync, the old replication-manager now keeps serving until the new one takes over. ([21f349135](https://github.com/rocicorp/mono/commit/21f349135))
+- Change-database connections leaked after a failed catch-up, and a change-database reset could hang. ([#6529](https://github.com/rocicorp/mono/pull/6529), [#6632](https://github.com/rocicorp/mono/pull/6632))
+- Litestream could run out of memory in small containers. Its upload concurrency now scales with available memory. ([240c05730](https://github.com/rocicorp/mono/commit/240c05730))
+- Configuration values that start with a dash, such as some shard IDs, were parsed wrong. ([81e6ac570](https://github.com/rocicorp/mono/commit/81e6ac570))
+- In the `zero` Docker image, initial sync and backfills could leave a stalled Postgres connection open after copying a table. Other installs keep the 1.10 behavior. ([3f139f52a](https://github.com/rocicorp/mono/commit/3f139f52a), [22fa3c27e](https://github.com/rocicorp/mono/commit/22fa3c27e))
+
+### Type Checking
+
+- A rejected `whereExists(..., {scalar: true})` now explains why, and a misspelled relationship name lists the valid ones. ([cc020b368](https://github.com/rocicorp/mono/commit/cc020b368), [12a36dcfe](https://github.com/rocicorp/mono/commit/12a36dcfe))
+
+## Breaking Changes
+
+- **Legacy queries:** `zero-cache` now rejects queries sent directly by clients. If you still use legacy queries, set [`ZERO_ALLOW_LEGACY_QUERIES=true`](/docs/zero-cache-config#allow-legacy-queries) or move to [custom queries](/docs/queries). ([#6399](https://github.com/rocicorp/mono/pull/6399))
+- **New states:** if your code switches over every result type or connection state, handle `'cached'` and `'initializing'`. Treat `'cached'` like `'unknown'`: the data may be out of date. ([#6578](https://github.com/rocicorp/mono/pull/6578), [#6561](https://github.com/rocicorp/mono/pull/6561))
+- **Litestream v5 by default:** remove the `ZERO_LITESTREAM_VFS_*` options; they no longer exist. Each replication-manager backs up to its own folder under the backup URL, so end the URL with `/` and add a lifecycle policy to your bucket. To keep writing legacy WAL backups with Litestream v0.3, set `ZERO_LITESTREAM_BACKUP_USING_V5=false`. If you set `ZERO_LITESTREAM_EXECUTABLE_V5`, use a Litestream build with `-fork-to-url`. ([fb536f686](https://github.com/rocicorp/mono/commit/fb536f686), [c1a586810](https://github.com/rocicorp/mono/commit/c1a586810))
+- **Changed defaults:** inactive client groups are kept for 168 hours (was 48), replication lag is reported every 10 seconds (was 30), and `ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_TIMEOUT_PROPORTION` is 4 (was 2). Startup can take longer while `zero-cache` waits for a backup and for replication to catch up, so give readiness probes more time. ([#6484](https://github.com/rocicorp/mono/pull/6484), [1c5ddcdb4](https://github.com/rocicorp/mono/commit/1c5ddcdb4), [#6521](https://github.com/rocicorp/mono/pull/6521), [#6465](https://github.com/rocicorp/mono/pull/6465))
+- **Server mutators:** return values must be JSON. A custom `Database` must pass `addRetryPredicate` on to the transaction it creates, or `tx.retryOn()` won't work. Using `tx` after its transaction has ended now throws. ([7756eb9bb](https://github.com/rocicorp/mono/commit/7756eb9bb), [4fe91c0d8](https://github.com/rocicorp/mono/commit/4fe91c0d8))
+- **`ZQLDatabase.run()`** no longer wraps reads in a transaction, so per-transaction settings such as `SET LOCAL` don't apply to it. ([#6577](https://github.com/rocicorp/mono/pull/6577))
+- **`useSuspenseQuery`** with an options object and no `suspendUntil` now suspends until the first rows arrive, as documented. Pass `suspendUntil: 'complete'` for the old behavior. ([ffe67b23a](https://github.com/rocicorp/mono/commit/ffe67b23a))
+- **Upgrading from 1.9 or earlier:** clients start with a new local database, and mutations they hadn't sent yet are lost. Clients upgrading from 1.10 keep theirs. ([d597c69e8](https://github.com/rocicorp/mono/commit/d597c69e8), [28e581483](https://github.com/rocicorp/mono/commit/28e581483))
+- **Client storage:** a client whose local store is corrupt now deletes it and calls `onClientStateNotFound`. Unsent mutations are lost. ([#6558](https://github.com/rocicorp/mono/pull/6558))
+- **One-time query reload:** queries are identified differently in 1.11, so after upgrading, and again after a rollback, `zero-cache` transforms and hydrates each client's queries once more. Expect a burst of requests to `ZERO_QUERY_URL`. ([#6450](https://github.com/rocicorp/mono/pull/6450), [#6462](https://github.com/rocicorp/mono/pull/6462))
+- **Deprecated:** `ZERO_CHANGE_STREAMER_MODE`. Use [`ZERO_CHANGE_STREAMER_URI`](/docs/zero-cache-config#change-streamer-uri) instead. ([698286b55](https://github.com/rocicorp/mono/commit/698286b55))
+- **Events:** there's a new `zero/events/status/view-syncer/v1` event type. Event consumers that reject unknown types must accept it. ([#6621](https://github.com/rocicorp/mono/pull/6621))
diff --git a/contents/docs/release-notes/index.mdx b/contents/docs/release-notes/index.mdx
index cabe1ee9..b7ac9238 100644
--- a/contents/docs/release-notes/index.mdx
+++ b/contents/docs/release-notes/index.mdx
@@ -2,6 +2,7 @@
title: Release Notes
---
+- [Zero 1.11: Cached Results and Sturdier Client Storage](/docs/release-notes/1.11)
- [Zero 1.10: Unique Keys and More Reliable Replication](/docs/release-notes/1.10)
- [Zero 1.9: Stability and Query Correctness](/docs/release-notes/1.9)
- [Zero 1.8: Observability and Reliability](/docs/release-notes/1.8)
diff --git a/contents/docs/server-zql.mdx b/contents/docs/server-zql.mdx
index b3be33f8..4931459d 100644
--- a/contents/docs/server-zql.mdx
+++ b/contents/docs/server-zql.mdx
@@ -287,6 +287,84 @@ export const mutators = defineMutators({
+### Isolation Level
+
+By default, each transaction starts with a plain `BEGIN`, so Postgres uses its default isolation level. On a stock Postgres, that is `read committed`.
+
+To use a different level, pass `isolationLevel` in the options argument of the adapter factory. It can be `'read committed'`, `'repeatable read'`, or `'serializable'`:
+
+
+
+```ts
+export const dbProvider = zeroDrizzle(
+ schema,
+ drizzleClient,
+ {
+ isolationLevel: 'serializable'
+ }
+)
+```
+
+```ts
+export const dbProvider = zeroKysely(schema, kysely, {
+ isolationLevel: 'serializable'
+})
+```
+
+```ts
+// Use the Postgres spelling, not Prisma's 'Serializable'.
+export const dbProvider = zeroPrisma(schema, prisma, {
+ isolationLevel: 'serializable'
+})
+```
+
+```ts
+// The third argument is an optional logger.
+export const dbProvider = zeroNodePg(
+ schema,
+ pool,
+ undefined,
+ {
+ isolationLevel: 'serializable'
+ }
+)
+```
+
+```ts
+export const dbProvider = zeroPostgresJS(schema, sql, {
+ isolationLevel: 'serializable'
+})
+```
+
+
+
+Every transaction the adapter opens uses this level, including the ones your mutators run in. The `TransactionOptions` and `IsolationLevel` types are exported from `@rocicorp/zero/server`.
+
+At `repeatable read` and `serializable`, Postgres can abort a transaction that conflicts with a concurrent one. Use [`tx.retryOn()`](/docs/mutators#retrying-mutators) to run the mutator again when that happens.
+
### Custom Database
To implement support for some other Postgres bindings library, you will implement the `DBConnection` interface.
diff --git a/contents/docs/zero-cache-config.mdx b/contents/docs/zero-cache-config.mdx
index 98d676d6..1c49ecb2 100644
--- a/contents/docs/zero-cache-config.mdx
+++ b/contents/docs/zero-cache-config.mdx
@@ -22,12 +22,24 @@ A password used to administer zero-cache server, for example to access the `/sta
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.
+The admin password also grants access to everything that the [operator password](#operator-password) does.
+
flag: `--admin-password`
env: `ZERO_ADMIN_PASSWORD`
required: in production (when `NODE_ENV=production`)
## Optional Flags
+### Allow Legacy Queries
+
+Allows clients to send query ASTs directly to `zero-cache`. [Ad-hoc queries](/docs/deprecated/ad-hoc-queries) (also called legacy queries) work this way.
+
+When this is `false`, `zero-cache` rejects client-sent ASTs without parsing them and closes the connection with an error. Keep it disabled when you use [queries](/docs/queries).
+
+flag: `--allow-legacy-queries`
+env: `ZERO_ALLOW_LEGACY_QUERIES`
+default: `false`
+
### App ID
Unique identifier for the app.
@@ -82,7 +94,7 @@ Use this to bound how long already-open connections can continue after logout, s
flag: `--auth-revalidate-interval-seconds`
env: `ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS`
-default: `unset`
+default: `300`
### Auth Retransform Interval Seconds
@@ -96,7 +108,7 @@ If clients already call `updateAuth` whenever auth changes, this mainly serves a
flag: `--auth-retransform-interval-seconds`
env: `ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS`
-default: `unset`
+default: `300`
### Auto Reset
@@ -131,13 +143,13 @@ default: `0.04`
During periodic flow-control checks, this is the amount of additional time to wait after the majority of serving replicas have acknowledged a checkpoint, expressed as a proportion of the time the majority took. Replication continues after that timeout even if some serving replicas have not acknowledged the checkpoint. A backup replica that is still catching up does not delay caught-up serving replicas; once caught up, the backup must acknowledge the checkpoint before replication continues.
-For example, if the majority responds in 2.5 ms, a proportion of `2` waits up to another 5 ms. The default lets a subscriber take roughly three times as long as the majority before early release. This bounds the effect of a lagging or unresponsive subscriber while allowing for normal processing-time variance.
+For example, if the majority responds in 2.5 ms, a proportion of `2` waits up to another 5 ms. The default lets a subscriber take roughly five times as long as the majority before early release. This bounds the effect of a lagging or unresponsive subscriber while allowing for normal processing-time variance.
This value is a ratio, not a duration in seconds. Set it to a negative number to disable early releases and wait for all subscribers.
flag: `--change-streamer-flow-control-consensus-timeout-proportion`
env: `ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_TIMEOUT_PROPORTION`
-default: `2`
+default: `4`
### Change Streamer Flow Control Slow Subscriber Grace Period Seconds
@@ -151,26 +163,6 @@ flag: `--change-streamer-flow-control-slow-subscriber-grace-period-seconds`
env: `ZERO_CHANGE_STREAMER_FLOW_CONTROL_SLOW_SUBSCRIBER_GRACE_PERIOD_SECONDS`
default: `30`
-### 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`.
@@ -205,9 +197,11 @@ env: `ZERO_CVR_DB`
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.
+Purging a CVR forces the next connection from that client group to sync from scratch. Set this well above how long a typical user goes between sessions.
+
flag: `--cvr-garbage-collection-inactivity-threshold-hours`
env: `ZERO_CVR_GARBAGE_COLLECTION_INACTIVITY_THRESHOLD_HOURS`
-default: `48`
+default: `168` (one week)
### CVR Garbage Collection Initial Batch Size
@@ -296,7 +290,7 @@ The location of the litestream backup, usually an s3:// URL. This is only consul
In multi-node deployments, this is required on the replication-manager so view-syncers can reserve snapshots; in single-node deployments it is optional.
-When enabling Litestream v5 backups, configure this as a folder URL ending in `/`, for example `s3://acme-zero-backups/v1/`. Zero resolves each replica-specific backup path relative to this URL; without the trailing slash, URL resolution replaces the final path segment instead of nesting the replica backup under it.
+With Litestream v5 backups (the default), configure this as a folder URL ending in `/`, for example `s3://acme-zero-backups/v1/`. Zero resolves each replica-specific backup path relative to this URL; without the trailing slash, URL resolution replaces the final path segment instead of nesting the replica backup under it.
flag: `--litestream-backup-url`
env: `ZERO_LITESTREAM_BACKUP_URL`
@@ -312,7 +306,7 @@ env: `ZERO_LITESTREAM_ENDPOINT`
### 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 v3. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. This setting is ignored when backing up with Litestream v5.
+The size of the WAL file at which Litestream performs background (PASSIVE) SQLite checkpoints to apply the writes in the WAL to the main database file. Each checkpoint creates new files (WAL segments with Litestream v3, LTX files with v5) that are then backed up. PASSIVE checkpoints are skipped while a write is in progress, so high write rates can make the WAL grow.
flag: `--litestream-checkpoint-threshold-mb`
env: `ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB`
@@ -348,14 +342,14 @@ env: `ZERO_LITESTREAM_EXECUTABLE`
### Litestream V5 Executable
-Path to the official Litestream v0.5.x executable used for v5 restores and backups. Litestream v0.5.8 and later can restore both legacy WAL backups and LTX backups, choosing the format with the latest data. When a backup URL is configured and `ZERO_LITESTREAM_RESTORE_USING_V5` is enabled, this executable is required. The official Zero Docker image includes Litestream 0.5.16.
+Path to the official Litestream v0.5.x executable used for v5 restores and backups. Litestream v0.5.8 and later can restore both legacy WAL backups and LTX backups, choosing the format with the latest data. When a backup URL is configured and `ZERO_LITESTREAM_RESTORE_USING_V5` or `ZERO_LITESTREAM_BACKUP_USING_V5` is enabled (both are by default), this executable is required. The official Zero Docker image includes Rocicorp's Litestream build `0.5.18-zero.16`. Restores fork the source backup with `litestream restore -fork-to-url`, so a custom executable must support that flag.
flag: `--litestream-executable-v5`
env: `ZERO_LITESTREAM_EXECUTABLE_V5`
### Litestream Restore Using V5
-Use `ZERO_LITESTREAM_EXECUTABLE_V5` for restores. Set this to `false` to force legacy restore behavior. When a backup URL is configured, disabling this option is also the way to run without a v5 executable.
+Use `ZERO_LITESTREAM_EXECUTABLE_V5` for restores. Set this to `false` to force legacy restore behavior. Because v5 backups require v5 restore, running without a v5 executable when a backup URL is configured requires setting both this option and `ZERO_LITESTREAM_BACKUP_USING_V5` to `false`.
Litestream v0.5 cannot restore legacy backups encrypted with Age. Keep legacy restore enabled for those backups or migrate them before enabling v5 restore.
@@ -365,43 +359,15 @@ default: `true`
### Litestream Backup Using V5
-Write LTX backups with Litestream v0.5.x. This is disabled by default to continue writing legacy WAL backups. Enabling it requires v5 restore and `ZERO_LITESTREAM_VFS_QUERY_EXECUTABLE`.
+Write LTX backups with Litestream v0.5.x. Set this to `false` to write legacy WAL backups with Litestream v0.3.x instead. This requires `ZERO_LITESTREAM_RESTORE_USING_V5`, which is enabled by default.
-With v5 backup enabled, Zero acknowledges upstream changes only after they are applied to the replica and confirmed in the backup. Each replica uses a distinct backup subpath under the folder URL configured by `ZERO_LITESTREAM_BACKUP_URL`. Configure an object-store lifecycle policy to remove old subpaths; Zero does not delete them automatically. Rolling back to a server that does not understand replica-specific paths can require a full resync.
+Zero requests an incremental backup every `ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_SECONDS`, and acknowledges upstream changes only after a backup containing them has completed.
+
+Each replication-manager backs up to its own subfolder of the folder URL configured by `ZERO_LITESTREAM_BACKUP_URL`. Zero does not delete subfolders left by previous replication-managers. The simplest way to remove them is an object-store lifecycle expiration rule. Every file a restore needs is at most about `ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS` old, so an expiration of at least twice that interval is safe. Rolling back to a server that does not understand replica-specific paths can require a full resync.
flag: `--litestream-backup-using-v5`
env: `ZERO_LITESTREAM_BACKUP_USING_V5`
-default: `false`
-
-### Litestream VFS Query Executable
-
-Path to the `vfs-query` executable used to monitor Litestream v5 backup progress. This is required when `ZERO_LITESTREAM_BACKUP_USING_V5` is enabled. The official Zero Docker image includes this executable.
-
-flag: `--litestream-vfs-query-executable`
-env: `ZERO_LITESTREAM_VFS_QUERY_EXECUTABLE`
-
-### Litestream VFS Poll Interval (ms)
-
-How often the Litestream VFS polls backup storage for the latest backup. This controls how quickly Zero can confirm a backup and acknowledge the corresponding upstream changes.
-
-flag: `--litestream-vfs-poll-interval-ms`
-env: `ZERO_LITESTREAM_VFS_POLL_INTERVAL_MS`
-default: `15000`
-
-### Litestream VFS Poll Timeout (ms)
-
-Reserved for configuring request timeouts to the Litestream VFS poller. In this release, `vfs-query` uses its internal 30-second timeout regardless of this value.
-
-flag: `--litestream-vfs-poll-timeout-ms`
-env: `ZERO_LITESTREAM_VFS_POLL_TIMEOUT_MS`
-default: `10000`
-
-### Litestream VFS Log File
-
-Reserved for redirecting Litestream VFS logs. In this release, `vfs-query` writes logs to the child process's standard error regardless of this value.
-
-flag: `--litestream-vfs-log-file`
-env: `ZERO_LITESTREAM_VFS_LOG_FILE`
+default: `true`
### Litestream Incremental Backup Interval Minutes
@@ -427,21 +393,14 @@ 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). This setting is ignored when backing up with Litestream v5.
-
-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.
+Leave this unset to use `48`, lowered as needed so that the parts fit in a quarter of the memory available to the container.
+
flag: `--litestream-multipart-concurrency`
env: `ZERO_LITESTREAM_MULTIPART_CONCURRENCY`
-default: `48`
### Litestream Multipart Size
@@ -607,6 +566,23 @@ The number of processes to use for view syncing. Leave this unset to use `max(1,
flag: `--num-sync-workers`
env: `ZERO_NUM_SYNC_WORKERS`
+### Operator Password
+
+A password for the diagnostic endpoints that can't expose application data, so the people who run `zero-cache` can debug it without being able to read the data it serves. It grants access to:
+
+- `/profz` and `/profrmz`: CPU profiles. Profiles served for the operator password have the source of every regular expression redacted, since those can contain `LIKE` patterns from client queries.
+- `/statz`: counts and sizes.
+- `/plannerz`: the replica's schema and planner statistics.
+
+The other protected endpoints, the [inspector](/docs/debug/inspector) and `/heapz`, still require the [admin password](#admin-password).
+
+For `/profrmz` in a multi-node deployment, the replication-manager must be configured with the same operator password.
+
+If the operator password is the same as the admin password, it grants admin access.
+
+flag: `--operator-password`
+env: `ZERO_OPERATOR_PASSWORD`
+
### Per User Mutation Limit Max
The maximum mutations per user within the specified windowMs.
@@ -728,17 +704,39 @@ Performs a VACUUM at server startup if the specified number of hours has elapsed
flag: `--replica-vacuum-interval-hours`
env: `ZERO_REPLICA_VACUUM_INTERVAL_HOURS`
+### Replication Lag Initial Readiness Threshold (ms)
+
+The maximum replication lag that a server can have before it reports that it is ready to serve clients. This keeps a server that is starting up from serving stale data.
+
+This only applies at startup. Once a server is ready, it keeps serving even if replication lag later goes above this threshold.
+
+When [lag reporting](#replication-lag-report-interval-ms) is disabled, readiness does not wait for replication lag.
+
+flag: `--replication-lag-initial-readiness-threshold-ms`
+env: `ZERO_REPLICATION_LAG_INITIAL_READINESS_THRESHOLD_MS`
+default: `1000`
+
+### Replication Lag Max Readiness Delay (ms)
+
+The maximum time that a server waits for replication lag to fall below [`ZERO_REPLICATION_LAG_INITIAL_READINESS_THRESHOLD_MS`](#replication-lag-initial-readiness-threshold-ms) before it reports ready anyway. This keeps a server from waiting forever, for example when the upstream database is under heavy load or there is a network partition.
+
+flag: `--replication-lag-max-readiness-delay-ms`
+env: `ZERO_REPLICATION_LAG_MAX_READINESS_DELAY_MS`
+default: `600000` (10 minutes)
+
### Replication Lag Report Interval (ms)
-The minimum interval at which replication lag reports are written upstream and reported via the `zero.replication.total_lag` [OpenTelemetry metric](/docs/otel). If an expected report is not received before the next interval, Zero emits a new report and increments `zero.replication.lag_report_retries`.
+The approximate interval at which replication lag reports are written upstream and reported via the `zero.replication.total_lag` [OpenTelemetry metric](/docs/otel). If an expected report is not received before the next interval, Zero emits a new report and increments `zero.replication.lag_report_retries`.
+
+Lag reports are also used at startup: a new server waits for replication lag to fall below [`ZERO_REPLICATION_LAG_INITIAL_READINESS_THRESHOLD_MS`](#replication-lag-initial-readiness-threshold-ms) before it reports ready. This interval sets how long the server waits for the next report.
-This feature requires write access to upstream Postgres (uses `pg_logical_emit_message()`). For PostgreSQL 17+, lag measurements accurately reflect committed write latency (single-digit milliseconds). For PostgreSQL 16 and earlier, measurements may appear 50-100ms longer due to flush behavior. A negative or 0 value disables lag reporting.
+This feature requires write access to upstream Postgres (uses `pg_logical_emit_message()`). For PostgreSQL 17+, lag measurements accurately reflect committed write latency (single-digit milliseconds). For PostgreSQL 16 and earlier, measurements may appear 50-100ms longer due to flush behavior. A negative or 0 value disables lag reporting and the readiness wait.
Even if otel is not enabled, info and warn-level logs are emitted for large lag values.
flag: `--replication-lag-report-interval-ms`
env: `ZERO_REPLICATION_LAG_REPORT_INTERVAL_MS`
-default: `30_000`
+default: `10000`
### Server Version
@@ -824,6 +822,30 @@ See [WAL Sender Timeout](/docs/connecting-to-postgres#wal-sender-timeout).
flag: `--upstream-pg-stream-inbound-timeout-ms`
env: `ZERO_UPSTREAM_PG_STREAM_INBOUND_TIMEOUT_MS`
+### View Syncer Hydration Budget (ms)
+
+A soft time limit for hydrating [inactive (cached) queries](/docs/queries#query-caching) when a view-syncer hydrates a client group's queries, for example after a client reconnects.
+
+Active queries always finish. Inactive queries are hydrated after them, starting with the ones whose TTL expires last. Once the budget is spent, the inactive queries that were not reached are removed, along with the remaining TTL that would have kept them cached. The budget is only checked between queries, so a query that has started is not cut short by it. Time spent waiting on your [query endpoint](#query-url) does not count toward the budget.
+
+A value of `0` disables the budget.
+
+flag: `--view-syncer-hydration-budget-ms`
+env: `ZERO_VIEW_SYNCER_HYDRATION_BUDGET_MS`
+default: `0`
+
+### View Syncer Query Hydration Timeout (ms)
+
+The maximum processing time that a view-syncer spends hydrating a single query. Time spent yielding to other work is not counted.
+
+If hydrating a query takes longer than this, the hydration is aborted and the query is removed from the client's view. Clients that use the query receive an error for it (see [Handling Errors](/docs/queries#handling-errors)). For the next five minutes, the query is rejected without being run again. After that, it can be retried.
+
+A value of `0` disables the limit.
+
+flag: `--view-syncer-query-hydration-timeout-ms`
+env: `ZERO_VIEW_SYNCER_QUERY_HYDRATION_TIMEOUT_MS`
+default: `0`
+
### 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
@@ -864,6 +886,37 @@ default: `10`
flag: `--change-max-conns`
env: `ZERO_CHANGE_MAX_CONNS`
+### Change Streamer Mode
+
+`ZERO_CHANGE_STREAMER_MODE` is deprecated. Set [`ZERO_CHANGE_STREAMER_URI`](#change-streamer-uri) on view-syncers instead.
+
+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).
+
+This option is ignored if `ZERO_CHANGE_STREAMER_URI` is set.
+
+flag: `--change-streamer-mode`
+env: `ZERO_CHANGE_STREAMER_MODE`
+default: `dedicated`
+
+### Litestream Minimum Checkpoint Page Count
+
+`ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT` is deprecated. Use [`ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB`](#litestream-checkpoint-threshold-mb) instead.
+
+The WAL page count at which SQLite attempts a PASSIVE checkpoint. If unset, it is derived from the checkpoint threshold as `checkpointThresholdMB * 256` (4 KiB pages).
+
+flag: `--litestream-min-checkpoint-page-count`
+env: `ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT`
+default: `checkpointThresholdMB * 256`
+
### Auth JWK
A public key in JWK format used to verify JWTs. Only one of jwk, jwksUrl and secret may be set.
diff --git a/lib/routes-config.ts b/lib/routes-config.ts
index 49dc1311..781acfa0 100644
--- a/lib/routes-config.ts
+++ b/lib/routes-config.ts
@@ -48,7 +48,7 @@ export const ROUTES = [
{title: 'REST APIs', href: '/rest'},
{
title: 'API Reference',
- href: 'https://npmx.dev/package-docs/@rocicorp/zero/v/1.10.0#class-Zero',
+ href: 'https://npmx.dev/package-docs/@rocicorp/zero/v/1.11.0#class-Zero',
},
],
},
diff --git a/public/images/connection-state/lifecycle.png b/public/images/connection-state/lifecycle.png
deleted file mode 100644
index 1d91ed28..00000000
Binary files a/public/images/connection-state/lifecycle.png and /dev/null differ
diff --git a/public/images/connection-state/lifecycle.svg b/public/images/connection-state/lifecycle.svg
new file mode 100644
index 00000000..8f91cbfe
--- /dev/null
+++ b/public/images/connection-state/lifecycle.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file