From ccfe8dc3df3b58b4492e027e56c0f1d182aa4e96 Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Fri, 7 Aug 2026 12:38:50 -0700 Subject: [PATCH 01/17] docs: add Zero 1.9 release notes --- .releases/1.9/commits.md | 203 ++++ assets/search-index.json | 1400 ++++++++++++---------- contents/docs/connecting-to-postgres.mdx | 12 + contents/docs/otel.mdx | 144 +-- contents/docs/release-notes/1.9.mdx | 44 + contents/docs/release-notes/index.mdx | 1 + contents/docs/zero-cache-config.mdx | 4 +- 7 files changed, 1091 insertions(+), 717 deletions(-) create mode 100644 .releases/1.9/commits.md create mode 100644 contents/docs/release-notes/1.9.mdx diff --git a/.releases/1.9/commits.md b/.releases/1.9/commits.md new file mode 100644 index 00000000..20869dcd --- /dev/null +++ b/.releases/1.9/commits.md @@ -0,0 +1,203 @@ +# Zero 1.9 Release Audit + +Status: public draft and product-doc updates complete; #6206 benchmarks deferred; smoke-test handoff blocked on a 1.9 canary. + +## Release Provenance + +- Release: `1.9.0` +- Monorepo: `/Users/chase/git/roci/mono` +- Monorepo remote: `git@github.com:rocicorp/mono.git` +- Docs repository: `/Users/chase/.worktree/zero-docs/1.9` +- Previous ref: `zero/v1.8.0` +- Previous SHA: `cdc02598f137ab4e071878f5674fdc716dbbc69d` +- Target ref: `maint/zero/v1.9`, frozen for this audit +- Target SHA: `ef892a123a11461e74a59a4b59ad310ba23180b3` +- Merge base: `2279e783edd94aaa20fdcc8e067860ad0c21d95b` +- Raw non-merge range: 39 commits +- Patch-equivalent commits already shipped in 1.8: 15 +- Unique 1.9 commits: 24 + +Commands used: + +```bash +git remote -v +git rev-parse zero/v1.8.0 maint/zero/v1.9 +git merge-base zero/v1.8.0 maint/zero/v1.9 +git rev-list --count --no-merges zero/v1.8.0..maint/zero/v1.9 +git log --reverse --oneline --no-merges zero/v1.8.0..maint/zero/v1.9 +git log --right-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1.8.0...maint/zero/v1.9 +git log --left-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1.8.0...maint/zero/v1.9 +git log --format='%H%x09%s%n%b' 2279e783edd94aaa20fdcc8e067860ad0c21d95b..zero/v1.8.0 +git show zero/v1.8.0:packages/zero-protocol/src/protocol-version.ts +git show ef892a123a11461e74a59a4b59ad310ba23180b3:packages/zero-protocol/src/protocol-version.ts +``` + +## Protocol Compatibility + +PASS. Both refs use sync protocol `51` and minimum server-supported sync protocol `30`. + +| Check | Zero 1.8 | Zero 1.9 target | Result | +| ------------------------------------ | -------: | --------------: | ------------------------------------ | +| Sync `PROTOCOL_VERSION` | 51 | 51 | Compatible | +| `MIN_SERVER_SUPPORTED_SYNC_PROTOCOL` | 30 | 30 | Compatible | +| Change-stream protocol | 6 | 6 | Compatible | +| DDL event protocol | 1 | 1 | Compatible; `newColumns` is optional | + +The required gate passes because the target minimum supported protocol, `30`, is less than or equal to the previous release protocol, `51`. No protocol migration or server-before-client deployment sequence is introduced by this range. + +## Backport Detection + +The 1.8 tag and 1.9 branch diverge at the merge base. The following target commits are patch-equivalent to maintenance commits already present in `zero/v1.8.0` and are therefore classified as already shipped: + +| Target commit | 1.8 commit | Subject | +| ------------- | ----------- | ------------------------------------------------ | +| `c263df101` | `31e48aa71` | z2s and NULL start handling (#6189) | +| `c6f4c9712` | `6c8b5e5a7` | Initial-sync metrics (#6191) | +| `a99e63093` | `ca6c2f3e9` | postgres.js disconnected-socket crash (#6193) | +| `d379d93cb` | `492b5c331` | Litestream backup and restore metrics (#6199) | +| `754be6b1d` | `3609d48af` | Node version for `availableParallelism` (#6198) | +| `bb8703753` | `4ef9e4ef0` | Catch-up subscriber flow control (#6186) | +| `3adc4383f` | `c2b5a7565` | API-server request metrics (#6203) | +| `e03cc5301` | `e91debd95` | CVR, WebSocket, and flow-control metrics (#6207) | +| `6cc6629ca` | `d711edbb2` | Metric naming alignment (#6209) | +| `596a9376b` | `dcbc5b5ea` | Remove SQLite quick-check (#6212) | +| `478711b76` | `d4258993d` | Aggregable lag metrics (#6214) | +| `89d57b460` | `f71c897c4` | Replication-slot metrics (#6210) | +| `89c48bbc2` | `832580d65` | Worker startup metric (#6208) | +| `ca40512bf` | `312a0f78f` | Release README changes (#6218) | +| `15fd7cdea` | `cdc02598f` | Export `MutatorResult` (#6223) | + +The previous-release side contains no additional `cherry-pick -x` trailers naming target commits beyond the patch-equivalent set above. + +## Commit Decisions + +| Commit | Category | Breaking? | Public impact | Decision and evidence | +| --------------------------------------------------------- | ----------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [`a59cf1a66`](https://github.com/rocicorp/mono/pull/6183) | skip | - | None; adds a SQLite change-log throughput benchmark. | Omit. Benchmark-only file `sqlite-change-log-ceiling.bench.ts`; no production path changes. | +| [`cb014857f`](https://github.com/rocicorp/mono/pull/6185) | fix | - | Preserves an own `__proto__` property in schema construction, CRUD maps, and materialized-view updates instead of invoking the legacy prototype setter. | Public candidate. Tests cover shared object helpers, schema builders, mutators, and IVM view updates. Scope wording narrowly because name mapping and several other dynamic-key paths lack end-to-end coverage. Existing affected schemas may get a corrected client-schema hash and normal resync. | +| [`5361250c2`](https://github.com/rocicorp/mono/pull/6182) | skip | - | Default-on shadow analysis detects query coverage and emits summary logs; it does not reuse results or change query execution. Adds `ZERO_ENABLE_QUERY_COVERING`. | Intentionally omit as internal rollout telemetry. Human review accepted the default's possible CPU/log overhead without promoting the control as a supported public option. Unit coverage is in `query-covering.test.ts` and config tests; no production overhead benchmark exists. | +| [`53194a166`](https://github.com/rocicorp/mono/pull/6190) | skip | - | Changes package version from 1.8.0 to 1.9.0. | Omit as release metadata. | +| [`c263df101`](https://github.com/rocicorp/mono/pull/6189) | skip | - | Fixed z2s `start` compilation and leading NULL cursor handling. | Already shipped in 1.8 as patch-equivalent `31e48aa71` and covered by the 1.8 release note. | +| [`c6f4c9712`](https://github.com/rocicorp/mono/pull/6191) | skip | - | Added initial-sync duration, phase, row, byte, chunk, and outcome metrics. | Already shipped in 1.8 as patch-equivalent `6c8b5e5a7` and covered by the 1.8 operational-metrics feature. | +| [`a99e63093`](https://github.com/rocicorp/mono/pull/6193) | skip | - | Prevented postgres.js writes through a socket after disconnection. | Already shipped in 1.8 as patch-equivalent `ca6c2f3e9` and listed in the 1.8 fixes. | +| [`34b204638`](https://github.com/rocicorp/mono/pull/6179) | skip | - | Adds a private Postgres-to-client throughput and latency harness. | Omit as benchmark infrastructure. Production-file changes only normalize imports. The harness may be temp-backported for #6206 measurements. | +| [`58861830f`](https://github.com/rocicorp/mono/pull/6192) | skip | - | None for developers or operators; parallelizes JavaScript CI jobs. | Omit as CI-only. | +| [`d379d93cb`](https://github.com/rocicorp/mono/pull/6199) | skip | - | Added Litestream backup, restore, validation, and subprocess metrics. | Already shipped in 1.8 as patch-equivalent `492b5c331` and covered by the 1.8 operational-metrics feature. | +| [`754be6b1d`](https://github.com/rocicorp/mono/pull/6198) | skip | - | Corrected Docker's Node version so default sync-worker sizing uses `availableParallelism`. | Already shipped in 1.8 as patch-equivalent `3609d48af` and listed in the 1.8 fixes. | +| [`48694d892`](https://github.com/rocicorp/mono/pull/6187) | fix | - | Retries missing replication-lag probes and stops `total_lag` from growing indefinitely when no report is received. Adds `lag_report_retries`; `last_total_lag` is no longer semantically distinct. | Include publicly, grouped with #6219. Tests cover retry and recorder behavior. Human review classified this as non-breaking and requires `otel.mdx`, `zero-cache-config.mdx`, and alert-migration guidance for operators relying on old gauge semantics. | +| [`bb8703753`](https://github.com/rocicorp/mono/pull/6186) | skip | - | Bounds catch-up subscriber backlogs and applies downstream flow control. | Already shipped in 1.8 as patch-equivalent `4ef9e4ef0` and listed in the 1.8 fixes. | +| [`ec7c5c678`](https://github.com/rocicorp/mono/pull/6197) | skip | - | Expands end-to-end fuzzing design and test coverage. | Omit as test-only. No shipped runtime behavior changed. | +| [`7d13c1b4b`](https://github.com/rocicorp/mono/pull/6196) | fix | - | Forces a CVR version bump when a same-hash query is rebuilt without another bump, ensuring changed rows reach clients instead of leaving stale results. | Public candidate. View-syncer and CVR integration tests cover missing-pipeline and row-signature drift cases and duplicate-delete avoidance. | +| [`3adc4383f`](https://github.com/rocicorp/mono/pull/6203) | skip | - | Added metrics for mutate, query, cleanup, and auth-validation API calls. | Already shipped in 1.8 as patch-equivalent `c2b5a7565` and covered by the 1.8 operational-metrics feature. | +| [`e03cc5301`](https://github.com/rocicorp/mono/pull/6207) | skip | - | Added CVR, WebSocket, and replication flow-control metrics. | Already shipped in 1.8 as patch-equivalent `e91debd95` and covered by the 1.8 stability-metrics feature. | +| [`6cc6629ca`](https://github.com/rocicorp/mono/pull/6209) | skip | - | Aligned metric names with product documentation. | Already shipped in 1.8 as patch-equivalent `d711edbb2`; no additional 1.9 behavior. | +| [`03223e310`](https://github.com/rocicorp/mono/pull/6211) | skip | - | None through supported exports; deletes an unused shared helper and tests. | Omit as an internal refactor. `shared` is private, the helper was not exported, and history shows no callers outside its tests. | +| [`42819059b`](https://github.com/rocicorp/mono/pull/6202) | fix | - | Bounds each zero-cache SQLite prepared-statement cache to 1,000 idle entries with LRU eviction, preventing unbounded retained statement state from varied query shapes. | Include as a non-breaking reliability fix. Unit tests cover the bound, LRU ordering, duplicate SQL instances, and in-flight statements. Human review accepted the fixed, non-configurable per-cache limit; avoid claiming immediate native-memory release because finalization remains GC-driven. | +| [`596a9376b`](https://github.com/rocicorp/mono/pull/6212) | skip | - | Removes a SQLite quick-check that was too slow in production. | Already shipped in 1.8 as patch-equivalent `dcbc5b5ea`; no additional 1.9 behavior. | +| [`86d531954`](https://github.com/rocicorp/mono/pull/6205) | skip | - | Adds fault-injection coverage for replication resumption after process, network, and acknowledgement failures. | Omit as resilience testing; validates existing behavior without a production change. | +| [`478711b76`](https://github.com/rocicorp/mono/pull/6214) | skip | - | Makes lag metrics aggregable across time and pods. | Already shipped in 1.8 as patch-equivalent `d4258993d` and covered by the 1.8 stability-metrics feature. | +| [`eac60e7c3`](https://github.com/rocicorp/mono/pull/6206) | performance | - | For sufficiently large or pathological updates, projects incremental-maintenance cost and rebuilds query pipelines when rebuilding should be cheaper, while allowing nearly complete updates to finish. | Performance evaluation deferred by human review. Synthetic tests verify reset thresholds, not end-to-end performance, so the public note makes no behavioral or quantitative claim. | +| [`b26add22f`](https://github.com/rocicorp/mono/pull/6204) | skip | - | Extends query-equivalence fuzzing through zero-cache and client materialization. | Omit as test-only; no runtime implementation changed. | +| [`89d57b460`](https://github.com/rocicorp/mono/pull/6210) | skip | - | Added replication-slot health and retained/safe WAL metrics. | Already shipped in 1.8 as patch-equivalent `f71c897c4` and covered by the 1.8 operational-metrics feature. | +| [`89c48bbc2`](https://github.com/rocicorp/mono/pull/6208) | skip | - | Added zero-cache worker startup duration metrics. | Already shipped in 1.8 as patch-equivalent `832580d65` and covered by the 1.8 operational-metrics feature. | +| [`7337ed18f`](https://github.com/rocicorp/mono/pull/6213) | feature | - | Adds opt-in npm `@rocicorp/zero@head` and GHCR `ghcr.io/rocicorp/zero:head` releases, plus immutable versions/tags containing the source SHA and date. Stable tags are unchanged. | Intentionally omit from Zero 1.9 notes after human review because this is a main-branch release channel, not a version-scoped runtime capability. Release-plan tests cover versions, branch/SHA validation, collisions, and refusal to create a git tag. | +| [`ca40512bf`](https://github.com/rocicorp/mono/pull/6218) | skip | - | Release README updates. | Already shipped in 1.8 as patch-equivalent `312a0f78f`; documentation-only and no additional 1.9 behavior. | +| [`6d84471c5`](https://github.com/rocicorp/mono/pull/6219) | fix | - | Excludes disconnected or not-yet-validated view-syncers from serving-lag metrics, preventing retained groups from producing false multi-hour spikes. | Include publicly, grouped with #6187. Human review classified the metric correction as non-breaking and requires operator-facing description of changed populations. Syncer tests cover eligible populations and cleanup; the change does not fix the underlying reason some disconnected view-syncers remain alive. | +| [`f9ff04d31`](https://github.com/rocicorp/mono/pull/6220) | fix | BREAKING | Resets PostgreSQL connections that carry no wire traffic across consecutive watchdog samples, allowing recovery from proxy-created half-open sockets. | Include with #6221 plus a Breaking Changes advisory. Default sampling is 120 seconds, giving an effective detection window of roughly two to four minutes. A legitimately silent statement can now be rejected; document `ZERO_PG_SOCKET_INACTIVITY_TIMEOUT` as the migration control and `0` as disabling the watchdog. | +| [`72f732cd4`](https://github.com/rocicorp/mono/pull/6221) | fix | BREAKING | Reimplements the inactivity watchdog so it survives postgres.js TLS socket upgrades. | Include with #6220 and the same migration advisory. Tests simulate TLS listener removal, activity, reset, warning logs, and disabling the watchdog. | +| [`e5b9c6f55`](https://github.com/rocicorp/mono/pull/6222) | skip | - | Accepts an optional nullable `newColumns` map in DDL events but does not emit or act on it. | Omit as reader-first rollout scaffolding for a future DDL optimization. Parsing tests cover present, null, and absent values. No migration, protocol bump, or 1.9 backfill behavior. | +| [`15fd7cdea`](https://github.com/rocicorp/mono/pull/6223) | skip | - | Exports `MutatorResult` for helpers that await client or server mutation results. | Already shipped in 1.8 as patch-equivalent `cdc02598f` and listed as a 1.8 feature. | +| [`d4f33d6a6`](https://github.com/rocicorp/mono/pull/6121) | fix | - | Makes compound cursor equality NULL-safe, preserves NULL groups in reverse walks, and propagates replica nullability so ordered pagination and window maintenance do not skip rows or hit `Bound should be set`. | Public candidate for the delta beyond already-shipped #6189. Query-builder, real-SQLite table-source, and lite-table tests cover tie-breaks, inclusive starts, reverse walks, and metadata. External author [@YevheniiKotyrlo](https://github.com/YevheniiKotyrlo), co-authored by Matt Wonlaw; GitHub profile shows no Rocicorp affiliation, so include thanks. | +| [`e374775a9`](https://github.com/rocicorp/mono/pull/6226) | skip | - | Avoids row remounts in the private zbugs application. | Omit as sample/private-app performance work; no Zero package changed. | +| [`7139287da`](https://github.com/rocicorp/mono/pull/6227) | skip | - | Migrates the private zbugs application to zero-virtual 0.6. | Omit as private-app dependency and layout work; no Zero API or runtime changed. | +| [`177d6c1f9`](https://github.com/rocicorp/mono/pull/6228) | fix | - | Disposes custom-query transformed-query caches when view-syncers stop, eliminating retained timers and cache maps for terminated client groups. | Public reliability candidate. Cache and transformer tests cover lazy/unrefed timers, idempotent destruction, and no timer resurrection. No API or TTL change. | +| [`ef892a123`](https://github.com/rocicorp/mono/pull/6224) | fix | - | Updates the optional bundled Litestream v5 executable from 0.5.11 to 0.5.14. Legacy Litestream remains the default. | Intentionally omit from public 1.9 notes after human review because it affects only the opt-in v5 executable. Upstream 0.5.12 includes restore WAL-gap detection, initial LTX-open retries, failed-restore cleanup, overwrite protection, and snapshot-setting fixes; 0.5.14 adds remote compaction reads, sustained S3 retries, replica-type handling, retention correction, and atomic SFTP writes. Keep v5 restore smoke coverage in the release handoff. | + +## Breaking-Change Review + +Human review identified one breaking operational change: the PostgreSQL inactivity watchdog can terminate a legitimately silent long-running statement. All `MAYBE` classifications are resolved. + +| Area | Finding | Required resolution | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| Public API and exports | No supported export is removed. The only new externally consumable interface is the additive `head` release channel. | No migration expected. The head channel is intentionally omitted from the version-scoped public note. | +| Configuration | #6182 adds default-on query-covering shadow analysis and `ZERO_ENABLE_QUERY_COVERING`. #6220 recognizes `ZERO_PG_SOCKET_INACTIVITY_TIMEOUT`. | Keep query covering internal. Document the PostgreSQL timeout control and `0` disable value as part of the breaking migration guidance. | +| Default behavior | PostgreSQL connections now reset after roughly two to four minutes without wire activity. Statement caches now retain at most 1,000 idle statements per cache. Load shedding may rebuild pipelines earlier. | Classify the watchdog as breaking. Human review accepted the statement-cache and load-shedding defaults as non-breaking. | +| Persisted data | No schema or replica migration. Correctly preserving `__proto__` can change the client schema hash for a previously broken schema and trigger normal resync behavior. | Narrow the claim; no special migration identified. | +| Protocol | Sync, change-stream, and DDL protocol constants are compatible. `newColumns` is optional and ignored when absent. | Resolved: compatible. | +| Dependencies | No npm runtime or peer dependency changes unique to 1.9. The release image changes optional Litestream v5 from 0.5.11 to 0.5.14. | Review upstream releases, completed above; retain v5 restore coverage in the smoke-test handoff. | +| Metrics and alerts | `total_lag`, `last_total_lag`, serving-lag populations, and retry reporting change semantics. | Update product docs and include alert-migration guidance; classified as non-breaking. | +| Deployment and rollback | The DDL reader accepts the optional future field without requiring writers to emit it. Head artifacts do not move `latest` or create git tags. | Resolved for protocol/rollback. Confirm release environments produce the intended head artifacts separately. | + +## Public Candidate Selection + +### Features + +- None selected. The head release channel (#6213) is intentionally omitted because it is not scoped to the 1.9 runtime. + +### Fixes + +- Ordered queries and window maintenance across NULL cursors (#6121), scoped to the additional tie-break, reverse-walk, and metadata behavior not already shipped through #6189. +- Preserving `__proto__` as user data in the specifically tested schema, mutation, and view paths (#6185). +- Same-hash query rehydration delivering changed rows instead of stale results (#6196). +- Bounded SQLite prepared-statement retention (#6202). +- Replication and serving-lag metric correctness (#6187 and #6219). +- Recovery from half-open PostgreSQL connections, including TLS (#6220 and #6221), with a breaking-change migration note. +- Custom-query cache/timer cleanup (#6228). +- Litestream v5 (#6224) is intentionally omitted because it affects only the opt-in v5 executable; retain restore smoke coverage in the release handoff. + +### Performance + +Deferred. + +Every non-skipped commit is represented or intentionally omitted above. #6206 remains deferred until release-quality end-to-end evidence is available. + +## Performance Evidence Plan + +Deferred. + +## Required Product Documentation + +- `contents/docs/otel.mdx`: correct `total_lag` and `last_total_lag`, add `lag_report_retries` and the same-hash rehydration counter, and describe serving-lag eligibility. +- `contents/docs/zero-cache-config.mdx`: update replication-lag-report retry behavior. Do not promote query-covering. +- `contents/docs/connecting-to-postgres.mdx`: document the socket inactivity behavior, supported override, and disable value. +- `contents/docs/release-notes/1.9.mdx`: create after audit review with performance deferred. +- `contents/docs/release-notes/index.mdx`: add Zero 1.9 first with a description matching frontmatter. +- Generated search and LLM artifacts: regenerate after product-doc edits. + +## Attribution + +- `d4f33d6a6` is authored by [@YevheniiKotyrlo](https://github.com/YevheniiKotyrlo) and co-authored by Matt Wonlaw. The author uses a GitHub noreply address and their public profile lists no company or Rocicorp affiliation. If the fix is included, append `(thanks [@YevheniiKotyrlo](https://github.com/YevheniiKotyrlo)!)`. +- `cb014857f` is authored by Erik Arvidsson but explicitly based on #6145 by external contributor [@tjenkinson](https://github.com/tjenkinson). Append `(thanks [@tjenkinson](https://github.com/tjenkinson)!)`. +- Other selected commits are authored by known Rocicorp contributors in the range. Recheck PR-level co-authors immediately before final publication. + +## Human Review Decisions + +- Classify the PostgreSQL inactivity watchdog as breaking and document `ZERO_PG_SOCKET_INACTIVITY_TIMEOUT`, including `0` to disable it. +- Defer #6206 benchmarks and publish no performance claim. +- Omit the head release channel from Zero 1.9 notes. +- Keep query-covering shadow telemetry internal and omit it publicly. +- Include the 1,000-entry statement-cache bound as a non-breaking reliability fix. +- Document the replication and serving-lag metric migration as a non-breaking operator-facing correction. +- Omit the optional Litestream v5 update from public notes, but retain its restore smoke test in the release handoff. + +Remaining blockers: + +1. Obtain a 1.9 canary for the smoke-test handoff. +2. Run the release-image Litestream v5 restore smoke test even though the dependency update is omitted publicly. + +## Audit Review Gate + +Human review is complete. Public drafting may proceed with performance deferred. + +## Validation + +- Audit coverage: PASS. All 39 raw-range commits have exactly one decision row; all 15 patch-equivalent 1.8 backports are recorded. +- Protocol compatibility: PASS. +- Placeholder links: PASS. No draft placeholders remain in the audit or release note. +- Formatting: PASS with `pnpm check-format` after formatting the generated search index. +- Types: PASS with `pnpm check-types`. +- Tests: PASS with `pnpm test` (73 tests). +- Production build: PASS with `pnpm build`; search and LLM artifacts were regenerated and Next.js built all 160 pages. +- Source lint: `pnpm exec oxlint --quiet` completed with 0 errors and 15 warnings. +- Type-aware lint: BLOCKED before file analysis. `pnpm lint` causes locked `oxlint@1.65.0` / `oxlint-tsgolint@0.8.6` to panic on unknown rule `no-useless-default-assignment`. This repository tool-version issue is recorded in the main repo's `PAPERCUTS.md`. +- Performance: Deferred by human review; no benchmark or public performance claim. diff --git a/assets/search-index.json b/assets/search-index.json index 0d4d7415..d1871063 100644 --- a/assets/search-index.json +++ b/assets/search-index.json @@ -14,7 +14,7 @@ "kind": "page" }, { - "id": "76-agents#options", + "id": "77-agents#options", "title": "Agent Support", "searchTitle": "Options", "sectionTitle": "Options", @@ -126,7 +126,7 @@ "kind": "page" }, { - "id": "77-auth#set-userid-on-client", + "id": "78-auth#set-userid-on-client", "title": "Authentication", "searchTitle": "Set userID on Client", "sectionTitle": "Set userID on Client", @@ -136,7 +136,7 @@ "kind": "section" }, { - "id": "78-auth#define-the-context-type", + "id": "79-auth#define-the-context-type", "title": "Authentication", "searchTitle": "Define the Context Type", "sectionTitle": "Define the Context Type", @@ -146,7 +146,7 @@ "kind": "section" }, { - "id": "79-auth#send-credentials", + "id": "80-auth#send-credentials", "title": "Authentication", "searchTitle": "Send Credentials", "sectionTitle": "Send Credentials", @@ -156,7 +156,7 @@ "kind": "section" }, { - "id": "80-auth#cookies", + "id": "81-auth#cookies", "title": "Authentication", "searchTitle": "Cookies", "sectionTitle": "Cookies", @@ -166,7 +166,7 @@ "kind": "section" }, { - "id": "81-auth#cookie-deployment", + "id": "82-auth#cookie-deployment", "title": "Authentication", "searchTitle": "Cookie Deployment", "sectionTitle": "Cookie Deployment", @@ -176,7 +176,7 @@ "kind": "section" }, { - "id": "82-auth#tokens", + "id": "83-auth#tokens", "title": "Authentication", "searchTitle": "Tokens", "sectionTitle": "Tokens", @@ -186,7 +186,7 @@ "kind": "section" }, { - "id": "83-auth#implement-api-endpoints", + "id": "84-auth#implement-api-endpoints", "title": "Authentication", "searchTitle": "Implement API Endpoints", "sectionTitle": "Implement API Endpoints", @@ -196,7 +196,7 @@ "kind": "section" }, { - "id": "84-auth#query", + "id": "85-auth#query", "title": "Authentication", "searchTitle": "Query", "sectionTitle": "Query", @@ -206,7 +206,7 @@ "kind": "section" }, { - "id": "85-auth#mutate", + "id": "86-auth#mutate", "title": "Authentication", "searchTitle": "Mutate", "sectionTitle": "Mutate", @@ -216,7 +216,7 @@ "kind": "section" }, { - "id": "86-auth#updating-tokens", + "id": "87-auth#updating-tokens", "title": "Authentication", "searchTitle": "Updating Tokens", "sectionTitle": "Updating Tokens", @@ -226,7 +226,7 @@ "kind": "section" }, { - "id": "87-auth#auth-failure-and-refresh", + "id": "88-auth#auth-failure-and-refresh", "title": "Authentication", "searchTitle": "Auth Failure and Refresh", "sectionTitle": "Auth Failure and Refresh", @@ -236,7 +236,7 @@ "kind": "section" }, { - "id": "88-auth#permission-patterns", + "id": "89-auth#permission-patterns", "title": "Authentication", "searchTitle": "Permission Patterns", "sectionTitle": "Permission Patterns", @@ -246,7 +246,7 @@ "kind": "section" }, { - "id": "89-auth#read-permissions", + "id": "90-auth#read-permissions", "title": "Authentication", "searchTitle": "Read Permissions", "sectionTitle": "Read Permissions", @@ -256,7 +256,7 @@ "kind": "section" }, { - "id": "90-auth#only-owned-rows", + "id": "91-auth#only-owned-rows", "title": "Authentication", "searchTitle": "Only Owned Rows", "sectionTitle": "Only Owned Rows", @@ -266,7 +266,7 @@ "kind": "section" }, { - "id": "91-auth#owned-or-shared-rows", + "id": "92-auth#owned-or-shared-rows", "title": "Authentication", "searchTitle": "Owned or Shared Rows", "sectionTitle": "Owned or Shared Rows", @@ -276,7 +276,7 @@ "kind": "section" }, { - "id": "92-auth#owned-rows-or-all-if-admin", + "id": "93-auth#owned-rows-or-all-if-admin", "title": "Authentication", "searchTitle": "Owned Rows or All if Admin", "sectionTitle": "Owned Rows or All if Admin", @@ -286,7 +286,7 @@ "kind": "section" }, { - "id": "93-auth#deny-by-returning-no-rows", + "id": "94-auth#deny-by-returning-no-rows", "title": "Authentication", "searchTitle": "Deny by Returning No Rows", "sectionTitle": "Deny by Returning No Rows", @@ -296,7 +296,7 @@ "kind": "section" }, { - "id": "94-auth#write-permissions", + "id": "95-auth#write-permissions", "title": "Authentication", "searchTitle": "Write Permissions", "sectionTitle": "Write Permissions", @@ -306,7 +306,7 @@ "kind": "section" }, { - "id": "95-auth#enforce-ownership", + "id": "96-auth#enforce-ownership", "title": "Authentication", "searchTitle": "Enforce Ownership", "sectionTitle": "Enforce Ownership", @@ -316,7 +316,7 @@ "kind": "section" }, { - "id": "96-auth#edit-owned-rows", + "id": "97-auth#edit-owned-rows", "title": "Authentication", "searchTitle": "Edit Owned Rows", "sectionTitle": "Edit Owned Rows", @@ -326,7 +326,7 @@ "kind": "section" }, { - "id": "97-auth#edit-owned-or-shared-rows", + "id": "98-auth#edit-owned-or-shared-rows", "title": "Authentication", "searchTitle": "Edit Owned or Shared Rows", "sectionTitle": "Edit Owned or Shared Rows", @@ -336,7 +336,7 @@ "kind": "section" }, { - "id": "98-auth#edit-owned-or-all-if-admin", + "id": "99-auth#edit-owned-or-all-if-admin", "title": "Authentication", "searchTitle": "Edit Owned or All if Admin", "sectionTitle": "Edit Owned or All if Admin", @@ -346,7 +346,7 @@ "kind": "section" }, { - "id": "99-auth#logging-out", + "id": "100-auth#logging-out", "title": "Authentication", "searchTitle": "Logging Out", "sectionTitle": "Logging Out", @@ -383,7 +383,7 @@ "kind": "page" }, { - "id": "100-community#ui-frameworks", + "id": "101-community#ui-frameworks", "title": "From the Community", "searchTitle": "UI Frameworks", "sectionTitle": "UI Frameworks", @@ -393,7 +393,7 @@ "kind": "section" }, { - "id": "101-community#miscellaneous", + "id": "102-community#miscellaneous", "title": "From the Community", "searchTitle": "Miscellaneous", "sectionTitle": "Miscellaneous", @@ -407,7 +407,7 @@ "title": "Connecting to Postgres", "searchTitle": "Connecting to Postgres", "url": "/docs/connecting-to-postgres", - "content": "In the future, Zero will work with many different backend databases. Today only Postgres is supported. Specifically, Zero requires Postgres v15.0 or higher, and support for logical replication. Here are some common Postgres options and what we know about their support level: Event Triggers Zero uses Postgres “Event Triggers” when possible to implement high-quality, efficient schema migration. Some hosted Postgres providers don't provide access to Event Triggers. Zero still works out of the box with these providers, but for correctness, any schema change triggers a full reset of all server-side and client-side state. For small databases (< 10GB) this can be OK, but for bigger databases you should either manually tell Zero about the schema change or choose a provider with event trigger support. Configuration WAL Level The Postgres wal_level config parameter has to be set to logical. You can check what level your pg has with this command: psql -c 'SHOW wal_level' If it doesn’t output logical then you need to change the wal level. To do this, run: psql -c \"ALTER SYSTEM SET wal_level = 'logical';\" Then restart Postgres. On most pg systems you can do this like so: data_dir=$(psql -t -A -c 'SHOW data_directory') pg_ctl -D \"$data_dir\" restart After your server restarts, show the wal_level again to ensure it has changed: psql -c 'SHOW wal_level' Bounding WAL Size For development databases, you can set a max_slot_wal_keep_size value in Postgres. This will help limit the amount of WAL kept around. This is a configuration parameter that bounds the amount of WAL kept around for replication slots, and invalidates the slots that are too far behind. Zero-cache will automatically detect if the replication slot has been invalidated and re-sync replicas from scratch. This configuration can cause problems like slot has been invalidated because it exceeded the maximum reserved size and is not recommended for production databases. Provider-Specific Notes PlanetScale for Postgres Roles zero-cache should connect using the default role that PlanetScale provides, because PlanetScale user-defined roles cannot create replication slots. Connection Limits Change max_connections to at least 100. The default is 25, which is too low for Zero in most configurations. Pooling Make sure to only use a direct connection for the ZERO_UPSTREAM_DB, and use pooled URLs for ZERO_CVR_DB, ZERO_CHANGE_DB, and your API (see Deployment). High Availability PlanetScale Postgres can fail over to a standby during maintenance or an outage. By default a logical replication slot does not survive promotion of a standby, so after a failover zero-cache would find its slot missing and re-sync every replica from scratch. To avoid this, first, run zero-cache with ZERO_UPSTREAM_PG_REPLICATION_SLOT_FAILOVER=true so it creates failover-enabled slots. Then, run the script below to register Zero's replication slots with PlanetScale and enable the two cluster parameters failover needs: APP=\"\" # your ZERO_APP_ID — on Zero Cloud this is your instance ID ORG=\"\" # PlanetScale organization DB=\"\" # PlanetScale database BRANCH=\"main\" SHARD=\"0\" if [ -z \"$APP\" ] || [ -z \"$ORG\" ] || [ -z \"$DB\" ]; then echo \"Set APP, ORG, and DB first — nothing was sent.\" elif pscale api -X PATCH \"organizations/${ORG}/databases/${DB}/branches/${BRANCH}/changes\" --input=- >/dev/null </dev/null <\" OTEL_EXPORTER_OTLP_HEADERS=\"\" OTEL_RESOURCE_ATTRIBUTES=\"\" OTEL_NODE_RESOURCE_DETECTORS=\"env,host,os\" Grafana Cloud Walkthrough Here are instructions to setup Grafana Cloud, but the setup for other otel collectors should be similar. Sign up for Grafana Cloud (Free Tier) Click Connections > Add Connection in the left sidebar add-connection Search for \"OpenTelemetry\" and select it Click \"Quickstart\" quickstart Select \"JavaScript\" javascript Create a new token Copy the environment variables into your .env file or similar copy-env Start zero-cache Look for logs under \"Drilldown\" > \"Logs\" in left sidebar Distributed Tracing You can enable end-to-end trace correlation from your frontend through zero-cache to your API server. This allows you to see the full request flow in your tracing UI. To enable this, provide a getTraceparent callback when creating your Zero client: import {ZeroProvider} from '@rocicorp/zero/react' import {propagation, context} from '@opentelemetry/api' function getTraceparent() { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } return ( )import {ZeroProvider} from '@rocicorp/zero/solid' import {propagation, context} from '@opentelemetry/api' function getTraceparent() { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } return ( )import {Zero} from '@rocicorp/zero' import {propagation, context} from '@opentelemetry/api' const zero = new Zero({ // ... other options getTraceparent: () => { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } }) This callback is called before sending WebSocket messages that trigger API server calls (push, changeDesiredQueries, initConnection). The returned W3C traceparent header is forwarded through zero-cache to your API server, where it can be used to continue the trace. Metrics Reference view_syncer_lag and view_syncer_hydration require OpenTelemetry exponential histogram support. Prometheus users must enable native histograms. Use the existing serving_lag gauges if your backend does not support them. zero.server zero.replica zero.replication zero.sync zero.mutation", + "content": "The zero-cache service embeds the JavaScript OTLP Exporter and can send logs, traces, and metrics to any standard otel collector. To enable otel, set the following environment variables then run zero-cache as normal: OTEL_EXPORTER_OTLP_ENDPOINT=\"\" OTEL_EXPORTER_OTLP_HEADERS=\"\" OTEL_RESOURCE_ATTRIBUTES=\"\" OTEL_NODE_RESOURCE_DETECTORS=\"env,host,os\" Grafana Cloud Walkthrough Here are instructions to setup Grafana Cloud, but the setup for other otel collectors should be similar. Sign up for Grafana Cloud (Free Tier) Click Connections > Add Connection in the left sidebar add-connection Search for \"OpenTelemetry\" and select it Click \"Quickstart\" quickstart Select \"JavaScript\" javascript Create a new token Copy the environment variables into your .env file or similar copy-env Start zero-cache Look for logs under \"Drilldown\" > \"Logs\" in left sidebar Distributed Tracing You can enable end-to-end trace correlation from your frontend through zero-cache to your API server. This allows you to see the full request flow in your tracing UI. To enable this, provide a getTraceparent callback when creating your Zero client: import {ZeroProvider} from '@rocicorp/zero/react' import {propagation, context} from '@opentelemetry/api' function getTraceparent() { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } return ( )import {ZeroProvider} from '@rocicorp/zero/solid' import {propagation, context} from '@opentelemetry/api' function getTraceparent() { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } return ( )import {Zero} from '@rocicorp/zero' import {propagation, context} from '@opentelemetry/api' const zero = new Zero({ // ... other options getTraceparent: () => { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } }) This callback is called before sending WebSocket messages that trigger API server calls (push, changeDesiredQueries, initConnection). The returned W3C traceparent header is forwarded through zero-cache to your API server, where it can be used to continue the trace. Metrics Reference view_syncer_lag and view_syncer_hydration require OpenTelemetry exponential histogram support. Prometheus users must enable native histograms. Use the existing serving_lag gauges if your backend does not support them. zero.server zero.replica zero.replication total_lag and last_total_lag now report the same latest measured round trip and do not grow when reports stop arriving. Use lag_report_retries to detect a stalled or missing report stream. zero.sync Serving-lag metrics include only client groups with at least one connected client and a validated background connection context. Retained groups without an eligible connection do not contribute lag. zero.mutation", "headings": [ { "text": "Grafana Cloud Walkthrough", @@ -2328,7 +2342,7 @@ "kind": "page" }, { - "id": "226-otel#grafana-cloud-walkthrough", + "id": "228-otel#grafana-cloud-walkthrough", "title": "OpenTelemetry", "searchTitle": "Grafana Cloud Walkthrough", "sectionTitle": "Grafana Cloud Walkthrough", @@ -2338,7 +2352,7 @@ "kind": "section" }, { - "id": "227-otel#distributed-tracing", + "id": "229-otel#distributed-tracing", "title": "OpenTelemetry", "searchTitle": "Distributed Tracing", "sectionTitle": "Distributed Tracing", @@ -2348,17 +2362,17 @@ "kind": "section" }, { - "id": "228-otel#metrics-reference", + "id": "230-otel#metrics-reference", "title": "OpenTelemetry", "searchTitle": "Metrics Reference", "sectionTitle": "Metrics Reference", "sectionId": "metrics-reference", "url": "/docs/otel", - "content": "view_syncer_lag and view_syncer_hydration require OpenTelemetry exponential histogram support. Prometheus users must enable native histograms. Use the existing serving_lag gauges if your backend does not support them. zero.server zero.replica zero.replication zero.sync zero.mutation", + "content": "view_syncer_lag and view_syncer_hydration require OpenTelemetry exponential histogram support. Prometheus users must enable native histograms. Use the existing serving_lag gauges if your backend does not support them. zero.server zero.replica zero.replication total_lag and last_total_lag now report the same latest measured round trip and do not grow when reports stop arriving. Use lag_report_retries to detect a stalled or missing report stream. zero.sync Serving-lag metrics include only client groups with at least one connected client and a validated background connection context. Retained groups without an eligible connection do not contribute lag. zero.mutation", "kind": "section" }, { - "id": "229-otel#zeroserver", + "id": "231-otel#zeroserver", "title": "OpenTelemetry", "searchTitle": "zero.server", "sectionTitle": "zero.server", @@ -2368,7 +2382,7 @@ "kind": "section" }, { - "id": "230-otel#zeroreplica", + "id": "232-otel#zeroreplica", "title": "OpenTelemetry", "searchTitle": "zero.replica", "sectionTitle": "zero.replica", @@ -2378,27 +2392,27 @@ "kind": "section" }, { - "id": "231-otel#zeroreplication", + "id": "233-otel#zeroreplication", "title": "OpenTelemetry", "searchTitle": "zero.replication", "sectionTitle": "zero.replication", "sectionId": "zeroreplication", "url": "/docs/otel", - "content": "", + "content": "total_lag and last_total_lag now report the same latest measured round trip and do not grow when reports stop arriving. Use lag_report_retries to detect a stalled or missing report stream.", "kind": "section" }, { - "id": "232-otel#zerosync", + "id": "234-otel#zerosync", "title": "OpenTelemetry", "searchTitle": "zero.sync", "sectionTitle": "zero.sync", "sectionId": "zerosync", "url": "/docs/otel", - "content": "", + "content": "Serving-lag metrics include only client groups with at least one connected client and a validated background connection context. Retained groups without an eligible connection do not contribute lag.", "kind": "section" }, { - "id": "233-otel#zeromutation", + "id": "235-otel#zeromutation", "title": "OpenTelemetry", "searchTitle": "zero.mutation", "sectionTitle": "zero.mutation", @@ -2458,7 +2472,7 @@ "kind": "page" }, { - "id": "234-postgres-support#object-names", + "id": "236-postgres-support#object-names", "title": "Supported Postgres Features", "searchTitle": "Object Names", "sectionTitle": "Object Names", @@ -2468,7 +2482,7 @@ "kind": "section" }, { - "id": "235-postgres-support#object-types", + "id": "237-postgres-support#object-types", "title": "Supported Postgres Features", "searchTitle": "Object Types", "sectionTitle": "Object Types", @@ -2478,7 +2492,7 @@ "kind": "section" }, { - "id": "236-postgres-support#column-types", + "id": "238-postgres-support#column-types", "title": "Supported Postgres Features", "searchTitle": "Column Types", "sectionTitle": "Column Types", @@ -2488,7 +2502,7 @@ "kind": "section" }, { - "id": "237-postgres-support#column-defaults", + "id": "239-postgres-support#column-defaults", "title": "Supported Postgres Features", "searchTitle": "Column Defaults", "sectionTitle": "Column Defaults", @@ -2498,7 +2512,7 @@ "kind": "section" }, { - "id": "238-postgres-support#ids", + "id": "240-postgres-support#ids", "title": "Supported Postgres Features", "searchTitle": "IDs", "sectionTitle": "IDs", @@ -2508,7 +2522,7 @@ "kind": "section" }, { - "id": "239-postgres-support#primary-keys", + "id": "241-postgres-support#primary-keys", "title": "Supported Postgres Features", "searchTitle": "Primary Keys", "sectionTitle": "Primary Keys", @@ -2518,7 +2532,7 @@ "kind": "section" }, { - "id": "240-postgres-support#limiting-replication", + "id": "242-postgres-support#limiting-replication", "title": "Supported Postgres Features", "searchTitle": "Limiting Replication", "sectionTitle": "Limiting Replication", @@ -2528,7 +2542,7 @@ "kind": "section" }, { - "id": "241-postgres-support#zero-cache-replication", + "id": "243-postgres-support#zero-cache-replication", "title": "Supported Postgres Features", "searchTitle": "zero-cache replication", "sectionTitle": "zero-cache replication", @@ -2538,7 +2552,7 @@ "kind": "section" }, { - "id": "242-postgres-support#browser-client-replication", + "id": "244-postgres-support#browser-client-replication", "title": "Supported Postgres Features", "searchTitle": "Browser client replication", "sectionTitle": "Browser client replication", @@ -2548,7 +2562,7 @@ "kind": "section" }, { - "id": "243-postgres-support#schema-changes", + "id": "245-postgres-support#schema-changes", "title": "Supported Postgres Features", "searchTitle": "Schema changes", "sectionTitle": "Schema changes", @@ -2584,7 +2598,7 @@ "kind": "page" }, { - "id": "244-previews#overview", + "id": "246-previews#overview", "title": "Previews", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -2594,7 +2608,7 @@ "kind": "section" }, { - "id": "245-previews#configure-allowed-endpoint-patterns", + "id": "247-previews#configure-allowed-endpoint-patterns", "title": "Previews", "searchTitle": "Configure Allowed Endpoint Patterns", "sectionTitle": "Configure Allowed Endpoint Patterns", @@ -2604,7 +2618,7 @@ "kind": "section" }, { - "id": "246-previews#choose-endpoint-urls-in-the-client", + "id": "248-previews#choose-endpoint-urls-in-the-client", "title": "Previews", "searchTitle": "Choose Endpoint URLs in the Client", "sectionTitle": "Choose Endpoint URLs in the Client", @@ -2614,7 +2628,7 @@ "kind": "section" }, { - "id": "247-previews#schema-changes-in-previews", + "id": "249-previews#schema-changes-in-previews", "title": "Previews", "searchTitle": "Schema Changes in Previews", "sectionTitle": "Schema Changes in Previews", @@ -2758,7 +2772,7 @@ "kind": "page" }, { - "id": "248-queries#architecture", + "id": "250-queries#architecture", "title": "Queries", "searchTitle": "Architecture", "sectionTitle": "Architecture", @@ -2768,7 +2782,7 @@ "kind": "section" }, { - "id": "249-queries#life-of-a-query", + "id": "251-queries#life-of-a-query", "title": "Queries", "searchTitle": "Life of a Query", "sectionTitle": "Life of a Query", @@ -2778,7 +2792,7 @@ "kind": "section" }, { - "id": "250-queries#defining-queries", + "id": "252-queries#defining-queries", "title": "Queries", "searchTitle": "Defining Queries", "sectionTitle": "Defining Queries", @@ -2788,7 +2802,7 @@ "kind": "section" }, { - "id": "251-queries#basics", + "id": "253-queries#basics", "title": "Queries", "searchTitle": "Basics", "sectionTitle": "Basics", @@ -2798,7 +2812,7 @@ "kind": "section" }, { - "id": "252-queries#arguments", + "id": "254-queries#arguments", "title": "Queries", "searchTitle": "Arguments", "sectionTitle": "Arguments", @@ -2808,7 +2822,7 @@ "kind": "section" }, { - "id": "253-queries#query-registries", + "id": "255-queries#query-registries", "title": "Queries", "searchTitle": "Query Registries", "sectionTitle": "Query Registries", @@ -2818,7 +2832,7 @@ "kind": "section" }, { - "id": "254-queries#query-names", + "id": "256-queries#query-names", "title": "Queries", "searchTitle": "Query Names", "sectionTitle": "Query Names", @@ -2828,7 +2842,7 @@ "kind": "section" }, { - "id": "255-queries#context", + "id": "257-queries#context", "title": "Queries", "searchTitle": "Context", "sectionTitle": "Context", @@ -2838,7 +2852,7 @@ "kind": "section" }, { - "id": "256-queries#queriests", + "id": "258-queries#queriests", "title": "Queries", "searchTitle": "queries.ts", "sectionTitle": "queries.ts", @@ -2848,7 +2862,7 @@ "kind": "section" }, { - "id": "257-queries#server-setup", + "id": "259-queries#server-setup", "title": "Queries", "searchTitle": "Server Setup", "sectionTitle": "Server Setup", @@ -2858,7 +2872,7 @@ "kind": "section" }, { - "id": "258-queries#registering-the-endpoint", + "id": "260-queries#registering-the-endpoint", "title": "Queries", "searchTitle": "Registering the Endpoint", "sectionTitle": "Registering the Endpoint", @@ -2868,7 +2882,7 @@ "kind": "section" }, { - "id": "259-queries#implementing-the-endpoint", + "id": "261-queries#implementing-the-endpoint", "title": "Queries", "searchTitle": "Implementing the Endpoint", "sectionTitle": "Implementing the Endpoint", @@ -2878,7 +2892,7 @@ "kind": "section" }, { - "id": "260-queries#custom-query-url", + "id": "262-queries#custom-query-url", "title": "Queries", "searchTitle": "Custom Query URL", "sectionTitle": "Custom Query URL", @@ -2888,7 +2902,7 @@ "kind": "section" }, { - "id": "261-queries#url-patterns", + "id": "263-queries#url-patterns", "title": "Queries", "searchTitle": "URL Patterns", "sectionTitle": "URL Patterns", @@ -2898,7 +2912,7 @@ "kind": "section" }, { - "id": "262-queries#running-queries", + "id": "264-queries#running-queries", "title": "Queries", "searchTitle": "Running Queries", "sectionTitle": "Running Queries", @@ -2908,7 +2922,7 @@ "kind": "section" }, { - "id": "263-queries#reactively", + "id": "265-queries#reactively", "title": "Queries", "searchTitle": "Reactively", "sectionTitle": "Reactively", @@ -2918,7 +2932,7 @@ "kind": "section" }, { - "id": "264-queries#conditionally", + "id": "266-queries#conditionally", "title": "Queries", "searchTitle": "Conditionally", "sectionTitle": "Conditionally", @@ -2928,7 +2942,7 @@ "kind": "section" }, { - "id": "265-queries#once", + "id": "267-queries#once", "title": "Queries", "searchTitle": "Once", "sectionTitle": "Once", @@ -2938,7 +2952,7 @@ "kind": "section" }, { - "id": "266-queries#for-preloading", + "id": "268-queries#for-preloading", "title": "Queries", "searchTitle": "For Preloading", "sectionTitle": "For Preloading", @@ -2948,7 +2962,7 @@ "kind": "section" }, { - "id": "267-queries#missing-data", + "id": "269-queries#missing-data", "title": "Queries", "searchTitle": "Missing Data", "sectionTitle": "Missing Data", @@ -2958,7 +2972,7 @@ "kind": "section" }, { - "id": "268-queries#partial-data", + "id": "270-queries#partial-data", "title": "Queries", "searchTitle": "Partial Data", "sectionTitle": "Partial Data", @@ -2968,7 +2982,7 @@ "kind": "section" }, { - "id": "269-queries#handling-errors", + "id": "271-queries#handling-errors", "title": "Queries", "searchTitle": "Handling Errors", "sectionTitle": "Handling Errors", @@ -2978,7 +2992,7 @@ "kind": "section" }, { - "id": "270-queries#granular-updates", + "id": "272-queries#granular-updates", "title": "Queries", "searchTitle": "Granular Updates", "sectionTitle": "Granular Updates", @@ -2988,7 +3002,7 @@ "kind": "section" }, { - "id": "271-queries#query-caching", + "id": "273-queries#query-caching", "title": "Queries", "searchTitle": "Query Caching", "sectionTitle": "Query Caching", @@ -2998,7 +3012,7 @@ "kind": "section" }, { - "id": "272-queries#ttls", + "id": "274-queries#ttls", "title": "Queries", "searchTitle": "TTLs", "sectionTitle": "TTLs", @@ -3008,7 +3022,7 @@ "kind": "section" }, { - "id": "273-queries#ttl-defaults", + "id": "275-queries#ttl-defaults", "title": "Queries", "searchTitle": "TTL Defaults", "sectionTitle": "TTL Defaults", @@ -3018,7 +3032,7 @@ "kind": "section" }, { - "id": "274-queries#setting-different-ttls", + "id": "276-queries#setting-different-ttls", "title": "Queries", "searchTitle": "Setting Different TTLs", "sectionTitle": "Setting Different TTLs", @@ -3028,7 +3042,7 @@ "kind": "section" }, { - "id": "275-queries#why-zero-ttls-are-short", + "id": "277-queries#why-zero-ttls-are-short", "title": "Queries", "searchTitle": "Why Zero TTLs are Short", "sectionTitle": "Why Zero TTLs are Short", @@ -3038,7 +3052,7 @@ "kind": "section" }, { - "id": "276-queries#local-only-queries", + "id": "278-queries#local-only-queries", "title": "Queries", "searchTitle": "Local-Only Queries", "sectionTitle": "Local-Only Queries", @@ -3048,7 +3062,7 @@ "kind": "section" }, { - "id": "277-queries#custom-server-implementation", + "id": "279-queries#custom-server-implementation", "title": "Queries", "searchTitle": "Custom Server Implementation", "sectionTitle": "Custom Server Implementation", @@ -3058,7 +3072,7 @@ "kind": "section" }, { - "id": "278-queries#consistency", + "id": "280-queries#consistency", "title": "Queries", "searchTitle": "Consistency", "sectionTitle": "Consistency", @@ -3090,7 +3104,7 @@ "kind": "page" }, { - "id": "279-quickstart#hello-zero-solid", + "id": "281-quickstart#hello-zero-solid", "title": "Quickstart", "searchTitle": "hello-zero-solid", "sectionTitle": "hello-zero-solid", @@ -3100,7 +3114,7 @@ "kind": "section" }, { - "id": "280-quickstart#hello-zero-cf", + "id": "282-quickstart#hello-zero-cf", "title": "Quickstart", "searchTitle": "hello-zero-cf", "sectionTitle": "hello-zero-cf", @@ -3110,7 +3124,7 @@ "kind": "section" }, { - "id": "281-quickstart#hello-zero", + "id": "283-quickstart#hello-zero", "title": "Quickstart", "searchTitle": "hello-zero", "sectionTitle": "hello-zero", @@ -3155,7 +3169,7 @@ "kind": "page" }, { - "id": "282-react#setup", + "id": "284-react#setup", "title": "React", "searchTitle": "Setup", "sectionTitle": "Setup", @@ -3165,7 +3179,7 @@ "kind": "section" }, { - "id": "283-react#usage", + "id": "285-react#usage", "title": "React", "searchTitle": "Usage", "sectionTitle": "Usage", @@ -3175,7 +3189,7 @@ "kind": "section" }, { - "id": "284-react#suspense", + "id": "286-react#suspense", "title": "React", "searchTitle": "Suspense", "sectionTitle": "Suspense", @@ -3185,7 +3199,7 @@ "kind": "section" }, { - "id": "285-react#examples", + "id": "287-react#examples", "title": "React", "searchTitle": "Examples", "sectionTitle": "Examples", @@ -3217,7 +3231,7 @@ "kind": "page" }, { - "id": "286-release-notes/0.1#breaking-changes", + "id": "288-release-notes/0.1#breaking-changes", "title": "Zero 0.1", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -3227,7 +3241,7 @@ "kind": "section" }, { - "id": "287-release-notes/0.1#features", + "id": "289-release-notes/0.1#features", "title": "Zero 0.1", "searchTitle": "Features", "sectionTitle": "Features", @@ -3237,7 +3251,7 @@ "kind": "section" }, { - "id": "288-release-notes/0.1#source-tree-fixes", + "id": "290-release-notes/0.1#source-tree-fixes", "title": "Zero 0.1", "searchTitle": "Source tree fixes", "sectionTitle": "Source tree fixes", @@ -3273,7 +3287,7 @@ "kind": "page" }, { - "id": "289-release-notes/0.10#install", + "id": "291-release-notes/0.10#install", "title": "Zero 0.10", "searchTitle": "Install", "sectionTitle": "Install", @@ -3283,7 +3297,7 @@ "kind": "section" }, { - "id": "290-release-notes/0.10#features", + "id": "292-release-notes/0.10#features", "title": "Zero 0.10", "searchTitle": "Features", "sectionTitle": "Features", @@ -3293,7 +3307,7 @@ "kind": "section" }, { - "id": "291-release-notes/0.10#fixes", + "id": "293-release-notes/0.10#fixes", "title": "Zero 0.10", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3303,7 +3317,7 @@ "kind": "section" }, { - "id": "292-release-notes/0.10#breaking-changes", + "id": "294-release-notes/0.10#breaking-changes", "title": "Zero 0.10", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3339,7 +3353,7 @@ "kind": "page" }, { - "id": "293-release-notes/0.11#install", + "id": "295-release-notes/0.11#install", "title": "Zero 0.11", "searchTitle": "Install", "sectionTitle": "Install", @@ -3349,7 +3363,7 @@ "kind": "section" }, { - "id": "294-release-notes/0.11#features", + "id": "296-release-notes/0.11#features", "title": "Zero 0.11", "searchTitle": "Features", "sectionTitle": "Features", @@ -3359,7 +3373,7 @@ "kind": "section" }, { - "id": "295-release-notes/0.11#fixes", + "id": "297-release-notes/0.11#fixes", "title": "Zero 0.11", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3369,7 +3383,7 @@ "kind": "section" }, { - "id": "296-release-notes/0.11#breaking-changes", + "id": "298-release-notes/0.11#breaking-changes", "title": "Zero 0.11", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3405,7 +3419,7 @@ "kind": "page" }, { - "id": "297-release-notes/0.12#install", + "id": "299-release-notes/0.12#install", "title": "Zero 0.12", "searchTitle": "Install", "sectionTitle": "Install", @@ -3415,7 +3429,7 @@ "kind": "section" }, { - "id": "298-release-notes/0.12#features", + "id": "300-release-notes/0.12#features", "title": "Zero 0.12", "searchTitle": "Features", "sectionTitle": "Features", @@ -3425,7 +3439,7 @@ "kind": "section" }, { - "id": "299-release-notes/0.12#fixes", + "id": "301-release-notes/0.12#fixes", "title": "Zero 0.12", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3435,7 +3449,7 @@ "kind": "section" }, { - "id": "300-release-notes/0.12#breaking-changes", + "id": "302-release-notes/0.12#breaking-changes", "title": "Zero 0.12", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3471,7 +3485,7 @@ "kind": "page" }, { - "id": "301-release-notes/0.13#install", + "id": "303-release-notes/0.13#install", "title": "Zero 0.13", "searchTitle": "Install", "sectionTitle": "Install", @@ -3481,7 +3495,7 @@ "kind": "section" }, { - "id": "302-release-notes/0.13#features", + "id": "304-release-notes/0.13#features", "title": "Zero 0.13", "searchTitle": "Features", "sectionTitle": "Features", @@ -3491,7 +3505,7 @@ "kind": "section" }, { - "id": "303-release-notes/0.13#fixes", + "id": "305-release-notes/0.13#fixes", "title": "Zero 0.13", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3501,7 +3515,7 @@ "kind": "section" }, { - "id": "304-release-notes/0.13#breaking-changes", + "id": "306-release-notes/0.13#breaking-changes", "title": "Zero 0.13", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3537,7 +3551,7 @@ "kind": "page" }, { - "id": "305-release-notes/0.14#install", + "id": "307-release-notes/0.14#install", "title": "Zero 0.14", "searchTitle": "Install", "sectionTitle": "Install", @@ -3547,7 +3561,7 @@ "kind": "section" }, { - "id": "306-release-notes/0.14#features", + "id": "308-release-notes/0.14#features", "title": "Zero 0.14", "searchTitle": "Features", "sectionTitle": "Features", @@ -3557,7 +3571,7 @@ "kind": "section" }, { - "id": "307-release-notes/0.14#fixes", + "id": "309-release-notes/0.14#fixes", "title": "Zero 0.14", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3567,7 +3581,7 @@ "kind": "section" }, { - "id": "308-release-notes/0.14#breaking-changes", + "id": "310-release-notes/0.14#breaking-changes", "title": "Zero 0.14", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3607,7 +3621,7 @@ "kind": "page" }, { - "id": "309-release-notes/0.15#install", + "id": "311-release-notes/0.15#install", "title": "Zero 0.15", "searchTitle": "Install", "sectionTitle": "Install", @@ -3617,7 +3631,7 @@ "kind": "section" }, { - "id": "310-release-notes/0.15#upgrade-guide", + "id": "312-release-notes/0.15#upgrade-guide", "title": "Zero 0.15", "searchTitle": "Upgrade Guide", "sectionTitle": "Upgrade Guide", @@ -3627,7 +3641,7 @@ "kind": "section" }, { - "id": "311-release-notes/0.15#features", + "id": "313-release-notes/0.15#features", "title": "Zero 0.15", "searchTitle": "Features", "sectionTitle": "Features", @@ -3637,7 +3651,7 @@ "kind": "section" }, { - "id": "312-release-notes/0.15#fixes", + "id": "314-release-notes/0.15#fixes", "title": "Zero 0.15", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3647,7 +3661,7 @@ "kind": "section" }, { - "id": "313-release-notes/0.15#breaking-changes", + "id": "315-release-notes/0.15#breaking-changes", "title": "Zero 0.15", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3687,7 +3701,7 @@ "kind": "page" }, { - "id": "314-release-notes/0.16#install", + "id": "316-release-notes/0.16#install", "title": "Zero 0.16", "searchTitle": "Install", "sectionTitle": "Install", @@ -3697,7 +3711,7 @@ "kind": "section" }, { - "id": "315-release-notes/0.16#upgrading", + "id": "317-release-notes/0.16#upgrading", "title": "Zero 0.16", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -3707,7 +3721,7 @@ "kind": "section" }, { - "id": "316-release-notes/0.16#features", + "id": "318-release-notes/0.16#features", "title": "Zero 0.16", "searchTitle": "Features", "sectionTitle": "Features", @@ -3717,7 +3731,7 @@ "kind": "section" }, { - "id": "317-release-notes/0.16#fixes", + "id": "319-release-notes/0.16#fixes", "title": "Zero 0.16", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3727,7 +3741,7 @@ "kind": "section" }, { - "id": "318-release-notes/0.16#breaking-changes", + "id": "320-release-notes/0.16#breaking-changes", "title": "Zero 0.16", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3767,7 +3781,7 @@ "kind": "page" }, { - "id": "319-release-notes/0.17#install", + "id": "321-release-notes/0.17#install", "title": "Zero 0.17", "searchTitle": "Install", "sectionTitle": "Install", @@ -3777,7 +3791,7 @@ "kind": "section" }, { - "id": "320-release-notes/0.17#upgrading", + "id": "322-release-notes/0.17#upgrading", "title": "Zero 0.17", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -3787,7 +3801,7 @@ "kind": "section" }, { - "id": "321-release-notes/0.17#features", + "id": "323-release-notes/0.17#features", "title": "Zero 0.17", "searchTitle": "Features", "sectionTitle": "Features", @@ -3797,7 +3811,7 @@ "kind": "section" }, { - "id": "322-release-notes/0.17#fixes", + "id": "324-release-notes/0.17#fixes", "title": "Zero 0.17", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3807,7 +3821,7 @@ "kind": "section" }, { - "id": "323-release-notes/0.17#breaking-changes", + "id": "325-release-notes/0.17#breaking-changes", "title": "Zero 0.17", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3847,7 +3861,7 @@ "kind": "page" }, { - "id": "324-release-notes/0.18#install", + "id": "326-release-notes/0.18#install", "title": "Zero 0.18", "searchTitle": "Install", "sectionTitle": "Install", @@ -3857,7 +3871,7 @@ "kind": "section" }, { - "id": "325-release-notes/0.18#upgrading", + "id": "327-release-notes/0.18#upgrading", "title": "Zero 0.18", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -3867,7 +3881,7 @@ "kind": "section" }, { - "id": "326-release-notes/0.18#features", + "id": "328-release-notes/0.18#features", "title": "Zero 0.18", "searchTitle": "Features", "sectionTitle": "Features", @@ -3877,7 +3891,7 @@ "kind": "section" }, { - "id": "327-release-notes/0.18#fixes", + "id": "329-release-notes/0.18#fixes", "title": "Zero 0.18", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3887,7 +3901,7 @@ "kind": "section" }, { - "id": "328-release-notes/0.18#breaking-changes", + "id": "330-release-notes/0.18#breaking-changes", "title": "Zero 0.18", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3927,7 +3941,7 @@ "kind": "page" }, { - "id": "329-release-notes/0.19#install", + "id": "331-release-notes/0.19#install", "title": "Zero 0.19", "searchTitle": "Install", "sectionTitle": "Install", @@ -3937,7 +3951,7 @@ "kind": "section" }, { - "id": "330-release-notes/0.19#upgrading", + "id": "332-release-notes/0.19#upgrading", "title": "Zero 0.19", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -3947,7 +3961,7 @@ "kind": "section" }, { - "id": "331-release-notes/0.19#features", + "id": "333-release-notes/0.19#features", "title": "Zero 0.19", "searchTitle": "Features", "sectionTitle": "Features", @@ -3957,7 +3971,7 @@ "kind": "section" }, { - "id": "332-release-notes/0.19#fixes", + "id": "334-release-notes/0.19#fixes", "title": "Zero 0.19", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3967,7 +3981,7 @@ "kind": "section" }, { - "id": "333-release-notes/0.19#breaking-changes", + "id": "335-release-notes/0.19#breaking-changes", "title": "Zero 0.19", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4011,7 +4025,7 @@ "kind": "page" }, { - "id": "334-release-notes/0.2#breaking-changes", + "id": "336-release-notes/0.2#breaking-changes", "title": "Zero 0.2", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -4021,7 +4035,7 @@ "kind": "section" }, { - "id": "335-release-notes/0.2#features", + "id": "337-release-notes/0.2#features", "title": "Zero 0.2", "searchTitle": "Features", "sectionTitle": "Features", @@ -4031,7 +4045,7 @@ "kind": "section" }, { - "id": "336-release-notes/0.2#fixes", + "id": "338-release-notes/0.2#fixes", "title": "Zero 0.2", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4041,7 +4055,7 @@ "kind": "section" }, { - "id": "337-release-notes/0.2#docs", + "id": "339-release-notes/0.2#docs", "title": "Zero 0.2", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4051,7 +4065,7 @@ "kind": "section" }, { - "id": "338-release-notes/0.2#source-tree-fixes", + "id": "340-release-notes/0.2#source-tree-fixes", "title": "Zero 0.2", "searchTitle": "Source tree fixes", "sectionTitle": "Source tree fixes", @@ -4061,7 +4075,7 @@ "kind": "section" }, { - "id": "339-release-notes/0.2#zbugs", + "id": "341-release-notes/0.2#zbugs", "title": "Zero 0.2", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4101,7 +4115,7 @@ "kind": "page" }, { - "id": "340-release-notes/0.20#install", + "id": "342-release-notes/0.20#install", "title": "Zero 0.20", "searchTitle": "Install", "sectionTitle": "Install", @@ -4111,7 +4125,7 @@ "kind": "section" }, { - "id": "341-release-notes/0.20#upgrading", + "id": "343-release-notes/0.20#upgrading", "title": "Zero 0.20", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -4121,7 +4135,7 @@ "kind": "section" }, { - "id": "342-release-notes/0.20#features", + "id": "344-release-notes/0.20#features", "title": "Zero 0.20", "searchTitle": "Features", "sectionTitle": "Features", @@ -4131,7 +4145,7 @@ "kind": "section" }, { - "id": "343-release-notes/0.20#fixes", + "id": "345-release-notes/0.20#fixes", "title": "Zero 0.20", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4141,7 +4155,7 @@ "kind": "section" }, { - "id": "344-release-notes/0.20#breaking-changes", + "id": "346-release-notes/0.20#breaking-changes", "title": "Zero 0.20", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4181,7 +4195,7 @@ "kind": "page" }, { - "id": "345-release-notes/0.21#install", + "id": "347-release-notes/0.21#install", "title": "Zero 0.21", "searchTitle": "Install", "sectionTitle": "Install", @@ -4191,7 +4205,7 @@ "kind": "section" }, { - "id": "346-release-notes/0.21#upgrading", + "id": "348-release-notes/0.21#upgrading", "title": "Zero 0.21", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -4201,7 +4215,7 @@ "kind": "section" }, { - "id": "347-release-notes/0.21#features", + "id": "349-release-notes/0.21#features", "title": "Zero 0.21", "searchTitle": "Features", "sectionTitle": "Features", @@ -4211,7 +4225,7 @@ "kind": "section" }, { - "id": "348-release-notes/0.21#fixes", + "id": "350-release-notes/0.21#fixes", "title": "Zero 0.21", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4221,7 +4235,7 @@ "kind": "section" }, { - "id": "349-release-notes/0.21#breaking-changes", + "id": "351-release-notes/0.21#breaking-changes", "title": "Zero 0.21", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4273,7 +4287,7 @@ "kind": "page" }, { - "id": "350-release-notes/0.22#install", + "id": "352-release-notes/0.22#install", "title": "Zero 0.22", "searchTitle": "Install", "sectionTitle": "Install", @@ -4283,7 +4297,7 @@ "kind": "section" }, { - "id": "351-release-notes/0.22#upgrading", + "id": "353-release-notes/0.22#upgrading", "title": "Zero 0.22", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -4293,7 +4307,7 @@ "kind": "section" }, { - "id": "352-release-notes/0.22#how-ttls-used-to-work", + "id": "354-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", @@ -4303,7 +4317,7 @@ "kind": "section" }, { - "id": "353-release-notes/0.22#how-ttls-work-now", + "id": "355-release-notes/0.22#how-ttls-work-now", "title": "Zero 0.22", "searchTitle": "How TTLs Work Now", "sectionTitle": "How TTLs Work Now", @@ -4313,7 +4327,7 @@ "kind": "section" }, { - "id": "354-release-notes/0.22#using-new-ttls", + "id": "356-release-notes/0.22#using-new-ttls", "title": "Zero 0.22", "searchTitle": "Using New TTLs", "sectionTitle": "Using New TTLs", @@ -4323,7 +4337,7 @@ "kind": "section" }, { - "id": "355-release-notes/0.22#features", + "id": "357-release-notes/0.22#features", "title": "Zero 0.22", "searchTitle": "Features", "sectionTitle": "Features", @@ -4333,7 +4347,7 @@ "kind": "section" }, { - "id": "356-release-notes/0.22#fixes", + "id": "358-release-notes/0.22#fixes", "title": "Zero 0.22", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4343,7 +4357,7 @@ "kind": "section" }, { - "id": "357-release-notes/0.22#breaking-changes", + "id": "359-release-notes/0.22#breaking-changes", "title": "Zero 0.22", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4387,7 +4401,7 @@ "kind": "page" }, { - "id": "358-release-notes/0.23#install", + "id": "360-release-notes/0.23#install", "title": "Zero 0.23", "searchTitle": "Install", "sectionTitle": "Install", @@ -4397,7 +4411,7 @@ "kind": "section" }, { - "id": "359-release-notes/0.23#upgrading", + "id": "361-release-notes/0.23#upgrading", "title": "Zero 0.23", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -4407,7 +4421,7 @@ "kind": "section" }, { - "id": "360-release-notes/0.23#features", + "id": "362-release-notes/0.23#features", "title": "Zero 0.23", "searchTitle": "Features", "sectionTitle": "Features", @@ -4417,7 +4431,7 @@ "kind": "section" }, { - "id": "361-release-notes/0.23#fixes", + "id": "363-release-notes/0.23#fixes", "title": "Zero 0.23", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4427,7 +4441,7 @@ "kind": "section" }, { - "id": "362-release-notes/0.23#zbugs", + "id": "364-release-notes/0.23#zbugs", "title": "Zero 0.23", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4437,7 +4451,7 @@ "kind": "section" }, { - "id": "363-release-notes/0.23#breaking-changes", + "id": "365-release-notes/0.23#breaking-changes", "title": "Zero 0.23", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4477,7 +4491,7 @@ "kind": "page" }, { - "id": "364-release-notes/0.24#installation", + "id": "366-release-notes/0.24#installation", "title": "Zero 0.24", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -4487,7 +4501,7 @@ "kind": "section" }, { - "id": "365-release-notes/0.24#features", + "id": "367-release-notes/0.24#features", "title": "Zero 0.24", "searchTitle": "Features", "sectionTitle": "Features", @@ -4497,7 +4511,7 @@ "kind": "section" }, { - "id": "366-release-notes/0.24#fixes", + "id": "368-release-notes/0.24#fixes", "title": "Zero 0.24", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4507,7 +4521,7 @@ "kind": "section" }, { - "id": "367-release-notes/0.24#breaking-changes", + "id": "369-release-notes/0.24#breaking-changes", "title": "Zero 0.24", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4517,7 +4531,7 @@ "kind": "section" }, { - "id": "368-release-notes/0.24#example-upgrades", + "id": "370-release-notes/0.24#example-upgrades", "title": "Zero 0.24", "searchTitle": "Example Upgrades", "sectionTitle": "Example Upgrades", @@ -4565,7 +4579,7 @@ "kind": "page" }, { - "id": "369-release-notes/0.25#installation", + "id": "371-release-notes/0.25#installation", "title": "Zero 0.25", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -4575,7 +4589,7 @@ "kind": "section" }, { - "id": "370-release-notes/0.25#overview", + "id": "372-release-notes/0.25#overview", "title": "Zero 0.25", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -4585,7 +4599,7 @@ "kind": "section" }, { - "id": "371-release-notes/0.25#upgrading", + "id": "373-release-notes/0.25#upgrading", "title": "Zero 0.25", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -4595,7 +4609,7 @@ "kind": "section" }, { - "id": "372-release-notes/0.25#features", + "id": "374-release-notes/0.25#features", "title": "Zero 0.25", "searchTitle": "Features", "sectionTitle": "Features", @@ -4605,7 +4619,7 @@ "kind": "section" }, { - "id": "373-release-notes/0.25#performance", + "id": "375-release-notes/0.25#performance", "title": "Zero 0.25", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -4615,7 +4629,7 @@ "kind": "section" }, { - "id": "374-release-notes/0.25#fixes", + "id": "376-release-notes/0.25#fixes", "title": "Zero 0.25", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4625,7 +4639,7 @@ "kind": "section" }, { - "id": "375-release-notes/0.25#breaking-changes", + "id": "377-release-notes/0.25#breaking-changes", "title": "Zero 0.25", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4661,7 +4675,7 @@ "kind": "page" }, { - "id": "376-release-notes/0.26#installation", + "id": "378-release-notes/0.26#installation", "title": "Zero 0.26", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -4671,7 +4685,7 @@ "kind": "section" }, { - "id": "377-release-notes/0.26#features", + "id": "379-release-notes/0.26#features", "title": "Zero 0.26", "searchTitle": "Features", "sectionTitle": "Features", @@ -4681,7 +4695,7 @@ "kind": "section" }, { - "id": "378-release-notes/0.26#fixes", + "id": "380-release-notes/0.26#fixes", "title": "Zero 0.26", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4691,7 +4705,7 @@ "kind": "section" }, { - "id": "379-release-notes/0.26#breaking-changes", + "id": "381-release-notes/0.26#breaking-changes", "title": "Zero 0.26", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4735,7 +4749,7 @@ "kind": "page" }, { - "id": "380-release-notes/0.3#install", + "id": "382-release-notes/0.3#install", "title": "Zero 0.3", "searchTitle": "Install", "sectionTitle": "Install", @@ -4745,7 +4759,7 @@ "kind": "section" }, { - "id": "381-release-notes/0.3#breaking-changes", + "id": "383-release-notes/0.3#breaking-changes", "title": "Zero 0.3", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -4755,7 +4769,7 @@ "kind": "section" }, { - "id": "382-release-notes/0.3#features", + "id": "384-release-notes/0.3#features", "title": "Zero 0.3", "searchTitle": "Features", "sectionTitle": "Features", @@ -4765,7 +4779,7 @@ "kind": "section" }, { - "id": "383-release-notes/0.3#fixes", + "id": "385-release-notes/0.3#fixes", "title": "Zero 0.3", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4775,7 +4789,7 @@ "kind": "section" }, { - "id": "384-release-notes/0.3#docs", + "id": "386-release-notes/0.3#docs", "title": "Zero 0.3", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4785,7 +4799,7 @@ "kind": "section" }, { - "id": "385-release-notes/0.3#zbugs", + "id": "387-release-notes/0.3#zbugs", "title": "Zero 0.3", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4829,7 +4843,7 @@ "kind": "page" }, { - "id": "386-release-notes/0.4#install", + "id": "388-release-notes/0.4#install", "title": "Zero 0.4", "searchTitle": "Install", "sectionTitle": "Install", @@ -4839,7 +4853,7 @@ "kind": "section" }, { - "id": "387-release-notes/0.4#breaking-changes", + "id": "389-release-notes/0.4#breaking-changes", "title": "Zero 0.4", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -4849,7 +4863,7 @@ "kind": "section" }, { - "id": "388-release-notes/0.4#added-or--and--and-not-to-zql-documentation", + "id": "390-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).", @@ -4859,7 +4873,7 @@ "kind": "section" }, { - "id": "389-release-notes/0.4#fixes", + "id": "391-release-notes/0.4#fixes", "title": "Zero 0.4", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4869,7 +4883,7 @@ "kind": "section" }, { - "id": "390-release-notes/0.4#docs", + "id": "392-release-notes/0.4#docs", "title": "Zero 0.4", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4879,7 +4893,7 @@ "kind": "section" }, { - "id": "391-release-notes/0.4#zbugs", + "id": "393-release-notes/0.4#zbugs", "title": "Zero 0.4", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4923,7 +4937,7 @@ "kind": "page" }, { - "id": "392-release-notes/0.5#install", + "id": "394-release-notes/0.5#install", "title": "Zero 0.5", "searchTitle": "Install", "sectionTitle": "Install", @@ -4933,7 +4947,7 @@ "kind": "section" }, { - "id": "393-release-notes/0.5#breaking-changes", + "id": "395-release-notes/0.5#breaking-changes", "title": "Zero 0.5", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -4943,7 +4957,7 @@ "kind": "section" }, { - "id": "394-release-notes/0.5#features", + "id": "396-release-notes/0.5#features", "title": "Zero 0.5", "searchTitle": "Features", "sectionTitle": "Features", @@ -4953,7 +4967,7 @@ "kind": "section" }, { - "id": "395-release-notes/0.5#fixes", + "id": "397-release-notes/0.5#fixes", "title": "Zero 0.5", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4963,7 +4977,7 @@ "kind": "section" }, { - "id": "396-release-notes/0.5#docs", + "id": "398-release-notes/0.5#docs", "title": "Zero 0.5", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4973,7 +4987,7 @@ "kind": "section" }, { - "id": "397-release-notes/0.5#zbugs", + "id": "399-release-notes/0.5#zbugs", "title": "Zero 0.5", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -5017,7 +5031,7 @@ "kind": "page" }, { - "id": "398-release-notes/0.6#install", + "id": "400-release-notes/0.6#install", "title": "Zero 0.6", "searchTitle": "Install", "sectionTitle": "Install", @@ -5027,7 +5041,7 @@ "kind": "section" }, { - "id": "399-release-notes/0.6#upgrade-guide", + "id": "401-release-notes/0.6#upgrade-guide", "title": "Zero 0.6", "searchTitle": "Upgrade Guide", "sectionTitle": "Upgrade Guide", @@ -5037,7 +5051,7 @@ "kind": "section" }, { - "id": "400-release-notes/0.6#breaking-changes", + "id": "402-release-notes/0.6#breaking-changes", "title": "Zero 0.6", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5047,7 +5061,7 @@ "kind": "section" }, { - "id": "401-release-notes/0.6#features", + "id": "403-release-notes/0.6#features", "title": "Zero 0.6", "searchTitle": "Features", "sectionTitle": "Features", @@ -5057,7 +5071,7 @@ "kind": "section" }, { - "id": "402-release-notes/0.6#zbugs", + "id": "404-release-notes/0.6#zbugs", "title": "Zero 0.6", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -5067,7 +5081,7 @@ "kind": "section" }, { - "id": "403-release-notes/0.6#docs", + "id": "405-release-notes/0.6#docs", "title": "Zero 0.6", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -5107,7 +5121,7 @@ "kind": "page" }, { - "id": "404-release-notes/0.7#install", + "id": "406-release-notes/0.7#install", "title": "Zero 0.7", "searchTitle": "Install", "sectionTitle": "Install", @@ -5117,7 +5131,7 @@ "kind": "section" }, { - "id": "405-release-notes/0.7#features", + "id": "407-release-notes/0.7#features", "title": "Zero 0.7", "searchTitle": "Features", "sectionTitle": "Features", @@ -5127,7 +5141,7 @@ "kind": "section" }, { - "id": "406-release-notes/0.7#breaking-changes", + "id": "408-release-notes/0.7#breaking-changes", "title": "Zero 0.7", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5137,7 +5151,7 @@ "kind": "section" }, { - "id": "407-release-notes/0.7#zbugs", + "id": "409-release-notes/0.7#zbugs", "title": "Zero 0.7", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -5147,7 +5161,7 @@ "kind": "section" }, { - "id": "408-release-notes/0.7#docs", + "id": "410-release-notes/0.7#docs", "title": "Zero 0.7", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -5183,7 +5197,7 @@ "kind": "page" }, { - "id": "409-release-notes/0.8#install", + "id": "411-release-notes/0.8#install", "title": "Zero 0.8", "searchTitle": "Install", "sectionTitle": "Install", @@ -5193,7 +5207,7 @@ "kind": "section" }, { - "id": "410-release-notes/0.8#features", + "id": "412-release-notes/0.8#features", "title": "Zero 0.8", "searchTitle": "Features", "sectionTitle": "Features", @@ -5203,7 +5217,7 @@ "kind": "section" }, { - "id": "411-release-notes/0.8#fixes", + "id": "413-release-notes/0.8#fixes", "title": "Zero 0.8", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5213,7 +5227,7 @@ "kind": "section" }, { - "id": "412-release-notes/0.8#breaking-changes", + "id": "414-release-notes/0.8#breaking-changes", "title": "Zero 0.8", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5249,7 +5263,7 @@ "kind": "page" }, { - "id": "413-release-notes/0.9#install", + "id": "415-release-notes/0.9#install", "title": "Zero 0.9", "searchTitle": "Install", "sectionTitle": "Install", @@ -5259,7 +5273,7 @@ "kind": "section" }, { - "id": "414-release-notes/0.9#features", + "id": "416-release-notes/0.9#features", "title": "Zero 0.9", "searchTitle": "Features", "sectionTitle": "Features", @@ -5269,7 +5283,7 @@ "kind": "section" }, { - "id": "415-release-notes/0.9#fixes", + "id": "417-release-notes/0.9#fixes", "title": "Zero 0.9", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5279,7 +5293,7 @@ "kind": "section" }, { - "id": "416-release-notes/0.9#breaking-changes", + "id": "418-release-notes/0.9#breaking-changes", "title": "Zero 0.9", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5319,7 +5333,7 @@ "kind": "page" }, { - "id": "417-release-notes/1.0#installation", + "id": "419-release-notes/1.0#installation", "title": "Zero 1.0", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5329,7 +5343,7 @@ "kind": "section" }, { - "id": "418-release-notes/1.0#overview", + "id": "420-release-notes/1.0#overview", "title": "Zero 1.0", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -5339,7 +5353,7 @@ "kind": "section" }, { - "id": "419-release-notes/1.0#features", + "id": "421-release-notes/1.0#features", "title": "Zero 1.0", "searchTitle": "Features", "sectionTitle": "Features", @@ -5349,7 +5363,7 @@ "kind": "section" }, { - "id": "420-release-notes/1.0#fixes", + "id": "422-release-notes/1.0#fixes", "title": "Zero 1.0", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5359,7 +5373,7 @@ "kind": "section" }, { - "id": "421-release-notes/1.0#breaking-changes", + "id": "423-release-notes/1.0#breaking-changes", "title": "Zero 1.0", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5395,7 +5409,7 @@ "kind": "page" }, { - "id": "422-release-notes/1.1#installation", + "id": "424-release-notes/1.1#installation", "title": "Zero 1.1", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5405,7 +5419,7 @@ "kind": "section" }, { - "id": "423-release-notes/1.1#features", + "id": "425-release-notes/1.1#features", "title": "Zero 1.1", "searchTitle": "Features", "sectionTitle": "Features", @@ -5415,7 +5429,7 @@ "kind": "section" }, { - "id": "424-release-notes/1.1#fixes", + "id": "426-release-notes/1.1#fixes", "title": "Zero 1.1", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5425,7 +5439,7 @@ "kind": "section" }, { - "id": "425-release-notes/1.1#breaking-changes", + "id": "427-release-notes/1.1#breaking-changes", "title": "Zero 1.1", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5465,7 +5479,7 @@ "kind": "page" }, { - "id": "426-release-notes/1.2#installation", + "id": "428-release-notes/1.2#installation", "title": "Zero 1.2", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5475,7 +5489,7 @@ "kind": "section" }, { - "id": "427-release-notes/1.2#features", + "id": "429-release-notes/1.2#features", "title": "Zero 1.2", "searchTitle": "Features", "sectionTitle": "Features", @@ -5485,7 +5499,7 @@ "kind": "section" }, { - "id": "428-release-notes/1.2#performance", + "id": "430-release-notes/1.2#performance", "title": "Zero 1.2", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -5495,7 +5509,7 @@ "kind": "section" }, { - "id": "429-release-notes/1.2#fixes", + "id": "431-release-notes/1.2#fixes", "title": "Zero 1.2", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5505,7 +5519,7 @@ "kind": "section" }, { - "id": "430-release-notes/1.2#breaking-changes", + "id": "432-release-notes/1.2#breaking-changes", "title": "Zero 1.2", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5545,7 +5559,7 @@ "kind": "page" }, { - "id": "431-release-notes/1.3#installation", + "id": "433-release-notes/1.3#installation", "title": "Zero 1.3", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5555,7 +5569,7 @@ "kind": "section" }, { - "id": "432-release-notes/1.3#features", + "id": "434-release-notes/1.3#features", "title": "Zero 1.3", "searchTitle": "Features", "sectionTitle": "Features", @@ -5565,7 +5579,7 @@ "kind": "section" }, { - "id": "433-release-notes/1.3#performance", + "id": "435-release-notes/1.3#performance", "title": "Zero 1.3", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -5575,7 +5589,7 @@ "kind": "section" }, { - "id": "434-release-notes/1.3#fixes", + "id": "436-release-notes/1.3#fixes", "title": "Zero 1.3", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5585,7 +5599,7 @@ "kind": "section" }, { - "id": "435-release-notes/1.3#breaking-changes", + "id": "437-release-notes/1.3#breaking-changes", "title": "Zero 1.3", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5633,7 +5647,7 @@ "kind": "page" }, { - "id": "436-release-notes/1.4#installation", + "id": "438-release-notes/1.4#installation", "title": "Zero 1.4", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5643,7 +5657,7 @@ "kind": "section" }, { - "id": "437-release-notes/1.4#upgrading", + "id": "439-release-notes/1.4#upgrading", "title": "Zero 1.4", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -5653,7 +5667,7 @@ "kind": "section" }, { - "id": "438-release-notes/1.4#userid-anon", + "id": "440-release-notes/1.4#userid-anon", "title": "Zero 1.4", "searchTitle": "userID: \"anon\"", "sectionTitle": "userID: \"anon\"", @@ -5663,7 +5677,7 @@ "kind": "section" }, { - "id": "439-release-notes/1.4#features", + "id": "441-release-notes/1.4#features", "title": "Zero 1.4", "searchTitle": "Features", "sectionTitle": "Features", @@ -5673,7 +5687,7 @@ "kind": "section" }, { - "id": "440-release-notes/1.4#performance", + "id": "442-release-notes/1.4#performance", "title": "Zero 1.4", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -5683,7 +5697,7 @@ "kind": "section" }, { - "id": "441-release-notes/1.4#fixes", + "id": "443-release-notes/1.4#fixes", "title": "Zero 1.4", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5693,7 +5707,7 @@ "kind": "section" }, { - "id": "442-release-notes/1.4#breaking-changes", + "id": "444-release-notes/1.4#breaking-changes", "title": "Zero 1.4", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5745,7 +5759,7 @@ "kind": "page" }, { - "id": "443-release-notes/1.5#installation", + "id": "445-release-notes/1.5#installation", "title": "Zero 1.5", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5755,7 +5769,7 @@ "kind": "section" }, { - "id": "444-release-notes/1.5#upgrading", + "id": "446-release-notes/1.5#upgrading", "title": "Zero 1.5", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -5765,7 +5779,7 @@ "kind": "section" }, { - "id": "445-release-notes/1.5#authenticated-client-groups", + "id": "447-release-notes/1.5#authenticated-client-groups", "title": "Zero 1.5", "searchTitle": "Authenticated Client Groups", "sectionTitle": "Authenticated Client Groups", @@ -5775,7 +5789,7 @@ "kind": "section" }, { - "id": "446-release-notes/1.5#deploy-order", + "id": "448-release-notes/1.5#deploy-order", "title": "Zero 1.5", "searchTitle": "Deploy Order", "sectionTitle": "Deploy Order", @@ -5785,7 +5799,7 @@ "kind": "section" }, { - "id": "447-release-notes/1.5#features", + "id": "449-release-notes/1.5#features", "title": "Zero 1.5", "searchTitle": "Features", "sectionTitle": "Features", @@ -5795,7 +5809,7 @@ "kind": "section" }, { - "id": "448-release-notes/1.5#performance", + "id": "450-release-notes/1.5#performance", "title": "Zero 1.5", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -5805,7 +5819,7 @@ "kind": "section" }, { - "id": "449-release-notes/1.5#fixes", + "id": "451-release-notes/1.5#fixes", "title": "Zero 1.5", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5815,7 +5829,7 @@ "kind": "section" }, { - "id": "450-release-notes/1.5#breaking-changes", + "id": "452-release-notes/1.5#breaking-changes", "title": "Zero 1.5", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5863,7 +5877,7 @@ "kind": "page" }, { - "id": "451-release-notes/1.6#installation", + "id": "453-release-notes/1.6#installation", "title": "Zero 1.6", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5873,7 +5887,7 @@ "kind": "section" }, { - "id": "452-release-notes/1.6#upgrading", + "id": "454-release-notes/1.6#upgrading", "title": "Zero 1.6", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -5883,7 +5897,7 @@ "kind": "section" }, { - "id": "453-release-notes/1.6#planetscale-failover", + "id": "455-release-notes/1.6#planetscale-failover", "title": "Zero 1.6", "searchTitle": "PlanetScale Failover", "sectionTitle": "PlanetScale Failover", @@ -5893,7 +5907,7 @@ "kind": "section" }, { - "id": "454-release-notes/1.6#features", + "id": "456-release-notes/1.6#features", "title": "Zero 1.6", "searchTitle": "Features", "sectionTitle": "Features", @@ -5903,7 +5917,7 @@ "kind": "section" }, { - "id": "455-release-notes/1.6#performance", + "id": "457-release-notes/1.6#performance", "title": "Zero 1.6", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -5913,7 +5927,7 @@ "kind": "section" }, { - "id": "456-release-notes/1.6#fixes", + "id": "458-release-notes/1.6#fixes", "title": "Zero 1.6", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5923,7 +5937,7 @@ "kind": "section" }, { - "id": "457-release-notes/1.6#breaking-changes", + "id": "459-release-notes/1.6#breaking-changes", "title": "Zero 1.6", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5975,7 +5989,7 @@ "kind": "page" }, { - "id": "458-release-notes/1.7#installation", + "id": "460-release-notes/1.7#installation", "title": "Zero 1.7", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5985,7 +5999,7 @@ "kind": "section" }, { - "id": "459-release-notes/1.7#overview", + "id": "461-release-notes/1.7#overview", "title": "Zero 1.7", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -5995,7 +6009,7 @@ "kind": "section" }, { - "id": "460-release-notes/1.7#features", + "id": "462-release-notes/1.7#features", "title": "Zero 1.7", "searchTitle": "Features", "sectionTitle": "Features", @@ -6005,7 +6019,7 @@ "kind": "section" }, { - "id": "461-release-notes/1.7#performance", + "id": "463-release-notes/1.7#performance", "title": "Zero 1.7", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -6015,7 +6029,7 @@ "kind": "section" }, { - "id": "462-release-notes/1.7#replication", + "id": "464-release-notes/1.7#replication", "title": "Zero 1.7", "searchTitle": "Replication", "sectionTitle": "Replication", @@ -6025,7 +6039,7 @@ "kind": "section" }, { - "id": "463-release-notes/1.7#flipped-exists-queries", + "id": "465-release-notes/1.7#flipped-exists-queries", "title": "Zero 1.7", "searchTitle": "Flipped Exists Queries", "sectionTitle": "Flipped Exists Queries", @@ -6035,7 +6049,7 @@ "kind": "section" }, { - "id": "464-release-notes/1.7#fixes", + "id": "466-release-notes/1.7#fixes", "title": "Zero 1.7", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -6045,7 +6059,7 @@ "kind": "section" }, { - "id": "465-release-notes/1.7#breaking-changes", + "id": "467-release-notes/1.7#breaking-changes", "title": "Zero 1.7", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -6059,7 +6073,7 @@ "title": "Zero 1.8", "searchTitle": "Zero 1.8", "url": "/docs/release-notes/1.8", - "content": "Installation npm install @rocicorp/zero@1.8 You can now use zero-cache from GHCR: docker pull rocicorp/zero:1.8.0 # or docker pull ghcr.io/rocicorp/zero:1.8.0 Overview Zero 1.8 improves observability, performance, and reliability. Features Request-header forwarding: zero-cache can forward selected WebSocket upgrade headers to custom APIs using ZERO_MUTATE_ALLOWED_REQUEST_HEADERS and ZERO_QUERY_ALLOWED_REQUEST_HEADERS. (#6144, thanks @tjenkinson!) GHCR Docker images: Zero images are now published to ghcr.io/rocicorp/zero as well as Docker Hub. (#6161) Mutator result type: MutatorResult is now exported from @rocicorp/zero for typing helpers that await .client or .server. (#6223) Operational metrics: zero-cache adds metrics for API calls and startup, initial sync and replication slots, and Litestream backup and restore. (#6203, #6208, #6191, #6199, #6210) Stability metrics: New serving-lag, CVR, and WebSocket metrics and replication flow-control metrics help diagnose delayed updates, reconnects, and backpressure. (#6157, #6214, #6207) Performance Zero 1.8 speeds up replication of large transactions, maintenance of queries with orderBy() and limit(), and local ZQL queries with related(). Replicating Large Transactions Bulk imports, backfills, or migrations often change thousands of rows in a single transaction. Zero 1.8 replicates these transactions about 50% faster. Maintaining orderBy() + limit() Queries For example, an app might show the first 50 open issues, ordered by priority: zql.issue .where('workspaceID', workspaceID) .where('status', 'open') .orderBy('priority', 'desc') .orderBy('created', 'asc') .limit(50) If only a few issues are open, the 50th matching issue can be far down the orderBy() index. When changes move rows in or out of the first 50 results, Zero may need to read more rows after the last row currently returned. Previously, that read could start at the beginning of the index, even though rows before the last returned row could not be next. In 1.8, Zero starts SQLite at the last returned row's sort key, so SQLite can seek into the index and scan from there. When the last returned row was 50,000 rows into the index, incremental updates rose from 248 to 521 updates/sec. At the end of a 100,000-row index, they rose from 250 to 14,977 updates/sec. Running Local ZQL Queries When a local query first runs, Zero hydrates it from data already on the client. Zero 1.8 makes that faster, especially for queries with relationships. For example: zql.issue.related('creator').related('comments') Hydrating 500 issues with creators and comments fell from 2.54 ms to 1.97 ms. Hydrating 500 issues with creators fell from 1.11 ms to 0.84 ms. Fixes Logical replication now reconnects when the inbound Postgres stream goes silent. Postgres writes no longer use sockets after disconnection. The Drizzle adapter now handles array-mode results from Drizzle 1.0 RC prepareQuery. (thanks @typedrat!) z2s now compiles queries using start, and SQLite fetches handle null start-cursor fields. Queries no longer appear complete with stale or empty results after reconnect. React Native reads now work with op-sqlite v17. View-syncers no longer fail while the first backup is uploading or retry before a restorable backup exists on cold start. Zero Docker images now choose the correct default sync-worker count. Change-stream catch-up now respects flow control, preventing unbounded in-memory backlogs. Breaking Changes None.", + "content": "Installation npm install @rocicorp/zero@1.8 You can now use zero-cache from GHCR: docker pull rocicorp/zero:1.8.0 # or docker pull ghcr.io/rocicorp/zero:1.8.0 Overview Zero 1.8 improves observability, performance, and reliability. Features Request-header forwarding: zero-cache can forward selected WebSocket upgrade headers to custom APIs using ZERO_MUTATE_ALLOWED_REQUEST_HEADERS and ZERO_QUERY_ALLOWED_REQUEST_HEADERS. (#6144, thanks @tjenkinson!) GHCR Docker images: Zero images are now published to ghcr.io/rocicorp/zero as well as Docker Hub. (#6161) Mutator result type: MutatorResult is now exported from @rocicorp/zero for typing helpers that await .client or .server. (#6223) Operational metrics: zero-cache adds metrics for API calls and startup, initial sync and replication slots, and Litestream backup and restore. (#6203, #6208, #6191, #6199, #6210) Stability metrics: New serving-lag, CVR, and WebSocket metrics and replication flow-control metrics help diagnose delayed updates, reconnects, and backpressure. (#6157, #6214, #6207) Performance Zero 1.8 speeds up replication of large transactions, maintenance of queries that use limit(), and client-side query hydration. Replicating Large Transactions Bulk imports, backfills, or migrations often change thousands of rows in a single Postgres transaction. These large transactions replicate about 1.5x faster in Zero 1.8. Maintaining limit() Queries Consider a query like this: zql.issue .where('status', 'open') .orderBy('created', 'asc') .limit(50) Zero can fulfill this query using an index on either status or created. If it decides to use the created index, Zero might have to consider many rows before it finds 50 matches. That is unavoidable. But when changes to the data move rows in or out of the first 50 results, Zero 1.7 repeated the work to find the first 50 results, making incremental updates slower than necessary. Zero 1.8 fixes this. In benchmarks, when the last returned row was 50,000 rows into the index, incremental updates were 2x faster in Zero 1.8. When it was 100,000 rows in, updates were over 50x faster in Zero 1.8. Client-Side Hydration Zero runs queries first on the client, then on the server. The initial client-side hydration got faster in Zero 1.8. For example, this query returns initial data from client about 1.3x faster in Zero 1.8: zql.issue.related('creator').related('comments') Fixes Logical replication now reconnects when the inbound Postgres stream goes silent. Postgres writes no longer use sockets after disconnection. The Drizzle adapter now handles array-mode results from Drizzle 1.0 RC prepareQuery. (thanks @typedrat!) z2s now compiles queries using start, and SQLite fetches handle null start-cursor fields. Queries no longer appear complete with stale or empty results after reconnect. React Native reads now work with op-sqlite v17. View-syncers no longer fail while the first backup is uploading or retry before a restorable backup exists on cold start. Zero Docker images now choose the correct default sync-worker count. Change-stream catch-up now respects flow control, preventing unbounded in-memory backlogs. Breaking Changes None.", "headings": [ { "text": "Installation", @@ -6082,12 +6096,12 @@ "id": "replicating-large-transactions" }, { - "text": "Maintaining orderBy() + limit() Queries", - "id": "maintaining-orderby--limit-queries" + "text": "Maintaining limit() Queries", + "id": "maintaining-limit-queries" }, { - "text": "Running Local ZQL Queries", - "id": "running-local-zql-queries" + "text": "Client-Side Hydration", + "id": "client-side-hydration" }, { "text": "Fixes", @@ -6101,7 +6115,7 @@ "kind": "page" }, { - "id": "466-release-notes/1.8#installation", + "id": "468-release-notes/1.8#installation", "title": "Zero 1.8", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -6111,7 +6125,7 @@ "kind": "section" }, { - "id": "467-release-notes/1.8#overview", + "id": "469-release-notes/1.8#overview", "title": "Zero 1.8", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -6121,7 +6135,7 @@ "kind": "section" }, { - "id": "468-release-notes/1.8#features", + "id": "470-release-notes/1.8#features", "title": "Zero 1.8", "searchTitle": "Features", "sectionTitle": "Features", @@ -6131,47 +6145,47 @@ "kind": "section" }, { - "id": "469-release-notes/1.8#performance", + "id": "471-release-notes/1.8#performance", "title": "Zero 1.8", "searchTitle": "Performance", "sectionTitle": "Performance", "sectionId": "performance", "url": "/docs/release-notes/1.8", - "content": "Zero 1.8 speeds up replication of large transactions, maintenance of queries with orderBy() and limit(), and local ZQL queries with related(). Replicating Large Transactions Bulk imports, backfills, or migrations often change thousands of rows in a single transaction. Zero 1.8 replicates these transactions about 50% faster. Maintaining orderBy() + limit() Queries For example, an app might show the first 50 open issues, ordered by priority: zql.issue .where('workspaceID', workspaceID) .where('status', 'open') .orderBy('priority', 'desc') .orderBy('created', 'asc') .limit(50) If only a few issues are open, the 50th matching issue can be far down the orderBy() index. When changes move rows in or out of the first 50 results, Zero may need to read more rows after the last row currently returned. Previously, that read could start at the beginning of the index, even though rows before the last returned row could not be next. In 1.8, Zero starts SQLite at the last returned row's sort key, so SQLite can seek into the index and scan from there. When the last returned row was 50,000 rows into the index, incremental updates rose from 248 to 521 updates/sec. At the end of a 100,000-row index, they rose from 250 to 14,977 updates/sec. Running Local ZQL Queries When a local query first runs, Zero hydrates it from data already on the client. Zero 1.8 makes that faster, especially for queries with relationships. For example: zql.issue.related('creator').related('comments') Hydrating 500 issues with creators and comments fell from 2.54 ms to 1.97 ms. Hydrating 500 issues with creators fell from 1.11 ms to 0.84 ms.", + "content": "Zero 1.8 speeds up replication of large transactions, maintenance of queries that use limit(), and client-side query hydration. Replicating Large Transactions Bulk imports, backfills, or migrations often change thousands of rows in a single Postgres transaction. These large transactions replicate about 1.5x faster in Zero 1.8. Maintaining limit() Queries Consider a query like this: zql.issue .where('status', 'open') .orderBy('created', 'asc') .limit(50) Zero can fulfill this query using an index on either status or created. If it decides to use the created index, Zero might have to consider many rows before it finds 50 matches. That is unavoidable. But when changes to the data move rows in or out of the first 50 results, Zero 1.7 repeated the work to find the first 50 results, making incremental updates slower than necessary. Zero 1.8 fixes this. In benchmarks, when the last returned row was 50,000 rows into the index, incremental updates were 2x faster in Zero 1.8. When it was 100,000 rows in, updates were over 50x faster in Zero 1.8. Client-Side Hydration Zero runs queries first on the client, then on the server. The initial client-side hydration got faster in Zero 1.8. For example, this query returns initial data from client about 1.3x faster in Zero 1.8: zql.issue.related('creator').related('comments')", "kind": "section" }, { - "id": "470-release-notes/1.8#replicating-large-transactions", + "id": "472-release-notes/1.8#replicating-large-transactions", "title": "Zero 1.8", "searchTitle": "Replicating Large Transactions", "sectionTitle": "Replicating Large Transactions", "sectionId": "replicating-large-transactions", "url": "/docs/release-notes/1.8", - "content": "Bulk imports, backfills, or migrations often change thousands of rows in a single transaction. Zero 1.8 replicates these transactions about 50% faster.", + "content": "Bulk imports, backfills, or migrations often change thousands of rows in a single Postgres transaction. These large transactions replicate about 1.5x faster in Zero 1.8.", "kind": "section" }, { - "id": "471-release-notes/1.8#maintaining-orderby--limit-queries", + "id": "473-release-notes/1.8#maintaining-limit-queries", "title": "Zero 1.8", - "searchTitle": "Maintaining orderBy() + limit() Queries", - "sectionTitle": "Maintaining orderBy() + limit() Queries", - "sectionId": "maintaining-orderby--limit-queries", + "searchTitle": "Maintaining limit() Queries", + "sectionTitle": "Maintaining limit() Queries", + "sectionId": "maintaining-limit-queries", "url": "/docs/release-notes/1.8", - "content": "For example, an app might show the first 50 open issues, ordered by priority: zql.issue .where('workspaceID', workspaceID) .where('status', 'open') .orderBy('priority', 'desc') .orderBy('created', 'asc') .limit(50) If only a few issues are open, the 50th matching issue can be far down the orderBy() index. When changes move rows in or out of the first 50 results, Zero may need to read more rows after the last row currently returned. Previously, that read could start at the beginning of the index, even though rows before the last returned row could not be next. In 1.8, Zero starts SQLite at the last returned row's sort key, so SQLite can seek into the index and scan from there. When the last returned row was 50,000 rows into the index, incremental updates rose from 248 to 521 updates/sec. At the end of a 100,000-row index, they rose from 250 to 14,977 updates/sec.", + "content": "Consider a query like this: zql.issue .where('status', 'open') .orderBy('created', 'asc') .limit(50) Zero can fulfill this query using an index on either status or created. If it decides to use the created index, Zero might have to consider many rows before it finds 50 matches. That is unavoidable. But when changes to the data move rows in or out of the first 50 results, Zero 1.7 repeated the work to find the first 50 results, making incremental updates slower than necessary. Zero 1.8 fixes this. In benchmarks, when the last returned row was 50,000 rows into the index, incremental updates were 2x faster in Zero 1.8. When it was 100,000 rows in, updates were over 50x faster in Zero 1.8.", "kind": "section" }, { - "id": "472-release-notes/1.8#running-local-zql-queries", + "id": "474-release-notes/1.8#client-side-hydration", "title": "Zero 1.8", - "searchTitle": "Running Local ZQL Queries", - "sectionTitle": "Running Local ZQL Queries", - "sectionId": "running-local-zql-queries", + "searchTitle": "Client-Side Hydration", + "sectionTitle": "Client-Side Hydration", + "sectionId": "client-side-hydration", "url": "/docs/release-notes/1.8", - "content": "When a local query first runs, Zero hydrates it from data already on the client. Zero 1.8 makes that faster, especially for queries with relationships. For example: zql.issue.related('creator').related('comments') Hydrating 500 issues with creators and comments fell from 2.54 ms to 1.97 ms. Hydrating 500 issues with creators fell from 1.11 ms to 0.84 ms.", + "content": "Zero runs queries first on the client, then on the server. The initial client-side hydration got faster in Zero 1.8. For example, this query returns initial data from client about 1.3x faster in Zero 1.8: zql.issue.related('creator').related('comments')", "kind": "section" }, { - "id": "473-release-notes/1.8#fixes", + "id": "475-release-notes/1.8#fixes", "title": "Zero 1.8", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -6181,7 +6195,7 @@ "kind": "section" }, { - "id": "474-release-notes/1.8#breaking-changes", + "id": "476-release-notes/1.8#breaking-changes", "title": "Zero 1.8", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -6191,16 +6205,110 @@ "kind": "section" }, { - "id": "61-release-notes", + "id": "61-release-notes/1.9", + "title": "Zero 1.9", + "searchTitle": "Zero 1.9", + "url": "/docs/release-notes/1.9", + "content": "Installation npm install @rocicorp/zero@1.9 You can use zero-cache from Docker Hub or GHCR: docker pull rocicorp/zero:1.9.0 # or docker pull ghcr.io/rocicorp/zero:1.9.0 Overview Zero 1.9 improves query correctness and zero-cache reliability. Performance Deferred. Fixes Ordered queries now paginate and maintain windows correctly when cursor fields contain NULL, including compound tie-break fields and reverse walks. This prevents skipped rows, empty windows, and related Bound should be set failures. (thanks @YevheniiKotyrlo!) Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data instead of invoking JavaScript's legacy prototype setter. (thanks @tjenkinson!) Clients now receive changed rows after a server-side query is rebuilt, instead of retaining stale results in a rare rehydration case. zero-cache now bounds its SQLite prepared-statement caches with LRU eviction, preventing unbounded statement retention when applications generate many distinct query shapes. Replication lag reports now retry when an expected report is missing, and serving-lag metrics exclude disconnected or not-yet-validated client groups. See the updated OpenTelemetry metric descriptions. zero-cache now detects and resets PostgreSQL connections that stop carrying wire traffic, including over TLS, allowing work to recover from proxy-created half-open sockets. See Breaking Changes. zero-cache now releases custom-query caches when client groups stop, preventing inactive groups from retaining timers and transformed queries. Breaking Changes PostgreSQL Socket Inactivity Timeout zero-cache now monitors wire activity on its PostgreSQL connections. By default, it checks every two minutes and resets a connection after one to two inactive intervals. This recovers half-open connections, but can interrupt a long-running statement that legitimately produces no network traffic. If legitimate Postgres operations can remain silent for this long, set ZERO_PG_SOCKET_INACTIVITY_TIMEOUT on zero-cache to a longer interval in milliseconds. Set it to 0 to disable the watchdog.", + "headings": [ + { + "text": "Installation", + "id": "installation" + }, + { + "text": "Overview", + "id": "overview" + }, + { + "text": "Performance", + "id": "performance" + }, + { + "text": "Fixes", + "id": "fixes" + }, + { + "text": "Breaking Changes", + "id": "breaking-changes" + }, + { + "text": "PostgreSQL Socket Inactivity Timeout", + "id": "postgresql-socket-inactivity-timeout" + } + ], + "kind": "page" + }, + { + "id": "477-release-notes/1.9#installation", + "title": "Zero 1.9", + "searchTitle": "Installation", + "sectionTitle": "Installation", + "sectionId": "installation", + "url": "/docs/release-notes/1.9", + "content": "npm install @rocicorp/zero@1.9 You can use zero-cache from Docker Hub or GHCR: docker pull rocicorp/zero:1.9.0 # or docker pull ghcr.io/rocicorp/zero:1.9.0", + "kind": "section" + }, + { + "id": "478-release-notes/1.9#overview", + "title": "Zero 1.9", + "searchTitle": "Overview", + "sectionTitle": "Overview", + "sectionId": "overview", + "url": "/docs/release-notes/1.9", + "content": "Zero 1.9 improves query correctness and zero-cache reliability.", + "kind": "section" + }, + { + "id": "479-release-notes/1.9#performance", + "title": "Zero 1.9", + "searchTitle": "Performance", + "sectionTitle": "Performance", + "sectionId": "performance", + "url": "/docs/release-notes/1.9", + "content": "Deferred.", + "kind": "section" + }, + { + "id": "480-release-notes/1.9#fixes", + "title": "Zero 1.9", + "searchTitle": "Fixes", + "sectionTitle": "Fixes", + "sectionId": "fixes", + "url": "/docs/release-notes/1.9", + "content": "Ordered queries now paginate and maintain windows correctly when cursor fields contain NULL, including compound tie-break fields and reverse walks. This prevents skipped rows, empty windows, and related Bound should be set failures. (thanks @YevheniiKotyrlo!) Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data instead of invoking JavaScript's legacy prototype setter. (thanks @tjenkinson!) Clients now receive changed rows after a server-side query is rebuilt, instead of retaining stale results in a rare rehydration case. zero-cache now bounds its SQLite prepared-statement caches with LRU eviction, preventing unbounded statement retention when applications generate many distinct query shapes. Replication lag reports now retry when an expected report is missing, and serving-lag metrics exclude disconnected or not-yet-validated client groups. See the updated OpenTelemetry metric descriptions. zero-cache now detects and resets PostgreSQL connections that stop carrying wire traffic, including over TLS, allowing work to recover from proxy-created half-open sockets. See Breaking Changes. zero-cache now releases custom-query caches when client groups stop, preventing inactive groups from retaining timers and transformed queries.", + "kind": "section" + }, + { + "id": "481-release-notes/1.9#breaking-changes", + "title": "Zero 1.9", + "searchTitle": "Breaking Changes", + "sectionTitle": "Breaking Changes", + "sectionId": "breaking-changes", + "url": "/docs/release-notes/1.9", + "content": "PostgreSQL Socket Inactivity Timeout zero-cache now monitors wire activity on its PostgreSQL connections. By default, it checks every two minutes and resets a connection after one to two inactive intervals. This recovers half-open connections, but can interrupt a long-running statement that legitimately produces no network traffic. If legitimate Postgres operations can remain silent for this long, set ZERO_PG_SOCKET_INACTIVITY_TIMEOUT on zero-cache to a longer interval in milliseconds. Set it to 0 to disable the watchdog.", + "kind": "section" + }, + { + "id": "482-release-notes/1.9#postgresql-socket-inactivity-timeout", + "title": "Zero 1.9", + "searchTitle": "PostgreSQL Socket Inactivity Timeout", + "sectionTitle": "PostgreSQL Socket Inactivity Timeout", + "sectionId": "postgresql-socket-inactivity-timeout", + "url": "/docs/release-notes/1.9", + "content": "zero-cache now monitors wire activity on its PostgreSQL connections. By default, it checks every two minutes and resets a connection after one to two inactive intervals. This recovers half-open connections, but can interrupt a long-running statement that legitimately produces no network traffic. If legitimate Postgres operations can remain silent for this long, set ZERO_PG_SOCKET_INACTIVITY_TIMEOUT on zero-cache to a longer interval in milliseconds. Set it to 0 to disable the watchdog.", + "kind": "section" + }, + { + "id": "62-release-notes", "title": "Release Notes", "searchTitle": "Release Notes", "url": "/docs/release-notes", - "content": "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.9: Query Correctness and Reliability 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": "62-reporting-bugs", + "id": "63-reporting-bugs", "title": "Reporting Bugs", "searchTitle": "Reporting Bugs", "url": "/docs/reporting-bugs", @@ -6218,7 +6326,7 @@ "kind": "page" }, { - "id": "475-reporting-bugs#zbugs", + "id": "483-reporting-bugs#zbugs", "title": "Reporting Bugs", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -6228,7 +6336,7 @@ "kind": "section" }, { - "id": "476-reporting-bugs#discord", + "id": "484-reporting-bugs#discord", "title": "Reporting Bugs", "searchTitle": "Discord", "sectionTitle": "Discord", @@ -6238,7 +6346,7 @@ "kind": "section" }, { - "id": "63-rest", + "id": "64-rest", "title": "REST", "searchTitle": "REST", "url": "/docs/rest", @@ -6264,7 +6372,7 @@ "kind": "page" }, { - "id": "477-rest#pattern", + "id": "485-rest#pattern", "title": "REST", "searchTitle": "Pattern", "sectionTitle": "Pattern", @@ -6274,7 +6382,7 @@ "kind": "section" }, { - "id": "478-rest#tanstack-start-example", + "id": "486-rest#tanstack-start-example", "title": "REST", "searchTitle": "TanStack Start Example", "sectionTitle": "TanStack Start Example", @@ -6284,7 +6392,7 @@ "kind": "section" }, { - "id": "479-rest#openapi-generation", + "id": "487-rest#openapi-generation", "title": "REST", "searchTitle": "OpenAPI Generation", "sectionTitle": "OpenAPI Generation", @@ -6294,7 +6402,7 @@ "kind": "section" }, { - "id": "480-rest#full-working-example", + "id": "488-rest#full-working-example", "title": "REST", "searchTitle": "Full Working Example", "sectionTitle": "Full Working Example", @@ -6304,7 +6412,7 @@ "kind": "section" }, { - "id": "64-roadmap", + "id": "65-roadmap", "title": "Roadmap", "searchTitle": "Roadmap", "url": "/docs/roadmap", @@ -6322,7 +6430,7 @@ "kind": "page" }, { - "id": "481-roadmap#q4-2025", + "id": "489-roadmap#q4-2025", "title": "Roadmap", "searchTitle": "Q4 2025", "sectionTitle": "Q4 2025", @@ -6332,7 +6440,7 @@ "kind": "section" }, { - "id": "482-roadmap#beyond", + "id": "490-roadmap#beyond", "title": "Roadmap", "searchTitle": "Beyond", "sectionTitle": "Beyond", @@ -6342,7 +6450,7 @@ "kind": "section" }, { - "id": "65-samples", + "id": "66-samples", "title": "Samples", "searchTitle": "Samples", "url": "/docs/samples", @@ -6368,7 +6476,7 @@ "kind": "page" }, { - "id": "483-samples#gigabugs", + "id": "491-samples#gigabugs", "title": "Samples", "searchTitle": "Gigabugs", "sectionTitle": "Gigabugs", @@ -6378,7 +6486,7 @@ "kind": "section" }, { - "id": "484-samples#ztunes", + "id": "492-samples#ztunes", "title": "Samples", "searchTitle": "ztunes", "sectionTitle": "ztunes", @@ -6388,7 +6496,7 @@ "kind": "section" }, { - "id": "485-samples#zslack", + "id": "493-samples#zslack", "title": "Samples", "searchTitle": "zslack", "sectionTitle": "zslack", @@ -6398,7 +6506,7 @@ "kind": "section" }, { - "id": "486-samples#zero-music", + "id": "494-samples#zero-music", "title": "Samples", "searchTitle": "zero-music", "sectionTitle": "zero-music", @@ -6408,7 +6516,7 @@ "kind": "section" }, { - "id": "66-schema", + "id": "67-schema", "title": "Zero Schema", "searchTitle": "Zero Schema", "url": "/docs/schema", @@ -6534,7 +6642,7 @@ "kind": "page" }, { - "id": "487-schema#generating-from-database", + "id": "495-schema#generating-from-database", "title": "Zero Schema", "searchTitle": "Generating from Database", "sectionTitle": "Generating from Database", @@ -6544,7 +6652,7 @@ "kind": "section" }, { - "id": "488-schema#writing-by-hand", + "id": "496-schema#writing-by-hand", "title": "Zero Schema", "searchTitle": "Writing by Hand", "sectionTitle": "Writing by Hand", @@ -6554,7 +6662,7 @@ "kind": "section" }, { - "id": "489-schema#table-schemas", + "id": "497-schema#table-schemas", "title": "Zero Schema", "searchTitle": "Table Schemas", "sectionTitle": "Table Schemas", @@ -6564,7 +6672,7 @@ "kind": "section" }, { - "id": "490-schema#name-mapping", + "id": "498-schema#name-mapping", "title": "Zero Schema", "searchTitle": "Name Mapping", "sectionTitle": "Name Mapping", @@ -6574,7 +6682,7 @@ "kind": "section" }, { - "id": "491-schema#multiple-schemas", + "id": "499-schema#multiple-schemas", "title": "Zero Schema", "searchTitle": "Multiple Schemas", "sectionTitle": "Multiple Schemas", @@ -6584,7 +6692,7 @@ "kind": "section" }, { - "id": "492-schema#optional-columns", + "id": "500-schema#optional-columns", "title": "Zero Schema", "searchTitle": "Optional Columns", "sectionTitle": "Optional Columns", @@ -6594,7 +6702,7 @@ "kind": "section" }, { - "id": "493-schema#enumerations", + "id": "501-schema#enumerations", "title": "Zero Schema", "searchTitle": "Enumerations", "sectionTitle": "Enumerations", @@ -6604,7 +6712,7 @@ "kind": "section" }, { - "id": "494-schema#custom-json-types", + "id": "502-schema#custom-json-types", "title": "Zero Schema", "searchTitle": "Custom JSON Types", "sectionTitle": "Custom JSON Types", @@ -6614,7 +6722,7 @@ "kind": "section" }, { - "id": "495-schema#compound-primary-keys", + "id": "503-schema#compound-primary-keys", "title": "Zero Schema", "searchTitle": "Compound Primary Keys", "sectionTitle": "Compound Primary Keys", @@ -6624,7 +6732,7 @@ "kind": "section" }, { - "id": "496-schema#relationships", + "id": "504-schema#relationships", "title": "Zero Schema", "searchTitle": "Relationships", "sectionTitle": "Relationships", @@ -6634,7 +6742,7 @@ "kind": "section" }, { - "id": "497-schema#many-to-many-relationships", + "id": "505-schema#many-to-many-relationships", "title": "Zero Schema", "searchTitle": "Many-to-Many Relationships", "sectionTitle": "Many-to-Many Relationships", @@ -6644,7 +6752,7 @@ "kind": "section" }, { - "id": "498-schema#compound-keys-relationships", + "id": "506-schema#compound-keys-relationships", "title": "Zero Schema", "searchTitle": "Compound Keys Relationships", "sectionTitle": "Compound Keys Relationships", @@ -6654,7 +6762,7 @@ "kind": "section" }, { - "id": "499-schema#circular-relationships", + "id": "507-schema#circular-relationships", "title": "Zero Schema", "searchTitle": "Circular Relationships", "sectionTitle": "Circular Relationships", @@ -6664,7 +6772,7 @@ "kind": "section" }, { - "id": "500-schema#database-schemas", + "id": "508-schema#database-schemas", "title": "Zero Schema", "searchTitle": "Database Schemas", "sectionTitle": "Database Schemas", @@ -6674,7 +6782,7 @@ "kind": "section" }, { - "id": "501-schema#register-schema-type", + "id": "509-schema#register-schema-type", "title": "Zero Schema", "searchTitle": "Register Schema Type", "sectionTitle": "Register Schema Type", @@ -6684,7 +6792,7 @@ "kind": "section" }, { - "id": "502-schema#schema-changes", + "id": "510-schema#schema-changes", "title": "Zero Schema", "searchTitle": "Schema Changes", "sectionTitle": "Schema Changes", @@ -6694,7 +6802,7 @@ "kind": "section" }, { - "id": "503-schema#development", + "id": "511-schema#development", "title": "Zero Schema", "searchTitle": "Development", "sectionTitle": "Development", @@ -6704,7 +6812,7 @@ "kind": "section" }, { - "id": "504-schema#production", + "id": "512-schema#production", "title": "Zero Schema", "searchTitle": "Production", "sectionTitle": "Production", @@ -6714,7 +6822,7 @@ "kind": "section" }, { - "id": "505-schema#expand-changes", + "id": "513-schema#expand-changes", "title": "Zero Schema", "searchTitle": "Expand Changes", "sectionTitle": "Expand Changes", @@ -6724,7 +6832,7 @@ "kind": "section" }, { - "id": "506-schema#contract-changes", + "id": "514-schema#contract-changes", "title": "Zero Schema", "searchTitle": "Contract Changes", "sectionTitle": "Contract Changes", @@ -6734,7 +6842,7 @@ "kind": "section" }, { - "id": "507-schema#compound-changes", + "id": "515-schema#compound-changes", "title": "Zero Schema", "searchTitle": "Compound Changes", "sectionTitle": "Compound Changes", @@ -6744,7 +6852,7 @@ "kind": "section" }, { - "id": "508-schema#examples", + "id": "516-schema#examples", "title": "Zero Schema", "searchTitle": "Examples", "sectionTitle": "Examples", @@ -6754,7 +6862,7 @@ "kind": "section" }, { - "id": "509-schema#adding-a-column", + "id": "517-schema#adding-a-column", "title": "Zero Schema", "searchTitle": "Adding a Column", "sectionTitle": "Adding a Column", @@ -6764,7 +6872,7 @@ "kind": "section" }, { - "id": "510-schema#removing-a-column", + "id": "518-schema#removing-a-column", "title": "Zero Schema", "searchTitle": "Removing a Column", "sectionTitle": "Removing a Column", @@ -6774,7 +6882,7 @@ "kind": "section" }, { - "id": "511-schema#renaming-a-column", + "id": "519-schema#renaming-a-column", "title": "Zero Schema", "searchTitle": "Renaming a Column", "sectionTitle": "Renaming a Column", @@ -6784,7 +6892,7 @@ "kind": "section" }, { - "id": "512-schema#making-a-column-optional", + "id": "520-schema#making-a-column-optional", "title": "Zero Schema", "searchTitle": "Making a Column Optional", "sectionTitle": "Making a Column Optional", @@ -6794,7 +6902,7 @@ "kind": "section" }, { - "id": "513-schema#quick-reference", + "id": "521-schema#quick-reference", "title": "Zero Schema", "searchTitle": "Quick Reference", "sectionTitle": "Quick Reference", @@ -6804,7 +6912,7 @@ "kind": "section" }, { - "id": "514-schema#backfill", + "id": "522-schema#backfill", "title": "Zero Schema", "searchTitle": "Backfill", "sectionTitle": "Backfill", @@ -6814,7 +6922,7 @@ "kind": "section" }, { - "id": "515-schema#monitoring-backfill-progress", + "id": "523-schema#monitoring-backfill-progress", "title": "Zero Schema", "searchTitle": "Monitoring Backfill Progress", "sectionTitle": "Monitoring Backfill Progress", @@ -6824,7 +6932,7 @@ "kind": "section" }, { - "id": "67-self-host", + "id": "68-self-host", "title": "Self-Hosting Zero", "searchTitle": "Self-Hosting Zero", "url": "/docs/self-host", @@ -6886,7 +6994,7 @@ "kind": "page" }, { - "id": "516-self-host#docker-images", + "id": "524-self-host#docker-images", "title": "Self-Hosting Zero", "searchTitle": "Docker Images", "sectionTitle": "Docker Images", @@ -6896,7 +7004,7 @@ "kind": "section" }, { - "id": "517-self-host#minimum-viable-strategy", + "id": "525-self-host#minimum-viable-strategy", "title": "Self-Hosting Zero", "searchTitle": "Minimum Viable Strategy", "sectionTitle": "Minimum Viable Strategy", @@ -6906,7 +7014,7 @@ "kind": "section" }, { - "id": "518-self-host#maximal-strategy", + "id": "526-self-host#maximal-strategy", "title": "Self-Hosting Zero", "searchTitle": "Maximal Strategy", "sectionTitle": "Maximal Strategy", @@ -6916,7 +7024,7 @@ "kind": "section" }, { - "id": "519-self-host#replica-lifecycle", + "id": "527-self-host#replica-lifecycle", "title": "Self-Hosting Zero", "searchTitle": "Replica Lifecycle", "sectionTitle": "Replica Lifecycle", @@ -6926,7 +7034,7 @@ "kind": "section" }, { - "id": "520-self-host#performance", + "id": "528-self-host#performance", "title": "Self-Hosting Zero", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -6936,7 +7044,7 @@ "kind": "section" }, { - "id": "521-self-host#hydration", + "id": "529-self-host#hydration", "title": "Self-Hosting Zero", "searchTitle": "Hydration", "sectionTitle": "Hydration", @@ -6946,7 +7054,7 @@ "kind": "section" }, { - "id": "522-self-host#ivm-advancement", + "id": "530-self-host#ivm-advancement", "title": "Self-Hosting Zero", "searchTitle": "IVM advancement", "sectionTitle": "IVM advancement", @@ -6956,7 +7064,7 @@ "kind": "section" }, { - "id": "523-self-host#system-level", + "id": "531-self-host#system-level", "title": "Self-Hosting Zero", "searchTitle": "System-level", "sectionTitle": "System-level", @@ -6966,7 +7074,7 @@ "kind": "section" }, { - "id": "524-self-host#networking", + "id": "532-self-host#networking", "title": "Self-Hosting Zero", "searchTitle": "Networking", "sectionTitle": "Networking", @@ -6976,7 +7084,7 @@ "kind": "section" }, { - "id": "525-self-host#sticky-sessions", + "id": "533-self-host#sticky-sessions", "title": "Self-Hosting Zero", "searchTitle": "Sticky Sessions", "sectionTitle": "Sticky Sessions", @@ -6986,7 +7094,7 @@ "kind": "section" }, { - "id": "526-self-host#rolling-updates", + "id": "534-self-host#rolling-updates", "title": "Self-Hosting Zero", "searchTitle": "Rolling Updates", "sectionTitle": "Rolling Updates", @@ -6996,7 +7104,7 @@ "kind": "section" }, { - "id": "527-self-host#clientserver-version-compatibility", + "id": "535-self-host#clientserver-version-compatibility", "title": "Self-Hosting Zero", "searchTitle": "Client/Server Version Compatibility", "sectionTitle": "Client/Server Version Compatibility", @@ -7006,7 +7114,7 @@ "kind": "section" }, { - "id": "528-self-host#configuration", + "id": "536-self-host#configuration", "title": "Self-Hosting Zero", "searchTitle": "Configuration", "sectionTitle": "Configuration", @@ -7016,7 +7124,7 @@ "kind": "section" }, { - "id": "68-server-zql", + "id": "69-server-zql", "title": "ZQL on the Server", "searchTitle": "ZQL on the Server", "url": "/docs/server-zql", @@ -7042,7 +7150,7 @@ "kind": "page" }, { - "id": "529-server-zql#creating-a-database", + "id": "537-server-zql#creating-a-database", "title": "ZQL on the Server", "searchTitle": "Creating a Database", "sectionTitle": "Creating a Database", @@ -7052,7 +7160,7 @@ "kind": "section" }, { - "id": "530-server-zql#custom-database", + "id": "538-server-zql#custom-database", "title": "ZQL on the Server", "searchTitle": "Custom Database", "sectionTitle": "Custom Database", @@ -7062,7 +7170,7 @@ "kind": "section" }, { - "id": "531-server-zql#running-zql", + "id": "539-server-zql#running-zql", "title": "ZQL on the Server", "searchTitle": "Running ZQL", "sectionTitle": "Running ZQL", @@ -7072,7 +7180,7 @@ "kind": "section" }, { - "id": "532-server-zql#ssr", + "id": "540-server-zql#ssr", "title": "ZQL on the Server", "searchTitle": "SSR", "sectionTitle": "SSR", @@ -7082,7 +7190,7 @@ "kind": "section" }, { - "id": "69-solidjs", + "id": "70-solidjs", "title": "SolidJS", "searchTitle": "SolidJS", "url": "/docs/solidjs", @@ -7104,7 +7212,7 @@ "kind": "page" }, { - "id": "533-solidjs#setup", + "id": "541-solidjs#setup", "title": "SolidJS", "searchTitle": "Setup", "sectionTitle": "Setup", @@ -7114,7 +7222,7 @@ "kind": "section" }, { - "id": "534-solidjs#usage", + "id": "542-solidjs#usage", "title": "SolidJS", "searchTitle": "Usage", "sectionTitle": "Usage", @@ -7124,7 +7232,7 @@ "kind": "section" }, { - "id": "535-solidjs#examples", + "id": "543-solidjs#examples", "title": "SolidJS", "searchTitle": "Examples", "sectionTitle": "Examples", @@ -7134,7 +7242,7 @@ "kind": "section" }, { - "id": "70-status", + "id": "71-status", "title": "Project Status", "searchTitle": "Project Status", "url": "/docs/status", @@ -7160,7 +7268,7 @@ "kind": "page" }, { - "id": "536-status#breaking-changes", + "id": "544-status#breaking-changes", "title": "Project Status", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -7170,7 +7278,7 @@ "kind": "section" }, { - "id": "537-status#roadmap", + "id": "545-status#roadmap", "title": "Project Status", "searchTitle": "Roadmap", "sectionTitle": "Roadmap", @@ -7180,7 +7288,7 @@ "kind": "section" }, { - "id": "538-status#2026", + "id": "546-status#2026", "title": "Project Status", "searchTitle": "2026", "sectionTitle": "2026", @@ -7190,7 +7298,7 @@ "kind": "section" }, { - "id": "539-status#soon", + "id": "547-status#soon", "title": "Project Status", "searchTitle": "Soon", "sectionTitle": "Soon", @@ -7200,7 +7308,7 @@ "kind": "section" }, { - "id": "71-sync", + "id": "72-sync", "title": "What is Sync?", "searchTitle": "What is Sync?", "url": "/docs/sync", @@ -7222,7 +7330,7 @@ "kind": "page" }, { - "id": "540-sync#problem", + "id": "548-sync#problem", "title": "What is Sync?", "searchTitle": "Problem", "sectionTitle": "Problem", @@ -7232,7 +7340,7 @@ "kind": "section" }, { - "id": "541-sync#solution", + "id": "549-sync#solution", "title": "What is Sync?", "searchTitle": "Solution", "sectionTitle": "Solution", @@ -7242,7 +7350,7 @@ "kind": "section" }, { - "id": "542-sync#history-of-sync", + "id": "550-sync#history-of-sync", "title": "What is Sync?", "searchTitle": "History of Sync", "sectionTitle": "History of Sync", @@ -7252,7 +7360,7 @@ "kind": "section" }, { - "id": "72-tutorial", + "id": "73-tutorial", "title": "Tutorial", "searchTitle": "Tutorial", "url": "/docs/tutorial", @@ -7326,7 +7434,7 @@ "kind": "page" }, { - "id": "543-tutorial#setup", + "id": "551-tutorial#setup", "title": "Tutorial", "searchTitle": "Setup", "sectionTitle": "Setup", @@ -7336,7 +7444,7 @@ "kind": "section" }, { - "id": "544-tutorial#create-a-project", + "id": "552-tutorial#create-a-project", "title": "Tutorial", "searchTitle": "Create a Project", "sectionTitle": "Create a Project", @@ -7346,7 +7454,7 @@ "kind": "section" }, { - "id": "545-tutorial#set-up-your-database", + "id": "553-tutorial#set-up-your-database", "title": "Tutorial", "searchTitle": "Set Up Your Database", "sectionTitle": "Set Up Your Database", @@ -7356,7 +7464,7 @@ "kind": "section" }, { - "id": "546-tutorial#install-and-run-zero-cache", + "id": "554-tutorial#install-and-run-zero-cache", "title": "Tutorial", "searchTitle": "Install and Run Zero-Cache", "sectionTitle": "Install and Run Zero-Cache", @@ -7366,7 +7474,7 @@ "kind": "section" }, { - "id": "547-tutorial#integrate-zero", + "id": "555-tutorial#integrate-zero", "title": "Tutorial", "searchTitle": "Integrate Zero", "sectionTitle": "Integrate Zero", @@ -7376,7 +7484,7 @@ "kind": "section" }, { - "id": "548-tutorial#set-up-your-zero-schema", + "id": "556-tutorial#set-up-your-zero-schema", "title": "Tutorial", "searchTitle": "Set Up Your Zero Schema", "sectionTitle": "Set Up Your Zero Schema", @@ -7386,7 +7494,7 @@ "kind": "section" }, { - "id": "549-tutorial#set-up-the-zero-client", + "id": "557-tutorial#set-up-the-zero-client", "title": "Tutorial", "searchTitle": "Set Up the Zero Client", "sectionTitle": "Set Up the Zero Client", @@ -7396,7 +7504,7 @@ "kind": "section" }, { - "id": "550-tutorial#sync-data", + "id": "558-tutorial#sync-data", "title": "Tutorial", "searchTitle": "Sync Data", "sectionTitle": "Sync Data", @@ -7406,7 +7514,7 @@ "kind": "section" }, { - "id": "551-tutorial#define-query", + "id": "559-tutorial#define-query", "title": "Tutorial", "searchTitle": "Define Query", "sectionTitle": "Define Query", @@ -7416,7 +7524,7 @@ "kind": "section" }, { - "id": "552-tutorial#add-query-endpoint", + "id": "560-tutorial#add-query-endpoint", "title": "Tutorial", "searchTitle": "Add Query Endpoint", "sectionTitle": "Add Query Endpoint", @@ -7426,7 +7534,7 @@ "kind": "section" }, { - "id": "553-tutorial#invoke-query", + "id": "561-tutorial#invoke-query", "title": "Tutorial", "searchTitle": "Invoke Query", "sectionTitle": "Invoke Query", @@ -7436,7 +7544,7 @@ "kind": "section" }, { - "id": "554-tutorial#mutate-data", + "id": "562-tutorial#mutate-data", "title": "Tutorial", "searchTitle": "Mutate Data", "sectionTitle": "Mutate Data", @@ -7446,7 +7554,7 @@ "kind": "section" }, { - "id": "555-tutorial#define-mutators", + "id": "563-tutorial#define-mutators", "title": "Tutorial", "searchTitle": "Define Mutators", "sectionTitle": "Define Mutators", @@ -7456,7 +7564,7 @@ "kind": "section" }, { - "id": "556-tutorial#add-mutate-endpoint", + "id": "564-tutorial#add-mutate-endpoint", "title": "Tutorial", "searchTitle": "Add Mutate Endpoint", "sectionTitle": "Add Mutate Endpoint", @@ -7466,7 +7574,7 @@ "kind": "section" }, { - "id": "557-tutorial#invoke-mutators", + "id": "565-tutorial#invoke-mutators", "title": "Tutorial", "searchTitle": "Invoke Mutators", "sectionTitle": "Invoke Mutators", @@ -7476,7 +7584,7 @@ "kind": "section" }, { - "id": "558-tutorial#next-steps", + "id": "566-tutorial#next-steps", "title": "Tutorial", "searchTitle": "Next Steps", "sectionTitle": "Next Steps", @@ -7486,7 +7594,7 @@ "kind": "section" }, { - "id": "73-when-to-use", + "id": "74-when-to-use", "title": "When To Use Zero", "searchTitle": "When To Use Zero", "url": "/docs/when-to-use", @@ -7552,7 +7660,7 @@ "kind": "page" }, { - "id": "559-when-to-use#zero-might-be-a-good-fit", + "id": "567-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", @@ -7562,7 +7670,7 @@ "kind": "section" }, { - "id": "560-when-to-use#you-want-to-sync-only-a-small-subset-of-data-to-client", + "id": "568-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", @@ -7572,7 +7680,7 @@ "kind": "section" }, { - "id": "561-when-to-use#you-need-fine-grained-read-or-write-permissions", + "id": "569-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", @@ -7582,7 +7690,7 @@ "kind": "section" }, { - "id": "562-when-to-use#you-are-building-a-traditional-client-server-web-app", + "id": "570-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", @@ -7592,7 +7700,7 @@ "kind": "section" }, { - "id": "563-when-to-use#you-use-postgresql", + "id": "571-when-to-use#you-use-postgresql", "title": "When To Use Zero", "searchTitle": "You use PostgreSQL", "sectionTitle": "You use PostgreSQL", @@ -7602,7 +7710,7 @@ "kind": "section" }, { - "id": "564-when-to-use#your-app-is-broadly-like-linear", + "id": "572-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\"", @@ -7612,7 +7720,7 @@ "kind": "section" }, { - "id": "565-when-to-use#interaction-performance-is-very-important-to-you", + "id": "573-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", @@ -7622,7 +7730,7 @@ "kind": "section" }, { - "id": "566-when-to-use#zero-might-not-be-a-good-fit", + "id": "574-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", @@ -7632,7 +7740,7 @@ "kind": "section" }, { - "id": "567-when-to-use#you-need-the-privacy-or-data-ownership-benefits-of-local-first", + "id": "575-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", @@ -7642,7 +7750,7 @@ "kind": "section" }, { - "id": "568-when-to-use#you-need-to-support-offline-writes-or-long-periods-offline", + "id": "576-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", @@ -7652,7 +7760,7 @@ "kind": "section" }, { - "id": "569-when-to-use#you-are-building-a-native-mobile-app", + "id": "577-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", @@ -7662,7 +7770,7 @@ "kind": "section" }, { - "id": "570-when-to-use#the-total-backend-dataset-is--100gb", + "id": "578-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", @@ -7672,7 +7780,7 @@ "kind": "section" }, { - "id": "571-when-to-use#zero-might-not-be-a-good-fit-yet", + "id": "579-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", @@ -7682,7 +7790,7 @@ "kind": "section" }, { - "id": "572-when-to-use#alternatives", + "id": "580-when-to-use#alternatives", "title": "When To Use Zero", "searchTitle": "Alternatives", "sectionTitle": "Alternatives", @@ -7692,11 +7800,11 @@ "kind": "section" }, { - "id": "74-zero-cache-config", + "id": "75-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 Max Connections The maximum number of connections to open to the change database. This is used by the change-streamer for catching up zero-cache replication subscriptions. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS default: 5 Change Streamer Back 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 Padding Seconds During periodic flow control checks (every 64kb), this is the amount of time to wait after the majority of subscribers have acked, after which replication continues even if some subscribers have yet to ack. This is not a timeout for the entire send; it starts only after the majority of receivers have acked. This allows a bounded amount of time for backlogged subscribers to catch up on each flush without forcing all subscribers to wait for the entire backlog to be processed. It is also useful for mitigating the effect of unresponsive subscribers due to severed WebSocket connections until liveness checks disconnect them. Set this to a negative number to disable early flow control releases. flag: --change-streamer-flow-control-consensus-padding-seconds env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_PADDING_SECONDS default: 1 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. 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. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40 Litestream Config Path Path to the litestream yaml config file. zero-cache will run this with its environment variables, which can be referenced in the file via ${ENV} substitution, for example: ZERO_REPLICA_FILE for the db Path ZERO_LITESTREAM_BACKUP_LOCATION for the db replica url ZERO_LITESTREAM_LOG_LEVEL for the log Level ZERO_LOG_FORMAT for the log type flag: --litestream-config-path env: ZERO_LITESTREAM_CONFIG_PATH default: ./src/services/litestream/config.yml Litestream Executable Path to the litestream executable. This 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 Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Maximum Checkpoint Page Count The WAL page count at which SQLite performs a RESTART checkpoint, which blocks writers until complete. Defaults to minCheckpointPageCount * 10. Set to 0 to disable RESTART checkpoints entirely. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 Litestream Minimum Checkpoint Page Count The WAL page count at which SQLite attempts a PASSIVE checkpoint, which transfers pages to the main database file without blocking writers. Defaults to checkpointThresholdMB * 250 (since SQLite page size is 4KB). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250 Litestream Multipart Concurrency The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY default: 48 Litestream Multipart Size The size of each part when uploading or downloading the snapshot with --litestream-multipart-concurrency. Note that up to concurrency * size bytes of memory are used when backing up or restoring the snapshot. flag: --litestream-multipart-size env: ZERO_LITESTREAM_MULTIPART_SIZE default: 16777216 (16 MiB) Litestream Log Level flag: --litestream-log-level env: ZERO_LITESTREAM_LOG_LEVEL default: warn values: debug, info, warn, error Litestream Port Port on which litestream exports metrics, used to determine the replication watermark up to which it is safe to purge change log records. flag: --litestream-port env: ZERO_LITESTREAM_PORT default: --port + 2 Litestream 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. This improves restore time at the expense of bandwidth. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12 Log Format Use text for developer-friendly console logging and json for consumption by structured-logging services. flag: --log-format env: ZERO_LOG_FORMAT default: \"text\" values: text, json Log IVM Sampling How often to collect IVM metrics. 1 out of N requests will be sampled where N is this value. flag: --log-ivm-sampling env: ZERO_LOG_IVM_SAMPLING default: 5000 Log Level Sets the logging level for the application. flag: --log-level env: ZERO_LOG_LEVEL default: \"info\" values: debug, info, warn, error Log Slow Hydrate Threshold The number of milliseconds a query hydration must take to print a slow warning. flag: --log-slow-hydrate-threshold env: ZERO_LOG_SLOW_HYDRATE_THRESHOLD default: 100 Log Slow Row Threshold The number of ms a row must take to fetch from table-source before it is considered slow. flag: --log-slow-row-threshold env: ZERO_LOG_SLOW_ROW_THRESHOLD default: 2 Mutate API Key An optional secret used to authorize zero-cache to call the API server handling writes. This is sent from zero-cache to your mutate endpoint in an X-Api-Key header. flag: --mutate-api-key env: ZERO_MUTATE_API_KEY Mutate 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 and Failover. 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. Because replication lag reports are only issued after the previous one was received, the actual interval between reports may be longer when there is a backlog in the replication stream. 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 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 Auth JWK A public key in JWK format used to verify JWTs. Only one of jwk, jwksUrl and secret may be set. flag: --auth-jwk env: ZERO_AUTH_JWK Auth JWKS URL A URL that returns a JWK set used to verify JWTs. Only one of jwk, jwksUrl and secret may be set. flag: --auth-jwks-url env: ZERO_AUTH_JWKS_URL Auth Secret A symmetric key used to verify JWTs. Only one of jwk, jwksUrl and secret may be set. flag: --auth-secret env: ZERO_AUTH_SECRET", + "content": "zero-cache is configured either via CLI flag or environment variable. There is no separate zero.config file. You can also see all available flags by running zero-cache --help. Required Flags Upstream DB The \"upstream\" authoritative postgres database. In the future we will support other types of upstream besides PG. flag: --upstream-db env: ZERO_UPSTREAM_DB required: true Admin Password A password used to administer zero-cache server, for example to access the /statz endpoint and the inspector. This is required in production (when NODE_ENV=production) because we want all Zero servers to be debuggable using admin tools by default, without needing a restart. But we also don't want to expose sensitive data using them. flag: --admin-password env: ZERO_ADMIN_PASSWORD required: in production (when NODE_ENV=production) Optional Flags App ID Unique identifier for the app. Multiple zero-cache apps can run on a single upstream database, each of which is isolated from the others, with its own permissions, sharding (future feature), and change/cvr databases. The metadata of an app is stored in an upstream schema with the same name, e.g. zero, and the metadata for each app shard, e.g. client and mutation ids, is stored in the {app-id}_{#} schema. (Currently there is only a single \"0\" shard, but this will change with sharding). The CVR and Change data are managed in schemas named {app-id}_{shard-num}/cvr and {app-id}_{shard-num}/cdc, respectively, allowing multiple apps and shards to share the same database instance (e.g. a Postgres \"cluster\") for CVR and Change management. Due to constraints on replication slot names, an App ID may only consist of lower-case letters, numbers, and the underscore character. Note that this option is used by both zero-cache and zero-deploy-permissions. flag: --app-id env: ZERO_APP_ID default: zero App Publications Postgres PUBLICATIONs that define the tables and columns to replicate. Publication names may not begin with an underscore, as zero reserves that prefix for internal use. If unspecified, zero-cache will create and use an internal publication that publishes all tables in the public schema, i.e.: CREATE PUBLICATION _{app-id}_public_0 FOR TABLES IN SCHEMA public; Note that changing the set of publications will result in resyncing the replica, which may involve downtime (replication lag) while the new replica is initializing. To change the set of publications without disrupting an existing app, a new app should be created. To use a custom publication, you can create one with: CREATE PUBLICATION zero_data FOR TABLES IN SCHEMA public; -- or, more selectively: CREATE PUBLICATION zero_data FOR TABLE users, orders; Then set the flag to that publication name, e.g.: ZERO_APP_PUBLICATIONS=zero_data. To specify multiple publications, separate them with commas, e.g.: ZERO_APP_PUBLICATIONS=zero_data1,zero_data2. flag: --app-publications env: ZERO_APP_PUBLICATIONS default: _{app-id}_public_0 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 Max Connections The maximum number of connections to open to the change database. This is used by the change-streamer for catching up zero-cache replication subscriptions. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS default: 5 Change Streamer Back 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 Padding Seconds During periodic flow control checks (every 64kb), this is the amount of time to wait after the majority of subscribers have acked, after which replication continues even if some subscribers have yet to ack. This is not a timeout for the entire send; it starts only after the majority of receivers have acked. This allows a bounded amount of time for backlogged subscribers to catch up on each flush without forcing all subscribers to wait for the entire backlog to be processed. It is also useful for mitigating the effect of unresponsive subscribers due to severed WebSocket connections until liveness checks disconnect them. Set this to a negative number to disable early flow control releases. flag: --change-streamer-flow-control-consensus-padding-seconds env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_PADDING_SECONDS default: 1 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. 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. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40 Litestream Config Path Path to the litestream yaml config file. zero-cache will run this with its environment variables, which can be referenced in the file via ${ENV} substitution, for example: ZERO_REPLICA_FILE for the db Path ZERO_LITESTREAM_BACKUP_LOCATION for the db replica url ZERO_LITESTREAM_LOG_LEVEL for the log Level ZERO_LOG_FORMAT for the log type flag: --litestream-config-path env: ZERO_LITESTREAM_CONFIG_PATH default: ./src/services/litestream/config.yml Litestream Executable Path to the litestream executable. This 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 Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Maximum Checkpoint Page Count The WAL page count at which SQLite performs a RESTART checkpoint, which blocks writers until complete. Defaults to minCheckpointPageCount * 10. Set to 0 to disable RESTART checkpoints entirely. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 Litestream Minimum Checkpoint Page Count The WAL page count at which SQLite attempts a PASSIVE checkpoint, which transfers pages to the main database file without blocking writers. Defaults to checkpointThresholdMB * 250 (since SQLite page size is 4KB). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250 Litestream Multipart Concurrency The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY default: 48 Litestream Multipart Size The size of each part when uploading or downloading the snapshot with --litestream-multipart-concurrency. Note that up to concurrency * size bytes of memory are used when backing up or restoring the snapshot. flag: --litestream-multipart-size env: ZERO_LITESTREAM_MULTIPART_SIZE default: 16777216 (16 MiB) Litestream Log Level flag: --litestream-log-level env: ZERO_LITESTREAM_LOG_LEVEL default: warn values: debug, info, warn, error Litestream Port Port on which litestream exports metrics, used to determine the replication watermark up to which it is safe to purge change log records. flag: --litestream-port env: ZERO_LITESTREAM_PORT default: --port + 2 Litestream 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. This improves restore time at the expense of bandwidth. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12 Log Format Use text for developer-friendly console logging and json for consumption by structured-logging services. flag: --log-format env: ZERO_LOG_FORMAT default: \"text\" values: text, json Log IVM Sampling How often to collect IVM metrics. 1 out of N requests will be sampled where N is this value. flag: --log-ivm-sampling env: ZERO_LOG_IVM_SAMPLING default: 5000 Log Level Sets the logging level for the application. flag: --log-level env: ZERO_LOG_LEVEL default: \"info\" values: debug, info, warn, error Log Slow Hydrate Threshold The number of milliseconds a query hydration must take to print a slow warning. flag: --log-slow-hydrate-threshold env: ZERO_LOG_SLOW_HYDRATE_THRESHOLD default: 100 Log Slow Row Threshold The number of ms a row must take to fetch from table-source before it is considered slow. flag: --log-slow-row-threshold env: ZERO_LOG_SLOW_ROW_THRESHOLD default: 2 Mutate API Key An optional secret used to authorize zero-cache to call the API server handling writes. This is sent from zero-cache to your mutate endpoint in an X-Api-Key header. flag: --mutate-api-key env: ZERO_MUTATE_API_KEY Mutate 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 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 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", @@ -8034,7 +8142,7 @@ "kind": "page" }, { - "id": "573-zero-cache-config#required-flags", + "id": "581-zero-cache-config#required-flags", "title": "zero-cache Config", "searchTitle": "Required Flags", "sectionTitle": "Required Flags", @@ -8044,7 +8152,7 @@ "kind": "section" }, { - "id": "574-zero-cache-config#upstream-db", + "id": "582-zero-cache-config#upstream-db", "title": "zero-cache Config", "searchTitle": "Upstream DB", "sectionTitle": "Upstream DB", @@ -8054,7 +8162,7 @@ "kind": "section" }, { - "id": "575-zero-cache-config#admin-password", + "id": "583-zero-cache-config#admin-password", "title": "zero-cache Config", "searchTitle": "Admin Password", "sectionTitle": "Admin Password", @@ -8064,17 +8172,17 @@ "kind": "section" }, { - "id": "576-zero-cache-config#optional-flags", + "id": "584-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 Max Connections The maximum number of connections to open to the change database. This is used by the change-streamer for catching up zero-cache replication subscriptions. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS default: 5 Change Streamer Back 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 Padding Seconds During periodic flow control checks (every 64kb), this is the amount of time to wait after the majority of subscribers have acked, after which replication continues even if some subscribers have yet to ack. This is not a timeout for the entire send; it starts only after the majority of receivers have acked. This allows a bounded amount of time for backlogged subscribers to catch up on each flush without forcing all subscribers to wait for the entire backlog to be processed. It is also useful for mitigating the effect of unresponsive subscribers due to severed WebSocket connections until liveness checks disconnect them. Set this to a negative number to disable early flow control releases. flag: --change-streamer-flow-control-consensus-padding-seconds env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_PADDING_SECONDS default: 1 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. 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. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40 Litestream Config Path Path to the litestream yaml config file. zero-cache will run this with its environment variables, which can be referenced in the file via ${ENV} substitution, for example: ZERO_REPLICA_FILE for the db Path ZERO_LITESTREAM_BACKUP_LOCATION for the db replica url ZERO_LITESTREAM_LOG_LEVEL for the log Level ZERO_LOG_FORMAT for the log type flag: --litestream-config-path env: ZERO_LITESTREAM_CONFIG_PATH default: ./src/services/litestream/config.yml Litestream Executable Path to the litestream executable. This 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 Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Maximum Checkpoint Page Count The WAL page count at which SQLite performs a RESTART checkpoint, which blocks writers until complete. Defaults to minCheckpointPageCount * 10. Set to 0 to disable RESTART checkpoints entirely. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 Litestream Minimum Checkpoint Page Count The WAL page count at which SQLite attempts a PASSIVE checkpoint, which transfers pages to the main database file without blocking writers. Defaults to checkpointThresholdMB * 250 (since SQLite page size is 4KB). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250 Litestream Multipart Concurrency The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY default: 48 Litestream Multipart Size The size of each part when uploading or downloading the snapshot with --litestream-multipart-concurrency. Note that up to concurrency * size bytes of memory are used when backing up or restoring the snapshot. flag: --litestream-multipart-size env: ZERO_LITESTREAM_MULTIPART_SIZE default: 16777216 (16 MiB) Litestream Log Level flag: --litestream-log-level env: ZERO_LITESTREAM_LOG_LEVEL default: warn values: debug, info, warn, error Litestream Port Port on which litestream exports metrics, used to determine the replication watermark up to which it is safe to purge change log records. flag: --litestream-port env: ZERO_LITESTREAM_PORT default: --port + 2 Litestream 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. This improves restore time at the expense of bandwidth. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12 Log Format Use text for developer-friendly console logging and json for consumption by structured-logging services. flag: --log-format env: ZERO_LOG_FORMAT default: \"text\" values: text, json Log IVM Sampling How often to collect IVM metrics. 1 out of N requests will be sampled where N is this value. flag: --log-ivm-sampling env: ZERO_LOG_IVM_SAMPLING default: 5000 Log Level Sets the logging level for the application. flag: --log-level env: ZERO_LOG_LEVEL default: \"info\" values: debug, info, warn, error Log Slow Hydrate Threshold The number of milliseconds a query hydration must take to print a slow warning. flag: --log-slow-hydrate-threshold env: ZERO_LOG_SLOW_HYDRATE_THRESHOLD default: 100 Log Slow Row Threshold The number of ms a row must take to fetch from table-source before it is considered slow. flag: --log-slow-row-threshold env: ZERO_LOG_SLOW_ROW_THRESHOLD default: 2 Mutate API Key An optional secret used to authorize zero-cache to call the API server handling writes. This is sent from zero-cache to your mutate endpoint in an X-Api-Key header. flag: --mutate-api-key env: ZERO_MUTATE_API_KEY Mutate 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 and Failover. 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. Because replication lag reports are only issued after the previous one was received, the actual interval between reports may be longer when there is a backlog in the replication stream. 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 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": "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 Max Connections The maximum number of connections to open to the change database. This is used by the change-streamer for catching up zero-cache replication subscriptions. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS default: 5 Change Streamer Back 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 Padding Seconds During periodic flow control checks (every 64kb), this is the amount of time to wait after the majority of subscribers have acked, after which replication continues even if some subscribers have yet to ack. This is not a timeout for the entire send; it starts only after the majority of receivers have acked. This allows a bounded amount of time for backlogged subscribers to catch up on each flush without forcing all subscribers to wait for the entire backlog to be processed. It is also useful for mitigating the effect of unresponsive subscribers due to severed WebSocket connections until liveness checks disconnect them. Set this to a negative number to disable early flow control releases. flag: --change-streamer-flow-control-consensus-padding-seconds env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_PADDING_SECONDS default: 1 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. 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. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40 Litestream Config Path Path to the litestream yaml config file. zero-cache will run this with its environment variables, which can be referenced in the file via ${ENV} substitution, for example: ZERO_REPLICA_FILE for the db Path ZERO_LITESTREAM_BACKUP_LOCATION for the db replica url ZERO_LITESTREAM_LOG_LEVEL for the log Level ZERO_LOG_FORMAT for the log type flag: --litestream-config-path env: ZERO_LITESTREAM_CONFIG_PATH default: ./src/services/litestream/config.yml Litestream Executable Path to the litestream executable. This 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 Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Maximum Checkpoint Page Count The WAL page count at which SQLite performs a RESTART checkpoint, which blocks writers until complete. Defaults to minCheckpointPageCount * 10. Set to 0 to disable RESTART checkpoints entirely. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 Litestream Minimum Checkpoint Page Count The WAL page count at which SQLite attempts a PASSIVE checkpoint, which transfers pages to the main database file without blocking writers. Defaults to checkpointThresholdMB * 250 (since SQLite page size is 4KB). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250 Litestream Multipart Concurrency The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY default: 48 Litestream Multipart Size The size of each part when uploading or downloading the snapshot with --litestream-multipart-concurrency. Note that up to concurrency * size bytes of memory are used when backing up or restoring the snapshot. flag: --litestream-multipart-size env: ZERO_LITESTREAM_MULTIPART_SIZE default: 16777216 (16 MiB) Litestream Log Level flag: --litestream-log-level env: ZERO_LITESTREAM_LOG_LEVEL default: warn values: debug, info, warn, error Litestream Port Port on which litestream exports metrics, used to determine the replication watermark up to which it is safe to purge change log records. flag: --litestream-port env: ZERO_LITESTREAM_PORT default: --port + 2 Litestream 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. This improves restore time at the expense of bandwidth. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12 Log Format Use text for developer-friendly console logging and json for consumption by structured-logging services. flag: --log-format env: ZERO_LOG_FORMAT default: \"text\" values: text, json Log IVM Sampling How often to collect IVM metrics. 1 out of N requests will be sampled where N is this value. flag: --log-ivm-sampling env: ZERO_LOG_IVM_SAMPLING default: 5000 Log Level Sets the logging level for the application. flag: --log-level env: ZERO_LOG_LEVEL default: \"info\" values: debug, info, warn, error Log Slow Hydrate Threshold The number of milliseconds a query hydration must take to print a slow warning. flag: --log-slow-hydrate-threshold env: ZERO_LOG_SLOW_HYDRATE_THRESHOLD default: 100 Log Slow Row Threshold The number of ms a row must take to fetch from table-source before it is considered slow. flag: --log-slow-row-threshold env: ZERO_LOG_SLOW_ROW_THRESHOLD default: 2 Mutate API Key An optional secret used to authorize zero-cache to call the API server handling writes. This is sent from zero-cache to your mutate endpoint in an X-Api-Key header. flag: --mutate-api-key env: ZERO_MUTATE_API_KEY Mutate 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 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": "577-zero-cache-config#app-id", + "id": "585-zero-cache-config#app-id", "title": "zero-cache Config", "searchTitle": "App ID", "sectionTitle": "App ID", @@ -8084,7 +8192,7 @@ "kind": "section" }, { - "id": "578-zero-cache-config#app-publications", + "id": "586-zero-cache-config#app-publications", "title": "zero-cache Config", "searchTitle": "App Publications", "sectionTitle": "App Publications", @@ -8094,7 +8202,7 @@ "kind": "section" }, { - "id": "579-zero-cache-config#auth-revalidate-interval-seconds", + "id": "587-zero-cache-config#auth-revalidate-interval-seconds", "title": "zero-cache Config", "searchTitle": "Auth Revalidate Interval Seconds", "sectionTitle": "Auth Revalidate Interval Seconds", @@ -8104,7 +8212,7 @@ "kind": "section" }, { - "id": "580-zero-cache-config#auth-retransform-interval-seconds", + "id": "588-zero-cache-config#auth-retransform-interval-seconds", "title": "zero-cache Config", "searchTitle": "Auth Retransform Interval Seconds", "sectionTitle": "Auth Retransform Interval Seconds", @@ -8114,7 +8222,7 @@ "kind": "section" }, { - "id": "581-zero-cache-config#auto-reset", + "id": "589-zero-cache-config#auto-reset", "title": "zero-cache Config", "searchTitle": "Auto Reset", "sectionTitle": "Auto Reset", @@ -8124,7 +8232,7 @@ "kind": "section" }, { - "id": "582-zero-cache-config#change-db", + "id": "590-zero-cache-config#change-db", "title": "zero-cache Config", "searchTitle": "Change DB", "sectionTitle": "Change DB", @@ -8134,7 +8242,7 @@ "kind": "section" }, { - "id": "583-zero-cache-config#change-max-connections", + "id": "591-zero-cache-config#change-max-connections", "title": "zero-cache Config", "searchTitle": "Change Max Connections", "sectionTitle": "Change Max Connections", @@ -8144,7 +8252,7 @@ "kind": "section" }, { - "id": "584-zero-cache-config#change-streamer-back-pressure-limit-heap-proportion", + "id": "592-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", @@ -8154,7 +8262,7 @@ "kind": "section" }, { - "id": "585-zero-cache-config#change-streamer-flow-control-consensus-padding-seconds", + "id": "593-zero-cache-config#change-streamer-flow-control-consensus-padding-seconds", "title": "zero-cache Config", "searchTitle": "Change Streamer Flow Control Consensus Padding Seconds", "sectionTitle": "Change Streamer Flow Control Consensus Padding Seconds", @@ -8164,7 +8272,7 @@ "kind": "section" }, { - "id": "586-zero-cache-config#change-streamer-mode", + "id": "594-zero-cache-config#change-streamer-mode", "title": "zero-cache Config", "searchTitle": "Change Streamer Mode", "sectionTitle": "Change Streamer Mode", @@ -8174,7 +8282,7 @@ "kind": "section" }, { - "id": "587-zero-cache-config#change-streamer-port", + "id": "595-zero-cache-config#change-streamer-port", "title": "zero-cache Config", "searchTitle": "Change Streamer Port", "sectionTitle": "Change Streamer Port", @@ -8184,7 +8292,7 @@ "kind": "section" }, { - "id": "588-zero-cache-config#change-streamer-startup-delay-ms", + "id": "596-zero-cache-config#change-streamer-startup-delay-ms", "title": "zero-cache Config", "searchTitle": "Change Streamer Startup Delay (ms)", "sectionTitle": "Change Streamer Startup Delay (ms)", @@ -8194,7 +8302,7 @@ "kind": "section" }, { - "id": "589-zero-cache-config#change-streamer-uri", + "id": "597-zero-cache-config#change-streamer-uri", "title": "zero-cache Config", "searchTitle": "Change Streamer URI", "sectionTitle": "Change Streamer URI", @@ -8204,7 +8312,7 @@ "kind": "section" }, { - "id": "590-zero-cache-config#cvr-db", + "id": "598-zero-cache-config#cvr-db", "title": "zero-cache Config", "searchTitle": "CVR DB", "sectionTitle": "CVR DB", @@ -8214,7 +8322,7 @@ "kind": "section" }, { - "id": "591-zero-cache-config#cvr-garbage-collection-inactivity-threshold-hours", + "id": "599-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", @@ -8224,7 +8332,7 @@ "kind": "section" }, { - "id": "592-zero-cache-config#cvr-garbage-collection-initial-batch-size", + "id": "600-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", @@ -8234,7 +8342,7 @@ "kind": "section" }, { - "id": "593-zero-cache-config#cvr-garbage-collection-initial-interval-seconds", + "id": "601-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", @@ -8244,7 +8352,7 @@ "kind": "section" }, { - "id": "594-zero-cache-config#cvr-max-connections", + "id": "602-zero-cache-config#cvr-max-connections", "title": "zero-cache Config", "searchTitle": "CVR Max Connections", "sectionTitle": "CVR Max Connections", @@ -8254,7 +8362,7 @@ "kind": "section" }, { - "id": "595-zero-cache-config#enable-query-planner", + "id": "603-zero-cache-config#enable-query-planner", "title": "zero-cache Config", "searchTitle": "Enable Query Planner", "sectionTitle": "Enable Query Planner", @@ -8264,7 +8372,7 @@ "kind": "section" }, { - "id": "596-zero-cache-config#enable-crud-mutations", + "id": "604-zero-cache-config#enable-crud-mutations", "title": "zero-cache Config", "searchTitle": "Enable CRUD Mutations", "sectionTitle": "Enable CRUD Mutations", @@ -8274,7 +8382,7 @@ "kind": "section" }, { - "id": "597-zero-cache-config#enable-telemetry", + "id": "605-zero-cache-config#enable-telemetry", "title": "zero-cache Config", "searchTitle": "Enable Telemetry", "sectionTitle": "Enable Telemetry", @@ -8284,7 +8392,7 @@ "kind": "section" }, { - "id": "598-zero-cache-config#initial-sync-table-copy-workers", + "id": "606-zero-cache-config#initial-sync-table-copy-workers", "title": "zero-cache Config", "searchTitle": "Initial Sync Table Copy Workers", "sectionTitle": "Initial Sync Table Copy Workers", @@ -8294,7 +8402,7 @@ "kind": "section" }, { - "id": "599-zero-cache-config#lazy-startup", + "id": "607-zero-cache-config#lazy-startup", "title": "zero-cache Config", "searchTitle": "Lazy Startup", "sectionTitle": "Lazy Startup", @@ -8304,7 +8412,7 @@ "kind": "section" }, { - "id": "600-zero-cache-config#litestream-backup-url", + "id": "608-zero-cache-config#litestream-backup-url", "title": "zero-cache Config", "searchTitle": "Litestream Backup URL", "sectionTitle": "Litestream Backup URL", @@ -8314,7 +8422,7 @@ "kind": "section" }, { - "id": "601-zero-cache-config#litestream-endpoint", + "id": "609-zero-cache-config#litestream-endpoint", "title": "zero-cache Config", "searchTitle": "Litestream Endpoint", "sectionTitle": "Litestream Endpoint", @@ -8324,7 +8432,7 @@ "kind": "section" }, { - "id": "602-zero-cache-config#litestream-checkpoint-threshold-mb", + "id": "610-zero-cache-config#litestream-checkpoint-threshold-mb", "title": "zero-cache Config", "searchTitle": "Litestream Checkpoint Threshold MB", "sectionTitle": "Litestream Checkpoint Threshold MB", @@ -8334,7 +8442,7 @@ "kind": "section" }, { - "id": "603-zero-cache-config#litestream-config-path", + "id": "611-zero-cache-config#litestream-config-path", "title": "zero-cache Config", "searchTitle": "Litestream Config Path", "sectionTitle": "Litestream Config Path", @@ -8344,7 +8452,7 @@ "kind": "section" }, { - "id": "604-zero-cache-config#litestream-executable", + "id": "612-zero-cache-config#litestream-executable", "title": "zero-cache Config", "searchTitle": "Litestream Executable", "sectionTitle": "Litestream Executable", @@ -8354,7 +8462,7 @@ "kind": "section" }, { - "id": "605-zero-cache-config#litestream-incremental-backup-interval-minutes", + "id": "613-zero-cache-config#litestream-incremental-backup-interval-minutes", "title": "zero-cache Config", "searchTitle": "Litestream Incremental Backup Interval Minutes", "sectionTitle": "Litestream Incremental Backup Interval Minutes", @@ -8364,7 +8472,7 @@ "kind": "section" }, { - "id": "606-zero-cache-config#litestream-maximum-checkpoint-page-count", + "id": "614-zero-cache-config#litestream-maximum-checkpoint-page-count", "title": "zero-cache Config", "searchTitle": "Litestream Maximum Checkpoint Page Count", "sectionTitle": "Litestream Maximum Checkpoint Page Count", @@ -8374,7 +8482,7 @@ "kind": "section" }, { - "id": "607-zero-cache-config#litestream-minimum-checkpoint-page-count", + "id": "615-zero-cache-config#litestream-minimum-checkpoint-page-count", "title": "zero-cache Config", "searchTitle": "Litestream Minimum Checkpoint Page Count", "sectionTitle": "Litestream Minimum Checkpoint Page Count", @@ -8384,7 +8492,7 @@ "kind": "section" }, { - "id": "608-zero-cache-config#litestream-multipart-concurrency", + "id": "616-zero-cache-config#litestream-multipart-concurrency", "title": "zero-cache Config", "searchTitle": "Litestream Multipart Concurrency", "sectionTitle": "Litestream Multipart Concurrency", @@ -8394,7 +8502,7 @@ "kind": "section" }, { - "id": "609-zero-cache-config#litestream-multipart-size", + "id": "617-zero-cache-config#litestream-multipart-size", "title": "zero-cache Config", "searchTitle": "Litestream Multipart Size", "sectionTitle": "Litestream Multipart Size", @@ -8404,7 +8512,7 @@ "kind": "section" }, { - "id": "610-zero-cache-config#litestream-log-level", + "id": "618-zero-cache-config#litestream-log-level", "title": "zero-cache Config", "searchTitle": "Litestream Log Level", "sectionTitle": "Litestream Log Level", @@ -8414,7 +8522,7 @@ "kind": "section" }, { - "id": "611-zero-cache-config#litestream-port", + "id": "619-zero-cache-config#litestream-port", "title": "zero-cache Config", "searchTitle": "Litestream Port", "sectionTitle": "Litestream Port", @@ -8424,7 +8532,7 @@ "kind": "section" }, { - "id": "612-zero-cache-config#litestream-region", + "id": "620-zero-cache-config#litestream-region", "title": "zero-cache Config", "searchTitle": "Litestream Region", "sectionTitle": "Litestream Region", @@ -8434,7 +8542,7 @@ "kind": "section" }, { - "id": "613-zero-cache-config#litestream-restore-parallelism", + "id": "621-zero-cache-config#litestream-restore-parallelism", "title": "zero-cache Config", "searchTitle": "Litestream Restore Parallelism", "sectionTitle": "Litestream Restore Parallelism", @@ -8444,7 +8552,7 @@ "kind": "section" }, { - "id": "614-zero-cache-config#litestream-snapshot-backup-interval-hours", + "id": "622-zero-cache-config#litestream-snapshot-backup-interval-hours", "title": "zero-cache Config", "searchTitle": "Litestream Snapshot Backup Interval Hours", "sectionTitle": "Litestream Snapshot Backup Interval Hours", @@ -8454,7 +8562,7 @@ "kind": "section" }, { - "id": "615-zero-cache-config#log-format", + "id": "623-zero-cache-config#log-format", "title": "zero-cache Config", "searchTitle": "Log Format", "sectionTitle": "Log Format", @@ -8464,7 +8572,7 @@ "kind": "section" }, { - "id": "616-zero-cache-config#log-ivm-sampling", + "id": "624-zero-cache-config#log-ivm-sampling", "title": "zero-cache Config", "searchTitle": "Log IVM Sampling", "sectionTitle": "Log IVM Sampling", @@ -8474,7 +8582,7 @@ "kind": "section" }, { - "id": "617-zero-cache-config#log-level", + "id": "625-zero-cache-config#log-level", "title": "zero-cache Config", "searchTitle": "Log Level", "sectionTitle": "Log Level", @@ -8484,7 +8592,7 @@ "kind": "section" }, { - "id": "618-zero-cache-config#log-slow-hydrate-threshold", + "id": "626-zero-cache-config#log-slow-hydrate-threshold", "title": "zero-cache Config", "searchTitle": "Log Slow Hydrate Threshold", "sectionTitle": "Log Slow Hydrate Threshold", @@ -8494,7 +8602,7 @@ "kind": "section" }, { - "id": "619-zero-cache-config#log-slow-row-threshold", + "id": "627-zero-cache-config#log-slow-row-threshold", "title": "zero-cache Config", "searchTitle": "Log Slow Row Threshold", "sectionTitle": "Log Slow Row Threshold", @@ -8504,7 +8612,7 @@ "kind": "section" }, { - "id": "620-zero-cache-config#mutate-api-key", + "id": "628-zero-cache-config#mutate-api-key", "title": "zero-cache Config", "searchTitle": "Mutate API Key", "sectionTitle": "Mutate API Key", @@ -8514,7 +8622,7 @@ "kind": "section" }, { - "id": "621-zero-cache-config#mutate-allowed-client-headers", + "id": "629-zero-cache-config#mutate-allowed-client-headers", "title": "zero-cache Config", "searchTitle": "Mutate Allowed Client Headers", "sectionTitle": "Mutate Allowed Client Headers", @@ -8524,7 +8632,7 @@ "kind": "section" }, { - "id": "622-zero-cache-config#mutate-allowed-request-headers", + "id": "630-zero-cache-config#mutate-allowed-request-headers", "title": "zero-cache Config", "searchTitle": "Mutate Allowed Request Headers", "sectionTitle": "Mutate Allowed Request Headers", @@ -8534,7 +8642,7 @@ "kind": "section" }, { - "id": "623-zero-cache-config#mutate-forward-cookies", + "id": "631-zero-cache-config#mutate-forward-cookies", "title": "zero-cache Config", "searchTitle": "Mutate Forward Cookies", "sectionTitle": "Mutate Forward Cookies", @@ -8544,7 +8652,7 @@ "kind": "section" }, { - "id": "624-zero-cache-config#mutate-url", + "id": "632-zero-cache-config#mutate-url", "title": "zero-cache Config", "searchTitle": "Mutate URL", "sectionTitle": "Mutate URL", @@ -8554,7 +8662,7 @@ "kind": "section" }, { - "id": "625-zero-cache-config#number-of-sync-workers", + "id": "633-zero-cache-config#number-of-sync-workers", "title": "zero-cache Config", "searchTitle": "Number of Sync Workers", "sectionTitle": "Number of Sync Workers", @@ -8564,7 +8672,7 @@ "kind": "section" }, { - "id": "626-zero-cache-config#per-user-mutation-limit-max", + "id": "634-zero-cache-config#per-user-mutation-limit-max", "title": "zero-cache Config", "searchTitle": "Per User Mutation Limit Max", "sectionTitle": "Per User Mutation Limit Max", @@ -8574,7 +8682,7 @@ "kind": "section" }, { - "id": "627-zero-cache-config#per-user-mutation-limit-window-ms", + "id": "635-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)", @@ -8584,17 +8692,17 @@ "kind": "section" }, { - "id": "628-zero-cache-config#pg-replication-slot-failover", + "id": "636-zero-cache-config#pg-replication-slot-failover", "title": "zero-cache Config", "searchTitle": "PG Replication Slot Failover", "sectionTitle": "PG Replication Slot Failover", "sectionId": "pg-replication-slot-failover", "url": "/docs/zero-cache-config", - "content": "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 and Failover. Has no effect on Postgres versions before 17. flag: --upstream-pg-replication-slot-failover env: ZERO_UPSTREAM_PG_REPLICATION_SLOT_FAILOVER default: false", + "content": "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", "kind": "section" }, { - "id": "629-zero-cache-config#port", + "id": "637-zero-cache-config#port", "title": "zero-cache Config", "searchTitle": "Port", "sectionTitle": "Port", @@ -8604,7 +8712,7 @@ "kind": "section" }, { - "id": "630-zero-cache-config#query-api-key", + "id": "638-zero-cache-config#query-api-key", "title": "zero-cache Config", "searchTitle": "Query API Key", "sectionTitle": "Query API Key", @@ -8614,7 +8722,7 @@ "kind": "section" }, { - "id": "631-zero-cache-config#query-allowed-client-headers", + "id": "639-zero-cache-config#query-allowed-client-headers", "title": "zero-cache Config", "searchTitle": "Query Allowed Client Headers", "sectionTitle": "Query Allowed Client Headers", @@ -8624,7 +8732,7 @@ "kind": "section" }, { - "id": "632-zero-cache-config#query-allowed-request-headers", + "id": "640-zero-cache-config#query-allowed-request-headers", "title": "zero-cache Config", "searchTitle": "Query Allowed Request Headers", "sectionTitle": "Query Allowed Request Headers", @@ -8634,7 +8742,7 @@ "kind": "section" }, { - "id": "633-zero-cache-config#query-forward-cookies", + "id": "641-zero-cache-config#query-forward-cookies", "title": "zero-cache Config", "searchTitle": "Query Forward Cookies", "sectionTitle": "Query Forward Cookies", @@ -8644,7 +8752,7 @@ "kind": "section" }, { - "id": "634-zero-cache-config#query-hydration-stats", + "id": "642-zero-cache-config#query-hydration-stats", "title": "zero-cache Config", "searchTitle": "Query Hydration Stats", "sectionTitle": "Query Hydration Stats", @@ -8654,7 +8762,7 @@ "kind": "section" }, { - "id": "635-zero-cache-config#query-url", + "id": "643-zero-cache-config#query-url", "title": "zero-cache Config", "searchTitle": "Query URL", "sectionTitle": "Query URL", @@ -8664,7 +8772,7 @@ "kind": "section" }, { - "id": "636-zero-cache-config#replica-file", + "id": "644-zero-cache-config#replica-file", "title": "zero-cache Config", "searchTitle": "Replica File", "sectionTitle": "Replica File", @@ -8674,7 +8782,7 @@ "kind": "section" }, { - "id": "637-zero-cache-config#replica-vacuum-interval-hours", + "id": "645-zero-cache-config#replica-vacuum-interval-hours", "title": "zero-cache Config", "searchTitle": "Replica Vacuum Interval Hours", "sectionTitle": "Replica Vacuum Interval Hours", @@ -8684,17 +8792,17 @@ "kind": "section" }, { - "id": "638-zero-cache-config#replication-lag-report-interval-ms", + "id": "646-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. Because replication lag reports are only issued after the previous one was received, the actual interval between reports may be longer when there is a backlog in the replication stream. 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 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", "kind": "section" }, { - "id": "639-zero-cache-config#server-version", + "id": "647-zero-cache-config#server-version", "title": "zero-cache Config", "searchTitle": "Server Version", "sectionTitle": "Server Version", @@ -8704,7 +8812,7 @@ "kind": "section" }, { - "id": "640-zero-cache-config#shadow-sync-enabled", + "id": "648-zero-cache-config#shadow-sync-enabled", "title": "zero-cache Config", "searchTitle": "Shadow Sync Enabled", "sectionTitle": "Shadow Sync Enabled", @@ -8714,7 +8822,7 @@ "kind": "section" }, { - "id": "641-zero-cache-config#shadow-sync-interval-hours", + "id": "649-zero-cache-config#shadow-sync-interval-hours", "title": "zero-cache Config", "searchTitle": "Shadow Sync Interval Hours", "sectionTitle": "Shadow Sync Interval Hours", @@ -8724,7 +8832,7 @@ "kind": "section" }, { - "id": "642-zero-cache-config#shadow-sync-sample-rate", + "id": "650-zero-cache-config#shadow-sync-sample-rate", "title": "zero-cache Config", "searchTitle": "Shadow Sync Sample Rate", "sectionTitle": "Shadow Sync Sample Rate", @@ -8734,7 +8842,7 @@ "kind": "section" }, { - "id": "643-zero-cache-config#shadow-sync-max-rows-per-table", + "id": "651-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", @@ -8744,7 +8852,7 @@ "kind": "section" }, { - "id": "644-zero-cache-config#storage-db-temp-dir", + "id": "652-zero-cache-config#storage-db-temp-dir", "title": "zero-cache Config", "searchTitle": "Storage DB Temp Dir", "sectionTitle": "Storage DB Temp Dir", @@ -8754,7 +8862,7 @@ "kind": "section" }, { - "id": "645-zero-cache-config#task-id", + "id": "653-zero-cache-config#task-id", "title": "zero-cache Config", "searchTitle": "Task ID", "sectionTitle": "Task ID", @@ -8764,7 +8872,7 @@ "kind": "section" }, { - "id": "646-zero-cache-config#upstream-max-connections", + "id": "654-zero-cache-config#upstream-max-connections", "title": "zero-cache Config", "searchTitle": "Upstream Max Connections", "sectionTitle": "Upstream Max Connections", @@ -8774,7 +8882,7 @@ "kind": "section" }, { - "id": "647-zero-cache-config#upstream-pg-replication-slot-failover", + "id": "655-zero-cache-config#upstream-pg-replication-slot-failover", "title": "zero-cache Config", "searchTitle": "Upstream PG Replication Slot Failover", "sectionTitle": "Upstream PG Replication Slot Failover", @@ -8784,7 +8892,7 @@ "kind": "section" }, { - "id": "648-zero-cache-config#websocket-compression", + "id": "656-zero-cache-config#websocket-compression", "title": "zero-cache Config", "searchTitle": "Websocket Compression", "sectionTitle": "Websocket Compression", @@ -8794,7 +8902,7 @@ "kind": "section" }, { - "id": "649-zero-cache-config#websocket-compression-options", + "id": "657-zero-cache-config#websocket-compression-options", "title": "zero-cache Config", "searchTitle": "Websocket Compression Options", "sectionTitle": "Websocket Compression Options", @@ -8804,7 +8912,7 @@ "kind": "section" }, { - "id": "650-zero-cache-config#websocket-max-payload-bytes", + "id": "658-zero-cache-config#websocket-max-payload-bytes", "title": "zero-cache Config", "searchTitle": "Websocket Max Payload Bytes", "sectionTitle": "Websocket Max Payload Bytes", @@ -8814,7 +8922,7 @@ "kind": "section" }, { - "id": "651-zero-cache-config#yield-threshold-ms", + "id": "659-zero-cache-config#yield-threshold-ms", "title": "zero-cache Config", "searchTitle": "Yield Threshold (ms)", "sectionTitle": "Yield Threshold (ms)", @@ -8824,7 +8932,7 @@ "kind": "section" }, { - "id": "652-zero-cache-config#deprecated-flags", + "id": "660-zero-cache-config#deprecated-flags", "title": "zero-cache Config", "searchTitle": "Deprecated Flags", "sectionTitle": "Deprecated Flags", @@ -8834,7 +8942,7 @@ "kind": "section" }, { - "id": "653-zero-cache-config#auth-jwk", + "id": "661-zero-cache-config#auth-jwk", "title": "zero-cache Config", "searchTitle": "Auth JWK", "sectionTitle": "Auth JWK", @@ -8844,7 +8952,7 @@ "kind": "section" }, { - "id": "654-zero-cache-config#auth-jwks-url", + "id": "662-zero-cache-config#auth-jwks-url", "title": "zero-cache Config", "searchTitle": "Auth JWKS URL", "sectionTitle": "Auth JWKS URL", @@ -8854,7 +8962,7 @@ "kind": "section" }, { - "id": "655-zero-cache-config#auth-secret", + "id": "663-zero-cache-config#auth-secret", "title": "zero-cache Config", "searchTitle": "Auth Secret", "sectionTitle": "Auth Secret", @@ -8864,7 +8972,7 @@ "kind": "section" }, { - "id": "75-zql", + "id": "76-zql", "title": "ZQL", "searchTitle": "ZQL", "url": "/docs/zql", @@ -8974,7 +9082,7 @@ "kind": "page" }, { - "id": "656-zql#create-a-builder", + "id": "664-zql#create-a-builder", "title": "ZQL", "searchTitle": "Create a Builder", "sectionTitle": "Create a Builder", @@ -8984,7 +9092,7 @@ "kind": "section" }, { - "id": "657-zql#select", + "id": "665-zql#select", "title": "ZQL", "searchTitle": "Select", "sectionTitle": "Select", @@ -8994,7 +9102,7 @@ "kind": "section" }, { - "id": "658-zql#ordering", + "id": "666-zql#ordering", "title": "ZQL", "searchTitle": "Ordering", "sectionTitle": "Ordering", @@ -9004,7 +9112,7 @@ "kind": "section" }, { - "id": "659-zql#limit", + "id": "667-zql#limit", "title": "ZQL", "searchTitle": "Limit", "sectionTitle": "Limit", @@ -9014,7 +9122,7 @@ "kind": "section" }, { - "id": "660-zql#paging", + "id": "668-zql#paging", "title": "ZQL", "searchTitle": "Paging", "sectionTitle": "Paging", @@ -9024,7 +9132,7 @@ "kind": "section" }, { - "id": "661-zql#getting-a-single-result", + "id": "669-zql#getting-a-single-result", "title": "ZQL", "searchTitle": "Getting a Single Result", "sectionTitle": "Getting a Single Result", @@ -9034,7 +9142,7 @@ "kind": "section" }, { - "id": "662-zql#relationships", + "id": "670-zql#relationships", "title": "ZQL", "searchTitle": "Relationships", "sectionTitle": "Relationships", @@ -9044,7 +9152,7 @@ "kind": "section" }, { - "id": "663-zql#refining-relationships", + "id": "671-zql#refining-relationships", "title": "ZQL", "searchTitle": "Refining Relationships", "sectionTitle": "Refining Relationships", @@ -9054,7 +9162,7 @@ "kind": "section" }, { - "id": "664-zql#nested-relationships", + "id": "672-zql#nested-relationships", "title": "ZQL", "searchTitle": "Nested Relationships", "sectionTitle": "Nested Relationships", @@ -9064,7 +9172,7 @@ "kind": "section" }, { - "id": "665-zql#where", + "id": "673-zql#where", "title": "ZQL", "searchTitle": "Where", "sectionTitle": "Where", @@ -9074,7 +9182,7 @@ "kind": "section" }, { - "id": "666-zql#comparison-operators", + "id": "674-zql#comparison-operators", "title": "ZQL", "searchTitle": "Comparison Operators", "sectionTitle": "Comparison Operators", @@ -9084,7 +9192,7 @@ "kind": "section" }, { - "id": "667-zql#equals-is-the-default-comparison-operator", + "id": "675-zql#equals-is-the-default-comparison-operator", "title": "ZQL", "searchTitle": "Equals is the Default Comparison Operator", "sectionTitle": "Equals is the Default Comparison Operator", @@ -9094,7 +9202,7 @@ "kind": "section" }, { - "id": "668-zql#comparing-to-null", + "id": "676-zql#comparing-to-null", "title": "ZQL", "searchTitle": "Comparing to null", "sectionTitle": "Comparing to null", @@ -9104,7 +9212,7 @@ "kind": "section" }, { - "id": "669-zql#comparing-to-undefined", + "id": "677-zql#comparing-to-undefined", "title": "ZQL", "searchTitle": "Comparing to undefined", "sectionTitle": "Comparing to undefined", @@ -9114,7 +9222,7 @@ "kind": "section" }, { - "id": "670-zql#compound-filters", + "id": "678-zql#compound-filters", "title": "ZQL", "searchTitle": "Compound Filters", "sectionTitle": "Compound Filters", @@ -9124,7 +9232,7 @@ "kind": "section" }, { - "id": "671-zql#comparing-literal-values", + "id": "679-zql#comparing-literal-values", "title": "ZQL", "searchTitle": "Comparing Literal Values", "sectionTitle": "Comparing Literal Values", @@ -9134,7 +9242,7 @@ "kind": "section" }, { - "id": "672-zql#relationship-filters", + "id": "680-zql#relationship-filters", "title": "ZQL", "searchTitle": "Relationship Filters", "sectionTitle": "Relationship Filters", @@ -9144,7 +9252,7 @@ "kind": "section" }, { - "id": "673-zql#type-helpers", + "id": "681-zql#type-helpers", "title": "ZQL", "searchTitle": "Type Helpers", "sectionTitle": "Type Helpers", @@ -9154,7 +9262,7 @@ "kind": "section" }, { - "id": "674-zql#planning", + "id": "682-zql#planning", "title": "ZQL", "searchTitle": "Planning", "sectionTitle": "Planning", @@ -9164,7 +9272,7 @@ "kind": "section" }, { - "id": "675-zql#inspecting-query-plans", + "id": "683-zql#inspecting-query-plans", "title": "ZQL", "searchTitle": "Inspecting Query Plans", "sectionTitle": "Inspecting Query Plans", @@ -9174,7 +9282,7 @@ "kind": "section" }, { - "id": "676-zql#manually-flipping-joins", + "id": "684-zql#manually-flipping-joins", "title": "ZQL", "searchTitle": "Manually Flipping Joins", "sectionTitle": "Manually Flipping Joins", @@ -9184,7 +9292,7 @@ "kind": "section" }, { - "id": "677-zql#scalar-subqueries", + "id": "685-zql#scalar-subqueries", "title": "ZQL", "searchTitle": "Scalar Subqueries", "sectionTitle": "Scalar Subqueries", @@ -9194,7 +9302,7 @@ "kind": "section" }, { - "id": "678-zql#why-it-matters", + "id": "686-zql#why-it-matters", "title": "ZQL", "searchTitle": "Why It Matters", "sectionTitle": "Why It Matters", @@ -9204,7 +9312,7 @@ "kind": "section" }, { - "id": "679-zql#trade-offs", + "id": "687-zql#trade-offs", "title": "ZQL", "searchTitle": "Trade-offs", "sectionTitle": "Trade-offs", @@ -9214,7 +9322,7 @@ "kind": "section" }, { - "id": "680-zql#future-work", + "id": "688-zql#future-work", "title": "ZQL", "searchTitle": "Future Work", "sectionTitle": "Future Work", diff --git a/contents/docs/connecting-to-postgres.mdx b/contents/docs/connecting-to-postgres.mdx index 0f86aadb..39e79f38 100644 --- a/contents/docs/connecting-to-postgres.mdx +++ b/contents/docs/connecting-to-postgres.mdx @@ -57,6 +57,18 @@ After your server restarts, show the `wal_level` again to ensure it has changed: psql -c 'SHOW wal_level' ``` +### Socket Inactivity Timeout + +`zero-cache` monitors wire activity on its Postgres connections so it can recover when a proxy or network failure leaves a half-open socket. The watchdog samples each connection every 120,000 milliseconds by default and resets it after one to two intervals without any bytes read or written. In-flight queries on a reset connection are rejected and can recover through their normal retry or restart paths. + +Wire activity resets the watchdog, so streaming operations such as `COPY` remain active. A statement that legitimately computes without sending any data for several minutes can be interrupted. Set `ZERO_PG_SOCKET_INACTIVITY_TIMEOUT` to a longer sampling interval in milliseconds when running such statements: + +```bash +ZERO_PG_SOCKET_INACTIVITY_TIMEOUT=600000 +``` + +Set the value to `0` to disable the watchdog. + ### Bounding WAL Size For development databases, you can set a `max_slot_wal_keep_size` value in Postgres. This will help limit the amount of WAL kept around. diff --git a/contents/docs/otel.mdx b/contents/docs/otel.mdx index 485c9298..ca49af39 100644 --- a/contents/docs/otel.mdx +++ b/contents/docs/otel.mdx @@ -152,78 +152,84 @@ This callback is called before sending WebSocket messages that trigger API serve ### zero.replication -| Metric | Type | Unit | Description | -| ------------------------------------ | --------- | ----- | ------------------------------------------------------------------------------------------------------------------- | -| `upstream_lag` | Gauge | ms | Latency from sending a replication report to receiving it in the stream | -| `replica_lag` | Gauge | ms | Latency from receiving a replication report to it reaching the replica | -| `total_lag` | Gauge | ms | End-to-end replication latency. Grows as an estimate if the next report hasn't arrived | -| `last_total_lag` | Gauge | ms | End-to-end latency of the most recently received report. Unlike `total_lag`, does not grow if reports stop arriving | -| `events` | Counter | | Number of replication events processed | -| `transactions` | Counter | | Count of replicated transactions | -| `changes` | Counter | | Count of replicated changes, including DML and DDL statements | -| `slot_health` | Gauge | 1 | One-hot status for the active logical replication slot: `ok`, `unreserved`, `lost`, `missing`, or `unknown` | -| `slot_retained_wal_bytes` | Gauge | bytes | WAL bytes retained by the active logical replication slot | -| `slot_safe_wal_bytes` | Gauge | bytes | Remaining WAL capacity before the active logical replication slot is lost; omitted when Postgres reports no value | -| `initial_sync_runs` | Counter | | Number of initial-sync runs | -| `initial_sync_duration` | Histogram | s | Wall-clock duration of an initial-sync run | -| `initial_sync_copy_duration` | Histogram | s | Wall-clock duration of the COPY phase for a successful initial-sync run | -| `initial_sync_copy_other_duration` | Histogram | s | Initial-sync duration excluding SQLite flush and index time for a successful run | -| `initial_sync_flush_duration` | Histogram | s | Total SQLite flush time for a successful initial-sync run | -| `initial_sync_index_duration` | Histogram | s | SQLite index creation time for a successful initial-sync run | -| `initial_sync_rows` | Counter | | Rows copied during successful initial-sync runs | -| `initial_sync_copy_stream` | Counter | bytes | PostgreSQL COPY stream bytes processed during initial sync, including in-progress and failed runs | -| `initial_sync_completed_copy_stream` | Counter | bytes | PostgreSQL COPY stream bytes processed during successful initial-sync runs | -| `initial_sync_copy_chunks` | Counter | | PostgreSQL COPY stream chunks processed during initial sync | -| `shadow-sync-runs` | Counter | | Number of [shadow initial-sync](/docs/zero-cache-config#shadow-sync-enabled) runs, labeled by `result` | -| `shadow-sync-duration` | Histogram | s | Wall-clock duration of a shadow initial-sync run, labeled by `result` | -| `flow_control.active_subscribers` | Gauge | | Active change-stream subscribers receiving live changes | -| `flow_control.queued_subscribers` | Gauge | | Change-stream subscribers waiting for the current transaction to finish before activation | -| `flow_control.pending_messages` | Gauge | | Downstream change-stream messages not yet acknowledged by subscribers | -| `flow_control.backlog_messages` | Gauge | | Live change-stream messages buffered while subscribers catch up | -| `flow_control.backlog_bytes` | Gauge | bytes | Live change-stream bytes buffered while subscribers catch up | -| `flow_control.max_backlog_bytes` | Gauge | bytes | Maximum live change-stream bytes buffered by a single subscriber | -| `flow_control.waits` | Counter | | Completed flow-control checkpoints | -| `flow_control.wait_duration` | Histogram | s | Time replication waits at flow-control checkpoints | +| Metric | Type | Unit | Description | +| ------------------------------------ | --------- | ----- | -------------------------------------------------------------------------------------------------------------------- | +| `upstream_lag` | Gauge | ms | Latency from sending a replication report to receiving it in the stream | +| `replica_lag` | Gauge | ms | Latency from receiving a replication report to it reaching the replica | +| `total_lag` | Gauge | ms | Measured end-to-end latency of the most recently received replication report; does not grow if reports stop arriving | +| `last_total_lag` | Gauge | ms | Alias of `total_lag`, retained for dashboards that explicitly use the non-extrapolated metric | +| `lag_report_retries` | Counter | | Replication lag reports retried because an expected report did not arrive before the next report interval | +| `events` | Counter | | Number of replication events processed | +| `transactions` | Counter | | Count of replicated transactions | +| `changes` | Counter | | Count of replicated changes, including DML and DDL statements | +| `slot_health` | Gauge | 1 | One-hot status for the active logical replication slot: `ok`, `unreserved`, `lost`, `missing`, or `unknown` | +| `slot_retained_wal_bytes` | Gauge | bytes | WAL bytes retained by the active logical replication slot | +| `slot_safe_wal_bytes` | Gauge | bytes | Remaining WAL capacity before the active logical replication slot is lost; omitted when Postgres reports no value | +| `initial_sync_runs` | Counter | | Number of initial-sync runs | +| `initial_sync_duration` | Histogram | s | Wall-clock duration of an initial-sync run | +| `initial_sync_copy_duration` | Histogram | s | Wall-clock duration of the COPY phase for a successful initial-sync run | +| `initial_sync_copy_other_duration` | Histogram | s | Initial-sync duration excluding SQLite flush and index time for a successful run | +| `initial_sync_flush_duration` | Histogram | s | Total SQLite flush time for a successful initial-sync run | +| `initial_sync_index_duration` | Histogram | s | SQLite index creation time for a successful initial-sync run | +| `initial_sync_rows` | Counter | | Rows copied during successful initial-sync runs | +| `initial_sync_copy_stream` | Counter | bytes | PostgreSQL COPY stream bytes processed during initial sync, including in-progress and failed runs | +| `initial_sync_completed_copy_stream` | Counter | bytes | PostgreSQL COPY stream bytes processed during successful initial-sync runs | +| `initial_sync_copy_chunks` | Counter | | PostgreSQL COPY stream chunks processed during initial sync | +| `shadow-sync-runs` | Counter | | Number of [shadow initial-sync](/docs/zero-cache-config#shadow-sync-enabled) runs, labeled by `result` | +| `shadow-sync-duration` | Histogram | s | Wall-clock duration of a shadow initial-sync run, labeled by `result` | +| `flow_control.active_subscribers` | Gauge | | Active change-stream subscribers receiving live changes | +| `flow_control.queued_subscribers` | Gauge | | Change-stream subscribers waiting for the current transaction to finish before activation | +| `flow_control.pending_messages` | Gauge | | Downstream change-stream messages not yet acknowledged by subscribers | +| `flow_control.backlog_messages` | Gauge | | Live change-stream messages buffered while subscribers catch up | +| `flow_control.backlog_bytes` | Gauge | bytes | Live change-stream bytes buffered while subscribers catch up | +| `flow_control.max_backlog_bytes` | Gauge | bytes | Maximum live change-stream bytes buffered by a single subscriber | +| `flow_control.waits` | Counter | | Completed flow-control checkpoints | +| `flow_control.wait_duration` | Histogram | s | Time replication waits at flow-control checkpoints | + +`total_lag` and `last_total_lag` now report the same latest measured round trip and do not grow when reports stop arriving. Use `lag_report_retries` to detect a stalled or missing report stream. ### zero.sync -| Metric | Type | Unit | Description | -| ----------------------------------- | ------------- | ---- | -------------------------------------------------------------------------------------------------------------- | -| `max-protocol-version` | Gauge | | Highest sync protocol version seen from connecting clients | -| `active-clients` | UpDownCounter | | Number of currently connected sync clients | -| `active-client-groups` | Gauge | | Number of active ViewSyncerService instances in a syncer worker | -| `queries` | Gauge | | Active IVM pipelines across all client groups in a syncer worker | -| `rows` | Gauge | | CVR-tracked rows across all client groups in a syncer worker | -| `serving_lag` | Gauge | ms | Longest time locally ready replica changes have remained unserved across active ViewSyncer client groups | -| `serving_lag_stats` | Gauge | ms | Distribution of serving lag across active ViewSyncer client groups | -| `serving_lagging_client_groups` | Gauge | | Active ViewSyncer client groups with locally ready replica changes not yet served to clients | -| `view_syncer_lag` | Histogram | s | Time from replica changes becoming ready to ViewSyncer output, sampled once per minute per active client group | -| `view_syncer_hydration` | Histogram | s | Time from a ViewSyncer query sync requiring hydration until output, per client group | -| `lock-wait-time` | Histogram | s | Time spent waiting to acquire the ViewSyncerService lock per operation | -| `pipeline-resets` | Counter | | Count of pipeline resets, labeled by `reason` | -| `hydration` | Counter | | Number of query hydrations | -| `hydration-time` | Histogram | s | Time to hydrate a query | -| `advance-time` | Histogram | s | Time to advance all queries for a client group after applying a transaction | -| `poke.time` | Histogram | s | Time per poke transaction (excludes canceled/noop pokes) | -| `poke.transactions` | Counter | | Count of poke transactions | -| `poke.rows` | Counter | | Count of poked rows | -| `cvr.load_attempts` | Counter | | CVR load attempts | -| `cvr.load_duration` | Histogram | s | Time to load a CVR | -| `cvr.flush_attempts` | Counter | | CVR flush attempts | -| `cvr.flush-time` | Histogram | s | Time to flush a CVR transaction | -| `cvr.rows-flushed` | Counter | | Number of changed rows flushed to a CVR | -| `websocket.open_connections` | UpDownCounter | | Open client WebSocket connections | -| `websocket.connection_attempts` | Counter | | Client WebSocket connection attempts | -| `websocket.connection_successes` | Counter | | Client WebSocket connections successfully initialized | -| `websocket.connection_failures` | Counter | | Client WebSocket connection attempts that failed before initialization | -| `websocket.errors` | Counter | | Client WebSocket error events | -| `ivm.advance-time` | Histogram | s | Time to advance IVM queries in response to a single change | -| `ivm.conflict-rows-deleted` | Counter | | Rows deleted because they conflicted with an added row | -| `query.transformations` | Counter | | Number of query transformations performed | -| `query.transformation-time` | Histogram | s | Time to transform custom queries via API server | -| `query.transformation-hash-changes` | Counter | | Times a query transformation hash changed | -| `query.transformation-no-ops` | Counter | | Times a query transformation was a no-op | -| `query.row-set-signature-drifts` | Counter | | Unchanged query rehydrations whose row-set signature differs from the CVR, forcing a config-version bump | +| Metric | Type | Unit | Description | +| ------------------------------------------ | ------------- | ---- | --------------------------------------------------------------------------------------------------------- | +| `max-protocol-version` | Gauge | | Highest sync protocol version seen from connecting clients | +| `active-clients` | UpDownCounter | | Number of currently connected sync clients | +| `active-client-groups` | Gauge | | Number of active ViewSyncerService instances in a syncer worker | +| `queries` | Gauge | | Active IVM pipelines across all client groups in a syncer worker | +| `rows` | Gauge | | CVR-tracked rows across all client groups in a syncer worker | +| `serving_lag` | Gauge | ms | Longest time locally ready replica changes have remained unserved across eligible active client groups | +| `serving_lag_stats` | Gauge | ms | Distribution of serving lag across eligible active client groups | +| `serving_lagging_client_groups` | Gauge | | Eligible active client groups with locally ready replica changes not yet served to clients | +| `view_syncer_lag` | Histogram | s | Time from replica changes becoming ready to ViewSyncer output, sampled once per minute per eligible group | +| `view_syncer_hydration` | Histogram | s | Time from a ViewSyncer query sync requiring hydration until output, per client group | +| `lock-wait-time` | Histogram | s | Time spent waiting to acquire the ViewSyncerService lock per operation | +| `pipeline-resets` | Counter | | Count of pipeline resets, labeled by `reason` | +| `hydration` | Counter | | Number of query hydrations | +| `hydration-time` | Histogram | s | Time to hydrate a query | +| `advance-time` | Histogram | s | Time to advance all queries for a client group after applying a transaction | +| `poke.time` | Histogram | s | Time per poke transaction (excludes canceled/noop pokes) | +| `poke.transactions` | Counter | | Count of poke transactions | +| `poke.rows` | Counter | | Count of poked rows | +| `cvr.load_attempts` | Counter | | CVR load attempts | +| `cvr.load_duration` | Histogram | s | Time to load a CVR | +| `cvr.flush_attempts` | Counter | | CVR flush attempts | +| `cvr.flush-time` | Histogram | s | Time to flush a CVR transaction | +| `cvr.rows-flushed` | Counter | | Number of changed rows flushed to a CVR | +| `websocket.open_connections` | UpDownCounter | | Open client WebSocket connections | +| `websocket.connection_attempts` | Counter | | Client WebSocket connection attempts | +| `websocket.connection_successes` | Counter | | Client WebSocket connections successfully initialized | +| `websocket.connection_failures` | Counter | | Client WebSocket connection attempts that failed before initialization | +| `websocket.errors` | Counter | | Client WebSocket error events | +| `ivm.advance-time` | Histogram | s | Time to advance IVM queries in response to a single change | +| `ivm.conflict-rows-deleted` | Counter | | Rows deleted because they conflicted with an added row | +| `query.transformations` | Counter | | Number of query transformations performed | +| `query.transformation-time` | Histogram | s | Time to transform custom queries via API server | +| `query.transformation-hash-changes` | Counter | | Times a query transformation hash changed | +| `query.transformation-no-ops` | Counter | | Times a query transformation was a no-op | +| `query.row-set-signature-drifts` | Counter | | Unchanged query rehydrations whose row-set signature differs from the CVR, forcing a config-version bump | +| `query.same-hash-rehydrations-forced-bump` | Counter | | Same-hash query rehydrations that force a config-version bump so changed rows are delivered | + +Serving-lag metrics include only client groups with at least one connected client and a validated background connection context. Retained groups without an eligible connection do not contribute lag. ### zero.mutation diff --git a/contents/docs/release-notes/1.9.mdx b/contents/docs/release-notes/1.9.mdx new file mode 100644 index 00000000..63604a56 --- /dev/null +++ b/contents/docs/release-notes/1.9.mdx @@ -0,0 +1,44 @@ +--- +title: Zero 1.9 +description: Query Correctness and Reliability +--- + +## Installation + +```bash +npm install @rocicorp/zero@1.9 +``` + +You can use `zero-cache` from Docker Hub or GHCR: + +```bash +docker pull rocicorp/zero:1.9.0 +# or +docker pull ghcr.io/rocicorp/zero:1.9.0 +``` + +## Overview + +Zero 1.9 improves query correctness and `zero-cache` reliability. + +## Performance + +Deferred. + +## Fixes + +- [Ordered queries now paginate and maintain windows correctly when cursor fields contain `NULL`, including compound tie-break fields and reverse walks.](https://github.com/rocicorp/mono/pull/6121) This prevents skipped rows, empty windows, and related `Bound should be set` failures. (thanks [@YevheniiKotyrlo](https://github.com/YevheniiKotyrlo)!) +- [Schema construction, CRUD mutators, and materialized views now preserve a key named `__proto__` as user data instead of invoking JavaScript's legacy prototype setter.](https://github.com/rocicorp/mono/pull/6185) (thanks [@tjenkinson](https://github.com/tjenkinson)!) +- [Clients now receive changed rows after a server-side query is rebuilt, instead of retaining stale results in a rare rehydration case.](https://github.com/rocicorp/mono/pull/6196) +- [`zero-cache` now bounds its SQLite prepared-statement caches with LRU eviction, preventing unbounded statement retention when applications generate many distinct query shapes.](https://github.com/rocicorp/mono/pull/6202) +- [Replication lag reports now retry when an expected report is missing](https://github.com/rocicorp/mono/pull/6187), and [serving-lag metrics exclude disconnected or not-yet-validated client groups](https://github.com/rocicorp/mono/pull/6219). See the updated [OpenTelemetry metric descriptions](/docs/otel#zeroreplication). +- [`zero-cache` now detects and resets PostgreSQL connections that stop carrying wire traffic](https://github.com/rocicorp/mono/pull/6220), [including over TLS](https://github.com/rocicorp/mono/pull/6221), allowing work to recover from proxy-created half-open sockets. See [Breaking Changes](#postgresql-socket-inactivity-timeout). +- [`zero-cache` now releases custom-query caches when client groups stop, preventing inactive groups from retaining timers and transformed queries.](https://github.com/rocicorp/mono/pull/6228) + +## Breaking Changes + +### PostgreSQL Socket Inactivity Timeout + +`zero-cache` now monitors wire activity on its PostgreSQL connections. By default, it checks every two minutes and resets a connection after one to two inactive intervals. This recovers half-open connections, but can interrupt a long-running statement that legitimately produces no network traffic. + +If legitimate Postgres operations can remain silent for this long, set [`ZERO_PG_SOCKET_INACTIVITY_TIMEOUT`](/docs/connecting-to-postgres#socket-inactivity-timeout) on `zero-cache` to a longer interval in milliseconds. Set it to `0` to disable the watchdog. diff --git a/contents/docs/release-notes/index.mdx b/contents/docs/release-notes/index.mdx index 9692efe0..b7da682f 100644 --- a/contents/docs/release-notes/index.mdx +++ b/contents/docs/release-notes/index.mdx @@ -2,6 +2,7 @@ title: Release Notes --- +- [Zero 1.9: Query Correctness and Reliability](/docs/release-notes/1.9) - [Zero 1.8: Observability and Reliability](/docs/release-notes/1.8) - [Zero 1.7: Query Correctness and Performance](/docs/release-notes/1.7) - [Zero 1.6: PlanetScale Failover Support](/docs/release-notes/1.6) diff --git a/contents/docs/zero-cache-config.mdx b/contents/docs/zero-cache-config.mdx index 2b0f6809..6df03098 100644 --- a/contents/docs/zero-cache-config.mdx +++ b/contents/docs/zero-cache-config.mdx @@ -545,7 +545,7 @@ 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 and Failover](/docs/connecting-to-postgres#high-availability-and-failover). Has no effect on Postgres versions before 17. +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](/docs/connecting-to-postgres#high-availability). Has no effect on Postgres versions before 17. flag: `--upstream-pg-replication-slot-failover`
env: `ZERO_UPSTREAM_PG_REPLICATION_SLOT_FAILOVER`
@@ -651,7 +651,7 @@ 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](/docs/otel). Because replication lag reports are only issued after the previous one was received, the actual interval between reports may be longer when there is a backlog in the replication stream. +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`. 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. From 4a52bdb70c62314f5a1d55a822726990ad100044 Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Fri, 7 Aug 2026 12:38:50 -0700 Subject: [PATCH 02/17] docs: 1.9 --- .releases/1.9/benchmarks/6292.md | 66 ++ .releases/1.9/commits.md | 267 +++-- assets/search-index.json | 1356 ++++++++++++---------- contents/docs/connecting-to-postgres.mdx | 6 + contents/docs/connection.mdx | 4 +- contents/docs/mutators.mdx | 4 +- contents/docs/otel.mdx | 79 +- contents/docs/release-notes/1.9.mdx | 66 +- contents/docs/zero-cache-config.mdx | 27 +- 9 files changed, 1110 insertions(+), 765 deletions(-) create mode 100644 .releases/1.9/benchmarks/6292.md diff --git a/.releases/1.9/benchmarks/6292.md b/.releases/1.9/benchmarks/6292.md new file mode 100644 index 00000000..07cc3c5d --- /dev/null +++ b/.releases/1.9/benchmarks/6292.md @@ -0,0 +1,66 @@ +# #6292 First Mutation Benchmark + +## Claim + +Zero 1.9 handles the first mutation with uncached server-schema metadata 2.74x faster than Zero 1.8 on this fixture. Median full-request latency fell from 20.973 ms to 7.645 ms, a 63.5% reduction. + +This is not a general process-startup claim. The measurement excludes process launch, module loading, fixture creation, and database connection setup. Subsequent mutations normally reuse the cached schema and are outside the claim. + +## Provenance + +- Zero 1.8: `zero/v1.8.0` at `cdc02598f137ab4e071878f5674fdc716dbbc69d` +- Zero 1.9 change: `67c8fe4c9d9a5673357bb80116c50d968e54c2e2`, the #6292 commit +- Zero 1.9 release target: `b90e79d8fcc9db1cf33eccab5f651fc93895f5a6` +- No `packages/zero-server` production file changed between the #6292 commit and the release target. +- Latest benchmark harness: `45706aad581b283037ce0b25130de5c1f27ac086` +- Benchmark source SHA-1: `872b0cbe5f61862e53cb8451940ff4664a39b78e` +- Benchmark config SHA-1: `26eeea5e963235dd229b3e205151af18f4c31da1` +- Shared latency recorder SHA-1: `40e63f7de24fe3b0fe4051d9903d03550a6931c2` +- The measured benchmark source and Zero Server runner were byte-identical in both worktrees. + +Zero 1.8 and the #6292 commit also differ in CRUD insert, update, and upsert behavior from #6251 and #6280. The benchmark uses a no-op custom mutation and does not execute those CRUD operations; the measured production-path difference is server-schema introspection. + +## Method + +- PostgreSQL 17 with a non-owner application role. +- Fixture: 517 application tables and 8,762 columns; the Zero schema selects 75 tables and 1,306 columns. +- One warmed postgres.js connection per run with `max: 1` and `prepare: false`. +- A new database provider per sample resets the `CRUDMutatorFactory` server-schema cache. +- The timed region is the complete `handleMutateRequest()` call for a successful no-op custom mutation. +- Ten fresh Vitest processes per ref, alternated by version to reduce temporal bias. +- Each process ran five warmups followed by 50 measured requests. +- The release metric is the median of the ten process-level medians. Individual request samples are not pooled for the claim. + +## Results + +| Run | Zero 1.8 median (ms) | Zero 1.9 median (ms) | +| ---------: | -------------------: | -------------------: | +| 1 | 21.792 | 9.005 | +| 2 | 20.192 | 8.026 | +| 3 | 20.188 | 6.455 | +| 4 | 23.361 | 7.731 | +| 5 | 20.448 | 8.207 | +| 6 | 23.372 | 7.560 | +| 7 | 21.337 | 10.019 | +| 8 | 20.609 | 7.344 | +| 9 | 25.217 | 7.272 | +| 10 | 20.263 | 6.491 | +| **Median** | **20.973** | **7.645** | + +- Speedup: 2.743x +- Latency reduction: 63.546% +- Zero 1.8 run-median range: 20.188-25.217 ms +- Zero 1.9 run-median range: 6.455-10.019 ms +- Pooled-sample medians, used only as a cross-check: 21.401 ms and 7.606 ms +- Cached-schema mutation control: 0.936 ms and 0.934 ms, effectively flat. +- Warmed-query control: inconclusive because the result reversed with execution order. + +## Host Conditions + +- Apple M5 Pro, 15 cores, 24 GB memory +- Time Machine inactive before and after the run +- Memory-pressure free percentage: 37% before and 34% after +- 90 CPU samples: 55.3% mean idle, 55.7% median idle, 34.3% minimum idle +- No thermal or performance warning before or after + +Raw benchmark samples and host telemetry are preserved at `/var/folders/97/c3gvpw6d46g3nm0y2684_cfm0000gn/T/opencode/zero-server-first-request-45706aad5/`. diff --git a/.releases/1.9/commits.md b/.releases/1.9/commits.md index 20869dcd..9d956e94 100644 --- a/.releases/1.9/commits.md +++ b/.releases/1.9/commits.md @@ -1,6 +1,6 @@ # Zero 1.9 Release Audit -Status: public draft and product-doc updates complete; #6206 benchmarks deferred; smoke-test handoff blocked on a 1.9 canary. +Status: audit and public draft updated through the reconstructed maintenance target; #6292 performance claim validated; smoke-test handoff blocked on a 1.9 canary. ## Release Provenance @@ -10,26 +10,27 @@ Status: public draft and product-doc updates complete; #6206 benchmarks deferred - Docs repository: `/Users/chase/.worktree/zero-docs/1.9` - Previous ref: `zero/v1.8.0` - Previous SHA: `cdc02598f137ab4e071878f5674fdc716dbbc69d` -- Target ref: `maint/zero/v1.9`, frozen for this audit -- Target SHA: `ef892a123a11461e74a59a4b59ad310ba23180b3` +- Target ref: `origin/maint/zero/v1.9`, reconstructed and published maintenance target +- Target SHA: `b90e79d8fcc9db1cf33eccab5f651fc93895f5a6` - Merge base: `2279e783edd94aaa20fdcc8e067860ad0c21d95b` -- Raw non-merge range: 39 commits +- Reconstruction base: `ef892a123a11461e74a59a4b59ad310ba23180b3` +- Raw non-merge range: 63 commits - Patch-equivalent commits already shipped in 1.8: 15 -- Unique 1.9 commits: 24 +- Unique 1.9 commits: 48 Commands used: ```bash git remote -v -git rev-parse zero/v1.8.0 maint/zero/v1.9 -git merge-base zero/v1.8.0 maint/zero/v1.9 -git rev-list --count --no-merges zero/v1.8.0..maint/zero/v1.9 -git log --reverse --oneline --no-merges zero/v1.8.0..maint/zero/v1.9 -git log --right-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1.8.0...maint/zero/v1.9 -git log --left-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1.8.0...maint/zero/v1.9 +git rev-parse zero/v1.8.0 origin/maint/zero/v1.9 +git merge-base zero/v1.8.0 origin/maint/zero/v1.9 +git rev-list --count --no-merges zero/v1.8.0..origin/maint/zero/v1.9 +git log --reverse --oneline --no-merges zero/v1.8.0..origin/maint/zero/v1.9 +git log --right-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1.8.0...origin/maint/zero/v1.9 +git log --left-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1.8.0...origin/maint/zero/v1.9 git log --format='%H%x09%s%n%b' 2279e783edd94aaa20fdcc8e067860ad0c21d95b..zero/v1.8.0 git show zero/v1.8.0:packages/zero-protocol/src/protocol-version.ts -git show ef892a123a11461e74a59a4b59ad310ba23180b3:packages/zero-protocol/src/protocol-version.ts +git show b90e79d8fcc9db1cf33eccab5f651fc93895f5a6:packages/zero-protocol/src/protocol-version.ts ``` ## Protocol Compatibility @@ -69,70 +70,103 @@ The 1.8 tag and 1.9 branch diverge at the merge base. The following target commi The previous-release side contains no additional `cherry-pick -x` trailers naming target commits beyond the patch-equivalent set above. +## Maintenance Reconstruction + +The target was rebuilt from shared mainline commit `ef892a123` by applying 22 selected, signed mainline commits in topological order with provenance trailers, then fast-forwarded with signed #6318 PR-head and #6312 canonical-main backports. This removes the bulk-cherry-pick timestamps from the old maintenance line and makes every selected change independently traceable to its source. + +The old maintenance-only commits `e91f964a7` and `c073aa39e` are represented by reconstructed commit `49b13e3e5`, the canonical #6280 patch. The resulting production code and tests are byte-identical to main's #6280 tree. Reconstructed #6311 differs in patch ID only because the 1.9 parent infers an unchanged test callback parameter type where main's rmv2-era parent spells out `unknown`; the production change, new transaction tests, and resulting behavior are identical. #6318 similarly omits only an unrelated mainline rollback helper that is absent from 1.9; its oversized-binding diagnostics and regression test match the PR. #6312's entire `packages/zero-cache` patch is patch-equivalent to main; the backport omits only four generated API snapshots because 1.9 predates the #6239 snapshot infrastructure. + +All other reconstructed commits are patch-equivalent to their named mainline source. Main-only rmv2 work, #6307's breaking scalar type enforcement, #6309's nullability-specific optimization, #6314's experimental Litestream update, and #6317's rmv2 test timeout remain excluded. + ## Commit Decisions -| Commit | Category | Breaking? | Public impact | Decision and evidence | -| --------------------------------------------------------- | ----------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [`a59cf1a66`](https://github.com/rocicorp/mono/pull/6183) | skip | - | None; adds a SQLite change-log throughput benchmark. | Omit. Benchmark-only file `sqlite-change-log-ceiling.bench.ts`; no production path changes. | -| [`cb014857f`](https://github.com/rocicorp/mono/pull/6185) | fix | - | Preserves an own `__proto__` property in schema construction, CRUD maps, and materialized-view updates instead of invoking the legacy prototype setter. | Public candidate. Tests cover shared object helpers, schema builders, mutators, and IVM view updates. Scope wording narrowly because name mapping and several other dynamic-key paths lack end-to-end coverage. Existing affected schemas may get a corrected client-schema hash and normal resync. | -| [`5361250c2`](https://github.com/rocicorp/mono/pull/6182) | skip | - | Default-on shadow analysis detects query coverage and emits summary logs; it does not reuse results or change query execution. Adds `ZERO_ENABLE_QUERY_COVERING`. | Intentionally omit as internal rollout telemetry. Human review accepted the default's possible CPU/log overhead without promoting the control as a supported public option. Unit coverage is in `query-covering.test.ts` and config tests; no production overhead benchmark exists. | -| [`53194a166`](https://github.com/rocicorp/mono/pull/6190) | skip | - | Changes package version from 1.8.0 to 1.9.0. | Omit as release metadata. | -| [`c263df101`](https://github.com/rocicorp/mono/pull/6189) | skip | - | Fixed z2s `start` compilation and leading NULL cursor handling. | Already shipped in 1.8 as patch-equivalent `31e48aa71` and covered by the 1.8 release note. | -| [`c6f4c9712`](https://github.com/rocicorp/mono/pull/6191) | skip | - | Added initial-sync duration, phase, row, byte, chunk, and outcome metrics. | Already shipped in 1.8 as patch-equivalent `6c8b5e5a7` and covered by the 1.8 operational-metrics feature. | -| [`a99e63093`](https://github.com/rocicorp/mono/pull/6193) | skip | - | Prevented postgres.js writes through a socket after disconnection. | Already shipped in 1.8 as patch-equivalent `ca6c2f3e9` and listed in the 1.8 fixes. | -| [`34b204638`](https://github.com/rocicorp/mono/pull/6179) | skip | - | Adds a private Postgres-to-client throughput and latency harness. | Omit as benchmark infrastructure. Production-file changes only normalize imports. The harness may be temp-backported for #6206 measurements. | -| [`58861830f`](https://github.com/rocicorp/mono/pull/6192) | skip | - | None for developers or operators; parallelizes JavaScript CI jobs. | Omit as CI-only. | -| [`d379d93cb`](https://github.com/rocicorp/mono/pull/6199) | skip | - | Added Litestream backup, restore, validation, and subprocess metrics. | Already shipped in 1.8 as patch-equivalent `492b5c331` and covered by the 1.8 operational-metrics feature. | -| [`754be6b1d`](https://github.com/rocicorp/mono/pull/6198) | skip | - | Corrected Docker's Node version so default sync-worker sizing uses `availableParallelism`. | Already shipped in 1.8 as patch-equivalent `3609d48af` and listed in the 1.8 fixes. | -| [`48694d892`](https://github.com/rocicorp/mono/pull/6187) | fix | - | Retries missing replication-lag probes and stops `total_lag` from growing indefinitely when no report is received. Adds `lag_report_retries`; `last_total_lag` is no longer semantically distinct. | Include publicly, grouped with #6219. Tests cover retry and recorder behavior. Human review classified this as non-breaking and requires `otel.mdx`, `zero-cache-config.mdx`, and alert-migration guidance for operators relying on old gauge semantics. | -| [`bb8703753`](https://github.com/rocicorp/mono/pull/6186) | skip | - | Bounds catch-up subscriber backlogs and applies downstream flow control. | Already shipped in 1.8 as patch-equivalent `4ef9e4ef0` and listed in the 1.8 fixes. | -| [`ec7c5c678`](https://github.com/rocicorp/mono/pull/6197) | skip | - | Expands end-to-end fuzzing design and test coverage. | Omit as test-only. No shipped runtime behavior changed. | -| [`7d13c1b4b`](https://github.com/rocicorp/mono/pull/6196) | fix | - | Forces a CVR version bump when a same-hash query is rebuilt without another bump, ensuring changed rows reach clients instead of leaving stale results. | Public candidate. View-syncer and CVR integration tests cover missing-pipeline and row-signature drift cases and duplicate-delete avoidance. | -| [`3adc4383f`](https://github.com/rocicorp/mono/pull/6203) | skip | - | Added metrics for mutate, query, cleanup, and auth-validation API calls. | Already shipped in 1.8 as patch-equivalent `c2b5a7565` and covered by the 1.8 operational-metrics feature. | -| [`e03cc5301`](https://github.com/rocicorp/mono/pull/6207) | skip | - | Added CVR, WebSocket, and replication flow-control metrics. | Already shipped in 1.8 as patch-equivalent `e91debd95` and covered by the 1.8 stability-metrics feature. | -| [`6cc6629ca`](https://github.com/rocicorp/mono/pull/6209) | skip | - | Aligned metric names with product documentation. | Already shipped in 1.8 as patch-equivalent `d711edbb2`; no additional 1.9 behavior. | -| [`03223e310`](https://github.com/rocicorp/mono/pull/6211) | skip | - | None through supported exports; deletes an unused shared helper and tests. | Omit as an internal refactor. `shared` is private, the helper was not exported, and history shows no callers outside its tests. | -| [`42819059b`](https://github.com/rocicorp/mono/pull/6202) | fix | - | Bounds each zero-cache SQLite prepared-statement cache to 1,000 idle entries with LRU eviction, preventing unbounded retained statement state from varied query shapes. | Include as a non-breaking reliability fix. Unit tests cover the bound, LRU ordering, duplicate SQL instances, and in-flight statements. Human review accepted the fixed, non-configurable per-cache limit; avoid claiming immediate native-memory release because finalization remains GC-driven. | -| [`596a9376b`](https://github.com/rocicorp/mono/pull/6212) | skip | - | Removes a SQLite quick-check that was too slow in production. | Already shipped in 1.8 as patch-equivalent `dcbc5b5ea`; no additional 1.9 behavior. | -| [`86d531954`](https://github.com/rocicorp/mono/pull/6205) | skip | - | Adds fault-injection coverage for replication resumption after process, network, and acknowledgement failures. | Omit as resilience testing; validates existing behavior without a production change. | -| [`478711b76`](https://github.com/rocicorp/mono/pull/6214) | skip | - | Makes lag metrics aggregable across time and pods. | Already shipped in 1.8 as patch-equivalent `d4258993d` and covered by the 1.8 stability-metrics feature. | -| [`eac60e7c3`](https://github.com/rocicorp/mono/pull/6206) | performance | - | For sufficiently large or pathological updates, projects incremental-maintenance cost and rebuilds query pipelines when rebuilding should be cheaper, while allowing nearly complete updates to finish. | Performance evaluation deferred by human review. Synthetic tests verify reset thresholds, not end-to-end performance, so the public note makes no behavioral or quantitative claim. | -| [`b26add22f`](https://github.com/rocicorp/mono/pull/6204) | skip | - | Extends query-equivalence fuzzing through zero-cache and client materialization. | Omit as test-only; no runtime implementation changed. | -| [`89d57b460`](https://github.com/rocicorp/mono/pull/6210) | skip | - | Added replication-slot health and retained/safe WAL metrics. | Already shipped in 1.8 as patch-equivalent `f71c897c4` and covered by the 1.8 operational-metrics feature. | -| [`89c48bbc2`](https://github.com/rocicorp/mono/pull/6208) | skip | - | Added zero-cache worker startup duration metrics. | Already shipped in 1.8 as patch-equivalent `832580d65` and covered by the 1.8 operational-metrics feature. | -| [`7337ed18f`](https://github.com/rocicorp/mono/pull/6213) | feature | - | Adds opt-in npm `@rocicorp/zero@head` and GHCR `ghcr.io/rocicorp/zero:head` releases, plus immutable versions/tags containing the source SHA and date. Stable tags are unchanged. | Intentionally omit from Zero 1.9 notes after human review because this is a main-branch release channel, not a version-scoped runtime capability. Release-plan tests cover versions, branch/SHA validation, collisions, and refusal to create a git tag. | -| [`ca40512bf`](https://github.com/rocicorp/mono/pull/6218) | skip | - | Release README updates. | Already shipped in 1.8 as patch-equivalent `312a0f78f`; documentation-only and no additional 1.9 behavior. | -| [`6d84471c5`](https://github.com/rocicorp/mono/pull/6219) | fix | - | Excludes disconnected or not-yet-validated view-syncers from serving-lag metrics, preventing retained groups from producing false multi-hour spikes. | Include publicly, grouped with #6187. Human review classified the metric correction as non-breaking and requires operator-facing description of changed populations. Syncer tests cover eligible populations and cleanup; the change does not fix the underlying reason some disconnected view-syncers remain alive. | -| [`f9ff04d31`](https://github.com/rocicorp/mono/pull/6220) | fix | BREAKING | Resets PostgreSQL connections that carry no wire traffic across consecutive watchdog samples, allowing recovery from proxy-created half-open sockets. | Include with #6221 plus a Breaking Changes advisory. Default sampling is 120 seconds, giving an effective detection window of roughly two to four minutes. A legitimately silent statement can now be rejected; document `ZERO_PG_SOCKET_INACTIVITY_TIMEOUT` as the migration control and `0` as disabling the watchdog. | -| [`72f732cd4`](https://github.com/rocicorp/mono/pull/6221) | fix | BREAKING | Reimplements the inactivity watchdog so it survives postgres.js TLS socket upgrades. | Include with #6220 and the same migration advisory. Tests simulate TLS listener removal, activity, reset, warning logs, and disabling the watchdog. | -| [`e5b9c6f55`](https://github.com/rocicorp/mono/pull/6222) | skip | - | Accepts an optional nullable `newColumns` map in DDL events but does not emit or act on it. | Omit as reader-first rollout scaffolding for a future DDL optimization. Parsing tests cover present, null, and absent values. No migration, protocol bump, or 1.9 backfill behavior. | -| [`15fd7cdea`](https://github.com/rocicorp/mono/pull/6223) | skip | - | Exports `MutatorResult` for helpers that await client or server mutation results. | Already shipped in 1.8 as patch-equivalent `cdc02598f` and listed as a 1.8 feature. | -| [`d4f33d6a6`](https://github.com/rocicorp/mono/pull/6121) | fix | - | Makes compound cursor equality NULL-safe, preserves NULL groups in reverse walks, and propagates replica nullability so ordered pagination and window maintenance do not skip rows or hit `Bound should be set`. | Public candidate for the delta beyond already-shipped #6189. Query-builder, real-SQLite table-source, and lite-table tests cover tie-breaks, inclusive starts, reverse walks, and metadata. External author [@YevheniiKotyrlo](https://github.com/YevheniiKotyrlo), co-authored by Matt Wonlaw; GitHub profile shows no Rocicorp affiliation, so include thanks. | -| [`e374775a9`](https://github.com/rocicorp/mono/pull/6226) | skip | - | Avoids row remounts in the private zbugs application. | Omit as sample/private-app performance work; no Zero package changed. | -| [`7139287da`](https://github.com/rocicorp/mono/pull/6227) | skip | - | Migrates the private zbugs application to zero-virtual 0.6. | Omit as private-app dependency and layout work; no Zero API or runtime changed. | -| [`177d6c1f9`](https://github.com/rocicorp/mono/pull/6228) | fix | - | Disposes custom-query transformed-query caches when view-syncers stop, eliminating retained timers and cache maps for terminated client groups. | Public reliability candidate. Cache and transformer tests cover lazy/unrefed timers, idempotent destruction, and no timer resurrection. No API or TTL change. | -| [`ef892a123`](https://github.com/rocicorp/mono/pull/6224) | fix | - | Updates the optional bundled Litestream v5 executable from 0.5.11 to 0.5.14. Legacy Litestream remains the default. | Intentionally omit from public 1.9 notes after human review because it affects only the opt-in v5 executable. Upstream 0.5.12 includes restore WAL-gap detection, initial LTX-open retries, failed-restore cleanup, overwrite protection, and snapshot-setting fixes; 0.5.14 adds remote compaction reads, sustained S3 retries, replica-type handling, retention correction, and atomic SFTP writes. Keep v5 restore smoke coverage in the release handoff. | +| Commit | Category | Breaking? | Public impact | Decision and evidence | +| --------------------------------------------------------- | ----------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`a59cf1a66`](https://github.com/rocicorp/mono/pull/6183) | skip | - | None; adds a SQLite change-log throughput benchmark. | Omit. Benchmark-only file `sqlite-change-log-ceiling.bench.ts`; no production path changes. | +| [`cb014857f`](https://github.com/rocicorp/mono/pull/6185) | fix | - | Preserves an own `__proto__` property in schema construction, CRUD maps, and materialized-view updates instead of invoking the legacy prototype setter. | Public candidate. Tests cover shared object helpers, schema builders, mutators, and IVM view updates. Scope wording narrowly because name mapping and several other dynamic-key paths lack end-to-end coverage. Existing affected schemas may get a corrected client-schema hash and normal resync. | +| [`5361250c2`](https://github.com/rocicorp/mono/pull/6182) | skip | - | Default-on shadow analysis detects query coverage and emits summary logs; it does not reuse results or change query execution. Adds `ZERO_ENABLE_QUERY_COVERING`. | Intentionally omit as internal rollout telemetry. Human review accepted the default's possible CPU/log overhead without promoting the control as a supported public option. Unit coverage is in `query-covering.test.ts` and config tests; no production overhead benchmark exists. | +| [`53194a166`](https://github.com/rocicorp/mono/pull/6190) | skip | - | Changes package version from 1.8.0 to 1.9.0. | Omit as release metadata. | +| [`c263df101`](https://github.com/rocicorp/mono/pull/6189) | skip | - | Fixed z2s `start` compilation and leading NULL cursor handling. | Already shipped in 1.8 as patch-equivalent `31e48aa71` and covered by the 1.8 release note. | +| [`c6f4c9712`](https://github.com/rocicorp/mono/pull/6191) | skip | - | Added initial-sync duration, phase, row, byte, chunk, and outcome metrics. | Already shipped in 1.8 as patch-equivalent `6c8b5e5a7` and covered by the 1.8 operational-metrics feature. | +| [`a99e63093`](https://github.com/rocicorp/mono/pull/6193) | skip | - | Prevented postgres.js writes through a socket after disconnection. | Already shipped in 1.8 as patch-equivalent `ca6c2f3e9` and listed in the 1.8 fixes. | +| [`34b204638`](https://github.com/rocicorp/mono/pull/6179) | skip | - | Adds a private Postgres-to-client throughput and latency harness. | Omit as benchmark infrastructure. Production-file changes only normalize imports. The harness may be temp-backported for #6206 measurements. | +| [`58861830f`](https://github.com/rocicorp/mono/pull/6192) | skip | - | None for developers or operators; parallelizes JavaScript CI jobs. | Omit as CI-only. | +| [`d379d93cb`](https://github.com/rocicorp/mono/pull/6199) | skip | - | Added Litestream backup, restore, validation, and subprocess metrics. | Already shipped in 1.8 as patch-equivalent `492b5c331` and covered by the 1.8 operational-metrics feature. | +| [`754be6b1d`](https://github.com/rocicorp/mono/pull/6198) | skip | - | Corrected Docker's Node version so default sync-worker sizing uses `availableParallelism`. | Already shipped in 1.8 as patch-equivalent `3609d48af` and listed in the 1.8 fixes. | +| [`48694d892`](https://github.com/rocicorp/mono/pull/6187) | fix | - | Retries missing replication-lag probes and stops `total_lag` from growing indefinitely when no report is received. Adds `lag_report_retries`; `last_total_lag` is no longer semantically distinct. | Include publicly, grouped with #6219. Tests cover retry and recorder behavior. Human review classified this as non-breaking and requires `otel.mdx`, `zero-cache-config.mdx`, and alert-migration guidance for operators relying on old gauge semantics. | +| [`bb8703753`](https://github.com/rocicorp/mono/pull/6186) | skip | - | Bounds catch-up subscriber backlogs and applies downstream flow control. | Already shipped in 1.8 as patch-equivalent `4ef9e4ef0` and listed in the 1.8 fixes. | +| [`ec7c5c678`](https://github.com/rocicorp/mono/pull/6197) | skip | - | Expands end-to-end fuzzing design and test coverage. | Omit as test-only. No shipped runtime behavior changed. | +| [`7d13c1b4b`](https://github.com/rocicorp/mono/pull/6196) | fix | - | Forces a CVR version bump when a same-hash query is rebuilt without another bump, ensuring changed rows reach clients instead of leaving stale results. | Public candidate. View-syncer and CVR integration tests cover missing-pipeline and row-signature drift cases and duplicate-delete avoidance. | +| [`3adc4383f`](https://github.com/rocicorp/mono/pull/6203) | skip | - | Added metrics for mutate, query, cleanup, and auth-validation API calls. | Already shipped in 1.8 as patch-equivalent `c2b5a7565` and covered by the 1.8 operational-metrics feature. | +| [`e03cc5301`](https://github.com/rocicorp/mono/pull/6207) | skip | - | Added CVR, WebSocket, and replication flow-control metrics. | Already shipped in 1.8 as patch-equivalent `e91debd95` and covered by the 1.8 stability-metrics feature. | +| [`6cc6629ca`](https://github.com/rocicorp/mono/pull/6209) | skip | - | Aligned metric names with product documentation. | Already shipped in 1.8 as patch-equivalent `d711edbb2`; no additional 1.9 behavior. | +| [`03223e310`](https://github.com/rocicorp/mono/pull/6211) | skip | - | None through supported exports; deletes an unused shared helper and tests. | Omit as an internal refactor. `shared` is private, the helper was not exported, and history shows no callers outside its tests. | +| [`42819059b`](https://github.com/rocicorp/mono/pull/6202) | fix | - | Bounds each zero-cache SQLite prepared-statement cache to 1,000 idle entries with LRU eviction, preventing unbounded retained statement state from varied query shapes. | Include as a non-breaking reliability fix. Unit tests cover the bound, LRU ordering, duplicate SQL instances, and in-flight statements. Human review accepted the fixed, non-configurable per-cache limit; avoid claiming immediate native-memory release because finalization remains GC-driven. | +| [`596a9376b`](https://github.com/rocicorp/mono/pull/6212) | skip | - | Removes a SQLite quick-check that was too slow in production. | Already shipped in 1.8 as patch-equivalent `dcbc5b5ea`; no additional 1.9 behavior. | +| [`86d531954`](https://github.com/rocicorp/mono/pull/6205) | skip | - | Adds fault-injection coverage for replication resumption after process, network, and acknowledgement failures. | Omit as resilience testing; validates existing behavior without a production change. | +| [`478711b76`](https://github.com/rocicorp/mono/pull/6214) | skip | - | Makes lag metrics aggregable across time and pods. | Already shipped in 1.8 as patch-equivalent `d4258993d` and covered by the 1.8 stability-metrics feature. | +| [`eac60e7c3`](https://github.com/rocicorp/mono/pull/6206) | performance | - | For sufficiently large or pathological updates, projects incremental-maintenance cost and rebuilds query pipelines when rebuilding should be cheaper, while allowing nearly complete updates to finish. | Performance evaluation deferred by human review. Synthetic tests verify reset thresholds, not end-to-end performance, so the public note makes no behavioral or quantitative claim. | +| [`b26add22f`](https://github.com/rocicorp/mono/pull/6204) | skip | - | Extends query-equivalence fuzzing through zero-cache and client materialization. | Omit as test-only; no runtime implementation changed. | +| [`89d57b460`](https://github.com/rocicorp/mono/pull/6210) | skip | - | Added replication-slot health and retained/safe WAL metrics. | Already shipped in 1.8 as patch-equivalent `f71c897c4` and covered by the 1.8 operational-metrics feature. | +| [`89c48bbc2`](https://github.com/rocicorp/mono/pull/6208) | skip | - | Added zero-cache worker startup duration metrics. | Already shipped in 1.8 as patch-equivalent `832580d65` and covered by the 1.8 operational-metrics feature. | +| [`7337ed18f`](https://github.com/rocicorp/mono/pull/6213) | feature | - | Adds opt-in npm `@rocicorp/zero@head` and GHCR `ghcr.io/rocicorp/zero:head` releases, plus immutable versions/tags containing the source SHA and date. Stable tags are unchanged. | Intentionally omit from Zero 1.9 notes after human review because this is a main-branch release channel, not a version-scoped runtime capability. Release-plan tests cover versions, branch/SHA validation, collisions, and refusal to create a git tag. | +| [`ca40512bf`](https://github.com/rocicorp/mono/pull/6218) | skip | - | Release README updates. | Already shipped in 1.8 as patch-equivalent `312a0f78f`; documentation-only and no additional 1.9 behavior. | +| [`6d84471c5`](https://github.com/rocicorp/mono/pull/6219) | fix | - | Excludes disconnected or not-yet-validated view-syncers from serving-lag metrics, preventing retained groups from producing false multi-hour spikes. | Include publicly, grouped with #6187. Human review classified the metric correction as non-breaking and requires operator-facing description of changed populations. Syncer tests cover eligible populations and cleanup; the change does not fix the underlying reason some disconnected view-syncers remain alive. | +| [`f9ff04d31`](https://github.com/rocicorp/mono/pull/6220) | fix | BREAKING | Resets PostgreSQL connections that carry no wire traffic across consecutive watchdog samples, allowing recovery from proxy-created half-open sockets. | Include with #6221 plus a Breaking Changes advisory. Default sampling is 120 seconds, giving an effective detection window of roughly two to four minutes. A legitimately silent statement can now be rejected; document `ZERO_PG_SOCKET_INACTIVITY_TIMEOUT` as the migration control and `0` as disabling the watchdog. | +| [`72f732cd4`](https://github.com/rocicorp/mono/pull/6221) | fix | BREAKING | Reimplements the inactivity watchdog so it survives postgres.js TLS socket upgrades. | Include with #6220 and the same migration advisory. Tests simulate TLS listener removal, activity, reset, warning logs, and disabling the watchdog. | +| [`e5b9c6f55`](https://github.com/rocicorp/mono/pull/6222) | skip | - | Accepts an optional nullable `newColumns` map in DDL events but does not emit or act on it. | Omit as reader-first rollout scaffolding for a future DDL optimization. Parsing tests cover present, null, and absent values. No migration, protocol bump, or 1.9 backfill behavior. | +| [`15fd7cdea`](https://github.com/rocicorp/mono/pull/6223) | skip | - | Exports `MutatorResult` for helpers that await client or server mutation results. | Already shipped in 1.8 as patch-equivalent `cdc02598f` and listed as a 1.8 feature. | +| [`d4f33d6a6`](https://github.com/rocicorp/mono/pull/6121) | fix | - | Makes compound cursor equality NULL-safe, preserves NULL groups in reverse walks, and propagates replica nullability so ordered pagination and window maintenance do not skip rows or hit `Bound should be set`. | Public candidate for the delta beyond already-shipped #6189. Query-builder, real-SQLite table-source, and lite-table tests cover tie-breaks, inclusive starts, reverse walks, and metadata. External author [@YevheniiKotyrlo](https://github.com/YevheniiKotyrlo), co-authored by Matt Wonlaw; GitHub profile shows no Rocicorp affiliation, so include thanks. | +| [`e374775a9`](https://github.com/rocicorp/mono/pull/6226) | skip | - | Avoids row remounts in the private zbugs application. | Omit as sample/private-app performance work; no Zero package changed. | +| [`7139287da`](https://github.com/rocicorp/mono/pull/6227) | skip | - | Migrates the private zbugs application to zero-virtual 0.6. | Omit as private-app dependency and layout work; no Zero API or runtime changed. | +| [`177d6c1f9`](https://github.com/rocicorp/mono/pull/6228) | fix | - | Disposes custom-query transformed-query caches when view-syncers stop, eliminating retained timers and cache maps for terminated client groups. | Public reliability candidate. Cache and transformer tests cover lazy/unrefed timers, idempotent destruction, and no timer resurrection. No API or TTL change. | +| [`ef892a123`](https://github.com/rocicorp/mono/pull/6224) | fix | - | Updates the bundled Litestream v5 executable from 0.5.11 to 0.5.14. It was optional at this point in the range but becomes the default restore executable under #6260. | Do not list separately. Represent the cumulative v5 dependency update through #6260, which activates the executable by default. Upstream 0.5.12 includes restore WAL-gap detection, initial LTX-open retries, failed-restore cleanup, overwrite protection, and snapshot-setting fixes; 0.5.14 adds remote compaction reads, sustained S3 retries, replica-type handling, retention correction, and atomic SFTP writes. | +| [`7546a1f79`](https://github.com/rocicorp/mono/pull/6231) | fix | - | Prevents pnpm workspaces with different React, database, or mobile integrations from installing peer-qualified duplicate copies of `@rocicorp/zero`, which caused cross-package type and module-augmentation failures. | Include publicly. Integration imports remain external and consumers still install the integrations they use. The package no longer supplies peer-version warnings; the build now verifies that emitted bare imports are declared dependencies, built-ins, or known integration packages. No public API or runtime protocol changes. | +| [`d29624c0c`](https://github.com/rocicorp/mono/pull/6195) | skip | - | Accepts future DDL protocol-v2 context-only `ddlStart` events. The target continues emitting protocol-v1 events with full schema snapshots. | Omit as reader-first rollout scaffolding. It does not yet reduce WAL volume or change normal 1.9 behavior. Deploying this reader before a future v2 emitter preserves one-release rollback safety; rollback past 1.9 after v2 triggers are installed will require separate review. | +| [`ca2762da2`](https://github.com/rocicorp/mono/pull/6215) | fix | - | Logs SQLite file metadata, replica state, and bounded integrity-check results when corruption is detected; recognizes additional wrapped corruption errors and flushes logs before fatal exit. | Include as an operator-facing diagnostic and recovery-classification fix. Do not claim that it prevents corruption. Diagnostics avoid querying application rows, but paths and schema object names can appear. Fatal handling can spend additional time running integrity checks and flushing sinks. Tests cover classification, diagnostic output, lifecycle registration, and sink flushing. | +| [`a4075b077`](https://github.com/rocicorp/mono/pull/6225) | fix | - | Preserves compound-index column order when replicated PostgreSQL type or nullability changes rebuild a SQLite column, and keeps column metadata synchronized. | Include publicly despite the `test:` subject because the patch changes production schema replication. Differential and fuzz tests compare incrementally migrated replicas with fresh replicas. Upgrading does not repair an index already reordered by an earlier release; affected deployments must resync the replica or recreate the index. | +| [`d111bcc2e`](https://github.com/rocicorp/mono/pull/6235) | skip | - | Adds deterministic initial-sync and binary-COPY benchmark fixtures plus validation tests. | Omit as benchmark and test infrastructure. No production module, option, protocol, dependency, or default changes. | +| [`7e2957cfd`](https://github.com/rocicorp/mono/pull/6237) | performance | - | Batches initial-sync COPY byte and chunk metric updates at 8 MiB boundaries, reducing calls into an active OpenTelemetry SDK while preserving final totals and labels. | Intentionally omit a public performance claim. The committed results lack raw process-isolated runs and active-OTel setup, use only three to five repetitions for several profiles, and some stated speedups do not match the displayed values. Record the metric timing change in product docs: in-progress counters advance in batches and residual totals flush at stream termination. | +| [`db394cf47`](https://github.com/rocicorp/mono/pull/6245) | skip | - | Added one restore retry and Cloud Zero-specific transient/final error messages. | Superseded by #6267, which fixes snapshot retention at the source and removes this workaround and its classifier-specific messages. No deployment-order gate remains. | +| [`0e3a0bffe`](https://github.com/rocicorp/mono/pull/6244) | fix | - | Treats PostgreSQL `wal_sender_timeout=0` as disabled instead of creating a zero-delay liveness timer that destroys and reconnects the replication stream continuously. | Include publicly. Positive timeout behavior is unchanged. With `0`, Zero also disables the keepalive and inbound-silence detection derived from that setting, matching PostgreSQL semantics. Unit tests cover disabled, default, and positive values. | +| [`299e4c97f`](https://github.com/rocicorp/mono/pull/6248) | fix | - | Logs expected schema and replica reset signals as warnings rather than migration errors while preserving reset and rollback behavior. | Include in a grouped operator-facing recovery/logging bullet. Genuine migration and rollback failures remain errors. Tests cover PostgreSQL and SQLite migration handlers and propagation of the original signal. | +| [`94938a65e`](https://github.com/rocicorp/mono/pull/6251) | fix | BREAKING | Makes custom and legacy CRUD `insert` operations a successful no-op when the Zero primary key already exists, matching optimistic-client and documented protocol behavior. | Include publicly with migration guidance. Existing rows remain unchanged, compound keys work, and other unique-constraint conflicts still fail. Applications that used duplicate-primary-key errors as validation, duplicate detection, or a transaction guard must implement that policy explicitly. Insert roles now need `SELECT` access to the conflict-target key columns. | +| [`5d8efe2f7`](https://github.com/rocicorp/mono/pull/6259) | fix | - | Skips the restore attempt and purge lock when Litestream backups are not configured, avoiding misleading restore/resync errors during local and backup-free startup. | Include in the grouped operator-facing recovery/logging bullet and credit [@asterikx](https://github.com/asterikx), whose #6250 supplied the diagnosis and original patch. No valid backup configuration changes. The landed guard requires both a backup URL and legacy executable because the current backup writer remains legacy. | +| [`5cd162213`](https://github.com/rocicorp/mono/pull/6260) | feature | BREAKING | Defaults restores to Litestream v5 when its executable is available and updates the official image to Litestream 0.5.15. Backups continue using the legacy WAL format. | Include prominently with migration and rollback guidance. The official image restores with v5; custom deployments without a v5 executable fall back to legacy. Set `ZERO_LITESTREAM_RESTORE_USING_V5=false` to retain legacy restore. Litestream v5 cannot restore Age-encrypted v3 backups. Restore metrics change from `litestream=legacy` to `litestream=v5`; custom Litestream configurations require staging validation. | +| [`46f64921c`](https://github.com/rocicorp/mono/pull/6267) | fix | - | Configures legacy Litestream snapshots on the intended interval and retains the previous generation for an additional six hours, preventing snapshots and WAL from being deleted during an active restore. | Include publicly with the Litestream feature. This supersedes #6245's retry and Cloud Zero classifier workaround. The configured snapshot interval now controls snapshot creation instead of cleanup, and retained backup storage can increase. Tests cover generated environment and configuration values. | +| [`7e0436c38`](https://github.com/rocicorp/mono/pull/6299) | fix | - | Bounds the complete client connection attempt, including asynchronous setup, abandons timed-out attempts, closes sockets created after cancellation, and prevents unhandled timeout rejections. | Include publicly as a client reliability fix. The existing ten-second timeout now applies as intended and retries use the normal backoff. Tests cover stalled setup, late sockets, cancellation, retry, and unhandled-rejection behavior. | +| [`49b13e3e5`](https://github.com/rocicorp/mono/pull/6280) | fix | - | Excludes primary-key columns from CRUD update assignments and upsert conflict updates, preventing unnecessary PostgreSQL row locks from blocking concurrent foreign-key inserts. | Include publicly. Key-only updates no-op and key-only upserts use `DO NOTHING`; keys remain in row selection, inserts, and conflict targets. Generated-SQL and real-PostgreSQL concurrency tests cover the behavior. Credit external contributor [@shayonj](https://github.com/shayonj). | +| [`855935f86`](https://github.com/rocicorp/mono/pull/6301) | skip | - | Corrects `Zero.run` JSDoc to say that the default returns currently available local data and `{type: 'complete'}` waits for authoritative data. | Omit from the public note as an inline-documentation-only correction. The product query documentation already describes the behavior correctly; no runtime, type, or API behavior changes. | +| [`43d2c0b8a`](https://github.com/rocicorp/mono/pull/6306) | fix | - | Prevents an inapplicable scalar hint from dropping valid rows or emitting invalid PostgreSQL for `NOT EXISTS`, and makes empty or NULL scalar `NOT EXISTS` conditions resolve correctly in SQLite. | Include publicly as a query-correctness fix. The server compiler now leaves decorrelation to PostgreSQL, while SQLite resolves the empty/NULL gate according to `EXISTS` versus `NOT EXISTS`. Compiler, pipeline, SQLite, fuzz, and PostgreSQL integration tests cover the corrected cases. | +| [`27636641c`](https://github.com/rocicorp/mono/pull/6310) | fix | - | Makes the official image apply the repository's `postgres@3.4.7` disconnect patch when installing the server's external runtime dependencies. | Include publicly. Workspace installs already applied the patch, but the image's generated pnpm workspace did not, leaving official images exposed to writes through disconnected sockets. The release Docker build now copies and applies the patch successfully. | +| [`67c8fe4c9`](https://github.com/rocicorp/mono/pull/6292) | fix | - | Resolves server-schema PostgreSQL types by OID rather than ambiguous type names and replaces request-path `information_schema.columns` introspection with an equivalent catalog query. | Include publicly for correctness and performance. PostgreSQL 15-18 tests cover domains, arrays, enums, views, generated and dropped columns, privileges, and ambiguous names. A release-quality PostgreSQL 17 benchmark measured the full first mutation with uncached schema metadata at 16.628 ms in Zero 1.8 and 5.782 ms in Zero 1.9, or 2.88x faster; see [`benchmarks/6292.md`](benchmarks/6292.md). Credit [@diegopereira99](https://github.com/diegopereira99). | +| [`b48eaf0f1`](https://github.com/rocicorp/mono/pull/6308) | fix | - | Records end-to-end query materialization timing only on initial completion instead of treating reconnect confirmation as a new measurement from the view's original start time. | Include publicly. This prevents false slow-query warnings and inflated materialization metrics after reconnect without changing query reconciliation. A regression test re-confirms a long-lived query and verifies only the initial metric is recorded. | +| [`6cfd0cf0f`](https://github.com/rocicorp/mono/pull/6311) | fix | - | Uses `BEGIN IMMEDIATE` for the serving replica's sole durable writer so large transactions can spill dirty pages to WAL, while snapshotters retain private `BEGIN CONCURRENT` transactions. | Include publicly as an OOM-resilience fix. Tests show more than 1 MiB of uncommitted frames spill, rollback remains safe, and historic snapshotters continue reading and staging private changes while the writer commits. No replica format, protocol, or migration changes. | +| [`bb9540345`](https://github.com/rocicorp/mono/pull/6315) | fix | - | Retries all API-server `5xx` responses instead of only `502` and `504`, preventing brief overloads and deployments from immediately surfacing as `PushFailed` or `TransformFailed`. | Include publicly. The existing four-attempt limit, exponential backoff, error bodies, and metrics remain unchanged; `4xx` responses still fail without retry. Parameterized tests cover `500`, `502`, `503`, `504`, and `599`. Credit external contributor [@shayonj](https://github.com/shayonj). | +| [`2c4a3db6b`](https://github.com/rocicorp/mono/pull/6318) | fix | - | Enriches oversized SQLite update-binding failures with transaction, relation, table, column, value type, and size context while preserving the native `RangeError` as the cause. | Include publicly as an operator diagnostic. Customer values are not included, and the change does not make oversized values replicable; it identifies the upstream poison transaction that must be corrected. A regression test covers bigint context and error causality. No protocol or schema-hash change. | +| [`b90e79d8f`](https://github.com/rocicorp/mono/pull/6312) | feature | - | Measures end-to-end serving lag from an upstream commit through ViewSyncer output, reports upstream clock-skew estimates, and counts negative lag observations that were clamped to zero. | Include publicly as operator observability. The optional `commitTimeMs` field is additive and the change stream parses in passthrough mode, so old peers ignore it and new peers accept its absence. Tests cover protocol compatibility, notification coalescing, clock-skew estimation, lag completion, and clamp reporting. | ## Breaking-Change Review -Human review identified one breaking operational change: the PostgreSQL inactivity watchdog can terminate a legitimately silent long-running statement. All `MAYBE` classifications are resolved. +Human review identified three breaking behavioral or operational changes: the PostgreSQL inactivity watchdog can terminate a legitimately silent statement, duplicate-primary-key `insert` operations now succeed instead of erroring, and the official image now restores with Litestream v5 by default. All `MAYBE` classifications are resolved. -| Area | Finding | Required resolution | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| Public API and exports | No supported export is removed. The only new externally consumable interface is the additive `head` release channel. | No migration expected. The head channel is intentionally omitted from the version-scoped public note. | -| Configuration | #6182 adds default-on query-covering shadow analysis and `ZERO_ENABLE_QUERY_COVERING`. #6220 recognizes `ZERO_PG_SOCKET_INACTIVITY_TIMEOUT`. | Keep query covering internal. Document the PostgreSQL timeout control and `0` disable value as part of the breaking migration guidance. | -| Default behavior | PostgreSQL connections now reset after roughly two to four minutes without wire activity. Statement caches now retain at most 1,000 idle statements per cache. Load shedding may rebuild pipelines earlier. | Classify the watchdog as breaking. Human review accepted the statement-cache and load-shedding defaults as non-breaking. | -| Persisted data | No schema or replica migration. Correctly preserving `__proto__` can change the client schema hash for a previously broken schema and trigger normal resync behavior. | Narrow the claim; no special migration identified. | -| Protocol | Sync, change-stream, and DDL protocol constants are compatible. `newColumns` is optional and ignored when absent. | Resolved: compatible. | -| Dependencies | No npm runtime or peer dependency changes unique to 1.9. The release image changes optional Litestream v5 from 0.5.11 to 0.5.14. | Review upstream releases, completed above; retain v5 restore coverage in the smoke-test handoff. | -| Metrics and alerts | `total_lag`, `last_total_lag`, serving-lag populations, and retry reporting change semantics. | Update product docs and include alert-migration guidance; classified as non-breaking. | -| Deployment and rollback | The DDL reader accepts the optional future field without requiring writers to emit it. Head artifacts do not move `latest` or create git tags. | Resolved for protocol/rollback. Confirm release environments produce the intended head artifacts separately. | +| Area | Finding | Required resolution | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public API and exports | No supported export is removed. The additive `head` release channel is intentionally omitted. `insert` signatures are unchanged, but authoritative duplicate-key behavior now matches the optimistic and protocol contract. #6312 adds an optional `commitTimeMs` field to the exported change protocol. | Document that `insert` is insert-if-absent by the Zero primary key and that success does not prove a row was created. Applications that require duplicate rejection need an explicit authoritative policy. The optional change-stream field is wire-compatible in both directions. | +| Configuration | #6182 adds internal `ZERO_ENABLE_QUERY_COVERING`; #6220 recognizes `ZERO_PG_SOCKET_INACTIVITY_TIMEOUT`; #6260 defaults `ZERO_LITESTREAM_RESTORE_USING_V5` to true when a v5 executable is available. | Keep query covering internal. Document the PostgreSQL timeout and disable value. Document the v5 executable, restore default, legacy writer, and `ZERO_LITESTREAM_RESTORE_USING_V5=false` opt-out. | +| Default behavior | PostgreSQL connections reset after roughly two to four minutes without wire activity. Client connection setup now obeys its existing ten-second timeout. Statement caches retain at most 1,000 idle statements. Duplicate-primary-key inserts no-op. CRUD updates omit primary-key assignments. Official-image restores use Litestream v5. Serving writes use spillable immediate transactions. API-server `5xx` responses retry. | Classify the watchdog, duplicate-insert behavior, and v5 restore default as breaking. The connection-timeout enforcement, CRUD lock correction, spillable writer, and `5xx` retries restore intended reliability behavior and remain non-breaking. | +| Persisted data | No source-database, replica-format, or protocol migration is introduced. Correctly preserving `__proto__` can trigger a normal client resync. #6225 prevents future compound-index reordering but does not repair replicas already affected. #6311 changes the serving transaction mode without changing stored data or snapshot isolation. | Tell affected #6225 deployments to resync the replica or recreate the index. No upstream application data migration is required. | +| Protocol | Sync `51`, minimum sync `30`, change-stream `6`, and DDL emitter `1` remain compatible. The DDL reader accepts future context-only v2 starts but this target still emits v1. | Resolved: compatible. Retain phase ordering and rollback constraints for the future v2 emitter in the private audit. | +| Dependencies | `@rocicorp/zero` removes optional integration peers to avoid peer-qualified duplicate installs; integrations remain consumer-provided. The official image changes Litestream v5 from 0.5.11 through 0.5.14 to 0.5.15 and activates it for restore. Litestream v5 cannot read Age-encrypted v3 backups. The image now applies the existing `postgres@3.4.7` patch. | Include the workspace-install and Docker patch fixes. Require Age users to retain legacy restore or migrate backups. Validate custom Litestream configurations in staging. | +| Metrics and alerts | Lag metrics and serving populations change as previously reviewed. Initial-sync byte/chunk counters advance in batches. Restore metrics in the official image change from `litestream=legacy` to `litestream=v5`. Query materialization timing is recorded only for initial completion. #6267 removes #6245's classifier-specific restore messages. | Update metric descriptions and dashboard migration guidance. No Cloud Zero classifier deployment order is required after #6267. | +| Deployment and rollback | DDL v2 reader support is phase-one scaffolding. This release still writes legacy Litestream backups but restores both legacy and LTX formats with v5, making 1.9 the intended rollback floor for a future v5 writer. A pre-#6260 image cannot safely restore a newer LTX-only backup. Legacy snapshots now retain an additional six-hour overlap. | Smoke-test legacy-to-v5 restore, mixed-format selection, opt-out, snapshot retention, and rollback. Roll back future v5 writer settings with the image. Keep `ZERO_LITESTREAM_RESTORE_USING_V5=true` if 1.9 is used as the rollback target for LTX backups. | ## Public Candidate Selection ### Features -- None selected. The head release channel (#6213) is intentionally omitted because it is not scoped to the 1.9 runtime. +- Default Litestream v5 restore with legacy backups still written in this release (#6260), including rollback and Age-encryption guidance, plus safe legacy snapshot retention (#6267). +- End-to-end serving lag, clock-skew estimates, and clamp diagnostics (#6312). ### Fixes @@ -143,61 +177,118 @@ Human review identified one breaking operational change: the PostgreSQL inactivi - Replication and serving-lag metric correctness (#6187 and #6219). - Recovery from half-open PostgreSQL connections, including TLS (#6220 and #6221), with a breaking-change migration note. - Custom-query cache/timer cleanup (#6228). -- Litestream v5 (#6224) is intentionally omitted because it affects only the opt-in v5 executable; retain restore smoke coverage in the release handoff. +- One installed `@rocicorp/zero` identity in pnpm workspaces with mixed integration versions (#6231). +- SQLite corruption diagnostics and fatal-log flushing (#6215). +- Compound-index order preservation during replicated type and nullability changes (#6225), including repair guidance for already-affected replicas. +- Replication compatibility when PostgreSQL disables `wal_sender_timeout` (#6244). +- Expected reset signals logged as warnings and restore skipped when Litestream is not configured (#6248 and #6259). +- Duplicate-primary-key `insert` consistency across optimistic, custom-mutator, and legacy CRUD paths (#6251), with a breaking-change migration note. +- Complete client connection-attempt timeout, cancellation, and retry cleanup (#6299). +- CRUD updates and upserts avoiding primary-key assignments and unnecessary PostgreSQL locks (#6280). +- Scalar-hint and scalar `NOT EXISTS` query correctness (#6306). +- The official image applying the bundled postgres.js disconnect patch (#6310). +- Server-schema OID resolution for same-named PostgreSQL types (#6292). +- Reconnect confirmations no longer producing false slow-query warnings or materialization timings (#6308). +- Serving replica writes spilling to WAL instead of retaining unbounded native dirty-page state (#6311). +- Mutation and query API requests retrying transient `5xx` responses (#6315). +- Oversized replication update failures reporting transaction, relation, table, column, type, and size context without customer values (#6318). ### Performance -Deferred. +- First mutation with uncached server-schema metadata (#6292): median full-request latency improved from 16.628 ms in Zero 1.8 to 5.782 ms in Zero 1.9, or 2.88x faster. +- #6206 and #6237 remain omitted because they lack release-quality end-to-end evidence for a useful developer claim. + +### Intentional Omissions + +- Head releases (#6213) are a main-branch release channel, not a version-scoped 1.9 runtime capability. +- Query-covering analysis (#6182) remains internal rollout telemetry. +- DDL v2 context-only starts (#6195) are reader-first scaffolding; the WAL reduction is not emitted by this release. +- Initial-sync benchmark fixtures (#6235) are benchmark-only. +- Initial-sync metric batching (#6237) has no public performance claim because the available evidence is insufficient; its metric reporting semantics are documented. +- The `Zero.run` JSDoc correction (#6301) is omitted because product documentation already states the correct behavior and runtime is unchanged. +- #6245's transient Litestream retry and classifier messages are superseded and removed by #6267. + +Every non-skipped commit is represented or intentionally omitted above. -Every non-skipped commit is represented or intentionally omitted above. #6206 remains deferred until release-quality end-to-end evidence is available. +## Performance Evidence -## Performance Evidence Plan +#6292 has release-quality evidence for a narrowly scoped first-mutation claim. -Deferred. +- #6206 synthetic reset-threshold tests do not measure complete incremental maintenance or user-visible latency. +- #6237's committed results do not include raw process-isolated samples or a repeatable active-OpenTelemetry setup, use fewer than ten runs, and contain ratios that do not match the displayed values. +- #6292 was measured on exact refs `zero/v1.8.0` and `67c8fe4c9`, with a byte-identical harness and no later Zero Server changes through the release target. Ten fresh Vitest processes per ref each ran five warmups and 50 measured full `handleMutateRequest()` calls. The median of run medians improved from 16.628 ms to 5.782 ms, a 2.88x speedup and 65.2% latency reduction. +- The claim is limited to first mutation handling with uncached server-schema metadata. It excludes process launch, module loading, database connection setup, and subsequent requests that reuse the schema cache. +- [`benchmarks/6292.md`](benchmarks/6292.md) records the complete methodology, run medians, host telemetry, harness hashes, and raw-log location. ## Required Product Documentation -- `contents/docs/otel.mdx`: correct `total_lag` and `last_total_lag`, add `lag_report_retries` and the same-hash rehydration counter, and describe serving-lag eligibility. -- `contents/docs/zero-cache-config.mdx`: update replication-lag-report retry behavior. Do not promote query-covering. -- `contents/docs/connecting-to-postgres.mdx`: document the socket inactivity behavior, supported override, and disable value. -- `contents/docs/release-notes/1.9.mdx`: create after audit review with performance deferred. -- `contents/docs/release-notes/index.mdx`: add Zero 1.9 first with a description matching frontmatter. +- `contents/docs/install.mdx`: state that framework, database, and mobile integrations are installed by the application. +- `contents/docs/mutators.mdx`: document existing-primary-key `insert` behavior, non-primary unique conflicts, and API retry handling. +- `contents/docs/postgres-support.mdx`: correct natural-key race guidance so applications do not rely on `insert` to reject duplicate primary keys. +- `contents/docs/connection.mdx` and `contents/docs/queries.mdx`: document API retries and final error handling. +- `contents/docs/connecting-to-postgres.mdx`: document socket inactivity and `wal_sender_timeout=0` behavior. +- `contents/docs/zero-cache-config.mdx`: retain lag-report documentation; add Litestream v5 executable, restore, and writer controls; document legacy snapshot retention. Do not promote query covering. +- `contents/docs/self-host.mdx`: document the v5 reader/legacy writer rollout, rollback floor, opt-out, custom-config validation, and Age incompatibility. +- `contents/docs/otel.mdx`: retain reviewed lag corrections; document batched initial-sync counters, the `litestream` restore label/default change, and #6312's end-to-end serving-lag, clamp, and clock-skew metrics. +- `contents/docs/queries.mdx`: no change required; it already states the corrected `Zero.run` default and `{type: 'complete'}` behavior. +- `contents/docs/release-notes/1.9.mdx`: include the reconstructed maintenance fixes and the validated #6292 first-mutation benchmark without broadening it into a general startup claim. +- `contents/docs/release-notes/index.mdx`: no change required while the existing description remains unchanged. - Generated search and LLM artifacts: regenerate after product-doc edits. ## Attribution - `d4f33d6a6` is authored by [@YevheniiKotyrlo](https://github.com/YevheniiKotyrlo) and co-authored by Matt Wonlaw. The author uses a GitHub noreply address and their public profile lists no company or Rocicorp affiliation. If the fix is included, append `(thanks [@YevheniiKotyrlo](https://github.com/YevheniiKotyrlo)!)`. - `cb014857f` is authored by Erik Arvidsson but explicitly based on #6145 by external contributor [@tjenkinson](https://github.com/tjenkinson). Append `(thanks [@tjenkinson](https://github.com/tjenkinson)!)`. -- Other selected commits are authored by known Rocicorp contributors in the range. Recheck PR-level co-authors immediately before final publication. +- `5d8efe2f7` is Rocicorp's landed implementation of the diagnosis and original #6250 patch from external contributor [@asterikx](https://github.com/asterikx). Append `(thanks [@asterikx](https://github.com/asterikx)!)`. +- `49b13e3e5` and `bb9540345` are authored by external contributor [@shayonj](https://github.com/shayonj), whose public profile lists Tines rather than Rocicorp. Append thanks to both public fixes. +- `67c8fe4c9` is authored by external contributor [@diegopereira99](https://github.com/diegopereira99), whose public profile and organizations list no Rocicorp affiliation. Append thanks to the public fix. +- Other selected commits are authored by known Rocicorp contributors in the range. ## Human Review Decisions - Classify the PostgreSQL inactivity watchdog as breaking and document `ZERO_PG_SOCKET_INACTIVITY_TIMEOUT`, including `0` to disable it. -- Defer #6206 benchmarks and publish no performance claim. +- Defer #6206 benchmarks and publish no performance claim for that change. - Omit the head release channel from Zero 1.9 notes. - Keep query-covering shadow telemetry internal and omit it publicly. - Include the 1,000-entry statement-cache bound as a non-breaking reliability fix. - Document the replication and serving-lag metric migration as a non-breaking operator-facing correction. -- Omit the optional Litestream v5 update from public notes, but retain its restore smoke test in the release handoff. +- Supersede the earlier optional-v5 omission: #6260 makes v5 the official-image restore default, so include it publicly and classify the Age incompatibility as breaking. +- Classify duplicate-primary-key `insert` success as breaking for applications that relied on the previous server error, even though the change restores the documented contract. +- Include the compound-index fix with an explicit repair note for replicas affected before upgrade. +- Defer #6237 performance claims; document only the changed metric reporting cadence. +- Mark #6245 superseded by #6267 and remove the obsolete Cloud Zero classifier deployment-order gate. +- Include #6280, #6299, #6306, #6310, #6292, #6308, #6311, and #6315 as public fixes; omit #6301 as inline documentation only. +- Include #6318 as an operator diagnostic without claiming that oversized values can replicate successfully. +- Include #6312 as additive operator observability and call out that upstream clock skew can bias end-to-end lag. +- Include #6292 in the Performance section using the 10-run Zero 1.8 versus Zero 1.9 comparison, scoped to first mutation handling with uncached server-schema metadata. Remaining blockers: 1. Obtain a 1.9 canary for the smoke-test handoff. -2. Run the release-image Litestream v5 restore smoke test even though the dependency update is omitted publicly. +2. Run release-image v3/WAL-to-v5 restore, mixed WAL/LTX selection, legacy opt-out, view-syncer, replication-manager, and rollback smoke tests on supported architectures. +3. Verify Age-encrypted backups are rejected or use the documented legacy opt-out before rollout. ## Audit Review Gate -Human review is complete. Public drafting may proceed with performance deferred. +Human review selected and published the reconstructed maintenance target, retained the three existing breaking classifications, and approved the narrowly scoped #6292 performance claim. Other performance claims remain deferred. ## Validation -- Audit coverage: PASS. All 39 raw-range commits have exactly one decision row; all 15 patch-equivalent 1.8 backports are recorded. +- Audit coverage: PASS. All 63 raw-range commits have exactly one decision row; all 15 patch-equivalent 1.8 backports are recorded. - Protocol compatibility: PASS. -- Placeholder links: PASS. No draft placeholders remain in the audit or release note. -- Formatting: PASS with `pnpm check-format` after formatting the generated search index. -- Types: PASS with `pnpm check-types`. -- Tests: PASS with `pnpm test` (73 tests). -- Production build: PASS with `pnpm build`; search and LLM artifacts were regenerated and Next.js built all 160 pages. +- Placeholder links: PASS. No `TODO`, `TBD`, or `PLACEHOLDER` markers remain in the audit or release note. +- Maintenance history: PASS. All 24 maintenance commits are signed. Twenty-one are patch-equivalent to main; #6311 differs only by an unchanged inferred test callback type, #6318 omits only an unrelated rollback helper absent from 1.9, and #6312 omits generated API snapshots whose infrastructure is absent from 1.9. Their selected production changes and added tests match their sources. +- Mono targeted tests: PASS. zero-client 645, selected zero-cache 150, zero-server 433, z2s 65, zqlite 192, and scalar PostgreSQL integration 5. +- Mono full zero-cache test: PASS after #6312 with 4,012 passed and 32 skipped across 301 test files. +- Mono static validation: PASS. All 42 typecheck/build tasks, formatting, dependency verification, and type-aware lint completed; lint reported 0 errors and 1,512 warnings. +- #6318 validation: PASS. The 46-test change-processor suite, zero-cache typecheck, formatting, and lint completed; lint reported 0 errors and 432 warnings. +- #6312 validation: PASS. The complete zero-cache suite, zero-cache typecheck, formatting, and lint completed; lint reported 0 errors and 432 warnings. Its Zero Cache patch ID matches canonical main commit `0beb0ba76`. +- Release image: PASS. `@rocicorp/zero@1.9.0` packed and the linux/amd64 Docker build completed with the relocated `postgres@3.4.7` patch copied and applied by the image's generated pnpm workspace. +- Docs formatting: PASS with `pnpm check-format` after formatting the generated search index. +- Docs types: PASS with `pnpm check-types`. +- Docs tests: PASS with `pnpm test` (73 tests). +- Docs production build: PASS. Search and LLM artifacts were regenerated and Next.js built all 160 pages. - Source lint: `pnpm exec oxlint --quiet` completed with 0 errors and 15 warnings. -- Type-aware lint: BLOCKED before file analysis. `pnpm lint` causes locked `oxlint@1.65.0` / `oxlint-tsgolint@0.8.6` to panic on unknown rule `no-useless-default-assignment`. This repository tool-version issue is recorded in the main repo's `PAPERCUTS.md`. -- Performance: Deferred by human review; no benchmark or public performance claim. +- Type-aware lint: BLOCKED before file analysis. `pnpm lint` causes locked `oxlint@1.65.0` / `oxlint-tsgolint@0.8.6` to panic on unknown rule `no-useless-default-assignment`. This existing repository tool-version issue remains recorded in the main repo's `PAPERCUTS.md`. +- Whitespace: PASS with `git diff --check`. +- Performance: PASS. The #6292 comparison used 10 process-isolated runs per release ref and measured a 2.88x improvement in first-mutation latency with uncached server-schema metadata; #6206 and #6237 remain deferred. diff --git a/assets/search-index.json b/assets/search-index.json index d1871063..5e425861 100644 --- a/assets/search-index.json +++ b/assets/search-index.json @@ -407,7 +407,7 @@ "title": "Connecting to Postgres", "searchTitle": "Connecting to Postgres", "url": "/docs/connecting-to-postgres", - "content": "In the future, Zero will work with many different backend databases. Today only Postgres is supported. Specifically, Zero requires Postgres v15.0 or higher, and support for logical replication. Here are some common Postgres options and what we know about their support level: Event Triggers Zero uses Postgres “Event Triggers” when possible to implement high-quality, efficient schema migration. Some hosted Postgres providers don't provide access to Event Triggers. Zero still works out of the box with these providers, but for correctness, any schema change triggers a full reset of all server-side and client-side state. For small databases (< 10GB) this can be OK, but for bigger databases you should either manually tell Zero about the schema change or choose a provider with event trigger support. Configuration WAL Level The Postgres wal_level config parameter has to be set to logical. You can check what level your pg has with this command: psql -c 'SHOW wal_level' If it doesn’t output logical then you need to change the wal level. To do this, run: psql -c \"ALTER SYSTEM SET wal_level = 'logical';\" Then restart Postgres. On most pg systems you can do this like so: data_dir=$(psql -t -A -c 'SHOW data_directory') pg_ctl -D \"$data_dir\" restart After your server restarts, show the wal_level again to ensure it has changed: psql -c 'SHOW wal_level' Socket Inactivity Timeout zero-cache monitors wire activity on its Postgres connections so it can recover when a proxy or network failure leaves a half-open socket. The watchdog samples each connection every 120,000 milliseconds by default and resets it after one to two intervals without any bytes read or written. In-flight queries on a reset connection are rejected and can recover through their normal retry or restart paths. Wire activity resets the watchdog, so streaming operations such as COPY remain active. A statement that legitimately computes without sending any data for several minutes can be interrupted. Set ZERO_PG_SOCKET_INACTIVITY_TIMEOUT to a longer sampling interval in milliseconds when running such statements: ZERO_PG_SOCKET_INACTIVITY_TIMEOUT=600000 Set the value to 0 to disable the watchdog. Bounding WAL Size For development databases, you can set a max_slot_wal_keep_size value in Postgres. This will help limit the amount of WAL kept around. This is a configuration parameter that bounds the amount of WAL kept around for replication slots, and invalidates the slots that are too far behind. Zero-cache will automatically detect if the replication slot has been invalidated and re-sync replicas from scratch. This configuration can cause problems like slot has been invalidated because it exceeded the maximum reserved size and is not recommended for production databases. Provider-Specific Notes PlanetScale for Postgres Roles zero-cache should connect using the default role that PlanetScale provides, because PlanetScale user-defined roles cannot create replication slots. Connection Limits Change max_connections to at least 100. The default is 25, which is too low for Zero in most configurations. Pooling Make sure to only use a direct connection for the ZERO_UPSTREAM_DB, and use pooled URLs for ZERO_CVR_DB, ZERO_CHANGE_DB, and your API (see Deployment). High Availability PlanetScale Postgres can fail over to a standby during maintenance or an outage. By default a logical replication slot does not survive promotion of a standby, so after a failover zero-cache would find its slot missing and re-sync every replica from scratch. To avoid this, first, run zero-cache with ZERO_UPSTREAM_PG_REPLICATION_SLOT_FAILOVER=true so it creates failover-enabled slots. Then, run the script below to register Zero's replication slots with PlanetScale and enable the two cluster parameters failover needs: APP=\"\" # your ZERO_APP_ID — on Zero Cloud this is your instance ID ORG=\"\" # PlanetScale organization DB=\"\" # PlanetScale database BRANCH=\"main\" SHARD=\"0\" if [ -z \"$APP\" ] || [ -z \"$ORG\" ] || [ -z \"$DB\" ]; then echo \"Set APP, ORG, and DB first — nothing was sent.\" elif pscale api -X PATCH \"organizations/${ORG}/databases/${DB}/branches/${BRANCH}/changes\" --input=- >/dev/null </dev/null <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 } }) 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 itself crashes, or if the mutate or query endpoints return a network or HTTP error, Zero transitions to the error state. This type of error is unlikely to resolve just by retrying, so Zero doesn't try. The app can retry the connection manually by calling zero.connection.connect(). 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: 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 '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 } }) 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 endpoint failures remain after retries, Zero enters the error state. Endpoint fetch failures and 5xx responses get up to four total attempts; 4xx responses are not retried. 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.", "headings": [ { "text": "Overview", @@ -833,7 +847,7 @@ "kind": "page" }, { - "id": "128-connection#overview", + "id": "129-connection#overview", "title": "Connection Status", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -843,7 +857,7 @@ "kind": "section" }, { - "id": "129-connection#usage", + "id": "130-connection#usage", "title": "Connection Status", "searchTitle": "Usage", "sectionTitle": "Usage", @@ -853,7 +867,7 @@ "kind": "section" }, { - "id": "130-connection#offline", + "id": "131-connection#offline", "title": "Connection Status", "searchTitle": "Offline", "sectionTitle": "Offline", @@ -863,7 +877,7 @@ "kind": "section" }, { - "id": "131-connection#offline-ui", + "id": "132-connection#offline-ui", "title": "Connection Status", "searchTitle": "Offline UI", "sectionTitle": "Offline UI", @@ -873,17 +887,17 @@ "kind": "section" }, { - "id": "132-connection#details", + "id": "133-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 itself crashes, or if the mutate or query endpoints return a network or HTTP error, Zero transitions to the error state. This type of error is unlikely to resolve just by retrying, so Zero doesn't try. The app can retry the connection manually by calling zero.connection.connect(). 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": "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 endpoint failures remain after retries, Zero enters the error state. Endpoint fetch failures and 5xx responses get up to four total attempts; 4xx responses are not retried. 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": "133-connection#connecting", + "id": "134-connection#connecting", "title": "Connection Status", "searchTitle": "Connecting", "sectionTitle": "Connecting", @@ -893,7 +907,7 @@ "kind": "section" }, { - "id": "134-connection#connected", + "id": "135-connection#connected", "title": "Connection Status", "searchTitle": "Connected", "sectionTitle": "Connected", @@ -903,7 +917,7 @@ "kind": "section" }, { - "id": "135-connection#disconnected", + "id": "136-connection#disconnected", "title": "Connection Status", "searchTitle": "Disconnected", "sectionTitle": "Disconnected", @@ -913,17 +927,17 @@ "kind": "section" }, { - "id": "136-connection#error", + "id": "137-connection#error", "title": "Connection Status", "searchTitle": "Error", "sectionTitle": "Error", "sectionId": "error", "url": "/docs/connection", - "content": "If zero-cache itself crashes, or if the mutate or query endpoints return a network or HTTP error, Zero transitions to the error state. This type of error is unlikely to resolve just by retrying, so Zero doesn't try. The app can retry the connection manually by calling zero.connection.connect(). 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}`) ) }) })", + "content": "If zero-cache crashes, or mutate or query endpoint failures remain after retries, Zero enters the error state. Endpoint fetch failures and 5xx responses get up to four total attempts; 4xx responses are not retried. 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}`) ) }) })", "kind": "section" }, { - "id": "137-connection#needs-auth", + "id": "138-connection#needs-auth", "title": "Connection Status", "searchTitle": "Needs-Auth", "sectionTitle": "Needs-Auth", @@ -933,7 +947,7 @@ "kind": "section" }, { - "id": "138-connection#closed", + "id": "139-connection#closed", "title": "Connection Status", "searchTitle": "Closed", "sectionTitle": "Closed", @@ -943,7 +957,7 @@ "kind": "section" }, { - "id": "139-connection#why-zero-doesnt-support-offline-writes", + "id": "140-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", @@ -953,7 +967,7 @@ "kind": "section" }, { - "id": "140-connection#example", + "id": "141-connection#example", "title": "Connection Status", "searchTitle": "Example", "sectionTitle": "Example", @@ -963,7 +977,7 @@ "kind": "section" }, { - "id": "141-connection#tradeoffs", + "id": "142-connection#tradeoffs", "title": "Connection Status", "searchTitle": "Tradeoffs", "sectionTitle": "Tradeoffs", @@ -973,7 +987,7 @@ "kind": "section" }, { - "id": "142-connection#zeros-position", + "id": "143-connection#zeros-position", "title": "Connection Status", "searchTitle": "Zero's Position", "sectionTitle": "Zero's Position", @@ -1021,7 +1035,7 @@ "kind": "page" }, { - "id": "143-debug/analyze-query-cli#set-up", + "id": "144-debug/analyze-query-cli#set-up", "title": "Analyze Query CLI", "searchTitle": "Set Up", "sectionTitle": "Set Up", @@ -1031,7 +1045,7 @@ "kind": "section" }, { - "id": "144-debug/analyze-query-cli#run-zql-queries", + "id": "145-debug/analyze-query-cli#run-zql-queries", "title": "Analyze Query CLI", "searchTitle": "Run ZQL Queries", "sectionTitle": "Run ZQL Queries", @@ -1041,7 +1055,7 @@ "kind": "section" }, { - "id": "145-debug/analyze-query-cli#production-use", + "id": "146-debug/analyze-query-cli#production-use", "title": "Analyze Query CLI", "searchTitle": "Production Use", "sectionTitle": "Production Use", @@ -1051,7 +1065,7 @@ "kind": "section" }, { - "id": "146-debug/analyze-query-cli#env-var-shorthand", + "id": "147-debug/analyze-query-cli#env-var-shorthand", "title": "Analyze Query CLI", "searchTitle": "Env Var Shorthand", "sectionTitle": "Env Var Shorthand", @@ -1061,7 +1075,7 @@ "kind": "section" }, { - "id": "147-debug/analyze-query-cli#other-input-modes", + "id": "148-debug/analyze-query-cli#other-input-modes", "title": "Analyze Query CLI", "searchTitle": "Other Input Modes", "sectionTitle": "Other Input Modes", @@ -1071,7 +1085,7 @@ "kind": "section" }, { - "id": "148-debug/analyze-query-cli#output", + "id": "149-debug/analyze-query-cli#output", "title": "Analyze Query CLI", "searchTitle": "Output", "sectionTitle": "Output", @@ -1081,7 +1095,7 @@ "kind": "section" }, { - "id": "149-debug/analyze-query-cli#optional-output", + "id": "150-debug/analyze-query-cli#optional-output", "title": "Analyze Query CLI", "searchTitle": "Optional Output", "sectionTitle": "Optional Output", @@ -1141,7 +1155,7 @@ "kind": "page" }, { - "id": "150-debug/inspector#accessing-the-inspector", + "id": "151-debug/inspector#accessing-the-inspector", "title": "Inspector", "searchTitle": "Accessing the Inspector", "sectionTitle": "Accessing the Inspector", @@ -1151,7 +1165,7 @@ "kind": "section" }, { - "id": "151-debug/inspector#clients-and-groups", + "id": "152-debug/inspector#clients-and-groups", "title": "Inspector", "searchTitle": "Clients and Groups", "sectionTitle": "Clients and Groups", @@ -1161,7 +1175,7 @@ "kind": "section" }, { - "id": "152-debug/inspector#queries", + "id": "153-debug/inspector#queries", "title": "Inspector", "searchTitle": "Queries", "sectionTitle": "Queries", @@ -1171,7 +1185,7 @@ "kind": "section" }, { - "id": "153-debug/inspector#analyzing-queries", + "id": "154-debug/inspector#analyzing-queries", "title": "Inspector", "searchTitle": "Analyzing Queries", "sectionTitle": "Analyzing Queries", @@ -1181,7 +1195,7 @@ "kind": "section" }, { - "id": "154-debug/inspector#interpreting-query-analysis", + "id": "155-debug/inspector#interpreting-query-analysis", "title": "Inspector", "searchTitle": "Interpreting Query Analysis", "sectionTitle": "Interpreting Query Analysis", @@ -1191,7 +1205,7 @@ "kind": "section" }, { - "id": "155-debug/inspector#viewing-sqlite-plans", + "id": "156-debug/inspector#viewing-sqlite-plans", "title": "Inspector", "searchTitle": "Viewing SQLite Plans", "sectionTitle": "Viewing SQLite Plans", @@ -1201,7 +1215,7 @@ "kind": "section" }, { - "id": "156-debug/inspector#viewing-zero-plans", + "id": "157-debug/inspector#viewing-zero-plans", "title": "Inspector", "searchTitle": "Viewing Zero Plans", "sectionTitle": "Viewing Zero Plans", @@ -1211,7 +1225,7 @@ "kind": "section" }, { - "id": "157-debug/inspector#analyzing-arbitrary-zql", + "id": "158-debug/inspector#analyzing-arbitrary-zql", "title": "Inspector", "searchTitle": "Analyzing Arbitrary ZQL", "sectionTitle": "Analyzing Arbitrary ZQL", @@ -1221,7 +1235,7 @@ "kind": "section" }, { - "id": "158-debug/inspector#table-data", + "id": "159-debug/inspector#table-data", "title": "Inspector", "searchTitle": "Table Data", "sectionTitle": "Table Data", @@ -1231,7 +1245,7 @@ "kind": "section" }, { - "id": "159-debug/inspector#server-version", + "id": "160-debug/inspector#server-version", "title": "Inspector", "searchTitle": "Server Version", "sectionTitle": "Server Version", @@ -1272,7 +1286,7 @@ "kind": "page" }, { - "id": "160-debug/replication#resetting", + "id": "161-debug/replication#resetting", "title": "Replication", "searchTitle": "Resetting", "sectionTitle": "Resetting", @@ -1282,7 +1296,7 @@ "kind": "section" }, { - "id": "161-debug/replication#inspecting", + "id": "162-debug/replication#inspecting", "title": "Replication", "searchTitle": "Inspecting", "sectionTitle": "Inspecting", @@ -1292,7 +1306,7 @@ "kind": "section" }, { - "id": "162-debug/replication#miscellaneous", + "id": "163-debug/replication#miscellaneous", "title": "Replication", "searchTitle": "Miscellaneous", "sectionTitle": "Miscellaneous", @@ -1332,7 +1346,7 @@ "kind": "page" }, { - "id": "163-debug/slow-queries#analyze-queries", + "id": "164-debug/slow-queries#analyze-queries", "title": "Slow Queries", "searchTitle": "Analyze Queries", "sectionTitle": "Analyze Queries", @@ -1342,7 +1356,7 @@ "kind": "section" }, { - "id": "164-debug/slow-queries#check-ttl", + "id": "165-debug/slow-queries#check-ttl", "title": "Slow Queries", "searchTitle": "Check ttl", "sectionTitle": "Check ttl", @@ -1352,7 +1366,7 @@ "kind": "section" }, { - "id": "165-debug/slow-queries#locality", + "id": "166-debug/slow-queries#locality", "title": "Slow Queries", "searchTitle": "Locality", "sectionTitle": "Locality", @@ -1362,7 +1376,7 @@ "kind": "section" }, { - "id": "166-debug/slow-queries#check-storage", + "id": "167-debug/slow-queries#check-storage", "title": "Slow Queries", "searchTitle": "Check Storage", "sectionTitle": "Check Storage", @@ -1372,7 +1386,7 @@ "kind": "section" }, { - "id": "167-debug/slow-queries#statz", + "id": "168-debug/slow-queries#statz", "title": "Slow Queries", "searchTitle": "/statz", "sectionTitle": "/statz", @@ -1405,7 +1419,7 @@ "kind": "page" }, { - "id": "168-deprecated/ad-hoc-queries#overview", + "id": "169-deprecated/ad-hoc-queries#overview", "title": "Ad-Hoc Queries (Deprecated)", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -1429,7 +1443,7 @@ "kind": "page" }, { - "id": "169-deprecated/crud-mutators#overview", + "id": "170-deprecated/crud-mutators#overview", "title": "CRUD Mutators (Deprecated)", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -1501,7 +1515,7 @@ "kind": "page" }, { - "id": "170-deprecated/rls-permissions#define-permissions", + "id": "171-deprecated/rls-permissions#define-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Define Permissions", "sectionTitle": "Define Permissions", @@ -1511,7 +1525,7 @@ "kind": "section" }, { - "id": "171-deprecated/rls-permissions#access-is-denied-by-default", + "id": "172-deprecated/rls-permissions#access-is-denied-by-default", "title": "RLS Permissions (Deprecated)", "searchTitle": "Access is Denied by Default", "sectionTitle": "Access is Denied by Default", @@ -1521,7 +1535,7 @@ "kind": "section" }, { - "id": "172-deprecated/rls-permissions#permission-evaluation", + "id": "173-deprecated/rls-permissions#permission-evaluation", "title": "RLS Permissions (Deprecated)", "searchTitle": "Permission Evaluation", "sectionTitle": "Permission Evaluation", @@ -1531,7 +1545,7 @@ "kind": "section" }, { - "id": "173-deprecated/rls-permissions#permission-deployment", + "id": "174-deprecated/rls-permissions#permission-deployment", "title": "RLS Permissions (Deprecated)", "searchTitle": "Permission Deployment", "sectionTitle": "Permission Deployment", @@ -1541,7 +1555,7 @@ "kind": "section" }, { - "id": "174-deprecated/rls-permissions#rules", + "id": "175-deprecated/rls-permissions#rules", "title": "RLS Permissions (Deprecated)", "searchTitle": "Rules", "sectionTitle": "Rules", @@ -1551,7 +1565,7 @@ "kind": "section" }, { - "id": "175-deprecated/rls-permissions#select-permissions", + "id": "176-deprecated/rls-permissions#select-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Select Permissions", "sectionTitle": "Select Permissions", @@ -1561,7 +1575,7 @@ "kind": "section" }, { - "id": "176-deprecated/rls-permissions#insert-permissions", + "id": "177-deprecated/rls-permissions#insert-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Insert Permissions", "sectionTitle": "Insert Permissions", @@ -1571,7 +1585,7 @@ "kind": "section" }, { - "id": "177-deprecated/rls-permissions#update-permissions", + "id": "178-deprecated/rls-permissions#update-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Update Permissions", "sectionTitle": "Update Permissions", @@ -1581,7 +1595,7 @@ "kind": "section" }, { - "id": "178-deprecated/rls-permissions#delete-permissions", + "id": "179-deprecated/rls-permissions#delete-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Delete Permissions", "sectionTitle": "Delete Permissions", @@ -1591,7 +1605,7 @@ "kind": "section" }, { - "id": "179-deprecated/rls-permissions#permissions-based-on-auth-data", + "id": "180-deprecated/rls-permissions#permissions-based-on-auth-data", "title": "RLS Permissions (Deprecated)", "searchTitle": "Permissions Based on Auth Data", "sectionTitle": "Permissions Based on Auth Data", @@ -1601,7 +1615,7 @@ "kind": "section" }, { - "id": "180-deprecated/rls-permissions#debugging", + "id": "181-deprecated/rls-permissions#debugging", "title": "RLS Permissions (Deprecated)", "searchTitle": "Debugging", "sectionTitle": "Debugging", @@ -1611,7 +1625,7 @@ "kind": "section" }, { - "id": "181-deprecated/rls-permissions#read-permissions", + "id": "182-deprecated/rls-permissions#read-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Read Permissions", "sectionTitle": "Read Permissions", @@ -1621,7 +1635,7 @@ "kind": "section" }, { - "id": "182-deprecated/rls-permissions#write-permissions", + "id": "183-deprecated/rls-permissions#write-permissions", "title": "RLS Permissions (Deprecated)", "searchTitle": "Write Permissions", "sectionTitle": "Write Permissions", @@ -1635,7 +1649,7 @@ "title": "Install Zero", "searchTitle": "Install Zero", "url": "/docs/install", - "content": "This guide shows how to add Zero to an existing TypeScript-based web app. For a concrete end-to-end walkthrough, build the music app in the tutorial. Integrate Zero Set Up Your Database You'll need a Postgres database with logical replication enabled for development. # IMPORTANT: logical WAL level is required for Zero # to sync data to its SQLite replica docker run -d --name zero-postgres \\ -e POSTGRES_DB=\"zero\" \\ -e POSTGRES_PASSWORD=\"pass\" \\ -p 5432:5432 \\ postgres:18 \\ postgres -c wal_level=logical# Start Postgres.app first. Requires Postgres 15 or higher. # If these already exist, you can skip those commands. createuser -s postgres createdb -O postgres zero psql -d postgres -c \"ALTER USER postgres WITH PASSWORD 'pass';\" psql -d postgres -c \"ALTER SYSTEM SET wal_level = 'logical';\" # Restart Postgres.app, then verify: psql -d postgres -c \"SHOW wal_level;\" See Provider Support and make sure wal_level is logical. Create a .env file so your app server and zero-cache-dev use the same Postgres connection: # Update to your app's database connection URL ZERO_UPSTREAM_DB=\"postgres://postgres:pass@localhost:5432/zero\" Install Zero Add Zero and the validator used in these examples: npm install @rocicorp/zero zodpnpm add @rocicorp/zero zod # Note: pnpm disables postinstall scripts by default for security. # Create or update pnpm-workspace.yaml to allow the native package build: # https://pnpm.io/settings#allowbuilds # allowBuilds: # '@rocicorp/zero-sqlite3': true pnpm rebuild @rocicorp/zero-sqlite3bun add @rocicorp/zero zod # Note: Bun disables postinstall scripts by default for security. # Either approve the build: bun pm trust @rocicorp/zero-sqlite3 # Or add to package.json, then rebuild the native packages: # \"trustedDependencies\": [\"@rocicorp/zero-sqlite3\"]yarn add @rocicorp/zero zod # Note: Modern Yarn doesn't run postinstall scripts by default. # Add to package.json, then rebuild the native packages: # \"dependenciesMeta\": { # \"@rocicorp/zero-sqlite3\": { # \"built\": true # } # } yarn rebuild @rocicorp/zero-sqlite3 These examples use Zod; any Standard Schema-compatible validator works. Set Up Your Zero Schema Zero uses a file called schema.ts to provide a type-safe query API. If you use Drizzle or Prisma, you can generate the schema automatically. Otherwise, you can create it manually. npm install -D drizzle-zero npx drizzle-zero generate --output src/zero/schema.tspnpm add -D drizzle-zero pnpm exec drizzle-zero generate --output src/zero/schema.tsbun add -D drizzle-zero bunx drizzle-zero generate --output src/zero/schema.tsyarn add -D drizzle-zero yarn exec drizzle-zero generate --output src/zero/schema.tsnpm install -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } npx prisma generatepnpm add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } pnpx prisma generatebun add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } bunx prisma generateyarn add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } yarn prisma generate// src/zero/schema.ts import { boolean, createBuilder, createSchema, string, table } from '@rocicorp/zero' const user = table('user') .columns({ id: string(), name: string(), active: boolean() }) .primaryKey('id') export const schema = createSchema({ tables: [user] }) export const zql = createBuilder(schema) declare module '@rocicorp/zero' { interface DefaultTypes { schema: typeof schema } } Set Up the Zero Client Zero has first-class support for React and SolidJS, and there is also a low-level API you can use in any TypeScript-based project. Choose the tab that most closely matches where your app creates its root layout or client instance. // src/routes/__root.tsx import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' import type {ReactNode} from 'react' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export const Route = createRootRoute({ shellComponent: RootDocument }) function RootDocument({children}: {children: ReactNode}) { return ( {children} ) }// src/app/providers.tsx 'use client' import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import type {ReactNode} from 'react' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export function Providers({ children }: { children: ReactNode }) { return {children} } // src/app/layout.tsx import type {ReactNode} from 'react' import {Providers} from './providers' export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} ) }// src/app.tsx import {MetaProvider, Title} from '@solidjs/meta' import {Router} from '@solidjs/router' import {FileRoutes} from '@solidjs/start/router' import {ZeroProvider} from '@rocicorp/zero/solid' import type {ZeroOptions} from '@rocicorp/zero' import {Suspense} from 'solid-js' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export default function App() { return ( ( Zero App {props.children} )} > ) }// src/zero.ts import {Zero} from '@rocicorp/zero' import type {ZeroOptions} from '@rocicorp/zero' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } const zero = new Zero(opts) export {zero} Sync Data Define Query Shared reads are conventionally stored in queries.ts. Use zql from schema.ts to construct and return a ZQL query: // src/zero/queries.ts import {defineQueries, defineQuery} from '@rocicorp/zero' import {zql} from './schema' export const queries = defineQueries({ allUsers: defineQuery(() => zql.user) }) See Reading Data for more on filters, sorting, relationships, and permissions. Add Query Endpoint Zero doesn't allow clients to send arbitrary ZQL to zero-cache. Instead, Zero sends the query name and arguments to the query endpoint on your server, which responds to zero-cache with the authoritative ZQL. This prevents clients from reading arbitrary data and is the basis of permissions. // src/routes/api/query.ts import {createFileRoute} from '@tanstack/react-router' import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from '../../zero/queries' import {schema} from '../../zero/schema' export const Route = createFileRoute('/api/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) } } } })// src/app/api/query/route.ts import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from '../../../zero/queries' import {schema} from '../../../zero/schema' 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/query.ts import type {APIEvent} from '@solidjs/start/server' import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from '../../zero/queries' import {schema} from '../../zero/schema' 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) }// src/api/app.ts import {Hono} from 'hono' import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from '../zero/queries' import {schema} from '../zero/schema' const app = new Hono() app.post('/api/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) }) Invoke Query Querying for data is framework-specific. Most of the time, you will use a helper like useQuery that integrates into your framework's rendering model: import {useQuery} from '@rocicorp/zero/react' import {queries} from './zero/queries' const [users] = useQuery(queries.allUsers())import {useQuery} from '@rocicorp/zero/solid' import {queries} from './zero/queries' const [users] = useQuery(() => queries.allUsers())import {zero} from './zero' import {queries} from './zero/queries' const users = await zero.run(queries.allUsers()) More about Queries Filters, sorting, relationships, preloading, and more Server-driven authentication Mutate Data Define Mutators Data is written in Zero apps using mutators. Similar to queries, shared writes usually live in mutators.ts: // src/zero/mutators.ts import {defineMutators, defineMutator} from '@rocicorp/zero' import {z} from 'zod' export const mutators = defineMutators({ activateUser: defineMutator( z.object({id: z.string()}), async ({args: {id}, tx}) => { await tx.mutate.user.update({id, active: true}) } ) }) You can use the CRUD-style API with tx.mutate..() to write data. You can also use tx.run(zql.
.) to run queries within your mutator. Register the mutators where you create the Zero client: // src/routes/__root.tsx import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from '../zero/mutators' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema, mutators }// src/app/providers.tsx import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from '../zero/mutators' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema, mutators }// src/app.tsx import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from './zero/mutators' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema, mutators }// src/zero.ts import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from './zero/mutators' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema, mutators } Add Mutate Endpoint Zero requires a mutate endpoint that runs on your server and connects directly to Postgres. First, create a dbProvider with the Postgres adapter that matches your stack. These examples assume the selected database client is already installed in your app. // src/zero/db-provider.ts import {zeroDrizzle} from '@rocicorp/zero/server/adapters/drizzle' import {drizzle} from 'drizzle-orm/node-postgres' import {Pool} from 'pg' import {schema} from './schema' import * as drizzleSchema from '../drizzle/schema' // If your app uses a different Drizzle driver, reuse your existing client. const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const pool = new Pool({ connectionString }) export const drizzleClient = drizzle(pool, { schema: drizzleSchema }) export const dbProvider = zeroDrizzle(schema, drizzleClient) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// src/zero/db-provider.ts import {Kysely, PostgresDialect} from 'kysely' import {zeroKysely} from '@rocicorp/zero/server/adapters/kysely' import {Pool} from 'pg' import {schema} from './schema' import type {Database} from '../kysely/database' const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const kysely = new Kysely({ dialect: new PostgresDialect({ pool: new Pool({ connectionString }) }) }) export const dbProvider = zeroKysely(schema, kysely) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// src/zero/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 './schema' const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) }) export const dbProvider = zeroPrisma(schema, prisma) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// src/zero/db-provider.ts import {zeroNodePg} from '@rocicorp/zero/server/adapters/pg' import {Pool} from 'pg' import {schema} from './schema' const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const pool = new Pool({ connectionString }) export const dbProvider = zeroNodePg(schema, pool) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// src/zero/db-provider.ts import {zeroPostgresJS} from '@rocicorp/zero/server/adapters/postgresjs' import postgres from 'postgres' import {schema} from './schema' const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const sql = postgres(connectionString) export const dbProvider = zeroPostgresJS(schema, sql) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } } Then use the dbProvider and helpers to define the mutate endpoint: // src/routes/api/mutate.ts import {createFileRoute} from '@tanstack/react-router' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from '../../zero/mutators' import {dbProvider} from '../../zero/db-provider' export const Route = createFileRoute('/api/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) } } } })// src/app/api/mutate/route.ts import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from '../../../zero/mutators' import {dbProvider} from '../../../zero/db-provider' 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/mutate.ts import type {APIEvent} from '@solidjs/start/server' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from '../../zero/mutators' import {dbProvider} from '../../zero/db-provider' 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) }// src/api/app.ts import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from '../zero/mutators' import {dbProvider} from '../zero/db-provider' app.post('/api/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) }) Mutators on the server allow for write permissions and can be different from the client implementation. You can also do work after a mutation runs on the server, like send notifications. These examples have only public queries and mutators, so they do not pass a context. In authenticated apps, you should validate auth in the request, derive context from the session, and pass the context to the mutate and query handlers. See Authentication. Start your app server in another terminal, then run zero-cache locally with ZERO_QUERY_URL and ZERO_MUTATE_URL configured. If your app uses a different origin, update localhost:3000. ZERO_QUERY_URL=\"http://localhost:3000/api/query\" \\ ZERO_MUTATE_URL=\"http://localhost:3000/api/mutate\" \\ npx zero-cache-devZERO_QUERY_URL=\"http://localhost:3000/api/query\" \\ ZERO_MUTATE_URL=\"http://localhost:3000/api/mutate\" \\ pnpm exec zero-cache-devZERO_QUERY_URL=\"http://localhost:3000/api/query\" \\ ZERO_MUTATE_URL=\"http://localhost:3000/api/mutate\" \\ bunx zero-cache-devZERO_QUERY_URL=\"http://localhost:3000/api/query\" \\ ZERO_MUTATE_URL=\"http://localhost:3000/api/mutate\" \\ yarn exec zero-cache-dev Invoke Mutators You can call a mutator with zero.mutate: import {useZero} from '@rocicorp/zero/react' import {mutators} from './zero/mutators' const zero = useZero() const onClick = () => { zero.mutate(mutators.activateUser({id: '1'})) }import {useZero} from '@rocicorp/zero/solid' import {mutators} from './zero/mutators' const zero = useZero() const onClick = () => { zero().mutate(mutators.activateUser({id: '1'})) }import {zero} from './zero' import {mutators} from './zero/mutators' await zero.mutate(mutators.activateUser({id: '1'})) When you run the mutator, Zero writes to the local database, updates queries optimistically, and then syncs in the background to your mutate endpoint. Your mutate endpoint writes to Postgres and zero-cache will instantly replicate those changes to other clients. More about Mutators CRUD, server-specific code, permissions, and more Server-driven auth and Context Learn how to deploy your app to production", + "content": "This guide shows how to add Zero to an existing TypeScript-based web app. For a concrete end-to-end walkthrough, build the music app in the tutorial. Integrate Zero Set Up Your Database You'll need a Postgres database with logical replication enabled for development. # IMPORTANT: logical WAL level is required for Zero # to sync data to its SQLite replica docker run -d --name zero-postgres \\ -e POSTGRES_DB=\"zero\" \\ -e POSTGRES_PASSWORD=\"pass\" \\ -p 5432:5432 \\ postgres:18 \\ postgres -c wal_level=logical# Start Postgres.app first. Requires Postgres 15 or higher. # If these already exist, you can skip those commands. createuser -s postgres createdb -O postgres zero psql -d postgres -c \"ALTER USER postgres WITH PASSWORD 'pass';\" psql -d postgres -c \"ALTER SYSTEM SET wal_level = 'logical';\" # Restart Postgres.app, then verify: psql -d postgres -c \"SHOW wal_level;\" See Provider Support and make sure wal_level is logical. Create a .env file so your app server and zero-cache-dev use the same Postgres connection: # Update to your app's database connection URL ZERO_UPSTREAM_DB=\"postgres://postgres:pass@localhost:5432/zero\" Install Zero Add Zero and the validator used in these examples: npm install @rocicorp/zero zodpnpm add @rocicorp/zero zod # Note: pnpm disables postinstall scripts by default for security. # Create or update pnpm-workspace.yaml to allow the native package build: # https://pnpm.io/settings#allowbuilds # allowBuilds: # '@rocicorp/zero-sqlite3': true pnpm rebuild @rocicorp/zero-sqlite3bun add @rocicorp/zero zod # Note: Bun disables postinstall scripts by default for security. # Either approve the build: bun pm trust @rocicorp/zero-sqlite3 # Or add to package.json, then rebuild the native packages: # \"trustedDependencies\": [\"@rocicorp/zero-sqlite3\"]yarn add @rocicorp/zero zod # Note: Modern Yarn doesn't run postinstall scripts by default. # Add to package.json, then rebuild the native packages: # \"dependenciesMeta\": { # \"@rocicorp/zero-sqlite3\": { # \"built\": true # } # } yarn rebuild @rocicorp/zero-sqlite3 Install every framework, database, or mobile package your app imports as a direct dependency of that workspace package. These examples use Zod; any Standard Schema-compatible validator works. Set Up Your Zero Schema Zero uses a file called schema.ts to provide a type-safe query API. If you use Drizzle or Prisma, you can generate the schema automatically. Otherwise, you can create it manually. npm install -D drizzle-zero npx drizzle-zero generate --output src/zero/schema.tspnpm add -D drizzle-zero pnpm exec drizzle-zero generate --output src/zero/schema.tsbun add -D drizzle-zero bunx drizzle-zero generate --output src/zero/schema.tsyarn add -D drizzle-zero yarn exec drizzle-zero generate --output src/zero/schema.tsnpm install -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } npx prisma generatepnpm add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } pnpx prisma generatebun add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } bunx prisma generateyarn add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } yarn prisma generate// src/zero/schema.ts import { boolean, createBuilder, createSchema, string, table } from '@rocicorp/zero' const user = table('user') .columns({ id: string(), name: string(), active: boolean() }) .primaryKey('id') export const schema = createSchema({ tables: [user] }) export const zql = createBuilder(schema) declare module '@rocicorp/zero' { interface DefaultTypes { schema: typeof schema } } Set Up the Zero Client Zero has first-class support for React and SolidJS, and there is also a low-level API you can use in any TypeScript-based project. Choose the tab that most closely matches where your app creates its root layout or client instance. // src/routes/__root.tsx import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' import type {ReactNode} from 'react' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export const Route = createRootRoute({ shellComponent: RootDocument }) function RootDocument({children}: {children: ReactNode}) { return ( {children} ) }// src/app/providers.tsx 'use client' import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import type {ReactNode} from 'react' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export function Providers({ children }: { children: ReactNode }) { return {children} } // src/app/layout.tsx import type {ReactNode} from 'react' import {Providers} from './providers' export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} ) }// src/app.tsx import {MetaProvider, Title} from '@solidjs/meta' import {Router} from '@solidjs/router' import {FileRoutes} from '@solidjs/start/router' import {ZeroProvider} from '@rocicorp/zero/solid' import type {ZeroOptions} from '@rocicorp/zero' import {Suspense} from 'solid-js' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export default function App() { return ( ( Zero App {props.children} )} > ) }// src/zero.ts import {Zero} from '@rocicorp/zero' import type {ZeroOptions} from '@rocicorp/zero' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } const zero = new Zero(opts) export {zero} Sync Data Define Query Shared reads are conventionally stored in queries.ts. Use zql from schema.ts to construct and return a ZQL query: // src/zero/queries.ts import {defineQueries, defineQuery} from '@rocicorp/zero' import {zql} from './schema' export const queries = defineQueries({ allUsers: defineQuery(() => zql.user) }) See Reading Data for more on filters, sorting, relationships, and permissions. Add Query Endpoint Zero doesn't allow clients to send arbitrary ZQL to zero-cache. Instead, Zero sends the query name and arguments to the query endpoint on your server, which responds to zero-cache with the authoritative ZQL. This prevents clients from reading arbitrary data and is the basis of permissions. // src/routes/api/query.ts import {createFileRoute} from '@tanstack/react-router' import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from '../../zero/queries' import {schema} from '../../zero/schema' export const Route = createFileRoute('/api/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) } } } })// src/app/api/query/route.ts import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from '../../../zero/queries' import {schema} from '../../../zero/schema' 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/query.ts import type {APIEvent} from '@solidjs/start/server' import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from '../../zero/queries' import {schema} from '../../zero/schema' 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) }// src/api/app.ts import {Hono} from 'hono' import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from '../zero/queries' import {schema} from '../zero/schema' const app = new Hono() app.post('/api/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) }) Invoke Query Querying for data is framework-specific. Most of the time, you will use a helper like useQuery that integrates into your framework's rendering model: import {useQuery} from '@rocicorp/zero/react' import {queries} from './zero/queries' const [users] = useQuery(queries.allUsers())import {useQuery} from '@rocicorp/zero/solid' import {queries} from './zero/queries' const [users] = useQuery(() => queries.allUsers())import {zero} from './zero' import {queries} from './zero/queries' const users = await zero.run(queries.allUsers()) More about Queries Filters, sorting, relationships, preloading, and more Server-driven authentication Mutate Data Define Mutators Data is written in Zero apps using mutators. Similar to queries, shared writes usually live in mutators.ts: // src/zero/mutators.ts import {defineMutators, defineMutator} from '@rocicorp/zero' import {z} from 'zod' export const mutators = defineMutators({ activateUser: defineMutator( z.object({id: z.string()}), async ({args: {id}, tx}) => { await tx.mutate.user.update({id, active: true}) } ) }) You can use the CRUD-style API with tx.mutate.
.() to write data. You can also use tx.run(zql.
.) to run queries within your mutator. Register the mutators where you create the Zero client: // src/routes/__root.tsx import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from '../zero/mutators' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema, mutators }// src/app/providers.tsx import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from '../zero/mutators' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema, mutators }// src/app.tsx import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from './zero/mutators' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema, mutators }// src/zero.ts import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from './zero/mutators' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema, mutators } Add Mutate Endpoint Zero requires a mutate endpoint that runs on your server and connects directly to Postgres. First, create a dbProvider with the Postgres adapter that matches your stack. These examples assume the selected database client is already installed in your app. // src/zero/db-provider.ts import {zeroDrizzle} from '@rocicorp/zero/server/adapters/drizzle' import {drizzle} from 'drizzle-orm/node-postgres' import {Pool} from 'pg' import {schema} from './schema' import * as drizzleSchema from '../drizzle/schema' // If your app uses a different Drizzle driver, reuse your existing client. const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const pool = new Pool({ connectionString }) export const drizzleClient = drizzle(pool, { schema: drizzleSchema }) export const dbProvider = zeroDrizzle(schema, drizzleClient) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// src/zero/db-provider.ts import {Kysely, PostgresDialect} from 'kysely' import {zeroKysely} from '@rocicorp/zero/server/adapters/kysely' import {Pool} from 'pg' import {schema} from './schema' import type {Database} from '../kysely/database' const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const kysely = new Kysely({ dialect: new PostgresDialect({ pool: new Pool({ connectionString }) }) }) export const dbProvider = zeroKysely(schema, kysely) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// src/zero/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 './schema' const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) }) export const dbProvider = zeroPrisma(schema, prisma) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// src/zero/db-provider.ts import {zeroNodePg} from '@rocicorp/zero/server/adapters/pg' import {Pool} from 'pg' import {schema} from './schema' const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const pool = new Pool({ connectionString }) export const dbProvider = zeroNodePg(schema, pool) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// src/zero/db-provider.ts import {zeroPostgresJS} from '@rocicorp/zero/server/adapters/postgresjs' import postgres from 'postgres' import {schema} from './schema' const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const sql = postgres(connectionString) export const dbProvider = zeroPostgresJS(schema, sql) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } } Then use the dbProvider and helpers to define the mutate endpoint: // src/routes/api/mutate.ts import {createFileRoute} from '@tanstack/react-router' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from '../../zero/mutators' import {dbProvider} from '../../zero/db-provider' export const Route = createFileRoute('/api/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) } } } })// src/app/api/mutate/route.ts import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from '../../../zero/mutators' import {dbProvider} from '../../../zero/db-provider' 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/mutate.ts import type {APIEvent} from '@solidjs/start/server' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from '../../zero/mutators' import {dbProvider} from '../../zero/db-provider' 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) }// src/api/app.ts import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from '../zero/mutators' import {dbProvider} from '../zero/db-provider' app.post('/api/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) }) Mutators on the server allow for write permissions and can be different from the client implementation. You can also do work after a mutation runs on the server, like send notifications. These examples have only public queries and mutators, so they do not pass a context. In authenticated apps, you should validate auth in the request, derive context from the session, and pass the context to the mutate and query handlers. See Authentication. Start your app server in another terminal, then run zero-cache locally with ZERO_QUERY_URL and ZERO_MUTATE_URL configured. If your app uses a different origin, update localhost:3000. ZERO_QUERY_URL=\"http://localhost:3000/api/query\" \\ ZERO_MUTATE_URL=\"http://localhost:3000/api/mutate\" \\ npx zero-cache-devZERO_QUERY_URL=\"http://localhost:3000/api/query\" \\ ZERO_MUTATE_URL=\"http://localhost:3000/api/mutate\" \\ pnpm exec zero-cache-devZERO_QUERY_URL=\"http://localhost:3000/api/query\" \\ ZERO_MUTATE_URL=\"http://localhost:3000/api/mutate\" \\ bunx zero-cache-devZERO_QUERY_URL=\"http://localhost:3000/api/query\" \\ ZERO_MUTATE_URL=\"http://localhost:3000/api/mutate\" \\ yarn exec zero-cache-dev Invoke Mutators You can call a mutator with zero.mutate: import {useZero} from '@rocicorp/zero/react' import {mutators} from './zero/mutators' const zero = useZero() const onClick = () => { zero.mutate(mutators.activateUser({id: '1'})) }import {useZero} from '@rocicorp/zero/solid' import {mutators} from './zero/mutators' const zero = useZero() const onClick = () => { zero().mutate(mutators.activateUser({id: '1'})) }import {zero} from './zero' import {mutators} from './zero/mutators' await zero.mutate(mutators.activateUser({id: '1'})) When you run the mutator, Zero writes to the local database, updates queries optimistically, and then syncs in the background to your mutate endpoint. Your mutate endpoint writes to Postgres and zero-cache will instantly replicate those changes to other clients. More about Mutators CRUD, server-specific code, permissions, and more Server-driven auth and Context Learn how to deploy your app to production", "headings": [ { "text": "Integrate Zero", @@ -1701,17 +1715,17 @@ "kind": "page" }, { - "id": "183-install#integrate-zero", + "id": "184-install#integrate-zero", "title": "Install Zero", "searchTitle": "Integrate Zero", "sectionTitle": "Integrate Zero", "sectionId": "integrate-zero", "url": "/docs/install", - "content": "Set Up Your Database You'll need a Postgres database with logical replication enabled for development. # IMPORTANT: logical WAL level is required for Zero # to sync data to its SQLite replica docker run -d --name zero-postgres \\ -e POSTGRES_DB=\"zero\" \\ -e POSTGRES_PASSWORD=\"pass\" \\ -p 5432:5432 \\ postgres:18 \\ postgres -c wal_level=logical# Start Postgres.app first. Requires Postgres 15 or higher. # If these already exist, you can skip those commands. createuser -s postgres createdb -O postgres zero psql -d postgres -c \"ALTER USER postgres WITH PASSWORD 'pass';\" psql -d postgres -c \"ALTER SYSTEM SET wal_level = 'logical';\" # Restart Postgres.app, then verify: psql -d postgres -c \"SHOW wal_level;\" See Provider Support and make sure wal_level is logical. Create a .env file so your app server and zero-cache-dev use the same Postgres connection: # Update to your app's database connection URL ZERO_UPSTREAM_DB=\"postgres://postgres:pass@localhost:5432/zero\" Install Zero Add Zero and the validator used in these examples: npm install @rocicorp/zero zodpnpm add @rocicorp/zero zod # Note: pnpm disables postinstall scripts by default for security. # Create or update pnpm-workspace.yaml to allow the native package build: # https://pnpm.io/settings#allowbuilds # allowBuilds: # '@rocicorp/zero-sqlite3': true pnpm rebuild @rocicorp/zero-sqlite3bun add @rocicorp/zero zod # Note: Bun disables postinstall scripts by default for security. # Either approve the build: bun pm trust @rocicorp/zero-sqlite3 # Or add to package.json, then rebuild the native packages: # \"trustedDependencies\": [\"@rocicorp/zero-sqlite3\"]yarn add @rocicorp/zero zod # Note: Modern Yarn doesn't run postinstall scripts by default. # Add to package.json, then rebuild the native packages: # \"dependenciesMeta\": { # \"@rocicorp/zero-sqlite3\": { # \"built\": true # } # } yarn rebuild @rocicorp/zero-sqlite3 These examples use Zod; any Standard Schema-compatible validator works. Set Up Your Zero Schema Zero uses a file called schema.ts to provide a type-safe query API. If you use Drizzle or Prisma, you can generate the schema automatically. Otherwise, you can create it manually. npm install -D drizzle-zero npx drizzle-zero generate --output src/zero/schema.tspnpm add -D drizzle-zero pnpm exec drizzle-zero generate --output src/zero/schema.tsbun add -D drizzle-zero bunx drizzle-zero generate --output src/zero/schema.tsyarn add -D drizzle-zero yarn exec drizzle-zero generate --output src/zero/schema.tsnpm install -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } npx prisma generatepnpm add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } pnpx prisma generatebun add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } bunx prisma generateyarn add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } yarn prisma generate// src/zero/schema.ts import { boolean, createBuilder, createSchema, string, table } from '@rocicorp/zero' const user = table('user') .columns({ id: string(), name: string(), active: boolean() }) .primaryKey('id') export const schema = createSchema({ tables: [user] }) export const zql = createBuilder(schema) declare module '@rocicorp/zero' { interface DefaultTypes { schema: typeof schema } } Set Up the Zero Client Zero has first-class support for React and SolidJS, and there is also a low-level API you can use in any TypeScript-based project. Choose the tab that most closely matches where your app creates its root layout or client instance. // src/routes/__root.tsx import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' import type {ReactNode} from 'react' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export const Route = createRootRoute({ shellComponent: RootDocument }) function RootDocument({children}: {children: ReactNode}) { return ( {children} ) }// src/app/providers.tsx 'use client' import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import type {ReactNode} from 'react' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export function Providers({ children }: { children: ReactNode }) { return {children} } // src/app/layout.tsx import type {ReactNode} from 'react' import {Providers} from './providers' export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} ) }// src/app.tsx import {MetaProvider, Title} from '@solidjs/meta' import {Router} from '@solidjs/router' import {FileRoutes} from '@solidjs/start/router' import {ZeroProvider} from '@rocicorp/zero/solid' import type {ZeroOptions} from '@rocicorp/zero' import {Suspense} from 'solid-js' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export default function App() { return ( ( Zero App {props.children} )} > ) }// src/zero.ts import {Zero} from '@rocicorp/zero' import type {ZeroOptions} from '@rocicorp/zero' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } const zero = new Zero(opts) export {zero}", + "content": "Set Up Your Database You'll need a Postgres database with logical replication enabled for development. # IMPORTANT: logical WAL level is required for Zero # to sync data to its SQLite replica docker run -d --name zero-postgres \\ -e POSTGRES_DB=\"zero\" \\ -e POSTGRES_PASSWORD=\"pass\" \\ -p 5432:5432 \\ postgres:18 \\ postgres -c wal_level=logical# Start Postgres.app first. Requires Postgres 15 or higher. # If these already exist, you can skip those commands. createuser -s postgres createdb -O postgres zero psql -d postgres -c \"ALTER USER postgres WITH PASSWORD 'pass';\" psql -d postgres -c \"ALTER SYSTEM SET wal_level = 'logical';\" # Restart Postgres.app, then verify: psql -d postgres -c \"SHOW wal_level;\" See Provider Support and make sure wal_level is logical. Create a .env file so your app server and zero-cache-dev use the same Postgres connection: # Update to your app's database connection URL ZERO_UPSTREAM_DB=\"postgres://postgres:pass@localhost:5432/zero\" Install Zero Add Zero and the validator used in these examples: npm install @rocicorp/zero zodpnpm add @rocicorp/zero zod # Note: pnpm disables postinstall scripts by default for security. # Create or update pnpm-workspace.yaml to allow the native package build: # https://pnpm.io/settings#allowbuilds # allowBuilds: # '@rocicorp/zero-sqlite3': true pnpm rebuild @rocicorp/zero-sqlite3bun add @rocicorp/zero zod # Note: Bun disables postinstall scripts by default for security. # Either approve the build: bun pm trust @rocicorp/zero-sqlite3 # Or add to package.json, then rebuild the native packages: # \"trustedDependencies\": [\"@rocicorp/zero-sqlite3\"]yarn add @rocicorp/zero zod # Note: Modern Yarn doesn't run postinstall scripts by default. # Add to package.json, then rebuild the native packages: # \"dependenciesMeta\": { # \"@rocicorp/zero-sqlite3\": { # \"built\": true # } # } yarn rebuild @rocicorp/zero-sqlite3 Install every framework, database, or mobile package your app imports as a direct dependency of that workspace package. These examples use Zod; any Standard Schema-compatible validator works. Set Up Your Zero Schema Zero uses a file called schema.ts to provide a type-safe query API. If you use Drizzle or Prisma, you can generate the schema automatically. Otherwise, you can create it manually. npm install -D drizzle-zero npx drizzle-zero generate --output src/zero/schema.tspnpm add -D drizzle-zero pnpm exec drizzle-zero generate --output src/zero/schema.tsbun add -D drizzle-zero bunx drizzle-zero generate --output src/zero/schema.tsyarn add -D drizzle-zero yarn exec drizzle-zero generate --output src/zero/schema.tsnpm install -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } npx prisma generatepnpm add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } pnpx prisma generatebun add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } bunx prisma generateyarn add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } yarn prisma generate// src/zero/schema.ts import { boolean, createBuilder, createSchema, string, table } from '@rocicorp/zero' const user = table('user') .columns({ id: string(), name: string(), active: boolean() }) .primaryKey('id') export const schema = createSchema({ tables: [user] }) export const zql = createBuilder(schema) declare module '@rocicorp/zero' { interface DefaultTypes { schema: typeof schema } } Set Up the Zero Client Zero has first-class support for React and SolidJS, and there is also a low-level API you can use in any TypeScript-based project. Choose the tab that most closely matches where your app creates its root layout or client instance. // src/routes/__root.tsx import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' import type {ReactNode} from 'react' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export const Route = createRootRoute({ shellComponent: RootDocument }) function RootDocument({children}: {children: ReactNode}) { return ( {children} ) }// src/app/providers.tsx 'use client' import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import type {ReactNode} from 'react' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export function Providers({ children }: { children: ReactNode }) { return {children} } // src/app/layout.tsx import type {ReactNode} from 'react' import {Providers} from './providers' export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} ) }// src/app.tsx import {MetaProvider, Title} from '@solidjs/meta' import {Router} from '@solidjs/router' import {FileRoutes} from '@solidjs/start/router' import {ZeroProvider} from '@rocicorp/zero/solid' import type {ZeroOptions} from '@rocicorp/zero' import {Suspense} from 'solid-js' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export default function App() { return ( ( Zero App {props.children} )} > ) }// src/zero.ts import {Zero} from '@rocicorp/zero' import type {ZeroOptions} from '@rocicorp/zero' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } const zero = new Zero(opts) export {zero}", "kind": "section" }, { - "id": "184-install#set-up-your-database", + "id": "185-install#set-up-your-database", "title": "Install Zero", "searchTitle": "Set Up Your Database", "sectionTitle": "Set Up Your Database", @@ -1721,17 +1735,17 @@ "kind": "section" }, { - "id": "185-install#install-zero", + "id": "186-install#install-zero", "title": "Install Zero", "searchTitle": "Install Zero", "sectionTitle": "Install Zero", "sectionId": "install-zero", "url": "/docs/install", - "content": "Add Zero and the validator used in these examples: npm install @rocicorp/zero zodpnpm add @rocicorp/zero zod # Note: pnpm disables postinstall scripts by default for security. # Create or update pnpm-workspace.yaml to allow the native package build: # https://pnpm.io/settings#allowbuilds # allowBuilds: # '@rocicorp/zero-sqlite3': true pnpm rebuild @rocicorp/zero-sqlite3bun add @rocicorp/zero zod # Note: Bun disables postinstall scripts by default for security. # Either approve the build: bun pm trust @rocicorp/zero-sqlite3 # Or add to package.json, then rebuild the native packages: # \"trustedDependencies\": [\"@rocicorp/zero-sqlite3\"]yarn add @rocicorp/zero zod # Note: Modern Yarn doesn't run postinstall scripts by default. # Add to package.json, then rebuild the native packages: # \"dependenciesMeta\": { # \"@rocicorp/zero-sqlite3\": { # \"built\": true # } # } yarn rebuild @rocicorp/zero-sqlite3 These examples use Zod; any Standard Schema-compatible validator works.", + "content": "Add Zero and the validator used in these examples: npm install @rocicorp/zero zodpnpm add @rocicorp/zero zod # Note: pnpm disables postinstall scripts by default for security. # Create or update pnpm-workspace.yaml to allow the native package build: # https://pnpm.io/settings#allowbuilds # allowBuilds: # '@rocicorp/zero-sqlite3': true pnpm rebuild @rocicorp/zero-sqlite3bun add @rocicorp/zero zod # Note: Bun disables postinstall scripts by default for security. # Either approve the build: bun pm trust @rocicorp/zero-sqlite3 # Or add to package.json, then rebuild the native packages: # \"trustedDependencies\": [\"@rocicorp/zero-sqlite3\"]yarn add @rocicorp/zero zod # Note: Modern Yarn doesn't run postinstall scripts by default. # Add to package.json, then rebuild the native packages: # \"dependenciesMeta\": { # \"@rocicorp/zero-sqlite3\": { # \"built\": true # } # } yarn rebuild @rocicorp/zero-sqlite3 Install every framework, database, or mobile package your app imports as a direct dependency of that workspace package. These examples use Zod; any Standard Schema-compatible validator works.", "kind": "section" }, { - "id": "186-install#set-up-your-zero-schema", + "id": "187-install#set-up-your-zero-schema", "title": "Install Zero", "searchTitle": "Set Up Your Zero Schema", "sectionTitle": "Set Up Your Zero Schema", @@ -1741,7 +1755,7 @@ "kind": "section" }, { - "id": "187-install#set-up-the-zero-client", + "id": "188-install#set-up-the-zero-client", "title": "Install Zero", "searchTitle": "Set Up the Zero Client", "sectionTitle": "Set Up the Zero Client", @@ -1751,7 +1765,7 @@ "kind": "section" }, { - "id": "188-install#sync-data", + "id": "189-install#sync-data", "title": "Install Zero", "searchTitle": "Sync Data", "sectionTitle": "Sync Data", @@ -1761,7 +1775,7 @@ "kind": "section" }, { - "id": "189-install#define-query", + "id": "190-install#define-query", "title": "Install Zero", "searchTitle": "Define Query", "sectionTitle": "Define Query", @@ -1771,7 +1785,7 @@ "kind": "section" }, { - "id": "190-install#add-query-endpoint", + "id": "191-install#add-query-endpoint", "title": "Install Zero", "searchTitle": "Add Query Endpoint", "sectionTitle": "Add Query Endpoint", @@ -1781,7 +1795,7 @@ "kind": "section" }, { - "id": "191-install#invoke-query", + "id": "192-install#invoke-query", "title": "Install Zero", "searchTitle": "Invoke Query", "sectionTitle": "Invoke Query", @@ -1791,7 +1805,7 @@ "kind": "section" }, { - "id": "192-install#more-about-queries", + "id": "193-install#more-about-queries", "title": "Install Zero", "searchTitle": "More about Queries", "sectionTitle": "More about Queries", @@ -1801,7 +1815,7 @@ "kind": "section" }, { - "id": "193-install#mutate-data", + "id": "194-install#mutate-data", "title": "Install Zero", "searchTitle": "Mutate Data", "sectionTitle": "Mutate Data", @@ -1811,7 +1825,7 @@ "kind": "section" }, { - "id": "194-install#define-mutators", + "id": "195-install#define-mutators", "title": "Install Zero", "searchTitle": "Define Mutators", "sectionTitle": "Define Mutators", @@ -1821,7 +1835,7 @@ "kind": "section" }, { - "id": "195-install#add-mutate-endpoint", + "id": "196-install#add-mutate-endpoint", "title": "Install Zero", "searchTitle": "Add Mutate Endpoint", "sectionTitle": "Add Mutate Endpoint", @@ -1831,7 +1845,7 @@ "kind": "section" }, { - "id": "196-install#invoke-mutators", + "id": "197-install#invoke-mutators", "title": "Install Zero", "searchTitle": "Invoke Mutators", "sectionTitle": "Invoke Mutators", @@ -1841,7 +1855,7 @@ "kind": "section" }, { - "id": "197-install#more-about-mutators", + "id": "198-install#more-about-mutators", "title": "Install Zero", "searchTitle": "More about Mutators", "sectionTitle": "More about Mutators", @@ -1864,7 +1878,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' }) 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') }) If Zero receives any response from the mutate endpoint other than HTTP 200, 401, or 403, it will disconnect and enter the error state. 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 succeeds without changing the row, so success does not prove creation. Other unique conflicts still fail; use upsert to update an existing row. The server role needs SELECT access to the primary-key columns. 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') }) Mutate endpoint fetch failures and 5xx responses get up to four total attempts. Exhausted retries and responses other than 200, 401, or 403 enter the error state. 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.", "headings": [ { "text": "Architecture", @@ -1986,7 +2000,7 @@ "kind": "page" }, { - "id": "198-mutators#architecture", + "id": "199-mutators#architecture", "title": "Mutators", "searchTitle": "Architecture", "sectionTitle": "Architecture", @@ -1996,7 +2010,7 @@ "kind": "section" }, { - "id": "199-mutators#life-of-a-mutation", + "id": "200-mutators#life-of-a-mutation", "title": "Mutators", "searchTitle": "Life of a Mutation", "sectionTitle": "Life of a Mutation", @@ -2006,17 +2020,17 @@ "kind": "section" }, { - "id": "200-mutators#defining-mutators", + "id": "201-mutators#defining-mutators", "title": "Mutators", "searchTitle": "Defining Mutators", "sectionTitle": "Defining Mutators", "sectionId": "defining-mutators", "url": "/docs/mutators", - "content": "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' }) 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.", + "content": "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 succeeds without changing the row, so success does not prove creation. Other unique conflicts still fail; use upsert to update an existing row. The server role needs SELECT access to the primary-key columns. 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.", "kind": "section" }, { - "id": "201-mutators#basics", + "id": "202-mutators#basics", "title": "Mutators", "searchTitle": "Basics", "sectionTitle": "Basics", @@ -2026,27 +2040,27 @@ "kind": "section" }, { - "id": "202-mutators#writing-data", + "id": "203-mutators#writing-data", "title": "Mutators", "searchTitle": "Writing Data", "sectionTitle": "Writing Data", "sectionId": "writing-data", "url": "/docs/mutators", - "content": "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' }) 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 })", + "content": "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 succeeds without changing the row, so success does not prove creation. Other unique conflicts still fail; use upsert to update an existing row. The server role needs SELECT access to the primary-key columns. 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 })", "kind": "section" }, { - "id": "203-mutators#insert", + "id": "204-mutators#insert", "title": "Mutators", "searchTitle": "Insert", "sectionTitle": "Insert", "sectionId": "insert", "url": "/docs/mutators", - "content": "Create new records with insert: tx.mutate.user.insert({ id: 'user-123', username: 'sam', language: 'js' }) 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 })", + "content": "Create new records with insert: tx.mutate.user.insert({ id: 'user-123', username: 'sam', language: 'js' }) If the Zero primary key already exists, insert succeeds without changing the row, so success does not prove creation. Other unique conflicts still fail; use upsert to update an existing row. The server role needs SELECT access to the primary-key columns. 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 })", "kind": "section" }, { - "id": "204-mutators#upsert", + "id": "205-mutators#upsert", "title": "Mutators", "searchTitle": "Upsert", "sectionTitle": "Upsert", @@ -2056,7 +2070,7 @@ "kind": "section" }, { - "id": "205-mutators#update", + "id": "206-mutators#update", "title": "Mutators", "searchTitle": "Update", "sectionTitle": "Update", @@ -2066,7 +2080,7 @@ "kind": "section" }, { - "id": "206-mutators#delete", + "id": "207-mutators#delete", "title": "Mutators", "searchTitle": "Delete", "sectionTitle": "Delete", @@ -2076,7 +2090,7 @@ "kind": "section" }, { - "id": "207-mutators#arguments", + "id": "208-mutators#arguments", "title": "Mutators", "searchTitle": "Arguments", "sectionTitle": "Arguments", @@ -2086,7 +2100,7 @@ "kind": "section" }, { - "id": "208-mutators#reading-data", + "id": "209-mutators#reading-data", "title": "Mutators", "searchTitle": "Reading Data", "sectionTitle": "Reading Data", @@ -2096,7 +2110,7 @@ "kind": "section" }, { - "id": "209-mutators#context", + "id": "210-mutators#context", "title": "Mutators", "searchTitle": "Context", "sectionTitle": "Context", @@ -2106,7 +2120,7 @@ "kind": "section" }, { - "id": "210-mutators#mutator-registries", + "id": "211-mutators#mutator-registries", "title": "Mutators", "searchTitle": "Mutator Registries", "sectionTitle": "Mutator Registries", @@ -2116,7 +2130,7 @@ "kind": "section" }, { - "id": "211-mutators#mutator-names", + "id": "212-mutators#mutator-names", "title": "Mutators", "searchTitle": "Mutator Names", "sectionTitle": "Mutator Names", @@ -2126,7 +2140,7 @@ "kind": "section" }, { - "id": "212-mutators#mutatorsts", + "id": "213-mutators#mutatorsts", "title": "Mutators", "searchTitle": "mutators.ts", "sectionTitle": "mutators.ts", @@ -2136,7 +2150,7 @@ "kind": "section" }, { - "id": "213-mutators#registration", + "id": "214-mutators#registration", "title": "Mutators", "searchTitle": "Registration", "sectionTitle": "Registration", @@ -2146,17 +2160,17 @@ "kind": "section" }, { - "id": "214-mutators#server-setup", + "id": "215-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') }) If Zero receives any response from the mutate endpoint other than HTTP 200, 401, or 403, it will disconnect and enter the error state. 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') }) Mutate endpoint fetch failures and 5xx responses get up to four total attempts. Exhausted retries and responses other than 200, 401, or 403 enter the error state. 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 } })", "kind": "section" }, { - "id": "215-mutators#registering-the-endpoint", + "id": "216-mutators#registering-the-endpoint", "title": "Mutators", "searchTitle": "Registering the Endpoint", "sectionTitle": "Registering the Endpoint", @@ -2166,7 +2180,7 @@ "kind": "section" }, { - "id": "216-mutators#implementing-the-endpoint", + "id": "217-mutators#implementing-the-endpoint", "title": "Mutators", "searchTitle": "Implementing the Endpoint", "sectionTitle": "Implementing the Endpoint", @@ -2176,17 +2190,17 @@ "kind": "section" }, { - "id": "217-mutators#handling-errors", + "id": "218-mutators#handling-errors", "title": "Mutators", "searchTitle": "Handling Errors", "sectionTitle": "Handling Errors", "sectionId": "handling-errors", "url": "/docs/mutators", - "content": "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') }) If Zero receives any response from the mutate endpoint other than HTTP 200, 401, or 403, it will disconnect and enter the error state. 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.", + "content": "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') }) Mutate endpoint fetch failures and 5xx responses get up to four total attempts. Exhausted retries and responses other than 200, 401, or 403 enter the error state. 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.", "kind": "section" }, { - "id": "218-mutators#custom-mutate-url", + "id": "219-mutators#custom-mutate-url", "title": "Mutators", "searchTitle": "Custom Mutate URL", "sectionTitle": "Custom Mutate URL", @@ -2196,7 +2210,7 @@ "kind": "section" }, { - "id": "219-mutators#url-patterns", + "id": "220-mutators#url-patterns", "title": "Mutators", "searchTitle": "URL Patterns", "sectionTitle": "URL Patterns", @@ -2206,7 +2220,7 @@ "kind": "section" }, { - "id": "220-mutators#server-specific-code", + "id": "221-mutators#server-specific-code", "title": "Mutators", "searchTitle": "Server-Specific Code", "sectionTitle": "Server-Specific Code", @@ -2216,7 +2230,7 @@ "kind": "section" }, { - "id": "221-mutators#running-mutators", + "id": "222-mutators#running-mutators", "title": "Mutators", "searchTitle": "Running Mutators", "sectionTitle": "Running Mutators", @@ -2226,7 +2240,7 @@ "kind": "section" }, { - "id": "222-mutators#waiting-for-results", + "id": "223-mutators#waiting-for-results", "title": "Mutators", "searchTitle": "Waiting for Results", "sectionTitle": "Waiting for Results", @@ -2236,7 +2250,7 @@ "kind": "section" }, { - "id": "223-mutators#permissions", + "id": "224-mutators#permissions", "title": "Mutators", "searchTitle": "Permissions", "sectionTitle": "Permissions", @@ -2246,7 +2260,7 @@ "kind": "section" }, { - "id": "224-mutators#dropping-down-to-raw-sql", + "id": "225-mutators#dropping-down-to-raw-sql", "title": "Mutators", "searchTitle": "Dropping Down to Raw SQL", "sectionTitle": "Dropping Down to Raw SQL", @@ -2256,7 +2270,7 @@ "kind": "section" }, { - "id": "225-mutators#notifications-and-async-work", + "id": "226-mutators#notifications-and-async-work", "title": "Mutators", "searchTitle": "Notifications and Async Work", "sectionTitle": "Notifications and Async Work", @@ -2266,7 +2280,7 @@ "kind": "section" }, { - "id": "226-mutators#custom-mutate-implementation", + "id": "227-mutators#custom-mutate-implementation", "title": "Mutators", "searchTitle": "Custom Mutate Implementation", "sectionTitle": "Custom Mutate Implementation", @@ -2290,7 +2304,7 @@ "kind": "page" }, { - "id": "227-open-source#business-model", + "id": "228-open-source#business-model", "title": "Zero is Open Source Software", "searchTitle": "Business Model", "sectionTitle": "Business Model", @@ -2304,7 +2318,7 @@ "title": "OpenTelemetry", "searchTitle": "OpenTelemetry", "url": "/docs/otel", - "content": "The zero-cache service embeds the JavaScript OTLP Exporter and can send logs, traces, and metrics to any standard otel collector. To enable otel, set the following environment variables then run zero-cache as normal: OTEL_EXPORTER_OTLP_ENDPOINT=\"\" OTEL_EXPORTER_OTLP_HEADERS=\"\" OTEL_RESOURCE_ATTRIBUTES=\"\" OTEL_NODE_RESOURCE_DETECTORS=\"env,host,os\" Grafana Cloud Walkthrough Here are instructions to setup Grafana Cloud, but the setup for other otel collectors should be similar. Sign up for Grafana Cloud (Free Tier) Click Connections > Add Connection in the left sidebar add-connection Search for \"OpenTelemetry\" and select it Click \"Quickstart\" quickstart Select \"JavaScript\" javascript Create a new token Copy the environment variables into your .env file or similar copy-env Start zero-cache Look for logs under \"Drilldown\" > \"Logs\" in left sidebar Distributed Tracing You can enable end-to-end trace correlation from your frontend through zero-cache to your API server. This allows you to see the full request flow in your tracing UI. To enable this, provide a getTraceparent callback when creating your Zero client: import {ZeroProvider} from '@rocicorp/zero/react' import {propagation, context} from '@opentelemetry/api' function getTraceparent() { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } return ( )import {ZeroProvider} from '@rocicorp/zero/solid' import {propagation, context} from '@opentelemetry/api' function getTraceparent() { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } return ( )import {Zero} from '@rocicorp/zero' import {propagation, context} from '@opentelemetry/api' const zero = new Zero({ // ... other options getTraceparent: () => { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } }) This callback is called before sending WebSocket messages that trigger API server calls (push, changeDesiredQueries, initConnection). The returned W3C traceparent header is forwarded through zero-cache to your API server, where it can be used to continue the trace. Metrics Reference view_syncer_lag and view_syncer_hydration require OpenTelemetry exponential histogram support. Prometheus users must enable native histograms. Use the existing serving_lag gauges if your backend does not support them. zero.server zero.replica zero.replication total_lag and last_total_lag now report the same latest measured round trip and do not grow when reports stop arriving. Use lag_report_retries to detect a stalled or missing report stream. zero.sync Serving-lag metrics include only client groups with at least one connected client and a validated background connection context. Retained groups without an eligible connection do not contribute lag. zero.mutation", + "content": "The zero-cache service embeds the JavaScript OTLP Exporter and can send logs, traces, and metrics to any standard otel collector. To enable otel, set the following environment variables then run zero-cache as normal: OTEL_EXPORTER_OTLP_ENDPOINT=\"\" OTEL_EXPORTER_OTLP_HEADERS=\"\" OTEL_RESOURCE_ATTRIBUTES=\"\" OTEL_NODE_RESOURCE_DETECTORS=\"env,host,os\" Grafana Cloud Walkthrough Here are instructions to setup Grafana Cloud, but the setup for other otel collectors should be similar. Sign up for Grafana Cloud (Free Tier) Click Connections > Add Connection in the left sidebar add-connection Search for \"OpenTelemetry\" and select it Click \"Quickstart\" quickstart Select \"JavaScript\" javascript Create a new token Copy the environment variables into your .env file or similar copy-env Start zero-cache Look for logs under \"Drilldown\" > \"Logs\" in left sidebar Distributed Tracing You can enable end-to-end trace correlation from your frontend through zero-cache to your API server. This allows you to see the full request flow in your tracing UI. To enable this, provide a getTraceparent callback when creating your Zero client: import {ZeroProvider} from '@rocicorp/zero/react' import {propagation, context} from '@opentelemetry/api' function getTraceparent() { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } return ( )import {ZeroProvider} from '@rocicorp/zero/solid' import {propagation, context} from '@opentelemetry/api' function getTraceparent() { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } return ( )import {Zero} from '@rocicorp/zero' import {propagation, context} from '@opentelemetry/api' const zero = new Zero({ // ... other options getTraceparent: () => { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } }) This callback is called before sending WebSocket messages that trigger API server calls (push, changeDesiredQueries, initConnection). The returned W3C traceparent header is forwarded through zero-cache to your API server, where it can be used to continue the trace. Metrics Reference view_syncer_lag, view_syncer_hydration, and e2e_serving_lag require OpenTelemetry exponential histogram support. Prometheus users must enable native histograms. Use the existing serving_lag gauges if your backend does not support them. zero.server zero.replica Litestream metrics include role, backup_scheme, and litestream labels. The official image reports restores as v5 and backups as legacy. zero.replication total_lag and last_total_lag now report the same latest measured round trip and do not grow when reports stop arriving. Use lag_report_retries to detect a stalled or missing report stream. zero.sync serving_lag, serving_lag_stats, serving_lagging_client_groups, and view_syncer_lag include only eligible connected client groups. e2e_serving_lag records completed delivery. An upstream clock ahead biases it low, while a clock behind biases it high; negative values clamp to zero and increment e2e_serving_lag_clamps. zero.mutation", "headings": [ { "text": "Grafana Cloud Walkthrough", @@ -2342,7 +2356,7 @@ "kind": "page" }, { - "id": "228-otel#grafana-cloud-walkthrough", + "id": "229-otel#grafana-cloud-walkthrough", "title": "OpenTelemetry", "searchTitle": "Grafana Cloud Walkthrough", "sectionTitle": "Grafana Cloud Walkthrough", @@ -2352,7 +2366,7 @@ "kind": "section" }, { - "id": "229-otel#distributed-tracing", + "id": "230-otel#distributed-tracing", "title": "OpenTelemetry", "searchTitle": "Distributed Tracing", "sectionTitle": "Distributed Tracing", @@ -2362,17 +2376,17 @@ "kind": "section" }, { - "id": "230-otel#metrics-reference", + "id": "231-otel#metrics-reference", "title": "OpenTelemetry", "searchTitle": "Metrics Reference", "sectionTitle": "Metrics Reference", "sectionId": "metrics-reference", "url": "/docs/otel", - "content": "view_syncer_lag and view_syncer_hydration require OpenTelemetry exponential histogram support. Prometheus users must enable native histograms. Use the existing serving_lag gauges if your backend does not support them. zero.server zero.replica zero.replication total_lag and last_total_lag now report the same latest measured round trip and do not grow when reports stop arriving. Use lag_report_retries to detect a stalled or missing report stream. zero.sync Serving-lag metrics include only client groups with at least one connected client and a validated background connection context. Retained groups without an eligible connection do not contribute lag. zero.mutation", + "content": "view_syncer_lag, view_syncer_hydration, and e2e_serving_lag require OpenTelemetry exponential histogram support. Prometheus users must enable native histograms. Use the existing serving_lag gauges if your backend does not support them. zero.server zero.replica Litestream metrics include role, backup_scheme, and litestream labels. The official image reports restores as v5 and backups as legacy. zero.replication total_lag and last_total_lag now report the same latest measured round trip and do not grow when reports stop arriving. Use lag_report_retries to detect a stalled or missing report stream. zero.sync serving_lag, serving_lag_stats, serving_lagging_client_groups, and view_syncer_lag include only eligible connected client groups. e2e_serving_lag records completed delivery. An upstream clock ahead biases it low, while a clock behind biases it high; negative values clamp to zero and increment e2e_serving_lag_clamps. zero.mutation", "kind": "section" }, { - "id": "231-otel#zeroserver", + "id": "232-otel#zeroserver", "title": "OpenTelemetry", "searchTitle": "zero.server", "sectionTitle": "zero.server", @@ -2382,17 +2396,17 @@ "kind": "section" }, { - "id": "232-otel#zeroreplica", + "id": "233-otel#zeroreplica", "title": "OpenTelemetry", "searchTitle": "zero.replica", "sectionTitle": "zero.replica", "sectionId": "zeroreplica", "url": "/docs/otel", - "content": "", + "content": "Litestream metrics include role, backup_scheme, and litestream labels. The official image reports restores as v5 and backups as legacy.", "kind": "section" }, { - "id": "233-otel#zeroreplication", + "id": "234-otel#zeroreplication", "title": "OpenTelemetry", "searchTitle": "zero.replication", "sectionTitle": "zero.replication", @@ -2402,17 +2416,17 @@ "kind": "section" }, { - "id": "234-otel#zerosync", + "id": "235-otel#zerosync", "title": "OpenTelemetry", "searchTitle": "zero.sync", "sectionTitle": "zero.sync", "sectionId": "zerosync", "url": "/docs/otel", - "content": "Serving-lag metrics include only client groups with at least one connected client and a validated background connection context. Retained groups without an eligible connection do not contribute lag.", + "content": "serving_lag, serving_lag_stats, serving_lagging_client_groups, and view_syncer_lag include only eligible connected client groups. e2e_serving_lag records completed delivery. An upstream clock ahead biases it low, while a clock behind biases it high; negative values clamp to zero and increment e2e_serving_lag_clamps.", "kind": "section" }, { - "id": "235-otel#zeromutation", + "id": "236-otel#zeromutation", "title": "OpenTelemetry", "searchTitle": "zero.mutation", "sectionTitle": "zero.mutation", @@ -2426,7 +2440,7 @@ "title": "Supported Postgres Features", "searchTitle": "Supported Postgres Features", "url": "/docs/postgres-support", - "content": "Postgres has a massive feature set, and Zero supports a growing subset of it. Object Names Table and column names must begin with a letter or underscore This can be followed by letters, numbers, underscores, and hyphens Regex: /^[A-Za-z_]+[A-Za-z0-9_-]*$/ The column name _0_version is reserved for internal use Object Types Tables are synced. Views are not synced. generated as identity columns are synced. In Postgres 18+, generated stored columns are synced. In lower Postgres versions they aren't. Indexes aren't synced per-se, but we do implicitly add indexes to the replica that match the upstream indexes. In the future, this will be customizable. Column Types Postgres Type schema.ts Type Resulting TS Type All numeric types number number char, varchar, text, uuid string string cidr, inet, macaddr, macaddr8 string string pg_lsn string string isn extension types (ean13 , isbn, etc.) string string bool boolean boolean date, timestamp, timestampz, time, timetz number number json, jsonb json JSONValue enum enumeration string T[] where T is a supported Postgres type (but please see ⚠️ below) json where U is the schema.ts type for T V[] where V is the JS/TS type for T Zero will sync arrays to the client, but there is no support for filtering or joining on array elements yet in ZQL. Other Postgres column types aren’t supported. They will be ignored when replicating (the synced data will be missing that column) and you will get a warning when zero-cache starts up. If your schema has a pg type not listed here, you can support it in Zero by using a trigger to map it to some type that Zero can support. For example if you have a GIS polygon type in the column my_poly polygon, you can use a trigger to map it to a my_poly_json json column. You could either use another trigger to map in the reverse direction to support changes for writes, or you could use a mutator to write to the polygon type directly on the server. Let us know if the lack of a particular column type is hindering your use of Zero. It can likely be added. Column Defaults Default values are allowed in the Postgres schema, but there currently is no way to use them from a Zero app. An insert() mutation requires all columns to be specified, except when columns are nullable (in which case, they default to null). Since there is no way to leave non-nullable columns off the insert on the client, there is no way for PG to apply the default. This is a known issue and will be fixed in the future. IDs It is strongly recommended to use client-generated random strings like crypto.randomUUID(), uuid, ulid, nanoid, etc for primary keys. This makes optimistic creation and updates much easier. Imagine that the PK of your table is an auto-incrementing integer. If you optimistically create an entity of this type, you will have to give it some ID – the type will require it locally, but also if you want to optimistically create relationships to this row you’ll need an ID. You could sync the highest value seen for that table, but there are race conditions and it is possible for that ID to be taken by the time the creation makes it to the server. Your database can resolve this and assign the next ID, but now the relationships you created optimistically will be against the wrong row. Blech. GUIDs makes a lot more sense in synced applications. If your table has a natural key you can use that and it has less problems. But there is still the chance for a conflict. Imagine you are modeling orgs and you choose domainName as the natural key. It is possible for a race to happen and when the creation gets to the server, somebody has already chosen that domain name. In that case, the best thing to do is reject the write and show the user an error. If you want to have a short auto-incrementing numeric ID for UX reasons (i.e., a bug number), that is possible - see this video. Primary Keys Each table synced with Zero must have either a primary key or at least one unique index. This is needed so that Zero can identify rows during sync, to distinguish between an edit and a remove/add. Multi-column primary and foreign keys are supported. Limiting Replication There are two levels of replication to consider with Zero: replicating from Postgres to zero-cache, and from zero-cache to the Zero browser client. zero-cache replication By default, Zero creates a Postgres publication that publishes all tables in the public schema to zero-cache. To limit which tables or columns are replicated to zero-cache, you can create a Postgres publication with the tables and columns you want: CREATE PUBLICATION zero_data FOR TABLE users (col1, col2, col3, ...), issues, comments; Then, specify this publication in the App Publications zero-cache option. Browser client replication You can use Read Permissions to control which rows are synced from the zero-cache replica to actual clients (e.g., web browsers). Currently, Permissions can limit which tables and rows can be replicated to the client. In the near future, you'll also be able to use Permissions to limit syncing individual columns. Until then, you will need to create a publication to control which columns are synced to zero-cache. Schema changes All Postgres schema changes are supported. See Schema Migrations.", + "content": "Postgres has a massive feature set, and Zero supports a growing subset of it. Object Names Table and column names must begin with a letter or underscore This can be followed by letters, numbers, underscores, and hyphens Regex: /^[A-Za-z_]+[A-Za-z0-9_-]*$/ The column name _0_version is reserved for internal use Object Types Tables are synced. Views are not synced. generated as identity columns are synced. In Postgres 18+, generated stored columns are synced. In lower Postgres versions they aren't. Indexes aren't synced per-se, but we do implicitly add indexes to the replica that match the upstream indexes. In the future, this will be customizable. Column Types Postgres Type schema.ts Type Resulting TS Type All numeric types number number char, varchar, text, uuid string string cidr, inet, macaddr, macaddr8 string string pg_lsn string string isn extension types (ean13 , isbn, etc.) string string bool boolean boolean date, timestamp, timestampz, time, timetz number number json, jsonb json JSONValue enum enumeration string T[] where T is a supported Postgres type (but please see ⚠️ below) json where U is the schema.ts type for T V[] where V is the JS/TS type for T Zero will sync arrays to the client, but there is no support for filtering or joining on array elements yet in ZQL. Other Postgres column types aren’t supported. They will be ignored when replicating (the synced data will be missing that column) and you will get a warning when zero-cache starts up. If your schema has a pg type not listed here, you can support it in Zero by using a trigger to map it to some type that Zero can support. For example if you have a GIS polygon type in the column my_poly polygon, you can use a trigger to map it to a my_poly_json json column. You could either use another trigger to map in the reverse direction to support changes for writes, or you could use a mutator to write to the polygon type directly on the server. Let us know if the lack of a particular column type is hindering your use of Zero. It can likely be added. Column Defaults Default values are allowed in the Postgres schema, but there currently is no way to use them from a Zero app. An insert() mutation requires all columns to be specified, except when columns are nullable (in which case, they default to null). Since there is no way to leave non-nullable columns off the insert on the client, there is no way for PG to apply the default. This is a known issue and will be fixed in the future. IDs It is strongly recommended to use client-generated random strings like crypto.randomUUID(), uuid, ulid, nanoid, etc for primary keys. This makes optimistic creation and updates much easier. Imagine that the PK of your table is an auto-incrementing integer. If you optimistically create an entity of this type, you will have to give it some ID – the type will require it locally, but also if you want to optimistically create relationships to this row you’ll need an ID. You could sync the highest value seen for that table, but there are race conditions and it is possible for that ID to be taken by the time the creation makes it to the server. Your database can resolve this and assign the next ID, but now the relationships you created optimistically will be against the wrong row. Blech. GUIDs makes a lot more sense in synced applications. Natural keys can still conflict. If the natural key is the Zero primary key, a duplicate insert leaves the existing row unchanged. Reject duplicates in an authoritative mutator; separate unique-constraint violations still fail. If you want to have a short auto-incrementing numeric ID for UX reasons (i.e., a bug number), that is possible - see this video. Primary Keys Each table synced with Zero must have either a primary key or at least one unique index. This is needed so that Zero can identify rows during sync, to distinguish between an edit and a remove/add. Multi-column primary and foreign keys are supported. Limiting Replication There are two levels of replication to consider with Zero: replicating from Postgres to zero-cache, and from zero-cache to the Zero browser client. zero-cache replication By default, Zero creates a Postgres publication that publishes all tables in the public schema to zero-cache. To limit which tables or columns are replicated to zero-cache, you can create a Postgres publication with the tables and columns you want: CREATE PUBLICATION zero_data FOR TABLE users (col1, col2, col3, ...), issues, comments; Then, specify this publication in the App Publications zero-cache option. Browser client replication You can use Read Permissions to control which rows are synced from the zero-cache replica to actual clients (e.g., web browsers). Currently, Permissions can limit which tables and rows can be replicated to the client. In the near future, you'll also be able to use Permissions to limit syncing individual columns. Until then, you will need to create a publication to control which columns are synced to zero-cache. Schema changes All Postgres schema changes are supported. See Schema Migrations.", "headings": [ { "text": "Object Names", @@ -2472,7 +2486,7 @@ "kind": "page" }, { - "id": "236-postgres-support#object-names", + "id": "237-postgres-support#object-names", "title": "Supported Postgres Features", "searchTitle": "Object Names", "sectionTitle": "Object Names", @@ -2482,7 +2496,7 @@ "kind": "section" }, { - "id": "237-postgres-support#object-types", + "id": "238-postgres-support#object-types", "title": "Supported Postgres Features", "searchTitle": "Object Types", "sectionTitle": "Object Types", @@ -2492,7 +2506,7 @@ "kind": "section" }, { - "id": "238-postgres-support#column-types", + "id": "239-postgres-support#column-types", "title": "Supported Postgres Features", "searchTitle": "Column Types", "sectionTitle": "Column Types", @@ -2502,7 +2516,7 @@ "kind": "section" }, { - "id": "239-postgres-support#column-defaults", + "id": "240-postgres-support#column-defaults", "title": "Supported Postgres Features", "searchTitle": "Column Defaults", "sectionTitle": "Column Defaults", @@ -2512,17 +2526,17 @@ "kind": "section" }, { - "id": "240-postgres-support#ids", + "id": "241-postgres-support#ids", "title": "Supported Postgres Features", "searchTitle": "IDs", "sectionTitle": "IDs", "sectionId": "ids", "url": "/docs/postgres-support", - "content": "It is strongly recommended to use client-generated random strings like crypto.randomUUID(), uuid, ulid, nanoid, etc for primary keys. This makes optimistic creation and updates much easier. Imagine that the PK of your table is an auto-incrementing integer. If you optimistically create an entity of this type, you will have to give it some ID – the type will require it locally, but also if you want to optimistically create relationships to this row you’ll need an ID. You could sync the highest value seen for that table, but there are race conditions and it is possible for that ID to be taken by the time the creation makes it to the server. Your database can resolve this and assign the next ID, but now the relationships you created optimistically will be against the wrong row. Blech. GUIDs makes a lot more sense in synced applications. If your table has a natural key you can use that and it has less problems. But there is still the chance for a conflict. Imagine you are modeling orgs and you choose domainName as the natural key. It is possible for a race to happen and when the creation gets to the server, somebody has already chosen that domain name. In that case, the best thing to do is reject the write and show the user an error. If you want to have a short auto-incrementing numeric ID for UX reasons (i.e., a bug number), that is possible - see this video.", + "content": "It is strongly recommended to use client-generated random strings like crypto.randomUUID(), uuid, ulid, nanoid, etc for primary keys. This makes optimistic creation and updates much easier. Imagine that the PK of your table is an auto-incrementing integer. If you optimistically create an entity of this type, you will have to give it some ID – the type will require it locally, but also if you want to optimistically create relationships to this row you’ll need an ID. You could sync the highest value seen for that table, but there are race conditions and it is possible for that ID to be taken by the time the creation makes it to the server. Your database can resolve this and assign the next ID, but now the relationships you created optimistically will be against the wrong row. Blech. GUIDs makes a lot more sense in synced applications. Natural keys can still conflict. If the natural key is the Zero primary key, a duplicate insert leaves the existing row unchanged. Reject duplicates in an authoritative mutator; separate unique-constraint violations still fail. If you want to have a short auto-incrementing numeric ID for UX reasons (i.e., a bug number), that is possible - see this video.", "kind": "section" }, { - "id": "241-postgres-support#primary-keys", + "id": "242-postgres-support#primary-keys", "title": "Supported Postgres Features", "searchTitle": "Primary Keys", "sectionTitle": "Primary Keys", @@ -2532,7 +2546,7 @@ "kind": "section" }, { - "id": "242-postgres-support#limiting-replication", + "id": "243-postgres-support#limiting-replication", "title": "Supported Postgres Features", "searchTitle": "Limiting Replication", "sectionTitle": "Limiting Replication", @@ -2542,7 +2556,7 @@ "kind": "section" }, { - "id": "243-postgres-support#zero-cache-replication", + "id": "244-postgres-support#zero-cache-replication", "title": "Supported Postgres Features", "searchTitle": "zero-cache replication", "sectionTitle": "zero-cache replication", @@ -2552,7 +2566,7 @@ "kind": "section" }, { - "id": "244-postgres-support#browser-client-replication", + "id": "245-postgres-support#browser-client-replication", "title": "Supported Postgres Features", "searchTitle": "Browser client replication", "sectionTitle": "Browser client replication", @@ -2562,7 +2576,7 @@ "kind": "section" }, { - "id": "245-postgres-support#schema-changes", + "id": "246-postgres-support#schema-changes", "title": "Supported Postgres Features", "searchTitle": "Schema changes", "sectionTitle": "Schema changes", @@ -2598,7 +2612,7 @@ "kind": "page" }, { - "id": "246-previews#overview", + "id": "247-previews#overview", "title": "Previews", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -2608,7 +2622,7 @@ "kind": "section" }, { - "id": "247-previews#configure-allowed-endpoint-patterns", + "id": "248-previews#configure-allowed-endpoint-patterns", "title": "Previews", "searchTitle": "Configure Allowed Endpoint Patterns", "sectionTitle": "Configure Allowed Endpoint Patterns", @@ -2618,7 +2632,7 @@ "kind": "section" }, { - "id": "248-previews#choose-endpoint-urls-in-the-client", + "id": "249-previews#choose-endpoint-urls-in-the-client", "title": "Previews", "searchTitle": "Choose Endpoint URLs in the Client", "sectionTitle": "Choose Endpoint URLs in the Client", @@ -2628,7 +2642,7 @@ "kind": "section" }, { - "id": "249-previews#schema-changes-in-previews", + "id": "250-previews#schema-changes-in-previews", "title": "Previews", "searchTitle": "Schema Changes in Previews", "sectionTitle": "Schema Changes in Previews", @@ -2642,7 +2656,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
404 Not Found
} else { return
{issue.title}
}const [issue] = useQuery(() => queries.issues.get('some-id') ) return ( {resolved => ( 404 Not Found} >
{resolved.title}
)}
)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
404 Not Found
} if (!issue) { return null } return
{issue.title}
const [issue, issueResult] = useQuery(() => queries.issues.get('some-id') ) return ( {resolved =>
{resolved.title}
}
404 Not Found
)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 (
Error loading posts: {postsResult.error.message}
) }const [posts, postsResult] = useQuery(() => queries.posts.byAuthorID('user123') ) return (
Error loading posts: {postsResult().error.message}
)// 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'} ) 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
404 Not Found
} else { return
{issue.title}
}const [issue] = useQuery(() => queries.issues.get('some-id') ) return ( {resolved => ( 404 Not Found} >
{resolved.title}
)}
)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
404 Not Found
} if (!issue) { return null } return
{issue.title}
const [issue, issueResult] = useQuery(() => queries.issues.get('some-id') ) return ( {resolved =>
{resolved.title}
}
404 Not Found
)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: Endpoint fetch failures and 5xx responses get up to four total attempts; 4xx responses are not retried. const [posts, postsResult] = useQuery( queries.posts.byAuthorID('user123') ) if (postsResult.type === 'error') { return (
Error loading posts: {postsResult.error.message}
) }const [posts, postsResult] = useQuery(() => queries.posts.byAuthorID('user123') ) return (
Error loading posts: {postsResult().error.message}
)// 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", @@ -2772,7 +2786,7 @@ "kind": "page" }, { - "id": "250-queries#architecture", + "id": "251-queries#architecture", "title": "Queries", "searchTitle": "Architecture", "sectionTitle": "Architecture", @@ -2782,7 +2796,7 @@ "kind": "section" }, { - "id": "251-queries#life-of-a-query", + "id": "252-queries#life-of-a-query", "title": "Queries", "searchTitle": "Life of a Query", "sectionTitle": "Life of a Query", @@ -2792,7 +2806,7 @@ "kind": "section" }, { - "id": "252-queries#defining-queries", + "id": "253-queries#defining-queries", "title": "Queries", "searchTitle": "Defining Queries", "sectionTitle": "Defining Queries", @@ -2802,7 +2816,7 @@ "kind": "section" }, { - "id": "253-queries#basics", + "id": "254-queries#basics", "title": "Queries", "searchTitle": "Basics", "sectionTitle": "Basics", @@ -2812,7 +2826,7 @@ "kind": "section" }, { - "id": "254-queries#arguments", + "id": "255-queries#arguments", "title": "Queries", "searchTitle": "Arguments", "sectionTitle": "Arguments", @@ -2822,7 +2836,7 @@ "kind": "section" }, { - "id": "255-queries#query-registries", + "id": "256-queries#query-registries", "title": "Queries", "searchTitle": "Query Registries", "sectionTitle": "Query Registries", @@ -2832,7 +2846,7 @@ "kind": "section" }, { - "id": "256-queries#query-names", + "id": "257-queries#query-names", "title": "Queries", "searchTitle": "Query Names", "sectionTitle": "Query Names", @@ -2842,7 +2856,7 @@ "kind": "section" }, { - "id": "257-queries#context", + "id": "258-queries#context", "title": "Queries", "searchTitle": "Context", "sectionTitle": "Context", @@ -2852,7 +2866,7 @@ "kind": "section" }, { - "id": "258-queries#queriests", + "id": "259-queries#queriests", "title": "Queries", "searchTitle": "queries.ts", "sectionTitle": "queries.ts", @@ -2862,7 +2876,7 @@ "kind": "section" }, { - "id": "259-queries#server-setup", + "id": "260-queries#server-setup", "title": "Queries", "searchTitle": "Server Setup", "sectionTitle": "Server Setup", @@ -2872,7 +2886,7 @@ "kind": "section" }, { - "id": "260-queries#registering-the-endpoint", + "id": "261-queries#registering-the-endpoint", "title": "Queries", "searchTitle": "Registering the Endpoint", "sectionTitle": "Registering the Endpoint", @@ -2882,7 +2896,7 @@ "kind": "section" }, { - "id": "261-queries#implementing-the-endpoint", + "id": "262-queries#implementing-the-endpoint", "title": "Queries", "searchTitle": "Implementing the Endpoint", "sectionTitle": "Implementing the Endpoint", @@ -2892,7 +2906,7 @@ "kind": "section" }, { - "id": "262-queries#custom-query-url", + "id": "263-queries#custom-query-url", "title": "Queries", "searchTitle": "Custom Query URL", "sectionTitle": "Custom Query URL", @@ -2902,7 +2916,7 @@ "kind": "section" }, { - "id": "263-queries#url-patterns", + "id": "264-queries#url-patterns", "title": "Queries", "searchTitle": "URL Patterns", "sectionTitle": "URL Patterns", @@ -2912,7 +2926,7 @@ "kind": "section" }, { - "id": "264-queries#running-queries", + "id": "265-queries#running-queries", "title": "Queries", "searchTitle": "Running Queries", "sectionTitle": "Running Queries", @@ -2922,7 +2936,7 @@ "kind": "section" }, { - "id": "265-queries#reactively", + "id": "266-queries#reactively", "title": "Queries", "searchTitle": "Reactively", "sectionTitle": "Reactively", @@ -2932,7 +2946,7 @@ "kind": "section" }, { - "id": "266-queries#conditionally", + "id": "267-queries#conditionally", "title": "Queries", "searchTitle": "Conditionally", "sectionTitle": "Conditionally", @@ -2942,7 +2956,7 @@ "kind": "section" }, { - "id": "267-queries#once", + "id": "268-queries#once", "title": "Queries", "searchTitle": "Once", "sectionTitle": "Once", @@ -2952,7 +2966,7 @@ "kind": "section" }, { - "id": "268-queries#for-preloading", + "id": "269-queries#for-preloading", "title": "Queries", "searchTitle": "For Preloading", "sectionTitle": "For Preloading", @@ -2962,7 +2976,7 @@ "kind": "section" }, { - "id": "269-queries#missing-data", + "id": "270-queries#missing-data", "title": "Queries", "searchTitle": "Missing Data", "sectionTitle": "Missing Data", @@ -2972,7 +2986,7 @@ "kind": "section" }, { - "id": "270-queries#partial-data", + "id": "271-queries#partial-data", "title": "Queries", "searchTitle": "Partial Data", "sectionTitle": "Partial Data", @@ -2982,17 +2996,17 @@ "kind": "section" }, { - "id": "271-queries#handling-errors", + "id": "272-queries#handling-errors", "title": "Queries", "searchTitle": "Handling Errors", "sectionTitle": "Handling Errors", "sectionId": "handling-errors", "url": "/docs/queries", - "content": "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 (
Error loading posts: {postsResult.error.message}
) }const [posts, postsResult] = useQuery(() => queries.posts.byAuthorID('user123') ) return (
Error loading posts: {postsResult().error.message}
)// 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.", + "content": "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: Endpoint fetch failures and 5xx responses get up to four total attempts; 4xx responses are not retried. const [posts, postsResult] = useQuery( queries.posts.byAuthorID('user123') ) if (postsResult.type === 'error') { return (
Error loading posts: {postsResult.error.message}
) }const [posts, postsResult] = useQuery(() => queries.posts.byAuthorID('user123') ) return (
Error loading posts: {postsResult().error.message}
)// 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.", "kind": "section" }, { - "id": "272-queries#granular-updates", + "id": "273-queries#granular-updates", "title": "Queries", "searchTitle": "Granular Updates", "sectionTitle": "Granular Updates", @@ -3002,7 +3016,7 @@ "kind": "section" }, { - "id": "273-queries#query-caching", + "id": "274-queries#query-caching", "title": "Queries", "searchTitle": "Query Caching", "sectionTitle": "Query Caching", @@ -3012,7 +3026,7 @@ "kind": "section" }, { - "id": "274-queries#ttls", + "id": "275-queries#ttls", "title": "Queries", "searchTitle": "TTLs", "sectionTitle": "TTLs", @@ -3022,7 +3036,7 @@ "kind": "section" }, { - "id": "275-queries#ttl-defaults", + "id": "276-queries#ttl-defaults", "title": "Queries", "searchTitle": "TTL Defaults", "sectionTitle": "TTL Defaults", @@ -3032,7 +3046,7 @@ "kind": "section" }, { - "id": "276-queries#setting-different-ttls", + "id": "277-queries#setting-different-ttls", "title": "Queries", "searchTitle": "Setting Different TTLs", "sectionTitle": "Setting Different TTLs", @@ -3042,7 +3056,7 @@ "kind": "section" }, { - "id": "277-queries#why-zero-ttls-are-short", + "id": "278-queries#why-zero-ttls-are-short", "title": "Queries", "searchTitle": "Why Zero TTLs are Short", "sectionTitle": "Why Zero TTLs are Short", @@ -3052,7 +3066,7 @@ "kind": "section" }, { - "id": "278-queries#local-only-queries", + "id": "279-queries#local-only-queries", "title": "Queries", "searchTitle": "Local-Only Queries", "sectionTitle": "Local-Only Queries", @@ -3062,7 +3076,7 @@ "kind": "section" }, { - "id": "279-queries#custom-server-implementation", + "id": "280-queries#custom-server-implementation", "title": "Queries", "searchTitle": "Custom Server Implementation", "sectionTitle": "Custom Server Implementation", @@ -3072,7 +3086,7 @@ "kind": "section" }, { - "id": "280-queries#consistency", + "id": "281-queries#consistency", "title": "Queries", "searchTitle": "Consistency", "sectionTitle": "Consistency", @@ -3104,7 +3118,7 @@ "kind": "page" }, { - "id": "281-quickstart#hello-zero-solid", + "id": "282-quickstart#hello-zero-solid", "title": "Quickstart", "searchTitle": "hello-zero-solid", "sectionTitle": "hello-zero-solid", @@ -3114,7 +3128,7 @@ "kind": "section" }, { - "id": "282-quickstart#hello-zero-cf", + "id": "283-quickstart#hello-zero-cf", "title": "Quickstart", "searchTitle": "hello-zero-cf", "sectionTitle": "hello-zero-cf", @@ -3124,7 +3138,7 @@ "kind": "section" }, { - "id": "283-quickstart#hello-zero", + "id": "284-quickstart#hello-zero", "title": "Quickstart", "searchTitle": "hello-zero", "sectionTitle": "hello-zero", @@ -3169,7 +3183,7 @@ "kind": "page" }, { - "id": "284-react#setup", + "id": "285-react#setup", "title": "React", "searchTitle": "Setup", "sectionTitle": "Setup", @@ -3179,7 +3193,7 @@ "kind": "section" }, { - "id": "285-react#usage", + "id": "286-react#usage", "title": "React", "searchTitle": "Usage", "sectionTitle": "Usage", @@ -3189,7 +3203,7 @@ "kind": "section" }, { - "id": "286-react#suspense", + "id": "287-react#suspense", "title": "React", "searchTitle": "Suspense", "sectionTitle": "Suspense", @@ -3199,7 +3213,7 @@ "kind": "section" }, { - "id": "287-react#examples", + "id": "288-react#examples", "title": "React", "searchTitle": "Examples", "sectionTitle": "Examples", @@ -3231,7 +3245,7 @@ "kind": "page" }, { - "id": "288-release-notes/0.1#breaking-changes", + "id": "289-release-notes/0.1#breaking-changes", "title": "Zero 0.1", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -3241,7 +3255,7 @@ "kind": "section" }, { - "id": "289-release-notes/0.1#features", + "id": "290-release-notes/0.1#features", "title": "Zero 0.1", "searchTitle": "Features", "sectionTitle": "Features", @@ -3251,7 +3265,7 @@ "kind": "section" }, { - "id": "290-release-notes/0.1#source-tree-fixes", + "id": "291-release-notes/0.1#source-tree-fixes", "title": "Zero 0.1", "searchTitle": "Source tree fixes", "sectionTitle": "Source tree fixes", @@ -3287,7 +3301,7 @@ "kind": "page" }, { - "id": "291-release-notes/0.10#install", + "id": "292-release-notes/0.10#install", "title": "Zero 0.10", "searchTitle": "Install", "sectionTitle": "Install", @@ -3297,7 +3311,7 @@ "kind": "section" }, { - "id": "292-release-notes/0.10#features", + "id": "293-release-notes/0.10#features", "title": "Zero 0.10", "searchTitle": "Features", "sectionTitle": "Features", @@ -3307,7 +3321,7 @@ "kind": "section" }, { - "id": "293-release-notes/0.10#fixes", + "id": "294-release-notes/0.10#fixes", "title": "Zero 0.10", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3317,7 +3331,7 @@ "kind": "section" }, { - "id": "294-release-notes/0.10#breaking-changes", + "id": "295-release-notes/0.10#breaking-changes", "title": "Zero 0.10", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3353,7 +3367,7 @@ "kind": "page" }, { - "id": "295-release-notes/0.11#install", + "id": "296-release-notes/0.11#install", "title": "Zero 0.11", "searchTitle": "Install", "sectionTitle": "Install", @@ -3363,7 +3377,7 @@ "kind": "section" }, { - "id": "296-release-notes/0.11#features", + "id": "297-release-notes/0.11#features", "title": "Zero 0.11", "searchTitle": "Features", "sectionTitle": "Features", @@ -3373,7 +3387,7 @@ "kind": "section" }, { - "id": "297-release-notes/0.11#fixes", + "id": "298-release-notes/0.11#fixes", "title": "Zero 0.11", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3383,7 +3397,7 @@ "kind": "section" }, { - "id": "298-release-notes/0.11#breaking-changes", + "id": "299-release-notes/0.11#breaking-changes", "title": "Zero 0.11", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3419,7 +3433,7 @@ "kind": "page" }, { - "id": "299-release-notes/0.12#install", + "id": "300-release-notes/0.12#install", "title": "Zero 0.12", "searchTitle": "Install", "sectionTitle": "Install", @@ -3429,7 +3443,7 @@ "kind": "section" }, { - "id": "300-release-notes/0.12#features", + "id": "301-release-notes/0.12#features", "title": "Zero 0.12", "searchTitle": "Features", "sectionTitle": "Features", @@ -3439,7 +3453,7 @@ "kind": "section" }, { - "id": "301-release-notes/0.12#fixes", + "id": "302-release-notes/0.12#fixes", "title": "Zero 0.12", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3449,7 +3463,7 @@ "kind": "section" }, { - "id": "302-release-notes/0.12#breaking-changes", + "id": "303-release-notes/0.12#breaking-changes", "title": "Zero 0.12", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3485,7 +3499,7 @@ "kind": "page" }, { - "id": "303-release-notes/0.13#install", + "id": "304-release-notes/0.13#install", "title": "Zero 0.13", "searchTitle": "Install", "sectionTitle": "Install", @@ -3495,7 +3509,7 @@ "kind": "section" }, { - "id": "304-release-notes/0.13#features", + "id": "305-release-notes/0.13#features", "title": "Zero 0.13", "searchTitle": "Features", "sectionTitle": "Features", @@ -3505,7 +3519,7 @@ "kind": "section" }, { - "id": "305-release-notes/0.13#fixes", + "id": "306-release-notes/0.13#fixes", "title": "Zero 0.13", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3515,7 +3529,7 @@ "kind": "section" }, { - "id": "306-release-notes/0.13#breaking-changes", + "id": "307-release-notes/0.13#breaking-changes", "title": "Zero 0.13", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3551,7 +3565,7 @@ "kind": "page" }, { - "id": "307-release-notes/0.14#install", + "id": "308-release-notes/0.14#install", "title": "Zero 0.14", "searchTitle": "Install", "sectionTitle": "Install", @@ -3561,7 +3575,7 @@ "kind": "section" }, { - "id": "308-release-notes/0.14#features", + "id": "309-release-notes/0.14#features", "title": "Zero 0.14", "searchTitle": "Features", "sectionTitle": "Features", @@ -3571,7 +3585,7 @@ "kind": "section" }, { - "id": "309-release-notes/0.14#fixes", + "id": "310-release-notes/0.14#fixes", "title": "Zero 0.14", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3581,7 +3595,7 @@ "kind": "section" }, { - "id": "310-release-notes/0.14#breaking-changes", + "id": "311-release-notes/0.14#breaking-changes", "title": "Zero 0.14", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3621,7 +3635,7 @@ "kind": "page" }, { - "id": "311-release-notes/0.15#install", + "id": "312-release-notes/0.15#install", "title": "Zero 0.15", "searchTitle": "Install", "sectionTitle": "Install", @@ -3631,7 +3645,7 @@ "kind": "section" }, { - "id": "312-release-notes/0.15#upgrade-guide", + "id": "313-release-notes/0.15#upgrade-guide", "title": "Zero 0.15", "searchTitle": "Upgrade Guide", "sectionTitle": "Upgrade Guide", @@ -3641,7 +3655,7 @@ "kind": "section" }, { - "id": "313-release-notes/0.15#features", + "id": "314-release-notes/0.15#features", "title": "Zero 0.15", "searchTitle": "Features", "sectionTitle": "Features", @@ -3651,7 +3665,7 @@ "kind": "section" }, { - "id": "314-release-notes/0.15#fixes", + "id": "315-release-notes/0.15#fixes", "title": "Zero 0.15", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3661,7 +3675,7 @@ "kind": "section" }, { - "id": "315-release-notes/0.15#breaking-changes", + "id": "316-release-notes/0.15#breaking-changes", "title": "Zero 0.15", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3701,7 +3715,7 @@ "kind": "page" }, { - "id": "316-release-notes/0.16#install", + "id": "317-release-notes/0.16#install", "title": "Zero 0.16", "searchTitle": "Install", "sectionTitle": "Install", @@ -3711,7 +3725,7 @@ "kind": "section" }, { - "id": "317-release-notes/0.16#upgrading", + "id": "318-release-notes/0.16#upgrading", "title": "Zero 0.16", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -3721,7 +3735,7 @@ "kind": "section" }, { - "id": "318-release-notes/0.16#features", + "id": "319-release-notes/0.16#features", "title": "Zero 0.16", "searchTitle": "Features", "sectionTitle": "Features", @@ -3731,7 +3745,7 @@ "kind": "section" }, { - "id": "319-release-notes/0.16#fixes", + "id": "320-release-notes/0.16#fixes", "title": "Zero 0.16", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3741,7 +3755,7 @@ "kind": "section" }, { - "id": "320-release-notes/0.16#breaking-changes", + "id": "321-release-notes/0.16#breaking-changes", "title": "Zero 0.16", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3781,7 +3795,7 @@ "kind": "page" }, { - "id": "321-release-notes/0.17#install", + "id": "322-release-notes/0.17#install", "title": "Zero 0.17", "searchTitle": "Install", "sectionTitle": "Install", @@ -3791,7 +3805,7 @@ "kind": "section" }, { - "id": "322-release-notes/0.17#upgrading", + "id": "323-release-notes/0.17#upgrading", "title": "Zero 0.17", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -3801,7 +3815,7 @@ "kind": "section" }, { - "id": "323-release-notes/0.17#features", + "id": "324-release-notes/0.17#features", "title": "Zero 0.17", "searchTitle": "Features", "sectionTitle": "Features", @@ -3811,7 +3825,7 @@ "kind": "section" }, { - "id": "324-release-notes/0.17#fixes", + "id": "325-release-notes/0.17#fixes", "title": "Zero 0.17", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3821,7 +3835,7 @@ "kind": "section" }, { - "id": "325-release-notes/0.17#breaking-changes", + "id": "326-release-notes/0.17#breaking-changes", "title": "Zero 0.17", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3861,7 +3875,7 @@ "kind": "page" }, { - "id": "326-release-notes/0.18#install", + "id": "327-release-notes/0.18#install", "title": "Zero 0.18", "searchTitle": "Install", "sectionTitle": "Install", @@ -3871,7 +3885,7 @@ "kind": "section" }, { - "id": "327-release-notes/0.18#upgrading", + "id": "328-release-notes/0.18#upgrading", "title": "Zero 0.18", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -3881,7 +3895,7 @@ "kind": "section" }, { - "id": "328-release-notes/0.18#features", + "id": "329-release-notes/0.18#features", "title": "Zero 0.18", "searchTitle": "Features", "sectionTitle": "Features", @@ -3891,7 +3905,7 @@ "kind": "section" }, { - "id": "329-release-notes/0.18#fixes", + "id": "330-release-notes/0.18#fixes", "title": "Zero 0.18", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3901,7 +3915,7 @@ "kind": "section" }, { - "id": "330-release-notes/0.18#breaking-changes", + "id": "331-release-notes/0.18#breaking-changes", "title": "Zero 0.18", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -3941,7 +3955,7 @@ "kind": "page" }, { - "id": "331-release-notes/0.19#install", + "id": "332-release-notes/0.19#install", "title": "Zero 0.19", "searchTitle": "Install", "sectionTitle": "Install", @@ -3951,7 +3965,7 @@ "kind": "section" }, { - "id": "332-release-notes/0.19#upgrading", + "id": "333-release-notes/0.19#upgrading", "title": "Zero 0.19", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -3961,7 +3975,7 @@ "kind": "section" }, { - "id": "333-release-notes/0.19#features", + "id": "334-release-notes/0.19#features", "title": "Zero 0.19", "searchTitle": "Features", "sectionTitle": "Features", @@ -3971,7 +3985,7 @@ "kind": "section" }, { - "id": "334-release-notes/0.19#fixes", + "id": "335-release-notes/0.19#fixes", "title": "Zero 0.19", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -3981,7 +3995,7 @@ "kind": "section" }, { - "id": "335-release-notes/0.19#breaking-changes", + "id": "336-release-notes/0.19#breaking-changes", "title": "Zero 0.19", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4025,7 +4039,7 @@ "kind": "page" }, { - "id": "336-release-notes/0.2#breaking-changes", + "id": "337-release-notes/0.2#breaking-changes", "title": "Zero 0.2", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -4035,7 +4049,7 @@ "kind": "section" }, { - "id": "337-release-notes/0.2#features", + "id": "338-release-notes/0.2#features", "title": "Zero 0.2", "searchTitle": "Features", "sectionTitle": "Features", @@ -4045,7 +4059,7 @@ "kind": "section" }, { - "id": "338-release-notes/0.2#fixes", + "id": "339-release-notes/0.2#fixes", "title": "Zero 0.2", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4055,7 +4069,7 @@ "kind": "section" }, { - "id": "339-release-notes/0.2#docs", + "id": "340-release-notes/0.2#docs", "title": "Zero 0.2", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4065,7 +4079,7 @@ "kind": "section" }, { - "id": "340-release-notes/0.2#source-tree-fixes", + "id": "341-release-notes/0.2#source-tree-fixes", "title": "Zero 0.2", "searchTitle": "Source tree fixes", "sectionTitle": "Source tree fixes", @@ -4075,7 +4089,7 @@ "kind": "section" }, { - "id": "341-release-notes/0.2#zbugs", + "id": "342-release-notes/0.2#zbugs", "title": "Zero 0.2", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4115,7 +4129,7 @@ "kind": "page" }, { - "id": "342-release-notes/0.20#install", + "id": "343-release-notes/0.20#install", "title": "Zero 0.20", "searchTitle": "Install", "sectionTitle": "Install", @@ -4125,7 +4139,7 @@ "kind": "section" }, { - "id": "343-release-notes/0.20#upgrading", + "id": "344-release-notes/0.20#upgrading", "title": "Zero 0.20", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -4135,7 +4149,7 @@ "kind": "section" }, { - "id": "344-release-notes/0.20#features", + "id": "345-release-notes/0.20#features", "title": "Zero 0.20", "searchTitle": "Features", "sectionTitle": "Features", @@ -4145,7 +4159,7 @@ "kind": "section" }, { - "id": "345-release-notes/0.20#fixes", + "id": "346-release-notes/0.20#fixes", "title": "Zero 0.20", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4155,7 +4169,7 @@ "kind": "section" }, { - "id": "346-release-notes/0.20#breaking-changes", + "id": "347-release-notes/0.20#breaking-changes", "title": "Zero 0.20", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4195,7 +4209,7 @@ "kind": "page" }, { - "id": "347-release-notes/0.21#install", + "id": "348-release-notes/0.21#install", "title": "Zero 0.21", "searchTitle": "Install", "sectionTitle": "Install", @@ -4205,7 +4219,7 @@ "kind": "section" }, { - "id": "348-release-notes/0.21#upgrading", + "id": "349-release-notes/0.21#upgrading", "title": "Zero 0.21", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -4215,7 +4229,7 @@ "kind": "section" }, { - "id": "349-release-notes/0.21#features", + "id": "350-release-notes/0.21#features", "title": "Zero 0.21", "searchTitle": "Features", "sectionTitle": "Features", @@ -4225,7 +4239,7 @@ "kind": "section" }, { - "id": "350-release-notes/0.21#fixes", + "id": "351-release-notes/0.21#fixes", "title": "Zero 0.21", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4235,7 +4249,7 @@ "kind": "section" }, { - "id": "351-release-notes/0.21#breaking-changes", + "id": "352-release-notes/0.21#breaking-changes", "title": "Zero 0.21", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4287,7 +4301,7 @@ "kind": "page" }, { - "id": "352-release-notes/0.22#install", + "id": "353-release-notes/0.22#install", "title": "Zero 0.22", "searchTitle": "Install", "sectionTitle": "Install", @@ -4297,7 +4311,7 @@ "kind": "section" }, { - "id": "353-release-notes/0.22#upgrading", + "id": "354-release-notes/0.22#upgrading", "title": "Zero 0.22", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -4307,7 +4321,7 @@ "kind": "section" }, { - "id": "354-release-notes/0.22#how-ttls-used-to-work", + "id": "355-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", @@ -4317,7 +4331,7 @@ "kind": "section" }, { - "id": "355-release-notes/0.22#how-ttls-work-now", + "id": "356-release-notes/0.22#how-ttls-work-now", "title": "Zero 0.22", "searchTitle": "How TTLs Work Now", "sectionTitle": "How TTLs Work Now", @@ -4327,7 +4341,7 @@ "kind": "section" }, { - "id": "356-release-notes/0.22#using-new-ttls", + "id": "357-release-notes/0.22#using-new-ttls", "title": "Zero 0.22", "searchTitle": "Using New TTLs", "sectionTitle": "Using New TTLs", @@ -4337,7 +4351,7 @@ "kind": "section" }, { - "id": "357-release-notes/0.22#features", + "id": "358-release-notes/0.22#features", "title": "Zero 0.22", "searchTitle": "Features", "sectionTitle": "Features", @@ -4347,7 +4361,7 @@ "kind": "section" }, { - "id": "358-release-notes/0.22#fixes", + "id": "359-release-notes/0.22#fixes", "title": "Zero 0.22", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4357,7 +4371,7 @@ "kind": "section" }, { - "id": "359-release-notes/0.22#breaking-changes", + "id": "360-release-notes/0.22#breaking-changes", "title": "Zero 0.22", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4401,7 +4415,7 @@ "kind": "page" }, { - "id": "360-release-notes/0.23#install", + "id": "361-release-notes/0.23#install", "title": "Zero 0.23", "searchTitle": "Install", "sectionTitle": "Install", @@ -4411,7 +4425,7 @@ "kind": "section" }, { - "id": "361-release-notes/0.23#upgrading", + "id": "362-release-notes/0.23#upgrading", "title": "Zero 0.23", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -4421,7 +4435,7 @@ "kind": "section" }, { - "id": "362-release-notes/0.23#features", + "id": "363-release-notes/0.23#features", "title": "Zero 0.23", "searchTitle": "Features", "sectionTitle": "Features", @@ -4431,7 +4445,7 @@ "kind": "section" }, { - "id": "363-release-notes/0.23#fixes", + "id": "364-release-notes/0.23#fixes", "title": "Zero 0.23", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4441,7 +4455,7 @@ "kind": "section" }, { - "id": "364-release-notes/0.23#zbugs", + "id": "365-release-notes/0.23#zbugs", "title": "Zero 0.23", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4451,7 +4465,7 @@ "kind": "section" }, { - "id": "365-release-notes/0.23#breaking-changes", + "id": "366-release-notes/0.23#breaking-changes", "title": "Zero 0.23", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4491,7 +4505,7 @@ "kind": "page" }, { - "id": "366-release-notes/0.24#installation", + "id": "367-release-notes/0.24#installation", "title": "Zero 0.24", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -4501,7 +4515,7 @@ "kind": "section" }, { - "id": "367-release-notes/0.24#features", + "id": "368-release-notes/0.24#features", "title": "Zero 0.24", "searchTitle": "Features", "sectionTitle": "Features", @@ -4511,7 +4525,7 @@ "kind": "section" }, { - "id": "368-release-notes/0.24#fixes", + "id": "369-release-notes/0.24#fixes", "title": "Zero 0.24", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4521,7 +4535,7 @@ "kind": "section" }, { - "id": "369-release-notes/0.24#breaking-changes", + "id": "370-release-notes/0.24#breaking-changes", "title": "Zero 0.24", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4531,7 +4545,7 @@ "kind": "section" }, { - "id": "370-release-notes/0.24#example-upgrades", + "id": "371-release-notes/0.24#example-upgrades", "title": "Zero 0.24", "searchTitle": "Example Upgrades", "sectionTitle": "Example Upgrades", @@ -4579,7 +4593,7 @@ "kind": "page" }, { - "id": "371-release-notes/0.25#installation", + "id": "372-release-notes/0.25#installation", "title": "Zero 0.25", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -4589,7 +4603,7 @@ "kind": "section" }, { - "id": "372-release-notes/0.25#overview", + "id": "373-release-notes/0.25#overview", "title": "Zero 0.25", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -4599,7 +4613,7 @@ "kind": "section" }, { - "id": "373-release-notes/0.25#upgrading", + "id": "374-release-notes/0.25#upgrading", "title": "Zero 0.25", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -4609,7 +4623,7 @@ "kind": "section" }, { - "id": "374-release-notes/0.25#features", + "id": "375-release-notes/0.25#features", "title": "Zero 0.25", "searchTitle": "Features", "sectionTitle": "Features", @@ -4619,7 +4633,7 @@ "kind": "section" }, { - "id": "375-release-notes/0.25#performance", + "id": "376-release-notes/0.25#performance", "title": "Zero 0.25", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -4629,7 +4643,7 @@ "kind": "section" }, { - "id": "376-release-notes/0.25#fixes", + "id": "377-release-notes/0.25#fixes", "title": "Zero 0.25", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4639,7 +4653,7 @@ "kind": "section" }, { - "id": "377-release-notes/0.25#breaking-changes", + "id": "378-release-notes/0.25#breaking-changes", "title": "Zero 0.25", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4675,7 +4689,7 @@ "kind": "page" }, { - "id": "378-release-notes/0.26#installation", + "id": "379-release-notes/0.26#installation", "title": "Zero 0.26", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -4685,7 +4699,7 @@ "kind": "section" }, { - "id": "379-release-notes/0.26#features", + "id": "380-release-notes/0.26#features", "title": "Zero 0.26", "searchTitle": "Features", "sectionTitle": "Features", @@ -4695,7 +4709,7 @@ "kind": "section" }, { - "id": "380-release-notes/0.26#fixes", + "id": "381-release-notes/0.26#fixes", "title": "Zero 0.26", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4705,7 +4719,7 @@ "kind": "section" }, { - "id": "381-release-notes/0.26#breaking-changes", + "id": "382-release-notes/0.26#breaking-changes", "title": "Zero 0.26", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -4749,7 +4763,7 @@ "kind": "page" }, { - "id": "382-release-notes/0.3#install", + "id": "383-release-notes/0.3#install", "title": "Zero 0.3", "searchTitle": "Install", "sectionTitle": "Install", @@ -4759,7 +4773,7 @@ "kind": "section" }, { - "id": "383-release-notes/0.3#breaking-changes", + "id": "384-release-notes/0.3#breaking-changes", "title": "Zero 0.3", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -4769,7 +4783,7 @@ "kind": "section" }, { - "id": "384-release-notes/0.3#features", + "id": "385-release-notes/0.3#features", "title": "Zero 0.3", "searchTitle": "Features", "sectionTitle": "Features", @@ -4779,7 +4793,7 @@ "kind": "section" }, { - "id": "385-release-notes/0.3#fixes", + "id": "386-release-notes/0.3#fixes", "title": "Zero 0.3", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4789,7 +4803,7 @@ "kind": "section" }, { - "id": "386-release-notes/0.3#docs", + "id": "387-release-notes/0.3#docs", "title": "Zero 0.3", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4799,7 +4813,7 @@ "kind": "section" }, { - "id": "387-release-notes/0.3#zbugs", + "id": "388-release-notes/0.3#zbugs", "title": "Zero 0.3", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4843,7 +4857,7 @@ "kind": "page" }, { - "id": "388-release-notes/0.4#install", + "id": "389-release-notes/0.4#install", "title": "Zero 0.4", "searchTitle": "Install", "sectionTitle": "Install", @@ -4853,7 +4867,7 @@ "kind": "section" }, { - "id": "389-release-notes/0.4#breaking-changes", + "id": "390-release-notes/0.4#breaking-changes", "title": "Zero 0.4", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -4863,7 +4877,7 @@ "kind": "section" }, { - "id": "390-release-notes/0.4#added-or--and--and-not-to-zql-documentation", + "id": "391-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).", @@ -4873,7 +4887,7 @@ "kind": "section" }, { - "id": "391-release-notes/0.4#fixes", + "id": "392-release-notes/0.4#fixes", "title": "Zero 0.4", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4883,7 +4897,7 @@ "kind": "section" }, { - "id": "392-release-notes/0.4#docs", + "id": "393-release-notes/0.4#docs", "title": "Zero 0.4", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4893,7 +4907,7 @@ "kind": "section" }, { - "id": "393-release-notes/0.4#zbugs", + "id": "394-release-notes/0.4#zbugs", "title": "Zero 0.4", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -4937,7 +4951,7 @@ "kind": "page" }, { - "id": "394-release-notes/0.5#install", + "id": "395-release-notes/0.5#install", "title": "Zero 0.5", "searchTitle": "Install", "sectionTitle": "Install", @@ -4947,7 +4961,7 @@ "kind": "section" }, { - "id": "395-release-notes/0.5#breaking-changes", + "id": "396-release-notes/0.5#breaking-changes", "title": "Zero 0.5", "searchTitle": "Breaking changes", "sectionTitle": "Breaking changes", @@ -4957,7 +4971,7 @@ "kind": "section" }, { - "id": "396-release-notes/0.5#features", + "id": "397-release-notes/0.5#features", "title": "Zero 0.5", "searchTitle": "Features", "sectionTitle": "Features", @@ -4967,7 +4981,7 @@ "kind": "section" }, { - "id": "397-release-notes/0.5#fixes", + "id": "398-release-notes/0.5#fixes", "title": "Zero 0.5", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -4977,7 +4991,7 @@ "kind": "section" }, { - "id": "398-release-notes/0.5#docs", + "id": "399-release-notes/0.5#docs", "title": "Zero 0.5", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -4987,7 +5001,7 @@ "kind": "section" }, { - "id": "399-release-notes/0.5#zbugs", + "id": "400-release-notes/0.5#zbugs", "title": "Zero 0.5", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -5031,7 +5045,7 @@ "kind": "page" }, { - "id": "400-release-notes/0.6#install", + "id": "401-release-notes/0.6#install", "title": "Zero 0.6", "searchTitle": "Install", "sectionTitle": "Install", @@ -5041,7 +5055,7 @@ "kind": "section" }, { - "id": "401-release-notes/0.6#upgrade-guide", + "id": "402-release-notes/0.6#upgrade-guide", "title": "Zero 0.6", "searchTitle": "Upgrade Guide", "sectionTitle": "Upgrade Guide", @@ -5051,7 +5065,7 @@ "kind": "section" }, { - "id": "402-release-notes/0.6#breaking-changes", + "id": "403-release-notes/0.6#breaking-changes", "title": "Zero 0.6", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5061,7 +5075,7 @@ "kind": "section" }, { - "id": "403-release-notes/0.6#features", + "id": "404-release-notes/0.6#features", "title": "Zero 0.6", "searchTitle": "Features", "sectionTitle": "Features", @@ -5071,7 +5085,7 @@ "kind": "section" }, { - "id": "404-release-notes/0.6#zbugs", + "id": "405-release-notes/0.6#zbugs", "title": "Zero 0.6", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -5081,7 +5095,7 @@ "kind": "section" }, { - "id": "405-release-notes/0.6#docs", + "id": "406-release-notes/0.6#docs", "title": "Zero 0.6", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -5121,7 +5135,7 @@ "kind": "page" }, { - "id": "406-release-notes/0.7#install", + "id": "407-release-notes/0.7#install", "title": "Zero 0.7", "searchTitle": "Install", "sectionTitle": "Install", @@ -5131,7 +5145,7 @@ "kind": "section" }, { - "id": "407-release-notes/0.7#features", + "id": "408-release-notes/0.7#features", "title": "Zero 0.7", "searchTitle": "Features", "sectionTitle": "Features", @@ -5141,7 +5155,7 @@ "kind": "section" }, { - "id": "408-release-notes/0.7#breaking-changes", + "id": "409-release-notes/0.7#breaking-changes", "title": "Zero 0.7", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5151,7 +5165,7 @@ "kind": "section" }, { - "id": "409-release-notes/0.7#zbugs", + "id": "410-release-notes/0.7#zbugs", "title": "Zero 0.7", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -5161,7 +5175,7 @@ "kind": "section" }, { - "id": "410-release-notes/0.7#docs", + "id": "411-release-notes/0.7#docs", "title": "Zero 0.7", "searchTitle": "Docs", "sectionTitle": "Docs", @@ -5197,7 +5211,7 @@ "kind": "page" }, { - "id": "411-release-notes/0.8#install", + "id": "412-release-notes/0.8#install", "title": "Zero 0.8", "searchTitle": "Install", "sectionTitle": "Install", @@ -5207,7 +5221,7 @@ "kind": "section" }, { - "id": "412-release-notes/0.8#features", + "id": "413-release-notes/0.8#features", "title": "Zero 0.8", "searchTitle": "Features", "sectionTitle": "Features", @@ -5217,7 +5231,7 @@ "kind": "section" }, { - "id": "413-release-notes/0.8#fixes", + "id": "414-release-notes/0.8#fixes", "title": "Zero 0.8", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5227,7 +5241,7 @@ "kind": "section" }, { - "id": "414-release-notes/0.8#breaking-changes", + "id": "415-release-notes/0.8#breaking-changes", "title": "Zero 0.8", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5263,7 +5277,7 @@ "kind": "page" }, { - "id": "415-release-notes/0.9#install", + "id": "416-release-notes/0.9#install", "title": "Zero 0.9", "searchTitle": "Install", "sectionTitle": "Install", @@ -5273,7 +5287,7 @@ "kind": "section" }, { - "id": "416-release-notes/0.9#features", + "id": "417-release-notes/0.9#features", "title": "Zero 0.9", "searchTitle": "Features", "sectionTitle": "Features", @@ -5283,7 +5297,7 @@ "kind": "section" }, { - "id": "417-release-notes/0.9#fixes", + "id": "418-release-notes/0.9#fixes", "title": "Zero 0.9", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5293,7 +5307,7 @@ "kind": "section" }, { - "id": "418-release-notes/0.9#breaking-changes", + "id": "419-release-notes/0.9#breaking-changes", "title": "Zero 0.9", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5333,7 +5347,7 @@ "kind": "page" }, { - "id": "419-release-notes/1.0#installation", + "id": "420-release-notes/1.0#installation", "title": "Zero 1.0", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5343,7 +5357,7 @@ "kind": "section" }, { - "id": "420-release-notes/1.0#overview", + "id": "421-release-notes/1.0#overview", "title": "Zero 1.0", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -5353,7 +5367,7 @@ "kind": "section" }, { - "id": "421-release-notes/1.0#features", + "id": "422-release-notes/1.0#features", "title": "Zero 1.0", "searchTitle": "Features", "sectionTitle": "Features", @@ -5363,7 +5377,7 @@ "kind": "section" }, { - "id": "422-release-notes/1.0#fixes", + "id": "423-release-notes/1.0#fixes", "title": "Zero 1.0", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5373,7 +5387,7 @@ "kind": "section" }, { - "id": "423-release-notes/1.0#breaking-changes", + "id": "424-release-notes/1.0#breaking-changes", "title": "Zero 1.0", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5409,7 +5423,7 @@ "kind": "page" }, { - "id": "424-release-notes/1.1#installation", + "id": "425-release-notes/1.1#installation", "title": "Zero 1.1", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5419,7 +5433,7 @@ "kind": "section" }, { - "id": "425-release-notes/1.1#features", + "id": "426-release-notes/1.1#features", "title": "Zero 1.1", "searchTitle": "Features", "sectionTitle": "Features", @@ -5429,7 +5443,7 @@ "kind": "section" }, { - "id": "426-release-notes/1.1#fixes", + "id": "427-release-notes/1.1#fixes", "title": "Zero 1.1", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5439,7 +5453,7 @@ "kind": "section" }, { - "id": "427-release-notes/1.1#breaking-changes", + "id": "428-release-notes/1.1#breaking-changes", "title": "Zero 1.1", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5479,7 +5493,7 @@ "kind": "page" }, { - "id": "428-release-notes/1.2#installation", + "id": "429-release-notes/1.2#installation", "title": "Zero 1.2", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5489,7 +5503,7 @@ "kind": "section" }, { - "id": "429-release-notes/1.2#features", + "id": "430-release-notes/1.2#features", "title": "Zero 1.2", "searchTitle": "Features", "sectionTitle": "Features", @@ -5499,7 +5513,7 @@ "kind": "section" }, { - "id": "430-release-notes/1.2#performance", + "id": "431-release-notes/1.2#performance", "title": "Zero 1.2", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -5509,7 +5523,7 @@ "kind": "section" }, { - "id": "431-release-notes/1.2#fixes", + "id": "432-release-notes/1.2#fixes", "title": "Zero 1.2", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5519,7 +5533,7 @@ "kind": "section" }, { - "id": "432-release-notes/1.2#breaking-changes", + "id": "433-release-notes/1.2#breaking-changes", "title": "Zero 1.2", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5559,7 +5573,7 @@ "kind": "page" }, { - "id": "433-release-notes/1.3#installation", + "id": "434-release-notes/1.3#installation", "title": "Zero 1.3", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5569,7 +5583,7 @@ "kind": "section" }, { - "id": "434-release-notes/1.3#features", + "id": "435-release-notes/1.3#features", "title": "Zero 1.3", "searchTitle": "Features", "sectionTitle": "Features", @@ -5579,7 +5593,7 @@ "kind": "section" }, { - "id": "435-release-notes/1.3#performance", + "id": "436-release-notes/1.3#performance", "title": "Zero 1.3", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -5589,7 +5603,7 @@ "kind": "section" }, { - "id": "436-release-notes/1.3#fixes", + "id": "437-release-notes/1.3#fixes", "title": "Zero 1.3", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5599,7 +5613,7 @@ "kind": "section" }, { - "id": "437-release-notes/1.3#breaking-changes", + "id": "438-release-notes/1.3#breaking-changes", "title": "Zero 1.3", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5647,7 +5661,7 @@ "kind": "page" }, { - "id": "438-release-notes/1.4#installation", + "id": "439-release-notes/1.4#installation", "title": "Zero 1.4", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5657,7 +5671,7 @@ "kind": "section" }, { - "id": "439-release-notes/1.4#upgrading", + "id": "440-release-notes/1.4#upgrading", "title": "Zero 1.4", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -5667,7 +5681,7 @@ "kind": "section" }, { - "id": "440-release-notes/1.4#userid-anon", + "id": "441-release-notes/1.4#userid-anon", "title": "Zero 1.4", "searchTitle": "userID: \"anon\"", "sectionTitle": "userID: \"anon\"", @@ -5677,7 +5691,7 @@ "kind": "section" }, { - "id": "441-release-notes/1.4#features", + "id": "442-release-notes/1.4#features", "title": "Zero 1.4", "searchTitle": "Features", "sectionTitle": "Features", @@ -5687,7 +5701,7 @@ "kind": "section" }, { - "id": "442-release-notes/1.4#performance", + "id": "443-release-notes/1.4#performance", "title": "Zero 1.4", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -5697,7 +5711,7 @@ "kind": "section" }, { - "id": "443-release-notes/1.4#fixes", + "id": "444-release-notes/1.4#fixes", "title": "Zero 1.4", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5707,7 +5721,7 @@ "kind": "section" }, { - "id": "444-release-notes/1.4#breaking-changes", + "id": "445-release-notes/1.4#breaking-changes", "title": "Zero 1.4", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5759,7 +5773,7 @@ "kind": "page" }, { - "id": "445-release-notes/1.5#installation", + "id": "446-release-notes/1.5#installation", "title": "Zero 1.5", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5769,7 +5783,7 @@ "kind": "section" }, { - "id": "446-release-notes/1.5#upgrading", + "id": "447-release-notes/1.5#upgrading", "title": "Zero 1.5", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -5779,7 +5793,7 @@ "kind": "section" }, { - "id": "447-release-notes/1.5#authenticated-client-groups", + "id": "448-release-notes/1.5#authenticated-client-groups", "title": "Zero 1.5", "searchTitle": "Authenticated Client Groups", "sectionTitle": "Authenticated Client Groups", @@ -5789,7 +5803,7 @@ "kind": "section" }, { - "id": "448-release-notes/1.5#deploy-order", + "id": "449-release-notes/1.5#deploy-order", "title": "Zero 1.5", "searchTitle": "Deploy Order", "sectionTitle": "Deploy Order", @@ -5799,7 +5813,7 @@ "kind": "section" }, { - "id": "449-release-notes/1.5#features", + "id": "450-release-notes/1.5#features", "title": "Zero 1.5", "searchTitle": "Features", "sectionTitle": "Features", @@ -5809,7 +5823,7 @@ "kind": "section" }, { - "id": "450-release-notes/1.5#performance", + "id": "451-release-notes/1.5#performance", "title": "Zero 1.5", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -5819,7 +5833,7 @@ "kind": "section" }, { - "id": "451-release-notes/1.5#fixes", + "id": "452-release-notes/1.5#fixes", "title": "Zero 1.5", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5829,7 +5843,7 @@ "kind": "section" }, { - "id": "452-release-notes/1.5#breaking-changes", + "id": "453-release-notes/1.5#breaking-changes", "title": "Zero 1.5", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5877,7 +5891,7 @@ "kind": "page" }, { - "id": "453-release-notes/1.6#installation", + "id": "454-release-notes/1.6#installation", "title": "Zero 1.6", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5887,7 +5901,7 @@ "kind": "section" }, { - "id": "454-release-notes/1.6#upgrading", + "id": "455-release-notes/1.6#upgrading", "title": "Zero 1.6", "searchTitle": "Upgrading", "sectionTitle": "Upgrading", @@ -5897,7 +5911,7 @@ "kind": "section" }, { - "id": "455-release-notes/1.6#planetscale-failover", + "id": "456-release-notes/1.6#planetscale-failover", "title": "Zero 1.6", "searchTitle": "PlanetScale Failover", "sectionTitle": "PlanetScale Failover", @@ -5907,7 +5921,7 @@ "kind": "section" }, { - "id": "456-release-notes/1.6#features", + "id": "457-release-notes/1.6#features", "title": "Zero 1.6", "searchTitle": "Features", "sectionTitle": "Features", @@ -5917,7 +5931,7 @@ "kind": "section" }, { - "id": "457-release-notes/1.6#performance", + "id": "458-release-notes/1.6#performance", "title": "Zero 1.6", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -5927,7 +5941,7 @@ "kind": "section" }, { - "id": "458-release-notes/1.6#fixes", + "id": "459-release-notes/1.6#fixes", "title": "Zero 1.6", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -5937,7 +5951,7 @@ "kind": "section" }, { - "id": "459-release-notes/1.6#breaking-changes", + "id": "460-release-notes/1.6#breaking-changes", "title": "Zero 1.6", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -5989,7 +6003,7 @@ "kind": "page" }, { - "id": "460-release-notes/1.7#installation", + "id": "461-release-notes/1.7#installation", "title": "Zero 1.7", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -5999,7 +6013,7 @@ "kind": "section" }, { - "id": "461-release-notes/1.7#overview", + "id": "462-release-notes/1.7#overview", "title": "Zero 1.7", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -6009,7 +6023,7 @@ "kind": "section" }, { - "id": "462-release-notes/1.7#features", + "id": "463-release-notes/1.7#features", "title": "Zero 1.7", "searchTitle": "Features", "sectionTitle": "Features", @@ -6019,7 +6033,7 @@ "kind": "section" }, { - "id": "463-release-notes/1.7#performance", + "id": "464-release-notes/1.7#performance", "title": "Zero 1.7", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -6029,7 +6043,7 @@ "kind": "section" }, { - "id": "464-release-notes/1.7#replication", + "id": "465-release-notes/1.7#replication", "title": "Zero 1.7", "searchTitle": "Replication", "sectionTitle": "Replication", @@ -6039,7 +6053,7 @@ "kind": "section" }, { - "id": "465-release-notes/1.7#flipped-exists-queries", + "id": "466-release-notes/1.7#flipped-exists-queries", "title": "Zero 1.7", "searchTitle": "Flipped Exists Queries", "sectionTitle": "Flipped Exists Queries", @@ -6049,7 +6063,7 @@ "kind": "section" }, { - "id": "466-release-notes/1.7#fixes", + "id": "467-release-notes/1.7#fixes", "title": "Zero 1.7", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -6059,7 +6073,7 @@ "kind": "section" }, { - "id": "467-release-notes/1.7#breaking-changes", + "id": "468-release-notes/1.7#breaking-changes", "title": "Zero 1.7", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -6115,7 +6129,7 @@ "kind": "page" }, { - "id": "468-release-notes/1.8#installation", + "id": "469-release-notes/1.8#installation", "title": "Zero 1.8", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -6125,7 +6139,7 @@ "kind": "section" }, { - "id": "469-release-notes/1.8#overview", + "id": "470-release-notes/1.8#overview", "title": "Zero 1.8", "searchTitle": "Overview", "sectionTitle": "Overview", @@ -6135,7 +6149,7 @@ "kind": "section" }, { - "id": "470-release-notes/1.8#features", + "id": "471-release-notes/1.8#features", "title": "Zero 1.8", "searchTitle": "Features", "sectionTitle": "Features", @@ -6145,7 +6159,7 @@ "kind": "section" }, { - "id": "471-release-notes/1.8#performance", + "id": "472-release-notes/1.8#performance", "title": "Zero 1.8", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -6155,7 +6169,7 @@ "kind": "section" }, { - "id": "472-release-notes/1.8#replicating-large-transactions", + "id": "473-release-notes/1.8#replicating-large-transactions", "title": "Zero 1.8", "searchTitle": "Replicating Large Transactions", "sectionTitle": "Replicating Large Transactions", @@ -6165,7 +6179,7 @@ "kind": "section" }, { - "id": "473-release-notes/1.8#maintaining-limit-queries", + "id": "474-release-notes/1.8#maintaining-limit-queries", "title": "Zero 1.8", "searchTitle": "Maintaining limit() Queries", "sectionTitle": "Maintaining limit() Queries", @@ -6175,7 +6189,7 @@ "kind": "section" }, { - "id": "474-release-notes/1.8#client-side-hydration", + "id": "475-release-notes/1.8#client-side-hydration", "title": "Zero 1.8", "searchTitle": "Client-Side Hydration", "sectionTitle": "Client-Side Hydration", @@ -6185,7 +6199,7 @@ "kind": "section" }, { - "id": "475-release-notes/1.8#fixes", + "id": "476-release-notes/1.8#fixes", "title": "Zero 1.8", "searchTitle": "Fixes", "sectionTitle": "Fixes", @@ -6195,7 +6209,7 @@ "kind": "section" }, { - "id": "476-release-notes/1.8#breaking-changes", + "id": "477-release-notes/1.8#breaking-changes", "title": "Zero 1.8", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -6209,7 +6223,7 @@ "title": "Zero 1.9", "searchTitle": "Zero 1.9", "url": "/docs/release-notes/1.9", - "content": "Installation npm install @rocicorp/zero@1.9 You can use zero-cache from Docker Hub or GHCR: docker pull rocicorp/zero:1.9.0 # or docker pull ghcr.io/rocicorp/zero:1.9.0 Overview Zero 1.9 improves query correctness and zero-cache reliability. Performance Deferred. Fixes Ordered queries now paginate and maintain windows correctly when cursor fields contain NULL, including compound tie-break fields and reverse walks. This prevents skipped rows, empty windows, and related Bound should be set failures. (thanks @YevheniiKotyrlo!) Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data instead of invoking JavaScript's legacy prototype setter. (thanks @tjenkinson!) Clients now receive changed rows after a server-side query is rebuilt, instead of retaining stale results in a rare rehydration case. zero-cache now bounds its SQLite prepared-statement caches with LRU eviction, preventing unbounded statement retention when applications generate many distinct query shapes. Replication lag reports now retry when an expected report is missing, and serving-lag metrics exclude disconnected or not-yet-validated client groups. See the updated OpenTelemetry metric descriptions. zero-cache now detects and resets PostgreSQL connections that stop carrying wire traffic, including over TLS, allowing work to recover from proxy-created half-open sockets. See Breaking Changes. zero-cache now releases custom-query caches when client groups stop, preventing inactive groups from retaining timers and transformed queries. Breaking Changes PostgreSQL Socket Inactivity Timeout zero-cache now monitors wire activity on its PostgreSQL connections. By default, it checks every two minutes and resets a connection after one to two inactive intervals. This recovers half-open connections, but can interrupt a long-running statement that legitimately produces no network traffic. If legitimate Postgres operations can remain silent for this long, set ZERO_PG_SOCKET_INACTIVITY_TIMEOUT on zero-cache to a longer interval in milliseconds. Set it to 0 to disable the watchdog.", + "content": "Installation npm install @rocicorp/zero@1.9 You can use zero-cache from Docker Hub or GHCR: docker pull rocicorp/zero:1.9.0 # or docker pull ghcr.io/rocicorp/zero:1.9.0 Overview Zero 1.9 improves query and mutation correctness, connection and replica reliability, and operational observability. It also speeds up the first server mutation when schema metadata is not yet cached. Features Litestream v5 restores: The official image now uses Litestream 0.5.15 for restores by default. It can restore legacy WAL or LTX backups, while Zero 1.9 continues writing legacy backups. Legacy snapshots now retain the previous generation for six additional hours, preventing cleanup during an active restore at the cost of temporary backup storage. (#6260, #6267) End-to-end serving lag: New metrics measure completed replicated work from the upstream transaction commit through zero-cache sync output. Upstream clock-skew estimates and clamp counts identify measurements biased by clock differences. (#6312) Performance Cold Mutation Latency On its first mutation, Zero Server fetches and caches PostgreSQL schema metadata. In benchmarks, that full request is 2.9x faster (#6292, thanks @diegopereira99!). The same change resolves types by OID, disambiguating same-named types across schemas. Fixes Ordered queries now paginate and maintain windows correctly when cursor fields contain NULL, including compound tie-break fields and reverse walks. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each, terminated client groups release custom-query timers and caches, and large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log bounded replica and integrity diagnostics and flush logs before exit. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Breaking Changes PostgreSQL Socket Inactivity Timeout zero-cache now checks wire activity on its PostgreSQL connections every two minutes by default and resets a connection after one to two inactive checks. This recovers half-open sockets, but can interrupt a statement that legitimately sends no data for several minutes. Increase ZERO_PG_SOCKET_INACTIVITY_TIMEOUT for workloads that can remain silent longer. Set it to 0 to disable the watchdog. Existing Primary-Key Inserts insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Other unique-constraint violations still fail, and a successful insert does not prove that a row was created. If your application relied on the error, enforce duplicate rejection in an authoritative custom mutator. The PostgreSQL role used for authoritative mutations must also have SELECT access to the primary-key columns. Litestream V5 Restore and Age-Encrypted Backups When a v5 executable is available, as it is in the official image, zero-cache now restores with Litestream 0.5.15 by default. Zero 1.9 continues writing legacy WAL backups, and the v5 restore path can read both legacy and LTX formats. Litestream v0.5 cannot restore legacy backups encrypted with Age. Before upgrading, either migrate those backups or set ZERO_LITESTREAM_RESTORE_USING_V5=false to keep using the legacy restore path. Test custom Litestream configurations in staging. If a future release has written a newer LTX backup, use Zero 1.9 or later with v5 restore enabled as the rollback target; older images cannot restore an LTX-only backup.", "headings": [ { "text": "Installation", @@ -6219,10 +6233,18 @@ "text": "Overview", "id": "overview" }, + { + "text": "Features", + "id": "features" + }, { "text": "Performance", "id": "performance" }, + { + "text": "Cold Mutation Latency", + "id": "cold-mutation-latency" + }, { "text": "Fixes", "id": "fixes" @@ -6234,12 +6256,20 @@ { "text": "PostgreSQL Socket Inactivity Timeout", "id": "postgresql-socket-inactivity-timeout" + }, + { + "text": "Existing Primary-Key Inserts", + "id": "existing-primary-key-inserts" + }, + { + "text": "Litestream V5 Restore and Age-Encrypted Backups", + "id": "litestream-v5-restore-and-age-encrypted-backups" } ], "kind": "page" }, { - "id": "477-release-notes/1.9#installation", + "id": "478-release-notes/1.9#installation", "title": "Zero 1.9", "searchTitle": "Installation", "sectionTitle": "Installation", @@ -6249,53 +6279,93 @@ "kind": "section" }, { - "id": "478-release-notes/1.9#overview", + "id": "479-release-notes/1.9#overview", "title": "Zero 1.9", "searchTitle": "Overview", "sectionTitle": "Overview", "sectionId": "overview", "url": "/docs/release-notes/1.9", - "content": "Zero 1.9 improves query correctness and zero-cache reliability.", + "content": "Zero 1.9 improves query and mutation correctness, connection and replica reliability, and operational observability. It also speeds up the first server mutation when schema metadata is not yet cached.", "kind": "section" }, { - "id": "479-release-notes/1.9#performance", + "id": "480-release-notes/1.9#features", + "title": "Zero 1.9", + "searchTitle": "Features", + "sectionTitle": "Features", + "sectionId": "features", + "url": "/docs/release-notes/1.9", + "content": "Litestream v5 restores: The official image now uses Litestream 0.5.15 for restores by default. It can restore legacy WAL or LTX backups, while Zero 1.9 continues writing legacy backups. Legacy snapshots now retain the previous generation for six additional hours, preventing cleanup during an active restore at the cost of temporary backup storage. (#6260, #6267) End-to-end serving lag: New metrics measure completed replicated work from the upstream transaction commit through zero-cache sync output. Upstream clock-skew estimates and clamp counts identify measurements biased by clock differences. (#6312)", + "kind": "section" + }, + { + "id": "481-release-notes/1.9#performance", "title": "Zero 1.9", "searchTitle": "Performance", "sectionTitle": "Performance", "sectionId": "performance", "url": "/docs/release-notes/1.9", - "content": "Deferred.", + "content": "Cold Mutation Latency On its first mutation, Zero Server fetches and caches PostgreSQL schema metadata. In benchmarks, that full request is 2.9x faster (#6292, thanks @diegopereira99!). The same change resolves types by OID, disambiguating same-named types across schemas.", "kind": "section" }, { - "id": "480-release-notes/1.9#fixes", + "id": "482-release-notes/1.9#cold-mutation-latency", + "title": "Zero 1.9", + "searchTitle": "Cold Mutation Latency", + "sectionTitle": "Cold Mutation Latency", + "sectionId": "cold-mutation-latency", + "url": "/docs/release-notes/1.9", + "content": "On its first mutation, Zero Server fetches and caches PostgreSQL schema metadata. In benchmarks, that full request is 2.9x faster (#6292, thanks @diegopereira99!). The same change resolves types by OID, disambiguating same-named types across schemas.", + "kind": "section" + }, + { + "id": "483-release-notes/1.9#fixes", "title": "Zero 1.9", "searchTitle": "Fixes", "sectionTitle": "Fixes", "sectionId": "fixes", "url": "/docs/release-notes/1.9", - "content": "Ordered queries now paginate and maintain windows correctly when cursor fields contain NULL, including compound tie-break fields and reverse walks. This prevents skipped rows, empty windows, and related Bound should be set failures. (thanks @YevheniiKotyrlo!) Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data instead of invoking JavaScript's legacy prototype setter. (thanks @tjenkinson!) Clients now receive changed rows after a server-side query is rebuilt, instead of retaining stale results in a rare rehydration case. zero-cache now bounds its SQLite prepared-statement caches with LRU eviction, preventing unbounded statement retention when applications generate many distinct query shapes. Replication lag reports now retry when an expected report is missing, and serving-lag metrics exclude disconnected or not-yet-validated client groups. See the updated OpenTelemetry metric descriptions. zero-cache now detects and resets PostgreSQL connections that stop carrying wire traffic, including over TLS, allowing work to recover from proxy-created half-open sockets. See Breaking Changes. zero-cache now releases custom-query caches when client groups stop, preventing inactive groups from retaining timers and transformed queries.", + "content": "Ordered queries now paginate and maintain windows correctly when cursor fields contain NULL, including compound tie-break fields and reverse walks. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each, terminated client groups release custom-query timers and caches, and large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log bounded replica and integrity diagnostics and flush logs before exit. Oversized replication updates now identify the transaction, affected column, and value type without logging the value.", "kind": "section" }, { - "id": "481-release-notes/1.9#breaking-changes", + "id": "484-release-notes/1.9#breaking-changes", "title": "Zero 1.9", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", "sectionId": "breaking-changes", "url": "/docs/release-notes/1.9", - "content": "PostgreSQL Socket Inactivity Timeout zero-cache now monitors wire activity on its PostgreSQL connections. By default, it checks every two minutes and resets a connection after one to two inactive intervals. This recovers half-open connections, but can interrupt a long-running statement that legitimately produces no network traffic. If legitimate Postgres operations can remain silent for this long, set ZERO_PG_SOCKET_INACTIVITY_TIMEOUT on zero-cache to a longer interval in milliseconds. Set it to 0 to disable the watchdog.", + "content": "PostgreSQL Socket Inactivity Timeout zero-cache now checks wire activity on its PostgreSQL connections every two minutes by default and resets a connection after one to two inactive checks. This recovers half-open sockets, but can interrupt a statement that legitimately sends no data for several minutes. Increase ZERO_PG_SOCKET_INACTIVITY_TIMEOUT for workloads that can remain silent longer. Set it to 0 to disable the watchdog. Existing Primary-Key Inserts insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Other unique-constraint violations still fail, and a successful insert does not prove that a row was created. If your application relied on the error, enforce duplicate rejection in an authoritative custom mutator. The PostgreSQL role used for authoritative mutations must also have SELECT access to the primary-key columns. Litestream V5 Restore and Age-Encrypted Backups When a v5 executable is available, as it is in the official image, zero-cache now restores with Litestream 0.5.15 by default. Zero 1.9 continues writing legacy WAL backups, and the v5 restore path can read both legacy and LTX formats. Litestream v0.5 cannot restore legacy backups encrypted with Age. Before upgrading, either migrate those backups or set ZERO_LITESTREAM_RESTORE_USING_V5=false to keep using the legacy restore path. Test custom Litestream configurations in staging. If a future release has written a newer LTX backup, use Zero 1.9 or later with v5 restore enabled as the rollback target; older images cannot restore an LTX-only backup.", "kind": "section" }, { - "id": "482-release-notes/1.9#postgresql-socket-inactivity-timeout", + "id": "485-release-notes/1.9#postgresql-socket-inactivity-timeout", "title": "Zero 1.9", "searchTitle": "PostgreSQL Socket Inactivity Timeout", "sectionTitle": "PostgreSQL Socket Inactivity Timeout", "sectionId": "postgresql-socket-inactivity-timeout", "url": "/docs/release-notes/1.9", - "content": "zero-cache now monitors wire activity on its PostgreSQL connections. By default, it checks every two minutes and resets a connection after one to two inactive intervals. This recovers half-open connections, but can interrupt a long-running statement that legitimately produces no network traffic. If legitimate Postgres operations can remain silent for this long, set ZERO_PG_SOCKET_INACTIVITY_TIMEOUT on zero-cache to a longer interval in milliseconds. Set it to 0 to disable the watchdog.", + "content": "zero-cache now checks wire activity on its PostgreSQL connections every two minutes by default and resets a connection after one to two inactive checks. This recovers half-open sockets, but can interrupt a statement that legitimately sends no data for several minutes. Increase ZERO_PG_SOCKET_INACTIVITY_TIMEOUT for workloads that can remain silent longer. Set it to 0 to disable the watchdog.", + "kind": "section" + }, + { + "id": "486-release-notes/1.9#existing-primary-key-inserts", + "title": "Zero 1.9", + "searchTitle": "Existing Primary-Key Inserts", + "sectionTitle": "Existing Primary-Key Inserts", + "sectionId": "existing-primary-key-inserts", + "url": "/docs/release-notes/1.9", + "content": "insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Other unique-constraint violations still fail, and a successful insert does not prove that a row was created. If your application relied on the error, enforce duplicate rejection in an authoritative custom mutator. The PostgreSQL role used for authoritative mutations must also have SELECT access to the primary-key columns.", + "kind": "section" + }, + { + "id": "487-release-notes/1.9#litestream-v5-restore-and-age-encrypted-backups", + "title": "Zero 1.9", + "searchTitle": "Litestream V5 Restore and Age-Encrypted Backups", + "sectionTitle": "Litestream V5 Restore and Age-Encrypted Backups", + "sectionId": "litestream-v5-restore-and-age-encrypted-backups", + "url": "/docs/release-notes/1.9", + "content": "When a v5 executable is available, as it is in the official image, zero-cache now restores with Litestream 0.5.15 by default. Zero 1.9 continues writing legacy WAL backups, and the v5 restore path can read both legacy and LTX formats. Litestream v0.5 cannot restore legacy backups encrypted with Age. Before upgrading, either migrate those backups or set ZERO_LITESTREAM_RESTORE_USING_V5=false to keep using the legacy restore path. Test custom Litestream configurations in staging. If a future release has written a newer LTX backup, use Zero 1.9 or later with v5 restore enabled as the rollback target; older images cannot restore an LTX-only backup.", "kind": "section" }, { @@ -6326,7 +6396,7 @@ "kind": "page" }, { - "id": "483-reporting-bugs#zbugs", + "id": "488-reporting-bugs#zbugs", "title": "Reporting Bugs", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -6336,7 +6406,7 @@ "kind": "section" }, { - "id": "484-reporting-bugs#discord", + "id": "489-reporting-bugs#discord", "title": "Reporting Bugs", "searchTitle": "Discord", "sectionTitle": "Discord", @@ -6372,7 +6442,7 @@ "kind": "page" }, { - "id": "485-rest#pattern", + "id": "490-rest#pattern", "title": "REST", "searchTitle": "Pattern", "sectionTitle": "Pattern", @@ -6382,7 +6452,7 @@ "kind": "section" }, { - "id": "486-rest#tanstack-start-example", + "id": "491-rest#tanstack-start-example", "title": "REST", "searchTitle": "TanStack Start Example", "sectionTitle": "TanStack Start Example", @@ -6392,7 +6462,7 @@ "kind": "section" }, { - "id": "487-rest#openapi-generation", + "id": "492-rest#openapi-generation", "title": "REST", "searchTitle": "OpenAPI Generation", "sectionTitle": "OpenAPI Generation", @@ -6402,7 +6472,7 @@ "kind": "section" }, { - "id": "488-rest#full-working-example", + "id": "493-rest#full-working-example", "title": "REST", "searchTitle": "Full Working Example", "sectionTitle": "Full Working Example", @@ -6430,7 +6500,7 @@ "kind": "page" }, { - "id": "489-roadmap#q4-2025", + "id": "494-roadmap#q4-2025", "title": "Roadmap", "searchTitle": "Q4 2025", "sectionTitle": "Q4 2025", @@ -6440,7 +6510,7 @@ "kind": "section" }, { - "id": "490-roadmap#beyond", + "id": "495-roadmap#beyond", "title": "Roadmap", "searchTitle": "Beyond", "sectionTitle": "Beyond", @@ -6476,7 +6546,7 @@ "kind": "page" }, { - "id": "491-samples#gigabugs", + "id": "496-samples#gigabugs", "title": "Samples", "searchTitle": "Gigabugs", "sectionTitle": "Gigabugs", @@ -6486,7 +6556,7 @@ "kind": "section" }, { - "id": "492-samples#ztunes", + "id": "497-samples#ztunes", "title": "Samples", "searchTitle": "ztunes", "sectionTitle": "ztunes", @@ -6496,7 +6566,7 @@ "kind": "section" }, { - "id": "493-samples#zslack", + "id": "498-samples#zslack", "title": "Samples", "searchTitle": "zslack", "sectionTitle": "zslack", @@ -6506,7 +6576,7 @@ "kind": "section" }, { - "id": "494-samples#zero-music", + "id": "499-samples#zero-music", "title": "Samples", "searchTitle": "zero-music", "sectionTitle": "zero-music", @@ -6642,7 +6712,7 @@ "kind": "page" }, { - "id": "495-schema#generating-from-database", + "id": "500-schema#generating-from-database", "title": "Zero Schema", "searchTitle": "Generating from Database", "sectionTitle": "Generating from Database", @@ -6652,7 +6722,7 @@ "kind": "section" }, { - "id": "496-schema#writing-by-hand", + "id": "501-schema#writing-by-hand", "title": "Zero Schema", "searchTitle": "Writing by Hand", "sectionTitle": "Writing by Hand", @@ -6662,7 +6732,7 @@ "kind": "section" }, { - "id": "497-schema#table-schemas", + "id": "502-schema#table-schemas", "title": "Zero Schema", "searchTitle": "Table Schemas", "sectionTitle": "Table Schemas", @@ -6672,7 +6742,7 @@ "kind": "section" }, { - "id": "498-schema#name-mapping", + "id": "503-schema#name-mapping", "title": "Zero Schema", "searchTitle": "Name Mapping", "sectionTitle": "Name Mapping", @@ -6682,7 +6752,7 @@ "kind": "section" }, { - "id": "499-schema#multiple-schemas", + "id": "504-schema#multiple-schemas", "title": "Zero Schema", "searchTitle": "Multiple Schemas", "sectionTitle": "Multiple Schemas", @@ -6692,7 +6762,7 @@ "kind": "section" }, { - "id": "500-schema#optional-columns", + "id": "505-schema#optional-columns", "title": "Zero Schema", "searchTitle": "Optional Columns", "sectionTitle": "Optional Columns", @@ -6702,7 +6772,7 @@ "kind": "section" }, { - "id": "501-schema#enumerations", + "id": "506-schema#enumerations", "title": "Zero Schema", "searchTitle": "Enumerations", "sectionTitle": "Enumerations", @@ -6712,7 +6782,7 @@ "kind": "section" }, { - "id": "502-schema#custom-json-types", + "id": "507-schema#custom-json-types", "title": "Zero Schema", "searchTitle": "Custom JSON Types", "sectionTitle": "Custom JSON Types", @@ -6722,7 +6792,7 @@ "kind": "section" }, { - "id": "503-schema#compound-primary-keys", + "id": "508-schema#compound-primary-keys", "title": "Zero Schema", "searchTitle": "Compound Primary Keys", "sectionTitle": "Compound Primary Keys", @@ -6732,7 +6802,7 @@ "kind": "section" }, { - "id": "504-schema#relationships", + "id": "509-schema#relationships", "title": "Zero Schema", "searchTitle": "Relationships", "sectionTitle": "Relationships", @@ -6742,7 +6812,7 @@ "kind": "section" }, { - "id": "505-schema#many-to-many-relationships", + "id": "510-schema#many-to-many-relationships", "title": "Zero Schema", "searchTitle": "Many-to-Many Relationships", "sectionTitle": "Many-to-Many Relationships", @@ -6752,7 +6822,7 @@ "kind": "section" }, { - "id": "506-schema#compound-keys-relationships", + "id": "511-schema#compound-keys-relationships", "title": "Zero Schema", "searchTitle": "Compound Keys Relationships", "sectionTitle": "Compound Keys Relationships", @@ -6762,7 +6832,7 @@ "kind": "section" }, { - "id": "507-schema#circular-relationships", + "id": "512-schema#circular-relationships", "title": "Zero Schema", "searchTitle": "Circular Relationships", "sectionTitle": "Circular Relationships", @@ -6772,7 +6842,7 @@ "kind": "section" }, { - "id": "508-schema#database-schemas", + "id": "513-schema#database-schemas", "title": "Zero Schema", "searchTitle": "Database Schemas", "sectionTitle": "Database Schemas", @@ -6782,7 +6852,7 @@ "kind": "section" }, { - "id": "509-schema#register-schema-type", + "id": "514-schema#register-schema-type", "title": "Zero Schema", "searchTitle": "Register Schema Type", "sectionTitle": "Register Schema Type", @@ -6792,7 +6862,7 @@ "kind": "section" }, { - "id": "510-schema#schema-changes", + "id": "515-schema#schema-changes", "title": "Zero Schema", "searchTitle": "Schema Changes", "sectionTitle": "Schema Changes", @@ -6802,7 +6872,7 @@ "kind": "section" }, { - "id": "511-schema#development", + "id": "516-schema#development", "title": "Zero Schema", "searchTitle": "Development", "sectionTitle": "Development", @@ -6812,7 +6882,7 @@ "kind": "section" }, { - "id": "512-schema#production", + "id": "517-schema#production", "title": "Zero Schema", "searchTitle": "Production", "sectionTitle": "Production", @@ -6822,7 +6892,7 @@ "kind": "section" }, { - "id": "513-schema#expand-changes", + "id": "518-schema#expand-changes", "title": "Zero Schema", "searchTitle": "Expand Changes", "sectionTitle": "Expand Changes", @@ -6832,7 +6902,7 @@ "kind": "section" }, { - "id": "514-schema#contract-changes", + "id": "519-schema#contract-changes", "title": "Zero Schema", "searchTitle": "Contract Changes", "sectionTitle": "Contract Changes", @@ -6842,7 +6912,7 @@ "kind": "section" }, { - "id": "515-schema#compound-changes", + "id": "520-schema#compound-changes", "title": "Zero Schema", "searchTitle": "Compound Changes", "sectionTitle": "Compound Changes", @@ -6852,7 +6922,7 @@ "kind": "section" }, { - "id": "516-schema#examples", + "id": "521-schema#examples", "title": "Zero Schema", "searchTitle": "Examples", "sectionTitle": "Examples", @@ -6862,7 +6932,7 @@ "kind": "section" }, { - "id": "517-schema#adding-a-column", + "id": "522-schema#adding-a-column", "title": "Zero Schema", "searchTitle": "Adding a Column", "sectionTitle": "Adding a Column", @@ -6872,7 +6942,7 @@ "kind": "section" }, { - "id": "518-schema#removing-a-column", + "id": "523-schema#removing-a-column", "title": "Zero Schema", "searchTitle": "Removing a Column", "sectionTitle": "Removing a Column", @@ -6882,7 +6952,7 @@ "kind": "section" }, { - "id": "519-schema#renaming-a-column", + "id": "524-schema#renaming-a-column", "title": "Zero Schema", "searchTitle": "Renaming a Column", "sectionTitle": "Renaming a Column", @@ -6892,7 +6962,7 @@ "kind": "section" }, { - "id": "520-schema#making-a-column-optional", + "id": "525-schema#making-a-column-optional", "title": "Zero Schema", "searchTitle": "Making a Column Optional", "sectionTitle": "Making a Column Optional", @@ -6902,7 +6972,7 @@ "kind": "section" }, { - "id": "521-schema#quick-reference", + "id": "526-schema#quick-reference", "title": "Zero Schema", "searchTitle": "Quick Reference", "sectionTitle": "Quick Reference", @@ -6912,7 +6982,7 @@ "kind": "section" }, { - "id": "522-schema#backfill", + "id": "527-schema#backfill", "title": "Zero Schema", "searchTitle": "Backfill", "sectionTitle": "Backfill", @@ -6922,7 +6992,7 @@ "kind": "section" }, { - "id": "523-schema#monitoring-backfill-progress", + "id": "528-schema#monitoring-backfill-progress", "title": "Zero Schema", "searchTitle": "Monitoring Backfill Progress", "sectionTitle": "Monitoring Backfill Progress", @@ -6936,7 +7006,7 @@ "title": "Self-Hosting Zero", "searchTitle": "Self-Hosting Zero", "url": "/docs/self-host", - "content": "To self-host Zero, you will need to deploy zero-cache, a Postgres database, your frontend, and your API server. Zero-cache is made up of two main components: One or more view-syncers: serving client queries using a SQLite replica. One replication-manager: bridge between the Postgres replication stream and view-syncers. These components have the following characteristics: You will also need to deploy a Postgres database, your frontend, and your API server for the query and mutate endpoints. Before setting up Postgres, read Connecting to Postgres for provider-specific notes. Docker Images The examples below use Docker Hub, but the Zero container image is available from: Docker Hub: rocicorp/zero:{version} GHCR: ghcr.io/rocicorp/zero:{version} Minimum Viable Strategy The simplest way to deploy Zero is to run everything on a single node. This is the least expensive way to run Zero, and it can take you surprisingly far. Here are equivalent single-node configurations for a few common deployment targets: services: zero-cache: image: rocicorp/zero:{version} ports: - 4848:4848 stop_grace_period: 10m environment: # Used for replication from postgres # This *must* be a direct connection (not via pgbouncer) ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records # Use a pooler in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries # Use a pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/replica.db # Password used to access the inspector and /statz ZERO_ADMIN_PASSWORD: pickanewpassword # URLs for your API /query and /mutate endpoints ZERO_QUERY_URL: https://api.example.com/api/zero/query ZERO_MUTATE_URL: https://api.example.com/api/zero/mutate ZERO_ENABLE_CRUD_MUTATIONS: 'false' volumes: - zero-cache-data:/data healthcheck: test: curl -f http://localhost:4848/keepalive interval: 5s start_period: 10m upstream-db: image: postgres:18 environment: POSTGRES_DB: zero POSTGRES_PASSWORD: pass ports: - 5432:5432 command: postgres -c wal_level=logical healthcheck: test: pg_isready interval: 10sapp = \"zero-cache\" primary_region = \"iad\" kill_timeout = 300 [build] image = \"rocicorp/zero:{version}\" [http_service] internal_port = 4848 force_https = true auto_stop_machines = \"off\" min_machines_running = 1 [[http_service.checks]] protocol = \"https\" path = \"/keepalive\" interval = \"5s\" timeout = \"5s\" grace_period = \"10m\" [mounts] source = \"zero_data\" destination = \"/data\" [env] ZERO_UPSTREAM_DB = \"postgresql://postgres:pass@db.internal:5432/zero\" ZERO_CVR_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_CHANGE_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_ADMIN_PASSWORD = \"pickanewpassword\" ZERO_QUERY_URL = \"https://api.example.com/api/zero/query\" ZERO_MUTATE_URL = \"https://api.example.com/api/zero/mutate\" ZERO_ENABLE_CRUD_MUTATIONS = \"false\" ZERO_REPLICA_FILE = \"/data/replica.db\"/// export default $config({ app(input) { return { name: 'zero', home: 'aws', removal: input?.stage === 'production' ? 'retain' : 'remove' } }, async run() { const vpc = new sst.aws.Vpc('ZeroVpc') const cluster = new sst.aws.Cluster('ZeroCluster', { vpc }) const efs = new sst.aws.Efs('ZeroReplicaFs', {vpc}) new sst.aws.Service('ZeroCache', { cluster, image: 'rocicorp/zero:{version}', cpu: '1 vCPU', memory: '2 GB', volumes: [{efs, path: '/data'}], environment: { ZERO_UPSTREAM_DB: 'postgresql://postgres:pass@postgres:5432/zero', ZERO_CVR_DB: 'postgresql://postgres:pass@pgbouncer:5432/zero', ZERO_CHANGE_DB: 'postgresql://postgres:pass@pgbouncer:5432/zero', ZERO_ADMIN_PASSWORD: 'pickanewpassword', ZERO_QUERY_URL: 'https://api.example.com/api/zero/query', ZERO_MUTATE_URL: 'https://api.example.com/api/zero/mutate', ZERO_ENABLE_CRUD_MUTATIONS: 'false', ZERO_REPLICA_FILE: '/data/replica.db' }, health: { command: [ 'CMD-SHELL', 'curl -f http://localhost:4848/keepalive || exit 1' ], startPeriod: '300 seconds' }, loadBalancer: { public: true, ports: [{listen: '80/http', forward: '4848/http'}] }, transform: { service: { healthCheckGracePeriodSeconds: 600 }, target: { healthCheck: { enabled: true, path: '/keepalive', protocol: 'HTTP', interval: 5, timeout: 3, healthyThreshold: 2 } } } }) } })apiVersion: apps/v1 kind: Deployment metadata: name: zero-cache spec: replicas: 1 selector: matchLabels: app: zero-cache template: metadata: labels: app: zero-cache spec: terminationGracePeriodSeconds: 600 containers: - name: zero-cache image: rocicorp/zero:{version} ports: - name: http containerPort: 4848 env: - name: ZERO_UPSTREAM_DB value: postgresql://postgres:pass@postgres:5432/zero - name: ZERO_CVR_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_CHANGE_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_ADMIN_PASSWORD value: pickanewpassword - name: ZERO_QUERY_URL value: https://api.example.com/api/zero/query - name: ZERO_MUTATE_URL value: https://api.example.com/api/zero/mutate - name: ZERO_ENABLE_CRUD_MUTATIONS value: 'false' - name: ZERO_REPLICA_FILE value: /data/replica.db lifecycle: preStop: exec: command: ['sh', '-c', 'sleep 10'] volumeMounts: - name: data mountPath: /data startupProbe: httpGet: path: / port: http periodSeconds: 5 failureThreshold: 120 readinessProbe: httpGet: path: / port: http periodSeconds: 5 livenessProbe: httpGet: path: /keepalive port: http periodSeconds: 10 volumes: - name: data persistentVolumeClaim: claimName: zero-cache-data --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: zero-cache-data spec: accessModes: [ReadWriteOnce] resources: requests: storage: 20Gi These snippets only show the zero-cache side of the deployment. The API behind ZERO_QUERY_URL and ZERO_MUTATE_URL can live anywhere zero-cache can reach. Maximal Strategy Once you reach the limits of the single-node deployment, you can split zero-cache into a multi-node topology. This is more expensive to run, but it gives you more flexibility and scalability. Here are equivalent multi-node configurations for the same topology on a few common deployment targets: services: replication-manager: image: rocicorp/zero:{version} # Do not expose the RM to the public internet - only view-syncers expose: - 4849 stop_grace_period: 10m depends_on: upstream-db: condition: service_healthy environment: ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_REPLICA_FILE: /data/replica.db ZERO_ADMIN_PASSWORD: pickanewpassword ZERO_NUM_SYNC_WORKERS: 0 ZERO_LITESTREAM_BACKUP_URL: s3://acme-zero-backups/v1 volumes: - replication-manager-data:/data healthcheck: test: curl -f http://localhost:4849/keepalive interval: 5s start_period: 10m view-syncer: image: rocicorp/zero:{version} ports: - 4848:4848 stop_grace_period: 10m depends_on: replication-manager: condition: service_healthy environment: ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_REPLICA_FILE: /data/replica.db ZERO_ADMIN_PASSWORD: pickanewpassword ZERO_QUERY_URL: https://api.example.com/api/zero/query ZERO_MUTATE_URL: https://api.example.com/api/zero/mutate ZERO_ENABLE_CRUD_MUTATIONS: 'false' ZERO_CHANGE_STREAMER_URI: ws://replication-manager:4849/ volumes: - view-syncer-data:/data healthcheck: test: curl -f http://localhost:4848/keepalive interval: 5s start_period: 10m upstream-db: image: postgres:18 environment: POSTGRES_DB: zero POSTGRES_PASSWORD: pass ports: - 5432:5432 command: postgres -c wal_level=logical healthcheck: test: pg_isready interval: 10s# replication-manager/fly.toml app = \"zero-replication-manager\" primary_region = \"iad\" kill_timeout = 300 [build] image = \"rocicorp/zero:{version}\" # Do not add [http_service] or [[services]] to this app. The # replication-manager serves Zero's internal replication protocol and should # only be reachable over Fly private networking at: # ws://zero-replication-manager.internal:4849/ # # Since this app does not have [http_service], use a top-level Machine check. [checks] [checks.replication_manager] type = \"http\" port = 4849 path = \"/\" interval = \"5s\" timeout = \"5s\" grace_period = \"10m\" [mounts] source = \"replication_data\" destination = \"/data\" [env] ZERO_UPSTREAM_DB = \"postgresql://postgres:pass@db.internal:5432/zero\" ZERO_CVR_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_CHANGE_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_ADMIN_PASSWORD = \"pickanewpassword\" ZERO_REPLICA_FILE = \"/data/replica.db\" ZERO_NUM_SYNC_WORKERS = \"0\" ZERO_LITESTREAM_BACKUP_URL = \"s3://acme-zero-backups/v1\" # view-syncer/fly.toml app = \"zero-view-syncer\" primary_region = \"iad\" kill_timeout = 300 [build] image = \"rocicorp/zero:{version}\" # If you run more than one view-syncer on Fly, add sticky routing # (for example Fly Replay / replay_cache) so clients stay on one machine. [http_service] internal_port = 4848 force_https = true auto_stop_machines = \"off\" min_machines_running = 1 # View-syncers are public, so their health checks attach to [http_service]. [[http_service.checks]] protocol = \"https\" path = \"/\" interval = \"5s\" timeout = \"5s\" grace_period = \"10m\" [mounts] source = \"view_syncer_data\" destination = \"/data\" [env] ZERO_UPSTREAM_DB = \"postgresql://postgres:pass@db.internal:5432/zero\" ZERO_CVR_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_CHANGE_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_ADMIN_PASSWORD = \"pickanewpassword\" ZERO_QUERY_URL = \"https://api.example.com/api/zero/query\" ZERO_MUTATE_URL = \"https://api.example.com/api/zero/mutate\" ZERO_ENABLE_CRUD_MUTATIONS = \"false\" ZERO_REPLICA_FILE = \"/data/replica.db\" ZERO_CHANGE_STREAMER_URI = \"ws://zero-replication-manager.internal:4849/\"/// export default $config({ app(input) { return { name: 'zero', home: 'aws', removal: input?.stage === 'production' ? 'retain' : 'remove' } }, async run() { const backups = new sst.aws.Bucket('ZeroBackups') const vpc = new sst.aws.Vpc('ZeroVpc') const cluster = new sst.aws.Cluster('ZeroCluster', { vpc }) const commonEnv = { ZERO_UPSTREAM_DB: 'postgresql://postgres:pass@postgres:5432/zero', ZERO_CVR_DB: 'postgresql://postgres:pass@pgbouncer:5432/zero', ZERO_CHANGE_DB: 'postgresql://postgres:pass@pgbouncer:5432/zero', ZERO_ADMIN_PASSWORD: 'pickanewpassword', ZERO_REPLICA_FILE: 'replica.db' } const replicationManager = new sst.aws.Service( 'ReplicationManager', { cluster, image: 'rocicorp/zero:{version}', cpu: '1 vCPU', memory: '2 GB', environment: { ...commonEnv, ZERO_NUM_SYNC_WORKERS: '0', ZERO_LITESTREAM_BACKUP_URL: `s3://${backups.name}/v1` }, health: { command: [ 'CMD-SHELL', 'curl -f http://localhost:4849/keepalive || exit 1' ], startPeriod: '300 seconds' }, loadBalancer: { public: false, ports: [{listen: '80/http', forward: '4849/http'}] }, transform: { service: { healthCheckGracePeriodSeconds: 600 }, target: { healthCheck: { enabled: true, path: '/keepalive', protocol: 'HTTP', interval: 5, timeout: 3, healthyThreshold: 2 } } } } ) new sst.aws.Service( 'ViewSyncer', { cluster, image: 'rocicorp/zero:{version}', cpu: '2 vCPU', memory: '4 GB', environment: { ...commonEnv, ZERO_QUERY_URL: 'https://api.example.com/api/zero/query', ZERO_MUTATE_URL: 'https://api.example.com/api/zero/mutate', ZERO_ENABLE_CRUD_MUTATIONS: 'false', ZERO_CHANGE_STREAMER_URI: replicationManager.url }, health: { command: [ 'CMD-SHELL', 'curl -f http://localhost:4848/keepalive || exit 1' ], startPeriod: '300 seconds' }, loadBalancer: { public: true, ports: [{listen: '80/http', forward: '4848/http'}] }, transform: { service: { healthCheckGracePeriodSeconds: 600 }, target: { healthCheck: { enabled: true, path: '/keepalive', protocol: 'HTTP', interval: 5, timeout: 3, healthyThreshold: 2 }, stickiness: { enabled: true, type: 'lb_cookie', cookieDuration: 120 } } } }, {dependsOn: [replicationManager]} ) } })apiVersion: apps/v1 kind: Deployment metadata: name: replication-manager spec: replicas: 1 selector: matchLabels: app: replication-manager template: metadata: labels: app: replication-manager spec: terminationGracePeriodSeconds: 600 containers: - name: replication-manager image: rocicorp/zero:{version} ports: - name: http containerPort: 4849 env: - name: ZERO_UPSTREAM_DB value: postgresql://postgres:pass@postgres:5432/zero - name: ZERO_CVR_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_CHANGE_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_ADMIN_PASSWORD value: pickanewpassword - name: ZERO_REPLICA_FILE value: /data/replica.db - name: ZERO_NUM_SYNC_WORKERS value: '0' - name: ZERO_LITESTREAM_BACKUP_URL value: s3://acme-zero-backups/v1 volumeMounts: - name: data mountPath: /data startupProbe: httpGet: path: / port: http periodSeconds: 5 failureThreshold: 120 readinessProbe: httpGet: path: / port: http periodSeconds: 5 livenessProbe: httpGet: path: /keepalive port: http periodSeconds: 10 volumes: - name: data emptyDir: {} --- apiVersion: v1 kind: Service metadata: name: replication-manager-service spec: type: ClusterIP selector: app: replication-manager ports: - name: http port: 4849 targetPort: http --- apiVersion: apps/v1 kind: Deployment metadata: name: view-syncer spec: replicas: 2 selector: matchLabels: app: view-syncer template: metadata: labels: app: view-syncer spec: terminationGracePeriodSeconds: 600 containers: - name: view-syncer image: rocicorp/zero:{version} ports: - name: http containerPort: 4848 env: - name: ZERO_UPSTREAM_DB value: postgresql://postgres:pass@postgres:5432/zero - name: ZERO_CVR_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_CHANGE_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_ADMIN_PASSWORD value: pickanewpassword - name: ZERO_QUERY_URL value: https://api.example.com/api/zero/query - name: ZERO_MUTATE_URL value: https://api.example.com/api/zero/mutate - name: ZERO_ENABLE_CRUD_MUTATIONS value: 'false' - name: ZERO_REPLICA_FILE value: /data/replica.db - name: ZERO_CHANGE_STREAMER_URI value: ws://replication-manager-service:4849/ lifecycle: preStop: exec: command: ['sh', '-c', 'sleep 10'] volumeMounts: - name: data mountPath: /data startupProbe: httpGet: path: / port: http periodSeconds: 5 failureThreshold: 120 readinessProbe: httpGet: path: / port: http periodSeconds: 5 livenessProbe: httpGet: path: /keepalive port: http periodSeconds: 10 volumes: - name: data emptyDir: {} --- apiVersion: v1 kind: Service metadata: name: view-syncer-service spec: type: LoadBalancer selector: app: view-syncer sessionAffinity: ClientIP ports: - name: http port: 4848 targetPort: http In multi-node deployments, keep ZERO_LITESTREAM_BACKUP_URL on the replication-manager only and point it at an AWS S3 bucket. The view-syncers in the multi-node topology can be horizontally scaled as needed. If restores or initial syncs take a while, configure your orchestrator to allow a startup grace period before treating startup checks as a failure. Ten minutes is a good default for most apps. For example, Docker Compose uses healthcheck.start_period, Fly.io uses grace_period, and ECS services can use healthCheckGracePeriodSeconds. Increase it if replica restore or initial sync routinely takes longer. Likewise, during deploys, give zero-cache, replication-manager, and view-syncer a generous shutdown grace period so they can finish cleanup and drain websocket connections. Replica Lifecycle Zero-cache is backed by a SQLite replica of your database. The SQLite replica uses upstream Postgres as the source of truth. If the replica is missing or a litestream restore fails, the replication-manager will resync the replica from upstream on the next start. Performance You want to optimize disk IOPS for the serving replica, since this is the file that is read by the view-syncers to run IVM-based queries, and one of the main bottlenecks for query hydration performance. View syncer's IVM is \"hydrate once, then incrementally push diffs\" against the ZQL pipeline, so performance is mostly about: How fast the server can materialize a subscription the first time (hydration). How fast it can keep it up to date (IVM advancement). Different bottlenecks dominate each phase. Hydration SQLite read cost: hydration is essentially \"run the query against the replica and stream all matching rows into the pipeline\", so it's bounded by SQLite scan/index performance + result size. Churn / TTL eviction: if queries get evicted (inactive long enough) and then get re-requested, you pay hydration again. Custom query transform latency: the HTTP request from zero-cache to your API at ZERO_QUERY_URL does transform/authorization for queries, adding network + CPU before hydration starts. IVM advancement Replication throughput: the view-syncer can only advance when the replicator commits and emits version-ready. If upstream replication is behind, query advancement is capped by how fast the replica advances. Change volume per transaction: advancement cost scales with number of changed rows, not number of queries. Circuit breaker behavior: if advancement looks like it'll take longer than rehydrating, zero-cache intentionally aborts and resets pipelines (which trades \"slow incremental\" for \"rehydrate\"). System-level Number of client groups per sync worker: each client group has its own pipelines; CPU and memory per group limits how many can be \"fast\" at once. Since Node is single-threaded, one client group can technically starve other groups. This is handled with time slicing and can be configured with the yield parameters, e.g. ZERO_YIELD_THRESHOLD_MS. SQLite concurrency limits: it's designed here for one writer (replicator) + many concurrent readers (view-syncer snapshots). It scales, but very heavy read workloads can still contend on cache/IO. Network to clients: even if IVM is fast, it can take time to send data over websocket. This can be improved by using CDNs (like CloudFront) that improve routing. Network between services: for a single-region deployment, all services should be colocated. Networking View syncers must be publicly reachable by clients on port 4848. The replication-manager must only be reachable by view-syncers over your private network on port 4849. The replication-manager serves Zero's internal replication protocol. Keep it behind private networking such as a private service address, internal load balancer, or Kubernetes ClusterIP service. The external load balancer for view-syncers must support websockets, and can use the health check at /keepalive to verify view-syncers are healthy. The replication-manager should also have a /keepalive health check, but that check should run through private infrastructure rather than a public load balancer. Sticky Sessions View syncers are designed to be disposable, but since they keep hydrated query pipelines in memory, it's important to try to keep clients connected to the same instance. If a reconnect/refresh lands on a different instance, that instance usually has to rehydrate instead of reusing warm state. If you are seeing a lot of Rehome errors, you may need to enable sticky sessions. Two instances can end up doing redundant hydration/advancement work for the same clientGroupID, and the \"loser\" will eventually force clients to reconnect. Rolling Updates Zero supports zero-downtime updates by rolling out changes in the following order: Upgrade replication-manager and wait for it to start up. Upgrade view-syncers (if they come up before the replication-manager, they'll sit in retry loops until the manager is updated). Update the API servers (your mutate and query endpoints). Update client(s). After most clients have refreshed, run contract migrations to drop or rename obsolete columns/tables. Rolling out Zero version changes and schema changes together is complicated because both require specific ordering, and the ordering depends on the type of schema change. For this reason, we recommend separating the two types of changes into different PRs and deployments. Client/Server Version Compatibility Servers are compatible with any client of same major version, and with clients one major version back. For example, server 2.2.0 is compatible with: Client 2.3.0 (same major version) Client 2.1.0 (same major version) Client 1.0.0 (previous major version) But server 2.2.0 is not compatible with: Client 3.0.0 (next major version) Client 0.1.0 (two major versions back) To upgrade Zero to a new major version, first deploy the new zero-cache, then the new frontend. Configuration The zero-cache image is configured via environment variables. See zero-cache Config for available options.", + "content": "To self-host Zero, you will need to deploy zero-cache, a Postgres database, your frontend, and your API server. Zero-cache is made up of two main components: One or more view-syncers: serving client queries using a SQLite replica. One replication-manager: bridge between the Postgres replication stream and view-syncers. These components have the following characteristics: You will also need to deploy a Postgres database, your frontend, and your API server for the query and mutate endpoints. Before setting up Postgres, read Connecting to Postgres for provider-specific notes. Docker Images The examples below use Docker Hub, but the Zero container image is available from: Docker Hub: rocicorp/zero:{version} GHCR: ghcr.io/rocicorp/zero:{version} Minimum Viable Strategy The simplest way to deploy Zero is to run everything on a single node. This is the least expensive way to run Zero, and it can take you surprisingly far. Here are equivalent single-node configurations for a few common deployment targets: services: zero-cache: image: rocicorp/zero:{version} ports: - 4848:4848 stop_grace_period: 10m environment: # Used for replication from postgres # This *must* be a direct connection (not via pgbouncer) ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records # Use a pooler in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries # Use a pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/replica.db # Password used to access the inspector and /statz ZERO_ADMIN_PASSWORD: pickanewpassword # URLs for your API /query and /mutate endpoints ZERO_QUERY_URL: https://api.example.com/api/zero/query ZERO_MUTATE_URL: https://api.example.com/api/zero/mutate ZERO_ENABLE_CRUD_MUTATIONS: 'false' volumes: - zero-cache-data:/data healthcheck: test: curl -f http://localhost:4848/keepalive interval: 5s start_period: 10m upstream-db: image: postgres:18 environment: POSTGRES_DB: zero POSTGRES_PASSWORD: pass ports: - 5432:5432 command: postgres -c wal_level=logical healthcheck: test: pg_isready interval: 10sapp = \"zero-cache\" primary_region = \"iad\" kill_timeout = 300 [build] image = \"rocicorp/zero:{version}\" [http_service] internal_port = 4848 force_https = true auto_stop_machines = \"off\" min_machines_running = 1 [[http_service.checks]] protocol = \"https\" path = \"/keepalive\" interval = \"5s\" timeout = \"5s\" grace_period = \"10m\" [mounts] source = \"zero_data\" destination = \"/data\" [env] ZERO_UPSTREAM_DB = \"postgresql://postgres:pass@db.internal:5432/zero\" ZERO_CVR_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_CHANGE_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_ADMIN_PASSWORD = \"pickanewpassword\" ZERO_QUERY_URL = \"https://api.example.com/api/zero/query\" ZERO_MUTATE_URL = \"https://api.example.com/api/zero/mutate\" ZERO_ENABLE_CRUD_MUTATIONS = \"false\" ZERO_REPLICA_FILE = \"/data/replica.db\"/// export default $config({ app(input) { return { name: 'zero', home: 'aws', removal: input?.stage === 'production' ? 'retain' : 'remove' } }, async run() { const vpc = new sst.aws.Vpc('ZeroVpc') const cluster = new sst.aws.Cluster('ZeroCluster', { vpc }) const efs = new sst.aws.Efs('ZeroReplicaFs', {vpc}) new sst.aws.Service('ZeroCache', { cluster, image: 'rocicorp/zero:{version}', cpu: '1 vCPU', memory: '2 GB', volumes: [{efs, path: '/data'}], environment: { ZERO_UPSTREAM_DB: 'postgresql://postgres:pass@postgres:5432/zero', ZERO_CVR_DB: 'postgresql://postgres:pass@pgbouncer:5432/zero', ZERO_CHANGE_DB: 'postgresql://postgres:pass@pgbouncer:5432/zero', ZERO_ADMIN_PASSWORD: 'pickanewpassword', ZERO_QUERY_URL: 'https://api.example.com/api/zero/query', ZERO_MUTATE_URL: 'https://api.example.com/api/zero/mutate', ZERO_ENABLE_CRUD_MUTATIONS: 'false', ZERO_REPLICA_FILE: '/data/replica.db' }, health: { command: [ 'CMD-SHELL', 'curl -f http://localhost:4848/keepalive || exit 1' ], startPeriod: '300 seconds' }, loadBalancer: { public: true, ports: [{listen: '80/http', forward: '4848/http'}] }, transform: { service: { healthCheckGracePeriodSeconds: 600 }, target: { healthCheck: { enabled: true, path: '/keepalive', protocol: 'HTTP', interval: 5, timeout: 3, healthyThreshold: 2 } } } }) } })apiVersion: apps/v1 kind: Deployment metadata: name: zero-cache spec: replicas: 1 selector: matchLabels: app: zero-cache template: metadata: labels: app: zero-cache spec: terminationGracePeriodSeconds: 600 containers: - name: zero-cache image: rocicorp/zero:{version} ports: - name: http containerPort: 4848 env: - name: ZERO_UPSTREAM_DB value: postgresql://postgres:pass@postgres:5432/zero - name: ZERO_CVR_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_CHANGE_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_ADMIN_PASSWORD value: pickanewpassword - name: ZERO_QUERY_URL value: https://api.example.com/api/zero/query - name: ZERO_MUTATE_URL value: https://api.example.com/api/zero/mutate - name: ZERO_ENABLE_CRUD_MUTATIONS value: 'false' - name: ZERO_REPLICA_FILE value: /data/replica.db lifecycle: preStop: exec: command: ['sh', '-c', 'sleep 10'] volumeMounts: - name: data mountPath: /data startupProbe: httpGet: path: / port: http periodSeconds: 5 failureThreshold: 120 readinessProbe: httpGet: path: / port: http periodSeconds: 5 livenessProbe: httpGet: path: /keepalive port: http periodSeconds: 10 volumes: - name: data persistentVolumeClaim: claimName: zero-cache-data --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: zero-cache-data spec: accessModes: [ReadWriteOnce] resources: requests: storage: 20Gi These snippets only show the zero-cache side of the deployment. The API behind ZERO_QUERY_URL and ZERO_MUTATE_URL can live anywhere zero-cache can reach. Maximal Strategy Once you reach the limits of the single-node deployment, you can split zero-cache into a multi-node topology. This is more expensive to run, but it gives you more flexibility and scalability. Here are equivalent multi-node configurations for the same topology on a few common deployment targets: services: replication-manager: image: rocicorp/zero:{version} # Do not expose the RM to the public internet - only view-syncers expose: - 4849 stop_grace_period: 10m depends_on: upstream-db: condition: service_healthy environment: ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_REPLICA_FILE: /data/replica.db ZERO_ADMIN_PASSWORD: pickanewpassword ZERO_NUM_SYNC_WORKERS: 0 ZERO_LITESTREAM_BACKUP_URL: s3://acme-zero-backups/v1 volumes: - replication-manager-data:/data healthcheck: test: curl -f http://localhost:4849/keepalive interval: 5s start_period: 10m view-syncer: image: rocicorp/zero:{version} ports: - 4848:4848 stop_grace_period: 10m depends_on: replication-manager: condition: service_healthy environment: ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_REPLICA_FILE: /data/replica.db ZERO_ADMIN_PASSWORD: pickanewpassword ZERO_QUERY_URL: https://api.example.com/api/zero/query ZERO_MUTATE_URL: https://api.example.com/api/zero/mutate ZERO_ENABLE_CRUD_MUTATIONS: 'false' ZERO_CHANGE_STREAMER_URI: ws://replication-manager:4849/ volumes: - view-syncer-data:/data healthcheck: test: curl -f http://localhost:4848/keepalive interval: 5s start_period: 10m upstream-db: image: postgres:18 environment: POSTGRES_DB: zero POSTGRES_PASSWORD: pass ports: - 5432:5432 command: postgres -c wal_level=logical healthcheck: test: pg_isready interval: 10s# replication-manager/fly.toml app = \"zero-replication-manager\" primary_region = \"iad\" kill_timeout = 300 [build] image = \"rocicorp/zero:{version}\" # Do not add [http_service] or [[services]] to this app. The # replication-manager serves Zero's internal replication protocol and should # only be reachable over Fly private networking at: # ws://zero-replication-manager.internal:4849/ # # Since this app does not have [http_service], use a top-level Machine check. [checks] [checks.replication_manager] type = \"http\" port = 4849 path = \"/\" interval = \"5s\" timeout = \"5s\" grace_period = \"10m\" [mounts] source = \"replication_data\" destination = \"/data\" [env] ZERO_UPSTREAM_DB = \"postgresql://postgres:pass@db.internal:5432/zero\" ZERO_CVR_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_CHANGE_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_ADMIN_PASSWORD = \"pickanewpassword\" ZERO_REPLICA_FILE = \"/data/replica.db\" ZERO_NUM_SYNC_WORKERS = \"0\" ZERO_LITESTREAM_BACKUP_URL = \"s3://acme-zero-backups/v1\" # view-syncer/fly.toml app = \"zero-view-syncer\" primary_region = \"iad\" kill_timeout = 300 [build] image = \"rocicorp/zero:{version}\" # If you run more than one view-syncer on Fly, add sticky routing # (for example Fly Replay / replay_cache) so clients stay on one machine. [http_service] internal_port = 4848 force_https = true auto_stop_machines = \"off\" min_machines_running = 1 # View-syncers are public, so their health checks attach to [http_service]. [[http_service.checks]] protocol = \"https\" path = \"/\" interval = \"5s\" timeout = \"5s\" grace_period = \"10m\" [mounts] source = \"view_syncer_data\" destination = \"/data\" [env] ZERO_UPSTREAM_DB = \"postgresql://postgres:pass@db.internal:5432/zero\" ZERO_CVR_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_CHANGE_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_ADMIN_PASSWORD = \"pickanewpassword\" ZERO_QUERY_URL = \"https://api.example.com/api/zero/query\" ZERO_MUTATE_URL = \"https://api.example.com/api/zero/mutate\" ZERO_ENABLE_CRUD_MUTATIONS = \"false\" ZERO_REPLICA_FILE = \"/data/replica.db\" ZERO_CHANGE_STREAMER_URI = \"ws://zero-replication-manager.internal:4849/\"/// export default $config({ app(input) { return { name: 'zero', home: 'aws', removal: input?.stage === 'production' ? 'retain' : 'remove' } }, async run() { const backups = new sst.aws.Bucket('ZeroBackups') const vpc = new sst.aws.Vpc('ZeroVpc') const cluster = new sst.aws.Cluster('ZeroCluster', { vpc }) const commonEnv = { ZERO_UPSTREAM_DB: 'postgresql://postgres:pass@postgres:5432/zero', ZERO_CVR_DB: 'postgresql://postgres:pass@pgbouncer:5432/zero', ZERO_CHANGE_DB: 'postgresql://postgres:pass@pgbouncer:5432/zero', ZERO_ADMIN_PASSWORD: 'pickanewpassword', ZERO_REPLICA_FILE: 'replica.db' } const replicationManager = new sst.aws.Service( 'ReplicationManager', { cluster, image: 'rocicorp/zero:{version}', cpu: '1 vCPU', memory: '2 GB', environment: { ...commonEnv, ZERO_NUM_SYNC_WORKERS: '0', ZERO_LITESTREAM_BACKUP_URL: `s3://${backups.name}/v1` }, health: { command: [ 'CMD-SHELL', 'curl -f http://localhost:4849/keepalive || exit 1' ], startPeriod: '300 seconds' }, loadBalancer: { public: false, ports: [{listen: '80/http', forward: '4849/http'}] }, transform: { service: { healthCheckGracePeriodSeconds: 600 }, target: { healthCheck: { enabled: true, path: '/keepalive', protocol: 'HTTP', interval: 5, timeout: 3, healthyThreshold: 2 } } } } ) new sst.aws.Service( 'ViewSyncer', { cluster, image: 'rocicorp/zero:{version}', cpu: '2 vCPU', memory: '4 GB', environment: { ...commonEnv, ZERO_QUERY_URL: 'https://api.example.com/api/zero/query', ZERO_MUTATE_URL: 'https://api.example.com/api/zero/mutate', ZERO_ENABLE_CRUD_MUTATIONS: 'false', ZERO_CHANGE_STREAMER_URI: replicationManager.url }, health: { command: [ 'CMD-SHELL', 'curl -f http://localhost:4848/keepalive || exit 1' ], startPeriod: '300 seconds' }, loadBalancer: { public: true, ports: [{listen: '80/http', forward: '4848/http'}] }, transform: { service: { healthCheckGracePeriodSeconds: 600 }, target: { healthCheck: { enabled: true, path: '/keepalive', protocol: 'HTTP', interval: 5, timeout: 3, healthyThreshold: 2 }, stickiness: { enabled: true, type: 'lb_cookie', cookieDuration: 120 } } } }, {dependsOn: [replicationManager]} ) } })apiVersion: apps/v1 kind: Deployment metadata: name: replication-manager spec: replicas: 1 selector: matchLabels: app: replication-manager template: metadata: labels: app: replication-manager spec: terminationGracePeriodSeconds: 600 containers: - name: replication-manager image: rocicorp/zero:{version} ports: - name: http containerPort: 4849 env: - name: ZERO_UPSTREAM_DB value: postgresql://postgres:pass@postgres:5432/zero - name: ZERO_CVR_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_CHANGE_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_ADMIN_PASSWORD value: pickanewpassword - name: ZERO_REPLICA_FILE value: /data/replica.db - name: ZERO_NUM_SYNC_WORKERS value: '0' - name: ZERO_LITESTREAM_BACKUP_URL value: s3://acme-zero-backups/v1 volumeMounts: - name: data mountPath: /data startupProbe: httpGet: path: / port: http periodSeconds: 5 failureThreshold: 120 readinessProbe: httpGet: path: / port: http periodSeconds: 5 livenessProbe: httpGet: path: /keepalive port: http periodSeconds: 10 volumes: - name: data emptyDir: {} --- apiVersion: v1 kind: Service metadata: name: replication-manager-service spec: type: ClusterIP selector: app: replication-manager ports: - name: http port: 4849 targetPort: http --- apiVersion: apps/v1 kind: Deployment metadata: name: view-syncer spec: replicas: 2 selector: matchLabels: app: view-syncer template: metadata: labels: app: view-syncer spec: terminationGracePeriodSeconds: 600 containers: - name: view-syncer image: rocicorp/zero:{version} ports: - name: http containerPort: 4848 env: - name: ZERO_UPSTREAM_DB value: postgresql://postgres:pass@postgres:5432/zero - name: ZERO_CVR_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_CHANGE_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_ADMIN_PASSWORD value: pickanewpassword - name: ZERO_QUERY_URL value: https://api.example.com/api/zero/query - name: ZERO_MUTATE_URL value: https://api.example.com/api/zero/mutate - name: ZERO_ENABLE_CRUD_MUTATIONS value: 'false' - name: ZERO_REPLICA_FILE value: /data/replica.db - name: ZERO_CHANGE_STREAMER_URI value: ws://replication-manager-service:4849/ lifecycle: preStop: exec: command: ['sh', '-c', 'sleep 10'] volumeMounts: - name: data mountPath: /data startupProbe: httpGet: path: / port: http periodSeconds: 5 failureThreshold: 120 readinessProbe: httpGet: path: / port: http periodSeconds: 5 livenessProbe: httpGet: path: /keepalive port: http periodSeconds: 10 volumes: - name: data emptyDir: {} --- apiVersion: v1 kind: Service metadata: name: view-syncer-service spec: type: LoadBalancer selector: app: view-syncer sessionAffinity: ClientIP ports: - name: http port: 4848 targetPort: http In multi-node deployments, keep ZERO_LITESTREAM_BACKUP_URL on the replication-manager only and point it at an AWS S3 bucket. The view-syncers in the multi-node topology can be horizontally scaled as needed. If restores or initial syncs take a while, configure your orchestrator to allow a startup grace period before treating startup checks as a failure. Ten minutes is a good default for most apps. For example, Docker Compose uses healthcheck.start_period, Fly.io uses grace_period, and ECS services can use healthCheckGracePeriodSeconds. Increase it if replica restore or initial sync routinely takes longer. Likewise, during deploys, give zero-cache, replication-manager, and view-syncer a generous shutdown grace period so they can finish cleanup and drain websocket connections. Replica Lifecycle Zero-cache is backed by a SQLite replica of your database. The SQLite replica uses upstream Postgres as the source of truth. If the replica is missing or a litestream restore fails, the replication-manager will resync the replica from upstream on the next start. Litestream Compatibility The official image restores with Litestream v5 but still writes legacy WAL backups. V5 reads both WAL and LTX; use ZERO_LITESTREAM_RESTORE_USING_V5=false for Age-encrypted backups. Test custom configurations in staging. Before writing LTX backups, enable v5 restore everywhere. Afterward, roll back only to Zero 1.9 or later with v5 restore enabled. Performance You want to optimize disk IOPS for the serving replica, since this is the file that is read by the view-syncers to run IVM-based queries, and one of the main bottlenecks for query hydration performance. View syncer's IVM is \"hydrate once, then incrementally push diffs\" against the ZQL pipeline, so performance is mostly about: How fast the server can materialize a subscription the first time (hydration). How fast it can keep it up to date (IVM advancement). Different bottlenecks dominate each phase. Hydration SQLite read cost: hydration is essentially \"run the query against the replica and stream all matching rows into the pipeline\", so it's bounded by SQLite scan/index performance + result size. Churn / TTL eviction: if queries get evicted (inactive long enough) and then get re-requested, you pay hydration again. Custom query transform latency: the HTTP request from zero-cache to your API at ZERO_QUERY_URL does transform/authorization for queries, adding network + CPU before hydration starts. IVM advancement Replication throughput: the view-syncer can only advance when the replicator commits and emits version-ready. If upstream replication is behind, query advancement is capped by how fast the replica advances. Change volume per transaction: advancement cost scales with number of changed rows, not number of queries. Circuit breaker behavior: if advancement looks like it'll take longer than rehydrating, zero-cache intentionally aborts and resets pipelines (which trades \"slow incremental\" for \"rehydrate\"). System-level Number of client groups per sync worker: each client group has its own pipelines; CPU and memory per group limits how many can be \"fast\" at once. Since Node is single-threaded, one client group can technically starve other groups. This is handled with time slicing and can be configured with the yield parameters, e.g. ZERO_YIELD_THRESHOLD_MS. SQLite concurrency limits: it's designed here for one writer (replicator) + many concurrent readers (view-syncer snapshots). It scales, but very heavy read workloads can still contend on cache/IO. Network to clients: even if IVM is fast, it can take time to send data over websocket. This can be improved by using CDNs (like CloudFront) that improve routing. Network between services: for a single-region deployment, all services should be colocated. Networking View syncers must be publicly reachable by clients on port 4848. The replication-manager must only be reachable by view-syncers over your private network on port 4849. The replication-manager serves Zero's internal replication protocol. Keep it behind private networking such as a private service address, internal load balancer, or Kubernetes ClusterIP service. The external load balancer for view-syncers must support websockets, and can use the health check at /keepalive to verify view-syncers are healthy. The replication-manager should also have a /keepalive health check, but that check should run through private infrastructure rather than a public load balancer. Sticky Sessions View syncers are designed to be disposable, but since they keep hydrated query pipelines in memory, it's important to try to keep clients connected to the same instance. If a reconnect/refresh lands on a different instance, that instance usually has to rehydrate instead of reusing warm state. If you are seeing a lot of Rehome errors, you may need to enable sticky sessions. Two instances can end up doing redundant hydration/advancement work for the same clientGroupID, and the \"loser\" will eventually force clients to reconnect. Rolling Updates Zero supports zero-downtime updates by rolling out changes in the following order: Upgrade replication-manager and wait for it to start up. Upgrade view-syncers (if they come up before the replication-manager, they'll sit in retry loops until the manager is updated). Update the API servers (your mutate and query endpoints). Update client(s). After most clients have refreshed, run contract migrations to drop or rename obsolete columns/tables. Rolling out Zero version changes and schema changes together is complicated because both require specific ordering, and the ordering depends on the type of schema change. For this reason, we recommend separating the two types of changes into different PRs and deployments. Client/Server Version Compatibility Servers are compatible with any client of same major version, and with clients one major version back. For example, server 2.2.0 is compatible with: Client 2.3.0 (same major version) Client 2.1.0 (same major version) Client 1.0.0 (previous major version) But server 2.2.0 is not compatible with: Client 3.0.0 (next major version) Client 0.1.0 (two major versions back) To upgrade Zero to a new major version, first deploy the new zero-cache, then the new frontend. Configuration The zero-cache image is configured via environment variables. See zero-cache Config for available options.", "headings": [ { "text": "Docker Images", @@ -6954,6 +7024,10 @@ "text": "Replica Lifecycle", "id": "replica-lifecycle" }, + { + "text": "Litestream Compatibility", + "id": "litestream-compatibility" + }, { "text": "Performance", "id": "performance" @@ -6994,7 +7068,7 @@ "kind": "page" }, { - "id": "524-self-host#docker-images", + "id": "529-self-host#docker-images", "title": "Self-Hosting Zero", "searchTitle": "Docker Images", "sectionTitle": "Docker Images", @@ -7004,7 +7078,7 @@ "kind": "section" }, { - "id": "525-self-host#minimum-viable-strategy", + "id": "530-self-host#minimum-viable-strategy", "title": "Self-Hosting Zero", "searchTitle": "Minimum Viable Strategy", "sectionTitle": "Minimum Viable Strategy", @@ -7014,7 +7088,7 @@ "kind": "section" }, { - "id": "526-self-host#maximal-strategy", + "id": "531-self-host#maximal-strategy", "title": "Self-Hosting Zero", "searchTitle": "Maximal Strategy", "sectionTitle": "Maximal Strategy", @@ -7024,17 +7098,27 @@ "kind": "section" }, { - "id": "527-self-host#replica-lifecycle", + "id": "532-self-host#replica-lifecycle", "title": "Self-Hosting Zero", "searchTitle": "Replica Lifecycle", "sectionTitle": "Replica Lifecycle", "sectionId": "replica-lifecycle", "url": "/docs/self-host", - "content": "Zero-cache is backed by a SQLite replica of your database. The SQLite replica uses upstream Postgres as the source of truth. If the replica is missing or a litestream restore fails, the replication-manager will resync the replica from upstream on the next start.", + "content": "Zero-cache is backed by a SQLite replica of your database. The SQLite replica uses upstream Postgres as the source of truth. If the replica is missing or a litestream restore fails, the replication-manager will resync the replica from upstream on the next start. Litestream Compatibility The official image restores with Litestream v5 but still writes legacy WAL backups. V5 reads both WAL and LTX; use ZERO_LITESTREAM_RESTORE_USING_V5=false for Age-encrypted backups. Test custom configurations in staging. Before writing LTX backups, enable v5 restore everywhere. Afterward, roll back only to Zero 1.9 or later with v5 restore enabled.", "kind": "section" }, { - "id": "528-self-host#performance", + "id": "533-self-host#litestream-compatibility", + "title": "Self-Hosting Zero", + "searchTitle": "Litestream Compatibility", + "sectionTitle": "Litestream Compatibility", + "sectionId": "litestream-compatibility", + "url": "/docs/self-host", + "content": "The official image restores with Litestream v5 but still writes legacy WAL backups. V5 reads both WAL and LTX; use ZERO_LITESTREAM_RESTORE_USING_V5=false for Age-encrypted backups. Test custom configurations in staging. Before writing LTX backups, enable v5 restore everywhere. Afterward, roll back only to Zero 1.9 or later with v5 restore enabled.", + "kind": "section" + }, + { + "id": "534-self-host#performance", "title": "Self-Hosting Zero", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -7044,7 +7128,7 @@ "kind": "section" }, { - "id": "529-self-host#hydration", + "id": "535-self-host#hydration", "title": "Self-Hosting Zero", "searchTitle": "Hydration", "sectionTitle": "Hydration", @@ -7054,7 +7138,7 @@ "kind": "section" }, { - "id": "530-self-host#ivm-advancement", + "id": "536-self-host#ivm-advancement", "title": "Self-Hosting Zero", "searchTitle": "IVM advancement", "sectionTitle": "IVM advancement", @@ -7064,7 +7148,7 @@ "kind": "section" }, { - "id": "531-self-host#system-level", + "id": "537-self-host#system-level", "title": "Self-Hosting Zero", "searchTitle": "System-level", "sectionTitle": "System-level", @@ -7074,7 +7158,7 @@ "kind": "section" }, { - "id": "532-self-host#networking", + "id": "538-self-host#networking", "title": "Self-Hosting Zero", "searchTitle": "Networking", "sectionTitle": "Networking", @@ -7084,7 +7168,7 @@ "kind": "section" }, { - "id": "533-self-host#sticky-sessions", + "id": "539-self-host#sticky-sessions", "title": "Self-Hosting Zero", "searchTitle": "Sticky Sessions", "sectionTitle": "Sticky Sessions", @@ -7094,7 +7178,7 @@ "kind": "section" }, { - "id": "534-self-host#rolling-updates", + "id": "540-self-host#rolling-updates", "title": "Self-Hosting Zero", "searchTitle": "Rolling Updates", "sectionTitle": "Rolling Updates", @@ -7104,7 +7188,7 @@ "kind": "section" }, { - "id": "535-self-host#clientserver-version-compatibility", + "id": "541-self-host#clientserver-version-compatibility", "title": "Self-Hosting Zero", "searchTitle": "Client/Server Version Compatibility", "sectionTitle": "Client/Server Version Compatibility", @@ -7114,7 +7198,7 @@ "kind": "section" }, { - "id": "536-self-host#configuration", + "id": "542-self-host#configuration", "title": "Self-Hosting Zero", "searchTitle": "Configuration", "sectionTitle": "Configuration", @@ -7150,7 +7234,7 @@ "kind": "page" }, { - "id": "537-server-zql#creating-a-database", + "id": "543-server-zql#creating-a-database", "title": "ZQL on the Server", "searchTitle": "Creating a Database", "sectionTitle": "Creating a Database", @@ -7160,7 +7244,7 @@ "kind": "section" }, { - "id": "538-server-zql#custom-database", + "id": "544-server-zql#custom-database", "title": "ZQL on the Server", "searchTitle": "Custom Database", "sectionTitle": "Custom Database", @@ -7170,7 +7254,7 @@ "kind": "section" }, { - "id": "539-server-zql#running-zql", + "id": "545-server-zql#running-zql", "title": "ZQL on the Server", "searchTitle": "Running ZQL", "sectionTitle": "Running ZQL", @@ -7180,7 +7264,7 @@ "kind": "section" }, { - "id": "540-server-zql#ssr", + "id": "546-server-zql#ssr", "title": "ZQL on the Server", "searchTitle": "SSR", "sectionTitle": "SSR", @@ -7212,7 +7296,7 @@ "kind": "page" }, { - "id": "541-solidjs#setup", + "id": "547-solidjs#setup", "title": "SolidJS", "searchTitle": "Setup", "sectionTitle": "Setup", @@ -7222,7 +7306,7 @@ "kind": "section" }, { - "id": "542-solidjs#usage", + "id": "548-solidjs#usage", "title": "SolidJS", "searchTitle": "Usage", "sectionTitle": "Usage", @@ -7232,7 +7316,7 @@ "kind": "section" }, { - "id": "543-solidjs#examples", + "id": "549-solidjs#examples", "title": "SolidJS", "searchTitle": "Examples", "sectionTitle": "Examples", @@ -7268,7 +7352,7 @@ "kind": "page" }, { - "id": "544-status#breaking-changes", + "id": "550-status#breaking-changes", "title": "Project Status", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -7278,7 +7362,7 @@ "kind": "section" }, { - "id": "545-status#roadmap", + "id": "551-status#roadmap", "title": "Project Status", "searchTitle": "Roadmap", "sectionTitle": "Roadmap", @@ -7288,7 +7372,7 @@ "kind": "section" }, { - "id": "546-status#2026", + "id": "552-status#2026", "title": "Project Status", "searchTitle": "2026", "sectionTitle": "2026", @@ -7298,7 +7382,7 @@ "kind": "section" }, { - "id": "547-status#soon", + "id": "553-status#soon", "title": "Project Status", "searchTitle": "Soon", "sectionTitle": "Soon", @@ -7330,7 +7414,7 @@ "kind": "page" }, { - "id": "548-sync#problem", + "id": "554-sync#problem", "title": "What is Sync?", "searchTitle": "Problem", "sectionTitle": "Problem", @@ -7340,7 +7424,7 @@ "kind": "section" }, { - "id": "549-sync#solution", + "id": "555-sync#solution", "title": "What is Sync?", "searchTitle": "Solution", "sectionTitle": "Solution", @@ -7350,7 +7434,7 @@ "kind": "section" }, { - "id": "550-sync#history-of-sync", + "id": "556-sync#history-of-sync", "title": "What is Sync?", "searchTitle": "History of Sync", "sectionTitle": "History of Sync", @@ -7434,7 +7518,7 @@ "kind": "page" }, { - "id": "551-tutorial#setup", + "id": "557-tutorial#setup", "title": "Tutorial", "searchTitle": "Setup", "sectionTitle": "Setup", @@ -7444,7 +7528,7 @@ "kind": "section" }, { - "id": "552-tutorial#create-a-project", + "id": "558-tutorial#create-a-project", "title": "Tutorial", "searchTitle": "Create a Project", "sectionTitle": "Create a Project", @@ -7454,7 +7538,7 @@ "kind": "section" }, { - "id": "553-tutorial#set-up-your-database", + "id": "559-tutorial#set-up-your-database", "title": "Tutorial", "searchTitle": "Set Up Your Database", "sectionTitle": "Set Up Your Database", @@ -7464,7 +7548,7 @@ "kind": "section" }, { - "id": "554-tutorial#install-and-run-zero-cache", + "id": "560-tutorial#install-and-run-zero-cache", "title": "Tutorial", "searchTitle": "Install and Run Zero-Cache", "sectionTitle": "Install and Run Zero-Cache", @@ -7474,7 +7558,7 @@ "kind": "section" }, { - "id": "555-tutorial#integrate-zero", + "id": "561-tutorial#integrate-zero", "title": "Tutorial", "searchTitle": "Integrate Zero", "sectionTitle": "Integrate Zero", @@ -7484,7 +7568,7 @@ "kind": "section" }, { - "id": "556-tutorial#set-up-your-zero-schema", + "id": "562-tutorial#set-up-your-zero-schema", "title": "Tutorial", "searchTitle": "Set Up Your Zero Schema", "sectionTitle": "Set Up Your Zero Schema", @@ -7494,7 +7578,7 @@ "kind": "section" }, { - "id": "557-tutorial#set-up-the-zero-client", + "id": "563-tutorial#set-up-the-zero-client", "title": "Tutorial", "searchTitle": "Set Up the Zero Client", "sectionTitle": "Set Up the Zero Client", @@ -7504,7 +7588,7 @@ "kind": "section" }, { - "id": "558-tutorial#sync-data", + "id": "564-tutorial#sync-data", "title": "Tutorial", "searchTitle": "Sync Data", "sectionTitle": "Sync Data", @@ -7514,7 +7598,7 @@ "kind": "section" }, { - "id": "559-tutorial#define-query", + "id": "565-tutorial#define-query", "title": "Tutorial", "searchTitle": "Define Query", "sectionTitle": "Define Query", @@ -7524,7 +7608,7 @@ "kind": "section" }, { - "id": "560-tutorial#add-query-endpoint", + "id": "566-tutorial#add-query-endpoint", "title": "Tutorial", "searchTitle": "Add Query Endpoint", "sectionTitle": "Add Query Endpoint", @@ -7534,7 +7618,7 @@ "kind": "section" }, { - "id": "561-tutorial#invoke-query", + "id": "567-tutorial#invoke-query", "title": "Tutorial", "searchTitle": "Invoke Query", "sectionTitle": "Invoke Query", @@ -7544,7 +7628,7 @@ "kind": "section" }, { - "id": "562-tutorial#mutate-data", + "id": "568-tutorial#mutate-data", "title": "Tutorial", "searchTitle": "Mutate Data", "sectionTitle": "Mutate Data", @@ -7554,7 +7638,7 @@ "kind": "section" }, { - "id": "563-tutorial#define-mutators", + "id": "569-tutorial#define-mutators", "title": "Tutorial", "searchTitle": "Define Mutators", "sectionTitle": "Define Mutators", @@ -7564,7 +7648,7 @@ "kind": "section" }, { - "id": "564-tutorial#add-mutate-endpoint", + "id": "570-tutorial#add-mutate-endpoint", "title": "Tutorial", "searchTitle": "Add Mutate Endpoint", "sectionTitle": "Add Mutate Endpoint", @@ -7574,7 +7658,7 @@ "kind": "section" }, { - "id": "565-tutorial#invoke-mutators", + "id": "571-tutorial#invoke-mutators", "title": "Tutorial", "searchTitle": "Invoke Mutators", "sectionTitle": "Invoke Mutators", @@ -7584,7 +7668,7 @@ "kind": "section" }, { - "id": "566-tutorial#next-steps", + "id": "572-tutorial#next-steps", "title": "Tutorial", "searchTitle": "Next Steps", "sectionTitle": "Next Steps", @@ -7660,7 +7744,7 @@ "kind": "page" }, { - "id": "567-when-to-use#zero-might-be-a-good-fit", + "id": "573-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", @@ -7670,7 +7754,7 @@ "kind": "section" }, { - "id": "568-when-to-use#you-want-to-sync-only-a-small-subset-of-data-to-client", + "id": "574-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", @@ -7680,7 +7764,7 @@ "kind": "section" }, { - "id": "569-when-to-use#you-need-fine-grained-read-or-write-permissions", + "id": "575-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", @@ -7690,7 +7774,7 @@ "kind": "section" }, { - "id": "570-when-to-use#you-are-building-a-traditional-client-server-web-app", + "id": "576-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", @@ -7700,7 +7784,7 @@ "kind": "section" }, { - "id": "571-when-to-use#you-use-postgresql", + "id": "577-when-to-use#you-use-postgresql", "title": "When To Use Zero", "searchTitle": "You use PostgreSQL", "sectionTitle": "You use PostgreSQL", @@ -7710,7 +7794,7 @@ "kind": "section" }, { - "id": "572-when-to-use#your-app-is-broadly-like-linear", + "id": "578-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\"", @@ -7720,7 +7804,7 @@ "kind": "section" }, { - "id": "573-when-to-use#interaction-performance-is-very-important-to-you", + "id": "579-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", @@ -7730,7 +7814,7 @@ "kind": "section" }, { - "id": "574-when-to-use#zero-might-not-be-a-good-fit", + "id": "580-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", @@ -7740,7 +7824,7 @@ "kind": "section" }, { - "id": "575-when-to-use#you-need-the-privacy-or-data-ownership-benefits-of-local-first", + "id": "581-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", @@ -7750,7 +7834,7 @@ "kind": "section" }, { - "id": "576-when-to-use#you-need-to-support-offline-writes-or-long-periods-offline", + "id": "582-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", @@ -7760,7 +7844,7 @@ "kind": "section" }, { - "id": "577-when-to-use#you-are-building-a-native-mobile-app", + "id": "583-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", @@ -7770,7 +7854,7 @@ "kind": "section" }, { - "id": "578-when-to-use#the-total-backend-dataset-is--100gb", + "id": "584-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", @@ -7780,7 +7864,7 @@ "kind": "section" }, { - "id": "579-when-to-use#zero-might-not-be-a-good-fit-yet", + "id": "585-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", @@ -7790,7 +7874,7 @@ "kind": "section" }, { - "id": "580-when-to-use#alternatives", + "id": "586-when-to-use#alternatives", "title": "When To Use Zero", "searchTitle": "Alternatives", "sectionTitle": "Alternatives", @@ -7804,7 +7888,7 @@ "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 Max Connections The maximum number of connections to open to the change database. This is used by the change-streamer for catching up zero-cache replication subscriptions. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS default: 5 Change Streamer Back 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 Padding Seconds During periodic flow control checks (every 64kb), this is the amount of time to wait after the majority of subscribers have acked, after which replication continues even if some subscribers have yet to ack. This is not a timeout for the entire send; it starts only after the majority of receivers have acked. This allows a bounded amount of time for backlogged subscribers to catch up on each flush without forcing all subscribers to wait for the entire backlog to be processed. It is also useful for mitigating the effect of unresponsive subscribers due to severed WebSocket connections until liveness checks disconnect them. Set this to a negative number to disable early flow control releases. flag: --change-streamer-flow-control-consensus-padding-seconds env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_PADDING_SECONDS default: 1 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. 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. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40 Litestream Config Path Path to the litestream yaml config file. zero-cache will run this with its environment variables, which can be referenced in the file via ${ENV} substitution, for example: ZERO_REPLICA_FILE for the db Path ZERO_LITESTREAM_BACKUP_LOCATION for the db replica url ZERO_LITESTREAM_LOG_LEVEL for the log Level ZERO_LOG_FORMAT for the log type flag: --litestream-config-path env: ZERO_LITESTREAM_CONFIG_PATH default: ./src/services/litestream/config.yml Litestream Executable Path to the litestream executable. This 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 Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Maximum Checkpoint Page Count The WAL page count at which SQLite performs a RESTART checkpoint, which blocks writers until complete. Defaults to minCheckpointPageCount * 10. Set to 0 to disable RESTART checkpoints entirely. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 Litestream Minimum Checkpoint Page Count The WAL page count at which SQLite attempts a PASSIVE checkpoint, which transfers pages to the main database file without blocking writers. Defaults to checkpointThresholdMB * 250 (since SQLite page size is 4KB). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250 Litestream Multipart Concurrency The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY default: 48 Litestream Multipart Size The size of each part when uploading or downloading the snapshot with --litestream-multipart-concurrency. Note that up to concurrency * size bytes of memory are used when backing up or restoring the snapshot. flag: --litestream-multipart-size env: ZERO_LITESTREAM_MULTIPART_SIZE default: 16777216 (16 MiB) Litestream Log Level flag: --litestream-log-level env: ZERO_LITESTREAM_LOG_LEVEL default: warn values: debug, info, warn, error Litestream Port Port on which litestream exports metrics, used to determine the replication watermark up to which it is safe to purge change log records. flag: --litestream-port env: ZERO_LITESTREAM_PORT default: --port + 2 Litestream 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. This improves restore time at the expense of bandwidth. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12 Log Format Use text for developer-friendly console logging and json for consumption by structured-logging services. flag: --log-format env: ZERO_LOG_FORMAT default: \"text\" values: text, json Log IVM Sampling How often to collect IVM metrics. 1 out of N requests will be sampled where N is this value. flag: --log-ivm-sampling env: ZERO_LOG_IVM_SAMPLING default: 5000 Log Level Sets the logging level for the application. flag: --log-level env: ZERO_LOG_LEVEL default: \"info\" values: debug, info, warn, error Log Slow Hydrate Threshold The number of milliseconds a query hydration must take to print a slow warning. flag: --log-slow-hydrate-threshold env: ZERO_LOG_SLOW_HYDRATE_THRESHOLD default: 100 Log Slow Row Threshold The number of ms a row must take to fetch from table-source before it is considered slow. flag: --log-slow-row-threshold env: ZERO_LOG_SLOW_ROW_THRESHOLD default: 2 Mutate API Key An optional secret used to authorize zero-cache to call the API server handling writes. This is sent from zero-cache to your mutate endpoint in an X-Api-Key header. flag: --mutate-api-key env: ZERO_MUTATE_API_KEY Mutate 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 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 Auth JWK A public key in JWK format used to verify JWTs. Only one of jwk, jwksUrl and secret may be set. flag: --auth-jwk env: ZERO_AUTH_JWK Auth JWKS URL A URL that returns a JWK set used to verify JWTs. Only one of jwk, jwksUrl and secret may be set. flag: --auth-jwks-url env: ZERO_AUTH_JWKS_URL Auth Secret A symmetric key used to verify JWTs. Only one of jwk, jwksUrl and secret may be set. flag: --auth-secret env: ZERO_AUTH_SECRET", + "content": "zero-cache is configured either via CLI flag or environment variable. There is no separate zero.config file. You can also see all available flags by running zero-cache --help. Required Flags Upstream DB The \"upstream\" authoritative postgres database. In the future we will support other types of upstream besides PG. flag: --upstream-db env: ZERO_UPSTREAM_DB required: true Admin Password A password used to administer zero-cache server, for example to access the /statz endpoint and the inspector. This is required in production (when NODE_ENV=production) because we want all Zero servers to be debuggable using admin tools by default, without needing a restart. But we also don't want to expose sensitive data using them. flag: --admin-password env: ZERO_ADMIN_PASSWORD required: in production (when NODE_ENV=production) Optional Flags App ID Unique identifier for the app. Multiple zero-cache apps can run on a single upstream database, each of which is isolated from the others, with its own permissions, sharding (future feature), and change/cvr databases. The metadata of an app is stored in an upstream schema with the same name, e.g. zero, and the metadata for each app shard, e.g. client and mutation ids, is stored in the {app-id}_{#} schema. (Currently there is only a single \"0\" shard, but this will change with sharding). The CVR and Change data are managed in schemas named {app-id}_{shard-num}/cvr and {app-id}_{shard-num}/cdc, respectively, allowing multiple apps and shards to share the same database instance (e.g. a Postgres \"cluster\") for CVR and Change management. Due to constraints on replication slot names, an App ID may only consist of lower-case letters, numbers, and the underscore character. Note that this option is used by both zero-cache and zero-deploy-permissions. flag: --app-id env: ZERO_APP_ID default: zero App Publications Postgres PUBLICATIONs that define the tables and columns to replicate. Publication names may not begin with an underscore, as zero reserves that prefix for internal use. If unspecified, zero-cache will create and use an internal publication that publishes all tables in the public schema, i.e.: CREATE PUBLICATION _{app-id}_public_0 FOR TABLES IN SCHEMA public; Note that changing the set of publications will result in resyncing the replica, which may involve downtime (replication lag) while the new replica is initializing. To change the set of publications without disrupting an existing app, a new app should be created. To use a custom publication, you can create one with: CREATE PUBLICATION zero_data FOR TABLES IN SCHEMA public; -- or, more selectively: CREATE PUBLICATION zero_data FOR TABLE users, orders; Then set the flag to that publication name, e.g.: ZERO_APP_PUBLICATIONS=zero_data. To specify multiple publications, separate them with commas, e.g.: ZERO_APP_PUBLICATIONS=zero_data1,zero_data2. flag: --app-publications env: ZERO_APP_PUBLICATIONS default: _{app-id}_public_0 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 Max Connections The maximum number of connections to open to the change database. This is used by the change-streamer for catching up zero-cache replication subscriptions. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS default: 5 Change Streamer Back 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 Padding Seconds During periodic flow control checks (every 64kb), this is the amount of time to wait after the majority of subscribers have acked, after which replication continues even if some subscribers have yet to ack. This is not a timeout for the entire send; it starts only after the majority of receivers have acked. This allows a bounded amount of time for backlogged subscribers to catch up on each flush without forcing all subscribers to wait for the entire backlog to be processed. It is also useful for mitigating the effect of unresponsive subscribers due to severed WebSocket connections until liveness checks disconnect them. Set this to a negative number to disable early flow control releases. flag: --change-streamer-flow-control-consensus-padding-seconds env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_PADDING_SECONDS default: 1 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. 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. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40 Litestream Config Path Path to the litestream yaml config file. zero-cache will run this with its environment variables, which can be referenced in the file via ${ENV} substitution, for example: ZERO_REPLICA_FILE for the db Path ZERO_LITESTREAM_BACKUP_LOCATION for the db replica url ZERO_LITESTREAM_LOG_LEVEL for the log Level ZERO_LOG_FORMAT for the log type flag: --litestream-config-path env: ZERO_LITESTREAM_CONFIG_PATH default: ./src/services/litestream/config.yml Litestream Executable Path to the litestream executable. This 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 restores when ZERO_LITESTREAM_RESTORE_USING_V5 is enabled. Litestream v0.5.8 and later can restore both legacy WAL backups and LTX backups, choosing the format with the latest data. The official Zero Docker image includes Litestream 0.5.15 at this path. flag: --litestream-executable-v5 env: ZERO_LITESTREAM_EXECUTABLE_V5 Litestream Restore Using V5 Use ZERO_LITESTREAM_EXECUTABLE_V5 for restores when that executable is configured. If it is unavailable, Zero falls back to the legacy executable. Set this to false to force legacy restore behavior. 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 requires v5 restore and identical ZERO_LITESTREAM_EXECUTABLE and ZERO_LITESTREAM_EXECUTABLE_V5 paths. Older images cannot restore an LTX-only backup. flag: --litestream-backup-using-v5 env: ZERO_LITESTREAM_BACKUP_USING_V5 default: false Litestream Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Maximum Checkpoint Page Count The WAL page count at which SQLite performs a RESTART checkpoint, which blocks writers until complete. Defaults to minCheckpointPageCount * 10. Set to 0 to disable RESTART checkpoints entirely. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 Litestream Minimum Checkpoint Page Count The WAL page count at which SQLite attempts a PASSIVE checkpoint, which transfers pages to the main database file without blocking writers. Defaults to checkpointThresholdMB * 250 (since SQLite page size is 4KB). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250 Litestream Multipart Concurrency The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY default: 48 Litestream Multipart Size The size of each part when uploading or downloading the snapshot with --litestream-multipart-concurrency. Note that up to concurrency * size bytes of memory are used when backing up or restoring the snapshot. flag: --litestream-multipart-size env: ZERO_LITESTREAM_MULTIPART_SIZE default: 16777216 (16 MiB) Litestream Log Level flag: --litestream-log-level env: ZERO_LITESTREAM_LOG_LEVEL default: warn values: debug, info, warn, error Litestream Port Port on which litestream exports metrics, used to determine the replication watermark up to which it is safe to purge change log records. flag: --litestream-port env: ZERO_LITESTREAM_PORT default: --port + 2 Litestream 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. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12 Log Format Use text for developer-friendly console logging and json for consumption by structured-logging services. flag: --log-format env: ZERO_LOG_FORMAT default: \"text\" values: text, json Log IVM Sampling How often to collect IVM metrics. 1 out of N requests will be sampled where N is this value. flag: --log-ivm-sampling env: ZERO_LOG_IVM_SAMPLING default: 5000 Log Level Sets the logging level for the application. flag: --log-level env: ZERO_LOG_LEVEL default: \"info\" values: debug, info, warn, error Log Slow Hydrate Threshold The number of milliseconds a query hydration must take to print a slow warning. flag: --log-slow-hydrate-threshold env: ZERO_LOG_SLOW_HYDRATE_THRESHOLD default: 100 Log Slow Row Threshold The number of ms a row must take to fetch from table-source before it is considered slow. flag: --log-slow-row-threshold env: ZERO_LOG_SLOW_ROW_THRESHOLD default: 2 Mutate API Key An optional secret used to authorize zero-cache to call the API server handling writes. This is sent from zero-cache to your mutate endpoint in an X-Api-Key header. flag: --mutate-api-key env: ZERO_MUTATE_API_KEY Mutate 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 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 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", @@ -7934,6 +8018,18 @@ "text": "Litestream Executable", "id": "litestream-executable" }, + { + "text": "Litestream V5 Executable", + "id": "litestream-v5-executable" + }, + { + "text": "Litestream Restore Using V5", + "id": "litestream-restore-using-v5" + }, + { + "text": "Litestream Backup Using V5", + "id": "litestream-backup-using-v5" + }, { "text": "Litestream Incremental Backup Interval Minutes", "id": "litestream-incremental-backup-interval-minutes" @@ -8142,7 +8238,7 @@ "kind": "page" }, { - "id": "581-zero-cache-config#required-flags", + "id": "587-zero-cache-config#required-flags", "title": "zero-cache Config", "searchTitle": "Required Flags", "sectionTitle": "Required Flags", @@ -8152,7 +8248,7 @@ "kind": "section" }, { - "id": "582-zero-cache-config#upstream-db", + "id": "588-zero-cache-config#upstream-db", "title": "zero-cache Config", "searchTitle": "Upstream DB", "sectionTitle": "Upstream DB", @@ -8162,7 +8258,7 @@ "kind": "section" }, { - "id": "583-zero-cache-config#admin-password", + "id": "589-zero-cache-config#admin-password", "title": "zero-cache Config", "searchTitle": "Admin Password", "sectionTitle": "Admin Password", @@ -8172,17 +8268,17 @@ "kind": "section" }, { - "id": "584-zero-cache-config#optional-flags", + "id": "590-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 Max Connections The maximum number of connections to open to the change database. This is used by the change-streamer for catching up zero-cache replication subscriptions. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS default: 5 Change Streamer Back 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 Padding Seconds During periodic flow control checks (every 64kb), this is the amount of time to wait after the majority of subscribers have acked, after which replication continues even if some subscribers have yet to ack. This is not a timeout for the entire send; it starts only after the majority of receivers have acked. This allows a bounded amount of time for backlogged subscribers to catch up on each flush without forcing all subscribers to wait for the entire backlog to be processed. It is also useful for mitigating the effect of unresponsive subscribers due to severed WebSocket connections until liveness checks disconnect them. Set this to a negative number to disable early flow control releases. flag: --change-streamer-flow-control-consensus-padding-seconds env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_PADDING_SECONDS default: 1 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. 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. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40 Litestream Config Path Path to the litestream yaml config file. zero-cache will run this with its environment variables, which can be referenced in the file via ${ENV} substitution, for example: ZERO_REPLICA_FILE for the db Path ZERO_LITESTREAM_BACKUP_LOCATION for the db replica url ZERO_LITESTREAM_LOG_LEVEL for the log Level ZERO_LOG_FORMAT for the log type flag: --litestream-config-path env: ZERO_LITESTREAM_CONFIG_PATH default: ./src/services/litestream/config.yml Litestream Executable Path to the litestream executable. This 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 Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Maximum Checkpoint Page Count The WAL page count at which SQLite performs a RESTART checkpoint, which blocks writers until complete. Defaults to minCheckpointPageCount * 10. Set to 0 to disable RESTART checkpoints entirely. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 Litestream Minimum Checkpoint Page Count The WAL page count at which SQLite attempts a PASSIVE checkpoint, which transfers pages to the main database file without blocking writers. Defaults to checkpointThresholdMB * 250 (since SQLite page size is 4KB). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250 Litestream Multipart Concurrency The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY default: 48 Litestream Multipart Size The size of each part when uploading or downloading the snapshot with --litestream-multipart-concurrency. Note that up to concurrency * size bytes of memory are used when backing up or restoring the snapshot. flag: --litestream-multipart-size env: ZERO_LITESTREAM_MULTIPART_SIZE default: 16777216 (16 MiB) Litestream Log Level flag: --litestream-log-level env: ZERO_LITESTREAM_LOG_LEVEL default: warn values: debug, info, warn, error Litestream Port Port on which litestream exports metrics, used to determine the replication watermark up to which it is safe to purge change log records. flag: --litestream-port env: ZERO_LITESTREAM_PORT default: --port + 2 Litestream 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. This improves restore time at the expense of bandwidth. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12 Log Format Use text for developer-friendly console logging and json for consumption by structured-logging services. flag: --log-format env: ZERO_LOG_FORMAT default: \"text\" values: text, json Log IVM Sampling How often to collect IVM metrics. 1 out of N requests will be sampled where N is this value. flag: --log-ivm-sampling env: ZERO_LOG_IVM_SAMPLING default: 5000 Log Level Sets the logging level for the application. flag: --log-level env: ZERO_LOG_LEVEL default: \"info\" values: debug, info, warn, error Log Slow Hydrate Threshold The number of milliseconds a query hydration must take to print a slow warning. flag: --log-slow-hydrate-threshold env: ZERO_LOG_SLOW_HYDRATE_THRESHOLD default: 100 Log Slow Row Threshold The number of ms a row must take to fetch from table-source before it is considered slow. flag: --log-slow-row-threshold env: ZERO_LOG_SLOW_ROW_THRESHOLD default: 2 Mutate API Key An optional secret used to authorize zero-cache to call the API server handling writes. This is sent from zero-cache to your mutate endpoint in an X-Api-Key header. flag: --mutate-api-key env: ZERO_MUTATE_API_KEY Mutate 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 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": "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 Max Connections The maximum number of connections to open to the change database. This is used by the change-streamer for catching up zero-cache replication subscriptions. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS default: 5 Change Streamer Back 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 Padding Seconds During periodic flow control checks (every 64kb), this is the amount of time to wait after the majority of subscribers have acked, after which replication continues even if some subscribers have yet to ack. This is not a timeout for the entire send; it starts only after the majority of receivers have acked. This allows a bounded amount of time for backlogged subscribers to catch up on each flush without forcing all subscribers to wait for the entire backlog to be processed. It is also useful for mitigating the effect of unresponsive subscribers due to severed WebSocket connections until liveness checks disconnect them. Set this to a negative number to disable early flow control releases. flag: --change-streamer-flow-control-consensus-padding-seconds env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_PADDING_SECONDS default: 1 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. 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. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40 Litestream Config Path Path to the litestream yaml config file. zero-cache will run this with its environment variables, which can be referenced in the file via ${ENV} substitution, for example: ZERO_REPLICA_FILE for the db Path ZERO_LITESTREAM_BACKUP_LOCATION for the db replica url ZERO_LITESTREAM_LOG_LEVEL for the log Level ZERO_LOG_FORMAT for the log type flag: --litestream-config-path env: ZERO_LITESTREAM_CONFIG_PATH default: ./src/services/litestream/config.yml Litestream Executable Path to the litestream executable. This 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 restores when ZERO_LITESTREAM_RESTORE_USING_V5 is enabled. Litestream v0.5.8 and later can restore both legacy WAL backups and LTX backups, choosing the format with the latest data. The official Zero Docker image includes Litestream 0.5.15 at this path. flag: --litestream-executable-v5 env: ZERO_LITESTREAM_EXECUTABLE_V5 Litestream Restore Using V5 Use ZERO_LITESTREAM_EXECUTABLE_V5 for restores when that executable is configured. If it is unavailable, Zero falls back to the legacy executable. Set this to false to force legacy restore behavior. 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 requires v5 restore and identical ZERO_LITESTREAM_EXECUTABLE and ZERO_LITESTREAM_EXECUTABLE_V5 paths. Older images cannot restore an LTX-only backup. flag: --litestream-backup-using-v5 env: ZERO_LITESTREAM_BACKUP_USING_V5 default: false Litestream Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Maximum Checkpoint Page Count The WAL page count at which SQLite performs a RESTART checkpoint, which blocks writers until complete. Defaults to minCheckpointPageCount * 10. Set to 0 to disable RESTART checkpoints entirely. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 Litestream Minimum Checkpoint Page Count The WAL page count at which SQLite attempts a PASSIVE checkpoint, which transfers pages to the main database file without blocking writers. Defaults to checkpointThresholdMB * 250 (since SQLite page size is 4KB). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250 Litestream Multipart Concurrency The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY default: 48 Litestream Multipart Size The size of each part when uploading or downloading the snapshot with --litestream-multipart-concurrency. Note that up to concurrency * size bytes of memory are used when backing up or restoring the snapshot. flag: --litestream-multipart-size env: ZERO_LITESTREAM_MULTIPART_SIZE default: 16777216 (16 MiB) Litestream Log Level flag: --litestream-log-level env: ZERO_LITESTREAM_LOG_LEVEL default: warn values: debug, info, warn, error Litestream Port Port on which litestream exports metrics, used to determine the replication watermark up to which it is safe to purge change log records. flag: --litestream-port env: ZERO_LITESTREAM_PORT default: --port + 2 Litestream 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. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12 Log Format Use text for developer-friendly console logging and json for consumption by structured-logging services. flag: --log-format env: ZERO_LOG_FORMAT default: \"text\" values: text, json Log IVM Sampling How often to collect IVM metrics. 1 out of N requests will be sampled where N is this value. flag: --log-ivm-sampling env: ZERO_LOG_IVM_SAMPLING default: 5000 Log Level Sets the logging level for the application. flag: --log-level env: ZERO_LOG_LEVEL default: \"info\" values: debug, info, warn, error Log Slow Hydrate Threshold The number of milliseconds a query hydration must take to print a slow warning. flag: --log-slow-hydrate-threshold env: ZERO_LOG_SLOW_HYDRATE_THRESHOLD default: 100 Log Slow Row Threshold The number of ms a row must take to fetch from table-source before it is considered slow. flag: --log-slow-row-threshold env: ZERO_LOG_SLOW_ROW_THRESHOLD default: 2 Mutate API Key An optional secret used to authorize zero-cache to call the API server handling writes. This is sent from zero-cache to your mutate endpoint in an X-Api-Key header. flag: --mutate-api-key env: ZERO_MUTATE_API_KEY Mutate 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 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": "585-zero-cache-config#app-id", + "id": "591-zero-cache-config#app-id", "title": "zero-cache Config", "searchTitle": "App ID", "sectionTitle": "App ID", @@ -8192,7 +8288,7 @@ "kind": "section" }, { - "id": "586-zero-cache-config#app-publications", + "id": "592-zero-cache-config#app-publications", "title": "zero-cache Config", "searchTitle": "App Publications", "sectionTitle": "App Publications", @@ -8202,7 +8298,7 @@ "kind": "section" }, { - "id": "587-zero-cache-config#auth-revalidate-interval-seconds", + "id": "593-zero-cache-config#auth-revalidate-interval-seconds", "title": "zero-cache Config", "searchTitle": "Auth Revalidate Interval Seconds", "sectionTitle": "Auth Revalidate Interval Seconds", @@ -8212,7 +8308,7 @@ "kind": "section" }, { - "id": "588-zero-cache-config#auth-retransform-interval-seconds", + "id": "594-zero-cache-config#auth-retransform-interval-seconds", "title": "zero-cache Config", "searchTitle": "Auth Retransform Interval Seconds", "sectionTitle": "Auth Retransform Interval Seconds", @@ -8222,7 +8318,7 @@ "kind": "section" }, { - "id": "589-zero-cache-config#auto-reset", + "id": "595-zero-cache-config#auto-reset", "title": "zero-cache Config", "searchTitle": "Auto Reset", "sectionTitle": "Auto Reset", @@ -8232,7 +8328,7 @@ "kind": "section" }, { - "id": "590-zero-cache-config#change-db", + "id": "596-zero-cache-config#change-db", "title": "zero-cache Config", "searchTitle": "Change DB", "sectionTitle": "Change DB", @@ -8242,7 +8338,7 @@ "kind": "section" }, { - "id": "591-zero-cache-config#change-max-connections", + "id": "597-zero-cache-config#change-max-connections", "title": "zero-cache Config", "searchTitle": "Change Max Connections", "sectionTitle": "Change Max Connections", @@ -8252,7 +8348,7 @@ "kind": "section" }, { - "id": "592-zero-cache-config#change-streamer-back-pressure-limit-heap-proportion", + "id": "598-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", @@ -8262,7 +8358,7 @@ "kind": "section" }, { - "id": "593-zero-cache-config#change-streamer-flow-control-consensus-padding-seconds", + "id": "599-zero-cache-config#change-streamer-flow-control-consensus-padding-seconds", "title": "zero-cache Config", "searchTitle": "Change Streamer Flow Control Consensus Padding Seconds", "sectionTitle": "Change Streamer Flow Control Consensus Padding Seconds", @@ -8272,7 +8368,7 @@ "kind": "section" }, { - "id": "594-zero-cache-config#change-streamer-mode", + "id": "600-zero-cache-config#change-streamer-mode", "title": "zero-cache Config", "searchTitle": "Change Streamer Mode", "sectionTitle": "Change Streamer Mode", @@ -8282,7 +8378,7 @@ "kind": "section" }, { - "id": "595-zero-cache-config#change-streamer-port", + "id": "601-zero-cache-config#change-streamer-port", "title": "zero-cache Config", "searchTitle": "Change Streamer Port", "sectionTitle": "Change Streamer Port", @@ -8292,7 +8388,7 @@ "kind": "section" }, { - "id": "596-zero-cache-config#change-streamer-startup-delay-ms", + "id": "602-zero-cache-config#change-streamer-startup-delay-ms", "title": "zero-cache Config", "searchTitle": "Change Streamer Startup Delay (ms)", "sectionTitle": "Change Streamer Startup Delay (ms)", @@ -8302,7 +8398,7 @@ "kind": "section" }, { - "id": "597-zero-cache-config#change-streamer-uri", + "id": "603-zero-cache-config#change-streamer-uri", "title": "zero-cache Config", "searchTitle": "Change Streamer URI", "sectionTitle": "Change Streamer URI", @@ -8312,7 +8408,7 @@ "kind": "section" }, { - "id": "598-zero-cache-config#cvr-db", + "id": "604-zero-cache-config#cvr-db", "title": "zero-cache Config", "searchTitle": "CVR DB", "sectionTitle": "CVR DB", @@ -8322,7 +8418,7 @@ "kind": "section" }, { - "id": "599-zero-cache-config#cvr-garbage-collection-inactivity-threshold-hours", + "id": "605-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", @@ -8332,7 +8428,7 @@ "kind": "section" }, { - "id": "600-zero-cache-config#cvr-garbage-collection-initial-batch-size", + "id": "606-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", @@ -8342,7 +8438,7 @@ "kind": "section" }, { - "id": "601-zero-cache-config#cvr-garbage-collection-initial-interval-seconds", + "id": "607-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", @@ -8352,7 +8448,7 @@ "kind": "section" }, { - "id": "602-zero-cache-config#cvr-max-connections", + "id": "608-zero-cache-config#cvr-max-connections", "title": "zero-cache Config", "searchTitle": "CVR Max Connections", "sectionTitle": "CVR Max Connections", @@ -8362,7 +8458,7 @@ "kind": "section" }, { - "id": "603-zero-cache-config#enable-query-planner", + "id": "609-zero-cache-config#enable-query-planner", "title": "zero-cache Config", "searchTitle": "Enable Query Planner", "sectionTitle": "Enable Query Planner", @@ -8372,7 +8468,7 @@ "kind": "section" }, { - "id": "604-zero-cache-config#enable-crud-mutations", + "id": "610-zero-cache-config#enable-crud-mutations", "title": "zero-cache Config", "searchTitle": "Enable CRUD Mutations", "sectionTitle": "Enable CRUD Mutations", @@ -8382,7 +8478,7 @@ "kind": "section" }, { - "id": "605-zero-cache-config#enable-telemetry", + "id": "611-zero-cache-config#enable-telemetry", "title": "zero-cache Config", "searchTitle": "Enable Telemetry", "sectionTitle": "Enable Telemetry", @@ -8392,7 +8488,7 @@ "kind": "section" }, { - "id": "606-zero-cache-config#initial-sync-table-copy-workers", + "id": "612-zero-cache-config#initial-sync-table-copy-workers", "title": "zero-cache Config", "searchTitle": "Initial Sync Table Copy Workers", "sectionTitle": "Initial Sync Table Copy Workers", @@ -8402,7 +8498,7 @@ "kind": "section" }, { - "id": "607-zero-cache-config#lazy-startup", + "id": "613-zero-cache-config#lazy-startup", "title": "zero-cache Config", "searchTitle": "Lazy Startup", "sectionTitle": "Lazy Startup", @@ -8412,7 +8508,7 @@ "kind": "section" }, { - "id": "608-zero-cache-config#litestream-backup-url", + "id": "614-zero-cache-config#litestream-backup-url", "title": "zero-cache Config", "searchTitle": "Litestream Backup URL", "sectionTitle": "Litestream Backup URL", @@ -8422,7 +8518,7 @@ "kind": "section" }, { - "id": "609-zero-cache-config#litestream-endpoint", + "id": "615-zero-cache-config#litestream-endpoint", "title": "zero-cache Config", "searchTitle": "Litestream Endpoint", "sectionTitle": "Litestream Endpoint", @@ -8432,7 +8528,7 @@ "kind": "section" }, { - "id": "610-zero-cache-config#litestream-checkpoint-threshold-mb", + "id": "616-zero-cache-config#litestream-checkpoint-threshold-mb", "title": "zero-cache Config", "searchTitle": "Litestream Checkpoint Threshold MB", "sectionTitle": "Litestream Checkpoint Threshold MB", @@ -8442,7 +8538,7 @@ "kind": "section" }, { - "id": "611-zero-cache-config#litestream-config-path", + "id": "617-zero-cache-config#litestream-config-path", "title": "zero-cache Config", "searchTitle": "Litestream Config Path", "sectionTitle": "Litestream Config Path", @@ -8452,7 +8548,7 @@ "kind": "section" }, { - "id": "612-zero-cache-config#litestream-executable", + "id": "618-zero-cache-config#litestream-executable", "title": "zero-cache Config", "searchTitle": "Litestream Executable", "sectionTitle": "Litestream Executable", @@ -8462,7 +8558,37 @@ "kind": "section" }, { - "id": "613-zero-cache-config#litestream-incremental-backup-interval-minutes", + "id": "619-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 restores when ZERO_LITESTREAM_RESTORE_USING_V5 is enabled. Litestream v0.5.8 and later can restore both legacy WAL backups and LTX backups, choosing the format with the latest data. The official Zero Docker image includes Litestream 0.5.15 at this path. flag: --litestream-executable-v5 env: ZERO_LITESTREAM_EXECUTABLE_V5", + "kind": "section" + }, + { + "id": "620-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 when that executable is configured. If it is unavailable, Zero falls back to the legacy executable. Set this to false to force legacy restore behavior. 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": "621-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 requires v5 restore and identical ZERO_LITESTREAM_EXECUTABLE and ZERO_LITESTREAM_EXECUTABLE_V5 paths. Older images cannot restore an LTX-only backup. flag: --litestream-backup-using-v5 env: ZERO_LITESTREAM_BACKUP_USING_V5 default: false", + "kind": "section" + }, + { + "id": "622-zero-cache-config#litestream-incremental-backup-interval-minutes", "title": "zero-cache Config", "searchTitle": "Litestream Incremental Backup Interval Minutes", "sectionTitle": "Litestream Incremental Backup Interval Minutes", @@ -8472,7 +8598,7 @@ "kind": "section" }, { - "id": "614-zero-cache-config#litestream-maximum-checkpoint-page-count", + "id": "623-zero-cache-config#litestream-maximum-checkpoint-page-count", "title": "zero-cache Config", "searchTitle": "Litestream Maximum Checkpoint Page Count", "sectionTitle": "Litestream Maximum Checkpoint Page Count", @@ -8482,7 +8608,7 @@ "kind": "section" }, { - "id": "615-zero-cache-config#litestream-minimum-checkpoint-page-count", + "id": "624-zero-cache-config#litestream-minimum-checkpoint-page-count", "title": "zero-cache Config", "searchTitle": "Litestream Minimum Checkpoint Page Count", "sectionTitle": "Litestream Minimum Checkpoint Page Count", @@ -8492,7 +8618,7 @@ "kind": "section" }, { - "id": "616-zero-cache-config#litestream-multipart-concurrency", + "id": "625-zero-cache-config#litestream-multipart-concurrency", "title": "zero-cache Config", "searchTitle": "Litestream Multipart Concurrency", "sectionTitle": "Litestream Multipart Concurrency", @@ -8502,7 +8628,7 @@ "kind": "section" }, { - "id": "617-zero-cache-config#litestream-multipart-size", + "id": "626-zero-cache-config#litestream-multipart-size", "title": "zero-cache Config", "searchTitle": "Litestream Multipart Size", "sectionTitle": "Litestream Multipart Size", @@ -8512,7 +8638,7 @@ "kind": "section" }, { - "id": "618-zero-cache-config#litestream-log-level", + "id": "627-zero-cache-config#litestream-log-level", "title": "zero-cache Config", "searchTitle": "Litestream Log Level", "sectionTitle": "Litestream Log Level", @@ -8522,7 +8648,7 @@ "kind": "section" }, { - "id": "619-zero-cache-config#litestream-port", + "id": "628-zero-cache-config#litestream-port", "title": "zero-cache Config", "searchTitle": "Litestream Port", "sectionTitle": "Litestream Port", @@ -8532,7 +8658,7 @@ "kind": "section" }, { - "id": "620-zero-cache-config#litestream-region", + "id": "629-zero-cache-config#litestream-region", "title": "zero-cache Config", "searchTitle": "Litestream Region", "sectionTitle": "Litestream Region", @@ -8542,7 +8668,7 @@ "kind": "section" }, { - "id": "621-zero-cache-config#litestream-restore-parallelism", + "id": "630-zero-cache-config#litestream-restore-parallelism", "title": "zero-cache Config", "searchTitle": "Litestream Restore Parallelism", "sectionTitle": "Litestream Restore Parallelism", @@ -8552,17 +8678,17 @@ "kind": "section" }, { - "id": "622-zero-cache-config#litestream-snapshot-backup-interval-hours", + "id": "631-zero-cache-config#litestream-snapshot-backup-interval-hours", "title": "zero-cache Config", "searchTitle": "Litestream Snapshot Backup Interval Hours", "sectionTitle": "Litestream Snapshot Backup Interval Hours", "sectionId": "litestream-snapshot-backup-interval-hours", "url": "/docs/zero-cache-config", - "content": "The interval between snapshot backups of the replica. Snapshot backups make a full copy of the database to a new litestream generation. This improves restore time at the expense of bandwidth. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12", + "content": "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. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12", "kind": "section" }, { - "id": "623-zero-cache-config#log-format", + "id": "632-zero-cache-config#log-format", "title": "zero-cache Config", "searchTitle": "Log Format", "sectionTitle": "Log Format", @@ -8572,7 +8698,7 @@ "kind": "section" }, { - "id": "624-zero-cache-config#log-ivm-sampling", + "id": "633-zero-cache-config#log-ivm-sampling", "title": "zero-cache Config", "searchTitle": "Log IVM Sampling", "sectionTitle": "Log IVM Sampling", @@ -8582,7 +8708,7 @@ "kind": "section" }, { - "id": "625-zero-cache-config#log-level", + "id": "634-zero-cache-config#log-level", "title": "zero-cache Config", "searchTitle": "Log Level", "sectionTitle": "Log Level", @@ -8592,7 +8718,7 @@ "kind": "section" }, { - "id": "626-zero-cache-config#log-slow-hydrate-threshold", + "id": "635-zero-cache-config#log-slow-hydrate-threshold", "title": "zero-cache Config", "searchTitle": "Log Slow Hydrate Threshold", "sectionTitle": "Log Slow Hydrate Threshold", @@ -8602,7 +8728,7 @@ "kind": "section" }, { - "id": "627-zero-cache-config#log-slow-row-threshold", + "id": "636-zero-cache-config#log-slow-row-threshold", "title": "zero-cache Config", "searchTitle": "Log Slow Row Threshold", "sectionTitle": "Log Slow Row Threshold", @@ -8612,7 +8738,7 @@ "kind": "section" }, { - "id": "628-zero-cache-config#mutate-api-key", + "id": "637-zero-cache-config#mutate-api-key", "title": "zero-cache Config", "searchTitle": "Mutate API Key", "sectionTitle": "Mutate API Key", @@ -8622,7 +8748,7 @@ "kind": "section" }, { - "id": "629-zero-cache-config#mutate-allowed-client-headers", + "id": "638-zero-cache-config#mutate-allowed-client-headers", "title": "zero-cache Config", "searchTitle": "Mutate Allowed Client Headers", "sectionTitle": "Mutate Allowed Client Headers", @@ -8632,7 +8758,7 @@ "kind": "section" }, { - "id": "630-zero-cache-config#mutate-allowed-request-headers", + "id": "639-zero-cache-config#mutate-allowed-request-headers", "title": "zero-cache Config", "searchTitle": "Mutate Allowed Request Headers", "sectionTitle": "Mutate Allowed Request Headers", @@ -8642,7 +8768,7 @@ "kind": "section" }, { - "id": "631-zero-cache-config#mutate-forward-cookies", + "id": "640-zero-cache-config#mutate-forward-cookies", "title": "zero-cache Config", "searchTitle": "Mutate Forward Cookies", "sectionTitle": "Mutate Forward Cookies", @@ -8652,7 +8778,7 @@ "kind": "section" }, { - "id": "632-zero-cache-config#mutate-url", + "id": "641-zero-cache-config#mutate-url", "title": "zero-cache Config", "searchTitle": "Mutate URL", "sectionTitle": "Mutate URL", @@ -8662,7 +8788,7 @@ "kind": "section" }, { - "id": "633-zero-cache-config#number-of-sync-workers", + "id": "642-zero-cache-config#number-of-sync-workers", "title": "zero-cache Config", "searchTitle": "Number of Sync Workers", "sectionTitle": "Number of Sync Workers", @@ -8672,7 +8798,7 @@ "kind": "section" }, { - "id": "634-zero-cache-config#per-user-mutation-limit-max", + "id": "643-zero-cache-config#per-user-mutation-limit-max", "title": "zero-cache Config", "searchTitle": "Per User Mutation Limit Max", "sectionTitle": "Per User Mutation Limit Max", @@ -8682,7 +8808,7 @@ "kind": "section" }, { - "id": "635-zero-cache-config#per-user-mutation-limit-window-ms", + "id": "644-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)", @@ -8692,7 +8818,7 @@ "kind": "section" }, { - "id": "636-zero-cache-config#pg-replication-slot-failover", + "id": "645-zero-cache-config#pg-replication-slot-failover", "title": "zero-cache Config", "searchTitle": "PG Replication Slot Failover", "sectionTitle": "PG Replication Slot Failover", @@ -8702,7 +8828,7 @@ "kind": "section" }, { - "id": "637-zero-cache-config#port", + "id": "646-zero-cache-config#port", "title": "zero-cache Config", "searchTitle": "Port", "sectionTitle": "Port", @@ -8712,7 +8838,7 @@ "kind": "section" }, { - "id": "638-zero-cache-config#query-api-key", + "id": "647-zero-cache-config#query-api-key", "title": "zero-cache Config", "searchTitle": "Query API Key", "sectionTitle": "Query API Key", @@ -8722,7 +8848,7 @@ "kind": "section" }, { - "id": "639-zero-cache-config#query-allowed-client-headers", + "id": "648-zero-cache-config#query-allowed-client-headers", "title": "zero-cache Config", "searchTitle": "Query Allowed Client Headers", "sectionTitle": "Query Allowed Client Headers", @@ -8732,7 +8858,7 @@ "kind": "section" }, { - "id": "640-zero-cache-config#query-allowed-request-headers", + "id": "649-zero-cache-config#query-allowed-request-headers", "title": "zero-cache Config", "searchTitle": "Query Allowed Request Headers", "sectionTitle": "Query Allowed Request Headers", @@ -8742,7 +8868,7 @@ "kind": "section" }, { - "id": "641-zero-cache-config#query-forward-cookies", + "id": "650-zero-cache-config#query-forward-cookies", "title": "zero-cache Config", "searchTitle": "Query Forward Cookies", "sectionTitle": "Query Forward Cookies", @@ -8752,7 +8878,7 @@ "kind": "section" }, { - "id": "642-zero-cache-config#query-hydration-stats", + "id": "651-zero-cache-config#query-hydration-stats", "title": "zero-cache Config", "searchTitle": "Query Hydration Stats", "sectionTitle": "Query Hydration Stats", @@ -8762,7 +8888,7 @@ "kind": "section" }, { - "id": "643-zero-cache-config#query-url", + "id": "652-zero-cache-config#query-url", "title": "zero-cache Config", "searchTitle": "Query URL", "sectionTitle": "Query URL", @@ -8772,7 +8898,7 @@ "kind": "section" }, { - "id": "644-zero-cache-config#replica-file", + "id": "653-zero-cache-config#replica-file", "title": "zero-cache Config", "searchTitle": "Replica File", "sectionTitle": "Replica File", @@ -8782,7 +8908,7 @@ "kind": "section" }, { - "id": "645-zero-cache-config#replica-vacuum-interval-hours", + "id": "654-zero-cache-config#replica-vacuum-interval-hours", "title": "zero-cache Config", "searchTitle": "Replica Vacuum Interval Hours", "sectionTitle": "Replica Vacuum Interval Hours", @@ -8792,7 +8918,7 @@ "kind": "section" }, { - "id": "646-zero-cache-config#replication-lag-report-interval-ms", + "id": "655-zero-cache-config#replication-lag-report-interval-ms", "title": "zero-cache Config", "searchTitle": "Replication Lag Report Interval (ms)", "sectionTitle": "Replication Lag Report Interval (ms)", @@ -8802,7 +8928,7 @@ "kind": "section" }, { - "id": "647-zero-cache-config#server-version", + "id": "656-zero-cache-config#server-version", "title": "zero-cache Config", "searchTitle": "Server Version", "sectionTitle": "Server Version", @@ -8812,7 +8938,7 @@ "kind": "section" }, { - "id": "648-zero-cache-config#shadow-sync-enabled", + "id": "657-zero-cache-config#shadow-sync-enabled", "title": "zero-cache Config", "searchTitle": "Shadow Sync Enabled", "sectionTitle": "Shadow Sync Enabled", @@ -8822,7 +8948,7 @@ "kind": "section" }, { - "id": "649-zero-cache-config#shadow-sync-interval-hours", + "id": "658-zero-cache-config#shadow-sync-interval-hours", "title": "zero-cache Config", "searchTitle": "Shadow Sync Interval Hours", "sectionTitle": "Shadow Sync Interval Hours", @@ -8832,7 +8958,7 @@ "kind": "section" }, { - "id": "650-zero-cache-config#shadow-sync-sample-rate", + "id": "659-zero-cache-config#shadow-sync-sample-rate", "title": "zero-cache Config", "searchTitle": "Shadow Sync Sample Rate", "sectionTitle": "Shadow Sync Sample Rate", @@ -8842,7 +8968,7 @@ "kind": "section" }, { - "id": "651-zero-cache-config#shadow-sync-max-rows-per-table", + "id": "660-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", @@ -8852,7 +8978,7 @@ "kind": "section" }, { - "id": "652-zero-cache-config#storage-db-temp-dir", + "id": "661-zero-cache-config#storage-db-temp-dir", "title": "zero-cache Config", "searchTitle": "Storage DB Temp Dir", "sectionTitle": "Storage DB Temp Dir", @@ -8862,7 +8988,7 @@ "kind": "section" }, { - "id": "653-zero-cache-config#task-id", + "id": "662-zero-cache-config#task-id", "title": "zero-cache Config", "searchTitle": "Task ID", "sectionTitle": "Task ID", @@ -8872,7 +8998,7 @@ "kind": "section" }, { - "id": "654-zero-cache-config#upstream-max-connections", + "id": "663-zero-cache-config#upstream-max-connections", "title": "zero-cache Config", "searchTitle": "Upstream Max Connections", "sectionTitle": "Upstream Max Connections", @@ -8882,7 +9008,7 @@ "kind": "section" }, { - "id": "655-zero-cache-config#upstream-pg-replication-slot-failover", + "id": "664-zero-cache-config#upstream-pg-replication-slot-failover", "title": "zero-cache Config", "searchTitle": "Upstream PG Replication Slot Failover", "sectionTitle": "Upstream PG Replication Slot Failover", @@ -8892,7 +9018,7 @@ "kind": "section" }, { - "id": "656-zero-cache-config#websocket-compression", + "id": "665-zero-cache-config#websocket-compression", "title": "zero-cache Config", "searchTitle": "Websocket Compression", "sectionTitle": "Websocket Compression", @@ -8902,7 +9028,7 @@ "kind": "section" }, { - "id": "657-zero-cache-config#websocket-compression-options", + "id": "666-zero-cache-config#websocket-compression-options", "title": "zero-cache Config", "searchTitle": "Websocket Compression Options", "sectionTitle": "Websocket Compression Options", @@ -8912,7 +9038,7 @@ "kind": "section" }, { - "id": "658-zero-cache-config#websocket-max-payload-bytes", + "id": "667-zero-cache-config#websocket-max-payload-bytes", "title": "zero-cache Config", "searchTitle": "Websocket Max Payload Bytes", "sectionTitle": "Websocket Max Payload Bytes", @@ -8922,7 +9048,7 @@ "kind": "section" }, { - "id": "659-zero-cache-config#yield-threshold-ms", + "id": "668-zero-cache-config#yield-threshold-ms", "title": "zero-cache Config", "searchTitle": "Yield Threshold (ms)", "sectionTitle": "Yield Threshold (ms)", @@ -8932,7 +9058,7 @@ "kind": "section" }, { - "id": "660-zero-cache-config#deprecated-flags", + "id": "669-zero-cache-config#deprecated-flags", "title": "zero-cache Config", "searchTitle": "Deprecated Flags", "sectionTitle": "Deprecated Flags", @@ -8942,7 +9068,7 @@ "kind": "section" }, { - "id": "661-zero-cache-config#auth-jwk", + "id": "670-zero-cache-config#auth-jwk", "title": "zero-cache Config", "searchTitle": "Auth JWK", "sectionTitle": "Auth JWK", @@ -8952,7 +9078,7 @@ "kind": "section" }, { - "id": "662-zero-cache-config#auth-jwks-url", + "id": "671-zero-cache-config#auth-jwks-url", "title": "zero-cache Config", "searchTitle": "Auth JWKS URL", "sectionTitle": "Auth JWKS URL", @@ -8962,7 +9088,7 @@ "kind": "section" }, { - "id": "663-zero-cache-config#auth-secret", + "id": "672-zero-cache-config#auth-secret", "title": "zero-cache Config", "searchTitle": "Auth Secret", "sectionTitle": "Auth Secret", @@ -9082,7 +9208,7 @@ "kind": "page" }, { - "id": "664-zql#create-a-builder", + "id": "673-zql#create-a-builder", "title": "ZQL", "searchTitle": "Create a Builder", "sectionTitle": "Create a Builder", @@ -9092,7 +9218,7 @@ "kind": "section" }, { - "id": "665-zql#select", + "id": "674-zql#select", "title": "ZQL", "searchTitle": "Select", "sectionTitle": "Select", @@ -9102,7 +9228,7 @@ "kind": "section" }, { - "id": "666-zql#ordering", + "id": "675-zql#ordering", "title": "ZQL", "searchTitle": "Ordering", "sectionTitle": "Ordering", @@ -9112,7 +9238,7 @@ "kind": "section" }, { - "id": "667-zql#limit", + "id": "676-zql#limit", "title": "ZQL", "searchTitle": "Limit", "sectionTitle": "Limit", @@ -9122,7 +9248,7 @@ "kind": "section" }, { - "id": "668-zql#paging", + "id": "677-zql#paging", "title": "ZQL", "searchTitle": "Paging", "sectionTitle": "Paging", @@ -9132,7 +9258,7 @@ "kind": "section" }, { - "id": "669-zql#getting-a-single-result", + "id": "678-zql#getting-a-single-result", "title": "ZQL", "searchTitle": "Getting a Single Result", "sectionTitle": "Getting a Single Result", @@ -9142,7 +9268,7 @@ "kind": "section" }, { - "id": "670-zql#relationships", + "id": "679-zql#relationships", "title": "ZQL", "searchTitle": "Relationships", "sectionTitle": "Relationships", @@ -9152,7 +9278,7 @@ "kind": "section" }, { - "id": "671-zql#refining-relationships", + "id": "680-zql#refining-relationships", "title": "ZQL", "searchTitle": "Refining Relationships", "sectionTitle": "Refining Relationships", @@ -9162,7 +9288,7 @@ "kind": "section" }, { - "id": "672-zql#nested-relationships", + "id": "681-zql#nested-relationships", "title": "ZQL", "searchTitle": "Nested Relationships", "sectionTitle": "Nested Relationships", @@ -9172,7 +9298,7 @@ "kind": "section" }, { - "id": "673-zql#where", + "id": "682-zql#where", "title": "ZQL", "searchTitle": "Where", "sectionTitle": "Where", @@ -9182,7 +9308,7 @@ "kind": "section" }, { - "id": "674-zql#comparison-operators", + "id": "683-zql#comparison-operators", "title": "ZQL", "searchTitle": "Comparison Operators", "sectionTitle": "Comparison Operators", @@ -9192,7 +9318,7 @@ "kind": "section" }, { - "id": "675-zql#equals-is-the-default-comparison-operator", + "id": "684-zql#equals-is-the-default-comparison-operator", "title": "ZQL", "searchTitle": "Equals is the Default Comparison Operator", "sectionTitle": "Equals is the Default Comparison Operator", @@ -9202,7 +9328,7 @@ "kind": "section" }, { - "id": "676-zql#comparing-to-null", + "id": "685-zql#comparing-to-null", "title": "ZQL", "searchTitle": "Comparing to null", "sectionTitle": "Comparing to null", @@ -9212,7 +9338,7 @@ "kind": "section" }, { - "id": "677-zql#comparing-to-undefined", + "id": "686-zql#comparing-to-undefined", "title": "ZQL", "searchTitle": "Comparing to undefined", "sectionTitle": "Comparing to undefined", @@ -9222,7 +9348,7 @@ "kind": "section" }, { - "id": "678-zql#compound-filters", + "id": "687-zql#compound-filters", "title": "ZQL", "searchTitle": "Compound Filters", "sectionTitle": "Compound Filters", @@ -9232,7 +9358,7 @@ "kind": "section" }, { - "id": "679-zql#comparing-literal-values", + "id": "688-zql#comparing-literal-values", "title": "ZQL", "searchTitle": "Comparing Literal Values", "sectionTitle": "Comparing Literal Values", @@ -9242,7 +9368,7 @@ "kind": "section" }, { - "id": "680-zql#relationship-filters", + "id": "689-zql#relationship-filters", "title": "ZQL", "searchTitle": "Relationship Filters", "sectionTitle": "Relationship Filters", @@ -9252,7 +9378,7 @@ "kind": "section" }, { - "id": "681-zql#type-helpers", + "id": "690-zql#type-helpers", "title": "ZQL", "searchTitle": "Type Helpers", "sectionTitle": "Type Helpers", @@ -9262,7 +9388,7 @@ "kind": "section" }, { - "id": "682-zql#planning", + "id": "691-zql#planning", "title": "ZQL", "searchTitle": "Planning", "sectionTitle": "Planning", @@ -9272,7 +9398,7 @@ "kind": "section" }, { - "id": "683-zql#inspecting-query-plans", + "id": "692-zql#inspecting-query-plans", "title": "ZQL", "searchTitle": "Inspecting Query Plans", "sectionTitle": "Inspecting Query Plans", @@ -9282,7 +9408,7 @@ "kind": "section" }, { - "id": "684-zql#manually-flipping-joins", + "id": "693-zql#manually-flipping-joins", "title": "ZQL", "searchTitle": "Manually Flipping Joins", "sectionTitle": "Manually Flipping Joins", @@ -9292,7 +9418,7 @@ "kind": "section" }, { - "id": "685-zql#scalar-subqueries", + "id": "694-zql#scalar-subqueries", "title": "ZQL", "searchTitle": "Scalar Subqueries", "sectionTitle": "Scalar Subqueries", @@ -9302,7 +9428,7 @@ "kind": "section" }, { - "id": "686-zql#why-it-matters", + "id": "695-zql#why-it-matters", "title": "ZQL", "searchTitle": "Why It Matters", "sectionTitle": "Why It Matters", @@ -9312,7 +9438,7 @@ "kind": "section" }, { - "id": "687-zql#trade-offs", + "id": "696-zql#trade-offs", "title": "ZQL", "searchTitle": "Trade-offs", "sectionTitle": "Trade-offs", @@ -9322,7 +9448,7 @@ "kind": "section" }, { - "id": "688-zql#future-work", + "id": "697-zql#future-work", "title": "ZQL", "searchTitle": "Future Work", "sectionTitle": "Future Work", diff --git a/contents/docs/connecting-to-postgres.mdx b/contents/docs/connecting-to-postgres.mdx index 39e79f38..d939fe6e 100644 --- a/contents/docs/connecting-to-postgres.mdx +++ b/contents/docs/connecting-to-postgres.mdx @@ -69,6 +69,12 @@ ZERO_PG_SOCKET_INACTIVITY_TIMEOUT=600000 Set the value to `0` to disable the watchdog. +### WAL Sender Timeout + +`zero-cache` uses Postgres's `wal_sender_timeout` setting to monitor its replication connection. When the timeout is greater than `0`, Zero sends keepalives and reconnects if the replication stream stops responding. + +Setting `wal_sender_timeout` to `0` disables the timeout in Postgres and the related keepalive and reconnect checks in Zero. Other connection failure detection remains active. + ### Bounding WAL Size For development databases, you can set a `max_slot_wal_keep_size` value in Postgres. This will help limit the amount of WAL kept around. diff --git a/contents/docs/connection.mdx b/contents/docs/connection.mdx index e0ae6299..99ab2ec5 100644 --- a/contents/docs/connection.mdx +++ b/contents/docs/connection.mdx @@ -183,9 +183,9 @@ Reads are allowed while `disconnected`, but writes are rejected and return an of ### Error -If `zero-cache` itself crashes, or if the [mutate](/docs/mutators) or [query](/docs/queries) endpoints return a network or HTTP error, Zero transitions to the `error` state. +If `zero-cache` crashes, or [mutate](/docs/mutators) or [query](/docs/queries) endpoints fail, Zero enters the `error` state. If the response code is `5xx`, `zero-cache` will retry up to four times - other response codes are not retried. -This type of error is unlikely to resolve just by retrying, so Zero doesn't try. The app can retry the connection manually by calling `zero.connection.connect()`. +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. diff --git a/contents/docs/mutators.mdx b/contents/docs/mutators.mdx index 322e6888..f420729c 100644 --- a/contents/docs/mutators.mdx +++ b/contents/docs/mutators.mdx @@ -111,6 +111,8 @@ tx.mutate.user.insert({ }) ``` +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): ```tsx @@ -856,7 +858,7 @@ app.post('/api/zero/mutate', async c => { -If Zero receives any response from the mutate endpoint other than HTTP 200, 401, or 403, it will disconnect and enter the [error state](/docs/connection#error). +Mutate endpoint fetch failures and `5xx` responses get up to four total attempts. Exhausted retries and responses other than 200, 401, or 403 enter the [error state](/docs/connection#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. diff --git a/contents/docs/otel.mdx b/contents/docs/otel.mdx index ca49af39..6a55aac3 100644 --- a/contents/docs/otel.mdx +++ b/contents/docs/otel.mdx @@ -106,11 +106,11 @@ This callback is called before sending WebSocket messages that trigger API serve ## Metrics Reference - `view_syncer_lag` and `view_syncer_hydration` require - OpenTelemetry exponential histogram support. Prometheus - users must enable native histograms. Use the existing - `serving_lag` gauges if your backend does not support - them. + `view_syncer_lag`, `view_syncer_hydration`, and + `e2e_serving_lag` require OpenTelemetry exponential + histogram support. Prometheus users must enable native + histograms. Use the existing `serving_lag` gauges if your + backend does not support them.
@@ -152,39 +152,40 @@ This callback is called before sending WebSocket messages that trigger API serve ### zero.replication -| Metric | Type | Unit | Description | -| ------------------------------------ | --------- | ----- | -------------------------------------------------------------------------------------------------------------------- | -| `upstream_lag` | Gauge | ms | Latency from sending a replication report to receiving it in the stream | -| `replica_lag` | Gauge | ms | Latency from receiving a replication report to it reaching the replica | -| `total_lag` | Gauge | ms | Measured end-to-end latency of the most recently received replication report; does not grow if reports stop arriving | -| `last_total_lag` | Gauge | ms | Alias of `total_lag`, retained for dashboards that explicitly use the non-extrapolated metric | -| `lag_report_retries` | Counter | | Replication lag reports retried because an expected report did not arrive before the next report interval | -| `events` | Counter | | Number of replication events processed | -| `transactions` | Counter | | Count of replicated transactions | -| `changes` | Counter | | Count of replicated changes, including DML and DDL statements | -| `slot_health` | Gauge | 1 | One-hot status for the active logical replication slot: `ok`, `unreserved`, `lost`, `missing`, or `unknown` | -| `slot_retained_wal_bytes` | Gauge | bytes | WAL bytes retained by the active logical replication slot | -| `slot_safe_wal_bytes` | Gauge | bytes | Remaining WAL capacity before the active logical replication slot is lost; omitted when Postgres reports no value | -| `initial_sync_runs` | Counter | | Number of initial-sync runs | -| `initial_sync_duration` | Histogram | s | Wall-clock duration of an initial-sync run | -| `initial_sync_copy_duration` | Histogram | s | Wall-clock duration of the COPY phase for a successful initial-sync run | -| `initial_sync_copy_other_duration` | Histogram | s | Initial-sync duration excluding SQLite flush and index time for a successful run | -| `initial_sync_flush_duration` | Histogram | s | Total SQLite flush time for a successful initial-sync run | -| `initial_sync_index_duration` | Histogram | s | SQLite index creation time for a successful initial-sync run | -| `initial_sync_rows` | Counter | | Rows copied during successful initial-sync runs | -| `initial_sync_copy_stream` | Counter | bytes | PostgreSQL COPY stream bytes processed during initial sync, including in-progress and failed runs | -| `initial_sync_completed_copy_stream` | Counter | bytes | PostgreSQL COPY stream bytes processed during successful initial-sync runs | -| `initial_sync_copy_chunks` | Counter | | PostgreSQL COPY stream chunks processed during initial sync | -| `shadow-sync-runs` | Counter | | Number of [shadow initial-sync](/docs/zero-cache-config#shadow-sync-enabled) runs, labeled by `result` | -| `shadow-sync-duration` | Histogram | s | Wall-clock duration of a shadow initial-sync run, labeled by `result` | -| `flow_control.active_subscribers` | Gauge | | Active change-stream subscribers receiving live changes | -| `flow_control.queued_subscribers` | Gauge | | Change-stream subscribers waiting for the current transaction to finish before activation | -| `flow_control.pending_messages` | Gauge | | Downstream change-stream messages not yet acknowledged by subscribers | -| `flow_control.backlog_messages` | Gauge | | Live change-stream messages buffered while subscribers catch up | -| `flow_control.backlog_bytes` | Gauge | bytes | Live change-stream bytes buffered while subscribers catch up | -| `flow_control.max_backlog_bytes` | Gauge | bytes | Maximum live change-stream bytes buffered by a single subscriber | -| `flow_control.waits` | Counter | | Completed flow-control checkpoints | -| `flow_control.wait_duration` | Histogram | s | Time replication waits at flow-control checkpoints | +| Metric | Type | Unit | Description | +| ------------------------------------ | --------- | ----- | ------------------------------------------------------------------------------------------------------------------------------ | +| `upstream_lag` | Gauge | ms | Latency from sending a replication report to receiving it in the stream | +| `replica_lag` | Gauge | ms | Latency from receiving a replication report to it reaching the replica | +| `total_lag` | Gauge | ms | Measured end-to-end latency of the most recently received replication report; does not grow if reports stop arriving | +| `last_total_lag` | Gauge | ms | Alias of `total_lag`, retained for dashboards that explicitly use the non-extrapolated metric | +| `upstream_clock_skew` | Gauge | ms | Estimated offset of the upstream database clock relative to `zero-cache`; positive values mean upstream is ahead | +| `lag_report_retries` | Counter | | Replication lag reports retried because an expected report did not arrive before the next report interval | +| `events` | Counter | | Number of replication events processed | +| `transactions` | Counter | | Count of replicated transactions | +| `changes` | Counter | | Count of replicated changes, including DML and DDL statements | +| `slot_health` | Gauge | 1 | One-hot status for the active logical replication slot: `ok`, `unreserved`, `lost`, `missing`, or `unknown` | +| `slot_retained_wal_bytes` | Gauge | bytes | WAL bytes retained by the active logical replication slot | +| `slot_safe_wal_bytes` | Gauge | bytes | Remaining WAL capacity before the active logical replication slot is lost; omitted when Postgres reports no value | +| `initial_sync_runs` | Counter | | Number of initial-sync runs | +| `initial_sync_duration` | Histogram | s | Wall-clock duration of an initial-sync run | +| `initial_sync_copy_duration` | Histogram | s | Wall-clock duration of the COPY phase for a successful initial-sync run | +| `initial_sync_copy_other_duration` | Histogram | s | Initial-sync duration excluding SQLite flush and index time for a successful run | +| `initial_sync_flush_duration` | Histogram | s | Total SQLite flush time for a successful initial-sync run | +| `initial_sync_index_duration` | Histogram | s | SQLite index creation time for a successful initial-sync run | +| `initial_sync_rows` | Counter | | Rows copied during successful initial-sync runs | +| `initial_sync_copy_stream` | Counter | bytes | PostgreSQL COPY stream bytes, including failed runs; reported in approximately 8 MiB batches and flushed when the stream ends | +| `initial_sync_completed_copy_stream` | Counter | bytes | PostgreSQL COPY stream bytes processed during successful initial-sync runs | +| `initial_sync_copy_chunks` | Counter | | PostgreSQL COPY stream chunks processed during initial sync; batched with COPY-stream updates and flushed when the stream ends | +| `shadow-sync-runs` | Counter | | Number of [shadow initial-sync](/docs/zero-cache-config#shadow-sync-enabled) runs, labeled by `result` | +| `shadow-sync-duration` | Histogram | s | Wall-clock duration of a shadow initial-sync run, labeled by `result` | +| `flow_control.active_subscribers` | Gauge | | Active change-stream subscribers receiving live changes | +| `flow_control.queued_subscribers` | Gauge | | Change-stream subscribers waiting for the current transaction to finish before activation | +| `flow_control.pending_messages` | Gauge | | Downstream change-stream messages not yet acknowledged by subscribers | +| `flow_control.backlog_messages` | Gauge | | Live change-stream messages buffered while subscribers catch up | +| `flow_control.backlog_bytes` | Gauge | bytes | Live change-stream bytes buffered while subscribers catch up | +| `flow_control.max_backlog_bytes` | Gauge | bytes | Maximum live change-stream bytes buffered by a single subscriber | +| `flow_control.waits` | Counter | | Completed flow-control checkpoints | +| `flow_control.wait_duration` | Histogram | s | Time replication waits at flow-control checkpoints | `total_lag` and `last_total_lag` now report the same latest measured round trip and do not grow when reports stop arriving. Use `lag_report_retries` to detect a stalled or missing report stream. @@ -202,6 +203,8 @@ This callback is called before sending WebSocket messages that trigger API serve | `serving_lagging_client_groups` | Gauge | | Eligible active client groups with locally ready replica changes not yet served to clients | | `view_syncer_lag` | Histogram | s | Time from replica changes becoming ready to ViewSyncer output, sampled once per minute per eligible group | | `view_syncer_hydration` | Histogram | s | Time from a ViewSyncer query sync requiring hydration until output, per client group | +| `e2e_serving_lag` | Histogram | s | Completion latency from the upstream transaction commit through ViewSyncer output | +| `e2e_serving_lag_clamps` | Counter | | Negative end-to-end lag observations clamped to zero because the upstream clock was ahead | | `lock-wait-time` | Histogram | s | Time spent waiting to acquire the ViewSyncerService lock per operation | | `pipeline-resets` | Counter | | Count of pipeline resets, labeled by `reason` | | `hydration` | Counter | | Number of query hydrations | diff --git a/contents/docs/release-notes/1.9.mdx b/contents/docs/release-notes/1.9.mdx index 63604a56..55368730 100644 --- a/contents/docs/release-notes/1.9.mdx +++ b/contents/docs/release-notes/1.9.mdx @@ -9,36 +9,62 @@ description: Query Correctness and Reliability npm install @rocicorp/zero@1.9 ``` -You can use `zero-cache` from Docker Hub or GHCR: +## Overview -```bash -docker pull rocicorp/zero:1.9.0 -# or -docker pull ghcr.io/rocicorp/zero:1.9.0 -``` +Zero 1.9 improves query/mutation correctness and reliability. -## Overview +## Features -Zero 1.9 improves query correctness and `zero-cache` reliability. +- [**Litestream v5 restores:**](/docs/zero-cache-config#litestream-restore-using-v5) The official image now uses Litestream 0.5.15 for restores by default. It can restore legacy WAL or LTX backups, while Zero 1.9 continues writing legacy backups. [Legacy snapshots now retain the previous generation for six additional hours](/docs/zero-cache-config#litestream-snapshot-backup-interval-hours), preventing cleanup during an active restore. ([#6260](https://github.com/rocicorp/mono/pull/6260), [#6267](https://github.com/rocicorp/mono/pull/6267)) +- [**End-to-end serving lag:**](/docs/otel#zerosync) New metrics measure completed replicated work from the upstream transaction commit through `zero-cache` sync output. [Upstream clock-skew estimate](/docs/otel#zeroreplication) identifies measurements biased by clock differences. ([#6312](https://github.com/rocicorp/mono/pull/6312)) ## Performance -Deferred. +### First Mutation Latency + +On the first mutation, Zero Server fetches and caches PostgreSQL schema metadata. In benchmarks, that full request is **2.7x faster** (done in [#6292](https://github.com/rocicorp/mono/pull/6292), thanks [@diegopereira99](https://github.com/diegopereira99)!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. + + ## Fixes -- [Ordered queries now paginate and maintain windows correctly when cursor fields contain `NULL`, including compound tie-break fields and reverse walks.](https://github.com/rocicorp/mono/pull/6121) This prevents skipped rows, empty windows, and related `Bound should be set` failures. (thanks [@YevheniiKotyrlo](https://github.com/YevheniiKotyrlo)!) -- [Schema construction, CRUD mutators, and materialized views now preserve a key named `__proto__` as user data instead of invoking JavaScript's legacy prototype setter.](https://github.com/rocicorp/mono/pull/6185) (thanks [@tjenkinson](https://github.com/tjenkinson)!) -- [Clients now receive changed rows after a server-side query is rebuilt, instead of retaining stale results in a rare rehydration case.](https://github.com/rocicorp/mono/pull/6196) -- [`zero-cache` now bounds its SQLite prepared-statement caches with LRU eviction, preventing unbounded statement retention when applications generate many distinct query shapes.](https://github.com/rocicorp/mono/pull/6202) -- [Replication lag reports now retry when an expected report is missing](https://github.com/rocicorp/mono/pull/6187), and [serving-lag metrics exclude disconnected or not-yet-validated client groups](https://github.com/rocicorp/mono/pull/6219). See the updated [OpenTelemetry metric descriptions](/docs/otel#zeroreplication). -- [`zero-cache` now detects and resets PostgreSQL connections that stop carrying wire traffic](https://github.com/rocicorp/mono/pull/6220), [including over TLS](https://github.com/rocicorp/mono/pull/6221), allowing work to recover from proxy-created half-open sockets. See [Breaking Changes](#postgresql-socket-inactivity-timeout). -- [`zero-cache` now releases custom-query caches when client groups stop, preventing inactive groups from retaining timers and transformed queries.](https://github.com/rocicorp/mono/pull/6228) +- [Ordered queries now paginate and maintain windows correctly when cursor fields contain `NULL`, including compound tie-break fields and reverse walks.](https://github.com/rocicorp/mono/pull/6121) (thanks [@YevheniiKotyrlo](https://github.com/YevheniiKotyrlo)!) +- [Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results.](https://github.com/rocicorp/mono/pull/6196) +- [Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar `NOT EXISTS` now handles empty or `NULL` results.](https://github.com/rocicorp/mono/pull/6306) +- [Schema construction, CRUD mutators, and materialized views now preserve a key named `__proto__` as user data.](https://github.com/rocicorp/mono/pull/6185) (thanks [@tjenkinson](https://github.com/tjenkinson)!) +- SQLite statement caches now [retain at most 1,000 idle entries each](https://github.com/rocicorp/mono/pull/6202), terminated client groups [release custom-query timers and caches](https://github.com/rocicorp/mono/pull/6228), and large replica transactions [can spill dirty pages to WAL instead of retaining the complete write set in native memory](https://github.com/rocicorp/mono/pull/6311). +- [Missing replication-lag reports are retried and `total_lag` no longer grows when reports stop arriving](https://github.com/rocicorp/mono/pull/6187), while [serving-lag metrics exclude disconnected or not-yet-validated client groups](https://github.com/rocicorp/mono/pull/6219). +- `zero-cache` now recovers from [half-open PostgreSQL sockets](https://github.com/rocicorp/mono/pull/6220), [including over TLS](https://github.com/rocicorp/mono/pull/6221), and [the official image applies the bundled postgres.js disconnect patch](https://github.com/rocicorp/mono/pull/6310). +- [With PostgreSQL `wal_sender_timeout=0`, replication no longer enters a continuous reconnect loop.](https://github.com/rocicorp/mono/pull/6244) See [WAL Sender Timeout](/docs/connecting-to-postgres#wal-sender-timeout). +- [Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally.](https://github.com/rocicorp/mono/pull/6299) [Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics.](https://github.com/rocicorp/mono/pull/6308) +- [Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of `@rocicorp/zero`, fixing cross-package type and module-augmentation failures.](https://github.com/rocicorp/mono/pull/6231) +- [Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas.](https://github.com/rocicorp/mono/pull/6225) To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. +- [Expected schema and replica resets now log warnings instead of errors](https://github.com/rocicorp/mono/pull/6248), and [`zero-cache` skips Litestream restore when backups are not configured](https://github.com/rocicorp/mono/pull/6259). (thanks [@asterikx](https://github.com/asterikx)!) +- [Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts.](https://github.com/rocicorp/mono/pull/6280) (thanks [@shayonj](https://github.com/shayonj)!) +- [Mutation and query API calls now retry all `5xx` responses using the existing four-attempt limit and backoff; `4xx` responses still fail without retry.](https://github.com/rocicorp/mono/pull/6315) (thanks [@shayonj](https://github.com/shayonj)!) +- [SQLite corruption failures now log bounded replica and integrity diagnostics and flush logs before exit.](https://github.com/rocicorp/mono/pull/6215) [Oversized replication updates now identify the transaction, affected column, and value type without logging the value.](https://github.com/rocicorp/mono/pull/6318) ## Breaking Changes -### PostgreSQL Socket Inactivity Timeout - -`zero-cache` now monitors wire activity on its PostgreSQL connections. By default, it checks every two minutes and resets a connection after one to two inactive intervals. This recovers half-open connections, but can interrupt a long-running statement that legitimately produces no network traffic. +### Existing Primary-Key Inserts -If legitimate Postgres operations can remain silent for this long, set [`ZERO_PG_SOCKET_INACTIVITY_TIMEOUT`](/docs/connecting-to-postgres#socket-inactivity-timeout) on `zero-cache` to a longer interval in milliseconds. Set it to `0` to disable the watchdog. +`insert` now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. A successful `insert` does not necessarily prove that a row was created. diff --git a/contents/docs/zero-cache-config.mdx b/contents/docs/zero-cache-config.mdx index 6df03098..8083ac1e 100644 --- a/contents/docs/zero-cache-config.mdx +++ b/contents/docs/zero-cache-config.mdx @@ -332,6 +332,31 @@ Path to the litestream executable. This must be built from the `rocicorp/litestr flag: `--litestream-executable`
env: `ZERO_LITESTREAM_EXECUTABLE`
+### Litestream V5 Executable + +Path to the official Litestream v0.5.x executable used for restores when `ZERO_LITESTREAM_RESTORE_USING_V5` is enabled. Litestream v0.5.8 and later can restore both legacy WAL backups and LTX backups, choosing the format with the latest data. The official Zero Docker image includes Litestream 0.5.15 at this path. + +flag: `--litestream-executable-v5`
+env: `ZERO_LITESTREAM_EXECUTABLE_V5`
+ +### Litestream Restore Using V5 + +Use `ZERO_LITESTREAM_EXECUTABLE_V5` for restores when that executable is configured. If it is unavailable, Zero falls back to the legacy executable. Set this to `false` to force legacy restore behavior. + +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 makes rollback difficult because older versions cannot restore an LTX-only backup. + +flag: `--litestream-backup-using-v5`
+env: `ZERO_LITESTREAM_BACKUP_USING_V5`
+default: `false` + ### Litestream Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. @@ -407,7 +432,7 @@ default: `48` ### Litestream Snapshot Backup Interval Hours -The interval between snapshot backups of the replica. Snapshot backups make a full copy of the database to a new litestream generation. This improves restore time at the expense of bandwidth. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). +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. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: `--litestream-snapshot-backup-interval-hours`
env: `ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS`
From 243225432070c3d0bf28606603d2d9fed7bc36a7 Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Fri, 7 Aug 2026 12:49:45 -0700 Subject: [PATCH 03/17] chore: fixup --- contents/docs/connecting-to-postgres.mdx | 10 ++-------- contents/docs/connection.mdx | 2 +- contents/docs/mutators.mdx | 2 +- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/contents/docs/connecting-to-postgres.mdx b/contents/docs/connecting-to-postgres.mdx index d939fe6e..b83cbe2a 100644 --- a/contents/docs/connecting-to-postgres.mdx +++ b/contents/docs/connecting-to-postgres.mdx @@ -59,15 +59,9 @@ psql -c 'SHOW wal_level' ### Socket Inactivity Timeout -`zero-cache` monitors wire activity on its Postgres connections so it can recover when a proxy or network failure leaves a half-open socket. The watchdog samples each connection every 120,000 milliseconds by default and resets it after one to two intervals without any bytes read or written. In-flight queries on a reset connection are rejected and can recover through their normal retry or restart paths. +`zero-cache` monitors wire activity on its Postgres connections so it can recover when a proxy or network failure leaves a half-open socket. The watchdog samples each connection every 120,000 milliseconds and resets it after one to two intervals without any bytes read or written. In-flight queries on a reset connection are rejected and can recover through their normal retry or restart paths. -Wire activity resets the watchdog, so streaming operations such as `COPY` remain active. A statement that legitimately computes without sending any data for several minutes can be interrupted. Set `ZERO_PG_SOCKET_INACTIVITY_TIMEOUT` to a longer sampling interval in milliseconds when running such statements: - -```bash -ZERO_PG_SOCKET_INACTIVITY_TIMEOUT=600000 -``` - -Set the value to `0` to disable the watchdog. +Wire activity resets the watchdog, so streaming operations such as `COPY` remain active. A statement that legitimately computes without sending any data for several minutes can be interrupted. ### WAL Sender Timeout diff --git a/contents/docs/connection.mdx b/contents/docs/connection.mdx index 99ab2ec5..7a197c73 100644 --- a/contents/docs/connection.mdx +++ b/contents/docs/connection.mdx @@ -183,7 +183,7 @@ Reads are allowed while `disconnected`, but writes are rejected and return an of ### Error -If `zero-cache` crashes, or [mutate](/docs/mutators) or [query](/docs/queries) endpoints fail, Zero enters the `error` state. If the response code is `5xx`, `zero-cache` will retry up to four times - other response codes are not retried. +If `zero-cache` crashes, or [mutate](/docs/mutators) or [query](/docs/queries) 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. diff --git a/contents/docs/mutators.mdx b/contents/docs/mutators.mdx index f420729c..919ea384 100644 --- a/contents/docs/mutators.mdx +++ b/contents/docs/mutators.mdx @@ -858,7 +858,7 @@ app.post('/api/zero/mutate', async c => { -Mutate endpoint fetch failures and `5xx` responses get up to four total attempts. Exhausted retries and responses other than 200, 401, or 403 enter the [error state](/docs/connection#error). +Responses other than 200, 401, or 403 enter the [error state](/docs/connection#error). `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. From 079b0fe416ea00961af19108d0cf3c7c875ff7b6 Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Fri, 7 Aug 2026 12:54:14 -0700 Subject: [PATCH 04/17] chore: fixup --- contents/docs/release-notes/1.9.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contents/docs/release-notes/1.9.mdx b/contents/docs/release-notes/1.9.mdx index 55368730..46e6c60a 100644 --- a/contents/docs/release-notes/1.9.mdx +++ b/contents/docs/release-notes/1.9.mdx @@ -15,8 +15,8 @@ Zero 1.9 improves query/mutation correctness and reliability. ## Features -- [**Litestream v5 restores:**](/docs/zero-cache-config#litestream-restore-using-v5) The official image now uses Litestream 0.5.15 for restores by default. It can restore legacy WAL or LTX backups, while Zero 1.9 continues writing legacy backups. [Legacy snapshots now retain the previous generation for six additional hours](/docs/zero-cache-config#litestream-snapshot-backup-interval-hours), preventing cleanup during an active restore. ([#6260](https://github.com/rocicorp/mono/pull/6260), [#6267](https://github.com/rocicorp/mono/pull/6267)) -- [**End-to-end serving lag:**](/docs/otel#zerosync) New metrics measure completed replicated work from the upstream transaction commit through `zero-cache` sync output. [Upstream clock-skew estimate](/docs/otel#zeroreplication) identifies measurements biased by clock differences. ([#6312](https://github.com/rocicorp/mono/pull/6312)) +- [**Litestream v5 restores:**](/docs/zero-cache-config#litestream-restore-using-v5) The official image now uses Litestream 0.5.15 for restores by default, which can handle legacy formats. The backup writes to S3 still use the legacy format. [Also, legacy snapshots now retain the previous generation for six additional hours](/docs/zero-cache-config#litestream-snapshot-backup-interval-hours), preventing cleanup during an active restore. ([#6260](https://github.com/rocicorp/mono/pull/6260), [#6267](https://github.com/rocicorp/mono/pull/6267)) +- [**End-to-end serving lag:**](/docs/otel#zerosync) `e2e_serving_lag` measures completed replicated work from the upstream transaction commit through `zero-cache` sync output. [Upstream clock-skew estimate](/docs/otel#zeroreplication) identifies measurements biased by clock differences. ([#6312](https://github.com/rocicorp/mono/pull/6312)) ## Performance From 4b8a39400587818fcf80c2134666b0b6c49e6333 Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Fri, 7 Aug 2026 12:56:30 -0700 Subject: [PATCH 05/17] chore: update --- contents/docs/release-notes/1.9.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contents/docs/release-notes/1.9.mdx b/contents/docs/release-notes/1.9.mdx index 46e6c60a..a9c866fd 100644 --- a/contents/docs/release-notes/1.9.mdx +++ b/contents/docs/release-notes/1.9.mdx @@ -16,7 +16,7 @@ Zero 1.9 improves query/mutation correctness and reliability. ## Features - [**Litestream v5 restores:**](/docs/zero-cache-config#litestream-restore-using-v5) The official image now uses Litestream 0.5.15 for restores by default, which can handle legacy formats. The backup writes to S3 still use the legacy format. [Also, legacy snapshots now retain the previous generation for six additional hours](/docs/zero-cache-config#litestream-snapshot-backup-interval-hours), preventing cleanup during an active restore. ([#6260](https://github.com/rocicorp/mono/pull/6260), [#6267](https://github.com/rocicorp/mono/pull/6267)) -- [**End-to-end serving lag:**](/docs/otel#zerosync) `e2e_serving_lag` measures completed replicated work from the upstream transaction commit through `zero-cache` sync output. [Upstream clock-skew estimate](/docs/otel#zeroreplication) identifies measurements biased by clock differences. ([#6312](https://github.com/rocicorp/mono/pull/6312)) +- [**End-to-end serving lag:**](/docs/otel#zerosync) `e2e_serving_lag` measures completed replicated work from the upstream transaction commit through view-syncer poke. [Upstream clock-skew estimate](/docs/otel#zeroreplication) tries to identify measurements biased by clock differences. ([#6312](https://github.com/rocicorp/mono/pull/6312)) ## Performance From e71251857b64f740ab76ad62877126f85f962f2f Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Fri, 7 Aug 2026 13:00:23 -0700 Subject: [PATCH 06/17] chore: update --- contents/docs/release-notes/1.9.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contents/docs/release-notes/1.9.mdx b/contents/docs/release-notes/1.9.mdx index a9c866fd..eb0b44cc 100644 --- a/contents/docs/release-notes/1.9.mdx +++ b/contents/docs/release-notes/1.9.mdx @@ -20,9 +20,9 @@ Zero 1.9 improves query/mutation correctness and reliability. ## Performance -### First Mutation Latency +### Cold Mutation Latency -On the first mutation, Zero Server fetches and caches PostgreSQL schema metadata. In benchmarks, that full request is **2.7x faster** (done in [#6292](https://github.com/rocicorp/mono/pull/6292), thanks [@diegopereira99](https://github.com/diegopereira99)!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. +On the first mutation, Zero Server fetches and caches PostgreSQL schema metadata. The first mutation on startup is now **2.7x faster** in Zero 1.9 (done in [#6292](https://github.com/rocicorp/mono/pull/6292), thanks [@diegopereira99](https://github.com/diegopereira99)!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. Date: Wed, 12 Aug 2026 10:23:44 -0700 Subject: [PATCH 07/17] chore: update --- contents/docs/otel.mdx | 232 ++++++++++++++-------------- contents/docs/release-notes/1.9.mdx | 20 ++- 2 files changed, 126 insertions(+), 126 deletions(-) diff --git a/contents/docs/otel.mdx b/contents/docs/otel.mdx index 6a55aac3..321226ec 100644 --- a/contents/docs/otel.mdx +++ b/contents/docs/otel.mdx @@ -106,138 +106,140 @@ This callback is called before sending WebSocket messages that trigger API serve ## Metrics Reference - `view_syncer_lag`, `view_syncer_hydration`, and - `e2e_serving_lag` require OpenTelemetry exponential - histogram support. Prometheus users must enable native - histograms. Use the existing `serving_lag` gauges if your - backend does not support them. + `zero_sync_view_syncer_lag`, + `zero_sync_view_syncer_hydration`, and + `zero_sync_e2e_serving_lag` require OpenTelemetry + exponential histogram support. Prometheus users must + enable native histograms. Use the existing + `zero_sync_serving_lag` gauges if your backend does not + support them.
### zero.server -| Metric | Type | Unit | Description | -| ------------------------- | ------------- | ---- | ------------------------------------------------------------------------------------- | -| `uptime` | Gauge | s | Cumulative uptime, starting from when requests are served | -| `api.requests` | Counter | | Calls to user mutate and query APIs, including cleanup and auth-validation operations | -| `api.request_duration` | Histogram | s | End-to-end user API request duration, including retries | -| `api.attempts` | Counter | | HTTP fetch attempts made while calling user API endpoints | -| `api.attempt_duration` | Histogram | s | Duration of each API HTTP attempt, excluding retry delays | -| `api.in_flight` | UpDownCounter | | API requests currently in flight | -| `startup_duration` | Histogram | s | Time from starting `zero-cache` until it is ready | -| `worker_startup_duration` | Histogram | s | Time from starting a worker until it is ready | +| Metric | Type | Unit | Description | +| ------------------------------------- | ------------- | ---- | ------------------------------------------------------------------------------------- | +| `zero_server_uptime` | Gauge | s | Cumulative uptime, starting from when requests are served | +| `zero_server_api_requests` | Counter | | Calls to user mutate and query APIs, including cleanup and auth-validation operations | +| `zero_server_api_request_duration` | Histogram | s | End-to-end user API request duration, including retries | +| `zero_server_api_attempts` | Counter | | HTTP fetch attempts made while calling user API endpoints | +| `zero_server_api_attempt_duration` | Histogram | s | Duration of each API HTTP attempt, excluding retry delays | +| `zero_server_api_in_flight` | UpDownCounter | | API requests currently in flight | +| `zero_server_startup_duration` | Histogram | s | Time from starting `zero-cache` until it is ready | +| `zero_server_worker_startup_duration` | Histogram | s | Time from starting a worker until it is ready | ### zero.replica -| Metric | Type | Unit | Description | -| ------------------------------------------ | --------- | ----- | ----------------------------------------------------------------------------------------------------------------------- | -| `db_size` | Gauge | bytes | Size of the replica's main db file (excludes WAL) | -| `wal_size` | Gauge | bytes | Size of the replica's WAL file | -| `wal2_size` | Gauge | bytes | Size of the replica's WAL2 file (only if using wal2 mode) | -| `backup_lag` | Gauge | ms | Time since last litestream backup. Expected to sawtooth from 0 to `ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES` | -| `purge_blocked` | Counter | | Number of change-log purges blocked because the actual backup state could not be verified or is stale | -| `litestream.restore.runs` | Counter | | Litestream restore runs | -| `litestream.restore.attempts` | Counter | | Litestream restore subprocess attempts | -| `litestream.restore.db_bytes` | Counter | bytes | SQLite database bytes restored by successful Litestream restores | -| `litestream.restore.duration` | Histogram | s | Wall-clock duration of Litestream restore runs | -| `litestream.restore.wait_duration` | Histogram | s | Time spent waiting for replication-manager snapshot status before restoring | -| `litestream.restore.process_duration` | Histogram | s | Wall-clock duration of Litestream restore subprocesses | -| `litestream.restore.validation_duration` | Histogram | s | Time spent validating restored replica databases | -| `litestream.backup.process_runs` | Counter | | Litestream backup process exits | -| `litestream.backup.process_duration` | Histogram | s | Runtime of Litestream backup subprocesses before exit | -| `litestream.backup.list_duration` | Histogram | s | Time to list the Litestream backup destination | -| `litestream.backup.verification_duration` | Histogram | s | Time to verify backup state in the destination | -| `litestream.snapshot.reservation_duration` | Histogram | s | Time snapshot reservations are held while view-syncers restore and subscribe | +| Metric | Type | Unit | Description | +| ------------------------------------------------------- | --------- | ----- | ----------------------------------------------------------------------------------------------------------------------- | +| `zero_replica_db_size` | Gauge | bytes | Size of the replica's main db file (excludes WAL) | +| `zero_replica_wal_size` | Gauge | bytes | Size of the replica's WAL file | +| `zero_replica_wal2_size` | Gauge | bytes | Size of the replica's WAL2 file (only if using wal2 mode) | +| `zero_replica_backup_lag` | Gauge | ms | Time since last litestream backup. Expected to sawtooth from 0 to `ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES` | +| `zero_replica_purge_blocked` | Counter | | Number of change-log purges blocked because the actual backup state could not be verified or is stale | +| `zero_replica_litestream_restore_runs` | Counter | | Litestream restore runs | +| `zero_replica_litestream_restore_attempts` | Counter | | Litestream restore subprocess attempts | +| `zero_replica_litestream_restore_db_bytes` | Counter | bytes | SQLite database bytes restored by successful Litestream restores | +| `zero_replica_litestream_restore_duration` | Histogram | s | Wall-clock duration of Litestream restore runs | +| `zero_replica_litestream_restore_wait_duration` | Histogram | s | Time spent waiting for replication-manager snapshot status before restoring | +| `zero_replica_litestream_restore_process_duration` | Histogram | s | Wall-clock duration of Litestream restore subprocesses | +| `zero_replica_litestream_restore_validation_duration` | Histogram | s | Time spent validating restored replica databases | +| `zero_replica_litestream_backup_process_runs` | Counter | | Litestream backup process exits | +| `zero_replica_litestream_backup_process_duration` | Histogram | s | Runtime of Litestream backup subprocesses before exit | +| `zero_replica_litestream_backup_list_duration` | Histogram | s | Time to list the Litestream backup destination | +| `zero_replica_litestream_backup_verification_duration` | Histogram | s | Time to verify backup state in the destination | +| `zero_replica_litestream_snapshot_reservation_duration` | Histogram | s | Time snapshot reservations are held while view-syncers restore and subscribe | ### zero.replication -| Metric | Type | Unit | Description | -| ------------------------------------ | --------- | ----- | ------------------------------------------------------------------------------------------------------------------------------ | -| `upstream_lag` | Gauge | ms | Latency from sending a replication report to receiving it in the stream | -| `replica_lag` | Gauge | ms | Latency from receiving a replication report to it reaching the replica | -| `total_lag` | Gauge | ms | Measured end-to-end latency of the most recently received replication report; does not grow if reports stop arriving | -| `last_total_lag` | Gauge | ms | Alias of `total_lag`, retained for dashboards that explicitly use the non-extrapolated metric | -| `upstream_clock_skew` | Gauge | ms | Estimated offset of the upstream database clock relative to `zero-cache`; positive values mean upstream is ahead | -| `lag_report_retries` | Counter | | Replication lag reports retried because an expected report did not arrive before the next report interval | -| `events` | Counter | | Number of replication events processed | -| `transactions` | Counter | | Count of replicated transactions | -| `changes` | Counter | | Count of replicated changes, including DML and DDL statements | -| `slot_health` | Gauge | 1 | One-hot status for the active logical replication slot: `ok`, `unreserved`, `lost`, `missing`, or `unknown` | -| `slot_retained_wal_bytes` | Gauge | bytes | WAL bytes retained by the active logical replication slot | -| `slot_safe_wal_bytes` | Gauge | bytes | Remaining WAL capacity before the active logical replication slot is lost; omitted when Postgres reports no value | -| `initial_sync_runs` | Counter | | Number of initial-sync runs | -| `initial_sync_duration` | Histogram | s | Wall-clock duration of an initial-sync run | -| `initial_sync_copy_duration` | Histogram | s | Wall-clock duration of the COPY phase for a successful initial-sync run | -| `initial_sync_copy_other_duration` | Histogram | s | Initial-sync duration excluding SQLite flush and index time for a successful run | -| `initial_sync_flush_duration` | Histogram | s | Total SQLite flush time for a successful initial-sync run | -| `initial_sync_index_duration` | Histogram | s | SQLite index creation time for a successful initial-sync run | -| `initial_sync_rows` | Counter | | Rows copied during successful initial-sync runs | -| `initial_sync_copy_stream` | Counter | bytes | PostgreSQL COPY stream bytes, including failed runs; reported in approximately 8 MiB batches and flushed when the stream ends | -| `initial_sync_completed_copy_stream` | Counter | bytes | PostgreSQL COPY stream bytes processed during successful initial-sync runs | -| `initial_sync_copy_chunks` | Counter | | PostgreSQL COPY stream chunks processed during initial sync; batched with COPY-stream updates and flushed when the stream ends | -| `shadow-sync-runs` | Counter | | Number of [shadow initial-sync](/docs/zero-cache-config#shadow-sync-enabled) runs, labeled by `result` | -| `shadow-sync-duration` | Histogram | s | Wall-clock duration of a shadow initial-sync run, labeled by `result` | -| `flow_control.active_subscribers` | Gauge | | Active change-stream subscribers receiving live changes | -| `flow_control.queued_subscribers` | Gauge | | Change-stream subscribers waiting for the current transaction to finish before activation | -| `flow_control.pending_messages` | Gauge | | Downstream change-stream messages not yet acknowledged by subscribers | -| `flow_control.backlog_messages` | Gauge | | Live change-stream messages buffered while subscribers catch up | -| `flow_control.backlog_bytes` | Gauge | bytes | Live change-stream bytes buffered while subscribers catch up | -| `flow_control.max_backlog_bytes` | Gauge | bytes | Maximum live change-stream bytes buffered by a single subscriber | -| `flow_control.waits` | Counter | | Completed flow-control checkpoints | -| `flow_control.wait_duration` | Histogram | s | Time replication waits at flow-control checkpoints | - -`total_lag` and `last_total_lag` now report the same latest measured round trip and do not grow when reports stop arriving. Use `lag_report_retries` to detect a stalled or missing report stream. +| Metric | Type | Unit | Description | +| ----------------------------------------------------- | --------- | ----- | ------------------------------------------------------------------------------------------------------------------------------ | +| `zero_replication_upstream_lag` | Gauge | ms | Latency from sending a replication report to receiving it in the stream | +| `zero_replication_replica_lag` | Gauge | ms | Latency from receiving a replication report to it reaching the replica | +| `zero_replication_total_lag` | Gauge | ms | Measured end-to-end latency of the most recently received replication report; does not grow if reports stop arriving | +| `zero_replication_last_total_lag` | Gauge | ms | Alias of `zero_replication_total_lag`, retained for dashboards that explicitly use the non-extrapolated metric | +| `zero_replication_upstream_clock_skew` | Gauge | ms | Estimated offset of the upstream database clock relative to `zero-cache`; positive values mean upstream is ahead | +| `zero_replication_lag_report_retries` | Counter | | Replication lag reports retried because an expected report did not arrive before the next report interval | +| `zero_replication_events` | Counter | | Number of replication events processed | +| `zero_replication_transactions` | Counter | | Count of replicated transactions | +| `zero_replication_changes` | Counter | | Count of replicated changes, including DML and DDL statements | +| `zero_replication_slot_health` | Gauge | 1 | One-hot status for the active logical replication slot: `ok`, `unreserved`, `lost`, `missing`, or `unknown` | +| `zero_replication_slot_retained_wal_bytes` | Gauge | bytes | WAL bytes retained by the active logical replication slot | +| `zero_replication_slot_safe_wal_bytes` | Gauge | bytes | Remaining WAL capacity before the active logical replication slot is lost; omitted when Postgres reports no value | +| `zero_replication_initial_sync_runs` | Counter | | Number of initial-sync runs | +| `zero_replication_initial_sync_duration` | Histogram | s | Wall-clock duration of an initial-sync run | +| `zero_replication_initial_sync_copy_duration` | Histogram | s | Wall-clock duration of the COPY phase for a successful initial-sync run | +| `zero_replication_initial_sync_copy_other_duration` | Histogram | s | Initial-sync duration excluding SQLite flush and index time for a successful run | +| `zero_replication_initial_sync_flush_duration` | Histogram | s | Total SQLite flush time for a successful initial-sync run | +| `zero_replication_initial_sync_index_duration` | Histogram | s | SQLite index creation time for a successful initial-sync run | +| `zero_replication_initial_sync_rows` | Counter | | Rows copied during successful initial-sync runs | +| `zero_replication_initial_sync_copy_stream` | Counter | bytes | PostgreSQL COPY stream bytes, including failed runs; reported in approximately 8 MiB batches and flushed when the stream ends | +| `zero_replication_initial_sync_completed_copy_stream` | Counter | bytes | PostgreSQL COPY stream bytes processed during successful initial-sync runs | +| `zero_replication_initial_sync_copy_chunks` | Counter | | PostgreSQL COPY stream chunks processed during initial sync; batched with COPY-stream updates and flushed when the stream ends | +| `zero_replication_shadow_sync_runs` | Counter | | Number of [shadow initial-sync](/docs/zero-cache-config#shadow-sync-enabled) runs, labeled by `result` | +| `zero_replication_shadow_sync_duration` | Histogram | s | Wall-clock duration of a shadow initial-sync run, labeled by `result` | +| `zero_replication_flow_control_active_subscribers` | Gauge | | Active change-stream subscribers receiving live changes | +| `zero_replication_flow_control_queued_subscribers` | Gauge | | Change-stream subscribers waiting for the current transaction to finish before activation | +| `zero_replication_flow_control_pending_messages` | Gauge | | Downstream change-stream messages not yet acknowledged by subscribers | +| `zero_replication_flow_control_backlog_messages` | Gauge | | Live change-stream messages buffered while subscribers catch up | +| `zero_replication_flow_control_backlog_bytes` | Gauge | bytes | Live change-stream bytes buffered while subscribers catch up | +| `zero_replication_flow_control_max_backlog_bytes` | Gauge | bytes | Maximum live change-stream bytes buffered by a single subscriber | +| `zero_replication_flow_control_waits` | Counter | | Completed flow-control checkpoints | +| `zero_replication_flow_control_wait_duration` | Histogram | s | Time replication waits at flow-control checkpoints | + +`zero_replication_total_lag` and `zero_replication_last_total_lag` now report the same latest measured round trip and do not grow when reports stop arriving. Use `zero_replication_lag_report_retries` to detect a stalled or missing report stream. ### zero.sync -| Metric | Type | Unit | Description | -| ------------------------------------------ | ------------- | ---- | --------------------------------------------------------------------------------------------------------- | -| `max-protocol-version` | Gauge | | Highest sync protocol version seen from connecting clients | -| `active-clients` | UpDownCounter | | Number of currently connected sync clients | -| `active-client-groups` | Gauge | | Number of active ViewSyncerService instances in a syncer worker | -| `queries` | Gauge | | Active IVM pipelines across all client groups in a syncer worker | -| `rows` | Gauge | | CVR-tracked rows across all client groups in a syncer worker | -| `serving_lag` | Gauge | ms | Longest time locally ready replica changes have remained unserved across eligible active client groups | -| `serving_lag_stats` | Gauge | ms | Distribution of serving lag across eligible active client groups | -| `serving_lagging_client_groups` | Gauge | | Eligible active client groups with locally ready replica changes not yet served to clients | -| `view_syncer_lag` | Histogram | s | Time from replica changes becoming ready to ViewSyncer output, sampled once per minute per eligible group | -| `view_syncer_hydration` | Histogram | s | Time from a ViewSyncer query sync requiring hydration until output, per client group | -| `e2e_serving_lag` | Histogram | s | Completion latency from the upstream transaction commit through ViewSyncer output | -| `e2e_serving_lag_clamps` | Counter | | Negative end-to-end lag observations clamped to zero because the upstream clock was ahead | -| `lock-wait-time` | Histogram | s | Time spent waiting to acquire the ViewSyncerService lock per operation | -| `pipeline-resets` | Counter | | Count of pipeline resets, labeled by `reason` | -| `hydration` | Counter | | Number of query hydrations | -| `hydration-time` | Histogram | s | Time to hydrate a query | -| `advance-time` | Histogram | s | Time to advance all queries for a client group after applying a transaction | -| `poke.time` | Histogram | s | Time per poke transaction (excludes canceled/noop pokes) | -| `poke.transactions` | Counter | | Count of poke transactions | -| `poke.rows` | Counter | | Count of poked rows | -| `cvr.load_attempts` | Counter | | CVR load attempts | -| `cvr.load_duration` | Histogram | s | Time to load a CVR | -| `cvr.flush_attempts` | Counter | | CVR flush attempts | -| `cvr.flush-time` | Histogram | s | Time to flush a CVR transaction | -| `cvr.rows-flushed` | Counter | | Number of changed rows flushed to a CVR | -| `websocket.open_connections` | UpDownCounter | | Open client WebSocket connections | -| `websocket.connection_attempts` | Counter | | Client WebSocket connection attempts | -| `websocket.connection_successes` | Counter | | Client WebSocket connections successfully initialized | -| `websocket.connection_failures` | Counter | | Client WebSocket connection attempts that failed before initialization | -| `websocket.errors` | Counter | | Client WebSocket error events | -| `ivm.advance-time` | Histogram | s | Time to advance IVM queries in response to a single change | -| `ivm.conflict-rows-deleted` | Counter | | Rows deleted because they conflicted with an added row | -| `query.transformations` | Counter | | Number of query transformations performed | -| `query.transformation-time` | Histogram | s | Time to transform custom queries via API server | -| `query.transformation-hash-changes` | Counter | | Times a query transformation hash changed | -| `query.transformation-no-ops` | Counter | | Times a query transformation was a no-op | -| `query.row-set-signature-drifts` | Counter | | Unchanged query rehydrations whose row-set signature differs from the CVR, forcing a config-version bump | -| `query.same-hash-rehydrations-forced-bump` | Counter | | Same-hash query rehydrations that force a config-version bump so changed rows are delivered | +| Metric | Type | Unit | Description | +| ---------------------------------------------------- | ------------- | ---- | --------------------------------------------------------------------------------------------------------- | +| `zero_sync_max_protocol_version` | Gauge | | Highest sync protocol version seen from connecting clients | +| `zero_sync_active_clients` | UpDownCounter | | Number of currently connected sync clients | +| `zero_sync_active_client_groups` | Gauge | | Number of active ViewSyncerService instances in a syncer worker | +| `zero_sync_queries` | Gauge | | Active IVM pipelines across all client groups in a syncer worker | +| `zero_sync_rows` | Gauge | | CVR-tracked rows across all client groups in a syncer worker | +| `zero_sync_serving_lag` | Gauge | ms | Longest time locally ready replica changes have remained unserved across eligible active client groups | +| `zero_sync_serving_lag_stats` | Gauge | ms | Distribution of serving lag across eligible active client groups | +| `zero_sync_serving_lagging_client_groups` | Gauge | | Eligible active client groups with locally ready replica changes not yet served to clients | +| `zero_sync_view_syncer_lag` | Histogram | s | Time from replica changes becoming ready to ViewSyncer output, sampled once per minute per eligible group | +| `zero_sync_view_syncer_hydration` | Histogram | s | Time from a ViewSyncer query sync requiring hydration until output, per client group | +| `zero_sync_e2e_serving_lag` | Histogram | s | Completion latency from the upstream transaction commit through ViewSyncer output | +| `zero_sync_e2e_serving_lag_clamps` | Counter | | Negative end-to-end lag observations clamped to zero because the upstream clock was ahead | +| `zero_sync_lock_wait_time` | Histogram | s | Time spent waiting to acquire the ViewSyncerService lock per operation | +| `zero_sync_pipeline_resets` | Counter | | Count of pipeline resets, labeled by `reason` | +| `zero_sync_hydration` | Counter | | Number of query hydrations | +| `zero_sync_hydration_time` | Histogram | s | Time to hydrate a query | +| `zero_sync_advance_time` | Histogram | s | Time to advance all queries for a client group after applying a transaction | +| `zero_sync_poke_time` | Histogram | s | Time per poke transaction (excludes canceled/noop pokes) | +| `zero_sync_poke_transactions` | Counter | | Count of poke transactions | +| `zero_sync_poke_rows` | Counter | | Count of poked rows | +| `zero_sync_cvr_load_attempts` | Counter | | CVR load attempts | +| `zero_sync_cvr_load_duration` | Histogram | s | Time to load a CVR | +| `zero_sync_cvr_flush_attempts` | Counter | | CVR flush attempts | +| `zero_sync_cvr_flush_time` | Histogram | s | Time to flush a CVR transaction | +| `zero_sync_cvr_rows_flushed` | Counter | | Number of changed rows flushed to a CVR | +| `zero_sync_websocket_open_connections` | UpDownCounter | | Open client WebSocket connections | +| `zero_sync_websocket_connection_attempts` | Counter | | Client WebSocket connection attempts | +| `zero_sync_websocket_connection_successes` | Counter | | Client WebSocket connections successfully initialized | +| `zero_sync_websocket_connection_failures` | Counter | | Client WebSocket connection attempts that failed before initialization | +| `zero_sync_websocket_errors` | Counter | | Client WebSocket error events | +| `zero_sync_ivm_advance_time` | Histogram | s | Time to advance IVM queries in response to a single change | +| `zero_sync_ivm_conflict_rows_deleted` | Counter | | Rows deleted because they conflicted with an added row | +| `zero_sync_query_transformations` | Counter | | Number of query transformations performed | +| `zero_sync_query_transformation_time` | Histogram | s | Time to transform custom queries via API server | +| `zero_sync_query_transformation_hash_changes` | Counter | | Times a query transformation hash changed | +| `zero_sync_query_transformation_no_ops` | Counter | | Times a query transformation was a no-op | +| `zero_sync_query_row_set_signature_drifts` | Counter | | Unchanged query rehydrations whose row-set signature differs from the CVR, forcing a config-version bump | +| `zero_sync_query_same_hash_rehydrations_forced_bump` | Counter | | Same-hash query rehydrations that force a config-version bump so changed rows are delivered | Serving-lag metrics include only client groups with at least one connected client and a validated background connection context. Retained groups without an eligible connection do not contribute lag. ### zero.mutation -| Metric | Type | Unit | Description | -| -------- | ------- | ---- | ------------------------------------ | -| `crud` | Counter | | Number of CRUD mutations processed | -| `custom` | Counter | | Number of custom mutations processed | -| `pushes` | Counter | | Number of pushes processed | +| Metric | Type | Unit | Description | +| ---------------------- | ------- | ---- | ------------------------------------ | +| `zero_mutation_crud` | Counter | | Number of CRUD mutations processed | +| `zero_mutation_custom` | Counter | | Number of custom mutations processed | +| `zero_mutation_pushes` | Counter | | Number of pushes processed | diff --git a/contents/docs/release-notes/1.9.mdx b/contents/docs/release-notes/1.9.mdx index eb0b44cc..b4e1001e 100644 --- a/contents/docs/release-notes/1.9.mdx +++ b/contents/docs/release-notes/1.9.mdx @@ -15,14 +15,13 @@ Zero 1.9 improves query/mutation correctness and reliability. ## Features -- [**Litestream v5 restores:**](/docs/zero-cache-config#litestream-restore-using-v5) The official image now uses Litestream 0.5.15 for restores by default, which can handle legacy formats. The backup writes to S3 still use the legacy format. [Also, legacy snapshots now retain the previous generation for six additional hours](/docs/zero-cache-config#litestream-snapshot-backup-interval-hours), preventing cleanup during an active restore. ([#6260](https://github.com/rocicorp/mono/pull/6260), [#6267](https://github.com/rocicorp/mono/pull/6267)) - [**End-to-end serving lag:**](/docs/otel#zerosync) `e2e_serving_lag` measures completed replicated work from the upstream transaction commit through view-syncer poke. [Upstream clock-skew estimate](/docs/otel#zeroreplication) tries to identify measurements biased by clock differences. ([#6312](https://github.com/rocicorp/mono/pull/6312)) ## Performance ### Cold Mutation Latency -On the first mutation, Zero Server fetches and caches PostgreSQL schema metadata. The first mutation on startup is now **2.7x faster** in Zero 1.9 (done in [#6292](https://github.com/rocicorp/mono/pull/6292), thanks [@diegopereira99](https://github.com/diegopereira99)!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. +Before running mutations, Zero Server fetches and caches PostgreSQL schema metadata. This is now **2.7x faster** in Zero 1.9 (done in [#6292](https://github.com/rocicorp/mono/pull/6292), thanks [@diegopereira99](https://github.com/diegopereira99)!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. Date: Wed, 12 Aug 2026 11:26:56 -0700 Subject: [PATCH 08/17] docs: include final Zero 1.9 fixes --- .releases/1.9/benchmarks/6292.md | 2 +- .releases/1.9/commits.md | 26 +- assets/search-index.json | 560 ++++++++++++---------------- contents/docs/release-notes/1.9.mdx | 6 +- 4 files changed, 267 insertions(+), 327 deletions(-) diff --git a/.releases/1.9/benchmarks/6292.md b/.releases/1.9/benchmarks/6292.md index 07cc3c5d..97f1999e 100644 --- a/.releases/1.9/benchmarks/6292.md +++ b/.releases/1.9/benchmarks/6292.md @@ -10,7 +10,7 @@ This is not a general process-startup claim. The measurement excludes process la - Zero 1.8: `zero/v1.8.0` at `cdc02598f137ab4e071878f5674fdc716dbbc69d` - Zero 1.9 change: `67c8fe4c9d9a5673357bb80116c50d968e54c2e2`, the #6292 commit -- Zero 1.9 release target: `b90e79d8fcc9db1cf33eccab5f651fc93895f5a6` +- Zero 1.9 release target: `8223561de0895036bacdce5d2ea599c1a3e1e62d` - No `packages/zero-server` production file changed between the #6292 commit and the release target. - Latest benchmark harness: `45706aad581b283037ce0b25130de5c1f27ac086` - Benchmark source SHA-1: `872b0cbe5f61862e53cb8451940ff4664a39b78e` diff --git a/.releases/1.9/commits.md b/.releases/1.9/commits.md index 9d956e94..208426bc 100644 --- a/.releases/1.9/commits.md +++ b/.releases/1.9/commits.md @@ -11,12 +11,12 @@ Status: audit and public draft updated through the reconstructed maintenance tar - Previous ref: `zero/v1.8.0` - Previous SHA: `cdc02598f137ab4e071878f5674fdc716dbbc69d` - Target ref: `origin/maint/zero/v1.9`, reconstructed and published maintenance target -- Target SHA: `b90e79d8fcc9db1cf33eccab5f651fc93895f5a6` +- Target SHA: `8223561de0895036bacdce5d2ea599c1a3e1e62d` - Merge base: `2279e783edd94aaa20fdcc8e067860ad0c21d95b` - Reconstruction base: `ef892a123a11461e74a59a4b59ad310ba23180b3` -- Raw non-merge range: 63 commits +- Raw non-merge range: 65 commits - Patch-equivalent commits already shipped in 1.8: 15 -- Unique 1.9 commits: 48 +- Unique 1.9 commits: 50 Commands used: @@ -30,7 +30,7 @@ git log --right-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1 git log --left-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1.8.0...origin/maint/zero/v1.9 git log --format='%H%x09%s%n%b' 2279e783edd94aaa20fdcc8e067860ad0c21d95b..zero/v1.8.0 git show zero/v1.8.0:packages/zero-protocol/src/protocol-version.ts -git show b90e79d8fcc9db1cf33eccab5f651fc93895f5a6:packages/zero-protocol/src/protocol-version.ts +git show 8223561de0895036bacdce5d2ea599c1a3e1e62d:packages/zero-protocol/src/protocol-version.ts ``` ## Protocol Compatibility @@ -72,9 +72,9 @@ The previous-release side contains no additional `cherry-pick -x` trailers namin ## Maintenance Reconstruction -The target was rebuilt from shared mainline commit `ef892a123` by applying 22 selected, signed mainline commits in topological order with provenance trailers, then fast-forwarded with signed #6318 PR-head and #6312 canonical-main backports. This removes the bulk-cherry-pick timestamps from the old maintenance line and makes every selected change independently traceable to its source. +The target was rebuilt from shared mainline commit `ef892a123` by applying 22 selected, signed mainline commits in topological order with provenance trailers, then fast-forwarded with signed #6318 PR-head and #6312 canonical-main backports, a signed #6326 canonical-main backport, and the signed #6341 PR head. This removes the bulk-cherry-pick timestamps from the old maintenance line and makes every selected change independently traceable to its source. -The old maintenance-only commits `e91f964a7` and `c073aa39e` are represented by reconstructed commit `49b13e3e5`, the canonical #6280 patch. The resulting production code and tests are byte-identical to main's #6280 tree. Reconstructed #6311 differs in patch ID only because the 1.9 parent infers an unchanged test callback parameter type where main's rmv2-era parent spells out `unknown`; the production change, new transaction tests, and resulting behavior are identical. #6318 similarly omits only an unrelated mainline rollback helper that is absent from 1.9; its oversized-binding diagnostics and regression test match the PR. #6312's entire `packages/zero-cache` patch is patch-equivalent to main; the backport omits only four generated API snapshots because 1.9 predates the #6239 snapshot infrastructure. +The old maintenance-only commits `e91f964a7` and `c073aa39e` are represented by reconstructed commit `49b13e3e5`, the canonical #6280 patch. The resulting production code and tests are byte-identical to main's #6280 tree. Reconstructed #6311 differs in patch ID only because the 1.9 parent infers an unchanged test callback parameter type where main's rmv2-era parent spells out `unknown`; the production change, new transaction tests, and resulting behavior are identical. #6318 similarly omits only an unrelated mainline rollback helper that is absent from 1.9; its oversized-binding diagnostics and regression test match the PR. #6312's entire `packages/zero-cache` patch is patch-equivalent to main; the backport omits only four generated API snapshots because 1.9 predates the #6239 snapshot infrastructure. #6326 applies cleanly; its patch ID differs because its change-processor cleanup overlaps the tailored #6318 backport already on the maintenance branch. #6341 omits only RMv2 change-log diagnostic registration absent from 1.9 and preserves the PR's behavior for every SQLite database used by this release. All other reconstructed commits are patch-equivalent to their named mainline source. Main-only rmv2 work, #6307's breaking scalar type enforcement, #6309's nullability-specific optimization, #6314's experimental Litestream update, and #6317's rmv2 test timeout remain excluded. @@ -145,6 +145,8 @@ All other reconstructed commits are patch-equivalent to their named mainline sou | [`bb9540345`](https://github.com/rocicorp/mono/pull/6315) | fix | - | Retries all API-server `5xx` responses instead of only `502` and `504`, preventing brief overloads and deployments from immediately surfacing as `PushFailed` or `TransformFailed`. | Include publicly. The existing four-attempt limit, exponential backoff, error bodies, and metrics remain unchanged; `4xx` responses still fail without retry. Parameterized tests cover `500`, `502`, `503`, `504`, and `599`. Credit external contributor [@shayonj](https://github.com/shayonj). | | [`2c4a3db6b`](https://github.com/rocicorp/mono/pull/6318) | fix | - | Enriches oversized SQLite update-binding failures with transaction, relation, table, column, value type, and size context while preserving the native `RangeError` as the cause. | Include publicly as an operator diagnostic. Customer values are not included, and the change does not make oversized values replicable; it identifies the upstream poison transaction that must be corrected. A regression test covers bigint context and error causality. No protocol or schema-hash change. | | [`b90e79d8f`](https://github.com/rocicorp/mono/pull/6312) | feature | - | Measures end-to-end serving lag from an upstream commit through ViewSyncer output, reports upstream clock-skew estimates, and counts negative lag observations that were clamped to zero. | Include publicly as operator observability. The optional `commitTimeMs` field is additive and the change stream parses in passthrough mode, so old peers ignore it and new peers accept its absence. Tests cover protocol compatibility, notification coalescing, clock-skew estimation, lag completion, and clamp reporting. | +| [`daa5c9a32`](https://github.com/rocicorp/mono/pull/6326) | fix | - | Propagates fatal replica-writer failures through replication status and exits `zero-cache` with a failure code instead of allowing incremental replication to stop silently. | Include publicly as an operator reliability fix. Tests cover error publication, rejection from incremental replication, and nonzero parent-process exit when workers terminate outside graceful drain. The published replication error remains generic and safe while detailed diagnostics stay in logs. | +| [`8223561de`](https://github.com/rocicorp/mono/pull/6341) | fix | - | Keeps lightweight SQLite corruption diagnostics enabled but makes synchronous full-database `quick_check` and `integrity_check` scans opt-in, avoiding long delays before fatal failures propagate. | Include publicly with #6215. The hidden `ZERO_SQLITE_CORRUPTION_CHECKS` option defaults to false and is intentionally not promoted as supported configuration. Tests cover default-skipped and explicitly enabled checks, and the option is propagated to all 1.9 fatal diagnostic targets. | ## Breaking-Change Review @@ -192,6 +194,8 @@ Human review identified three breaking behavioral or operational changes: the Po - Serving replica writes spilling to WAL instead of retaining unbounded native dirty-page state (#6311). - Mutation and query API requests retrying transient `5xx` responses (#6315). - Oversized replication update failures reporting transaction, relation, table, column, type, and size context without customer values (#6318). +- Fatal replica-writer failures surfacing through replication status and terminating `zero-cache` with a failure code (#6326). +- SQLite corruption failures no longer running potentially long full-database checks by default (#6341). ### Performance @@ -231,7 +235,7 @@ Every non-skipped commit is represented or intentionally omitted above. - `contents/docs/self-host.mdx`: document the v5 reader/legacy writer rollout, rollback floor, opt-out, custom-config validation, and Age incompatibility. - `contents/docs/otel.mdx`: retain reviewed lag corrections; document batched initial-sync counters, the `litestream` restore label/default change, and #6312's end-to-end serving-lag, clamp, and clock-skew metrics. - `contents/docs/queries.mdx`: no change required; it already states the corrected `Zero.run` default and `{type: 'complete'}` behavior. -- `contents/docs/release-notes/1.9.mdx`: include the reconstructed maintenance fixes and the validated #6292 first-mutation benchmark without broadening it into a general startup claim. +- `contents/docs/release-notes/1.9.mdx`: include the reconstructed maintenance fixes, including #6326 and #6341, and the validated #6292 first-mutation benchmark without broadening it into a general startup claim. - `contents/docs/release-notes/index.mdx`: no change required while the existing description remains unchanged. - Generated search and LLM artifacts: regenerate after product-doc edits. @@ -260,6 +264,8 @@ Every non-skipped commit is represented or intentionally omitted above. - Include #6280, #6299, #6306, #6310, #6292, #6308, #6311, and #6315 as public fixes; omit #6301 as inline documentation only. - Include #6318 as an operator diagnostic without claiming that oversized values can replicate successfully. - Include #6312 as additive operator observability and call out that upstream clock skew can bias end-to-end lag. +- Include #6326 as an operator reliability fix so fatal replica-writer failures are visible and restartable. +- Include #6341 with #6215 and keep its diagnostic opt-in hidden rather than promoting it as supported configuration. - Include #6292 in the Performance section using the 10-run Zero 1.8 versus Zero 1.9 comparison, scoped to first mutation handling with uncached server-schema metadata. Remaining blockers: @@ -274,15 +280,17 @@ Human review selected and published the reconstructed maintenance target, retain ## Validation -- Audit coverage: PASS. All 63 raw-range commits have exactly one decision row; all 15 patch-equivalent 1.8 backports are recorded. +- Audit coverage: PASS. All 65 raw-range commits have exactly one decision row; all 15 patch-equivalent 1.8 backports are recorded. - Protocol compatibility: PASS. - Placeholder links: PASS. No `TODO`, `TBD`, or `PLACEHOLDER` markers remain in the audit or release note. -- Maintenance history: PASS. All 24 maintenance commits are signed. Twenty-one are patch-equivalent to main; #6311 differs only by an unchanged inferred test callback type, #6318 omits only an unrelated rollback helper absent from 1.9, and #6312 omits generated API snapshots whose infrastructure is absent from 1.9. Their selected production changes and added tests match their sources. +- Maintenance history: PASS. All 26 maintenance commits are signed. Twenty-one are patch-equivalent to main; #6311 differs only by an unchanged inferred test callback type, #6318 omits only an unrelated rollback helper absent from 1.9, #6312 omits generated API snapshots whose infrastructure is absent from 1.9, #6326 overlaps the tailored #6318 backport, and #6341 omits RMv2-only change-log registration. Their selected production changes and added tests match their sources. - Mono targeted tests: PASS. zero-client 645, selected zero-cache 150, zero-server 433, z2s 65, zqlite 192, and scalar PostgreSQL integration 5. - Mono full zero-cache test: PASS after #6312 with 4,012 passed and 32 skipped across 301 test files. - Mono static validation: PASS. All 42 typecheck/build tasks, formatting, dependency verification, and type-aware lint completed; lint reported 0 errors and 1,512 warnings. - #6318 validation: PASS. The 46-test change-processor suite, zero-cache typecheck, formatting, and lint completed; lint reported 0 errors and 432 warnings. - #6312 validation: PASS. The complete zero-cache suite, zero-cache typecheck, formatting, and lint completed; lint reported 0 errors and 432 warnings. Its Zero Cache patch ID matches canonical main commit `0beb0ba76`. +- #6326 validation: PASS. The 75 affected life-cycle, incremental-sync, and change-processor tests, zero-cache typecheck, and zero-cache formatting completed. +- #6341 validation: PASS. The 8 affected SQLite-corruption and logging tests, zero-cache typecheck, and zero-cache formatting completed. - Release image: PASS. `@rocicorp/zero@1.9.0` packed and the linux/amd64 Docker build completed with the relocated `postgres@3.4.7` patch copied and applied by the image's generated pnpm workspace. - Docs formatting: PASS with `pnpm check-format` after formatting the generated search index. - Docs types: PASS with `pnpm check-types`. diff --git a/assets/search-index.json b/assets/search-index.json index 5e425861..0fb4047b 100644 --- a/assets/search-index.json +++ b/assets/search-index.json @@ -407,7 +407,7 @@ "title": "Connecting to Postgres", "searchTitle": "Connecting to Postgres", "url": "/docs/connecting-to-postgres", - "content": "In the future, Zero will work with many different backend databases. Today only Postgres is supported. Specifically, Zero requires Postgres v15.0 or higher, and support for logical replication. Here are some common Postgres options and what we know about their support level: Event Triggers Zero uses Postgres “Event Triggers” when possible to implement high-quality, efficient schema migration. Some hosted Postgres providers don't provide access to Event Triggers. Zero still works out of the box with these providers, but for correctness, any schema change triggers a full reset of all server-side and client-side state. For small databases (< 10GB) this can be OK, but for bigger databases you should either manually tell Zero about the schema change or choose a provider with event trigger support. Configuration WAL Level The Postgres wal_level config parameter has to be set to logical. You can check what level your pg has with this command: psql -c 'SHOW wal_level' If it doesn’t output logical then you need to change the wal level. To do this, run: psql -c \"ALTER SYSTEM SET wal_level = 'logical';\" Then restart Postgres. On most pg systems you can do this like so: data_dir=$(psql -t -A -c 'SHOW data_directory') pg_ctl -D \"$data_dir\" restart After your server restarts, show the wal_level again to ensure it has changed: psql -c 'SHOW wal_level' Socket Inactivity Timeout zero-cache monitors wire activity on its Postgres connections so it can recover when a proxy or network failure leaves a half-open socket. The watchdog samples each connection every 120,000 milliseconds by default and resets it after one to two intervals without any bytes read or written. In-flight queries on a reset connection are rejected and can recover through their normal retry or restart paths. Wire activity resets the watchdog, so streaming operations such as COPY remain active. A statement that legitimately computes without sending any data for several minutes can be interrupted. Set ZERO_PG_SOCKET_INACTIVITY_TIMEOUT to a longer sampling interval in milliseconds when running such statements: ZERO_PG_SOCKET_INACTIVITY_TIMEOUT=600000 Set the value to 0 to disable the watchdog. WAL Sender Timeout zero-cache uses Postgres's wal_sender_timeout setting to monitor its replication connection. When the timeout is greater than 0, Zero sends keepalives and reconnects if the replication stream stops responding. Setting wal_sender_timeout to 0 disables the timeout in Postgres and the related keepalive and reconnect checks in Zero. Other connection failure detection remains active. Bounding WAL Size For development databases, you can set a max_slot_wal_keep_size value in Postgres. This will help limit the amount of WAL kept around. This is a configuration parameter that bounds the amount of WAL kept around for replication slots, and invalidates the slots that are too far behind. Zero-cache will automatically detect if the replication slot has been invalidated and re-sync replicas from scratch. This configuration can cause problems like slot has been invalidated because it exceeded the maximum reserved size and is not recommended for production databases. Provider-Specific Notes PlanetScale for Postgres Roles zero-cache should connect using the default role that PlanetScale provides, because PlanetScale user-defined roles cannot create replication slots. Connection Limits Change max_connections to at least 100. The default is 25, which is too low for Zero in most configurations. Pooling Make sure to only use a direct connection for the ZERO_UPSTREAM_DB, and use pooled URLs for ZERO_CVR_DB, ZERO_CHANGE_DB, and your API (see Deployment). High Availability PlanetScale Postgres can fail over to a standby during maintenance or an outage. By default a logical replication slot does not survive promotion of a standby, so after a failover zero-cache would find its slot missing and re-sync every replica from scratch. To avoid this, first, run zero-cache with ZERO_UPSTREAM_PG_REPLICATION_SLOT_FAILOVER=true so it creates failover-enabled slots. Then, run the script below to register Zero's replication slots with PlanetScale and enable the two cluster parameters failover needs: APP=\"\" # your ZERO_APP_ID — on Zero Cloud this is your instance ID ORG=\"\" # PlanetScale organization DB=\"\" # PlanetScale database BRANCH=\"main\" SHARD=\"0\" if [ -z \"$APP\" ] || [ -z \"$ORG\" ] || [ -z \"$DB\" ]; then echo \"Set APP, ORG, and DB first — nothing was sent.\" elif pscale api -X PATCH \"organizations/${ORG}/databases/${DB}/branches/${BRANCH}/changes\" --input=- >/dev/null </dev/null <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 } }) 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 endpoint failures remain after retries, Zero enters the error state. Endpoint fetch failures and 5xx responses get up to four total attempts; 4xx responses are not retried. 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: 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 '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 } }) 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.", "headings": [ { "text": "Overview", @@ -893,7 +893,7 @@ "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 endpoint failures remain after retries, Zero enters the error state. Endpoint fetch failures and 5xx responses get up to four total attempts; 4xx responses are not retried. 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": "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.", "kind": "section" }, { @@ -933,7 +933,7 @@ "sectionTitle": "Error", "sectionId": "error", "url": "/docs/connection", - "content": "If zero-cache crashes, or mutate or query endpoint failures remain after retries, Zero enters the error state. Endpoint fetch failures and 5xx responses get up to four total attempts; 4xx responses are not retried. 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}`) ) }) })", + "content": "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}`) ) }) })", "kind": "section" }, { @@ -1649,7 +1649,7 @@ "title": "Install Zero", "searchTitle": "Install Zero", "url": "/docs/install", - "content": "This guide shows how to add Zero to an existing TypeScript-based web app. For a concrete end-to-end walkthrough, build the music app in the tutorial. Integrate Zero Set Up Your Database You'll need a Postgres database with logical replication enabled for development. # IMPORTANT: logical WAL level is required for Zero # to sync data to its SQLite replica docker run -d --name zero-postgres \\ -e POSTGRES_DB=\"zero\" \\ -e POSTGRES_PASSWORD=\"pass\" \\ -p 5432:5432 \\ postgres:18 \\ postgres -c wal_level=logical# Start Postgres.app first. Requires Postgres 15 or higher. # If these already exist, you can skip those commands. createuser -s postgres createdb -O postgres zero psql -d postgres -c \"ALTER USER postgres WITH PASSWORD 'pass';\" psql -d postgres -c \"ALTER SYSTEM SET wal_level = 'logical';\" # Restart Postgres.app, then verify: psql -d postgres -c \"SHOW wal_level;\" See Provider Support and make sure wal_level is logical. Create a .env file so your app server and zero-cache-dev use the same Postgres connection: # Update to your app's database connection URL ZERO_UPSTREAM_DB=\"postgres://postgres:pass@localhost:5432/zero\" Install Zero Add Zero and the validator used in these examples: npm install @rocicorp/zero zodpnpm add @rocicorp/zero zod # Note: pnpm disables postinstall scripts by default for security. # Create or update pnpm-workspace.yaml to allow the native package build: # https://pnpm.io/settings#allowbuilds # allowBuilds: # '@rocicorp/zero-sqlite3': true pnpm rebuild @rocicorp/zero-sqlite3bun add @rocicorp/zero zod # Note: Bun disables postinstall scripts by default for security. # Either approve the build: bun pm trust @rocicorp/zero-sqlite3 # Or add to package.json, then rebuild the native packages: # \"trustedDependencies\": [\"@rocicorp/zero-sqlite3\"]yarn add @rocicorp/zero zod # Note: Modern Yarn doesn't run postinstall scripts by default. # Add to package.json, then rebuild the native packages: # \"dependenciesMeta\": { # \"@rocicorp/zero-sqlite3\": { # \"built\": true # } # } yarn rebuild @rocicorp/zero-sqlite3 Install every framework, database, or mobile package your app imports as a direct dependency of that workspace package. These examples use Zod; any Standard Schema-compatible validator works. Set Up Your Zero Schema Zero uses a file called schema.ts to provide a type-safe query API. If you use Drizzle or Prisma, you can generate the schema automatically. Otherwise, you can create it manually. npm install -D drizzle-zero npx drizzle-zero generate --output src/zero/schema.tspnpm add -D drizzle-zero pnpm exec drizzle-zero generate --output src/zero/schema.tsbun add -D drizzle-zero bunx drizzle-zero generate --output src/zero/schema.tsyarn add -D drizzle-zero yarn exec drizzle-zero generate --output src/zero/schema.tsnpm install -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } npx prisma generatepnpm add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } pnpx prisma generatebun add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } bunx prisma generateyarn add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } yarn prisma generate// src/zero/schema.ts import { boolean, createBuilder, createSchema, string, table } from '@rocicorp/zero' const user = table('user') .columns({ id: string(), name: string(), active: boolean() }) .primaryKey('id') export const schema = createSchema({ tables: [user] }) export const zql = createBuilder(schema) declare module '@rocicorp/zero' { interface DefaultTypes { schema: typeof schema } } Set Up the Zero Client Zero has first-class support for React and SolidJS, and there is also a low-level API you can use in any TypeScript-based project. Choose the tab that most closely matches where your app creates its root layout or client instance. // src/routes/__root.tsx import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' import type {ReactNode} from 'react' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export const Route = createRootRoute({ shellComponent: RootDocument }) function RootDocument({children}: {children: ReactNode}) { return ( {children} ) }// src/app/providers.tsx 'use client' import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import type {ReactNode} from 'react' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export function Providers({ children }: { children: ReactNode }) { return {children} } // src/app/layout.tsx import type {ReactNode} from 'react' import {Providers} from './providers' export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} ) }// src/app.tsx import {MetaProvider, Title} from '@solidjs/meta' import {Router} from '@solidjs/router' import {FileRoutes} from '@solidjs/start/router' import {ZeroProvider} from '@rocicorp/zero/solid' import type {ZeroOptions} from '@rocicorp/zero' import {Suspense} from 'solid-js' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export default function App() { return ( ( Zero App {props.children} )} > ) }// src/zero.ts import {Zero} from '@rocicorp/zero' import type {ZeroOptions} from '@rocicorp/zero' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } const zero = new Zero(opts) export {zero} Sync Data Define Query Shared reads are conventionally stored in queries.ts. Use zql from schema.ts to construct and return a ZQL query: // src/zero/queries.ts import {defineQueries, defineQuery} from '@rocicorp/zero' import {zql} from './schema' export const queries = defineQueries({ allUsers: defineQuery(() => zql.user) }) See Reading Data for more on filters, sorting, relationships, and permissions. Add Query Endpoint Zero doesn't allow clients to send arbitrary ZQL to zero-cache. Instead, Zero sends the query name and arguments to the query endpoint on your server, which responds to zero-cache with the authoritative ZQL. This prevents clients from reading arbitrary data and is the basis of permissions. // src/routes/api/query.ts import {createFileRoute} from '@tanstack/react-router' import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from '../../zero/queries' import {schema} from '../../zero/schema' export const Route = createFileRoute('/api/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) } } } })// src/app/api/query/route.ts import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from '../../../zero/queries' import {schema} from '../../../zero/schema' 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/query.ts import type {APIEvent} from '@solidjs/start/server' import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from '../../zero/queries' import {schema} from '../../zero/schema' 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) }// src/api/app.ts import {Hono} from 'hono' import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from '../zero/queries' import {schema} from '../zero/schema' const app = new Hono() app.post('/api/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) }) Invoke Query Querying for data is framework-specific. Most of the time, you will use a helper like useQuery that integrates into your framework's rendering model: import {useQuery} from '@rocicorp/zero/react' import {queries} from './zero/queries' const [users] = useQuery(queries.allUsers())import {useQuery} from '@rocicorp/zero/solid' import {queries} from './zero/queries' const [users] = useQuery(() => queries.allUsers())import {zero} from './zero' import {queries} from './zero/queries' const users = await zero.run(queries.allUsers()) More about Queries Filters, sorting, relationships, preloading, and more Server-driven authentication Mutate Data Define Mutators Data is written in Zero apps using mutators. Similar to queries, shared writes usually live in mutators.ts: // src/zero/mutators.ts import {defineMutators, defineMutator} from '@rocicorp/zero' import {z} from 'zod' export const mutators = defineMutators({ activateUser: defineMutator( z.object({id: z.string()}), async ({args: {id}, tx}) => { await tx.mutate.user.update({id, active: true}) } ) }) You can use the CRUD-style API with tx.mutate.
.() to write data. You can also use tx.run(zql.
.) to run queries within your mutator. Register the mutators where you create the Zero client: // src/routes/__root.tsx import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from '../zero/mutators' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema, mutators }// src/app/providers.tsx import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from '../zero/mutators' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema, mutators }// src/app.tsx import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from './zero/mutators' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema, mutators }// src/zero.ts import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from './zero/mutators' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema, mutators } Add Mutate Endpoint Zero requires a mutate endpoint that runs on your server and connects directly to Postgres. First, create a dbProvider with the Postgres adapter that matches your stack. These examples assume the selected database client is already installed in your app. // src/zero/db-provider.ts import {zeroDrizzle} from '@rocicorp/zero/server/adapters/drizzle' import {drizzle} from 'drizzle-orm/node-postgres' import {Pool} from 'pg' import {schema} from './schema' import * as drizzleSchema from '../drizzle/schema' // If your app uses a different Drizzle driver, reuse your existing client. const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const pool = new Pool({ connectionString }) export const drizzleClient = drizzle(pool, { schema: drizzleSchema }) export const dbProvider = zeroDrizzle(schema, drizzleClient) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// src/zero/db-provider.ts import {Kysely, PostgresDialect} from 'kysely' import {zeroKysely} from '@rocicorp/zero/server/adapters/kysely' import {Pool} from 'pg' import {schema} from './schema' import type {Database} from '../kysely/database' const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const kysely = new Kysely({ dialect: new PostgresDialect({ pool: new Pool({ connectionString }) }) }) export const dbProvider = zeroKysely(schema, kysely) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// src/zero/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 './schema' const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) }) export const dbProvider = zeroPrisma(schema, prisma) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// src/zero/db-provider.ts import {zeroNodePg} from '@rocicorp/zero/server/adapters/pg' import {Pool} from 'pg' import {schema} from './schema' const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const pool = new Pool({ connectionString }) export const dbProvider = zeroNodePg(schema, pool) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// src/zero/db-provider.ts import {zeroPostgresJS} from '@rocicorp/zero/server/adapters/postgresjs' import postgres from 'postgres' import {schema} from './schema' const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const sql = postgres(connectionString) export const dbProvider = zeroPostgresJS(schema, sql) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } } Then use the dbProvider and helpers to define the mutate endpoint: // src/routes/api/mutate.ts import {createFileRoute} from '@tanstack/react-router' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from '../../zero/mutators' import {dbProvider} from '../../zero/db-provider' export const Route = createFileRoute('/api/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) } } } })// src/app/api/mutate/route.ts import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from '../../../zero/mutators' import {dbProvider} from '../../../zero/db-provider' 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/mutate.ts import type {APIEvent} from '@solidjs/start/server' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from '../../zero/mutators' import {dbProvider} from '../../zero/db-provider' 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) }// src/api/app.ts import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from '../zero/mutators' import {dbProvider} from '../zero/db-provider' app.post('/api/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) }) Mutators on the server allow for write permissions and can be different from the client implementation. You can also do work after a mutation runs on the server, like send notifications. These examples have only public queries and mutators, so they do not pass a context. In authenticated apps, you should validate auth in the request, derive context from the session, and pass the context to the mutate and query handlers. See Authentication. Start your app server in another terminal, then run zero-cache locally with ZERO_QUERY_URL and ZERO_MUTATE_URL configured. If your app uses a different origin, update localhost:3000. ZERO_QUERY_URL=\"http://localhost:3000/api/query\" \\ ZERO_MUTATE_URL=\"http://localhost:3000/api/mutate\" \\ npx zero-cache-devZERO_QUERY_URL=\"http://localhost:3000/api/query\" \\ ZERO_MUTATE_URL=\"http://localhost:3000/api/mutate\" \\ pnpm exec zero-cache-devZERO_QUERY_URL=\"http://localhost:3000/api/query\" \\ ZERO_MUTATE_URL=\"http://localhost:3000/api/mutate\" \\ bunx zero-cache-devZERO_QUERY_URL=\"http://localhost:3000/api/query\" \\ ZERO_MUTATE_URL=\"http://localhost:3000/api/mutate\" \\ yarn exec zero-cache-dev Invoke Mutators You can call a mutator with zero.mutate: import {useZero} from '@rocicorp/zero/react' import {mutators} from './zero/mutators' const zero = useZero() const onClick = () => { zero.mutate(mutators.activateUser({id: '1'})) }import {useZero} from '@rocicorp/zero/solid' import {mutators} from './zero/mutators' const zero = useZero() const onClick = () => { zero().mutate(mutators.activateUser({id: '1'})) }import {zero} from './zero' import {mutators} from './zero/mutators' await zero.mutate(mutators.activateUser({id: '1'})) When you run the mutator, Zero writes to the local database, updates queries optimistically, and then syncs in the background to your mutate endpoint. Your mutate endpoint writes to Postgres and zero-cache will instantly replicate those changes to other clients. More about Mutators CRUD, server-specific code, permissions, and more Server-driven auth and Context Learn how to deploy your app to production", + "content": "This guide shows how to add Zero to an existing TypeScript-based web app. For a concrete end-to-end walkthrough, build the music app in the tutorial. Integrate Zero Set Up Your Database You'll need a Postgres database with logical replication enabled for development. # IMPORTANT: logical WAL level is required for Zero # to sync data to its SQLite replica docker run -d --name zero-postgres \\ -e POSTGRES_DB=\"zero\" \\ -e POSTGRES_PASSWORD=\"pass\" \\ -p 5432:5432 \\ postgres:18 \\ postgres -c wal_level=logical# Start Postgres.app first. Requires Postgres 15 or higher. # If these already exist, you can skip those commands. createuser -s postgres createdb -O postgres zero psql -d postgres -c \"ALTER USER postgres WITH PASSWORD 'pass';\" psql -d postgres -c \"ALTER SYSTEM SET wal_level = 'logical';\" # Restart Postgres.app, then verify: psql -d postgres -c \"SHOW wal_level;\" See Provider Support and make sure wal_level is logical. Create a .env file so your app server and zero-cache-dev use the same Postgres connection: # Update to your app's database connection URL ZERO_UPSTREAM_DB=\"postgres://postgres:pass@localhost:5432/zero\" Install Zero Add Zero and the validator used in these examples: npm install @rocicorp/zero zodpnpm add @rocicorp/zero zod # Note: pnpm disables postinstall scripts by default for security. # Create or update pnpm-workspace.yaml to allow the native package build: # https://pnpm.io/settings#allowbuilds # allowBuilds: # '@rocicorp/zero-sqlite3': true pnpm rebuild @rocicorp/zero-sqlite3bun add @rocicorp/zero zod # Note: Bun disables postinstall scripts by default for security. # Either approve the build: bun pm trust @rocicorp/zero-sqlite3 # Or add to package.json, then rebuild the native packages: # \"trustedDependencies\": [\"@rocicorp/zero-sqlite3\"]yarn add @rocicorp/zero zod # Note: Modern Yarn doesn't run postinstall scripts by default. # Add to package.json, then rebuild the native packages: # \"dependenciesMeta\": { # \"@rocicorp/zero-sqlite3\": { # \"built\": true # } # } yarn rebuild @rocicorp/zero-sqlite3 These examples use Zod; any Standard Schema-compatible validator works. Set Up Your Zero Schema Zero uses a file called schema.ts to provide a type-safe query API. If you use Drizzle or Prisma, you can generate the schema automatically. Otherwise, you can create it manually. npm install -D drizzle-zero npx drizzle-zero generate --output src/zero/schema.tspnpm add -D drizzle-zero pnpm exec drizzle-zero generate --output src/zero/schema.tsbun add -D drizzle-zero bunx drizzle-zero generate --output src/zero/schema.tsyarn add -D drizzle-zero yarn exec drizzle-zero generate --output src/zero/schema.tsnpm install -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } npx prisma generatepnpm add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } pnpx prisma generatebun add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } bunx prisma generateyarn add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } yarn prisma generate// src/zero/schema.ts import { boolean, createBuilder, createSchema, string, table } from '@rocicorp/zero' const user = table('user') .columns({ id: string(), name: string(), active: boolean() }) .primaryKey('id') export const schema = createSchema({ tables: [user] }) export const zql = createBuilder(schema) declare module '@rocicorp/zero' { interface DefaultTypes { schema: typeof schema } } Set Up the Zero Client Zero has first-class support for React and SolidJS, and there is also a low-level API you can use in any TypeScript-based project. Choose the tab that most closely matches where your app creates its root layout or client instance. // src/routes/__root.tsx import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' import type {ReactNode} from 'react' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export const Route = createRootRoute({ shellComponent: RootDocument }) function RootDocument({children}: {children: ReactNode}) { return ( {children} ) }// src/app/providers.tsx 'use client' import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import type {ReactNode} from 'react' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export function Providers({ children }: { children: ReactNode }) { return {children} } // src/app/layout.tsx import type {ReactNode} from 'react' import {Providers} from './providers' export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} ) }// src/app.tsx import {MetaProvider, Title} from '@solidjs/meta' import {Router} from '@solidjs/router' import {FileRoutes} from '@solidjs/start/router' import {ZeroProvider} from '@rocicorp/zero/solid' import type {ZeroOptions} from '@rocicorp/zero' import {Suspense} from 'solid-js' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export default function App() { return ( ( Zero App {props.children} )} > ) }// src/zero.ts import {Zero} from '@rocicorp/zero' import type {ZeroOptions} from '@rocicorp/zero' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } const zero = new Zero(opts) export {zero} Sync Data Define Query Shared reads are conventionally stored in queries.ts. Use zql from schema.ts to construct and return a ZQL query: // src/zero/queries.ts import {defineQueries, defineQuery} from '@rocicorp/zero' import {zql} from './schema' export const queries = defineQueries({ allUsers: defineQuery(() => zql.user) }) See Reading Data for more on filters, sorting, relationships, and permissions. Add Query Endpoint Zero doesn't allow clients to send arbitrary ZQL to zero-cache. Instead, Zero sends the query name and arguments to the query endpoint on your server, which responds to zero-cache with the authoritative ZQL. This prevents clients from reading arbitrary data and is the basis of permissions. // src/routes/api/query.ts import {createFileRoute} from '@tanstack/react-router' import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from '../../zero/queries' import {schema} from '../../zero/schema' export const Route = createFileRoute('/api/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) } } } })// src/app/api/query/route.ts import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from '../../../zero/queries' import {schema} from '../../../zero/schema' 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/query.ts import type {APIEvent} from '@solidjs/start/server' import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from '../../zero/queries' import {schema} from '../../zero/schema' 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) }// src/api/app.ts import {Hono} from 'hono' import {handleQueryRequest} from '@rocicorp/zero/server' import {mustGetQuery} from '@rocicorp/zero' import {queries} from '../zero/queries' import {schema} from '../zero/schema' const app = new Hono() app.post('/api/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) }) Invoke Query Querying for data is framework-specific. Most of the time, you will use a helper like useQuery that integrates into your framework's rendering model: import {useQuery} from '@rocicorp/zero/react' import {queries} from './zero/queries' const [users] = useQuery(queries.allUsers())import {useQuery} from '@rocicorp/zero/solid' import {queries} from './zero/queries' const [users] = useQuery(() => queries.allUsers())import {zero} from './zero' import {queries} from './zero/queries' const users = await zero.run(queries.allUsers()) More about Queries Filters, sorting, relationships, preloading, and more Server-driven authentication Mutate Data Define Mutators Data is written in Zero apps using mutators. Similar to queries, shared writes usually live in mutators.ts: // src/zero/mutators.ts import {defineMutators, defineMutator} from '@rocicorp/zero' import {z} from 'zod' export const mutators = defineMutators({ activateUser: defineMutator( z.object({id: z.string()}), async ({args: {id}, tx}) => { await tx.mutate.user.update({id, active: true}) } ) }) You can use the CRUD-style API with tx.mutate.
.() to write data. You can also use tx.run(zql.
.) to run queries within your mutator. Register the mutators where you create the Zero client: // src/routes/__root.tsx import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from '../zero/mutators' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema, mutators }// src/app/providers.tsx import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from '../zero/mutators' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema, mutators }// src/app.tsx import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from './zero/mutators' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema, mutators }// src/zero.ts import type {ZeroOptions} from '@rocicorp/zero' import {mutators} from './zero/mutators' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema, mutators } Add Mutate Endpoint Zero requires a mutate endpoint that runs on your server and connects directly to Postgres. First, create a dbProvider with the Postgres adapter that matches your stack. These examples assume the selected database client is already installed in your app. // src/zero/db-provider.ts import {zeroDrizzle} from '@rocicorp/zero/server/adapters/drizzle' import {drizzle} from 'drizzle-orm/node-postgres' import {Pool} from 'pg' import {schema} from './schema' import * as drizzleSchema from '../drizzle/schema' // If your app uses a different Drizzle driver, reuse your existing client. const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const pool = new Pool({ connectionString }) export const drizzleClient = drizzle(pool, { schema: drizzleSchema }) export const dbProvider = zeroDrizzle(schema, drizzleClient) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// src/zero/db-provider.ts import {Kysely, PostgresDialect} from 'kysely' import {zeroKysely} from '@rocicorp/zero/server/adapters/kysely' import {Pool} from 'pg' import {schema} from './schema' import type {Database} from '../kysely/database' const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const kysely = new Kysely({ dialect: new PostgresDialect({ pool: new Pool({ connectionString }) }) }) export const dbProvider = zeroKysely(schema, kysely) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// src/zero/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 './schema' const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) }) export const dbProvider = zeroPrisma(schema, prisma) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// src/zero/db-provider.ts import {zeroNodePg} from '@rocicorp/zero/server/adapters/pg' import {Pool} from 'pg' import {schema} from './schema' const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const pool = new Pool({ connectionString }) export const dbProvider = zeroNodePg(schema, pool) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } }// src/zero/db-provider.ts import {zeroPostgresJS} from '@rocicorp/zero/server/adapters/postgresjs' import postgres from 'postgres' import {schema} from './schema' const connectionString = process.env.ZERO_UPSTREAM_DB if (!connectionString) { throw new Error('ZERO_UPSTREAM_DB is not set') } const sql = postgres(connectionString) export const dbProvider = zeroPostgresJS(schema, sql) // Register global types for mutators on the server declare module '@rocicorp/zero' { interface DefaultTypes { dbProvider: typeof dbProvider } } Then use the dbProvider and helpers to define the mutate endpoint: // src/routes/api/mutate.ts import {createFileRoute} from '@tanstack/react-router' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from '../../zero/mutators' import {dbProvider} from '../../zero/db-provider' export const Route = createFileRoute('/api/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) } } } })// src/app/api/mutate/route.ts import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from '../../../zero/mutators' import {dbProvider} from '../../../zero/db-provider' 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/mutate.ts import type {APIEvent} from '@solidjs/start/server' import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from '../../zero/mutators' import {dbProvider} from '../../zero/db-provider' 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) }// src/api/app.ts import {handleMutateRequest} from '@rocicorp/zero/server' import {mustGetMutator} from '@rocicorp/zero' import {mutators} from '../zero/mutators' import {dbProvider} from '../zero/db-provider' app.post('/api/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) }) Mutators on the server allow for write permissions and can be different from the client implementation. You can also do work after a mutation runs on the server, like send notifications. These examples have only public queries and mutators, so they do not pass a context. In authenticated apps, you should validate auth in the request, derive context from the session, and pass the context to the mutate and query handlers. See Authentication. Start your app server in another terminal, then run zero-cache locally with ZERO_QUERY_URL and ZERO_MUTATE_URL configured. If your app uses a different origin, update localhost:3000. ZERO_QUERY_URL=\"http://localhost:3000/api/query\" \\ ZERO_MUTATE_URL=\"http://localhost:3000/api/mutate\" \\ npx zero-cache-devZERO_QUERY_URL=\"http://localhost:3000/api/query\" \\ ZERO_MUTATE_URL=\"http://localhost:3000/api/mutate\" \\ pnpm exec zero-cache-devZERO_QUERY_URL=\"http://localhost:3000/api/query\" \\ ZERO_MUTATE_URL=\"http://localhost:3000/api/mutate\" \\ bunx zero-cache-devZERO_QUERY_URL=\"http://localhost:3000/api/query\" \\ ZERO_MUTATE_URL=\"http://localhost:3000/api/mutate\" \\ yarn exec zero-cache-dev Invoke Mutators You can call a mutator with zero.mutate: import {useZero} from '@rocicorp/zero/react' import {mutators} from './zero/mutators' const zero = useZero() const onClick = () => { zero.mutate(mutators.activateUser({id: '1'})) }import {useZero} from '@rocicorp/zero/solid' import {mutators} from './zero/mutators' const zero = useZero() const onClick = () => { zero().mutate(mutators.activateUser({id: '1'})) }import {zero} from './zero' import {mutators} from './zero/mutators' await zero.mutate(mutators.activateUser({id: '1'})) When you run the mutator, Zero writes to the local database, updates queries optimistically, and then syncs in the background to your mutate endpoint. Your mutate endpoint writes to Postgres and zero-cache will instantly replicate those changes to other clients. More about Mutators CRUD, server-specific code, permissions, and more Server-driven auth and Context Learn how to deploy your app to production", "headings": [ { "text": "Integrate Zero", @@ -1721,7 +1721,7 @@ "sectionTitle": "Integrate Zero", "sectionId": "integrate-zero", "url": "/docs/install", - "content": "Set Up Your Database You'll need a Postgres database with logical replication enabled for development. # IMPORTANT: logical WAL level is required for Zero # to sync data to its SQLite replica docker run -d --name zero-postgres \\ -e POSTGRES_DB=\"zero\" \\ -e POSTGRES_PASSWORD=\"pass\" \\ -p 5432:5432 \\ postgres:18 \\ postgres -c wal_level=logical# Start Postgres.app first. Requires Postgres 15 or higher. # If these already exist, you can skip those commands. createuser -s postgres createdb -O postgres zero psql -d postgres -c \"ALTER USER postgres WITH PASSWORD 'pass';\" psql -d postgres -c \"ALTER SYSTEM SET wal_level = 'logical';\" # Restart Postgres.app, then verify: psql -d postgres -c \"SHOW wal_level;\" See Provider Support and make sure wal_level is logical. Create a .env file so your app server and zero-cache-dev use the same Postgres connection: # Update to your app's database connection URL ZERO_UPSTREAM_DB=\"postgres://postgres:pass@localhost:5432/zero\" Install Zero Add Zero and the validator used in these examples: npm install @rocicorp/zero zodpnpm add @rocicorp/zero zod # Note: pnpm disables postinstall scripts by default for security. # Create or update pnpm-workspace.yaml to allow the native package build: # https://pnpm.io/settings#allowbuilds # allowBuilds: # '@rocicorp/zero-sqlite3': true pnpm rebuild @rocicorp/zero-sqlite3bun add @rocicorp/zero zod # Note: Bun disables postinstall scripts by default for security. # Either approve the build: bun pm trust @rocicorp/zero-sqlite3 # Or add to package.json, then rebuild the native packages: # \"trustedDependencies\": [\"@rocicorp/zero-sqlite3\"]yarn add @rocicorp/zero zod # Note: Modern Yarn doesn't run postinstall scripts by default. # Add to package.json, then rebuild the native packages: # \"dependenciesMeta\": { # \"@rocicorp/zero-sqlite3\": { # \"built\": true # } # } yarn rebuild @rocicorp/zero-sqlite3 Install every framework, database, or mobile package your app imports as a direct dependency of that workspace package. These examples use Zod; any Standard Schema-compatible validator works. Set Up Your Zero Schema Zero uses a file called schema.ts to provide a type-safe query API. If you use Drizzle or Prisma, you can generate the schema automatically. Otherwise, you can create it manually. npm install -D drizzle-zero npx drizzle-zero generate --output src/zero/schema.tspnpm add -D drizzle-zero pnpm exec drizzle-zero generate --output src/zero/schema.tsbun add -D drizzle-zero bunx drizzle-zero generate --output src/zero/schema.tsyarn add -D drizzle-zero yarn exec drizzle-zero generate --output src/zero/schema.tsnpm install -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } npx prisma generatepnpm add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } pnpx prisma generatebun add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } bunx prisma generateyarn add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } yarn prisma generate// src/zero/schema.ts import { boolean, createBuilder, createSchema, string, table } from '@rocicorp/zero' const user = table('user') .columns({ id: string(), name: string(), active: boolean() }) .primaryKey('id') export const schema = createSchema({ tables: [user] }) export const zql = createBuilder(schema) declare module '@rocicorp/zero' { interface DefaultTypes { schema: typeof schema } } Set Up the Zero Client Zero has first-class support for React and SolidJS, and there is also a low-level API you can use in any TypeScript-based project. Choose the tab that most closely matches where your app creates its root layout or client instance. // src/routes/__root.tsx import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' import type {ReactNode} from 'react' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export const Route = createRootRoute({ shellComponent: RootDocument }) function RootDocument({children}: {children: ReactNode}) { return ( {children} ) }// src/app/providers.tsx 'use client' import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import type {ReactNode} from 'react' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export function Providers({ children }: { children: ReactNode }) { return {children} } // src/app/layout.tsx import type {ReactNode} from 'react' import {Providers} from './providers' export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} ) }// src/app.tsx import {MetaProvider, Title} from '@solidjs/meta' import {Router} from '@solidjs/router' import {FileRoutes} from '@solidjs/start/router' import {ZeroProvider} from '@rocicorp/zero/solid' import type {ZeroOptions} from '@rocicorp/zero' import {Suspense} from 'solid-js' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export default function App() { return ( ( Zero App {props.children} )} > ) }// src/zero.ts import {Zero} from '@rocicorp/zero' import type {ZeroOptions} from '@rocicorp/zero' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } const zero = new Zero(opts) export {zero}", + "content": "Set Up Your Database You'll need a Postgres database with logical replication enabled for development. # IMPORTANT: logical WAL level is required for Zero # to sync data to its SQLite replica docker run -d --name zero-postgres \\ -e POSTGRES_DB=\"zero\" \\ -e POSTGRES_PASSWORD=\"pass\" \\ -p 5432:5432 \\ postgres:18 \\ postgres -c wal_level=logical# Start Postgres.app first. Requires Postgres 15 or higher. # If these already exist, you can skip those commands. createuser -s postgres createdb -O postgres zero psql -d postgres -c \"ALTER USER postgres WITH PASSWORD 'pass';\" psql -d postgres -c \"ALTER SYSTEM SET wal_level = 'logical';\" # Restart Postgres.app, then verify: psql -d postgres -c \"SHOW wal_level;\" See Provider Support and make sure wal_level is logical. Create a .env file so your app server and zero-cache-dev use the same Postgres connection: # Update to your app's database connection URL ZERO_UPSTREAM_DB=\"postgres://postgres:pass@localhost:5432/zero\" Install Zero Add Zero and the validator used in these examples: npm install @rocicorp/zero zodpnpm add @rocicorp/zero zod # Note: pnpm disables postinstall scripts by default for security. # Create or update pnpm-workspace.yaml to allow the native package build: # https://pnpm.io/settings#allowbuilds # allowBuilds: # '@rocicorp/zero-sqlite3': true pnpm rebuild @rocicorp/zero-sqlite3bun add @rocicorp/zero zod # Note: Bun disables postinstall scripts by default for security. # Either approve the build: bun pm trust @rocicorp/zero-sqlite3 # Or add to package.json, then rebuild the native packages: # \"trustedDependencies\": [\"@rocicorp/zero-sqlite3\"]yarn add @rocicorp/zero zod # Note: Modern Yarn doesn't run postinstall scripts by default. # Add to package.json, then rebuild the native packages: # \"dependenciesMeta\": { # \"@rocicorp/zero-sqlite3\": { # \"built\": true # } # } yarn rebuild @rocicorp/zero-sqlite3 These examples use Zod; any Standard Schema-compatible validator works. Set Up Your Zero Schema Zero uses a file called schema.ts to provide a type-safe query API. If you use Drizzle or Prisma, you can generate the schema automatically. Otherwise, you can create it manually. npm install -D drizzle-zero npx drizzle-zero generate --output src/zero/schema.tspnpm add -D drizzle-zero pnpm exec drizzle-zero generate --output src/zero/schema.tsbun add -D drizzle-zero bunx drizzle-zero generate --output src/zero/schema.tsyarn add -D drizzle-zero yarn exec drizzle-zero generate --output src/zero/schema.tsnpm install -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } npx prisma generatepnpm add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } pnpx prisma generatebun add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } bunx prisma generateyarn add -D prisma-zero # Add this to prisma/schema.prisma: # generator zero { # provider = \"prisma-zero\" # output = \"../src/zero\" # } yarn prisma generate// src/zero/schema.ts import { boolean, createBuilder, createSchema, string, table } from '@rocicorp/zero' const user = table('user') .columns({ id: string(), name: string(), active: boolean() }) .primaryKey('id') export const schema = createSchema({ tables: [user] }) export const zql = createBuilder(schema) declare module '@rocicorp/zero' { interface DefaultTypes { schema: typeof schema } } Set Up the Zero Client Zero has first-class support for React and SolidJS, and there is also a low-level API you can use in any TypeScript-based project. Choose the tab that most closely matches where your app creates its root layout or client instance. // src/routes/__root.tsx import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' import type {ReactNode} from 'react' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export const Route = createRootRoute({ shellComponent: RootDocument }) function RootDocument({children}: {children: ReactNode}) { return ( {children} ) }// src/app/providers.tsx 'use client' import {ZeroProvider} from '@rocicorp/zero/react' import type {ZeroOptions} from '@rocicorp/zero' import type {ReactNode} from 'react' import {schema} from '../zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export function Providers({ children }: { children: ReactNode }) { return {children} } // src/app/layout.tsx import type {ReactNode} from 'react' import {Providers} from './providers' export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} ) }// src/app.tsx import {MetaProvider, Title} from '@solidjs/meta' import {Router} from '@solidjs/router' import {FileRoutes} from '@solidjs/start/router' import {ZeroProvider} from '@rocicorp/zero/solid' import type {ZeroOptions} from '@rocicorp/zero' import {Suspense} from 'solid-js' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } export default function App() { return ( ( Zero App {props.children} )} > ) }// src/zero.ts import {Zero} from '@rocicorp/zero' import type {ZeroOptions} from '@rocicorp/zero' import {schema} from './zero/schema' const opts: ZeroOptions = { cacheURL: 'http://localhost:4848', schema } const zero = new Zero(opts) export {zero}", "kind": "section" }, { @@ -1741,7 +1741,7 @@ "sectionTitle": "Install Zero", "sectionId": "install-zero", "url": "/docs/install", - "content": "Add Zero and the validator used in these examples: npm install @rocicorp/zero zodpnpm add @rocicorp/zero zod # Note: pnpm disables postinstall scripts by default for security. # Create or update pnpm-workspace.yaml to allow the native package build: # https://pnpm.io/settings#allowbuilds # allowBuilds: # '@rocicorp/zero-sqlite3': true pnpm rebuild @rocicorp/zero-sqlite3bun add @rocicorp/zero zod # Note: Bun disables postinstall scripts by default for security. # Either approve the build: bun pm trust @rocicorp/zero-sqlite3 # Or add to package.json, then rebuild the native packages: # \"trustedDependencies\": [\"@rocicorp/zero-sqlite3\"]yarn add @rocicorp/zero zod # Note: Modern Yarn doesn't run postinstall scripts by default. # Add to package.json, then rebuild the native packages: # \"dependenciesMeta\": { # \"@rocicorp/zero-sqlite3\": { # \"built\": true # } # } yarn rebuild @rocicorp/zero-sqlite3 Install every framework, database, or mobile package your app imports as a direct dependency of that workspace package. These examples use Zod; any Standard Schema-compatible validator works.", + "content": "Add Zero and the validator used in these examples: npm install @rocicorp/zero zodpnpm add @rocicorp/zero zod # Note: pnpm disables postinstall scripts by default for security. # Create or update pnpm-workspace.yaml to allow the native package build: # https://pnpm.io/settings#allowbuilds # allowBuilds: # '@rocicorp/zero-sqlite3': true pnpm rebuild @rocicorp/zero-sqlite3bun add @rocicorp/zero zod # Note: Bun disables postinstall scripts by default for security. # Either approve the build: bun pm trust @rocicorp/zero-sqlite3 # Or add to package.json, then rebuild the native packages: # \"trustedDependencies\": [\"@rocicorp/zero-sqlite3\"]yarn add @rocicorp/zero zod # Note: Modern Yarn doesn't run postinstall scripts by default. # Add to package.json, then rebuild the native packages: # \"dependenciesMeta\": { # \"@rocicorp/zero-sqlite3\": { # \"built\": true # } # } yarn rebuild @rocicorp/zero-sqlite3 These examples use Zod; any Standard Schema-compatible validator works.", "kind": "section" }, { @@ -1878,7 +1878,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 succeeds without changing the row, so success does not prove creation. Other unique conflicts still fail; use upsert to update an existing row. The server role needs SELECT access to the primary-key columns. 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') }) Mutate endpoint fetch failures and 5xx responses get up to four total attempts. Exhausted retries and responses other than 200, 401, or 403 enter the error state. 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. 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.", "headings": [ { "text": "Architecture", @@ -2026,7 +2026,7 @@ "sectionTitle": "Defining Mutators", "sectionId": "defining-mutators", "url": "/docs/mutators", - "content": "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 succeeds without changing the row, so success does not prove creation. Other unique conflicts still fail; use upsert to update an existing row. The server role needs SELECT access to the primary-key columns. 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.", + "content": "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.", "kind": "section" }, { @@ -2046,7 +2046,7 @@ "sectionTitle": "Writing Data", "sectionId": "writing-data", "url": "/docs/mutators", - "content": "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 succeeds without changing the row, so success does not prove creation. Other unique conflicts still fail; use upsert to update an existing row. The server role needs SELECT access to the primary-key columns. 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 })", + "content": "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 })", "kind": "section" }, { @@ -2056,7 +2056,7 @@ "sectionTitle": "Insert", "sectionId": "insert", "url": "/docs/mutators", - "content": "Create new records with insert: tx.mutate.user.insert({ id: 'user-123', username: 'sam', language: 'js' }) If the Zero primary key already exists, insert succeeds without changing the row, so success does not prove creation. Other unique conflicts still fail; use upsert to update an existing row. The server role needs SELECT access to the primary-key columns. 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 })", + "content": "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 })", "kind": "section" }, { @@ -2166,7 +2166,7 @@ "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') }) Mutate endpoint fetch failures and 5xx responses get up to four total attempts. Exhausted retries and responses other than 200, 401, or 403 enter the error state. 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. 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" }, { @@ -2196,7 +2196,7 @@ "sectionTitle": "Handling Errors", "sectionId": "handling-errors", "url": "/docs/mutators", - "content": "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') }) Mutate endpoint fetch failures and 5xx responses get up to four total attempts. Exhausted retries and responses other than 200, 401, or 403 enter the error state. 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.", + "content": "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.", "kind": "section" }, { @@ -2318,7 +2318,7 @@ "title": "OpenTelemetry", "searchTitle": "OpenTelemetry", "url": "/docs/otel", - "content": "The zero-cache service embeds the JavaScript OTLP Exporter and can send logs, traces, and metrics to any standard otel collector. To enable otel, set the following environment variables then run zero-cache as normal: OTEL_EXPORTER_OTLP_ENDPOINT=\"\" OTEL_EXPORTER_OTLP_HEADERS=\"\" OTEL_RESOURCE_ATTRIBUTES=\"\" OTEL_NODE_RESOURCE_DETECTORS=\"env,host,os\" Grafana Cloud Walkthrough Here are instructions to setup Grafana Cloud, but the setup for other otel collectors should be similar. Sign up for Grafana Cloud (Free Tier) Click Connections > Add Connection in the left sidebar add-connection Search for \"OpenTelemetry\" and select it Click \"Quickstart\" quickstart Select \"JavaScript\" javascript Create a new token Copy the environment variables into your .env file or similar copy-env Start zero-cache Look for logs under \"Drilldown\" > \"Logs\" in left sidebar Distributed Tracing You can enable end-to-end trace correlation from your frontend through zero-cache to your API server. This allows you to see the full request flow in your tracing UI. To enable this, provide a getTraceparent callback when creating your Zero client: import {ZeroProvider} from '@rocicorp/zero/react' import {propagation, context} from '@opentelemetry/api' function getTraceparent() { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } return ( )import {ZeroProvider} from '@rocicorp/zero/solid' import {propagation, context} from '@opentelemetry/api' function getTraceparent() { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } return ( )import {Zero} from '@rocicorp/zero' import {propagation, context} from '@opentelemetry/api' const zero = new Zero({ // ... other options getTraceparent: () => { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } }) This callback is called before sending WebSocket messages that trigger API server calls (push, changeDesiredQueries, initConnection). The returned W3C traceparent header is forwarded through zero-cache to your API server, where it can be used to continue the trace. Metrics Reference view_syncer_lag, view_syncer_hydration, and e2e_serving_lag require OpenTelemetry exponential histogram support. Prometheus users must enable native histograms. Use the existing serving_lag gauges if your backend does not support them. zero.server zero.replica Litestream metrics include role, backup_scheme, and litestream labels. The official image reports restores as v5 and backups as legacy. zero.replication total_lag and last_total_lag now report the same latest measured round trip and do not grow when reports stop arriving. Use lag_report_retries to detect a stalled or missing report stream. zero.sync serving_lag, serving_lag_stats, serving_lagging_client_groups, and view_syncer_lag include only eligible connected client groups. e2e_serving_lag records completed delivery. An upstream clock ahead biases it low, while a clock behind biases it high; negative values clamp to zero and increment e2e_serving_lag_clamps. zero.mutation", + "content": "The zero-cache service embeds the JavaScript OTLP Exporter and can send logs, traces, and metrics to any standard otel collector. To enable otel, set the following environment variables then run zero-cache as normal: OTEL_EXPORTER_OTLP_ENDPOINT=\"\" OTEL_EXPORTER_OTLP_HEADERS=\"\" OTEL_RESOURCE_ATTRIBUTES=\"\" OTEL_NODE_RESOURCE_DETECTORS=\"env,host,os\" Grafana Cloud Walkthrough Here are instructions to setup Grafana Cloud, but the setup for other otel collectors should be similar. Sign up for Grafana Cloud (Free Tier) Click Connections > Add Connection in the left sidebar add-connection Search for \"OpenTelemetry\" and select it Click \"Quickstart\" quickstart Select \"JavaScript\" javascript Create a new token Copy the environment variables into your .env file or similar copy-env Start zero-cache Look for logs under \"Drilldown\" > \"Logs\" in left sidebar Distributed Tracing You can enable end-to-end trace correlation from your frontend through zero-cache to your API server. This allows you to see the full request flow in your tracing UI. To enable this, provide a getTraceparent callback when creating your Zero client: import {ZeroProvider} from '@rocicorp/zero/react' import {propagation, context} from '@opentelemetry/api' function getTraceparent() { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } return ( )import {ZeroProvider} from '@rocicorp/zero/solid' import {propagation, context} from '@opentelemetry/api' function getTraceparent() { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } return ( )import {Zero} from '@rocicorp/zero' import {propagation, context} from '@opentelemetry/api' const zero = new Zero({ // ... other options getTraceparent: () => { const carrier: Record = {} propagation.inject(context.active(), carrier) return carrier.traceparent } }) This callback is called before sending WebSocket messages that trigger API server calls (push, changeDesiredQueries, initConnection). The returned W3C traceparent header is forwarded through zero-cache to your API server, where it can be used to continue the trace. Metrics Reference zero_sync_view_syncer_lag, zero_sync_view_syncer_hydration, and zero_sync_e2e_serving_lag require OpenTelemetry exponential histogram support. Prometheus users must enable native histograms. Use the existing zero_sync_serving_lag gauges if your backend does not support them. zero.server zero.replica zero.replication zero_replication_total_lag and zero_replication_last_total_lag now report the same latest measured round trip and do not grow when reports stop arriving. Use zero_replication_lag_report_retries to detect a stalled or missing report stream. zero.sync Serving-lag metrics include only client groups with at least one connected client and a validated background connection context. Retained groups without an eligible connection do not contribute lag. zero.mutation", "headings": [ { "text": "Grafana Cloud Walkthrough", @@ -2382,7 +2382,7 @@ "sectionTitle": "Metrics Reference", "sectionId": "metrics-reference", "url": "/docs/otel", - "content": "view_syncer_lag, view_syncer_hydration, and e2e_serving_lag require OpenTelemetry exponential histogram support. Prometheus users must enable native histograms. Use the existing serving_lag gauges if your backend does not support them. zero.server zero.replica Litestream metrics include role, backup_scheme, and litestream labels. The official image reports restores as v5 and backups as legacy. zero.replication total_lag and last_total_lag now report the same latest measured round trip and do not grow when reports stop arriving. Use lag_report_retries to detect a stalled or missing report stream. zero.sync serving_lag, serving_lag_stats, serving_lagging_client_groups, and view_syncer_lag include only eligible connected client groups. e2e_serving_lag records completed delivery. An upstream clock ahead biases it low, while a clock behind biases it high; negative values clamp to zero and increment e2e_serving_lag_clamps. zero.mutation", + "content": "zero_sync_view_syncer_lag, zero_sync_view_syncer_hydration, and zero_sync_e2e_serving_lag require OpenTelemetry exponential histogram support. Prometheus users must enable native histograms. Use the existing zero_sync_serving_lag gauges if your backend does not support them. zero.server zero.replica zero.replication zero_replication_total_lag and zero_replication_last_total_lag now report the same latest measured round trip and do not grow when reports stop arriving. Use zero_replication_lag_report_retries to detect a stalled or missing report stream. zero.sync Serving-lag metrics include only client groups with at least one connected client and a validated background connection context. Retained groups without an eligible connection do not contribute lag. zero.mutation", "kind": "section" }, { @@ -2402,7 +2402,7 @@ "sectionTitle": "zero.replica", "sectionId": "zeroreplica", "url": "/docs/otel", - "content": "Litestream metrics include role, backup_scheme, and litestream labels. The official image reports restores as v5 and backups as legacy.", + "content": "", "kind": "section" }, { @@ -2412,7 +2412,7 @@ "sectionTitle": "zero.replication", "sectionId": "zeroreplication", "url": "/docs/otel", - "content": "total_lag and last_total_lag now report the same latest measured round trip and do not grow when reports stop arriving. Use lag_report_retries to detect a stalled or missing report stream.", + "content": "zero_replication_total_lag and zero_replication_last_total_lag now report the same latest measured round trip and do not grow when reports stop arriving. Use zero_replication_lag_report_retries to detect a stalled or missing report stream.", "kind": "section" }, { @@ -2422,7 +2422,7 @@ "sectionTitle": "zero.sync", "sectionId": "zerosync", "url": "/docs/otel", - "content": "serving_lag, serving_lag_stats, serving_lagging_client_groups, and view_syncer_lag include only eligible connected client groups. e2e_serving_lag records completed delivery. An upstream clock ahead biases it low, while a clock behind biases it high; negative values clamp to zero and increment e2e_serving_lag_clamps.", + "content": "Serving-lag metrics include only client groups with at least one connected client and a validated background connection context. Retained groups without an eligible connection do not contribute lag.", "kind": "section" }, { @@ -2440,7 +2440,7 @@ "title": "Supported Postgres Features", "searchTitle": "Supported Postgres Features", "url": "/docs/postgres-support", - "content": "Postgres has a massive feature set, and Zero supports a growing subset of it. Object Names Table and column names must begin with a letter or underscore This can be followed by letters, numbers, underscores, and hyphens Regex: /^[A-Za-z_]+[A-Za-z0-9_-]*$/ The column name _0_version is reserved for internal use Object Types Tables are synced. Views are not synced. generated as identity columns are synced. In Postgres 18+, generated stored columns are synced. In lower Postgres versions they aren't. Indexes aren't synced per-se, but we do implicitly add indexes to the replica that match the upstream indexes. In the future, this will be customizable. Column Types Postgres Type schema.ts Type Resulting TS Type All numeric types number number char, varchar, text, uuid string string cidr, inet, macaddr, macaddr8 string string pg_lsn string string isn extension types (ean13 , isbn, etc.) string string bool boolean boolean date, timestamp, timestampz, time, timetz number number json, jsonb json JSONValue enum enumeration string T[] where T is a supported Postgres type (but please see ⚠️ below) json where U is the schema.ts type for T V[] where V is the JS/TS type for T Zero will sync arrays to the client, but there is no support for filtering or joining on array elements yet in ZQL. Other Postgres column types aren’t supported. They will be ignored when replicating (the synced data will be missing that column) and you will get a warning when zero-cache starts up. If your schema has a pg type not listed here, you can support it in Zero by using a trigger to map it to some type that Zero can support. For example if you have a GIS polygon type in the column my_poly polygon, you can use a trigger to map it to a my_poly_json json column. You could either use another trigger to map in the reverse direction to support changes for writes, or you could use a mutator to write to the polygon type directly on the server. Let us know if the lack of a particular column type is hindering your use of Zero. It can likely be added. Column Defaults Default values are allowed in the Postgres schema, but there currently is no way to use them from a Zero app. An insert() mutation requires all columns to be specified, except when columns are nullable (in which case, they default to null). Since there is no way to leave non-nullable columns off the insert on the client, there is no way for PG to apply the default. This is a known issue and will be fixed in the future. IDs It is strongly recommended to use client-generated random strings like crypto.randomUUID(), uuid, ulid, nanoid, etc for primary keys. This makes optimistic creation and updates much easier. Imagine that the PK of your table is an auto-incrementing integer. If you optimistically create an entity of this type, you will have to give it some ID – the type will require it locally, but also if you want to optimistically create relationships to this row you’ll need an ID. You could sync the highest value seen for that table, but there are race conditions and it is possible for that ID to be taken by the time the creation makes it to the server. Your database can resolve this and assign the next ID, but now the relationships you created optimistically will be against the wrong row. Blech. GUIDs makes a lot more sense in synced applications. Natural keys can still conflict. If the natural key is the Zero primary key, a duplicate insert leaves the existing row unchanged. Reject duplicates in an authoritative mutator; separate unique-constraint violations still fail. If you want to have a short auto-incrementing numeric ID for UX reasons (i.e., a bug number), that is possible - see this video. Primary Keys Each table synced with Zero must have either a primary key or at least one unique index. This is needed so that Zero can identify rows during sync, to distinguish between an edit and a remove/add. Multi-column primary and foreign keys are supported. Limiting Replication There are two levels of replication to consider with Zero: replicating from Postgres to zero-cache, and from zero-cache to the Zero browser client. zero-cache replication By default, Zero creates a Postgres publication that publishes all tables in the public schema to zero-cache. To limit which tables or columns are replicated to zero-cache, you can create a Postgres publication with the tables and columns you want: CREATE PUBLICATION zero_data FOR TABLE users (col1, col2, col3, ...), issues, comments; Then, specify this publication in the App Publications zero-cache option. Browser client replication You can use Read Permissions to control which rows are synced from the zero-cache replica to actual clients (e.g., web browsers). Currently, Permissions can limit which tables and rows can be replicated to the client. In the near future, you'll also be able to use Permissions to limit syncing individual columns. Until then, you will need to create a publication to control which columns are synced to zero-cache. Schema changes All Postgres schema changes are supported. See Schema Migrations.", + "content": "Postgres has a massive feature set, and Zero supports a growing subset of it. Object Names Table and column names must begin with a letter or underscore This can be followed by letters, numbers, underscores, and hyphens Regex: /^[A-Za-z_]+[A-Za-z0-9_-]*$/ The column name _0_version is reserved for internal use Object Types Tables are synced. Views are not synced. generated as identity columns are synced. In Postgres 18+, generated stored columns are synced. In lower Postgres versions they aren't. Indexes aren't synced per-se, but we do implicitly add indexes to the replica that match the upstream indexes. In the future, this will be customizable. Column Types Postgres Type schema.ts Type Resulting TS Type All numeric types number number char, varchar, text, uuid string string cidr, inet, macaddr, macaddr8 string string pg_lsn string string isn extension types (ean13 , isbn, etc.) string string bool boolean boolean date, timestamp, timestampz, time, timetz number number json, jsonb json JSONValue enum enumeration string T[] where T is a supported Postgres type (but please see ⚠️ below) json where U is the schema.ts type for T V[] where V is the JS/TS type for T Zero will sync arrays to the client, but there is no support for filtering or joining on array elements yet in ZQL. Other Postgres column types aren’t supported. They will be ignored when replicating (the synced data will be missing that column) and you will get a warning when zero-cache starts up. If your schema has a pg type not listed here, you can support it in Zero by using a trigger to map it to some type that Zero can support. For example if you have a GIS polygon type in the column my_poly polygon, you can use a trigger to map it to a my_poly_json json column. You could either use another trigger to map in the reverse direction to support changes for writes, or you could use a mutator to write to the polygon type directly on the server. Let us know if the lack of a particular column type is hindering your use of Zero. It can likely be added. Column Defaults Default values are allowed in the Postgres schema, but there currently is no way to use them from a Zero app. An insert() mutation requires all columns to be specified, except when columns are nullable (in which case, they default to null). Since there is no way to leave non-nullable columns off the insert on the client, there is no way for PG to apply the default. This is a known issue and will be fixed in the future. IDs It is strongly recommended to use client-generated random strings like crypto.randomUUID(), uuid, ulid, nanoid, etc for primary keys. This makes optimistic creation and updates much easier. Imagine that the PK of your table is an auto-incrementing integer. If you optimistically create an entity of this type, you will have to give it some ID – the type will require it locally, but also if you want to optimistically create relationships to this row you’ll need an ID. You could sync the highest value seen for that table, but there are race conditions and it is possible for that ID to be taken by the time the creation makes it to the server. Your database can resolve this and assign the next ID, but now the relationships you created optimistically will be against the wrong row. Blech. GUIDs makes a lot more sense in synced applications. If your table has a natural key you can use that and it has less problems. But there is still the chance for a conflict. Imagine you are modeling orgs and you choose domainName as the natural key. It is possible for a race to happen and when the creation gets to the server, somebody has already chosen that domain name. In that case, the best thing to do is reject the write and show the user an error. If you want to have a short auto-incrementing numeric ID for UX reasons (i.e., a bug number), that is possible - see this video. Primary Keys Each table synced with Zero must have either a primary key or at least one unique index. This is needed so that Zero can identify rows during sync, to distinguish between an edit and a remove/add. Multi-column primary and foreign keys are supported. Limiting Replication There are two levels of replication to consider with Zero: replicating from Postgres to zero-cache, and from zero-cache to the Zero browser client. zero-cache replication By default, Zero creates a Postgres publication that publishes all tables in the public schema to zero-cache. To limit which tables or columns are replicated to zero-cache, you can create a Postgres publication with the tables and columns you want: CREATE PUBLICATION zero_data FOR TABLE users (col1, col2, col3, ...), issues, comments; Then, specify this publication in the App Publications zero-cache option. Browser client replication You can use Read Permissions to control which rows are synced from the zero-cache replica to actual clients (e.g., web browsers). Currently, Permissions can limit which tables and rows can be replicated to the client. In the near future, you'll also be able to use Permissions to limit syncing individual columns. Until then, you will need to create a publication to control which columns are synced to zero-cache. Schema changes All Postgres schema changes are supported. See Schema Migrations.", "headings": [ { "text": "Object Names", @@ -2532,7 +2532,7 @@ "sectionTitle": "IDs", "sectionId": "ids", "url": "/docs/postgres-support", - "content": "It is strongly recommended to use client-generated random strings like crypto.randomUUID(), uuid, ulid, nanoid, etc for primary keys. This makes optimistic creation and updates much easier. Imagine that the PK of your table is an auto-incrementing integer. If you optimistically create an entity of this type, you will have to give it some ID – the type will require it locally, but also if you want to optimistically create relationships to this row you’ll need an ID. You could sync the highest value seen for that table, but there are race conditions and it is possible for that ID to be taken by the time the creation makes it to the server. Your database can resolve this and assign the next ID, but now the relationships you created optimistically will be against the wrong row. Blech. GUIDs makes a lot more sense in synced applications. Natural keys can still conflict. If the natural key is the Zero primary key, a duplicate insert leaves the existing row unchanged. Reject duplicates in an authoritative mutator; separate unique-constraint violations still fail. If you want to have a short auto-incrementing numeric ID for UX reasons (i.e., a bug number), that is possible - see this video.", + "content": "It is strongly recommended to use client-generated random strings like crypto.randomUUID(), uuid, ulid, nanoid, etc for primary keys. This makes optimistic creation and updates much easier. Imagine that the PK of your table is an auto-incrementing integer. If you optimistically create an entity of this type, you will have to give it some ID – the type will require it locally, but also if you want to optimistically create relationships to this row you’ll need an ID. You could sync the highest value seen for that table, but there are race conditions and it is possible for that ID to be taken by the time the creation makes it to the server. Your database can resolve this and assign the next ID, but now the relationships you created optimistically will be against the wrong row. Blech. GUIDs makes a lot more sense in synced applications. If your table has a natural key you can use that and it has less problems. But there is still the chance for a conflict. Imagine you are modeling orgs and you choose domainName as the natural key. It is possible for a race to happen and when the creation gets to the server, somebody has already chosen that domain name. In that case, the best thing to do is reject the write and show the user an error. If you want to have a short auto-incrementing numeric ID for UX reasons (i.e., a bug number), that is possible - see this video.", "kind": "section" }, { @@ -2656,7 +2656,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
404 Not Found
} else { return
{issue.title}
}const [issue] = useQuery(() => queries.issues.get('some-id') ) return ( {resolved => ( 404 Not Found} >
{resolved.title}
)}
)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
404 Not Found
} if (!issue) { return null } return
{issue.title}
const [issue, issueResult] = useQuery(() => queries.issues.get('some-id') ) return ( {resolved =>
{resolved.title}
}
404 Not Found
)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: Endpoint fetch failures and 5xx responses get up to four total attempts; 4xx responses are not retried. const [posts, postsResult] = useQuery( queries.posts.byAuthorID('user123') ) if (postsResult.type === 'error') { return (
Error loading posts: {postsResult.error.message}
) }const [posts, postsResult] = useQuery(() => queries.posts.byAuthorID('user123') ) return (
Error loading posts: {postsResult().error.message}
)// 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'} ) 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
404 Not Found
} else { return
{issue.title}
}const [issue] = useQuery(() => queries.issues.get('some-id') ) return ( {resolved => ( 404 Not Found} >
{resolved.title}
)}
)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
404 Not Found
} if (!issue) { return null } return
{issue.title}
const [issue, issueResult] = useQuery(() => queries.issues.get('some-id') ) return ( {resolved =>
{resolved.title}
}
404 Not Found
)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 (
Error loading posts: {postsResult.error.message}
) }const [posts, postsResult] = useQuery(() => queries.posts.byAuthorID('user123') ) return (
Error loading posts: {postsResult().error.message}
)// 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", @@ -3002,7 +3002,7 @@ "sectionTitle": "Handling Errors", "sectionId": "handling-errors", "url": "/docs/queries", - "content": "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: Endpoint fetch failures and 5xx responses get up to four total attempts; 4xx responses are not retried. const [posts, postsResult] = useQuery( queries.posts.byAuthorID('user123') ) if (postsResult.type === 'error') { return (
Error loading posts: {postsResult.error.message}
) }const [posts, postsResult] = useQuery(() => queries.posts.byAuthorID('user123') ) return (
Error loading posts: {postsResult().error.message}
)// 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.", + "content": "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 (
Error loading posts: {postsResult.error.message}
) }const [posts, postsResult] = useQuery(() => queries.posts.byAuthorID('user123') ) return (
Error loading posts: {postsResult().error.message}
)// 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.", "kind": "section" }, { @@ -6223,7 +6223,7 @@ "title": "Zero 1.9", "searchTitle": "Zero 1.9", "url": "/docs/release-notes/1.9", - "content": "Installation npm install @rocicorp/zero@1.9 You can use zero-cache from Docker Hub or GHCR: docker pull rocicorp/zero:1.9.0 # or docker pull ghcr.io/rocicorp/zero:1.9.0 Overview Zero 1.9 improves query and mutation correctness, connection and replica reliability, and operational observability. It also speeds up the first server mutation when schema metadata is not yet cached. Features Litestream v5 restores: The official image now uses Litestream 0.5.15 for restores by default. It can restore legacy WAL or LTX backups, while Zero 1.9 continues writing legacy backups. Legacy snapshots now retain the previous generation for six additional hours, preventing cleanup during an active restore at the cost of temporary backup storage. (#6260, #6267) End-to-end serving lag: New metrics measure completed replicated work from the upstream transaction commit through zero-cache sync output. Upstream clock-skew estimates and clamp counts identify measurements biased by clock differences. (#6312) Performance Cold Mutation Latency On its first mutation, Zero Server fetches and caches PostgreSQL schema metadata. In benchmarks, that full request is 2.9x faster (#6292, thanks @diegopereira99!). The same change resolves types by OID, disambiguating same-named types across schemas. Fixes Ordered queries now paginate and maintain windows correctly when cursor fields contain NULL, including compound tie-break fields and reverse walks. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each, terminated client groups release custom-query timers and caches, and large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log bounded replica and integrity diagnostics and flush logs before exit. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Breaking Changes PostgreSQL Socket Inactivity Timeout zero-cache now checks wire activity on its PostgreSQL connections every two minutes by default and resets a connection after one to two inactive checks. This recovers half-open sockets, but can interrupt a statement that legitimately sends no data for several minutes. Increase ZERO_PG_SOCKET_INACTIVITY_TIMEOUT for workloads that can remain silent longer. Set it to 0 to disable the watchdog. Existing Primary-Key Inserts insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Other unique-constraint violations still fail, and a successful insert does not prove that a row was created. If your application relied on the error, enforce duplicate rejection in an authoritative custom mutator. The PostgreSQL role used for authoritative mutations must also have SELECT access to the primary-key columns. Litestream V5 Restore and Age-Encrypted Backups When a v5 executable is available, as it is in the official image, zero-cache now restores with Litestream 0.5.15 by default. Zero 1.9 continues writing legacy WAL backups, and the v5 restore path can read both legacy and LTX formats. Litestream v0.5 cannot restore legacy backups encrypted with Age. Before upgrading, either migrate those backups or set ZERO_LITESTREAM_RESTORE_USING_V5=false to keep using the legacy restore path. Test custom Litestream configurations in staging. If a future release has written a newer LTX backup, use Zero 1.9 or later with v5 restore enabled as the rollback target; older images cannot restore an LTX-only backup.", + "content": "Installation npm install @rocicorp/zero@1.9 Overview Zero 1.9 improves query/mutation correctness and reliability. Features End-to-end serving lag: e2e_serving_lag measures completed replicated work from the upstream transaction commit through view-syncer poke. Upstream clock-skew estimate tries to identify measurements biased by clock differences. (#6312) Performance Cold Mutation Latency Before running mutations, Zero Server fetches and caches PostgreSQL schema metadata. This is now 2.7x faster in Zero 1.9 (done in #6292, thanks @diegopereira99!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. Fixes Litestream restores: Restores now use Litestream 0.5.15 for legacy-format compatibility, and legacy snapshots retain the previous generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication.", "headings": [ { "text": "Installation", @@ -6248,22 +6248,6 @@ { "text": "Fixes", "id": "fixes" - }, - { - "text": "Breaking Changes", - "id": "breaking-changes" - }, - { - "text": "PostgreSQL Socket Inactivity Timeout", - "id": "postgresql-socket-inactivity-timeout" - }, - { - "text": "Existing Primary-Key Inserts", - "id": "existing-primary-key-inserts" - }, - { - "text": "Litestream V5 Restore and Age-Encrypted Backups", - "id": "litestream-v5-restore-and-age-encrypted-backups" } ], "kind": "page" @@ -6275,7 +6259,7 @@ "sectionTitle": "Installation", "sectionId": "installation", "url": "/docs/release-notes/1.9", - "content": "npm install @rocicorp/zero@1.9 You can use zero-cache from Docker Hub or GHCR: docker pull rocicorp/zero:1.9.0 # or docker pull ghcr.io/rocicorp/zero:1.9.0", + "content": "npm install @rocicorp/zero@1.9", "kind": "section" }, { @@ -6285,7 +6269,7 @@ "sectionTitle": "Overview", "sectionId": "overview", "url": "/docs/release-notes/1.9", - "content": "Zero 1.9 improves query and mutation correctness, connection and replica reliability, and operational observability. It also speeds up the first server mutation when schema metadata is not yet cached.", + "content": "Zero 1.9 improves query/mutation correctness and reliability.", "kind": "section" }, { @@ -6295,7 +6279,7 @@ "sectionTitle": "Features", "sectionId": "features", "url": "/docs/release-notes/1.9", - "content": "Litestream v5 restores: The official image now uses Litestream 0.5.15 for restores by default. It can restore legacy WAL or LTX backups, while Zero 1.9 continues writing legacy backups. Legacy snapshots now retain the previous generation for six additional hours, preventing cleanup during an active restore at the cost of temporary backup storage. (#6260, #6267) End-to-end serving lag: New metrics measure completed replicated work from the upstream transaction commit through zero-cache sync output. Upstream clock-skew estimates and clamp counts identify measurements biased by clock differences. (#6312)", + "content": "End-to-end serving lag: e2e_serving_lag measures completed replicated work from the upstream transaction commit through view-syncer poke. Upstream clock-skew estimate tries to identify measurements biased by clock differences. (#6312)", "kind": "section" }, { @@ -6305,7 +6289,7 @@ "sectionTitle": "Performance", "sectionId": "performance", "url": "/docs/release-notes/1.9", - "content": "Cold Mutation Latency On its first mutation, Zero Server fetches and caches PostgreSQL schema metadata. In benchmarks, that full request is 2.9x faster (#6292, thanks @diegopereira99!). The same change resolves types by OID, disambiguating same-named types across schemas.", + "content": "Cold Mutation Latency Before running mutations, Zero Server fetches and caches PostgreSQL schema metadata. This is now 2.7x faster in Zero 1.9 (done in #6292, thanks @diegopereira99!). This is most noticeable with cold-starts in serverless environments like AWS Lambda.", "kind": "section" }, { @@ -6315,7 +6299,7 @@ "sectionTitle": "Cold Mutation Latency", "sectionId": "cold-mutation-latency", "url": "/docs/release-notes/1.9", - "content": "On its first mutation, Zero Server fetches and caches PostgreSQL schema metadata. In benchmarks, that full request is 2.9x faster (#6292, thanks @diegopereira99!). The same change resolves types by OID, disambiguating same-named types across schemas.", + "content": "Before running mutations, Zero Server fetches and caches PostgreSQL schema metadata. This is now 2.7x faster in Zero 1.9 (done in #6292, thanks @diegopereira99!). This is most noticeable with cold-starts in serverless environments like AWS Lambda.", "kind": "section" }, { @@ -6325,47 +6309,7 @@ "sectionTitle": "Fixes", "sectionId": "fixes", "url": "/docs/release-notes/1.9", - "content": "Ordered queries now paginate and maintain windows correctly when cursor fields contain NULL, including compound tie-break fields and reverse walks. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each, terminated client groups release custom-query timers and caches, and large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log bounded replica and integrity diagnostics and flush logs before exit. Oversized replication updates now identify the transaction, affected column, and value type without logging the value.", - "kind": "section" - }, - { - "id": "484-release-notes/1.9#breaking-changes", - "title": "Zero 1.9", - "searchTitle": "Breaking Changes", - "sectionTitle": "Breaking Changes", - "sectionId": "breaking-changes", - "url": "/docs/release-notes/1.9", - "content": "PostgreSQL Socket Inactivity Timeout zero-cache now checks wire activity on its PostgreSQL connections every two minutes by default and resets a connection after one to two inactive checks. This recovers half-open sockets, but can interrupt a statement that legitimately sends no data for several minutes. Increase ZERO_PG_SOCKET_INACTIVITY_TIMEOUT for workloads that can remain silent longer. Set it to 0 to disable the watchdog. Existing Primary-Key Inserts insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Other unique-constraint violations still fail, and a successful insert does not prove that a row was created. If your application relied on the error, enforce duplicate rejection in an authoritative custom mutator. The PostgreSQL role used for authoritative mutations must also have SELECT access to the primary-key columns. Litestream V5 Restore and Age-Encrypted Backups When a v5 executable is available, as it is in the official image, zero-cache now restores with Litestream 0.5.15 by default. Zero 1.9 continues writing legacy WAL backups, and the v5 restore path can read both legacy and LTX formats. Litestream v0.5 cannot restore legacy backups encrypted with Age. Before upgrading, either migrate those backups or set ZERO_LITESTREAM_RESTORE_USING_V5=false to keep using the legacy restore path. Test custom Litestream configurations in staging. If a future release has written a newer LTX backup, use Zero 1.9 or later with v5 restore enabled as the rollback target; older images cannot restore an LTX-only backup.", - "kind": "section" - }, - { - "id": "485-release-notes/1.9#postgresql-socket-inactivity-timeout", - "title": "Zero 1.9", - "searchTitle": "PostgreSQL Socket Inactivity Timeout", - "sectionTitle": "PostgreSQL Socket Inactivity Timeout", - "sectionId": "postgresql-socket-inactivity-timeout", - "url": "/docs/release-notes/1.9", - "content": "zero-cache now checks wire activity on its PostgreSQL connections every two minutes by default and resets a connection after one to two inactive checks. This recovers half-open sockets, but can interrupt a statement that legitimately sends no data for several minutes. Increase ZERO_PG_SOCKET_INACTIVITY_TIMEOUT for workloads that can remain silent longer. Set it to 0 to disable the watchdog.", - "kind": "section" - }, - { - "id": "486-release-notes/1.9#existing-primary-key-inserts", - "title": "Zero 1.9", - "searchTitle": "Existing Primary-Key Inserts", - "sectionTitle": "Existing Primary-Key Inserts", - "sectionId": "existing-primary-key-inserts", - "url": "/docs/release-notes/1.9", - "content": "insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Other unique-constraint violations still fail, and a successful insert does not prove that a row was created. If your application relied on the error, enforce duplicate rejection in an authoritative custom mutator. The PostgreSQL role used for authoritative mutations must also have SELECT access to the primary-key columns.", - "kind": "section" - }, - { - "id": "487-release-notes/1.9#litestream-v5-restore-and-age-encrypted-backups", - "title": "Zero 1.9", - "searchTitle": "Litestream V5 Restore and Age-Encrypted Backups", - "sectionTitle": "Litestream V5 Restore and Age-Encrypted Backups", - "sectionId": "litestream-v5-restore-and-age-encrypted-backups", - "url": "/docs/release-notes/1.9", - "content": "When a v5 executable is available, as it is in the official image, zero-cache now restores with Litestream 0.5.15 by default. Zero 1.9 continues writing legacy WAL backups, and the v5 restore path can read both legacy and LTX formats. Litestream v0.5 cannot restore legacy backups encrypted with Age. Before upgrading, either migrate those backups or set ZERO_LITESTREAM_RESTORE_USING_V5=false to keep using the legacy restore path. Test custom Litestream configurations in staging. If a future release has written a newer LTX backup, use Zero 1.9 or later with v5 restore enabled as the rollback target; older images cannot restore an LTX-only backup.", + "content": "Litestream restores: Restores now use Litestream 0.5.15 for legacy-format compatibility, and legacy snapshots retain the previous generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication.", "kind": "section" }, { @@ -6396,7 +6340,7 @@ "kind": "page" }, { - "id": "488-reporting-bugs#zbugs", + "id": "484-reporting-bugs#zbugs", "title": "Reporting Bugs", "searchTitle": "zbugs", "sectionTitle": "zbugs", @@ -6406,7 +6350,7 @@ "kind": "section" }, { - "id": "489-reporting-bugs#discord", + "id": "485-reporting-bugs#discord", "title": "Reporting Bugs", "searchTitle": "Discord", "sectionTitle": "Discord", @@ -6442,7 +6386,7 @@ "kind": "page" }, { - "id": "490-rest#pattern", + "id": "486-rest#pattern", "title": "REST", "searchTitle": "Pattern", "sectionTitle": "Pattern", @@ -6452,7 +6396,7 @@ "kind": "section" }, { - "id": "491-rest#tanstack-start-example", + "id": "487-rest#tanstack-start-example", "title": "REST", "searchTitle": "TanStack Start Example", "sectionTitle": "TanStack Start Example", @@ -6462,7 +6406,7 @@ "kind": "section" }, { - "id": "492-rest#openapi-generation", + "id": "488-rest#openapi-generation", "title": "REST", "searchTitle": "OpenAPI Generation", "sectionTitle": "OpenAPI Generation", @@ -6472,7 +6416,7 @@ "kind": "section" }, { - "id": "493-rest#full-working-example", + "id": "489-rest#full-working-example", "title": "REST", "searchTitle": "Full Working Example", "sectionTitle": "Full Working Example", @@ -6500,7 +6444,7 @@ "kind": "page" }, { - "id": "494-roadmap#q4-2025", + "id": "490-roadmap#q4-2025", "title": "Roadmap", "searchTitle": "Q4 2025", "sectionTitle": "Q4 2025", @@ -6510,7 +6454,7 @@ "kind": "section" }, { - "id": "495-roadmap#beyond", + "id": "491-roadmap#beyond", "title": "Roadmap", "searchTitle": "Beyond", "sectionTitle": "Beyond", @@ -6546,7 +6490,7 @@ "kind": "page" }, { - "id": "496-samples#gigabugs", + "id": "492-samples#gigabugs", "title": "Samples", "searchTitle": "Gigabugs", "sectionTitle": "Gigabugs", @@ -6556,7 +6500,7 @@ "kind": "section" }, { - "id": "497-samples#ztunes", + "id": "493-samples#ztunes", "title": "Samples", "searchTitle": "ztunes", "sectionTitle": "ztunes", @@ -6566,7 +6510,7 @@ "kind": "section" }, { - "id": "498-samples#zslack", + "id": "494-samples#zslack", "title": "Samples", "searchTitle": "zslack", "sectionTitle": "zslack", @@ -6576,7 +6520,7 @@ "kind": "section" }, { - "id": "499-samples#zero-music", + "id": "495-samples#zero-music", "title": "Samples", "searchTitle": "zero-music", "sectionTitle": "zero-music", @@ -6712,7 +6656,7 @@ "kind": "page" }, { - "id": "500-schema#generating-from-database", + "id": "496-schema#generating-from-database", "title": "Zero Schema", "searchTitle": "Generating from Database", "sectionTitle": "Generating from Database", @@ -6722,7 +6666,7 @@ "kind": "section" }, { - "id": "501-schema#writing-by-hand", + "id": "497-schema#writing-by-hand", "title": "Zero Schema", "searchTitle": "Writing by Hand", "sectionTitle": "Writing by Hand", @@ -6732,7 +6676,7 @@ "kind": "section" }, { - "id": "502-schema#table-schemas", + "id": "498-schema#table-schemas", "title": "Zero Schema", "searchTitle": "Table Schemas", "sectionTitle": "Table Schemas", @@ -6742,7 +6686,7 @@ "kind": "section" }, { - "id": "503-schema#name-mapping", + "id": "499-schema#name-mapping", "title": "Zero Schema", "searchTitle": "Name Mapping", "sectionTitle": "Name Mapping", @@ -6752,7 +6696,7 @@ "kind": "section" }, { - "id": "504-schema#multiple-schemas", + "id": "500-schema#multiple-schemas", "title": "Zero Schema", "searchTitle": "Multiple Schemas", "sectionTitle": "Multiple Schemas", @@ -6762,7 +6706,7 @@ "kind": "section" }, { - "id": "505-schema#optional-columns", + "id": "501-schema#optional-columns", "title": "Zero Schema", "searchTitle": "Optional Columns", "sectionTitle": "Optional Columns", @@ -6772,7 +6716,7 @@ "kind": "section" }, { - "id": "506-schema#enumerations", + "id": "502-schema#enumerations", "title": "Zero Schema", "searchTitle": "Enumerations", "sectionTitle": "Enumerations", @@ -6782,7 +6726,7 @@ "kind": "section" }, { - "id": "507-schema#custom-json-types", + "id": "503-schema#custom-json-types", "title": "Zero Schema", "searchTitle": "Custom JSON Types", "sectionTitle": "Custom JSON Types", @@ -6792,7 +6736,7 @@ "kind": "section" }, { - "id": "508-schema#compound-primary-keys", + "id": "504-schema#compound-primary-keys", "title": "Zero Schema", "searchTitle": "Compound Primary Keys", "sectionTitle": "Compound Primary Keys", @@ -6802,7 +6746,7 @@ "kind": "section" }, { - "id": "509-schema#relationships", + "id": "505-schema#relationships", "title": "Zero Schema", "searchTitle": "Relationships", "sectionTitle": "Relationships", @@ -6812,7 +6756,7 @@ "kind": "section" }, { - "id": "510-schema#many-to-many-relationships", + "id": "506-schema#many-to-many-relationships", "title": "Zero Schema", "searchTitle": "Many-to-Many Relationships", "sectionTitle": "Many-to-Many Relationships", @@ -6822,7 +6766,7 @@ "kind": "section" }, { - "id": "511-schema#compound-keys-relationships", + "id": "507-schema#compound-keys-relationships", "title": "Zero Schema", "searchTitle": "Compound Keys Relationships", "sectionTitle": "Compound Keys Relationships", @@ -6832,7 +6776,7 @@ "kind": "section" }, { - "id": "512-schema#circular-relationships", + "id": "508-schema#circular-relationships", "title": "Zero Schema", "searchTitle": "Circular Relationships", "sectionTitle": "Circular Relationships", @@ -6842,7 +6786,7 @@ "kind": "section" }, { - "id": "513-schema#database-schemas", + "id": "509-schema#database-schemas", "title": "Zero Schema", "searchTitle": "Database Schemas", "sectionTitle": "Database Schemas", @@ -6852,7 +6796,7 @@ "kind": "section" }, { - "id": "514-schema#register-schema-type", + "id": "510-schema#register-schema-type", "title": "Zero Schema", "searchTitle": "Register Schema Type", "sectionTitle": "Register Schema Type", @@ -6862,7 +6806,7 @@ "kind": "section" }, { - "id": "515-schema#schema-changes", + "id": "511-schema#schema-changes", "title": "Zero Schema", "searchTitle": "Schema Changes", "sectionTitle": "Schema Changes", @@ -6872,7 +6816,7 @@ "kind": "section" }, { - "id": "516-schema#development", + "id": "512-schema#development", "title": "Zero Schema", "searchTitle": "Development", "sectionTitle": "Development", @@ -6882,7 +6826,7 @@ "kind": "section" }, { - "id": "517-schema#production", + "id": "513-schema#production", "title": "Zero Schema", "searchTitle": "Production", "sectionTitle": "Production", @@ -6892,7 +6836,7 @@ "kind": "section" }, { - "id": "518-schema#expand-changes", + "id": "514-schema#expand-changes", "title": "Zero Schema", "searchTitle": "Expand Changes", "sectionTitle": "Expand Changes", @@ -6902,7 +6846,7 @@ "kind": "section" }, { - "id": "519-schema#contract-changes", + "id": "515-schema#contract-changes", "title": "Zero Schema", "searchTitle": "Contract Changes", "sectionTitle": "Contract Changes", @@ -6912,7 +6856,7 @@ "kind": "section" }, { - "id": "520-schema#compound-changes", + "id": "516-schema#compound-changes", "title": "Zero Schema", "searchTitle": "Compound Changes", "sectionTitle": "Compound Changes", @@ -6922,7 +6866,7 @@ "kind": "section" }, { - "id": "521-schema#examples", + "id": "517-schema#examples", "title": "Zero Schema", "searchTitle": "Examples", "sectionTitle": "Examples", @@ -6932,7 +6876,7 @@ "kind": "section" }, { - "id": "522-schema#adding-a-column", + "id": "518-schema#adding-a-column", "title": "Zero Schema", "searchTitle": "Adding a Column", "sectionTitle": "Adding a Column", @@ -6942,7 +6886,7 @@ "kind": "section" }, { - "id": "523-schema#removing-a-column", + "id": "519-schema#removing-a-column", "title": "Zero Schema", "searchTitle": "Removing a Column", "sectionTitle": "Removing a Column", @@ -6952,7 +6896,7 @@ "kind": "section" }, { - "id": "524-schema#renaming-a-column", + "id": "520-schema#renaming-a-column", "title": "Zero Schema", "searchTitle": "Renaming a Column", "sectionTitle": "Renaming a Column", @@ -6962,7 +6906,7 @@ "kind": "section" }, { - "id": "525-schema#making-a-column-optional", + "id": "521-schema#making-a-column-optional", "title": "Zero Schema", "searchTitle": "Making a Column Optional", "sectionTitle": "Making a Column Optional", @@ -6972,7 +6916,7 @@ "kind": "section" }, { - "id": "526-schema#quick-reference", + "id": "522-schema#quick-reference", "title": "Zero Schema", "searchTitle": "Quick Reference", "sectionTitle": "Quick Reference", @@ -6982,7 +6926,7 @@ "kind": "section" }, { - "id": "527-schema#backfill", + "id": "523-schema#backfill", "title": "Zero Schema", "searchTitle": "Backfill", "sectionTitle": "Backfill", @@ -6992,7 +6936,7 @@ "kind": "section" }, { - "id": "528-schema#monitoring-backfill-progress", + "id": "524-schema#monitoring-backfill-progress", "title": "Zero Schema", "searchTitle": "Monitoring Backfill Progress", "sectionTitle": "Monitoring Backfill Progress", @@ -7006,7 +6950,7 @@ "title": "Self-Hosting Zero", "searchTitle": "Self-Hosting Zero", "url": "/docs/self-host", - "content": "To self-host Zero, you will need to deploy zero-cache, a Postgres database, your frontend, and your API server. Zero-cache is made up of two main components: One or more view-syncers: serving client queries using a SQLite replica. One replication-manager: bridge between the Postgres replication stream and view-syncers. These components have the following characteristics: You will also need to deploy a Postgres database, your frontend, and your API server for the query and mutate endpoints. Before setting up Postgres, read Connecting to Postgres for provider-specific notes. Docker Images The examples below use Docker Hub, but the Zero container image is available from: Docker Hub: rocicorp/zero:{version} GHCR: ghcr.io/rocicorp/zero:{version} Minimum Viable Strategy The simplest way to deploy Zero is to run everything on a single node. This is the least expensive way to run Zero, and it can take you surprisingly far. Here are equivalent single-node configurations for a few common deployment targets: services: zero-cache: image: rocicorp/zero:{version} ports: - 4848:4848 stop_grace_period: 10m environment: # Used for replication from postgres # This *must* be a direct connection (not via pgbouncer) ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records # Use a pooler in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries # Use a pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/replica.db # Password used to access the inspector and /statz ZERO_ADMIN_PASSWORD: pickanewpassword # URLs for your API /query and /mutate endpoints ZERO_QUERY_URL: https://api.example.com/api/zero/query ZERO_MUTATE_URL: https://api.example.com/api/zero/mutate ZERO_ENABLE_CRUD_MUTATIONS: 'false' volumes: - zero-cache-data:/data healthcheck: test: curl -f http://localhost:4848/keepalive interval: 5s start_period: 10m upstream-db: image: postgres:18 environment: POSTGRES_DB: zero POSTGRES_PASSWORD: pass ports: - 5432:5432 command: postgres -c wal_level=logical healthcheck: test: pg_isready interval: 10sapp = \"zero-cache\" primary_region = \"iad\" kill_timeout = 300 [build] image = \"rocicorp/zero:{version}\" [http_service] internal_port = 4848 force_https = true auto_stop_machines = \"off\" min_machines_running = 1 [[http_service.checks]] protocol = \"https\" path = \"/keepalive\" interval = \"5s\" timeout = \"5s\" grace_period = \"10m\" [mounts] source = \"zero_data\" destination = \"/data\" [env] ZERO_UPSTREAM_DB = \"postgresql://postgres:pass@db.internal:5432/zero\" ZERO_CVR_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_CHANGE_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_ADMIN_PASSWORD = \"pickanewpassword\" ZERO_QUERY_URL = \"https://api.example.com/api/zero/query\" ZERO_MUTATE_URL = \"https://api.example.com/api/zero/mutate\" ZERO_ENABLE_CRUD_MUTATIONS = \"false\" ZERO_REPLICA_FILE = \"/data/replica.db\"/// export default $config({ app(input) { return { name: 'zero', home: 'aws', removal: input?.stage === 'production' ? 'retain' : 'remove' } }, async run() { const vpc = new sst.aws.Vpc('ZeroVpc') const cluster = new sst.aws.Cluster('ZeroCluster', { vpc }) const efs = new sst.aws.Efs('ZeroReplicaFs', {vpc}) new sst.aws.Service('ZeroCache', { cluster, image: 'rocicorp/zero:{version}', cpu: '1 vCPU', memory: '2 GB', volumes: [{efs, path: '/data'}], environment: { ZERO_UPSTREAM_DB: 'postgresql://postgres:pass@postgres:5432/zero', ZERO_CVR_DB: 'postgresql://postgres:pass@pgbouncer:5432/zero', ZERO_CHANGE_DB: 'postgresql://postgres:pass@pgbouncer:5432/zero', ZERO_ADMIN_PASSWORD: 'pickanewpassword', ZERO_QUERY_URL: 'https://api.example.com/api/zero/query', ZERO_MUTATE_URL: 'https://api.example.com/api/zero/mutate', ZERO_ENABLE_CRUD_MUTATIONS: 'false', ZERO_REPLICA_FILE: '/data/replica.db' }, health: { command: [ 'CMD-SHELL', 'curl -f http://localhost:4848/keepalive || exit 1' ], startPeriod: '300 seconds' }, loadBalancer: { public: true, ports: [{listen: '80/http', forward: '4848/http'}] }, transform: { service: { healthCheckGracePeriodSeconds: 600 }, target: { healthCheck: { enabled: true, path: '/keepalive', protocol: 'HTTP', interval: 5, timeout: 3, healthyThreshold: 2 } } } }) } })apiVersion: apps/v1 kind: Deployment metadata: name: zero-cache spec: replicas: 1 selector: matchLabels: app: zero-cache template: metadata: labels: app: zero-cache spec: terminationGracePeriodSeconds: 600 containers: - name: zero-cache image: rocicorp/zero:{version} ports: - name: http containerPort: 4848 env: - name: ZERO_UPSTREAM_DB value: postgresql://postgres:pass@postgres:5432/zero - name: ZERO_CVR_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_CHANGE_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_ADMIN_PASSWORD value: pickanewpassword - name: ZERO_QUERY_URL value: https://api.example.com/api/zero/query - name: ZERO_MUTATE_URL value: https://api.example.com/api/zero/mutate - name: ZERO_ENABLE_CRUD_MUTATIONS value: 'false' - name: ZERO_REPLICA_FILE value: /data/replica.db lifecycle: preStop: exec: command: ['sh', '-c', 'sleep 10'] volumeMounts: - name: data mountPath: /data startupProbe: httpGet: path: / port: http periodSeconds: 5 failureThreshold: 120 readinessProbe: httpGet: path: / port: http periodSeconds: 5 livenessProbe: httpGet: path: /keepalive port: http periodSeconds: 10 volumes: - name: data persistentVolumeClaim: claimName: zero-cache-data --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: zero-cache-data spec: accessModes: [ReadWriteOnce] resources: requests: storage: 20Gi These snippets only show the zero-cache side of the deployment. The API behind ZERO_QUERY_URL and ZERO_MUTATE_URL can live anywhere zero-cache can reach. Maximal Strategy Once you reach the limits of the single-node deployment, you can split zero-cache into a multi-node topology. This is more expensive to run, but it gives you more flexibility and scalability. Here are equivalent multi-node configurations for the same topology on a few common deployment targets: services: replication-manager: image: rocicorp/zero:{version} # Do not expose the RM to the public internet - only view-syncers expose: - 4849 stop_grace_period: 10m depends_on: upstream-db: condition: service_healthy environment: ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_REPLICA_FILE: /data/replica.db ZERO_ADMIN_PASSWORD: pickanewpassword ZERO_NUM_SYNC_WORKERS: 0 ZERO_LITESTREAM_BACKUP_URL: s3://acme-zero-backups/v1 volumes: - replication-manager-data:/data healthcheck: test: curl -f http://localhost:4849/keepalive interval: 5s start_period: 10m view-syncer: image: rocicorp/zero:{version} ports: - 4848:4848 stop_grace_period: 10m depends_on: replication-manager: condition: service_healthy environment: ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_REPLICA_FILE: /data/replica.db ZERO_ADMIN_PASSWORD: pickanewpassword ZERO_QUERY_URL: https://api.example.com/api/zero/query ZERO_MUTATE_URL: https://api.example.com/api/zero/mutate ZERO_ENABLE_CRUD_MUTATIONS: 'false' ZERO_CHANGE_STREAMER_URI: ws://replication-manager:4849/ volumes: - view-syncer-data:/data healthcheck: test: curl -f http://localhost:4848/keepalive interval: 5s start_period: 10m upstream-db: image: postgres:18 environment: POSTGRES_DB: zero POSTGRES_PASSWORD: pass ports: - 5432:5432 command: postgres -c wal_level=logical healthcheck: test: pg_isready interval: 10s# replication-manager/fly.toml app = \"zero-replication-manager\" primary_region = \"iad\" kill_timeout = 300 [build] image = \"rocicorp/zero:{version}\" # Do not add [http_service] or [[services]] to this app. The # replication-manager serves Zero's internal replication protocol and should # only be reachable over Fly private networking at: # ws://zero-replication-manager.internal:4849/ # # Since this app does not have [http_service], use a top-level Machine check. [checks] [checks.replication_manager] type = \"http\" port = 4849 path = \"/\" interval = \"5s\" timeout = \"5s\" grace_period = \"10m\" [mounts] source = \"replication_data\" destination = \"/data\" [env] ZERO_UPSTREAM_DB = \"postgresql://postgres:pass@db.internal:5432/zero\" ZERO_CVR_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_CHANGE_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_ADMIN_PASSWORD = \"pickanewpassword\" ZERO_REPLICA_FILE = \"/data/replica.db\" ZERO_NUM_SYNC_WORKERS = \"0\" ZERO_LITESTREAM_BACKUP_URL = \"s3://acme-zero-backups/v1\" # view-syncer/fly.toml app = \"zero-view-syncer\" primary_region = \"iad\" kill_timeout = 300 [build] image = \"rocicorp/zero:{version}\" # If you run more than one view-syncer on Fly, add sticky routing # (for example Fly Replay / replay_cache) so clients stay on one machine. [http_service] internal_port = 4848 force_https = true auto_stop_machines = \"off\" min_machines_running = 1 # View-syncers are public, so their health checks attach to [http_service]. [[http_service.checks]] protocol = \"https\" path = \"/\" interval = \"5s\" timeout = \"5s\" grace_period = \"10m\" [mounts] source = \"view_syncer_data\" destination = \"/data\" [env] ZERO_UPSTREAM_DB = \"postgresql://postgres:pass@db.internal:5432/zero\" ZERO_CVR_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_CHANGE_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_ADMIN_PASSWORD = \"pickanewpassword\" ZERO_QUERY_URL = \"https://api.example.com/api/zero/query\" ZERO_MUTATE_URL = \"https://api.example.com/api/zero/mutate\" ZERO_ENABLE_CRUD_MUTATIONS = \"false\" ZERO_REPLICA_FILE = \"/data/replica.db\" ZERO_CHANGE_STREAMER_URI = \"ws://zero-replication-manager.internal:4849/\"/// export default $config({ app(input) { return { name: 'zero', home: 'aws', removal: input?.stage === 'production' ? 'retain' : 'remove' } }, async run() { const backups = new sst.aws.Bucket('ZeroBackups') const vpc = new sst.aws.Vpc('ZeroVpc') const cluster = new sst.aws.Cluster('ZeroCluster', { vpc }) const commonEnv = { ZERO_UPSTREAM_DB: 'postgresql://postgres:pass@postgres:5432/zero', ZERO_CVR_DB: 'postgresql://postgres:pass@pgbouncer:5432/zero', ZERO_CHANGE_DB: 'postgresql://postgres:pass@pgbouncer:5432/zero', ZERO_ADMIN_PASSWORD: 'pickanewpassword', ZERO_REPLICA_FILE: 'replica.db' } const replicationManager = new sst.aws.Service( 'ReplicationManager', { cluster, image: 'rocicorp/zero:{version}', cpu: '1 vCPU', memory: '2 GB', environment: { ...commonEnv, ZERO_NUM_SYNC_WORKERS: '0', ZERO_LITESTREAM_BACKUP_URL: `s3://${backups.name}/v1` }, health: { command: [ 'CMD-SHELL', 'curl -f http://localhost:4849/keepalive || exit 1' ], startPeriod: '300 seconds' }, loadBalancer: { public: false, ports: [{listen: '80/http', forward: '4849/http'}] }, transform: { service: { healthCheckGracePeriodSeconds: 600 }, target: { healthCheck: { enabled: true, path: '/keepalive', protocol: 'HTTP', interval: 5, timeout: 3, healthyThreshold: 2 } } } } ) new sst.aws.Service( 'ViewSyncer', { cluster, image: 'rocicorp/zero:{version}', cpu: '2 vCPU', memory: '4 GB', environment: { ...commonEnv, ZERO_QUERY_URL: 'https://api.example.com/api/zero/query', ZERO_MUTATE_URL: 'https://api.example.com/api/zero/mutate', ZERO_ENABLE_CRUD_MUTATIONS: 'false', ZERO_CHANGE_STREAMER_URI: replicationManager.url }, health: { command: [ 'CMD-SHELL', 'curl -f http://localhost:4848/keepalive || exit 1' ], startPeriod: '300 seconds' }, loadBalancer: { public: true, ports: [{listen: '80/http', forward: '4848/http'}] }, transform: { service: { healthCheckGracePeriodSeconds: 600 }, target: { healthCheck: { enabled: true, path: '/keepalive', protocol: 'HTTP', interval: 5, timeout: 3, healthyThreshold: 2 }, stickiness: { enabled: true, type: 'lb_cookie', cookieDuration: 120 } } } }, {dependsOn: [replicationManager]} ) } })apiVersion: apps/v1 kind: Deployment metadata: name: replication-manager spec: replicas: 1 selector: matchLabels: app: replication-manager template: metadata: labels: app: replication-manager spec: terminationGracePeriodSeconds: 600 containers: - name: replication-manager image: rocicorp/zero:{version} ports: - name: http containerPort: 4849 env: - name: ZERO_UPSTREAM_DB value: postgresql://postgres:pass@postgres:5432/zero - name: ZERO_CVR_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_CHANGE_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_ADMIN_PASSWORD value: pickanewpassword - name: ZERO_REPLICA_FILE value: /data/replica.db - name: ZERO_NUM_SYNC_WORKERS value: '0' - name: ZERO_LITESTREAM_BACKUP_URL value: s3://acme-zero-backups/v1 volumeMounts: - name: data mountPath: /data startupProbe: httpGet: path: / port: http periodSeconds: 5 failureThreshold: 120 readinessProbe: httpGet: path: / port: http periodSeconds: 5 livenessProbe: httpGet: path: /keepalive port: http periodSeconds: 10 volumes: - name: data emptyDir: {} --- apiVersion: v1 kind: Service metadata: name: replication-manager-service spec: type: ClusterIP selector: app: replication-manager ports: - name: http port: 4849 targetPort: http --- apiVersion: apps/v1 kind: Deployment metadata: name: view-syncer spec: replicas: 2 selector: matchLabels: app: view-syncer template: metadata: labels: app: view-syncer spec: terminationGracePeriodSeconds: 600 containers: - name: view-syncer image: rocicorp/zero:{version} ports: - name: http containerPort: 4848 env: - name: ZERO_UPSTREAM_DB value: postgresql://postgres:pass@postgres:5432/zero - name: ZERO_CVR_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_CHANGE_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_ADMIN_PASSWORD value: pickanewpassword - name: ZERO_QUERY_URL value: https://api.example.com/api/zero/query - name: ZERO_MUTATE_URL value: https://api.example.com/api/zero/mutate - name: ZERO_ENABLE_CRUD_MUTATIONS value: 'false' - name: ZERO_REPLICA_FILE value: /data/replica.db - name: ZERO_CHANGE_STREAMER_URI value: ws://replication-manager-service:4849/ lifecycle: preStop: exec: command: ['sh', '-c', 'sleep 10'] volumeMounts: - name: data mountPath: /data startupProbe: httpGet: path: / port: http periodSeconds: 5 failureThreshold: 120 readinessProbe: httpGet: path: / port: http periodSeconds: 5 livenessProbe: httpGet: path: /keepalive port: http periodSeconds: 10 volumes: - name: data emptyDir: {} --- apiVersion: v1 kind: Service metadata: name: view-syncer-service spec: type: LoadBalancer selector: app: view-syncer sessionAffinity: ClientIP ports: - name: http port: 4848 targetPort: http In multi-node deployments, keep ZERO_LITESTREAM_BACKUP_URL on the replication-manager only and point it at an AWS S3 bucket. The view-syncers in the multi-node topology can be horizontally scaled as needed. If restores or initial syncs take a while, configure your orchestrator to allow a startup grace period before treating startup checks as a failure. Ten minutes is a good default for most apps. For example, Docker Compose uses healthcheck.start_period, Fly.io uses grace_period, and ECS services can use healthCheckGracePeriodSeconds. Increase it if replica restore or initial sync routinely takes longer. Likewise, during deploys, give zero-cache, replication-manager, and view-syncer a generous shutdown grace period so they can finish cleanup and drain websocket connections. Replica Lifecycle Zero-cache is backed by a SQLite replica of your database. The SQLite replica uses upstream Postgres as the source of truth. If the replica is missing or a litestream restore fails, the replication-manager will resync the replica from upstream on the next start. Litestream Compatibility The official image restores with Litestream v5 but still writes legacy WAL backups. V5 reads both WAL and LTX; use ZERO_LITESTREAM_RESTORE_USING_V5=false for Age-encrypted backups. Test custom configurations in staging. Before writing LTX backups, enable v5 restore everywhere. Afterward, roll back only to Zero 1.9 or later with v5 restore enabled. Performance You want to optimize disk IOPS for the serving replica, since this is the file that is read by the view-syncers to run IVM-based queries, and one of the main bottlenecks for query hydration performance. View syncer's IVM is \"hydrate once, then incrementally push diffs\" against the ZQL pipeline, so performance is mostly about: How fast the server can materialize a subscription the first time (hydration). How fast it can keep it up to date (IVM advancement). Different bottlenecks dominate each phase. Hydration SQLite read cost: hydration is essentially \"run the query against the replica and stream all matching rows into the pipeline\", so it's bounded by SQLite scan/index performance + result size. Churn / TTL eviction: if queries get evicted (inactive long enough) and then get re-requested, you pay hydration again. Custom query transform latency: the HTTP request from zero-cache to your API at ZERO_QUERY_URL does transform/authorization for queries, adding network + CPU before hydration starts. IVM advancement Replication throughput: the view-syncer can only advance when the replicator commits and emits version-ready. If upstream replication is behind, query advancement is capped by how fast the replica advances. Change volume per transaction: advancement cost scales with number of changed rows, not number of queries. Circuit breaker behavior: if advancement looks like it'll take longer than rehydrating, zero-cache intentionally aborts and resets pipelines (which trades \"slow incremental\" for \"rehydrate\"). System-level Number of client groups per sync worker: each client group has its own pipelines; CPU and memory per group limits how many can be \"fast\" at once. Since Node is single-threaded, one client group can technically starve other groups. This is handled with time slicing and can be configured with the yield parameters, e.g. ZERO_YIELD_THRESHOLD_MS. SQLite concurrency limits: it's designed here for one writer (replicator) + many concurrent readers (view-syncer snapshots). It scales, but very heavy read workloads can still contend on cache/IO. Network to clients: even if IVM is fast, it can take time to send data over websocket. This can be improved by using CDNs (like CloudFront) that improve routing. Network between services: for a single-region deployment, all services should be colocated. Networking View syncers must be publicly reachable by clients on port 4848. The replication-manager must only be reachable by view-syncers over your private network on port 4849. The replication-manager serves Zero's internal replication protocol. Keep it behind private networking such as a private service address, internal load balancer, or Kubernetes ClusterIP service. The external load balancer for view-syncers must support websockets, and can use the health check at /keepalive to verify view-syncers are healthy. The replication-manager should also have a /keepalive health check, but that check should run through private infrastructure rather than a public load balancer. Sticky Sessions View syncers are designed to be disposable, but since they keep hydrated query pipelines in memory, it's important to try to keep clients connected to the same instance. If a reconnect/refresh lands on a different instance, that instance usually has to rehydrate instead of reusing warm state. If you are seeing a lot of Rehome errors, you may need to enable sticky sessions. Two instances can end up doing redundant hydration/advancement work for the same clientGroupID, and the \"loser\" will eventually force clients to reconnect. Rolling Updates Zero supports zero-downtime updates by rolling out changes in the following order: Upgrade replication-manager and wait for it to start up. Upgrade view-syncers (if they come up before the replication-manager, they'll sit in retry loops until the manager is updated). Update the API servers (your mutate and query endpoints). Update client(s). After most clients have refreshed, run contract migrations to drop or rename obsolete columns/tables. Rolling out Zero version changes and schema changes together is complicated because both require specific ordering, and the ordering depends on the type of schema change. For this reason, we recommend separating the two types of changes into different PRs and deployments. Client/Server Version Compatibility Servers are compatible with any client of same major version, and with clients one major version back. For example, server 2.2.0 is compatible with: Client 2.3.0 (same major version) Client 2.1.0 (same major version) Client 1.0.0 (previous major version) But server 2.2.0 is not compatible with: Client 3.0.0 (next major version) Client 0.1.0 (two major versions back) To upgrade Zero to a new major version, first deploy the new zero-cache, then the new frontend. Configuration The zero-cache image is configured via environment variables. See zero-cache Config for available options.", + "content": "To self-host Zero, you will need to deploy zero-cache, a Postgres database, your frontend, and your API server. Zero-cache is made up of two main components: One or more view-syncers: serving client queries using a SQLite replica. One replication-manager: bridge between the Postgres replication stream and view-syncers. These components have the following characteristics: You will also need to deploy a Postgres database, your frontend, and your API server for the query and mutate endpoints. Before setting up Postgres, read Connecting to Postgres for provider-specific notes. Docker Images The examples below use Docker Hub, but the Zero container image is available from: Docker Hub: rocicorp/zero:{version} GHCR: ghcr.io/rocicorp/zero:{version} Minimum Viable Strategy The simplest way to deploy Zero is to run everything on a single node. This is the least expensive way to run Zero, and it can take you surprisingly far. Here are equivalent single-node configurations for a few common deployment targets: services: zero-cache: image: rocicorp/zero:{version} ports: - 4848:4848 stop_grace_period: 10m environment: # Used for replication from postgres # This *must* be a direct connection (not via pgbouncer) ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing client view records # Use a pooler in production ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero # Used for storing recent replication log entries # Use a pooler in production ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero # Path to the SQLite replica ZERO_REPLICA_FILE: /data/replica.db # Password used to access the inspector and /statz ZERO_ADMIN_PASSWORD: pickanewpassword # URLs for your API /query and /mutate endpoints ZERO_QUERY_URL: https://api.example.com/api/zero/query ZERO_MUTATE_URL: https://api.example.com/api/zero/mutate ZERO_ENABLE_CRUD_MUTATIONS: 'false' volumes: - zero-cache-data:/data healthcheck: test: curl -f http://localhost:4848/keepalive interval: 5s start_period: 10m upstream-db: image: postgres:18 environment: POSTGRES_DB: zero POSTGRES_PASSWORD: pass ports: - 5432:5432 command: postgres -c wal_level=logical healthcheck: test: pg_isready interval: 10sapp = \"zero-cache\" primary_region = \"iad\" kill_timeout = 300 [build] image = \"rocicorp/zero:{version}\" [http_service] internal_port = 4848 force_https = true auto_stop_machines = \"off\" min_machines_running = 1 [[http_service.checks]] protocol = \"https\" path = \"/keepalive\" interval = \"5s\" timeout = \"5s\" grace_period = \"10m\" [mounts] source = \"zero_data\" destination = \"/data\" [env] ZERO_UPSTREAM_DB = \"postgresql://postgres:pass@db.internal:5432/zero\" ZERO_CVR_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_CHANGE_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_ADMIN_PASSWORD = \"pickanewpassword\" ZERO_QUERY_URL = \"https://api.example.com/api/zero/query\" ZERO_MUTATE_URL = \"https://api.example.com/api/zero/mutate\" ZERO_ENABLE_CRUD_MUTATIONS = \"false\" ZERO_REPLICA_FILE = \"/data/replica.db\"/// export default $config({ app(input) { return { name: 'zero', home: 'aws', removal: input?.stage === 'production' ? 'retain' : 'remove' } }, async run() { const vpc = new sst.aws.Vpc('ZeroVpc') const cluster = new sst.aws.Cluster('ZeroCluster', { vpc }) const efs = new sst.aws.Efs('ZeroReplicaFs', {vpc}) new sst.aws.Service('ZeroCache', { cluster, image: 'rocicorp/zero:{version}', cpu: '1 vCPU', memory: '2 GB', volumes: [{efs, path: '/data'}], environment: { ZERO_UPSTREAM_DB: 'postgresql://postgres:pass@postgres:5432/zero', ZERO_CVR_DB: 'postgresql://postgres:pass@pgbouncer:5432/zero', ZERO_CHANGE_DB: 'postgresql://postgres:pass@pgbouncer:5432/zero', ZERO_ADMIN_PASSWORD: 'pickanewpassword', ZERO_QUERY_URL: 'https://api.example.com/api/zero/query', ZERO_MUTATE_URL: 'https://api.example.com/api/zero/mutate', ZERO_ENABLE_CRUD_MUTATIONS: 'false', ZERO_REPLICA_FILE: '/data/replica.db' }, health: { command: [ 'CMD-SHELL', 'curl -f http://localhost:4848/keepalive || exit 1' ], startPeriod: '300 seconds' }, loadBalancer: { public: true, ports: [{listen: '80/http', forward: '4848/http'}] }, transform: { service: { healthCheckGracePeriodSeconds: 600 }, target: { healthCheck: { enabled: true, path: '/keepalive', protocol: 'HTTP', interval: 5, timeout: 3, healthyThreshold: 2 } } } }) } })apiVersion: apps/v1 kind: Deployment metadata: name: zero-cache spec: replicas: 1 selector: matchLabels: app: zero-cache template: metadata: labels: app: zero-cache spec: terminationGracePeriodSeconds: 600 containers: - name: zero-cache image: rocicorp/zero:{version} ports: - name: http containerPort: 4848 env: - name: ZERO_UPSTREAM_DB value: postgresql://postgres:pass@postgres:5432/zero - name: ZERO_CVR_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_CHANGE_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_ADMIN_PASSWORD value: pickanewpassword - name: ZERO_QUERY_URL value: https://api.example.com/api/zero/query - name: ZERO_MUTATE_URL value: https://api.example.com/api/zero/mutate - name: ZERO_ENABLE_CRUD_MUTATIONS value: 'false' - name: ZERO_REPLICA_FILE value: /data/replica.db lifecycle: preStop: exec: command: ['sh', '-c', 'sleep 10'] volumeMounts: - name: data mountPath: /data startupProbe: httpGet: path: / port: http periodSeconds: 5 failureThreshold: 120 readinessProbe: httpGet: path: / port: http periodSeconds: 5 livenessProbe: httpGet: path: /keepalive port: http periodSeconds: 10 volumes: - name: data persistentVolumeClaim: claimName: zero-cache-data --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: zero-cache-data spec: accessModes: [ReadWriteOnce] resources: requests: storage: 20Gi These snippets only show the zero-cache side of the deployment. The API behind ZERO_QUERY_URL and ZERO_MUTATE_URL can live anywhere zero-cache can reach. Maximal Strategy Once you reach the limits of the single-node deployment, you can split zero-cache into a multi-node topology. This is more expensive to run, but it gives you more flexibility and scalability. Here are equivalent multi-node configurations for the same topology on a few common deployment targets: services: replication-manager: image: rocicorp/zero:{version} # Do not expose the RM to the public internet - only view-syncers expose: - 4849 stop_grace_period: 10m depends_on: upstream-db: condition: service_healthy environment: ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_REPLICA_FILE: /data/replica.db ZERO_ADMIN_PASSWORD: pickanewpassword ZERO_NUM_SYNC_WORKERS: 0 ZERO_LITESTREAM_BACKUP_URL: s3://acme-zero-backups/v1 volumes: - replication-manager-data:/data healthcheck: test: curl -f http://localhost:4849/keepalive interval: 5s start_period: 10m view-syncer: image: rocicorp/zero:{version} ports: - 4848:4848 stop_grace_period: 10m depends_on: replication-manager: condition: service_healthy environment: ZERO_UPSTREAM_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_CVR_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_CHANGE_DB: postgres://postgres:pass@upstream-db:5432/zero ZERO_REPLICA_FILE: /data/replica.db ZERO_ADMIN_PASSWORD: pickanewpassword ZERO_QUERY_URL: https://api.example.com/api/zero/query ZERO_MUTATE_URL: https://api.example.com/api/zero/mutate ZERO_ENABLE_CRUD_MUTATIONS: 'false' ZERO_CHANGE_STREAMER_URI: ws://replication-manager:4849/ volumes: - view-syncer-data:/data healthcheck: test: curl -f http://localhost:4848/keepalive interval: 5s start_period: 10m upstream-db: image: postgres:18 environment: POSTGRES_DB: zero POSTGRES_PASSWORD: pass ports: - 5432:5432 command: postgres -c wal_level=logical healthcheck: test: pg_isready interval: 10s# replication-manager/fly.toml app = \"zero-replication-manager\" primary_region = \"iad\" kill_timeout = 300 [build] image = \"rocicorp/zero:{version}\" # Do not add [http_service] or [[services]] to this app. The # replication-manager serves Zero's internal replication protocol and should # only be reachable over Fly private networking at: # ws://zero-replication-manager.internal:4849/ # # Since this app does not have [http_service], use a top-level Machine check. [checks] [checks.replication_manager] type = \"http\" port = 4849 path = \"/\" interval = \"5s\" timeout = \"5s\" grace_period = \"10m\" [mounts] source = \"replication_data\" destination = \"/data\" [env] ZERO_UPSTREAM_DB = \"postgresql://postgres:pass@db.internal:5432/zero\" ZERO_CVR_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_CHANGE_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_ADMIN_PASSWORD = \"pickanewpassword\" ZERO_REPLICA_FILE = \"/data/replica.db\" ZERO_NUM_SYNC_WORKERS = \"0\" ZERO_LITESTREAM_BACKUP_URL = \"s3://acme-zero-backups/v1\" # view-syncer/fly.toml app = \"zero-view-syncer\" primary_region = \"iad\" kill_timeout = 300 [build] image = \"rocicorp/zero:{version}\" # If you run more than one view-syncer on Fly, add sticky routing # (for example Fly Replay / replay_cache) so clients stay on one machine. [http_service] internal_port = 4848 force_https = true auto_stop_machines = \"off\" min_machines_running = 1 # View-syncers are public, so their health checks attach to [http_service]. [[http_service.checks]] protocol = \"https\" path = \"/\" interval = \"5s\" timeout = \"5s\" grace_period = \"10m\" [mounts] source = \"view_syncer_data\" destination = \"/data\" [env] ZERO_UPSTREAM_DB = \"postgresql://postgres:pass@db.internal:5432/zero\" ZERO_CVR_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_CHANGE_DB = \"postgresql://postgres:pass@pgbouncer.internal:5432/zero\" ZERO_ADMIN_PASSWORD = \"pickanewpassword\" ZERO_QUERY_URL = \"https://api.example.com/api/zero/query\" ZERO_MUTATE_URL = \"https://api.example.com/api/zero/mutate\" ZERO_ENABLE_CRUD_MUTATIONS = \"false\" ZERO_REPLICA_FILE = \"/data/replica.db\" ZERO_CHANGE_STREAMER_URI = \"ws://zero-replication-manager.internal:4849/\"/// export default $config({ app(input) { return { name: 'zero', home: 'aws', removal: input?.stage === 'production' ? 'retain' : 'remove' } }, async run() { const backups = new sst.aws.Bucket('ZeroBackups') const vpc = new sst.aws.Vpc('ZeroVpc') const cluster = new sst.aws.Cluster('ZeroCluster', { vpc }) const commonEnv = { ZERO_UPSTREAM_DB: 'postgresql://postgres:pass@postgres:5432/zero', ZERO_CVR_DB: 'postgresql://postgres:pass@pgbouncer:5432/zero', ZERO_CHANGE_DB: 'postgresql://postgres:pass@pgbouncer:5432/zero', ZERO_ADMIN_PASSWORD: 'pickanewpassword', ZERO_REPLICA_FILE: 'replica.db' } const replicationManager = new sst.aws.Service( 'ReplicationManager', { cluster, image: 'rocicorp/zero:{version}', cpu: '1 vCPU', memory: '2 GB', environment: { ...commonEnv, ZERO_NUM_SYNC_WORKERS: '0', ZERO_LITESTREAM_BACKUP_URL: `s3://${backups.name}/v1` }, health: { command: [ 'CMD-SHELL', 'curl -f http://localhost:4849/keepalive || exit 1' ], startPeriod: '300 seconds' }, loadBalancer: { public: false, ports: [{listen: '80/http', forward: '4849/http'}] }, transform: { service: { healthCheckGracePeriodSeconds: 600 }, target: { healthCheck: { enabled: true, path: '/keepalive', protocol: 'HTTP', interval: 5, timeout: 3, healthyThreshold: 2 } } } } ) new sst.aws.Service( 'ViewSyncer', { cluster, image: 'rocicorp/zero:{version}', cpu: '2 vCPU', memory: '4 GB', environment: { ...commonEnv, ZERO_QUERY_URL: 'https://api.example.com/api/zero/query', ZERO_MUTATE_URL: 'https://api.example.com/api/zero/mutate', ZERO_ENABLE_CRUD_MUTATIONS: 'false', ZERO_CHANGE_STREAMER_URI: replicationManager.url }, health: { command: [ 'CMD-SHELL', 'curl -f http://localhost:4848/keepalive || exit 1' ], startPeriod: '300 seconds' }, loadBalancer: { public: true, ports: [{listen: '80/http', forward: '4848/http'}] }, transform: { service: { healthCheckGracePeriodSeconds: 600 }, target: { healthCheck: { enabled: true, path: '/keepalive', protocol: 'HTTP', interval: 5, timeout: 3, healthyThreshold: 2 }, stickiness: { enabled: true, type: 'lb_cookie', cookieDuration: 120 } } } }, {dependsOn: [replicationManager]} ) } })apiVersion: apps/v1 kind: Deployment metadata: name: replication-manager spec: replicas: 1 selector: matchLabels: app: replication-manager template: metadata: labels: app: replication-manager spec: terminationGracePeriodSeconds: 600 containers: - name: replication-manager image: rocicorp/zero:{version} ports: - name: http containerPort: 4849 env: - name: ZERO_UPSTREAM_DB value: postgresql://postgres:pass@postgres:5432/zero - name: ZERO_CVR_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_CHANGE_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_ADMIN_PASSWORD value: pickanewpassword - name: ZERO_REPLICA_FILE value: /data/replica.db - name: ZERO_NUM_SYNC_WORKERS value: '0' - name: ZERO_LITESTREAM_BACKUP_URL value: s3://acme-zero-backups/v1 volumeMounts: - name: data mountPath: /data startupProbe: httpGet: path: / port: http periodSeconds: 5 failureThreshold: 120 readinessProbe: httpGet: path: / port: http periodSeconds: 5 livenessProbe: httpGet: path: /keepalive port: http periodSeconds: 10 volumes: - name: data emptyDir: {} --- apiVersion: v1 kind: Service metadata: name: replication-manager-service spec: type: ClusterIP selector: app: replication-manager ports: - name: http port: 4849 targetPort: http --- apiVersion: apps/v1 kind: Deployment metadata: name: view-syncer spec: replicas: 2 selector: matchLabels: app: view-syncer template: metadata: labels: app: view-syncer spec: terminationGracePeriodSeconds: 600 containers: - name: view-syncer image: rocicorp/zero:{version} ports: - name: http containerPort: 4848 env: - name: ZERO_UPSTREAM_DB value: postgresql://postgres:pass@postgres:5432/zero - name: ZERO_CVR_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_CHANGE_DB value: postgresql://postgres:pass@pgbouncer:5432/zero - name: ZERO_ADMIN_PASSWORD value: pickanewpassword - name: ZERO_QUERY_URL value: https://api.example.com/api/zero/query - name: ZERO_MUTATE_URL value: https://api.example.com/api/zero/mutate - name: ZERO_ENABLE_CRUD_MUTATIONS value: 'false' - name: ZERO_REPLICA_FILE value: /data/replica.db - name: ZERO_CHANGE_STREAMER_URI value: ws://replication-manager-service:4849/ lifecycle: preStop: exec: command: ['sh', '-c', 'sleep 10'] volumeMounts: - name: data mountPath: /data startupProbe: httpGet: path: / port: http periodSeconds: 5 failureThreshold: 120 readinessProbe: httpGet: path: / port: http periodSeconds: 5 livenessProbe: httpGet: path: /keepalive port: http periodSeconds: 10 volumes: - name: data emptyDir: {} --- apiVersion: v1 kind: Service metadata: name: view-syncer-service spec: type: LoadBalancer selector: app: view-syncer sessionAffinity: ClientIP ports: - name: http port: 4848 targetPort: http In multi-node deployments, keep ZERO_LITESTREAM_BACKUP_URL on the replication-manager only and point it at an AWS S3 bucket. The view-syncers in the multi-node topology can be horizontally scaled as needed. If restores or initial syncs take a while, configure your orchestrator to allow a startup grace period before treating startup checks as a failure. Ten minutes is a good default for most apps. For example, Docker Compose uses healthcheck.start_period, Fly.io uses grace_period, and ECS services can use healthCheckGracePeriodSeconds. Increase it if replica restore or initial sync routinely takes longer. Likewise, during deploys, give zero-cache, replication-manager, and view-syncer a generous shutdown grace period so they can finish cleanup and drain websocket connections. Replica Lifecycle Zero-cache is backed by a SQLite replica of your database. The SQLite replica uses upstream Postgres as the source of truth. If the replica is missing or a litestream restore fails, the replication-manager will resync the replica from upstream on the next start. Performance You want to optimize disk IOPS for the serving replica, since this is the file that is read by the view-syncers to run IVM-based queries, and one of the main bottlenecks for query hydration performance. View syncer's IVM is \"hydrate once, then incrementally push diffs\" against the ZQL pipeline, so performance is mostly about: How fast the server can materialize a subscription the first time (hydration). How fast it can keep it up to date (IVM advancement). Different bottlenecks dominate each phase. Hydration SQLite read cost: hydration is essentially \"run the query against the replica and stream all matching rows into the pipeline\", so it's bounded by SQLite scan/index performance + result size. Churn / TTL eviction: if queries get evicted (inactive long enough) and then get re-requested, you pay hydration again. Custom query transform latency: the HTTP request from zero-cache to your API at ZERO_QUERY_URL does transform/authorization for queries, adding network + CPU before hydration starts. IVM advancement Replication throughput: the view-syncer can only advance when the replicator commits and emits version-ready. If upstream replication is behind, query advancement is capped by how fast the replica advances. Change volume per transaction: advancement cost scales with number of changed rows, not number of queries. Circuit breaker behavior: if advancement looks like it'll take longer than rehydrating, zero-cache intentionally aborts and resets pipelines (which trades \"slow incremental\" for \"rehydrate\"). System-level Number of client groups per sync worker: each client group has its own pipelines; CPU and memory per group limits how many can be \"fast\" at once. Since Node is single-threaded, one client group can technically starve other groups. This is handled with time slicing and can be configured with the yield parameters, e.g. ZERO_YIELD_THRESHOLD_MS. SQLite concurrency limits: it's designed here for one writer (replicator) + many concurrent readers (view-syncer snapshots). It scales, but very heavy read workloads can still contend on cache/IO. Network to clients: even if IVM is fast, it can take time to send data over websocket. This can be improved by using CDNs (like CloudFront) that improve routing. Network between services: for a single-region deployment, all services should be colocated. Networking View syncers must be publicly reachable by clients on port 4848. The replication-manager must only be reachable by view-syncers over your private network on port 4849. The replication-manager serves Zero's internal replication protocol. Keep it behind private networking such as a private service address, internal load balancer, or Kubernetes ClusterIP service. The external load balancer for view-syncers must support websockets, and can use the health check at /keepalive to verify view-syncers are healthy. The replication-manager should also have a /keepalive health check, but that check should run through private infrastructure rather than a public load balancer. Sticky Sessions View syncers are designed to be disposable, but since they keep hydrated query pipelines in memory, it's important to try to keep clients connected to the same instance. If a reconnect/refresh lands on a different instance, that instance usually has to rehydrate instead of reusing warm state. If you are seeing a lot of Rehome errors, you may need to enable sticky sessions. Two instances can end up doing redundant hydration/advancement work for the same clientGroupID, and the \"loser\" will eventually force clients to reconnect. Rolling Updates Zero supports zero-downtime updates by rolling out changes in the following order: Upgrade replication-manager and wait for it to start up. Upgrade view-syncers (if they come up before the replication-manager, they'll sit in retry loops until the manager is updated). Update the API servers (your mutate and query endpoints). Update client(s). After most clients have refreshed, run contract migrations to drop or rename obsolete columns/tables. Rolling out Zero version changes and schema changes together is complicated because both require specific ordering, and the ordering depends on the type of schema change. For this reason, we recommend separating the two types of changes into different PRs and deployments. Client/Server Version Compatibility Servers are compatible with any client of same major version, and with clients one major version back. For example, server 2.2.0 is compatible with: Client 2.3.0 (same major version) Client 2.1.0 (same major version) Client 1.0.0 (previous major version) But server 2.2.0 is not compatible with: Client 3.0.0 (next major version) Client 0.1.0 (two major versions back) To upgrade Zero to a new major version, first deploy the new zero-cache, then the new frontend. Configuration The zero-cache image is configured via environment variables. See zero-cache Config for available options.", "headings": [ { "text": "Docker Images", @@ -7024,10 +6968,6 @@ "text": "Replica Lifecycle", "id": "replica-lifecycle" }, - { - "text": "Litestream Compatibility", - "id": "litestream-compatibility" - }, { "text": "Performance", "id": "performance" @@ -7068,7 +7008,7 @@ "kind": "page" }, { - "id": "529-self-host#docker-images", + "id": "525-self-host#docker-images", "title": "Self-Hosting Zero", "searchTitle": "Docker Images", "sectionTitle": "Docker Images", @@ -7078,7 +7018,7 @@ "kind": "section" }, { - "id": "530-self-host#minimum-viable-strategy", + "id": "526-self-host#minimum-viable-strategy", "title": "Self-Hosting Zero", "searchTitle": "Minimum Viable Strategy", "sectionTitle": "Minimum Viable Strategy", @@ -7088,7 +7028,7 @@ "kind": "section" }, { - "id": "531-self-host#maximal-strategy", + "id": "527-self-host#maximal-strategy", "title": "Self-Hosting Zero", "searchTitle": "Maximal Strategy", "sectionTitle": "Maximal Strategy", @@ -7098,27 +7038,17 @@ "kind": "section" }, { - "id": "532-self-host#replica-lifecycle", + "id": "528-self-host#replica-lifecycle", "title": "Self-Hosting Zero", "searchTitle": "Replica Lifecycle", "sectionTitle": "Replica Lifecycle", "sectionId": "replica-lifecycle", "url": "/docs/self-host", - "content": "Zero-cache is backed by a SQLite replica of your database. The SQLite replica uses upstream Postgres as the source of truth. If the replica is missing or a litestream restore fails, the replication-manager will resync the replica from upstream on the next start. Litestream Compatibility The official image restores with Litestream v5 but still writes legacy WAL backups. V5 reads both WAL and LTX; use ZERO_LITESTREAM_RESTORE_USING_V5=false for Age-encrypted backups. Test custom configurations in staging. Before writing LTX backups, enable v5 restore everywhere. Afterward, roll back only to Zero 1.9 or later with v5 restore enabled.", - "kind": "section" - }, - { - "id": "533-self-host#litestream-compatibility", - "title": "Self-Hosting Zero", - "searchTitle": "Litestream Compatibility", - "sectionTitle": "Litestream Compatibility", - "sectionId": "litestream-compatibility", - "url": "/docs/self-host", - "content": "The official image restores with Litestream v5 but still writes legacy WAL backups. V5 reads both WAL and LTX; use ZERO_LITESTREAM_RESTORE_USING_V5=false for Age-encrypted backups. Test custom configurations in staging. Before writing LTX backups, enable v5 restore everywhere. Afterward, roll back only to Zero 1.9 or later with v5 restore enabled.", + "content": "Zero-cache is backed by a SQLite replica of your database. The SQLite replica uses upstream Postgres as the source of truth. If the replica is missing or a litestream restore fails, the replication-manager will resync the replica from upstream on the next start.", "kind": "section" }, { - "id": "534-self-host#performance", + "id": "529-self-host#performance", "title": "Self-Hosting Zero", "searchTitle": "Performance", "sectionTitle": "Performance", @@ -7128,7 +7058,7 @@ "kind": "section" }, { - "id": "535-self-host#hydration", + "id": "530-self-host#hydration", "title": "Self-Hosting Zero", "searchTitle": "Hydration", "sectionTitle": "Hydration", @@ -7138,7 +7068,7 @@ "kind": "section" }, { - "id": "536-self-host#ivm-advancement", + "id": "531-self-host#ivm-advancement", "title": "Self-Hosting Zero", "searchTitle": "IVM advancement", "sectionTitle": "IVM advancement", @@ -7148,7 +7078,7 @@ "kind": "section" }, { - "id": "537-self-host#system-level", + "id": "532-self-host#system-level", "title": "Self-Hosting Zero", "searchTitle": "System-level", "sectionTitle": "System-level", @@ -7158,7 +7088,7 @@ "kind": "section" }, { - "id": "538-self-host#networking", + "id": "533-self-host#networking", "title": "Self-Hosting Zero", "searchTitle": "Networking", "sectionTitle": "Networking", @@ -7168,7 +7098,7 @@ "kind": "section" }, { - "id": "539-self-host#sticky-sessions", + "id": "534-self-host#sticky-sessions", "title": "Self-Hosting Zero", "searchTitle": "Sticky Sessions", "sectionTitle": "Sticky Sessions", @@ -7178,7 +7108,7 @@ "kind": "section" }, { - "id": "540-self-host#rolling-updates", + "id": "535-self-host#rolling-updates", "title": "Self-Hosting Zero", "searchTitle": "Rolling Updates", "sectionTitle": "Rolling Updates", @@ -7188,7 +7118,7 @@ "kind": "section" }, { - "id": "541-self-host#clientserver-version-compatibility", + "id": "536-self-host#clientserver-version-compatibility", "title": "Self-Hosting Zero", "searchTitle": "Client/Server Version Compatibility", "sectionTitle": "Client/Server Version Compatibility", @@ -7198,7 +7128,7 @@ "kind": "section" }, { - "id": "542-self-host#configuration", + "id": "537-self-host#configuration", "title": "Self-Hosting Zero", "searchTitle": "Configuration", "sectionTitle": "Configuration", @@ -7234,7 +7164,7 @@ "kind": "page" }, { - "id": "543-server-zql#creating-a-database", + "id": "538-server-zql#creating-a-database", "title": "ZQL on the Server", "searchTitle": "Creating a Database", "sectionTitle": "Creating a Database", @@ -7244,7 +7174,7 @@ "kind": "section" }, { - "id": "544-server-zql#custom-database", + "id": "539-server-zql#custom-database", "title": "ZQL on the Server", "searchTitle": "Custom Database", "sectionTitle": "Custom Database", @@ -7254,7 +7184,7 @@ "kind": "section" }, { - "id": "545-server-zql#running-zql", + "id": "540-server-zql#running-zql", "title": "ZQL on the Server", "searchTitle": "Running ZQL", "sectionTitle": "Running ZQL", @@ -7264,7 +7194,7 @@ "kind": "section" }, { - "id": "546-server-zql#ssr", + "id": "541-server-zql#ssr", "title": "ZQL on the Server", "searchTitle": "SSR", "sectionTitle": "SSR", @@ -7296,7 +7226,7 @@ "kind": "page" }, { - "id": "547-solidjs#setup", + "id": "542-solidjs#setup", "title": "SolidJS", "searchTitle": "Setup", "sectionTitle": "Setup", @@ -7306,7 +7236,7 @@ "kind": "section" }, { - "id": "548-solidjs#usage", + "id": "543-solidjs#usage", "title": "SolidJS", "searchTitle": "Usage", "sectionTitle": "Usage", @@ -7316,7 +7246,7 @@ "kind": "section" }, { - "id": "549-solidjs#examples", + "id": "544-solidjs#examples", "title": "SolidJS", "searchTitle": "Examples", "sectionTitle": "Examples", @@ -7352,7 +7282,7 @@ "kind": "page" }, { - "id": "550-status#breaking-changes", + "id": "545-status#breaking-changes", "title": "Project Status", "searchTitle": "Breaking Changes", "sectionTitle": "Breaking Changes", @@ -7362,7 +7292,7 @@ "kind": "section" }, { - "id": "551-status#roadmap", + "id": "546-status#roadmap", "title": "Project Status", "searchTitle": "Roadmap", "sectionTitle": "Roadmap", @@ -7372,7 +7302,7 @@ "kind": "section" }, { - "id": "552-status#2026", + "id": "547-status#2026", "title": "Project Status", "searchTitle": "2026", "sectionTitle": "2026", @@ -7382,7 +7312,7 @@ "kind": "section" }, { - "id": "553-status#soon", + "id": "548-status#soon", "title": "Project Status", "searchTitle": "Soon", "sectionTitle": "Soon", @@ -7414,7 +7344,7 @@ "kind": "page" }, { - "id": "554-sync#problem", + "id": "549-sync#problem", "title": "What is Sync?", "searchTitle": "Problem", "sectionTitle": "Problem", @@ -7424,7 +7354,7 @@ "kind": "section" }, { - "id": "555-sync#solution", + "id": "550-sync#solution", "title": "What is Sync?", "searchTitle": "Solution", "sectionTitle": "Solution", @@ -7434,7 +7364,7 @@ "kind": "section" }, { - "id": "556-sync#history-of-sync", + "id": "551-sync#history-of-sync", "title": "What is Sync?", "searchTitle": "History of Sync", "sectionTitle": "History of Sync", @@ -7518,7 +7448,7 @@ "kind": "page" }, { - "id": "557-tutorial#setup", + "id": "552-tutorial#setup", "title": "Tutorial", "searchTitle": "Setup", "sectionTitle": "Setup", @@ -7528,7 +7458,7 @@ "kind": "section" }, { - "id": "558-tutorial#create-a-project", + "id": "553-tutorial#create-a-project", "title": "Tutorial", "searchTitle": "Create a Project", "sectionTitle": "Create a Project", @@ -7538,7 +7468,7 @@ "kind": "section" }, { - "id": "559-tutorial#set-up-your-database", + "id": "554-tutorial#set-up-your-database", "title": "Tutorial", "searchTitle": "Set Up Your Database", "sectionTitle": "Set Up Your Database", @@ -7548,7 +7478,7 @@ "kind": "section" }, { - "id": "560-tutorial#install-and-run-zero-cache", + "id": "555-tutorial#install-and-run-zero-cache", "title": "Tutorial", "searchTitle": "Install and Run Zero-Cache", "sectionTitle": "Install and Run Zero-Cache", @@ -7558,7 +7488,7 @@ "kind": "section" }, { - "id": "561-tutorial#integrate-zero", + "id": "556-tutorial#integrate-zero", "title": "Tutorial", "searchTitle": "Integrate Zero", "sectionTitle": "Integrate Zero", @@ -7568,7 +7498,7 @@ "kind": "section" }, { - "id": "562-tutorial#set-up-your-zero-schema", + "id": "557-tutorial#set-up-your-zero-schema", "title": "Tutorial", "searchTitle": "Set Up Your Zero Schema", "sectionTitle": "Set Up Your Zero Schema", @@ -7578,7 +7508,7 @@ "kind": "section" }, { - "id": "563-tutorial#set-up-the-zero-client", + "id": "558-tutorial#set-up-the-zero-client", "title": "Tutorial", "searchTitle": "Set Up the Zero Client", "sectionTitle": "Set Up the Zero Client", @@ -7588,7 +7518,7 @@ "kind": "section" }, { - "id": "564-tutorial#sync-data", + "id": "559-tutorial#sync-data", "title": "Tutorial", "searchTitle": "Sync Data", "sectionTitle": "Sync Data", @@ -7598,7 +7528,7 @@ "kind": "section" }, { - "id": "565-tutorial#define-query", + "id": "560-tutorial#define-query", "title": "Tutorial", "searchTitle": "Define Query", "sectionTitle": "Define Query", @@ -7608,7 +7538,7 @@ "kind": "section" }, { - "id": "566-tutorial#add-query-endpoint", + "id": "561-tutorial#add-query-endpoint", "title": "Tutorial", "searchTitle": "Add Query Endpoint", "sectionTitle": "Add Query Endpoint", @@ -7618,7 +7548,7 @@ "kind": "section" }, { - "id": "567-tutorial#invoke-query", + "id": "562-tutorial#invoke-query", "title": "Tutorial", "searchTitle": "Invoke Query", "sectionTitle": "Invoke Query", @@ -7628,7 +7558,7 @@ "kind": "section" }, { - "id": "568-tutorial#mutate-data", + "id": "563-tutorial#mutate-data", "title": "Tutorial", "searchTitle": "Mutate Data", "sectionTitle": "Mutate Data", @@ -7638,7 +7568,7 @@ "kind": "section" }, { - "id": "569-tutorial#define-mutators", + "id": "564-tutorial#define-mutators", "title": "Tutorial", "searchTitle": "Define Mutators", "sectionTitle": "Define Mutators", @@ -7648,7 +7578,7 @@ "kind": "section" }, { - "id": "570-tutorial#add-mutate-endpoint", + "id": "565-tutorial#add-mutate-endpoint", "title": "Tutorial", "searchTitle": "Add Mutate Endpoint", "sectionTitle": "Add Mutate Endpoint", @@ -7658,7 +7588,7 @@ "kind": "section" }, { - "id": "571-tutorial#invoke-mutators", + "id": "566-tutorial#invoke-mutators", "title": "Tutorial", "searchTitle": "Invoke Mutators", "sectionTitle": "Invoke Mutators", @@ -7668,7 +7598,7 @@ "kind": "section" }, { - "id": "572-tutorial#next-steps", + "id": "567-tutorial#next-steps", "title": "Tutorial", "searchTitle": "Next Steps", "sectionTitle": "Next Steps", @@ -7744,7 +7674,7 @@ "kind": "page" }, { - "id": "573-when-to-use#zero-might-be-a-good-fit", + "id": "568-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", @@ -7754,7 +7684,7 @@ "kind": "section" }, { - "id": "574-when-to-use#you-want-to-sync-only-a-small-subset-of-data-to-client", + "id": "569-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", @@ -7764,7 +7694,7 @@ "kind": "section" }, { - "id": "575-when-to-use#you-need-fine-grained-read-or-write-permissions", + "id": "570-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", @@ -7774,7 +7704,7 @@ "kind": "section" }, { - "id": "576-when-to-use#you-are-building-a-traditional-client-server-web-app", + "id": "571-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", @@ -7784,7 +7714,7 @@ "kind": "section" }, { - "id": "577-when-to-use#you-use-postgresql", + "id": "572-when-to-use#you-use-postgresql", "title": "When To Use Zero", "searchTitle": "You use PostgreSQL", "sectionTitle": "You use PostgreSQL", @@ -7794,7 +7724,7 @@ "kind": "section" }, { - "id": "578-when-to-use#your-app-is-broadly-like-linear", + "id": "573-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\"", @@ -7804,7 +7734,7 @@ "kind": "section" }, { - "id": "579-when-to-use#interaction-performance-is-very-important-to-you", + "id": "574-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", @@ -7814,7 +7744,7 @@ "kind": "section" }, { - "id": "580-when-to-use#zero-might-not-be-a-good-fit", + "id": "575-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", @@ -7824,7 +7754,7 @@ "kind": "section" }, { - "id": "581-when-to-use#you-need-the-privacy-or-data-ownership-benefits-of-local-first", + "id": "576-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", @@ -7834,7 +7764,7 @@ "kind": "section" }, { - "id": "582-when-to-use#you-need-to-support-offline-writes-or-long-periods-offline", + "id": "577-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", @@ -7844,7 +7774,7 @@ "kind": "section" }, { - "id": "583-when-to-use#you-are-building-a-native-mobile-app", + "id": "578-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", @@ -7854,7 +7784,7 @@ "kind": "section" }, { - "id": "584-when-to-use#the-total-backend-dataset-is--100gb", + "id": "579-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", @@ -7864,7 +7794,7 @@ "kind": "section" }, { - "id": "585-when-to-use#zero-might-not-be-a-good-fit-yet", + "id": "580-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", @@ -7874,7 +7804,7 @@ "kind": "section" }, { - "id": "586-when-to-use#alternatives", + "id": "581-when-to-use#alternatives", "title": "When To Use Zero", "searchTitle": "Alternatives", "sectionTitle": "Alternatives", @@ -7888,7 +7818,7 @@ "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 Max Connections The maximum number of connections to open to the change database. This is used by the change-streamer for catching up zero-cache replication subscriptions. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS default: 5 Change Streamer Back 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 Padding Seconds During periodic flow control checks (every 64kb), this is the amount of time to wait after the majority of subscribers have acked, after which replication continues even if some subscribers have yet to ack. This is not a timeout for the entire send; it starts only after the majority of receivers have acked. This allows a bounded amount of time for backlogged subscribers to catch up on each flush without forcing all subscribers to wait for the entire backlog to be processed. It is also useful for mitigating the effect of unresponsive subscribers due to severed WebSocket connections until liveness checks disconnect them. Set this to a negative number to disable early flow control releases. flag: --change-streamer-flow-control-consensus-padding-seconds env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_PADDING_SECONDS default: 1 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. 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. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40 Litestream Config Path Path to the litestream yaml config file. zero-cache will run this with its environment variables, which can be referenced in the file via ${ENV} substitution, for example: ZERO_REPLICA_FILE for the db Path ZERO_LITESTREAM_BACKUP_LOCATION for the db replica url ZERO_LITESTREAM_LOG_LEVEL for the log Level ZERO_LOG_FORMAT for the log type flag: --litestream-config-path env: ZERO_LITESTREAM_CONFIG_PATH default: ./src/services/litestream/config.yml Litestream Executable Path to the litestream executable. This 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 restores when ZERO_LITESTREAM_RESTORE_USING_V5 is enabled. Litestream v0.5.8 and later can restore both legacy WAL backups and LTX backups, choosing the format with the latest data. The official Zero Docker image includes Litestream 0.5.15 at this path. flag: --litestream-executable-v5 env: ZERO_LITESTREAM_EXECUTABLE_V5 Litestream Restore Using V5 Use ZERO_LITESTREAM_EXECUTABLE_V5 for restores when that executable is configured. If it is unavailable, Zero falls back to the legacy executable. Set this to false to force legacy restore behavior. 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 requires v5 restore and identical ZERO_LITESTREAM_EXECUTABLE and ZERO_LITESTREAM_EXECUTABLE_V5 paths. Older images cannot restore an LTX-only backup. flag: --litestream-backup-using-v5 env: ZERO_LITESTREAM_BACKUP_USING_V5 default: false Litestream Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Maximum Checkpoint Page Count The WAL page count at which SQLite performs a RESTART checkpoint, which blocks writers until complete. Defaults to minCheckpointPageCount * 10. Set to 0 to disable RESTART checkpoints entirely. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 Litestream Minimum Checkpoint Page Count The WAL page count at which SQLite attempts a PASSIVE checkpoint, which transfers pages to the main database file without blocking writers. Defaults to checkpointThresholdMB * 250 (since SQLite page size is 4KB). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250 Litestream Multipart Concurrency The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY default: 48 Litestream Multipart Size The size of each part when uploading or downloading the snapshot with --litestream-multipart-concurrency. Note that up to concurrency * size bytes of memory are used when backing up or restoring the snapshot. flag: --litestream-multipart-size env: ZERO_LITESTREAM_MULTIPART_SIZE default: 16777216 (16 MiB) Litestream Log Level flag: --litestream-log-level env: ZERO_LITESTREAM_LOG_LEVEL default: warn values: debug, info, warn, error Litestream Port Port on which litestream exports metrics, used to determine the replication watermark up to which it is safe to purge change log records. flag: --litestream-port env: ZERO_LITESTREAM_PORT default: --port + 2 Litestream 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. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12 Log Format Use text for developer-friendly console logging and json for consumption by structured-logging services. flag: --log-format env: ZERO_LOG_FORMAT default: \"text\" values: text, json Log IVM Sampling How often to collect IVM metrics. 1 out of N requests will be sampled where N is this value. flag: --log-ivm-sampling env: ZERO_LOG_IVM_SAMPLING default: 5000 Log Level Sets the logging level for the application. flag: --log-level env: ZERO_LOG_LEVEL default: \"info\" values: debug, info, warn, error Log Slow Hydrate Threshold The number of milliseconds a query hydration must take to print a slow warning. flag: --log-slow-hydrate-threshold env: ZERO_LOG_SLOW_HYDRATE_THRESHOLD default: 100 Log Slow Row Threshold The number of ms a row must take to fetch from table-source before it is considered slow. flag: --log-slow-row-threshold env: ZERO_LOG_SLOW_ROW_THRESHOLD default: 2 Mutate API Key An optional secret used to authorize zero-cache to call the API server handling writes. This is sent from zero-cache to your mutate endpoint in an X-Api-Key header. flag: --mutate-api-key env: ZERO_MUTATE_API_KEY Mutate 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 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 Auth JWK A public key in JWK format used to verify JWTs. Only one of jwk, jwksUrl and secret may be set. flag: --auth-jwk env: ZERO_AUTH_JWK Auth JWKS URL A URL that returns a JWK set used to verify JWTs. Only one of jwk, jwksUrl and secret may be set. flag: --auth-jwks-url env: ZERO_AUTH_JWKS_URL Auth Secret A symmetric key used to verify JWTs. Only one of jwk, jwksUrl and secret may be set. flag: --auth-secret env: ZERO_AUTH_SECRET", + "content": "zero-cache is configured either via CLI flag or environment variable. There is no separate zero.config file. You can also see all available flags by running zero-cache --help. Required Flags Upstream DB The \"upstream\" authoritative postgres database. In the future we will support other types of upstream besides PG. flag: --upstream-db env: ZERO_UPSTREAM_DB required: true Admin Password A password used to administer zero-cache server, for example to access the /statz endpoint and the inspector. This is required in production (when NODE_ENV=production) because we want all Zero servers to be debuggable using admin tools by default, without needing a restart. But we also don't want to expose sensitive data using them. flag: --admin-password env: ZERO_ADMIN_PASSWORD required: in production (when NODE_ENV=production) Optional Flags App ID Unique identifier for the app. Multiple zero-cache apps can run on a single upstream database, each of which is isolated from the others, with its own permissions, sharding (future feature), and change/cvr databases. The metadata of an app is stored in an upstream schema with the same name, e.g. zero, and the metadata for each app shard, e.g. client and mutation ids, is stored in the {app-id}_{#} schema. (Currently there is only a single \"0\" shard, but this will change with sharding). The CVR and Change data are managed in schemas named {app-id}_{shard-num}/cvr and {app-id}_{shard-num}/cdc, respectively, allowing multiple apps and shards to share the same database instance (e.g. a Postgres \"cluster\") for CVR and Change management. Due to constraints on replication slot names, an App ID may only consist of lower-case letters, numbers, and the underscore character. Note that this option is used by both zero-cache and zero-deploy-permissions. flag: --app-id env: ZERO_APP_ID default: zero App Publications Postgres PUBLICATIONs that define the tables and columns to replicate. Publication names may not begin with an underscore, as zero reserves that prefix for internal use. If unspecified, zero-cache will create and use an internal publication that publishes all tables in the public schema, i.e.: CREATE PUBLICATION _{app-id}_public_0 FOR TABLES IN SCHEMA public; Note that changing the set of publications will result in resyncing the replica, which may involve downtime (replication lag) while the new replica is initializing. To change the set of publications without disrupting an existing app, a new app should be created. To use a custom publication, you can create one with: CREATE PUBLICATION zero_data FOR TABLES IN SCHEMA public; -- or, more selectively: CREATE PUBLICATION zero_data FOR TABLE users, orders; Then set the flag to that publication name, e.g.: ZERO_APP_PUBLICATIONS=zero_data. To specify multiple publications, separate them with commas, e.g.: ZERO_APP_PUBLICATIONS=zero_data1,zero_data2. flag: --app-publications env: ZERO_APP_PUBLICATIONS default: _{app-id}_public_0 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 Max Connections The maximum number of connections to open to the change database. This is used by the change-streamer for catching up zero-cache replication subscriptions. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS default: 5 Change Streamer Back 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 Padding Seconds During periodic flow control checks (every 64kb), this is the amount of time to wait after the majority of subscribers have acked, after which replication continues even if some subscribers have yet to ack. This is not a timeout for the entire send; it starts only after the majority of receivers have acked. This allows a bounded amount of time for backlogged subscribers to catch up on each flush without forcing all subscribers to wait for the entire backlog to be processed. It is also useful for mitigating the effect of unresponsive subscribers due to severed WebSocket connections until liveness checks disconnect them. Set this to a negative number to disable early flow control releases. flag: --change-streamer-flow-control-consensus-padding-seconds env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_PADDING_SECONDS default: 1 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. 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. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40 Litestream Config Path Path to the litestream yaml config file. zero-cache will run this with its environment variables, which can be referenced in the file via ${ENV} substitution, for example: ZERO_REPLICA_FILE for the db Path ZERO_LITESTREAM_BACKUP_LOCATION for the db replica url ZERO_LITESTREAM_LOG_LEVEL for the log Level ZERO_LOG_FORMAT for the log type flag: --litestream-config-path env: ZERO_LITESTREAM_CONFIG_PATH default: ./src/services/litestream/config.yml Litestream Executable Path to the litestream executable. This 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 restores when ZERO_LITESTREAM_RESTORE_USING_V5 is enabled. Litestream v0.5.8 and later can restore both legacy WAL backups and LTX backups, choosing the format with the latest data. The official Zero Docker image includes Litestream 0.5.15 at this path. flag: --litestream-executable-v5 env: ZERO_LITESTREAM_EXECUTABLE_V5 Litestream Restore Using V5 Use ZERO_LITESTREAM_EXECUTABLE_V5 for restores when that executable is configured. If it is unavailable, Zero falls back to the legacy executable. Set this to false to force legacy restore behavior. 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 makes rollback difficult because older versions cannot restore an LTX-only backup. flag: --litestream-backup-using-v5 env: ZERO_LITESTREAM_BACKUP_USING_V5 default: false Litestream Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Maximum Checkpoint Page Count The WAL page count at which SQLite performs a RESTART checkpoint, which blocks writers until complete. Defaults to minCheckpointPageCount * 10. Set to 0 to disable RESTART checkpoints entirely. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 Litestream Minimum Checkpoint Page Count The WAL page count at which SQLite attempts a PASSIVE checkpoint, which transfers pages to the main database file without blocking writers. Defaults to checkpointThresholdMB * 250 (since SQLite page size is 4KB). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250 Litestream Multipart Concurrency The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY default: 48 Litestream Multipart Size The size of each part when uploading or downloading the snapshot with --litestream-multipart-concurrency. Note that up to concurrency * size bytes of memory are used when backing up or restoring the snapshot. flag: --litestream-multipart-size env: ZERO_LITESTREAM_MULTIPART_SIZE default: 16777216 (16 MiB) Litestream Log Level flag: --litestream-log-level env: ZERO_LITESTREAM_LOG_LEVEL default: warn values: debug, info, warn, error Litestream Port Port on which litestream exports metrics, used to determine the replication watermark up to which it is safe to purge change log records. flag: --litestream-port env: ZERO_LITESTREAM_PORT default: --port + 2 Litestream 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. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12 Log Format Use text for developer-friendly console logging and json for consumption by structured-logging services. flag: --log-format env: ZERO_LOG_FORMAT default: \"text\" values: text, json Log IVM Sampling How often to collect IVM metrics. 1 out of N requests will be sampled where N is this value. flag: --log-ivm-sampling env: ZERO_LOG_IVM_SAMPLING default: 5000 Log Level Sets the logging level for the application. flag: --log-level env: ZERO_LOG_LEVEL default: \"info\" values: debug, info, warn, error Log Slow Hydrate Threshold The number of milliseconds a query hydration must take to print a slow warning. flag: --log-slow-hydrate-threshold env: ZERO_LOG_SLOW_HYDRATE_THRESHOLD default: 100 Log Slow Row Threshold The number of ms a row must take to fetch from table-source before it is considered slow. flag: --log-slow-row-threshold env: ZERO_LOG_SLOW_ROW_THRESHOLD default: 2 Mutate API Key An optional secret used to authorize zero-cache to call the API server handling writes. This is sent from zero-cache to your mutate endpoint in an X-Api-Key header. flag: --mutate-api-key env: ZERO_MUTATE_API_KEY Mutate 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 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 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", @@ -8238,7 +8168,7 @@ "kind": "page" }, { - "id": "587-zero-cache-config#required-flags", + "id": "582-zero-cache-config#required-flags", "title": "zero-cache Config", "searchTitle": "Required Flags", "sectionTitle": "Required Flags", @@ -8248,7 +8178,7 @@ "kind": "section" }, { - "id": "588-zero-cache-config#upstream-db", + "id": "583-zero-cache-config#upstream-db", "title": "zero-cache Config", "searchTitle": "Upstream DB", "sectionTitle": "Upstream DB", @@ -8258,7 +8188,7 @@ "kind": "section" }, { - "id": "589-zero-cache-config#admin-password", + "id": "584-zero-cache-config#admin-password", "title": "zero-cache Config", "searchTitle": "Admin Password", "sectionTitle": "Admin Password", @@ -8268,17 +8198,17 @@ "kind": "section" }, { - "id": "590-zero-cache-config#optional-flags", + "id": "585-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 Max Connections The maximum number of connections to open to the change database. This is used by the change-streamer for catching up zero-cache replication subscriptions. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS default: 5 Change Streamer Back 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 Padding Seconds During periodic flow control checks (every 64kb), this is the amount of time to wait after the majority of subscribers have acked, after which replication continues even if some subscribers have yet to ack. This is not a timeout for the entire send; it starts only after the majority of receivers have acked. This allows a bounded amount of time for backlogged subscribers to catch up on each flush without forcing all subscribers to wait for the entire backlog to be processed. It is also useful for mitigating the effect of unresponsive subscribers due to severed WebSocket connections until liveness checks disconnect them. Set this to a negative number to disable early flow control releases. flag: --change-streamer-flow-control-consensus-padding-seconds env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_PADDING_SECONDS default: 1 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. 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. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40 Litestream Config Path Path to the litestream yaml config file. zero-cache will run this with its environment variables, which can be referenced in the file via ${ENV} substitution, for example: ZERO_REPLICA_FILE for the db Path ZERO_LITESTREAM_BACKUP_LOCATION for the db replica url ZERO_LITESTREAM_LOG_LEVEL for the log Level ZERO_LOG_FORMAT for the log type flag: --litestream-config-path env: ZERO_LITESTREAM_CONFIG_PATH default: ./src/services/litestream/config.yml Litestream Executable Path to the litestream executable. This 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 restores when ZERO_LITESTREAM_RESTORE_USING_V5 is enabled. Litestream v0.5.8 and later can restore both legacy WAL backups and LTX backups, choosing the format with the latest data. The official Zero Docker image includes Litestream 0.5.15 at this path. flag: --litestream-executable-v5 env: ZERO_LITESTREAM_EXECUTABLE_V5 Litestream Restore Using V5 Use ZERO_LITESTREAM_EXECUTABLE_V5 for restores when that executable is configured. If it is unavailable, Zero falls back to the legacy executable. Set this to false to force legacy restore behavior. 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 requires v5 restore and identical ZERO_LITESTREAM_EXECUTABLE and ZERO_LITESTREAM_EXECUTABLE_V5 paths. Older images cannot restore an LTX-only backup. flag: --litestream-backup-using-v5 env: ZERO_LITESTREAM_BACKUP_USING_V5 default: false Litestream Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Maximum Checkpoint Page Count The WAL page count at which SQLite performs a RESTART checkpoint, which blocks writers until complete. Defaults to minCheckpointPageCount * 10. Set to 0 to disable RESTART checkpoints entirely. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 Litestream Minimum Checkpoint Page Count The WAL page count at which SQLite attempts a PASSIVE checkpoint, which transfers pages to the main database file without blocking writers. Defaults to checkpointThresholdMB * 250 (since SQLite page size is 4KB). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250 Litestream Multipart Concurrency The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY default: 48 Litestream Multipart Size The size of each part when uploading or downloading the snapshot with --litestream-multipart-concurrency. Note that up to concurrency * size bytes of memory are used when backing up or restoring the snapshot. flag: --litestream-multipart-size env: ZERO_LITESTREAM_MULTIPART_SIZE default: 16777216 (16 MiB) Litestream Log Level flag: --litestream-log-level env: ZERO_LITESTREAM_LOG_LEVEL default: warn values: debug, info, warn, error Litestream Port Port on which litestream exports metrics, used to determine the replication watermark up to which it is safe to purge change log records. flag: --litestream-port env: ZERO_LITESTREAM_PORT default: --port + 2 Litestream 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. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12 Log Format Use text for developer-friendly console logging and json for consumption by structured-logging services. flag: --log-format env: ZERO_LOG_FORMAT default: \"text\" values: text, json Log IVM Sampling How often to collect IVM metrics. 1 out of N requests will be sampled where N is this value. flag: --log-ivm-sampling env: ZERO_LOG_IVM_SAMPLING default: 5000 Log Level Sets the logging level for the application. flag: --log-level env: ZERO_LOG_LEVEL default: \"info\" values: debug, info, warn, error Log Slow Hydrate Threshold The number of milliseconds a query hydration must take to print a slow warning. flag: --log-slow-hydrate-threshold env: ZERO_LOG_SLOW_HYDRATE_THRESHOLD default: 100 Log Slow Row Threshold The number of ms a row must take to fetch from table-source before it is considered slow. flag: --log-slow-row-threshold env: ZERO_LOG_SLOW_ROW_THRESHOLD default: 2 Mutate API Key An optional secret used to authorize zero-cache to call the API server handling writes. This is sent from zero-cache to your mutate endpoint in an X-Api-Key header. flag: --mutate-api-key env: ZERO_MUTATE_API_KEY Mutate 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 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": "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 Max Connections The maximum number of connections to open to the change database. This is used by the change-streamer for catching up zero-cache replication subscriptions. flag: --change-max-conns env: ZERO_CHANGE_MAX_CONNS default: 5 Change Streamer Back 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 Padding Seconds During periodic flow control checks (every 64kb), this is the amount of time to wait after the majority of subscribers have acked, after which replication continues even if some subscribers have yet to ack. This is not a timeout for the entire send; it starts only after the majority of receivers have acked. This allows a bounded amount of time for backlogged subscribers to catch up on each flush without forcing all subscribers to wait for the entire backlog to be processed. It is also useful for mitigating the effect of unresponsive subscribers due to severed WebSocket connections until liveness checks disconnect them. Set this to a negative number to disable early flow control releases. flag: --change-streamer-flow-control-consensus-padding-seconds env: ZERO_CHANGE_STREAMER_FLOW_CONTROL_CONSENSUS_PADDING_SECONDS default: 1 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. 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. Smaller thresholds may improve read performance, at the expense of creating more files to download when restoring the replica from the backup. flag: --litestream-checkpoint-threshold-mb env: ZERO_LITESTREAM_CHECKPOINT_THRESHOLD_MB default: 40 Litestream Config Path Path to the litestream yaml config file. zero-cache will run this with its environment variables, which can be referenced in the file via ${ENV} substitution, for example: ZERO_REPLICA_FILE for the db Path ZERO_LITESTREAM_BACKUP_LOCATION for the db replica url ZERO_LITESTREAM_LOG_LEVEL for the log Level ZERO_LOG_FORMAT for the log type flag: --litestream-config-path env: ZERO_LITESTREAM_CONFIG_PATH default: ./src/services/litestream/config.yml Litestream Executable Path to the litestream executable. This 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 restores when ZERO_LITESTREAM_RESTORE_USING_V5 is enabled. Litestream v0.5.8 and later can restore both legacy WAL backups and LTX backups, choosing the format with the latest data. The official Zero Docker image includes Litestream 0.5.15 at this path. flag: --litestream-executable-v5 env: ZERO_LITESTREAM_EXECUTABLE_V5 Litestream Restore Using V5 Use ZERO_LITESTREAM_EXECUTABLE_V5 for restores when that executable is configured. If it is unavailable, Zero falls back to the legacy executable. Set this to false to force legacy restore behavior. 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 makes rollback difficult because older versions cannot restore an LTX-only backup. flag: --litestream-backup-using-v5 env: ZERO_LITESTREAM_BACKUP_USING_V5 default: false Litestream Incremental Backup Interval Minutes The interval between incremental backups of the replica. Shorter intervals reduce the amount of change history that needs to be replayed when catching up a new view-syncer, at the expense of increasing the number of files needed to download for the initial litestream restore. flag: --litestream-incremental-backup-interval-minutes env: ZERO_LITESTREAM_INCREMENTAL_BACKUP_INTERVAL_MINUTES default: 15 Litestream Maximum Checkpoint Page Count The WAL page count at which SQLite performs a RESTART checkpoint, which blocks writers until complete. Defaults to minCheckpointPageCount * 10. Set to 0 to disable RESTART checkpoints entirely. flag: --litestream-max-checkpoint-page-count env: ZERO_LITESTREAM_MAX_CHECKPOINT_PAGE_COUNT default: minCheckpointPageCount * 10 Litestream Minimum Checkpoint Page Count The WAL page count at which SQLite attempts a PASSIVE checkpoint, which transfers pages to the main database file without blocking writers. Defaults to checkpointThresholdMB * 250 (since SQLite page size is 4KB). flag: --litestream-min-checkpoint-page-count env: ZERO_LITESTREAM_MIN_CHECKPOINT_PAGE_COUNT default: checkpointThresholdMB * 250 Litestream Multipart Concurrency The number of parts (of size --litestream-multipart-size bytes) to upload or download in parallel when backing up or restoring the snapshot. flag: --litestream-multipart-concurrency env: ZERO_LITESTREAM_MULTIPART_CONCURRENCY default: 48 Litestream Multipart Size The size of each part when uploading or downloading the snapshot with --litestream-multipart-concurrency. Note that up to concurrency * size bytes of memory are used when backing up or restoring the snapshot. flag: --litestream-multipart-size env: ZERO_LITESTREAM_MULTIPART_SIZE default: 16777216 (16 MiB) Litestream Log Level flag: --litestream-log-level env: ZERO_LITESTREAM_LOG_LEVEL default: warn values: debug, info, warn, error Litestream Port Port on which litestream exports metrics, used to determine the replication watermark up to which it is safe to purge change log records. flag: --litestream-port env: ZERO_LITESTREAM_PORT default: --port + 2 Litestream 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. Applications with a large database and low write rate can increase this interval to reduce network usage for backups (litestream defaults to 24 hours). flag: --litestream-snapshot-backup-interval-hours env: ZERO_LITESTREAM_SNAPSHOT_BACKUP_INTERVAL_HOURS default: 12 Log Format Use text for developer-friendly console logging and json for consumption by structured-logging services. flag: --log-format env: ZERO_LOG_FORMAT default: \"text\" values: text, json Log IVM Sampling How often to collect IVM metrics. 1 out of N requests will be sampled where N is this value. flag: --log-ivm-sampling env: ZERO_LOG_IVM_SAMPLING default: 5000 Log Level Sets the logging level for the application. flag: --log-level env: ZERO_LOG_LEVEL default: \"info\" values: debug, info, warn, error Log Slow Hydrate Threshold The number of milliseconds a query hydration must take to print a slow warning. flag: --log-slow-hydrate-threshold env: ZERO_LOG_SLOW_HYDRATE_THRESHOLD default: 100 Log Slow Row Threshold The number of ms a row must take to fetch from table-source before it is considered slow. flag: --log-slow-row-threshold env: ZERO_LOG_SLOW_ROW_THRESHOLD default: 2 Mutate API Key An optional secret used to authorize zero-cache to call the API server handling writes. This is sent from zero-cache to your mutate endpoint in an X-Api-Key header. flag: --mutate-api-key env: ZERO_MUTATE_API_KEY Mutate 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 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": "591-zero-cache-config#app-id", + "id": "586-zero-cache-config#app-id", "title": "zero-cache Config", "searchTitle": "App ID", "sectionTitle": "App ID", @@ -8288,7 +8218,7 @@ "kind": "section" }, { - "id": "592-zero-cache-config#app-publications", + "id": "587-zero-cache-config#app-publications", "title": "zero-cache Config", "searchTitle": "App Publications", "sectionTitle": "App Publications", @@ -8298,7 +8228,7 @@ "kind": "section" }, { - "id": "593-zero-cache-config#auth-revalidate-interval-seconds", + "id": "588-zero-cache-config#auth-revalidate-interval-seconds", "title": "zero-cache Config", "searchTitle": "Auth Revalidate Interval Seconds", "sectionTitle": "Auth Revalidate Interval Seconds", @@ -8308,7 +8238,7 @@ "kind": "section" }, { - "id": "594-zero-cache-config#auth-retransform-interval-seconds", + "id": "589-zero-cache-config#auth-retransform-interval-seconds", "title": "zero-cache Config", "searchTitle": "Auth Retransform Interval Seconds", "sectionTitle": "Auth Retransform Interval Seconds", @@ -8318,7 +8248,7 @@ "kind": "section" }, { - "id": "595-zero-cache-config#auto-reset", + "id": "590-zero-cache-config#auto-reset", "title": "zero-cache Config", "searchTitle": "Auto Reset", "sectionTitle": "Auto Reset", @@ -8328,7 +8258,7 @@ "kind": "section" }, { - "id": "596-zero-cache-config#change-db", + "id": "591-zero-cache-config#change-db", "title": "zero-cache Config", "searchTitle": "Change DB", "sectionTitle": "Change DB", @@ -8338,7 +8268,7 @@ "kind": "section" }, { - "id": "597-zero-cache-config#change-max-connections", + "id": "592-zero-cache-config#change-max-connections", "title": "zero-cache Config", "searchTitle": "Change Max Connections", "sectionTitle": "Change Max Connections", @@ -8348,7 +8278,7 @@ "kind": "section" }, { - "id": "598-zero-cache-config#change-streamer-back-pressure-limit-heap-proportion", + "id": "593-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", @@ -8358,7 +8288,7 @@ "kind": "section" }, { - "id": "599-zero-cache-config#change-streamer-flow-control-consensus-padding-seconds", + "id": "594-zero-cache-config#change-streamer-flow-control-consensus-padding-seconds", "title": "zero-cache Config", "searchTitle": "Change Streamer Flow Control Consensus Padding Seconds", "sectionTitle": "Change Streamer Flow Control Consensus Padding Seconds", @@ -8368,7 +8298,7 @@ "kind": "section" }, { - "id": "600-zero-cache-config#change-streamer-mode", + "id": "595-zero-cache-config#change-streamer-mode", "title": "zero-cache Config", "searchTitle": "Change Streamer Mode", "sectionTitle": "Change Streamer Mode", @@ -8378,7 +8308,7 @@ "kind": "section" }, { - "id": "601-zero-cache-config#change-streamer-port", + "id": "596-zero-cache-config#change-streamer-port", "title": "zero-cache Config", "searchTitle": "Change Streamer Port", "sectionTitle": "Change Streamer Port", @@ -8388,7 +8318,7 @@ "kind": "section" }, { - "id": "602-zero-cache-config#change-streamer-startup-delay-ms", + "id": "597-zero-cache-config#change-streamer-startup-delay-ms", "title": "zero-cache Config", "searchTitle": "Change Streamer Startup Delay (ms)", "sectionTitle": "Change Streamer Startup Delay (ms)", @@ -8398,7 +8328,7 @@ "kind": "section" }, { - "id": "603-zero-cache-config#change-streamer-uri", + "id": "598-zero-cache-config#change-streamer-uri", "title": "zero-cache Config", "searchTitle": "Change Streamer URI", "sectionTitle": "Change Streamer URI", @@ -8408,7 +8338,7 @@ "kind": "section" }, { - "id": "604-zero-cache-config#cvr-db", + "id": "599-zero-cache-config#cvr-db", "title": "zero-cache Config", "searchTitle": "CVR DB", "sectionTitle": "CVR DB", @@ -8418,7 +8348,7 @@ "kind": "section" }, { - "id": "605-zero-cache-config#cvr-garbage-collection-inactivity-threshold-hours", + "id": "600-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", @@ -8428,7 +8358,7 @@ "kind": "section" }, { - "id": "606-zero-cache-config#cvr-garbage-collection-initial-batch-size", + "id": "601-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", @@ -8438,7 +8368,7 @@ "kind": "section" }, { - "id": "607-zero-cache-config#cvr-garbage-collection-initial-interval-seconds", + "id": "602-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", @@ -8448,7 +8378,7 @@ "kind": "section" }, { - "id": "608-zero-cache-config#cvr-max-connections", + "id": "603-zero-cache-config#cvr-max-connections", "title": "zero-cache Config", "searchTitle": "CVR Max Connections", "sectionTitle": "CVR Max Connections", @@ -8458,7 +8388,7 @@ "kind": "section" }, { - "id": "609-zero-cache-config#enable-query-planner", + "id": "604-zero-cache-config#enable-query-planner", "title": "zero-cache Config", "searchTitle": "Enable Query Planner", "sectionTitle": "Enable Query Planner", @@ -8468,7 +8398,7 @@ "kind": "section" }, { - "id": "610-zero-cache-config#enable-crud-mutations", + "id": "605-zero-cache-config#enable-crud-mutations", "title": "zero-cache Config", "searchTitle": "Enable CRUD Mutations", "sectionTitle": "Enable CRUD Mutations", @@ -8478,7 +8408,7 @@ "kind": "section" }, { - "id": "611-zero-cache-config#enable-telemetry", + "id": "606-zero-cache-config#enable-telemetry", "title": "zero-cache Config", "searchTitle": "Enable Telemetry", "sectionTitle": "Enable Telemetry", @@ -8488,7 +8418,7 @@ "kind": "section" }, { - "id": "612-zero-cache-config#initial-sync-table-copy-workers", + "id": "607-zero-cache-config#initial-sync-table-copy-workers", "title": "zero-cache Config", "searchTitle": "Initial Sync Table Copy Workers", "sectionTitle": "Initial Sync Table Copy Workers", @@ -8498,7 +8428,7 @@ "kind": "section" }, { - "id": "613-zero-cache-config#lazy-startup", + "id": "608-zero-cache-config#lazy-startup", "title": "zero-cache Config", "searchTitle": "Lazy Startup", "sectionTitle": "Lazy Startup", @@ -8508,7 +8438,7 @@ "kind": "section" }, { - "id": "614-zero-cache-config#litestream-backup-url", + "id": "609-zero-cache-config#litestream-backup-url", "title": "zero-cache Config", "searchTitle": "Litestream Backup URL", "sectionTitle": "Litestream Backup URL", @@ -8518,7 +8448,7 @@ "kind": "section" }, { - "id": "615-zero-cache-config#litestream-endpoint", + "id": "610-zero-cache-config#litestream-endpoint", "title": "zero-cache Config", "searchTitle": "Litestream Endpoint", "sectionTitle": "Litestream Endpoint", @@ -8528,7 +8458,7 @@ "kind": "section" }, { - "id": "616-zero-cache-config#litestream-checkpoint-threshold-mb", + "id": "611-zero-cache-config#litestream-checkpoint-threshold-mb", "title": "zero-cache Config", "searchTitle": "Litestream Checkpoint Threshold MB", "sectionTitle": "Litestream Checkpoint Threshold MB", @@ -8538,7 +8468,7 @@ "kind": "section" }, { - "id": "617-zero-cache-config#litestream-config-path", + "id": "612-zero-cache-config#litestream-config-path", "title": "zero-cache Config", "searchTitle": "Litestream Config Path", "sectionTitle": "Litestream Config Path", @@ -8548,7 +8478,7 @@ "kind": "section" }, { - "id": "618-zero-cache-config#litestream-executable", + "id": "613-zero-cache-config#litestream-executable", "title": "zero-cache Config", "searchTitle": "Litestream Executable", "sectionTitle": "Litestream Executable", @@ -8558,7 +8488,7 @@ "kind": "section" }, { - "id": "619-zero-cache-config#litestream-v5-executable", + "id": "614-zero-cache-config#litestream-v5-executable", "title": "zero-cache Config", "searchTitle": "Litestream V5 Executable", "sectionTitle": "Litestream V5 Executable", @@ -8568,7 +8498,7 @@ "kind": "section" }, { - "id": "620-zero-cache-config#litestream-restore-using-v5", + "id": "615-zero-cache-config#litestream-restore-using-v5", "title": "zero-cache Config", "searchTitle": "Litestream Restore Using V5", "sectionTitle": "Litestream Restore Using V5", @@ -8578,17 +8508,17 @@ "kind": "section" }, { - "id": "621-zero-cache-config#litestream-backup-using-v5", + "id": "616-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 requires v5 restore and identical ZERO_LITESTREAM_EXECUTABLE and ZERO_LITESTREAM_EXECUTABLE_V5 paths. Older images cannot restore an LTX-only backup. flag: --litestream-backup-using-v5 env: ZERO_LITESTREAM_BACKUP_USING_V5 default: false", + "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 makes rollback difficult because older versions cannot restore an LTX-only backup. flag: --litestream-backup-using-v5 env: ZERO_LITESTREAM_BACKUP_USING_V5 default: false", "kind": "section" }, { - "id": "622-zero-cache-config#litestream-incremental-backup-interval-minutes", + "id": "617-zero-cache-config#litestream-incremental-backup-interval-minutes", "title": "zero-cache Config", "searchTitle": "Litestream Incremental Backup Interval Minutes", "sectionTitle": "Litestream Incremental Backup Interval Minutes", @@ -8598,7 +8528,7 @@ "kind": "section" }, { - "id": "623-zero-cache-config#litestream-maximum-checkpoint-page-count", + "id": "618-zero-cache-config#litestream-maximum-checkpoint-page-count", "title": "zero-cache Config", "searchTitle": "Litestream Maximum Checkpoint Page Count", "sectionTitle": "Litestream Maximum Checkpoint Page Count", @@ -8608,7 +8538,7 @@ "kind": "section" }, { - "id": "624-zero-cache-config#litestream-minimum-checkpoint-page-count", + "id": "619-zero-cache-config#litestream-minimum-checkpoint-page-count", "title": "zero-cache Config", "searchTitle": "Litestream Minimum Checkpoint Page Count", "sectionTitle": "Litestream Minimum Checkpoint Page Count", @@ -8618,7 +8548,7 @@ "kind": "section" }, { - "id": "625-zero-cache-config#litestream-multipart-concurrency", + "id": "620-zero-cache-config#litestream-multipart-concurrency", "title": "zero-cache Config", "searchTitle": "Litestream Multipart Concurrency", "sectionTitle": "Litestream Multipart Concurrency", @@ -8628,7 +8558,7 @@ "kind": "section" }, { - "id": "626-zero-cache-config#litestream-multipart-size", + "id": "621-zero-cache-config#litestream-multipart-size", "title": "zero-cache Config", "searchTitle": "Litestream Multipart Size", "sectionTitle": "Litestream Multipart Size", @@ -8638,7 +8568,7 @@ "kind": "section" }, { - "id": "627-zero-cache-config#litestream-log-level", + "id": "622-zero-cache-config#litestream-log-level", "title": "zero-cache Config", "searchTitle": "Litestream Log Level", "sectionTitle": "Litestream Log Level", @@ -8648,7 +8578,7 @@ "kind": "section" }, { - "id": "628-zero-cache-config#litestream-port", + "id": "623-zero-cache-config#litestream-port", "title": "zero-cache Config", "searchTitle": "Litestream Port", "sectionTitle": "Litestream Port", @@ -8658,7 +8588,7 @@ "kind": "section" }, { - "id": "629-zero-cache-config#litestream-region", + "id": "624-zero-cache-config#litestream-region", "title": "zero-cache Config", "searchTitle": "Litestream Region", "sectionTitle": "Litestream Region", @@ -8668,7 +8598,7 @@ "kind": "section" }, { - "id": "630-zero-cache-config#litestream-restore-parallelism", + "id": "625-zero-cache-config#litestream-restore-parallelism", "title": "zero-cache Config", "searchTitle": "Litestream Restore Parallelism", "sectionTitle": "Litestream Restore Parallelism", @@ -8678,7 +8608,7 @@ "kind": "section" }, { - "id": "631-zero-cache-config#litestream-snapshot-backup-interval-hours", + "id": "626-zero-cache-config#litestream-snapshot-backup-interval-hours", "title": "zero-cache Config", "searchTitle": "Litestream Snapshot Backup Interval Hours", "sectionTitle": "Litestream Snapshot Backup Interval Hours", @@ -8688,7 +8618,7 @@ "kind": "section" }, { - "id": "632-zero-cache-config#log-format", + "id": "627-zero-cache-config#log-format", "title": "zero-cache Config", "searchTitle": "Log Format", "sectionTitle": "Log Format", @@ -8698,7 +8628,7 @@ "kind": "section" }, { - "id": "633-zero-cache-config#log-ivm-sampling", + "id": "628-zero-cache-config#log-ivm-sampling", "title": "zero-cache Config", "searchTitle": "Log IVM Sampling", "sectionTitle": "Log IVM Sampling", @@ -8708,7 +8638,7 @@ "kind": "section" }, { - "id": "634-zero-cache-config#log-level", + "id": "629-zero-cache-config#log-level", "title": "zero-cache Config", "searchTitle": "Log Level", "sectionTitle": "Log Level", @@ -8718,7 +8648,7 @@ "kind": "section" }, { - "id": "635-zero-cache-config#log-slow-hydrate-threshold", + "id": "630-zero-cache-config#log-slow-hydrate-threshold", "title": "zero-cache Config", "searchTitle": "Log Slow Hydrate Threshold", "sectionTitle": "Log Slow Hydrate Threshold", @@ -8728,7 +8658,7 @@ "kind": "section" }, { - "id": "636-zero-cache-config#log-slow-row-threshold", + "id": "631-zero-cache-config#log-slow-row-threshold", "title": "zero-cache Config", "searchTitle": "Log Slow Row Threshold", "sectionTitle": "Log Slow Row Threshold", @@ -8738,7 +8668,7 @@ "kind": "section" }, { - "id": "637-zero-cache-config#mutate-api-key", + "id": "632-zero-cache-config#mutate-api-key", "title": "zero-cache Config", "searchTitle": "Mutate API Key", "sectionTitle": "Mutate API Key", @@ -8748,7 +8678,7 @@ "kind": "section" }, { - "id": "638-zero-cache-config#mutate-allowed-client-headers", + "id": "633-zero-cache-config#mutate-allowed-client-headers", "title": "zero-cache Config", "searchTitle": "Mutate Allowed Client Headers", "sectionTitle": "Mutate Allowed Client Headers", @@ -8758,7 +8688,7 @@ "kind": "section" }, { - "id": "639-zero-cache-config#mutate-allowed-request-headers", + "id": "634-zero-cache-config#mutate-allowed-request-headers", "title": "zero-cache Config", "searchTitle": "Mutate Allowed Request Headers", "sectionTitle": "Mutate Allowed Request Headers", @@ -8768,7 +8698,7 @@ "kind": "section" }, { - "id": "640-zero-cache-config#mutate-forward-cookies", + "id": "635-zero-cache-config#mutate-forward-cookies", "title": "zero-cache Config", "searchTitle": "Mutate Forward Cookies", "sectionTitle": "Mutate Forward Cookies", @@ -8778,7 +8708,7 @@ "kind": "section" }, { - "id": "641-zero-cache-config#mutate-url", + "id": "636-zero-cache-config#mutate-url", "title": "zero-cache Config", "searchTitle": "Mutate URL", "sectionTitle": "Mutate URL", @@ -8788,7 +8718,7 @@ "kind": "section" }, { - "id": "642-zero-cache-config#number-of-sync-workers", + "id": "637-zero-cache-config#number-of-sync-workers", "title": "zero-cache Config", "searchTitle": "Number of Sync Workers", "sectionTitle": "Number of Sync Workers", @@ -8798,7 +8728,7 @@ "kind": "section" }, { - "id": "643-zero-cache-config#per-user-mutation-limit-max", + "id": "638-zero-cache-config#per-user-mutation-limit-max", "title": "zero-cache Config", "searchTitle": "Per User Mutation Limit Max", "sectionTitle": "Per User Mutation Limit Max", @@ -8808,7 +8738,7 @@ "kind": "section" }, { - "id": "644-zero-cache-config#per-user-mutation-limit-window-ms", + "id": "639-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)", @@ -8818,7 +8748,7 @@ "kind": "section" }, { - "id": "645-zero-cache-config#pg-replication-slot-failover", + "id": "640-zero-cache-config#pg-replication-slot-failover", "title": "zero-cache Config", "searchTitle": "PG Replication Slot Failover", "sectionTitle": "PG Replication Slot Failover", @@ -8828,7 +8758,7 @@ "kind": "section" }, { - "id": "646-zero-cache-config#port", + "id": "641-zero-cache-config#port", "title": "zero-cache Config", "searchTitle": "Port", "sectionTitle": "Port", @@ -8838,7 +8768,7 @@ "kind": "section" }, { - "id": "647-zero-cache-config#query-api-key", + "id": "642-zero-cache-config#query-api-key", "title": "zero-cache Config", "searchTitle": "Query API Key", "sectionTitle": "Query API Key", @@ -8848,7 +8778,7 @@ "kind": "section" }, { - "id": "648-zero-cache-config#query-allowed-client-headers", + "id": "643-zero-cache-config#query-allowed-client-headers", "title": "zero-cache Config", "searchTitle": "Query Allowed Client Headers", "sectionTitle": "Query Allowed Client Headers", @@ -8858,7 +8788,7 @@ "kind": "section" }, { - "id": "649-zero-cache-config#query-allowed-request-headers", + "id": "644-zero-cache-config#query-allowed-request-headers", "title": "zero-cache Config", "searchTitle": "Query Allowed Request Headers", "sectionTitle": "Query Allowed Request Headers", @@ -8868,7 +8798,7 @@ "kind": "section" }, { - "id": "650-zero-cache-config#query-forward-cookies", + "id": "645-zero-cache-config#query-forward-cookies", "title": "zero-cache Config", "searchTitle": "Query Forward Cookies", "sectionTitle": "Query Forward Cookies", @@ -8878,7 +8808,7 @@ "kind": "section" }, { - "id": "651-zero-cache-config#query-hydration-stats", + "id": "646-zero-cache-config#query-hydration-stats", "title": "zero-cache Config", "searchTitle": "Query Hydration Stats", "sectionTitle": "Query Hydration Stats", @@ -8888,7 +8818,7 @@ "kind": "section" }, { - "id": "652-zero-cache-config#query-url", + "id": "647-zero-cache-config#query-url", "title": "zero-cache Config", "searchTitle": "Query URL", "sectionTitle": "Query URL", @@ -8898,7 +8828,7 @@ "kind": "section" }, { - "id": "653-zero-cache-config#replica-file", + "id": "648-zero-cache-config#replica-file", "title": "zero-cache Config", "searchTitle": "Replica File", "sectionTitle": "Replica File", @@ -8908,7 +8838,7 @@ "kind": "section" }, { - "id": "654-zero-cache-config#replica-vacuum-interval-hours", + "id": "649-zero-cache-config#replica-vacuum-interval-hours", "title": "zero-cache Config", "searchTitle": "Replica Vacuum Interval Hours", "sectionTitle": "Replica Vacuum Interval Hours", @@ -8918,7 +8848,7 @@ "kind": "section" }, { - "id": "655-zero-cache-config#replication-lag-report-interval-ms", + "id": "650-zero-cache-config#replication-lag-report-interval-ms", "title": "zero-cache Config", "searchTitle": "Replication Lag Report Interval (ms)", "sectionTitle": "Replication Lag Report Interval (ms)", @@ -8928,7 +8858,7 @@ "kind": "section" }, { - "id": "656-zero-cache-config#server-version", + "id": "651-zero-cache-config#server-version", "title": "zero-cache Config", "searchTitle": "Server Version", "sectionTitle": "Server Version", @@ -8938,7 +8868,7 @@ "kind": "section" }, { - "id": "657-zero-cache-config#shadow-sync-enabled", + "id": "652-zero-cache-config#shadow-sync-enabled", "title": "zero-cache Config", "searchTitle": "Shadow Sync Enabled", "sectionTitle": "Shadow Sync Enabled", @@ -8948,7 +8878,7 @@ "kind": "section" }, { - "id": "658-zero-cache-config#shadow-sync-interval-hours", + "id": "653-zero-cache-config#shadow-sync-interval-hours", "title": "zero-cache Config", "searchTitle": "Shadow Sync Interval Hours", "sectionTitle": "Shadow Sync Interval Hours", @@ -8958,7 +8888,7 @@ "kind": "section" }, { - "id": "659-zero-cache-config#shadow-sync-sample-rate", + "id": "654-zero-cache-config#shadow-sync-sample-rate", "title": "zero-cache Config", "searchTitle": "Shadow Sync Sample Rate", "sectionTitle": "Shadow Sync Sample Rate", @@ -8968,7 +8898,7 @@ "kind": "section" }, { - "id": "660-zero-cache-config#shadow-sync-max-rows-per-table", + "id": "655-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", @@ -8978,7 +8908,7 @@ "kind": "section" }, { - "id": "661-zero-cache-config#storage-db-temp-dir", + "id": "656-zero-cache-config#storage-db-temp-dir", "title": "zero-cache Config", "searchTitle": "Storage DB Temp Dir", "sectionTitle": "Storage DB Temp Dir", @@ -8988,7 +8918,7 @@ "kind": "section" }, { - "id": "662-zero-cache-config#task-id", + "id": "657-zero-cache-config#task-id", "title": "zero-cache Config", "searchTitle": "Task ID", "sectionTitle": "Task ID", @@ -8998,7 +8928,7 @@ "kind": "section" }, { - "id": "663-zero-cache-config#upstream-max-connections", + "id": "658-zero-cache-config#upstream-max-connections", "title": "zero-cache Config", "searchTitle": "Upstream Max Connections", "sectionTitle": "Upstream Max Connections", @@ -9008,7 +8938,7 @@ "kind": "section" }, { - "id": "664-zero-cache-config#upstream-pg-replication-slot-failover", + "id": "659-zero-cache-config#upstream-pg-replication-slot-failover", "title": "zero-cache Config", "searchTitle": "Upstream PG Replication Slot Failover", "sectionTitle": "Upstream PG Replication Slot Failover", @@ -9018,7 +8948,7 @@ "kind": "section" }, { - "id": "665-zero-cache-config#websocket-compression", + "id": "660-zero-cache-config#websocket-compression", "title": "zero-cache Config", "searchTitle": "Websocket Compression", "sectionTitle": "Websocket Compression", @@ -9028,7 +8958,7 @@ "kind": "section" }, { - "id": "666-zero-cache-config#websocket-compression-options", + "id": "661-zero-cache-config#websocket-compression-options", "title": "zero-cache Config", "searchTitle": "Websocket Compression Options", "sectionTitle": "Websocket Compression Options", @@ -9038,7 +8968,7 @@ "kind": "section" }, { - "id": "667-zero-cache-config#websocket-max-payload-bytes", + "id": "662-zero-cache-config#websocket-max-payload-bytes", "title": "zero-cache Config", "searchTitle": "Websocket Max Payload Bytes", "sectionTitle": "Websocket Max Payload Bytes", @@ -9048,7 +8978,7 @@ "kind": "section" }, { - "id": "668-zero-cache-config#yield-threshold-ms", + "id": "663-zero-cache-config#yield-threshold-ms", "title": "zero-cache Config", "searchTitle": "Yield Threshold (ms)", "sectionTitle": "Yield Threshold (ms)", @@ -9058,7 +8988,7 @@ "kind": "section" }, { - "id": "669-zero-cache-config#deprecated-flags", + "id": "664-zero-cache-config#deprecated-flags", "title": "zero-cache Config", "searchTitle": "Deprecated Flags", "sectionTitle": "Deprecated Flags", @@ -9068,7 +8998,7 @@ "kind": "section" }, { - "id": "670-zero-cache-config#auth-jwk", + "id": "665-zero-cache-config#auth-jwk", "title": "zero-cache Config", "searchTitle": "Auth JWK", "sectionTitle": "Auth JWK", @@ -9078,7 +9008,7 @@ "kind": "section" }, { - "id": "671-zero-cache-config#auth-jwks-url", + "id": "666-zero-cache-config#auth-jwks-url", "title": "zero-cache Config", "searchTitle": "Auth JWKS URL", "sectionTitle": "Auth JWKS URL", @@ -9088,7 +9018,7 @@ "kind": "section" }, { - "id": "672-zero-cache-config#auth-secret", + "id": "667-zero-cache-config#auth-secret", "title": "zero-cache Config", "searchTitle": "Auth Secret", "sectionTitle": "Auth Secret", @@ -9208,7 +9138,7 @@ "kind": "page" }, { - "id": "673-zql#create-a-builder", + "id": "668-zql#create-a-builder", "title": "ZQL", "searchTitle": "Create a Builder", "sectionTitle": "Create a Builder", @@ -9218,7 +9148,7 @@ "kind": "section" }, { - "id": "674-zql#select", + "id": "669-zql#select", "title": "ZQL", "searchTitle": "Select", "sectionTitle": "Select", @@ -9228,7 +9158,7 @@ "kind": "section" }, { - "id": "675-zql#ordering", + "id": "670-zql#ordering", "title": "ZQL", "searchTitle": "Ordering", "sectionTitle": "Ordering", @@ -9238,7 +9168,7 @@ "kind": "section" }, { - "id": "676-zql#limit", + "id": "671-zql#limit", "title": "ZQL", "searchTitle": "Limit", "sectionTitle": "Limit", @@ -9248,7 +9178,7 @@ "kind": "section" }, { - "id": "677-zql#paging", + "id": "672-zql#paging", "title": "ZQL", "searchTitle": "Paging", "sectionTitle": "Paging", @@ -9258,7 +9188,7 @@ "kind": "section" }, { - "id": "678-zql#getting-a-single-result", + "id": "673-zql#getting-a-single-result", "title": "ZQL", "searchTitle": "Getting a Single Result", "sectionTitle": "Getting a Single Result", @@ -9268,7 +9198,7 @@ "kind": "section" }, { - "id": "679-zql#relationships", + "id": "674-zql#relationships", "title": "ZQL", "searchTitle": "Relationships", "sectionTitle": "Relationships", @@ -9278,7 +9208,7 @@ "kind": "section" }, { - "id": "680-zql#refining-relationships", + "id": "675-zql#refining-relationships", "title": "ZQL", "searchTitle": "Refining Relationships", "sectionTitle": "Refining Relationships", @@ -9288,7 +9218,7 @@ "kind": "section" }, { - "id": "681-zql#nested-relationships", + "id": "676-zql#nested-relationships", "title": "ZQL", "searchTitle": "Nested Relationships", "sectionTitle": "Nested Relationships", @@ -9298,7 +9228,7 @@ "kind": "section" }, { - "id": "682-zql#where", + "id": "677-zql#where", "title": "ZQL", "searchTitle": "Where", "sectionTitle": "Where", @@ -9308,7 +9238,7 @@ "kind": "section" }, { - "id": "683-zql#comparison-operators", + "id": "678-zql#comparison-operators", "title": "ZQL", "searchTitle": "Comparison Operators", "sectionTitle": "Comparison Operators", @@ -9318,7 +9248,7 @@ "kind": "section" }, { - "id": "684-zql#equals-is-the-default-comparison-operator", + "id": "679-zql#equals-is-the-default-comparison-operator", "title": "ZQL", "searchTitle": "Equals is the Default Comparison Operator", "sectionTitle": "Equals is the Default Comparison Operator", @@ -9328,7 +9258,7 @@ "kind": "section" }, { - "id": "685-zql#comparing-to-null", + "id": "680-zql#comparing-to-null", "title": "ZQL", "searchTitle": "Comparing to null", "sectionTitle": "Comparing to null", @@ -9338,7 +9268,7 @@ "kind": "section" }, { - "id": "686-zql#comparing-to-undefined", + "id": "681-zql#comparing-to-undefined", "title": "ZQL", "searchTitle": "Comparing to undefined", "sectionTitle": "Comparing to undefined", @@ -9348,7 +9278,7 @@ "kind": "section" }, { - "id": "687-zql#compound-filters", + "id": "682-zql#compound-filters", "title": "ZQL", "searchTitle": "Compound Filters", "sectionTitle": "Compound Filters", @@ -9358,7 +9288,7 @@ "kind": "section" }, { - "id": "688-zql#comparing-literal-values", + "id": "683-zql#comparing-literal-values", "title": "ZQL", "searchTitle": "Comparing Literal Values", "sectionTitle": "Comparing Literal Values", @@ -9368,7 +9298,7 @@ "kind": "section" }, { - "id": "689-zql#relationship-filters", + "id": "684-zql#relationship-filters", "title": "ZQL", "searchTitle": "Relationship Filters", "sectionTitle": "Relationship Filters", @@ -9378,7 +9308,7 @@ "kind": "section" }, { - "id": "690-zql#type-helpers", + "id": "685-zql#type-helpers", "title": "ZQL", "searchTitle": "Type Helpers", "sectionTitle": "Type Helpers", @@ -9388,7 +9318,7 @@ "kind": "section" }, { - "id": "691-zql#planning", + "id": "686-zql#planning", "title": "ZQL", "searchTitle": "Planning", "sectionTitle": "Planning", @@ -9398,7 +9328,7 @@ "kind": "section" }, { - "id": "692-zql#inspecting-query-plans", + "id": "687-zql#inspecting-query-plans", "title": "ZQL", "searchTitle": "Inspecting Query Plans", "sectionTitle": "Inspecting Query Plans", @@ -9408,7 +9338,7 @@ "kind": "section" }, { - "id": "693-zql#manually-flipping-joins", + "id": "688-zql#manually-flipping-joins", "title": "ZQL", "searchTitle": "Manually Flipping Joins", "sectionTitle": "Manually Flipping Joins", @@ -9418,7 +9348,7 @@ "kind": "section" }, { - "id": "694-zql#scalar-subqueries", + "id": "689-zql#scalar-subqueries", "title": "ZQL", "searchTitle": "Scalar Subqueries", "sectionTitle": "Scalar Subqueries", @@ -9428,7 +9358,7 @@ "kind": "section" }, { - "id": "695-zql#why-it-matters", + "id": "690-zql#why-it-matters", "title": "ZQL", "searchTitle": "Why It Matters", "sectionTitle": "Why It Matters", @@ -9438,7 +9368,7 @@ "kind": "section" }, { - "id": "696-zql#trade-offs", + "id": "691-zql#trade-offs", "title": "ZQL", "searchTitle": "Trade-offs", "sectionTitle": "Trade-offs", @@ -9448,7 +9378,7 @@ "kind": "section" }, { - "id": "697-zql#future-work", + "id": "692-zql#future-work", "title": "ZQL", "searchTitle": "Future Work", "sectionTitle": "Future Work", diff --git a/contents/docs/release-notes/1.9.mdx b/contents/docs/release-notes/1.9.mdx index b4e1001e..38218e0f 100644 --- a/contents/docs/release-notes/1.9.mdx +++ b/contents/docs/release-notes/1.9.mdx @@ -47,7 +47,7 @@ Before running mutations, Zero Server fetches and caches PostgreSQL schema metad ## Fixes - [**Litestream restores:**](/docs/zero-cache-config#litestream-restore-using-v5) Restores now use Litestream 0.5.15 for legacy-format compatibility, and [legacy snapshots retain the previous generation during active restores](/docs/zero-cache-config#litestream-snapshot-backup-interval-hours). ([#6260](https://github.com/rocicorp/mono/pull/6260), [#6267](https://github.com/rocicorp/mono/pull/6267)) -- **Existing primary-key inserts:** `insert` now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. +- [`insert` now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error.](https://github.com/rocicorp/mono/pull/6251) - [Ordered queries now return correct results when cursor fields contain `NULL`.](https://github.com/rocicorp/mono/pull/6121) (thanks [@YevheniiKotyrlo](https://github.com/YevheniiKotyrlo)!) - [Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results.](https://github.com/rocicorp/mono/pull/6196) - [Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar `NOT EXISTS` now handles empty or `NULL` results.](https://github.com/rocicorp/mono/pull/6306) @@ -65,4 +65,6 @@ Before running mutations, Zero Server fetches and caches PostgreSQL schema metad - [Expected schema and replica resets now log warnings instead of errors](https://github.com/rocicorp/mono/pull/6248), and [`zero-cache` skips Litestream restore when backups are not configured](https://github.com/rocicorp/mono/pull/6259). (thanks [@asterikx](https://github.com/asterikx)!) - [Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts.](https://github.com/rocicorp/mono/pull/6280) (thanks [@shayonj](https://github.com/shayonj)!) - [Mutation and query API calls now retry all `5xx` responses using the existing four-attempt limit and backoff; `4xx` responses still fail without retry.](https://github.com/rocicorp/mono/pull/6315) (thanks [@shayonj](https://github.com/shayonj)!) -- [SQLite corruption failures now log bounded replica and integrity diagnostics and flush logs before exit.](https://github.com/rocicorp/mono/pull/6215) [Oversized replication updates now identify the transaction, affected column, and value type without logging the value.](https://github.com/rocicorp/mono/pull/6318) +- [SQLite corruption failures now log diagnostics and flush logs before exit](https://github.com/rocicorp/mono/pull/6215), with [deeper checks available opt-in](https://github.com/rocicorp/mono/pull/6341). +- [Oversized replication updates now identify the transaction, affected column, and value type without logging the value.](https://github.com/rocicorp/mono/pull/6318) +- [Fatal replica-writer failures now surface as replication errors and cause `zero-cache` to exit with a failure instead of silently stopping replication.](https://github.com/rocicorp/mono/pull/6326) From dad9f60b78cec326cc1a520bd64ee3a75af67626 Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Wed, 12 Aug 2026 11:40:34 -0700 Subject: [PATCH 09/17] docs: simplify Litestream release note --- assets/search-index.json | 4 ++-- contents/docs/release-notes/1.9.mdx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/search-index.json b/assets/search-index.json index 0fb4047b..58d066ad 100644 --- a/assets/search-index.json +++ b/assets/search-index.json @@ -6223,7 +6223,7 @@ "title": "Zero 1.9", "searchTitle": "Zero 1.9", "url": "/docs/release-notes/1.9", - "content": "Installation npm install @rocicorp/zero@1.9 Overview Zero 1.9 improves query/mutation correctness and reliability. Features End-to-end serving lag: e2e_serving_lag measures completed replicated work from the upstream transaction commit through view-syncer poke. Upstream clock-skew estimate tries to identify measurements biased by clock differences. (#6312) Performance Cold Mutation Latency Before running mutations, Zero Server fetches and caches PostgreSQL schema metadata. This is now 2.7x faster in Zero 1.9 (done in #6292, thanks @diegopereira99!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. Fixes Litestream restores: Restores now use Litestream 0.5.15 for legacy-format compatibility, and legacy snapshots retain the previous generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication.", + "content": "Installation npm install @rocicorp/zero@1.9 Overview Zero 1.9 improves query/mutation correctness and reliability. Features End-to-end serving lag: e2e_serving_lag measures completed replicated work from the upstream transaction commit through view-syncer poke. Upstream clock-skew estimate tries to identify measurements biased by clock differences. (#6312) Performance Cold Mutation Latency Before running mutations, Zero Server fetches and caches PostgreSQL schema metadata. This is now 2.7x faster in Zero 1.9 (done in #6292, thanks @diegopereira99!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. Fixes Restores now use Litestream 0.5.15 for legacy-format compatibility, and legacy snapshots retain the previous generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, with deeper checks available opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication.", "headings": [ { "text": "Installation", @@ -6309,7 +6309,7 @@ "sectionTitle": "Fixes", "sectionId": "fixes", "url": "/docs/release-notes/1.9", - "content": "Litestream restores: Restores now use Litestream 0.5.15 for legacy-format compatibility, and legacy snapshots retain the previous generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication.", + "content": "Restores now use Litestream 0.5.15 for legacy-format compatibility, and legacy snapshots retain the previous generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, with deeper checks available opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication.", "kind": "section" }, { diff --git a/contents/docs/release-notes/1.9.mdx b/contents/docs/release-notes/1.9.mdx index 38218e0f..921ccaac 100644 --- a/contents/docs/release-notes/1.9.mdx +++ b/contents/docs/release-notes/1.9.mdx @@ -46,7 +46,7 @@ Before running mutations, Zero Server fetches and caches PostgreSQL schema metad ## Fixes -- [**Litestream restores:**](/docs/zero-cache-config#litestream-restore-using-v5) Restores now use Litestream 0.5.15 for legacy-format compatibility, and [legacy snapshots retain the previous generation during active restores](/docs/zero-cache-config#litestream-snapshot-backup-interval-hours). ([#6260](https://github.com/rocicorp/mono/pull/6260), [#6267](https://github.com/rocicorp/mono/pull/6267)) +- [Restores now use Litestream 0.5.15 for legacy-format compatibility](/docs/zero-cache-config#litestream-restore-using-v5), and [legacy snapshots retain the previous generation during active restores](/docs/zero-cache-config#litestream-snapshot-backup-interval-hours). ([#6260](https://github.com/rocicorp/mono/pull/6260), [#6267](https://github.com/rocicorp/mono/pull/6267)) - [`insert` now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error.](https://github.com/rocicorp/mono/pull/6251) - [Ordered queries now return correct results when cursor fields contain `NULL`.](https://github.com/rocicorp/mono/pull/6121) (thanks [@YevheniiKotyrlo](https://github.com/YevheniiKotyrlo)!) - [Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results.](https://github.com/rocicorp/mono/pull/6196) From 907fb7b4f5e72df1202a2033c2e45ff1773eaf6b Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Wed, 12 Aug 2026 13:18:01 -0700 Subject: [PATCH 10/17] docs: include final replication fixes --- .releases/1.9/benchmarks/6292.md | 2 +- .releases/1.9/commits.md | 26 +++++++++++++++++--------- assets/search-index.json | 4 ++-- contents/docs/release-notes/1.9.mdx | 3 ++- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/.releases/1.9/benchmarks/6292.md b/.releases/1.9/benchmarks/6292.md index 97f1999e..c40ffd32 100644 --- a/.releases/1.9/benchmarks/6292.md +++ b/.releases/1.9/benchmarks/6292.md @@ -10,7 +10,7 @@ This is not a general process-startup claim. The measurement excludes process la - Zero 1.8: `zero/v1.8.0` at `cdc02598f137ab4e071878f5674fdc716dbbc69d` - Zero 1.9 change: `67c8fe4c9d9a5673357bb80116c50d968e54c2e2`, the #6292 commit -- Zero 1.9 release target: `8223561de0895036bacdce5d2ea599c1a3e1e62d` +- Zero 1.9 release target: `342fc33d519fb5ca3ddab49eaf862b9a3a290cd0` - No `packages/zero-server` production file changed between the #6292 commit and the release target. - Latest benchmark harness: `45706aad581b283037ce0b25130de5c1f27ac086` - Benchmark source SHA-1: `872b0cbe5f61862e53cb8451940ff4664a39b78e` diff --git a/.releases/1.9/commits.md b/.releases/1.9/commits.md index 208426bc..95931137 100644 --- a/.releases/1.9/commits.md +++ b/.releases/1.9/commits.md @@ -11,12 +11,12 @@ Status: audit and public draft updated through the reconstructed maintenance tar - Previous ref: `zero/v1.8.0` - Previous SHA: `cdc02598f137ab4e071878f5674fdc716dbbc69d` - Target ref: `origin/maint/zero/v1.9`, reconstructed and published maintenance target -- Target SHA: `8223561de0895036bacdce5d2ea599c1a3e1e62d` +- Target SHA: `342fc33d519fb5ca3ddab49eaf862b9a3a290cd0` - Merge base: `2279e783edd94aaa20fdcc8e067860ad0c21d95b` - Reconstruction base: `ef892a123a11461e74a59a4b59ad310ba23180b3` -- Raw non-merge range: 65 commits +- Raw non-merge range: 68 commits - Patch-equivalent commits already shipped in 1.8: 15 -- Unique 1.9 commits: 50 +- Unique 1.9 commits: 53 Commands used: @@ -30,7 +30,7 @@ git log --right-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1 git log --left-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1.8.0...origin/maint/zero/v1.9 git log --format='%H%x09%s%n%b' 2279e783edd94aaa20fdcc8e067860ad0c21d95b..zero/v1.8.0 git show zero/v1.8.0:packages/zero-protocol/src/protocol-version.ts -git show 8223561de0895036bacdce5d2ea599c1a3e1e62d:packages/zero-protocol/src/protocol-version.ts +git show 342fc33d519fb5ca3ddab49eaf862b9a3a290cd0:packages/zero-protocol/src/protocol-version.ts ``` ## Protocol Compatibility @@ -72,9 +72,9 @@ The previous-release side contains no additional `cherry-pick -x` trailers namin ## Maintenance Reconstruction -The target was rebuilt from shared mainline commit `ef892a123` by applying 22 selected, signed mainline commits in topological order with provenance trailers, then fast-forwarded with signed #6318 PR-head and #6312 canonical-main backports, a signed #6326 canonical-main backport, and the signed #6341 PR head. This removes the bulk-cherry-pick timestamps from the old maintenance line and makes every selected change independently traceable to its source. +The target was rebuilt from shared mainline commit `ef892a123` by applying 22 selected, signed mainline commits in topological order with provenance trailers, then fast-forwarded with signed #6318 PR-head and #6312 canonical-main backports, a signed #6326 canonical-main backport, the signed #6341 PR head, canonical-main backports for #6339 and #6338, and a signed maintenance-only test adaptation for #6338. This removes the bulk-cherry-pick timestamps from the old maintenance line and makes every selected change independently traceable to its source. -The old maintenance-only commits `e91f964a7` and `c073aa39e` are represented by reconstructed commit `49b13e3e5`, the canonical #6280 patch. The resulting production code and tests are byte-identical to main's #6280 tree. Reconstructed #6311 differs in patch ID only because the 1.9 parent infers an unchanged test callback parameter type where main's rmv2-era parent spells out `unknown`; the production change, new transaction tests, and resulting behavior are identical. #6318 similarly omits only an unrelated mainline rollback helper that is absent from 1.9; its oversized-binding diagnostics and regression test match the PR. #6312's entire `packages/zero-cache` patch is patch-equivalent to main; the backport omits only four generated API snapshots because 1.9 predates the #6239 snapshot infrastructure. #6326 applies cleanly; its patch ID differs because its change-processor cleanup overlaps the tailored #6318 backport already on the maintenance branch. #6341 omits only RMv2 change-log diagnostic registration absent from 1.9 and preserves the PR's behavior for every SQLite database used by this release. +The old maintenance-only commits `e91f964a7` and `c073aa39e` are represented by reconstructed commit `49b13e3e5`, the canonical #6280 patch. The resulting production code and tests are byte-identical to main's #6280 tree. Reconstructed #6311 differs in patch ID only because the 1.9 parent infers an unchanged test callback parameter type where main's rmv2-era parent spells out `unknown`; the production change, new transaction tests, and resulting behavior are identical. #6318 similarly omits only an unrelated mainline rollback helper that is absent from 1.9; its oversized-binding diagnostics and regression test match the PR. #6312's entire `packages/zero-cache` patch is patch-equivalent to main; the backport omits only four generated API snapshots because 1.9 predates the #6239 snapshot infrastructure. #6326 applies cleanly; its patch ID differs because its change-processor cleanup overlaps the tailored #6318 backport already on the maintenance branch. #6341 omits only RMv2 change-log diagnostic registration absent from 1.9 and preserves the PR's behavior for every SQLite database used by this release. #6339 applies cleanly. #6338 omits RMv2-only purge and transaction-boundary bookkeeping while preserving source-termination handling for 1.9's Storer and subscriber flow-control waits. All other reconstructed commits are patch-equivalent to their named mainline source. Main-only rmv2 work, #6307's breaking scalar type enforcement, #6309's nullability-specific optimization, #6314's experimental Litestream update, and #6317's rmv2 test timeout remain excluded. @@ -147,6 +147,9 @@ All other reconstructed commits are patch-equivalent to their named mainline sou | [`b90e79d8f`](https://github.com/rocicorp/mono/pull/6312) | feature | - | Measures end-to-end serving lag from an upstream commit through ViewSyncer output, reports upstream clock-skew estimates, and counts negative lag observations that were clamped to zero. | Include publicly as operator observability. The optional `commitTimeMs` field is additive and the change stream parses in passthrough mode, so old peers ignore it and new peers accept its absence. Tests cover protocol compatibility, notification coalescing, clock-skew estimation, lag completion, and clamp reporting. | | [`daa5c9a32`](https://github.com/rocicorp/mono/pull/6326) | fix | - | Propagates fatal replica-writer failures through replication status and exits `zero-cache` with a failure code instead of allowing incremental replication to stop silently. | Include publicly as an operator reliability fix. Tests cover error publication, rejection from incremental replication, and nonzero parent-process exit when workers terminate outside graceful drain. The published replication error remains generic and safe while detailed diagnostics stay in logs. | | [`8223561de`](https://github.com/rocicorp/mono/pull/6341) | fix | - | Keeps lightweight SQLite corruption diagnostics enabled but makes synchronous full-database `quick_check` and `integrity_check` scans opt-in, avoiding long delays before fatal failures propagate. | Include publicly with #6215. The hidden `ZERO_SQLITE_CORRUPTION_CHECKS` option defaults to false and is intentionally not promoted as supported configuration. Tests cover default-skipped and explicitly enabled checks, and the option is propagated to all 1.9 fatal diagnostic targets. | +| [`2a2972c37`](https://github.com/rocicorp/mono/pull/6339) | fix | - | Recognizes SQLite extended corruption result codes such as `SQLITE_CORRUPT_INDEX`, `SQLITE_CORRUPT_VTAB`, and `SQLITE_CORRUPT_SEQUENCE` as corruption regardless of message text. | Include publicly with #6215 and #6341. Tests cover supported extended codes and reject unrelated prefixes. This broadens when existing corruption diagnostics and recovery handling activate; it does not change database contents or configuration. | +| [`bd70c07ea`](https://github.com/rocicorp/mono/pull/6338) | fix | - | Exits the change-streamer when its PostgreSQL replication source terminates while Storer or subscriber flow control is blocked, allowing process replacement and recovery from durable state. | Include publicly as an operator reliability fix. The backport retains source-termination signaling, flow-control races, bounded cleanup, and incident regression coverage while omitting RMv2-only bookkeeping absent from 1.9. Intentional local cancellation remains nonfatal. | +| `342fc33d5` | skip | - | None; adapts #6338's new regression test to the older 1.9 `initializeStreamer` signature. | Omit as maintenance-only test plumbing. It removes a main-only constructor argument and does not change production behavior or the scenario under test. | ## Breaking-Change Review @@ -196,6 +199,8 @@ Human review identified three breaking behavioral or operational changes: the Po - Oversized replication update failures reporting transaction, relation, table, column, type, and size context without customer values (#6318). - Fatal replica-writer failures surfacing through replication status and terminating `zero-cache` with a failure code (#6326). - SQLite corruption failures no longer running potentially long full-database checks by default (#6341). +- Extended SQLite corruption errors activating diagnostics and recovery handling (#6339). +- Recovery when PostgreSQL replication terminates during blocked flow control (#6338). ### Performance @@ -235,7 +240,7 @@ Every non-skipped commit is represented or intentionally omitted above. - `contents/docs/self-host.mdx`: document the v5 reader/legacy writer rollout, rollback floor, opt-out, custom-config validation, and Age incompatibility. - `contents/docs/otel.mdx`: retain reviewed lag corrections; document batched initial-sync counters, the `litestream` restore label/default change, and #6312's end-to-end serving-lag, clamp, and clock-skew metrics. - `contents/docs/queries.mdx`: no change required; it already states the corrected `Zero.run` default and `{type: 'complete'}` behavior. -- `contents/docs/release-notes/1.9.mdx`: include the reconstructed maintenance fixes, including #6326 and #6341, and the validated #6292 first-mutation benchmark without broadening it into a general startup claim. +- `contents/docs/release-notes/1.9.mdx`: include the reconstructed maintenance fixes, including #6326, #6341, #6339, and #6338, and the validated #6292 first-mutation benchmark without broadening it into a general startup claim. - `contents/docs/release-notes/index.mdx`: no change required while the existing description remains unchanged. - Generated search and LLM artifacts: regenerate after product-doc edits. @@ -266,6 +271,8 @@ Every non-skipped commit is represented or intentionally omitted above. - Include #6312 as additive operator observability and call out that upstream clock skew can bias end-to-end lag. - Include #6326 as an operator reliability fix so fatal replica-writer failures are visible and restartable. - Include #6341 with #6215 and keep its diagnostic opt-in hidden rather than promoting it as supported configuration. +- Include #6339 with the SQLite corruption diagnostics fix. +- Include #6338 as a replication recovery fix. - Include #6292 in the Performance section using the 10-run Zero 1.8 versus Zero 1.9 comparison, scoped to first mutation handling with uncached server-schema metadata. Remaining blockers: @@ -280,10 +287,10 @@ Human review selected and published the reconstructed maintenance target, retain ## Validation -- Audit coverage: PASS. All 65 raw-range commits have exactly one decision row; all 15 patch-equivalent 1.8 backports are recorded. +- Audit coverage: PASS. All 68 raw-range commits have exactly one decision row; all 15 patch-equivalent 1.8 backports are recorded. - Protocol compatibility: PASS. - Placeholder links: PASS. No `TODO`, `TBD`, or `PLACEHOLDER` markers remain in the audit or release note. -- Maintenance history: PASS. All 26 maintenance commits are signed. Twenty-one are patch-equivalent to main; #6311 differs only by an unchanged inferred test callback type, #6318 omits only an unrelated rollback helper absent from 1.9, #6312 omits generated API snapshots whose infrastructure is absent from 1.9, #6326 overlaps the tailored #6318 backport, and #6341 omits RMv2-only change-log registration. Their selected production changes and added tests match their sources. +- Maintenance history: PASS. All 29 maintenance commits are signed. Twenty-two are patch-equivalent to main; #6311 differs only by an unchanged inferred test callback type, #6318 omits only an unrelated rollback helper absent from 1.9, #6312 omits generated API snapshots whose infrastructure is absent from 1.9, #6326 overlaps the tailored #6318 backport, #6341 omits RMv2-only change-log registration, and #6338 omits RMv2-only bookkeeping and has a maintenance-only test-signature adaptation. Their selected production changes and added tests match their sources. - Mono targeted tests: PASS. zero-client 645, selected zero-cache 150, zero-server 433, z2s 65, zqlite 192, and scalar PostgreSQL integration 5. - Mono full zero-cache test: PASS after #6312 with 4,012 passed and 32 skipped across 301 test files. - Mono static validation: PASS. All 42 typecheck/build tasks, formatting, dependency verification, and type-aware lint completed; lint reported 0 errors and 1,512 warnings. @@ -291,6 +298,7 @@ Human review selected and published the reconstructed maintenance target, retain - #6312 validation: PASS. The complete zero-cache suite, zero-cache typecheck, formatting, and lint completed; lint reported 0 errors and 432 warnings. Its Zero Cache patch ID matches canonical main commit `0beb0ba76`. - #6326 validation: PASS. The 75 affected life-cycle, incremental-sync, and change-processor tests, zero-cache typecheck, and zero-cache formatting completed. - #6341 validation: PASS. The 8 affected SQLite-corruption and logging tests, zero-cache typecheck, and zero-cache formatting completed. +- #6339 and #6338 validation: PASS. Five SQLite-corruption tests and 30 PostgreSQL 17 logical-replication/change-streamer tests passed, with one skipped; zero-cache typecheck and formatting completed. - Release image: PASS. `@rocicorp/zero@1.9.0` packed and the linux/amd64 Docker build completed with the relocated `postgres@3.4.7` patch copied and applied by the image's generated pnpm workspace. - Docs formatting: PASS with `pnpm check-format` after formatting the generated search index. - Docs types: PASS with `pnpm check-types`. diff --git a/assets/search-index.json b/assets/search-index.json index 58d066ad..3a0a7237 100644 --- a/assets/search-index.json +++ b/assets/search-index.json @@ -6223,7 +6223,7 @@ "title": "Zero 1.9", "searchTitle": "Zero 1.9", "url": "/docs/release-notes/1.9", - "content": "Installation npm install @rocicorp/zero@1.9 Overview Zero 1.9 improves query/mutation correctness and reliability. Features End-to-end serving lag: e2e_serving_lag measures completed replicated work from the upstream transaction commit through view-syncer poke. Upstream clock-skew estimate tries to identify measurements biased by clock differences. (#6312) Performance Cold Mutation Latency Before running mutations, Zero Server fetches and caches PostgreSQL schema metadata. This is now 2.7x faster in Zero 1.9 (done in #6292, thanks @diegopereira99!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. Fixes Restores now use Litestream 0.5.15 for legacy-format compatibility, and legacy snapshots retain the previous generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, with deeper checks available opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication.", + "content": "Installation npm install @rocicorp/zero@1.9 Overview Zero 1.9 improves query/mutation correctness and reliability. Features End-to-end serving lag: e2e_serving_lag measures completed replicated work from the upstream transaction commit through view-syncer poke. Upstream clock-skew estimate tries to identify measurements biased by clock differences. (#6312) Performance Cold Mutation Latency Before running mutations, Zero Server fetches and caches PostgreSQL schema metadata. This is now 2.7x faster in Zero 1.9 (done in #6292, thanks @diegopereira99!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. Fixes Restores now use Litestream 0.5.15 for legacy-format compatibility, and legacy snapshots retain the previous generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, including extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers when its PostgreSQL connection terminates while flow control is blocked.", "headings": [ { "text": "Installation", @@ -6309,7 +6309,7 @@ "sectionTitle": "Fixes", "sectionId": "fixes", "url": "/docs/release-notes/1.9", - "content": "Restores now use Litestream 0.5.15 for legacy-format compatibility, and legacy snapshots retain the previous generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, with deeper checks available opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication.", + "content": "Restores now use Litestream 0.5.15 for legacy-format compatibility, and legacy snapshots retain the previous generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, including extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers when its PostgreSQL connection terminates while flow control is blocked.", "kind": "section" }, { diff --git a/contents/docs/release-notes/1.9.mdx b/contents/docs/release-notes/1.9.mdx index 921ccaac..fbad1c7f 100644 --- a/contents/docs/release-notes/1.9.mdx +++ b/contents/docs/release-notes/1.9.mdx @@ -65,6 +65,7 @@ Before running mutations, Zero Server fetches and caches PostgreSQL schema metad - [Expected schema and replica resets now log warnings instead of errors](https://github.com/rocicorp/mono/pull/6248), and [`zero-cache` skips Litestream restore when backups are not configured](https://github.com/rocicorp/mono/pull/6259). (thanks [@asterikx](https://github.com/asterikx)!) - [Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts.](https://github.com/rocicorp/mono/pull/6280) (thanks [@shayonj](https://github.com/shayonj)!) - [Mutation and query API calls now retry all `5xx` responses using the existing four-attempt limit and backoff; `4xx` responses still fail without retry.](https://github.com/rocicorp/mono/pull/6315) (thanks [@shayonj](https://github.com/shayonj)!) -- [SQLite corruption failures now log diagnostics and flush logs before exit](https://github.com/rocicorp/mono/pull/6215), with [deeper checks available opt-in](https://github.com/rocicorp/mono/pull/6341). +- [SQLite corruption failures now log diagnostics and flush logs before exit](https://github.com/rocicorp/mono/pull/6215), including [extended corruption errors](https://github.com/rocicorp/mono/pull/6339), with [deeper checks available as an opt-in](https://github.com/rocicorp/mono/pull/6341). - [Oversized replication updates now identify the transaction, affected column, and value type without logging the value.](https://github.com/rocicorp/mono/pull/6318) - [Fatal replica-writer failures now surface as replication errors and cause `zero-cache` to exit with a failure instead of silently stopping replication.](https://github.com/rocicorp/mono/pull/6326) +- [Replication now recovers when its PostgreSQL connection terminates while flow control is blocked.](https://github.com/rocicorp/mono/pull/6338) From 443cf5788a1978742e559d6d4272e4ac74f04170 Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Wed, 12 Aug 2026 15:34:16 -0700 Subject: [PATCH 11/17] docs: audit Litestream restore fix --- .releases/1.9/benchmarks/6292.md | 2 +- .releases/1.9/commits.md | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.releases/1.9/benchmarks/6292.md b/.releases/1.9/benchmarks/6292.md index c40ffd32..a100d2c7 100644 --- a/.releases/1.9/benchmarks/6292.md +++ b/.releases/1.9/benchmarks/6292.md @@ -10,7 +10,7 @@ This is not a general process-startup claim. The measurement excludes process la - Zero 1.8: `zero/v1.8.0` at `cdc02598f137ab4e071878f5674fdc716dbbc69d` - Zero 1.9 change: `67c8fe4c9d9a5673357bb80116c50d968e54c2e2`, the #6292 commit -- Zero 1.9 release target: `342fc33d519fb5ca3ddab49eaf862b9a3a290cd0` +- Zero 1.9 release target: `693907a314eac6c398aa62d395789d796984d9db` - No `packages/zero-server` production file changed between the #6292 commit and the release target. - Latest benchmark harness: `45706aad581b283037ce0b25130de5c1f27ac086` - Benchmark source SHA-1: `872b0cbe5f61862e53cb8451940ff4664a39b78e` diff --git a/.releases/1.9/commits.md b/.releases/1.9/commits.md index 95931137..4cce98e3 100644 --- a/.releases/1.9/commits.md +++ b/.releases/1.9/commits.md @@ -11,12 +11,12 @@ Status: audit and public draft updated through the reconstructed maintenance tar - Previous ref: `zero/v1.8.0` - Previous SHA: `cdc02598f137ab4e071878f5674fdc716dbbc69d` - Target ref: `origin/maint/zero/v1.9`, reconstructed and published maintenance target -- Target SHA: `342fc33d519fb5ca3ddab49eaf862b9a3a290cd0` +- Target SHA: `693907a314eac6c398aa62d395789d796984d9db` - Merge base: `2279e783edd94aaa20fdcc8e067860ad0c21d95b` - Reconstruction base: `ef892a123a11461e74a59a4b59ad310ba23180b3` -- Raw non-merge range: 68 commits +- Raw non-merge range: 69 commits - Patch-equivalent commits already shipped in 1.8: 15 -- Unique 1.9 commits: 53 +- Unique 1.9 commits: 54 Commands used: @@ -30,7 +30,7 @@ git log --right-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1 git log --left-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1.8.0...origin/maint/zero/v1.9 git log --format='%H%x09%s%n%b' 2279e783edd94aaa20fdcc8e067860ad0c21d95b..zero/v1.8.0 git show zero/v1.8.0:packages/zero-protocol/src/protocol-version.ts -git show 342fc33d519fb5ca3ddab49eaf862b9a3a290cd0:packages/zero-protocol/src/protocol-version.ts +git show 693907a314eac6c398aa62d395789d796984d9db:packages/zero-protocol/src/protocol-version.ts ``` ## Protocol Compatibility @@ -72,9 +72,9 @@ The previous-release side contains no additional `cherry-pick -x` trailers namin ## Maintenance Reconstruction -The target was rebuilt from shared mainline commit `ef892a123` by applying 22 selected, signed mainline commits in topological order with provenance trailers, then fast-forwarded with signed #6318 PR-head and #6312 canonical-main backports, a signed #6326 canonical-main backport, the signed #6341 PR head, canonical-main backports for #6339 and #6338, and a signed maintenance-only test adaptation for #6338. This removes the bulk-cherry-pick timestamps from the old maintenance line and makes every selected change independently traceable to its source. +The target was rebuilt from shared mainline commit `ef892a123` by applying 22 selected, signed mainline commits in topological order with provenance trailers, then fast-forwarded with signed #6318 PR-head and #6312 canonical-main backports, a signed #6326 canonical-main backport, the signed #6341 PR head, canonical-main backports for #6339 and #6338, a signed maintenance-only test adaptation for #6338, and the canonical-main #6343 backport. This removes the bulk-cherry-pick timestamps from the old maintenance line and makes every selected change independently traceable to its source. -The old maintenance-only commits `e91f964a7` and `c073aa39e` are represented by reconstructed commit `49b13e3e5`, the canonical #6280 patch. The resulting production code and tests are byte-identical to main's #6280 tree. Reconstructed #6311 differs in patch ID only because the 1.9 parent infers an unchanged test callback parameter type where main's rmv2-era parent spells out `unknown`; the production change, new transaction tests, and resulting behavior are identical. #6318 similarly omits only an unrelated mainline rollback helper that is absent from 1.9; its oversized-binding diagnostics and regression test match the PR. #6312's entire `packages/zero-cache` patch is patch-equivalent to main; the backport omits only four generated API snapshots because 1.9 predates the #6239 snapshot infrastructure. #6326 applies cleanly; its patch ID differs because its change-processor cleanup overlaps the tailored #6318 backport already on the maintenance branch. #6341 omits only RMv2 change-log diagnostic registration absent from 1.9 and preserves the PR's behavior for every SQLite database used by this release. #6339 applies cleanly. #6338 omits RMv2-only purge and transaction-boundary bookkeeping while preserving source-termination handling for 1.9's Storer and subscriber flow-control waits. +The old maintenance-only commits `e91f964a7` and `c073aa39e` are represented by reconstructed commit `49b13e3e5`, the canonical #6280 patch. The resulting production code and tests are byte-identical to main's #6280 tree. Reconstructed #6311 differs in patch ID only because the 1.9 parent infers an unchanged test callback parameter type where main's rmv2-era parent spells out `unknown`; the production change, new transaction tests, and resulting behavior are identical. #6318 similarly omits only an unrelated mainline rollback helper that is absent from 1.9; its oversized-binding diagnostics and regression test match the PR. #6312's entire `packages/zero-cache` patch is patch-equivalent to main; the backport omits only four generated API snapshots because 1.9 predates the #6239 snapshot infrastructure. #6326 applies cleanly; its patch ID differs because its change-processor cleanup overlaps the tailored #6318 backport already on the maintenance branch. #6341 omits only RMv2 change-log diagnostic registration absent from 1.9 and preserves the PR's behavior for every SQLite database used by this release. #6339 applies cleanly. #6338 omits RMv2-only purge and transaction-boundary bookkeeping while preserving source-termination handling for 1.9's Storer and subscriber flow-control waits. #6343 retains the shared error-preservation change and adapts its regression test and final restore log to 1.9's pre-#6268 restore path. All other reconstructed commits are patch-equivalent to their named mainline source. Main-only rmv2 work, #6307's breaking scalar type enforcement, #6309's nullability-specific optimization, #6314's experimental Litestream update, and #6317's rmv2 test timeout remain excluded. @@ -150,6 +150,7 @@ All other reconstructed commits are patch-equivalent to their named mainline sou | [`2a2972c37`](https://github.com/rocicorp/mono/pull/6339) | fix | - | Recognizes SQLite extended corruption result codes such as `SQLITE_CORRUPT_INDEX`, `SQLITE_CORRUPT_VTAB`, and `SQLITE_CORRUPT_SEQUENCE` as corruption regardless of message text. | Include publicly with #6215 and #6341. Tests cover supported extended codes and reject unrelated prefixes. This broadens when existing corruption diagnostics and recovery handling activate; it does not change database contents or configuration. | | [`bd70c07ea`](https://github.com/rocicorp/mono/pull/6338) | fix | - | Exits the change-streamer when its PostgreSQL replication source terminates while Storer or subscriber flow control is blocked, allowing process replacement and recovery from durable state. | Include publicly as an operator reliability fix. The backport retains source-termination signaling, flow-control races, bounded cleanup, and incident regression coverage while omitting RMv2-only bookkeeping absent from 1.9. Intentional local cancellation remains nonfatal. | | `342fc33d5` | skip | - | None; adapts #6338's new regression test to the older 1.9 `initializeStreamer` signature. | Omit as maintenance-only test plumbing. It removes a main-only constructor argument and does not change production behavior or the scenario under test. | +| [`693907a31`](https://github.com/rocicorp/mono/pull/6343) | fix | - | Preserves captured Litestream stdout and stderr in failed-restore errors so structured logs retain the underlying restore failure instead of only the subprocess exit code. | Backport as part of the unreleased Litestream restore fixes, but omit a separate public bullet per human review. The 1.9 adaptation retains the shared command change and tests it through the older public restore function; its existing change-streamer failure log attaches the enriched error once. | ## Breaking-Change Review @@ -216,6 +217,7 @@ Human review identified three breaking behavioral or operational changes: the Po - Initial-sync metric batching (#6237) has no public performance claim because the available evidence is insufficient; its metric reporting semantics are documented. - The `Zero.run` JSDoc correction (#6301) is omitted because product documentation already states the correct behavior and runtime is unchanged. - #6245's transient Litestream retry and classifier messages are superseded and removed by #6267. +- Litestream restore output preservation (#6343) is folded into the unreleased restore fixes and does not need a separate public bullet. Every non-skipped commit is represented or intentionally omitted above. @@ -273,6 +275,7 @@ Every non-skipped commit is represented or intentionally omitted above. - Include #6341 with #6215 and keep its diagnostic opt-in hidden rather than promoting it as supported configuration. - Include #6339 with the SQLite corruption diagnostics fix. - Include #6338 as a replication recovery fix. +- Backport #6343 but do not add a public release-note bullet because it corrects the unreleased Litestream restore changes already represented in 1.9. - Include #6292 in the Performance section using the 10-run Zero 1.8 versus Zero 1.9 comparison, scoped to first mutation handling with uncached server-schema metadata. Remaining blockers: @@ -287,10 +290,10 @@ Human review selected and published the reconstructed maintenance target, retain ## Validation -- Audit coverage: PASS. All 68 raw-range commits have exactly one decision row; all 15 patch-equivalent 1.8 backports are recorded. +- Audit coverage: PASS. All 69 raw-range commits have exactly one decision row; all 15 patch-equivalent 1.8 backports are recorded. - Protocol compatibility: PASS. - Placeholder links: PASS. No `TODO`, `TBD`, or `PLACEHOLDER` markers remain in the audit or release note. -- Maintenance history: PASS. All 29 maintenance commits are signed. Twenty-two are patch-equivalent to main; #6311 differs only by an unchanged inferred test callback type, #6318 omits only an unrelated rollback helper absent from 1.9, #6312 omits generated API snapshots whose infrastructure is absent from 1.9, #6326 overlaps the tailored #6318 backport, #6341 omits RMv2-only change-log registration, and #6338 omits RMv2-only bookkeeping and has a maintenance-only test-signature adaptation. Their selected production changes and added tests match their sources. +- Maintenance history: PASS. All 30 maintenance commits are signed. Twenty-two are patch-equivalent to main; #6311 differs only by an unchanged inferred test callback type, #6318 omits only an unrelated rollback helper absent from 1.9, #6312 omits generated API snapshots whose infrastructure is absent from 1.9, #6326 overlaps the tailored #6318 backport, #6341 omits RMv2-only change-log registration, #6338 omits RMv2-only bookkeeping and has a maintenance-only test-signature adaptation, and #6343 is adapted to the pre-#6268 restore path. Their selected production changes and added tests match their sources. - Mono targeted tests: PASS. zero-client 645, selected zero-cache 150, zero-server 433, z2s 65, zqlite 192, and scalar PostgreSQL integration 5. - Mono full zero-cache test: PASS after #6312 with 4,012 passed and 32 skipped across 301 test files. - Mono static validation: PASS. All 42 typecheck/build tasks, formatting, dependency verification, and type-aware lint completed; lint reported 0 errors and 1,512 warnings. @@ -299,6 +302,7 @@ Human review selected and published the reconstructed maintenance target, retain - #6326 validation: PASS. The 75 affected life-cycle, incremental-sync, and change-processor tests, zero-cache typecheck, and zero-cache formatting completed. - #6341 validation: PASS. The 8 affected SQLite-corruption and logging tests, zero-cache typecheck, and zero-cache formatting completed. - #6339 and #6338 validation: PASS. Five SQLite-corruption tests and 30 PostgreSQL 17 logical-replication/change-streamer tests passed, with one skipped; zero-cache typecheck and formatting completed. +- #6343 validation: PASS. All 15 Litestream command tests, zero-cache typecheck, and zero-cache formatting completed. - Release image: PASS. `@rocicorp/zero@1.9.0` packed and the linux/amd64 Docker build completed with the relocated `postgres@3.4.7` patch copied and applied by the image's generated pnpm workspace. - Docs formatting: PASS with `pnpm check-format` after formatting the generated search index. - Docs types: PASS with `pnpm check-types`. From aea682e33d8f5e217256ed1e66b5a9aabd1fd918 Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Thu, 13 Aug 2026 10:09:39 -0700 Subject: [PATCH 12/17] docs: update final Zero 1.9 fixes --- .releases/1.9/benchmarks/6292.md | 2 +- .releases/1.9/commits.md | 34 +++++++++++++++++++---------- assets/search-index.json | 4 ++-- contents/docs/release-notes/1.9.mdx | 5 +++-- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/.releases/1.9/benchmarks/6292.md b/.releases/1.9/benchmarks/6292.md index a100d2c7..5c207eae 100644 --- a/.releases/1.9/benchmarks/6292.md +++ b/.releases/1.9/benchmarks/6292.md @@ -10,7 +10,7 @@ This is not a general process-startup claim. The measurement excludes process la - Zero 1.8: `zero/v1.8.0` at `cdc02598f137ab4e071878f5674fdc716dbbc69d` - Zero 1.9 change: `67c8fe4c9d9a5673357bb80116c50d968e54c2e2`, the #6292 commit -- Zero 1.9 release target: `693907a314eac6c398aa62d395789d796984d9db` +- Zero 1.9 release target: `dcbc14f0251d22c0ba4fed4c082f537db50b7875` - No `packages/zero-server` production file changed between the #6292 commit and the release target. - Latest benchmark harness: `45706aad581b283037ce0b25130de5c1f27ac086` - Benchmark source SHA-1: `872b0cbe5f61862e53cb8451940ff4664a39b78e` diff --git a/.releases/1.9/commits.md b/.releases/1.9/commits.md index 4cce98e3..dfe62c8e 100644 --- a/.releases/1.9/commits.md +++ b/.releases/1.9/commits.md @@ -11,12 +11,12 @@ Status: audit and public draft updated through the reconstructed maintenance tar - Previous ref: `zero/v1.8.0` - Previous SHA: `cdc02598f137ab4e071878f5674fdc716dbbc69d` - Target ref: `origin/maint/zero/v1.9`, reconstructed and published maintenance target -- Target SHA: `693907a314eac6c398aa62d395789d796984d9db` +- Target SHA: `dcbc14f0251d22c0ba4fed4c082f537db50b7875` - Merge base: `2279e783edd94aaa20fdcc8e067860ad0c21d95b` - Reconstruction base: `ef892a123a11461e74a59a4b59ad310ba23180b3` -- Raw non-merge range: 69 commits +- Raw non-merge range: 75 commits - Patch-equivalent commits already shipped in 1.8: 15 -- Unique 1.9 commits: 54 +- Unique 1.9 commits: 60 Commands used: @@ -30,7 +30,7 @@ git log --right-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1 git log --left-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1.8.0...origin/maint/zero/v1.9 git log --format='%H%x09%s%n%b' 2279e783edd94aaa20fdcc8e067860ad0c21d95b..zero/v1.8.0 git show zero/v1.8.0:packages/zero-protocol/src/protocol-version.ts -git show 693907a314eac6c398aa62d395789d796984d9db:packages/zero-protocol/src/protocol-version.ts +git show dcbc14f0251d22c0ba4fed4c082f537db50b7875:packages/zero-protocol/src/protocol-version.ts ``` ## Protocol Compatibility @@ -72,9 +72,9 @@ The previous-release side contains no additional `cherry-pick -x` trailers namin ## Maintenance Reconstruction -The target was rebuilt from shared mainline commit `ef892a123` by applying 22 selected, signed mainline commits in topological order with provenance trailers, then fast-forwarded with signed #6318 PR-head and #6312 canonical-main backports, a signed #6326 canonical-main backport, the signed #6341 PR head, canonical-main backports for #6339 and #6338, a signed maintenance-only test adaptation for #6338, and the canonical-main #6343 backport. This removes the bulk-cherry-pick timestamps from the old maintenance line and makes every selected change independently traceable to its source. +The target was rebuilt from shared mainline commit `ef892a123` by applying selected signed mainline commits with provenance trailers. Later maintenance updates add #6326, #6341, #6339, #6343, the final #6346/#6348 backpressure recovery after reverting #6338, #6347, #6349, and #6340. This removes the bulk-cherry-pick timestamps from the old maintenance line and makes every selected change independently traceable to its source. -The old maintenance-only commits `e91f964a7` and `c073aa39e` are represented by reconstructed commit `49b13e3e5`, the canonical #6280 patch. The resulting production code and tests are byte-identical to main's #6280 tree. Reconstructed #6311 differs in patch ID only because the 1.9 parent infers an unchanged test callback parameter type where main's rmv2-era parent spells out `unknown`; the production change, new transaction tests, and resulting behavior are identical. #6318 similarly omits only an unrelated mainline rollback helper that is absent from 1.9; its oversized-binding diagnostics and regression test match the PR. #6312's entire `packages/zero-cache` patch is patch-equivalent to main; the backport omits only four generated API snapshots because 1.9 predates the #6239 snapshot infrastructure. #6326 applies cleanly; its patch ID differs because its change-processor cleanup overlaps the tailored #6318 backport already on the maintenance branch. #6341 omits only RMv2 change-log diagnostic registration absent from 1.9 and preserves the PR's behavior for every SQLite database used by this release. #6339 applies cleanly. #6338 omits RMv2-only purge and transaction-boundary bookkeeping while preserving source-termination handling for 1.9's Storer and subscriber flow-control waits. #6343 retains the shared error-preservation change and adapts its regression test and final restore log to 1.9's pre-#6268 restore path. +The old maintenance-only commits `e91f964a7` and `c073aa39e` are represented by reconstructed commit `49b13e3e5`, the canonical #6280 patch. #6311, #6318, #6312, #6326, #6341, and #6343 retain targeted differences required by 1.9's older code shape. #6338 and its maintenance-only test adaptation are superseded by #6345 and the final #6346/#6348 implementation. #6339, #6347, #6349, and #6340 apply without production changes beyond their source patches. All other reconstructed commits are patch-equivalent to their named mainline source. Main-only rmv2 work, #6307's breaking scalar type enforcement, #6309's nullability-specific optimization, #6314's experimental Litestream update, and #6317's rmv2 test timeout remain excluded. @@ -151,6 +151,12 @@ All other reconstructed commits are patch-equivalent to their named mainline sou | [`bd70c07ea`](https://github.com/rocicorp/mono/pull/6338) | fix | - | Exits the change-streamer when its PostgreSQL replication source terminates while Storer or subscriber flow control is blocked, allowing process replacement and recovery from durable state. | Include publicly as an operator reliability fix. The backport retains source-termination signaling, flow-control races, bounded cleanup, and incident regression coverage while omitting RMv2-only bookkeeping absent from 1.9. Intentional local cancellation remains nonfatal. | | `342fc33d5` | skip | - | None; adapts #6338's new regression test to the older 1.9 `initializeStreamer` signature. | Omit as maintenance-only test plumbing. It removes a main-only constructor argument and does not change production behavior or the scenario under test. | | [`693907a31`](https://github.com/rocicorp/mono/pull/6343) | fix | - | Preserves captured Litestream stdout and stderr in failed-restore errors so structured logs retain the underlying restore failure instead of only the subprocess exit code. | Backport as part of the unreleased Litestream restore fixes, but omit a separate public bullet per human review. The 1.9 adaptation retains the shared command change and tests it through the older public restore function; its existing change-streamer failure log attaches the enriched error once. | +| [`4540339f9`](https://github.com/rocicorp/mono/pull/6345) | skip | - | Reverts #6338's source-termination implementation before release. | Omit publicly. #6346 and #6348 provide the final memory-safe replacement behavior. | +| [`da9b61778`](https://github.com/rocicorp/mono/pull/6346) | fix | - | Stops waiting on subscriber or Storer flow control when the upstream stream ends, and bounds Storer draining so a wedged process can be replaced. | Include publicly with #6348. The implementation uses abort-aware source completion rather than the superseded long-lived `Promise.race` from #6338. Tests cover flow-control interruption, bounded drain, resumption, and cancellation behavior. | +| [`8ec7364c0`](https://github.com/rocicorp/mono/pull/6347) | fix | - | Retries a failed replication-manager Litestream restore twice before falling back to initial sync. | Fold into the existing Litestream restore bullet. View-syncer startup behavior is unchanged; persistent failures still take the existing recovery path. | +| [`ad0c6dd3f`](https://github.com/rocicorp/mono/pull/6348) | fix | - | Treats queued downstream changes as evidence that the upstream connection is alive and adds an explicit timeout for a Storer that makes no progress under backpressure. | Include publicly with #6346. This avoids destroying a healthy replication connection solely because downstream backpressure paused reads while still terminating genuinely wedged PostgreSQL writes. | +| [`f0d6fbe82`](https://github.com/rocicorp/mono/pull/6349) | fix | - | Upgrades `@rocicorp/zero-sqlite3` from 1.1.2 to 1.1.4 across Zero, zero-cache, ZQLite, and Replicache. | Omit from the public note. The commit expresses only a hope that the newer SQLite fixes observed corruption; there is no release-range evidence supporting a concrete user-facing claim. Record the raised package and peer dependency minimum privately. | +| [`dcbc14f02`](https://github.com/rocicorp/mono/pull/6340) | fix | - | Tracks sent mutation IDs per client rather than by one position in a reorderable client-group commit chain, preventing one tab from skipping another tab's pending mutations. | Include publicly as a multi-tab mutation correctness fix. Tests cover reordered pending chains, contiguous mutation IDs, reconnect resends, and per-connection state reset. | ## Breaking-Change Review @@ -163,7 +169,7 @@ Human review identified three breaking behavioral or operational changes: the Po | Default behavior | PostgreSQL connections reset after roughly two to four minutes without wire activity. Client connection setup now obeys its existing ten-second timeout. Statement caches retain at most 1,000 idle statements. Duplicate-primary-key inserts no-op. CRUD updates omit primary-key assignments. Official-image restores use Litestream v5. Serving writes use spillable immediate transactions. API-server `5xx` responses retry. | Classify the watchdog, duplicate-insert behavior, and v5 restore default as breaking. The connection-timeout enforcement, CRUD lock correction, spillable writer, and `5xx` retries restore intended reliability behavior and remain non-breaking. | | Persisted data | No source-database, replica-format, or protocol migration is introduced. Correctly preserving `__proto__` can trigger a normal client resync. #6225 prevents future compound-index reordering but does not repair replicas already affected. #6311 changes the serving transaction mode without changing stored data or snapshot isolation. | Tell affected #6225 deployments to resync the replica or recreate the index. No upstream application data migration is required. | | Protocol | Sync `51`, minimum sync `30`, change-stream `6`, and DDL emitter `1` remain compatible. The DDL reader accepts future context-only v2 starts but this target still emits v1. | Resolved: compatible. Retain phase ordering and rollback constraints for the future v2 emitter in the private audit. | -| Dependencies | `@rocicorp/zero` removes optional integration peers to avoid peer-qualified duplicate installs; integrations remain consumer-provided. The official image changes Litestream v5 from 0.5.11 through 0.5.14 to 0.5.15 and activates it for restore. Litestream v5 cannot read Age-encrypted v3 backups. The image now applies the existing `postgres@3.4.7` patch. | Include the workspace-install and Docker patch fixes. Require Age users to retain legacy restore or migrate backups. Validate custom Litestream configurations in staging. | +| Dependencies | `@rocicorp/zero` removes optional integration peers to avoid peer-qualified duplicate installs; integrations remain consumer-provided. The official image changes Litestream v5 from 0.5.11 through 0.5.14 to 0.5.15 and activates it for restore. Litestream v5 cannot read Age-encrypted v3 backups. The image applies the existing `postgres@3.4.7` patch. `@rocicorp/zero-sqlite3` now requires 1.1.4. | Include the workspace-install and Docker patch fixes. Require Age users to retain legacy restore or migrate backups. Validate custom Litestream configurations in staging. Do not claim that zero-sqlite3 1.1.4 fixes corruption without evidence. | | Metrics and alerts | Lag metrics and serving populations change as previously reviewed. Initial-sync byte/chunk counters advance in batches. Restore metrics in the official image change from `litestream=legacy` to `litestream=v5`. Query materialization timing is recorded only for initial completion. #6267 removes #6245's classifier-specific restore messages. | Update metric descriptions and dashboard migration guidance. No Cloud Zero classifier deployment order is required after #6267. | | Deployment and rollback | DDL v2 reader support is phase-one scaffolding. This release still writes legacy Litestream backups but restores both legacy and LTX formats with v5, making 1.9 the intended rollback floor for a future v5 writer. A pre-#6260 image cannot safely restore a newer LTX-only backup. Legacy snapshots now retain an additional six-hour overlap. | Smoke-test legacy-to-v5 restore, mixed-format selection, opt-out, snapshot retention, and rollback. Roll back future v5 writer settings with the image. Keep `ZERO_LITESTREAM_RESTORE_USING_V5=true` if 1.9 is used as the rollback target for LTX backups. | @@ -201,7 +207,8 @@ Human review identified three breaking behavioral or operational changes: the Po - Fatal replica-writer failures surfacing through replication status and terminating `zero-cache` with a failure code (#6326). - SQLite corruption failures no longer running potentially long full-database checks by default (#6341). - Extended SQLite corruption errors activating diagnostics and recovery handling (#6339). -- Recovery when PostgreSQL replication terminates during blocked flow control (#6338). +- Recovery from upstream disconnects and stalled PostgreSQL writes during blocked flow control (#6346 and #6348); #6338 is reverted by #6345. +- Multi-tab client-group mutations no longer being skipped or sent out of order (#6340). ### Performance @@ -218,6 +225,7 @@ Human review identified three breaking behavioral or operational changes: the Po - The `Zero.run` JSDoc correction (#6301) is omitted because product documentation already states the correct behavior and runtime is unchanged. - #6245's transient Litestream retry and classifier messages are superseded and removed by #6267. - Litestream restore output preservation (#6343) is folded into the unreleased restore fixes and does not need a separate public bullet. +- The zero-sqlite3 1.1.4 update (#6349) has no substantiated public corruption-fix claim. Every non-skipped commit is represented or intentionally omitted above. @@ -274,8 +282,11 @@ Every non-skipped commit is represented or intentionally omitted above. - Include #6326 as an operator reliability fix so fatal replica-writer failures are visible and restartable. - Include #6341 with #6215 and keep its diagnostic opt-in hidden rather than promoting it as supported configuration. - Include #6339 with the SQLite corruption diagnostics fix. -- Include #6338 as a replication recovery fix. - Backport #6343 but do not add a public release-note bullet because it corrects the unreleased Litestream restore changes already represented in 1.9. +- Replace the superseded #6338 note with final #6346/#6348 behavior and omit its #6345 revert. +- Fold #6347 into the existing Litestream restore bullet. +- Omit a speculative corruption claim for #6349. +- Include #6340 as a multi-tab mutation ordering fix. - Include #6292 in the Performance section using the 10-run Zero 1.8 versus Zero 1.9 comparison, scoped to first mutation handling with uncached server-schema metadata. Remaining blockers: @@ -290,10 +301,10 @@ Human review selected and published the reconstructed maintenance target, retain ## Validation -- Audit coverage: PASS. All 69 raw-range commits have exactly one decision row; all 15 patch-equivalent 1.8 backports are recorded. +- Audit coverage: PASS. All 75 raw-range commits have exactly one decision row; all 15 patch-equivalent 1.8 backports are recorded. - Protocol compatibility: PASS. - Placeholder links: PASS. No `TODO`, `TBD`, or `PLACEHOLDER` markers remain in the audit or release note. -- Maintenance history: PASS. All 30 maintenance commits are signed. Twenty-two are patch-equivalent to main; #6311 differs only by an unchanged inferred test callback type, #6318 omits only an unrelated rollback helper absent from 1.9, #6312 omits generated API snapshots whose infrastructure is absent from 1.9, #6326 overlaps the tailored #6318 backport, #6341 omits RMv2-only change-log registration, #6338 omits RMv2-only bookkeeping and has a maintenance-only test-signature adaptation, and #6343 is adapted to the pre-#6268 restore path. Their selected production changes and added tests match their sources. +- Maintenance history: PASS. All 36 maintenance commits are signed. Targeted backports retain documented differences required by the 1.9 code shape; #6338 is fully superseded by its revert and final #6346/#6348 replacement. - Mono targeted tests: PASS. zero-client 645, selected zero-cache 150, zero-server 433, z2s 65, zqlite 192, and scalar PostgreSQL integration 5. - Mono full zero-cache test: PASS after #6312 with 4,012 passed and 32 skipped across 301 test files. - Mono static validation: PASS. All 42 typecheck/build tasks, formatting, dependency verification, and type-aware lint completed; lint reported 0 errors and 1,512 warnings. @@ -303,6 +314,7 @@ Human review selected and published the reconstructed maintenance target, retain - #6341 validation: PASS. The 8 affected SQLite-corruption and logging tests, zero-cache typecheck, and zero-cache formatting completed. - #6339 and #6338 validation: PASS. Five SQLite-corruption tests and 30 PostgreSQL 17 logical-replication/change-streamer tests passed, with one skipped; zero-cache typecheck and formatting completed. - #6343 validation: PASS. All 15 Litestream command tests, zero-cache typecheck, and zero-cache formatting completed. +- #6345 through #6349 and #6340 validation: PASS. The focused zero-client suite passed 124 tests; targeted zero-cache subscription, Litestream, logical-replication, Storer, and change-streamer suites passed 94 tests. Zero-client and zero-cache typechecks and formatting completed. - Release image: PASS. `@rocicorp/zero@1.9.0` packed and the linux/amd64 Docker build completed with the relocated `postgres@3.4.7` patch copied and applied by the image's generated pnpm workspace. - Docs formatting: PASS with `pnpm check-format` after formatting the generated search index. - Docs types: PASS with `pnpm check-types`. diff --git a/assets/search-index.json b/assets/search-index.json index 3a0a7237..52d0c907 100644 --- a/assets/search-index.json +++ b/assets/search-index.json @@ -6223,7 +6223,7 @@ "title": "Zero 1.9", "searchTitle": "Zero 1.9", "url": "/docs/release-notes/1.9", - "content": "Installation npm install @rocicorp/zero@1.9 Overview Zero 1.9 improves query/mutation correctness and reliability. Features End-to-end serving lag: e2e_serving_lag measures completed replicated work from the upstream transaction commit through view-syncer poke. Upstream clock-skew estimate tries to identify measurements biased by clock differences. (#6312) Performance Cold Mutation Latency Before running mutations, Zero Server fetches and caches PostgreSQL schema metadata. This is now 2.7x faster in Zero 1.9 (done in #6292, thanks @diegopereira99!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. Fixes Restores now use Litestream 0.5.15 for legacy-format compatibility, and legacy snapshots retain the previous generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, including extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers when its PostgreSQL connection terminates while flow control is blocked.", + "content": "Installation npm install @rocicorp/zero@1.9 Overview Zero 1.9 improves query/mutation correctness and reliability. Features End-to-end serving lag: e2e_serving_lag measures completed replicated work from the upstream transaction commit through view-syncer poke. Upstream clock-skew estimate tries to identify measurements biased by clock differences. (#6312) Performance Cold Mutation Latency Before running mutations, Zero Server fetches and caches PostgreSQL schema metadata. This is now 2.7x faster in Zero 1.9 (done in #6292, thanks @diegopereira99!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. Fixes Restores now use Litestream 0.5.15 for legacy-format compatibility, retry transient failures, and retain the previous snapshot generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, including extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers from upstream disconnects or stalled PostgreSQL writes while flow control is blocked. (#6348) Mutations from multiple tabs in the same client group are no longer skipped or sent out of order.", "headings": [ { "text": "Installation", @@ -6309,7 +6309,7 @@ "sectionTitle": "Fixes", "sectionId": "fixes", "url": "/docs/release-notes/1.9", - "content": "Restores now use Litestream 0.5.15 for legacy-format compatibility, and legacy snapshots retain the previous generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, including extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers when its PostgreSQL connection terminates while flow control is blocked.", + "content": "Restores now use Litestream 0.5.15 for legacy-format compatibility, retry transient failures, and retain the previous snapshot generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, including extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers from upstream disconnects or stalled PostgreSQL writes while flow control is blocked. (#6348) Mutations from multiple tabs in the same client group are no longer skipped or sent out of order.", "kind": "section" }, { diff --git a/contents/docs/release-notes/1.9.mdx b/contents/docs/release-notes/1.9.mdx index fbad1c7f..f463eae8 100644 --- a/contents/docs/release-notes/1.9.mdx +++ b/contents/docs/release-notes/1.9.mdx @@ -46,7 +46,7 @@ Before running mutations, Zero Server fetches and caches PostgreSQL schema metad ## Fixes -- [Restores now use Litestream 0.5.15 for legacy-format compatibility](/docs/zero-cache-config#litestream-restore-using-v5), and [legacy snapshots retain the previous generation during active restores](/docs/zero-cache-config#litestream-snapshot-backup-interval-hours). ([#6260](https://github.com/rocicorp/mono/pull/6260), [#6267](https://github.com/rocicorp/mono/pull/6267)) +- [Restores now use Litestream 0.5.15 for legacy-format compatibility](/docs/zero-cache-config#litestream-restore-using-v5), [retry transient failures](https://github.com/rocicorp/mono/pull/6347), and [retain the previous snapshot generation during active restores](/docs/zero-cache-config#litestream-snapshot-backup-interval-hours). ([#6260](https://github.com/rocicorp/mono/pull/6260), [#6267](https://github.com/rocicorp/mono/pull/6267)) - [`insert` now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error.](https://github.com/rocicorp/mono/pull/6251) - [Ordered queries now return correct results when cursor fields contain `NULL`.](https://github.com/rocicorp/mono/pull/6121) (thanks [@YevheniiKotyrlo](https://github.com/YevheniiKotyrlo)!) - [Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results.](https://github.com/rocicorp/mono/pull/6196) @@ -68,4 +68,5 @@ Before running mutations, Zero Server fetches and caches PostgreSQL schema metad - [SQLite corruption failures now log diagnostics and flush logs before exit](https://github.com/rocicorp/mono/pull/6215), including [extended corruption errors](https://github.com/rocicorp/mono/pull/6339), with [deeper checks available as an opt-in](https://github.com/rocicorp/mono/pull/6341). - [Oversized replication updates now identify the transaction, affected column, and value type without logging the value.](https://github.com/rocicorp/mono/pull/6318) - [Fatal replica-writer failures now surface as replication errors and cause `zero-cache` to exit with a failure instead of silently stopping replication.](https://github.com/rocicorp/mono/pull/6326) -- [Replication now recovers when its PostgreSQL connection terminates while flow control is blocked.](https://github.com/rocicorp/mono/pull/6338) +- [Replication now recovers from upstream disconnects or stalled PostgreSQL writes while flow control is blocked.](https://github.com/rocicorp/mono/pull/6346) ([#6348](https://github.com/rocicorp/mono/pull/6348)) +- [Mutations from multiple tabs in the same client group are no longer skipped or sent out of order.](https://github.com/rocicorp/mono/pull/6340) From c2fc5bd94885c65b48c35662b5b77e4e45d324d3 Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Thu, 13 Aug 2026 11:24:56 -0700 Subject: [PATCH 13/17] docs: clarify replication recovery links --- assets/search-index.json | 4 ++-- contents/docs/release-notes/1.9.mdx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/search-index.json b/assets/search-index.json index 52d0c907..bac4d8e3 100644 --- a/assets/search-index.json +++ b/assets/search-index.json @@ -6223,7 +6223,7 @@ "title": "Zero 1.9", "searchTitle": "Zero 1.9", "url": "/docs/release-notes/1.9", - "content": "Installation npm install @rocicorp/zero@1.9 Overview Zero 1.9 improves query/mutation correctness and reliability. Features End-to-end serving lag: e2e_serving_lag measures completed replicated work from the upstream transaction commit through view-syncer poke. Upstream clock-skew estimate tries to identify measurements biased by clock differences. (#6312) Performance Cold Mutation Latency Before running mutations, Zero Server fetches and caches PostgreSQL schema metadata. This is now 2.7x faster in Zero 1.9 (done in #6292, thanks @diegopereira99!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. Fixes Restores now use Litestream 0.5.15 for legacy-format compatibility, retry transient failures, and retain the previous snapshot generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, including extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers from upstream disconnects or stalled PostgreSQL writes while flow control is blocked. (#6348) Mutations from multiple tabs in the same client group are no longer skipped or sent out of order.", + "content": "Installation npm install @rocicorp/zero@1.9 Overview Zero 1.9 improves query/mutation correctness and reliability. Features End-to-end serving lag: e2e_serving_lag measures completed replicated work from the upstream transaction commit through view-syncer poke. Upstream clock-skew estimate tries to identify measurements biased by clock differences. (#6312) Performance Cold Mutation Latency Before running mutations, Zero Server fetches and caches PostgreSQL schema metadata. This is now 2.7x faster in Zero 1.9 (done in #6292, thanks @diegopereira99!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. Fixes Restores now use Litestream 0.5.15 for legacy-format compatibility, retry transient failures, and retain the previous snapshot generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, including extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers from upstream disconnects or stalled PostgreSQL writes while flow control is blocked. Mutations from multiple tabs in the same client group are no longer skipped or sent out of order.", "headings": [ { "text": "Installation", @@ -6309,7 +6309,7 @@ "sectionTitle": "Fixes", "sectionId": "fixes", "url": "/docs/release-notes/1.9", - "content": "Restores now use Litestream 0.5.15 for legacy-format compatibility, retry transient failures, and retain the previous snapshot generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, including extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers from upstream disconnects or stalled PostgreSQL writes while flow control is blocked. (#6348) Mutations from multiple tabs in the same client group are no longer skipped or sent out of order.", + "content": "Restores now use Litestream 0.5.15 for legacy-format compatibility, retry transient failures, and retain the previous snapshot generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, including extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers from upstream disconnects or stalled PostgreSQL writes while flow control is blocked. Mutations from multiple tabs in the same client group are no longer skipped or sent out of order.", "kind": "section" }, { diff --git a/contents/docs/release-notes/1.9.mdx b/contents/docs/release-notes/1.9.mdx index f463eae8..d698869c 100644 --- a/contents/docs/release-notes/1.9.mdx +++ b/contents/docs/release-notes/1.9.mdx @@ -68,5 +68,5 @@ Before running mutations, Zero Server fetches and caches PostgreSQL schema metad - [SQLite corruption failures now log diagnostics and flush logs before exit](https://github.com/rocicorp/mono/pull/6215), including [extended corruption errors](https://github.com/rocicorp/mono/pull/6339), with [deeper checks available as an opt-in](https://github.com/rocicorp/mono/pull/6341). - [Oversized replication updates now identify the transaction, affected column, and value type without logging the value.](https://github.com/rocicorp/mono/pull/6318) - [Fatal replica-writer failures now surface as replication errors and cause `zero-cache` to exit with a failure instead of silently stopping replication.](https://github.com/rocicorp/mono/pull/6326) -- [Replication now recovers from upstream disconnects or stalled PostgreSQL writes while flow control is blocked.](https://github.com/rocicorp/mono/pull/6346) ([#6348](https://github.com/rocicorp/mono/pull/6348)) +- [Replication now recovers from upstream disconnects](https://github.com/rocicorp/mono/pull/6346) or [stalled PostgreSQL writes](https://github.com/rocicorp/mono/pull/6348) while flow control is blocked. - [Mutations from multiple tabs in the same client group are no longer skipped or sent out of order.](https://github.com/rocicorp/mono/pull/6340) From 8f63de30430a07d52c0d10c9fd89d0b98c6bdb71 Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Thu, 13 Aug 2026 11:45:58 -0700 Subject: [PATCH 14/17] docs: include corruption recovery fix --- .releases/1.9/benchmarks/6292.md | 2 +- .releases/1.9/commits.md | 18 +++++++++++------- assets/search-index.json | 4 ++-- contents/docs/release-notes/1.9.mdx | 2 +- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.releases/1.9/benchmarks/6292.md b/.releases/1.9/benchmarks/6292.md index 5c207eae..1c87924e 100644 --- a/.releases/1.9/benchmarks/6292.md +++ b/.releases/1.9/benchmarks/6292.md @@ -10,7 +10,7 @@ This is not a general process-startup claim. The measurement excludes process la - Zero 1.8: `zero/v1.8.0` at `cdc02598f137ab4e071878f5674fdc716dbbc69d` - Zero 1.9 change: `67c8fe4c9d9a5673357bb80116c50d968e54c2e2`, the #6292 commit -- Zero 1.9 release target: `dcbc14f0251d22c0ba4fed4c082f537db50b7875` +- Zero 1.9 release target: `1a0095a00ad21c0f156186d9eee833df85275b38` - No `packages/zero-server` production file changed between the #6292 commit and the release target. - Latest benchmark harness: `45706aad581b283037ce0b25130de5c1f27ac086` - Benchmark source SHA-1: `872b0cbe5f61862e53cb8451940ff4664a39b78e` diff --git a/.releases/1.9/commits.md b/.releases/1.9/commits.md index dfe62c8e..2f0573b5 100644 --- a/.releases/1.9/commits.md +++ b/.releases/1.9/commits.md @@ -11,12 +11,12 @@ Status: audit and public draft updated through the reconstructed maintenance tar - Previous ref: `zero/v1.8.0` - Previous SHA: `cdc02598f137ab4e071878f5674fdc716dbbc69d` - Target ref: `origin/maint/zero/v1.9`, reconstructed and published maintenance target -- Target SHA: `dcbc14f0251d22c0ba4fed4c082f537db50b7875` +- Target SHA: `1a0095a00ad21c0f156186d9eee833df85275b38` - Merge base: `2279e783edd94aaa20fdcc8e067860ad0c21d95b` - Reconstruction base: `ef892a123a11461e74a59a4b59ad310ba23180b3` -- Raw non-merge range: 75 commits +- Raw non-merge range: 76 commits - Patch-equivalent commits already shipped in 1.8: 15 -- Unique 1.9 commits: 60 +- Unique 1.9 commits: 61 Commands used: @@ -30,7 +30,7 @@ git log --right-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1 git log --left-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1.8.0...origin/maint/zero/v1.9 git log --format='%H%x09%s%n%b' 2279e783edd94aaa20fdcc8e067860ad0c21d95b..zero/v1.8.0 git show zero/v1.8.0:packages/zero-protocol/src/protocol-version.ts -git show dcbc14f0251d22c0ba4fed4c082f537db50b7875:packages/zero-protocol/src/protocol-version.ts +git show 1a0095a00ad21c0f156186d9eee833df85275b38:packages/zero-protocol/src/protocol-version.ts ``` ## Protocol Compatibility @@ -72,7 +72,7 @@ The previous-release side contains no additional `cherry-pick -x` trailers namin ## Maintenance Reconstruction -The target was rebuilt from shared mainline commit `ef892a123` by applying selected signed mainline commits with provenance trailers. Later maintenance updates add #6326, #6341, #6339, #6343, the final #6346/#6348 backpressure recovery after reverting #6338, #6347, #6349, and #6340. This removes the bulk-cherry-pick timestamps from the old maintenance line and makes every selected change independently traceable to its source. +The target was rebuilt from shared mainline commit `ef892a123` by applying selected signed mainline commits with provenance trailers. Later maintenance updates add #6326, #6341, #6339, #6343, the final #6346/#6348 backpressure recovery after reverting #6338, #6347, #6349, #6340, and #6342. This removes the bulk-cherry-pick timestamps from the old maintenance line and makes every selected change independently traceable to its source. The old maintenance-only commits `e91f964a7` and `c073aa39e` are represented by reconstructed commit `49b13e3e5`, the canonical #6280 patch. #6311, #6318, #6312, #6326, #6341, and #6343 retain targeted differences required by 1.9's older code shape. #6338 and its maintenance-only test adaptation are superseded by #6345 and the final #6346/#6348 implementation. #6339, #6347, #6349, and #6340 apply without production changes beyond their source patches. @@ -157,6 +157,7 @@ All other reconstructed commits are patch-equivalent to their named mainline sou | [`ad0c6dd3f`](https://github.com/rocicorp/mono/pull/6348) | fix | - | Treats queued downstream changes as evidence that the upstream connection is alive and adds an explicit timeout for a Storer that makes no progress under backpressure. | Include publicly with #6346. This avoids destroying a healthy replication connection solely because downstream backpressure paused reads while still terminating genuinely wedged PostgreSQL writes. | | [`f0d6fbe82`](https://github.com/rocicorp/mono/pull/6349) | fix | - | Upgrades `@rocicorp/zero-sqlite3` from 1.1.2 to 1.1.4 across Zero, zero-cache, ZQLite, and Replicache. | Omit from the public note. The commit expresses only a hope that the newer SQLite fixes observed corruption; there is no release-range evidence supporting a concrete user-facing claim. Record the raised package and peer dependency minimum privately. | | [`dcbc14f02`](https://github.com/rocicorp/mono/pull/6340) | fix | - | Tracks sent mutation IDs per client rather than by one position in a reorderable client-group commit chain, preventing one tab from skipping another tab's pending mutations. | Include publicly as a multi-tab mutation correctness fix. Tests cover reordered pending chains, contiguous mutation IDs, reconnect resends, and per-connection state reset. | +| [`1a0095a00`](https://github.com/rocicorp/mono/pull/6342) | fix | - | After logging a write-worker SQLite corruption failure, best-effort deletes the replica database and sidecars so process replacement can restore or rebuild a clean replica instead of reopening the corrupt file. | Include publicly with the existing corruption diagnostics. Deletion failures are warned and do not replace the original fatal error. This targets the write-worker replica path and does not delete upstream application data. | ## Breaking-Change Review @@ -207,6 +208,7 @@ Human review identified three breaking behavioral or operational changes: the Po - Fatal replica-writer failures surfacing through replication status and terminating `zero-cache` with a failure code (#6326). - SQLite corruption failures no longer running potentially long full-database checks by default (#6341). - Extended SQLite corruption errors activating diagnostics and recovery handling (#6339). +- Corrupted write-worker replicas being deleted before exit so restart can restore or rebuild them (#6342). - Recovery from upstream disconnects and stalled PostgreSQL writes during blocked flow control (#6346 and #6348); #6338 is reverted by #6345. - Multi-tab client-group mutations no longer being skipped or sent out of order (#6340). @@ -287,6 +289,7 @@ Every non-skipped commit is represented or intentionally omitted above. - Fold #6347 into the existing Litestream restore bullet. - Omit a speculative corruption claim for #6349. - Include #6340 as a multi-tab mutation ordering fix. +- Include #6342 with the existing SQLite corruption diagnostics and recovery bullet. - Include #6292 in the Performance section using the 10-run Zero 1.8 versus Zero 1.9 comparison, scoped to first mutation handling with uncached server-schema metadata. Remaining blockers: @@ -301,10 +304,10 @@ Human review selected and published the reconstructed maintenance target, retain ## Validation -- Audit coverage: PASS. All 75 raw-range commits have exactly one decision row; all 15 patch-equivalent 1.8 backports are recorded. +- Audit coverage: PASS. All 76 raw-range commits have exactly one decision row; all 15 patch-equivalent 1.8 backports are recorded. - Protocol compatibility: PASS. - Placeholder links: PASS. No `TODO`, `TBD`, or `PLACEHOLDER` markers remain in the audit or release note. -- Maintenance history: PASS. All 36 maintenance commits are signed. Targeted backports retain documented differences required by the 1.9 code shape; #6338 is fully superseded by its revert and final #6346/#6348 replacement. +- Maintenance history: PASS. All 37 maintenance commits are signed. Targeted backports retain documented differences required by the 1.9 code shape; #6338 is fully superseded by its revert and final #6346/#6348 replacement. #6342 preserves 1.9's existing diagnostic-target registration while adding the source deletion behavior. - Mono targeted tests: PASS. zero-client 645, selected zero-cache 150, zero-server 433, z2s 65, zqlite 192, and scalar PostgreSQL integration 5. - Mono full zero-cache test: PASS after #6312 with 4,012 passed and 32 skipped across 301 test files. - Mono static validation: PASS. All 42 typecheck/build tasks, formatting, dependency verification, and type-aware lint completed; lint reported 0 errors and 1,512 warnings. @@ -315,6 +318,7 @@ Human review selected and published the reconstructed maintenance target, retain - #6339 and #6338 validation: PASS. Five SQLite-corruption tests and 30 PostgreSQL 17 logical-replication/change-streamer tests passed, with one skipped; zero-cache typecheck and formatting completed. - #6343 validation: PASS. All 15 Litestream command tests, zero-cache typecheck, and zero-cache formatting completed. - #6345 through #6349 and #6340 validation: PASS. The focused zero-client suite passed 124 tests; targeted zero-cache subscription, Litestream, logical-replication, Storer, and change-streamer suites passed 94 tests. Zero-client and zero-cache typechecks and formatting completed. +- #6342 validation: PASS. All 11 focused write-worker and SQLite-corruption tests, zero-cache typecheck, and zero-cache formatting completed. - Release image: PASS. `@rocicorp/zero@1.9.0` packed and the linux/amd64 Docker build completed with the relocated `postgres@3.4.7` patch copied and applied by the image's generated pnpm workspace. - Docs formatting: PASS with `pnpm check-format` after formatting the generated search index. - Docs types: PASS with `pnpm check-types`. diff --git a/assets/search-index.json b/assets/search-index.json index bac4d8e3..817c2786 100644 --- a/assets/search-index.json +++ b/assets/search-index.json @@ -6223,7 +6223,7 @@ "title": "Zero 1.9", "searchTitle": "Zero 1.9", "url": "/docs/release-notes/1.9", - "content": "Installation npm install @rocicorp/zero@1.9 Overview Zero 1.9 improves query/mutation correctness and reliability. Features End-to-end serving lag: e2e_serving_lag measures completed replicated work from the upstream transaction commit through view-syncer poke. Upstream clock-skew estimate tries to identify measurements biased by clock differences. (#6312) Performance Cold Mutation Latency Before running mutations, Zero Server fetches and caches PostgreSQL schema metadata. This is now 2.7x faster in Zero 1.9 (done in #6292, thanks @diegopereira99!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. Fixes Restores now use Litestream 0.5.15 for legacy-format compatibility, retry transient failures, and retain the previous snapshot generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, including extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers from upstream disconnects or stalled PostgreSQL writes while flow control is blocked. Mutations from multiple tabs in the same client group are no longer skipped or sent out of order.", + "content": "Installation npm install @rocicorp/zero@1.9 Overview Zero 1.9 improves query/mutation correctness and reliability. Features End-to-end serving lag: e2e_serving_lag measures completed replicated work from the upstream transaction commit through view-syncer poke. Upstream clock-skew estimate tries to identify measurements biased by clock differences. (#6312) Performance Cold Mutation Latency Before running mutations, Zero Server fetches and caches PostgreSQL schema metadata. This is now 2.7x faster in Zero 1.9 (done in #6292, thanks @diegopereira99!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. Fixes Restores now use Litestream 0.5.15 for legacy-format compatibility, retry transient failures, and retain the previous snapshot generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics, delete the corrupted replica before exit, and support extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers from upstream disconnects or stalled PostgreSQL writes while flow control is blocked. Mutations from multiple tabs in the same client group are no longer skipped or sent out of order.", "headings": [ { "text": "Installation", @@ -6309,7 +6309,7 @@ "sectionTitle": "Fixes", "sectionId": "fixes", "url": "/docs/release-notes/1.9", - "content": "Restores now use Litestream 0.5.15 for legacy-format compatibility, retry transient failures, and retain the previous snapshot generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics and flush logs before exit, including extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers from upstream disconnects or stalled PostgreSQL writes while flow control is blocked. Mutations from multiple tabs in the same client group are no longer skipped or sent out of order.", + "content": "Restores now use Litestream 0.5.15 for legacy-format compatibility, retry transient failures, and retain the previous snapshot generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics, delete the corrupted replica before exit, and support extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers from upstream disconnects or stalled PostgreSQL writes while flow control is blocked. Mutations from multiple tabs in the same client group are no longer skipped or sent out of order.", "kind": "section" }, { diff --git a/contents/docs/release-notes/1.9.mdx b/contents/docs/release-notes/1.9.mdx index d698869c..289d264c 100644 --- a/contents/docs/release-notes/1.9.mdx +++ b/contents/docs/release-notes/1.9.mdx @@ -65,7 +65,7 @@ Before running mutations, Zero Server fetches and caches PostgreSQL schema metad - [Expected schema and replica resets now log warnings instead of errors](https://github.com/rocicorp/mono/pull/6248), and [`zero-cache` skips Litestream restore when backups are not configured](https://github.com/rocicorp/mono/pull/6259). (thanks [@asterikx](https://github.com/asterikx)!) - [Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts.](https://github.com/rocicorp/mono/pull/6280) (thanks [@shayonj](https://github.com/shayonj)!) - [Mutation and query API calls now retry all `5xx` responses using the existing four-attempt limit and backoff; `4xx` responses still fail without retry.](https://github.com/rocicorp/mono/pull/6315) (thanks [@shayonj](https://github.com/shayonj)!) -- [SQLite corruption failures now log diagnostics and flush logs before exit](https://github.com/rocicorp/mono/pull/6215), including [extended corruption errors](https://github.com/rocicorp/mono/pull/6339), with [deeper checks available as an opt-in](https://github.com/rocicorp/mono/pull/6341). +- [SQLite corruption failures now log diagnostics](https://github.com/rocicorp/mono/pull/6215), [delete the corrupted replica before exit](https://github.com/rocicorp/mono/pull/6342), and support [extended corruption errors](https://github.com/rocicorp/mono/pull/6339), with [deeper checks available as an opt-in](https://github.com/rocicorp/mono/pull/6341). - [Oversized replication updates now identify the transaction, affected column, and value type without logging the value.](https://github.com/rocicorp/mono/pull/6318) - [Fatal replica-writer failures now surface as replication errors and cause `zero-cache` to exit with a failure instead of silently stopping replication.](https://github.com/rocicorp/mono/pull/6326) - [Replication now recovers from upstream disconnects](https://github.com/rocicorp/mono/pull/6346) or [stalled PostgreSQL writes](https://github.com/rocicorp/mono/pull/6348) while flow control is blocked. From 065b9be12c1abb8a283690606f191f69ffab5c28 Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Thu, 13 Aug 2026 17:43:39 -0700 Subject: [PATCH 15/17] docs: include Litestream cleanup fix --- .releases/1.9/commits.md | 19 +++++++++++-------- assets/search-index.json | 4 ++-- contents/docs/release-notes/1.9.mdx | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.releases/1.9/commits.md b/.releases/1.9/commits.md index 2f0573b5..25c5ad2b 100644 --- a/.releases/1.9/commits.md +++ b/.releases/1.9/commits.md @@ -11,12 +11,12 @@ Status: audit and public draft updated through the reconstructed maintenance tar - Previous ref: `zero/v1.8.0` - Previous SHA: `cdc02598f137ab4e071878f5674fdc716dbbc69d` - Target ref: `origin/maint/zero/v1.9`, reconstructed and published maintenance target -- Target SHA: `1a0095a00ad21c0f156186d9eee833df85275b38` +- Target SHA: `2288320e77536ab2922d01917e0e59bc1a6f8601` - Merge base: `2279e783edd94aaa20fdcc8e067860ad0c21d95b` - Reconstruction base: `ef892a123a11461e74a59a4b59ad310ba23180b3` -- Raw non-merge range: 76 commits +- Raw non-merge range: 77 commits - Patch-equivalent commits already shipped in 1.8: 15 -- Unique 1.9 commits: 61 +- Unique 1.9 commits: 62 Commands used: @@ -30,7 +30,7 @@ git log --right-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1 git log --left-only --no-merges --cherry-mark --format='%m%x09%h%x09%s' zero/v1.8.0...origin/maint/zero/v1.9 git log --format='%H%x09%s%n%b' 2279e783edd94aaa20fdcc8e067860ad0c21d95b..zero/v1.8.0 git show zero/v1.8.0:packages/zero-protocol/src/protocol-version.ts -git show 1a0095a00ad21c0f156186d9eee833df85275b38:packages/zero-protocol/src/protocol-version.ts +git show 2288320e77536ab2922d01917e0e59bc1a6f8601:packages/zero-protocol/src/protocol-version.ts ``` ## Protocol Compatibility @@ -72,9 +72,9 @@ The previous-release side contains no additional `cherry-pick -x` trailers namin ## Maintenance Reconstruction -The target was rebuilt from shared mainline commit `ef892a123` by applying selected signed mainline commits with provenance trailers. Later maintenance updates add #6326, #6341, #6339, #6343, the final #6346/#6348 backpressure recovery after reverting #6338, #6347, #6349, #6340, and #6342. This removes the bulk-cherry-pick timestamps from the old maintenance line and makes every selected change independently traceable to its source. +The target was rebuilt from shared mainline commit `ef892a123` by applying selected signed mainline commits with provenance trailers. Later maintenance updates add #6326, #6341, #6339, #6343, the final #6346/#6348 backpressure recovery after reverting #6338, #6347, #6349, #6340, #6342, and #6355. This removes the bulk-cherry-pick timestamps from the old maintenance line and makes every selected change independently traceable to its source. -The old maintenance-only commits `e91f964a7` and `c073aa39e` are represented by reconstructed commit `49b13e3e5`, the canonical #6280 patch. #6311, #6318, #6312, #6326, #6341, and #6343 retain targeted differences required by 1.9's older code shape. #6338 and its maintenance-only test adaptation are superseded by #6345 and the final #6346/#6348 implementation. #6339, #6347, #6349, and #6340 apply without production changes beyond their source patches. +The old maintenance-only commits `e91f964a7` and `c073aa39e` are represented by reconstructed commit `49b13e3e5`, the canonical #6280 patch. #6311, #6318, #6312, #6326, #6341, #6343, and #6355 retain targeted differences required by 1.9's older code shape. #6338 and its maintenance-only test adaptation are superseded by #6345 and the final #6346/#6348 implementation. #6339, #6347, #6349, and #6340 apply without production changes beyond their source patches. All other reconstructed commits are patch-equivalent to their named mainline source. Main-only rmv2 work, #6307's breaking scalar type enforcement, #6309's nullability-specific optimization, #6314's experimental Litestream update, and #6317's rmv2 test timeout remain excluded. @@ -158,6 +158,7 @@ All other reconstructed commits are patch-equivalent to their named mainline sou | [`f0d6fbe82`](https://github.com/rocicorp/mono/pull/6349) | fix | - | Upgrades `@rocicorp/zero-sqlite3` from 1.1.2 to 1.1.4 across Zero, zero-cache, ZQLite, and Replicache. | Omit from the public note. The commit expresses only a hope that the newer SQLite fixes observed corruption; there is no release-range evidence supporting a concrete user-facing claim. Record the raised package and peer dependency minimum privately. | | [`dcbc14f02`](https://github.com/rocicorp/mono/pull/6340) | fix | - | Tracks sent mutation IDs per client rather than by one position in a reorderable client-group commit chain, preventing one tab from skipping another tab's pending mutations. | Include publicly as a multi-tab mutation correctness fix. Tests cover reordered pending chains, contiguous mutation IDs, reconnect resends, and per-connection state reset. | | [`1a0095a00`](https://github.com/rocicorp/mono/pull/6342) | fix | - | After logging a write-worker SQLite corruption failure, best-effort deletes the replica database and sidecars so process replacement can restore or rebuild a clean replica instead of reopening the corrupt file. | Include publicly with the existing corruption diagnostics. Deletion failures are warned and do not replace the original fatal error. This targets the write-worker replica path and does not delete upstream application data. | +| [`2288320e7`](https://github.com/rocicorp/mono/pull/6355) | fix | - | Removes Litestream temporary SQLite families and indexed staged WAL files before and after restore attempts so repeated failures do not accumulate files on the replica volume. | Include publicly in the existing Litestream restore bullet. Final cleanup failures are warned without replacing the restore result, directory diagnostics are bounded to 100 entries, and the real replica family is preserved. The 1.9 adaptation uses its older restore configuration shape and omits an RMv2-only test-file update absent from this branch; all production behavior and applicable regression coverage are retained. | ## Breaking-Change Review @@ -211,6 +212,7 @@ Human review identified three breaking behavioral or operational changes: the Po - Corrupted write-worker replicas being deleted before exit so restart can restore or rebuild them (#6342). - Recovery from upstream disconnects and stalled PostgreSQL writes during blocked flow control (#6346 and #6348); #6338 is reverted by #6345. - Multi-tab client-group mutations no longer being skipped or sent out of order (#6340). +- Failed or interrupted Litestream restores cleaning up temporary databases and staged WAL files (#6355). ### Performance @@ -304,10 +306,10 @@ Human review selected and published the reconstructed maintenance target, retain ## Validation -- Audit coverage: PASS. All 76 raw-range commits have exactly one decision row; all 15 patch-equivalent 1.8 backports are recorded. +- Audit coverage: PASS. All 77 raw-range commits have exactly one decision row; all 15 patch-equivalent 1.8 backports are recorded. - Protocol compatibility: PASS. - Placeholder links: PASS. No `TODO`, `TBD`, or `PLACEHOLDER` markers remain in the audit or release note. -- Maintenance history: PASS. All 37 maintenance commits are signed. Targeted backports retain documented differences required by the 1.9 code shape; #6338 is fully superseded by its revert and final #6346/#6348 replacement. #6342 preserves 1.9's existing diagnostic-target registration while adding the source deletion behavior. +- Maintenance history: PASS. All 38 maintenance commits are signed. Targeted backports retain documented differences required by the 1.9 code shape; #6338 is fully superseded by its revert and final #6346/#6348 replacement. #6342 preserves 1.9's existing diagnostic-target registration while adding the source deletion behavior. #6355 retains the canonical cleanup behavior through 1.9's older restore configuration shape. - Mono targeted tests: PASS. zero-client 645, selected zero-cache 150, zero-server 433, z2s 65, zqlite 192, and scalar PostgreSQL integration 5. - Mono full zero-cache test: PASS after #6312 with 4,012 passed and 32 skipped across 301 test files. - Mono static validation: PASS. All 42 typecheck/build tasks, formatting, dependency verification, and type-aware lint completed; lint reported 0 errors and 1,512 warnings. @@ -319,6 +321,7 @@ Human review selected and published the reconstructed maintenance target, retain - #6343 validation: PASS. All 15 Litestream command tests, zero-cache typecheck, and zero-cache formatting completed. - #6345 through #6349 and #6340 validation: PASS. The focused zero-client suite passed 124 tests; targeted zero-cache subscription, Litestream, logical-replication, Storer, and change-streamer suites passed 94 tests. Zero-client and zero-cache typechecks and formatting completed. - #6342 validation: PASS. All 11 focused write-worker and SQLite-corruption tests, zero-cache typecheck, and zero-cache formatting completed. +- #6355 validation: PASS. All 20 Litestream command tests, zero-cache typecheck, focused formatting, and whitespace checks completed. - Release image: PASS. `@rocicorp/zero@1.9.0` packed and the linux/amd64 Docker build completed with the relocated `postgres@3.4.7` patch copied and applied by the image's generated pnpm workspace. - Docs formatting: PASS with `pnpm check-format` after formatting the generated search index. - Docs types: PASS with `pnpm check-types`. diff --git a/assets/search-index.json b/assets/search-index.json index 817c2786..f14308eb 100644 --- a/assets/search-index.json +++ b/assets/search-index.json @@ -6223,7 +6223,7 @@ "title": "Zero 1.9", "searchTitle": "Zero 1.9", "url": "/docs/release-notes/1.9", - "content": "Installation npm install @rocicorp/zero@1.9 Overview Zero 1.9 improves query/mutation correctness and reliability. Features End-to-end serving lag: e2e_serving_lag measures completed replicated work from the upstream transaction commit through view-syncer poke. Upstream clock-skew estimate tries to identify measurements biased by clock differences. (#6312) Performance Cold Mutation Latency Before running mutations, Zero Server fetches and caches PostgreSQL schema metadata. This is now 2.7x faster in Zero 1.9 (done in #6292, thanks @diegopereira99!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. Fixes Restores now use Litestream 0.5.15 for legacy-format compatibility, retry transient failures, and retain the previous snapshot generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics, delete the corrupted replica before exit, and support extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers from upstream disconnects or stalled PostgreSQL writes while flow control is blocked. Mutations from multiple tabs in the same client group are no longer skipped or sent out of order.", + "content": "Installation npm install @rocicorp/zero@1.9 Overview Zero 1.9 improves query/mutation correctness and reliability. Features End-to-end serving lag: e2e_serving_lag measures completed replicated work from the upstream transaction commit through view-syncer poke. Upstream clock-skew estimate tries to identify measurements biased by clock differences. (#6312) Performance Cold Mutation Latency Before running mutations, Zero Server fetches and caches PostgreSQL schema metadata. This is now 2.7x faster in Zero 1.9 (done in #6292, thanks @diegopereira99!). This is most noticeable with cold-starts in serverless environments like AWS Lambda. Fixes Restores now use Litestream 0.5.15 for legacy-format compatibility, retry transient failures, clean up temporary databases and staged WAL files after failed or interrupted attempts, and retain the previous snapshot generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics, delete the corrupted replica before exit, and support extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers from upstream disconnects or stalled PostgreSQL writes while flow control is blocked. Mutations from multiple tabs in the same client group are no longer skipped or sent out of order.", "headings": [ { "text": "Installation", @@ -6309,7 +6309,7 @@ "sectionTitle": "Fixes", "sectionId": "fixes", "url": "/docs/release-notes/1.9", - "content": "Restores now use Litestream 0.5.15 for legacy-format compatibility, retry transient failures, and retain the previous snapshot generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics, delete the corrupted replica before exit, and support extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers from upstream disconnects or stalled PostgreSQL writes while flow control is blocked. Mutations from multiple tabs in the same client group are no longer skipped or sent out of order.", + "content": "Restores now use Litestream 0.5.15 for legacy-format compatibility, retry transient failures, clean up temporary databases and staged WAL files after failed or interrupted attempts, and retain the previous snapshot generation during active restores. (#6260, #6267) insert now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error. Ordered queries now return correct results when cursor fields contain NULL. (thanks @YevheniiKotyrlo!) Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results. Queries no longer drop rows or emit invalid SQL when given an inapplicable scalar hint, and scalar NOT EXISTS now handles empty or NULL results. Schema construction, CRUD mutators, and materialized views now preserve a key named __proto__ as user data. (thanks @tjenkinson!) SQLite statement caches now retain at most 1,000 idle entries each. Terminated client groups now release custom-query timers and caches. Large replica transactions can spill dirty pages to WAL instead of retaining the complete write set in native memory. Missing replication-lag reports are retried and total_lag no longer grows when reports stop arriving, while serving-lag metrics exclude disconnected or not-yet-validated client groups. zero-cache now recovers from half-open PostgreSQL sockets, including over TLS, and the official image applies the bundled postgres.js disconnect patch. With PostgreSQL wal_sender_timeout=0, replication no longer enters a continuous reconnect loop. See WAL Sender Timeout. Client connection attempts now time out across setup and the server handshake, abandon late sockets, and retry normally. Reconnect confirmations no longer produce false slow-query warnings or inflated materialization metrics. Different integration versions in a pnpm workspace no longer create peer-qualified duplicate copies of @rocicorp/zero, fixing cross-package type and module-augmentation failures. Replicated PostgreSQL type and nullability changes now preserve compound-index column order in SQLite replicas. To repair an affected replica, resync it from Postgres or recreate the PostgreSQL index. Expected schema and replica resets now log warnings instead of errors, and zero-cache skips Litestream restore when backups are not configured. (thanks @asterikx!) Server CRUD updates and upserts no longer assign primary-key columns, avoiding PostgreSQL locks that could block concurrent foreign-key inserts. (thanks @shayonj!) Mutation and query API calls now retry all 5xx responses using the existing four-attempt limit and backoff; 4xx responses still fail without retry. (thanks @shayonj!) SQLite corruption failures now log diagnostics, delete the corrupted replica before exit, and support extended corruption errors, with deeper checks available as an opt-in. Oversized replication updates now identify the transaction, affected column, and value type without logging the value. Fatal replica-writer failures now surface as replication errors and cause zero-cache to exit with a failure instead of silently stopping replication. Replication now recovers from upstream disconnects or stalled PostgreSQL writes while flow control is blocked. Mutations from multiple tabs in the same client group are no longer skipped or sent out of order.", "kind": "section" }, { diff --git a/contents/docs/release-notes/1.9.mdx b/contents/docs/release-notes/1.9.mdx index 289d264c..623584e6 100644 --- a/contents/docs/release-notes/1.9.mdx +++ b/contents/docs/release-notes/1.9.mdx @@ -46,7 +46,7 @@ Before running mutations, Zero Server fetches and caches PostgreSQL schema metad ## Fixes -- [Restores now use Litestream 0.5.15 for legacy-format compatibility](/docs/zero-cache-config#litestream-restore-using-v5), [retry transient failures](https://github.com/rocicorp/mono/pull/6347), and [retain the previous snapshot generation during active restores](/docs/zero-cache-config#litestream-snapshot-backup-interval-hours). ([#6260](https://github.com/rocicorp/mono/pull/6260), [#6267](https://github.com/rocicorp/mono/pull/6267)) +- [Restores now use Litestream 0.5.15 for legacy-format compatibility](/docs/zero-cache-config#litestream-restore-using-v5), [retry transient failures](https://github.com/rocicorp/mono/pull/6347), [clean up temporary databases and staged WAL files after failed or interrupted attempts](https://github.com/rocicorp/mono/pull/6355), and [retain the previous snapshot generation during active restores](/docs/zero-cache-config#litestream-snapshot-backup-interval-hours). ([#6260](https://github.com/rocicorp/mono/pull/6260), [#6267](https://github.com/rocicorp/mono/pull/6267)) - [`insert` now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error.](https://github.com/rocicorp/mono/pull/6251) - [Ordered queries now return correct results when cursor fields contain `NULL`.](https://github.com/rocicorp/mono/pull/6121) (thanks [@YevheniiKotyrlo](https://github.com/YevheniiKotyrlo)!) - [Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results.](https://github.com/rocicorp/mono/pull/6196) From f1be0a4ad594681eb350a0804cc5889c4ee7fdfa Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Thu, 13 Aug 2026 18:36:41 -0700 Subject: [PATCH 16/17] chore: update --- contents/docs/release-notes/1.9.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contents/docs/release-notes/1.9.mdx b/contents/docs/release-notes/1.9.mdx index 623584e6..ba46a54f 100644 --- a/contents/docs/release-notes/1.9.mdx +++ b/contents/docs/release-notes/1.9.mdx @@ -11,11 +11,11 @@ npm install @rocicorp/zero@1.9 ## Overview -Zero 1.9 improves query/mutation correctness and reliability. +Zero 1.9 improves query/mutation correctness and contains numerous reliability improvements. ## Features -- [**End-to-end serving lag:**](/docs/otel#zerosync) `e2e_serving_lag` measures completed replicated work from the upstream transaction commit through view-syncer poke. [Upstream clock-skew estimate](/docs/otel#zeroreplication) tries to identify measurements biased by clock differences. ([#6312](https://github.com/rocicorp/mono/pull/6312)) +- [`zero_sync_e2e_serving_lag`](/docs/otel#zerosync) measures completed replicated work from the upstream transaction commit through view-syncer poke. [`zero_replication_upstream_clock_skew`](/docs/otel#zeroreplication) tries to identify measurements biased by clock differences. ([#6312](https://github.com/rocicorp/mono/pull/6312)) ## Performance From 6a568fa789dda29e4fd5168620abfc8a9a4ea94b Mon Sep 17 00:00:00 2001 From: Chase Adams Date: Thu, 13 Aug 2026 18:42:52 -0700 Subject: [PATCH 17/17] docs: link Litestream fixes to PRs --- contents/docs/release-notes/1.9.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contents/docs/release-notes/1.9.mdx b/contents/docs/release-notes/1.9.mdx index ba46a54f..04695659 100644 --- a/contents/docs/release-notes/1.9.mdx +++ b/contents/docs/release-notes/1.9.mdx @@ -46,7 +46,7 @@ Before running mutations, Zero Server fetches and caches PostgreSQL schema metad ## Fixes -- [Restores now use Litestream 0.5.15 for legacy-format compatibility](/docs/zero-cache-config#litestream-restore-using-v5), [retry transient failures](https://github.com/rocicorp/mono/pull/6347), [clean up temporary databases and staged WAL files after failed or interrupted attempts](https://github.com/rocicorp/mono/pull/6355), and [retain the previous snapshot generation during active restores](/docs/zero-cache-config#litestream-snapshot-backup-interval-hours). ([#6260](https://github.com/rocicorp/mono/pull/6260), [#6267](https://github.com/rocicorp/mono/pull/6267)) +- [Restores now use Litestream 0.5.15 for legacy-format compatibility](https://github.com/rocicorp/mono/pull/6260), [retry transient failures](https://github.com/rocicorp/mono/pull/6347), [clean up temporary databases and staged WAL files after failed or interrupted attempts](https://github.com/rocicorp/mono/pull/6355), and [retain the previous snapshot generation during active restores](https://github.com/rocicorp/mono/pull/6267). - [`insert` now succeeds without changing the row when its Zero primary key already exists; before 1.9, the server returned an error.](https://github.com/rocicorp/mono/pull/6251) - [Ordered queries now return correct results when cursor fields contain `NULL`.](https://github.com/rocicorp/mono/pull/6121) (thanks [@YevheniiKotyrlo](https://github.com/YevheniiKotyrlo)!) - [Rebuilt queries now deliver changed rows instead of occasionally leaving clients with stale results.](https://github.com/rocicorp/mono/pull/6196)